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}");
|
||||
}
|
||||
|
||||
// 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 [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue