diff --git a/app/Services/Billing/PlanChange.php b/app/Services/Billing/PlanChange.php index da1c9db..d418953 100644 --- a/app/Services/Billing/PlanChange.php +++ b/app/Services/Billing/PlanChange.php @@ -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); diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php index fc2f799..6ce7f26 100644 --- a/tests/Feature/Billing/PlanChangeTest.php +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -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'); +});