fix(billing): a scheduled downgrade must be applicable, and never cost money
tests / pest (push) Successful in 6m49s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details

Once the term is over, the downgrade has to be allowed — otherwise the job that
is supposed to carry it out never can, and the change waits forever for a date
that has already passed.

And a grandfathered plan can be cheaper than the smaller plan costs today, which
made the goodwill credit negative: an invoice for the privilege of downgrading.
Clamped to zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 09:54:16 +02:00
parent 460fac01b1
commit 52b1acbb80
2 changed files with 45 additions and 6 deletions

View File

@ -50,13 +50,17 @@ final readonly class PlanChange
$remainingDays = (int) max(0, floor($at->diffInDays($subscription->current_period_end, absolute: false))); $remainingDays = (int) max(0, floor($at->diffInDays($subscription->current_period_end, absolute: false)));
if (! $isUpgrade) { if (! $isUpgrade) {
// Downgrade: nothing changes and nothing is owed until the term ends. // Downgrade: nothing changes and nothing is owed until the term
// ends — but once it HAS ended it must actually be applicable, or
// the job that is supposed to carry it out never can.
$termOver = $at->greaterThanOrEqualTo($subscription->current_period_end);
return new self( return new self(
isUpgrade: false, isUpgrade: false,
allowedNow: false, allowedNow: $termOver,
chargeCents: 0, chargeCents: 0,
creditCents: 0, creditCents: 0,
effectiveAt: $subscription->current_period_end->copy(), effectiveAt: $termOver ? $at->copy() : $subscription->current_period_end->copy(),
remainingDays: $remainingDays, remainingDays: $remainingDays,
termDays: $termDays, termDays: $termDays,
); );
@ -100,8 +104,9 @@ final readonly class PlanChange
$target = Subscription::snapshotFrom($targetPlan, $subscription->term); $target = Subscription::snapshotFrom($targetPlan, $subscription->term);
$difference = $subscription->price_cents - $target['price_cents']; $difference = $subscription->price_cents - $target['price_cents'];
// Only the unused part of the difference: they used the expensive plan // Never negative: a grandfathered plan can be cheaper than the smaller
// for the days that have passed. // plan costs today, and a downgrade must not invoice the customer for
return (int) round($difference * $change->remainingDays / $change->termDays); // the privilege.
return max(0, (int) round($difference * $change->remainingDays / $change->termDays));
} }
} }

View File

@ -144,3 +144,37 @@ it('reads the direction from the plan rank, not from a grandfathered price', fun
->and($change->allowedNow)->toBeFalse() ->and($change->allowedNow)->toBeFalse()
->and($change->chargeCents)->toBe(0); ->and($change->chargeCents)->toBe(0);
}); });
it('lets a scheduled downgrade actually happen once the term is over', function () {
$start = Illuminate\Support\Carbon::parse('2026-08-01');
$subscription = Subscription::factory()->plan('business')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
// Mid-term: not yet.
expect(PlanChange::evaluate($subscription, 'team', Illuminate\Support\Carbon::parse('2026-08-15'))->allowedNow)
->toBeFalse();
// On the boundary and after it: the job carrying it out has to be able to.
foreach (['2026-09-01', '2026-09-03'] as $when) {
$change = PlanChange::evaluate($subscription, 'team', Illuminate\Support\Carbon::parse($when));
expect($change->allowedNow)->toBeTrue()
->and($change->effectiveAt->toDateString())->toBe($when);
}
});
it('never invoices a customer for downgrading', function () {
$start = Illuminate\Support\Carbon::parse('2026-08-01');
$subscription = Subscription::factory()->plan('business')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
// Grandfathered below today's smaller plan.
$subscription->forceFill(['price_cents' => 9900])->saveQuietly();
config()->set('provisioning.plans.team.price_cents', 17900);
$credit = PlanChange::goodwillCredit($subscription->fresh(), 'team', Illuminate\Support\Carbon::parse('2026-08-15'));
expect($credit)->toBe(0);
});