fix(billing): no free upgrade in the last hours of a term
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
parent
52b1acbb80
commit
35324b986a
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue