fix(billing): no plan changes after cancellation, no unknown terms
tests / pest (push) Successful in 6m46s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details

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
nexxo 2026-07-26 09:57:53 +02:00
parent 35324b986a
commit 6119044669
3 changed files with 42 additions and 0 deletions

View File

@ -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 [

View File

@ -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

View File

@ -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');
});