fix(billing): no free upgrade in the last hours of a term
tests / pest (push) Successful in 6m48s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details

Flooring the remaining time made an upgrade with under a day left cost nothing,
and an upgrade requested after the period had ended cost nothing while being
applied immediately — the bigger plan for free. Remaining time now rounds up
while any service is left, and an expired period defers the change to the next
term instead of pricing it at zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 09:55:53 +02:00
parent 52b1acbb80
commit 35324b986a
2 changed files with 52 additions and 3 deletions

View File

@ -44,10 +44,15 @@ final readonly class PlanChange
// for it immediately. Prices decide the amount, not the direction.
$isUpgrade = (int) $target['tier'] > (int) $subscription->tier;
// Whole days, and never zero: on the last day of a term there is still
// a day of service left to charge or refund.
$termDays = max(1, (int) $subscription->current_period_start->diffInDays($subscription->current_period_end));
$remainingDays = (int) max(0, floor($at->diffInDays($subscription->current_period_end, absolute: false)));
// Rounded UP while any service remains: with less than a day left,
// flooring would hand out an upgrade for nothing. Zero only once the
// period is genuinely over.
$hoursLeft = $at->diffInHours($subscription->current_period_end, absolute: false);
$remainingDays = $hoursLeft > 0
? min($termDays, (int) ceil($hoursLeft / 24))
: 0;
if (! $isUpgrade) {
// Downgrade: nothing changes and nothing is owed until the term
@ -66,6 +71,20 @@ final readonly class PlanChange
);
}
// The period is already over: there is nothing left to prorate, and an
// upgrade here belongs to the next term, not to this one.
if ($remainingDays === 0) {
return new self(
isUpgrade: true,
allowedNow: false,
chargeCents: 0,
creditCents: 0,
effectiveAt: $subscription->current_period_end->copy(),
remainingDays: 0,
termDays: $termDays,
);
}
// Pro rata on both sides: the unused part of what they already paid is
// worth something, and only the difference is charged.
$unusedOld = (int) round($subscription->price_cents * $remainingDays / $termDays);

View File

@ -178,3 +178,33 @@ it('never invoices a customer for downgrading', function () {
expect($credit)->toBe(0);
});
it('does not give away an upgrade in the last hours of a term', function () {
$start = Illuminate\Support\Carbon::parse('2026-08-01');
$subscription = Subscription::factory()->plan('start')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
// Six hours left: still service, so still a charge.
$change = PlanChange::evaluate($subscription, 'business', Illuminate\Support\Carbon::parse('2026-08-31 18:00'));
expect($change->remainingDays)->toBe(1)
->and($change->chargeCents)->toBeGreaterThan(0);
});
it('sends an upgrade requested after the term into the next one', function () {
$start = Illuminate\Support\Carbon::parse('2026-08-01');
$subscription = Subscription::factory()->plan('start')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
// Nothing left to prorate — charging zero and switching immediately would
// hand out the bigger plan for free.
$change = PlanChange::evaluate($subscription, 'business', Illuminate\Support\Carbon::parse('2026-09-05'));
expect($change->allowedNow)->toBeFalse()
->and($change->chargeCents)->toBe(0)
->and($change->effectiveAt->toDateString())->toBe('2026-09-01');
});