diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 5b654bf..6d8d3b8 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -86,6 +86,12 @@ class Subscription extends Model 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); return [ diff --git a/app/Services/Billing/PlanChange.php b/app/Services/Billing/PlanChange.php index d418953..8b63485 100644 --- a/app/Services/Billing/PlanChange.php +++ b/app/Services/Billing/PlanChange.php @@ -36,6 +36,22 @@ final readonly class PlanChange public static function evaluate(Subscription $subscription, string $targetPlan, ?Carbon $at = null): self { $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); // Direction comes from the plan's rank, never from the prices: a diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php index 6ce7f26..5e75af9 100644 --- a/tests/Feature/Billing/PlanChangeTest.php +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -208,3 +208,23 @@ it('sends an upgrade requested after the term into the next one', function () { ->and($change->chargeCents)->toBe(0) ->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'); +});