fix(billing): a scheduled downgrade must be applicable, and never cost money
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
parent
460fac01b1
commit
52b1acbb80
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue