fix(billing): no plan changes after cancellation, no unknown terms
A cancelled subscription still reported an upgrade as allowed and priced it, so a caller trusting that would have provisioned and billed a customer who had already left. And any term string other than the two we support was silently priced monthly while keeping the unknown value — a subscription whose price and billing period disagree. Both are refused now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
35324b986a
commit
6119044669
|
|
@ -86,6 +86,12 @@ class Subscription extends Model
|
||||||
throw new RuntimeException("Unknown plan: {$plan}");
|
throw new RuntimeException("Unknown plan: {$plan}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A typo would otherwise be priced monthly while carrying an unknown
|
||||||
|
// term — a subscription whose price and billing period disagree.
|
||||||
|
if (! in_array($term, [self::TERM_MONTHLY, self::TERM_YEARLY], true)) {
|
||||||
|
throw new RuntimeException("Unknown term: {$term}");
|
||||||
|
}
|
||||||
|
|
||||||
$monthly = (int) ($catalogue['price_cents'] ?? 0);
|
$monthly = (int) ($catalogue['price_cents'] ?? 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,22 @@ final readonly class PlanChange
|
||||||
public static function evaluate(Subscription $subscription, string $targetPlan, ?Carbon $at = null): self
|
public static function evaluate(Subscription $subscription, string $targetPlan, ?Carbon $at = null): self
|
||||||
{
|
{
|
||||||
$at = $at?->copy() ?? now();
|
$at = $at?->copy() ?? now();
|
||||||
|
|
||||||
|
// A cancelled contract has nothing left to change. Without this, a
|
||||||
|
// caller trusting allowedNow would provision and bill an upgrade for a
|
||||||
|
// customer who has already left.
|
||||||
|
if ($subscription->status !== 'active') {
|
||||||
|
return new self(
|
||||||
|
isUpgrade: false,
|
||||||
|
allowedNow: false,
|
||||||
|
chargeCents: 0,
|
||||||
|
creditCents: 0,
|
||||||
|
effectiveAt: null,
|
||||||
|
remainingDays: 0,
|
||||||
|
termDays: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
|
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
|
||||||
|
|
||||||
// Direction comes from the plan's rank, never from the prices: a
|
// Direction comes from the plan's rank, never from the prices: a
|
||||||
|
|
|
||||||
|
|
@ -208,3 +208,23 @@ it('sends an upgrade requested after the term into the next one', function () {
|
||||||
->and($change->chargeCents)->toBe(0)
|
->and($change->chargeCents)->toBe(0)
|
||||||
->and($change->effectiveAt->toDateString())->toBe('2026-09-01');
|
->and($change->effectiveAt->toDateString())->toBe('2026-09-01');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('has nothing to change once a subscription is cancelled', function () {
|
||||||
|
$subscription = Subscription::factory()->plan('start')->create([
|
||||||
|
'status' => 'cancelled',
|
||||||
|
'cancelled_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A caller trusting allowedNow would otherwise provision and bill an
|
||||||
|
// upgrade for a customer who has already left.
|
||||||
|
$change = PlanChange::evaluate($subscription, 'business');
|
||||||
|
|
||||||
|
expect($change->allowedNow)->toBeFalse()
|
||||||
|
->and($change->chargeCents)->toBe(0)
|
||||||
|
->and($change->effectiveAt)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a term it does not know', function () {
|
||||||
|
expect(fn () => Subscription::snapshotFrom('team', 'yearlyy'))
|
||||||
|
->toThrow(RuntimeException::class, 'Unknown term');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue