From 52b1acbb80e8264554cc0b8b5690aeccf42eea12 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 09:54:16 +0200 Subject: [PATCH] fix(billing): a scheduled downgrade must be applicable, and never cost money MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Services/Billing/PlanChange.php | 17 +++++++----- tests/Feature/Billing/PlanChangeTest.php | 34 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/Services/Billing/PlanChange.php b/app/Services/Billing/PlanChange.php index fbfeb1a..da1c9db 100644 --- a/app/Services/Billing/PlanChange.php +++ b/app/Services/Billing/PlanChange.php @@ -50,13 +50,17 @@ final readonly class PlanChange $remainingDays = (int) max(0, floor($at->diffInDays($subscription->current_period_end, absolute: false))); 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( isUpgrade: false, - allowedNow: false, + allowedNow: $termOver, chargeCents: 0, creditCents: 0, - effectiveAt: $subscription->current_period_end->copy(), + effectiveAt: $termOver ? $at->copy() : $subscription->current_period_end->copy(), remainingDays: $remainingDays, termDays: $termDays, ); @@ -100,8 +104,9 @@ final readonly class PlanChange $target = Subscription::snapshotFrom($targetPlan, $subscription->term); $difference = $subscription->price_cents - $target['price_cents']; - // Only the unused part of the difference: they used the expensive plan - // for the days that have passed. - return (int) round($difference * $change->remainingDays / $change->termDays); + // Never negative: a grandfathered plan can be cheaper than the smaller + // plan costs today, and a downgrade must not invoice the customer for + // the privilege. + return max(0, (int) round($difference * $change->remainingDays / $change->termDays)); } } diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php index 35b8ae4..fc2f799 100644 --- a/tests/Feature/Billing/PlanChangeTest.php +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -144,3 +144,37 @@ it('reads the direction from the plan rank, not from a grandfathered price', fun ->and($change->allowedNow)->toBeFalse() ->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); +});