193 lines
8.2 KiB
PHP
193 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\Subscription;
|
|
use Illuminate\Support\Carbon;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* The PREVIEW of a plan change: what it would cost, and whether it is allowed
|
|
* yet.
|
|
*
|
|
* A preview, not an invoice. Since Stripe took over the billing cycle, the
|
|
* amount a customer is actually charged for an upgrade comes back on Stripe's
|
|
* proration invoice, and that is the authority — it accounts for tax, for
|
|
* credit already on the account, and for the exact second the change lands, and
|
|
* ours cannot. What this is for is the sentence shown before someone confirms,
|
|
* so nobody is asked to agree to a number they have not seen.
|
|
*
|
|
* The two are expected to agree to the cent in the ordinary case. Where they do
|
|
* not, the invoice wins and the proof register records what was charged.
|
|
*
|
|
* Two rules, both the customer's rules:
|
|
*
|
|
* - **Upgrading is immediate and pro rata.** They get the bigger plan now and
|
|
* pay only for the days left in the term they already paid for, minus what
|
|
* the old plan was worth over those same days.
|
|
* - **Downgrading waits for the end of the term.** Someone on a yearly
|
|
* contract bought a year; they can move down when that year is up, not in
|
|
* month three. A month is a month.
|
|
*
|
|
* Everything is computed against the SUBSCRIPTION's frozen price, never against
|
|
* today's catalogue — that is the whole point of the snapshot.
|
|
*/
|
|
final readonly class PlanChange
|
|
{
|
|
public function __construct(
|
|
public bool $isUpgrade,
|
|
public bool $allowedNow,
|
|
public int $chargeCents,
|
|
public int $creditCents,
|
|
public ?Carbon $effectiveAt,
|
|
public int $remainingDays,
|
|
public int $termDays,
|
|
) {}
|
|
|
|
public static function evaluate(Subscription $subscription, string $targetPlan, ?Carbon $at = null): self
|
|
{
|
|
$at = $at?->copy() ?? now();
|
|
|
|
// A cancelled contract has nothing left to change. Without this, a
|
|
// caller trusting allowedNow would provision and bill an upgrade for a
|
|
// customer who has already left.
|
|
if ($subscription->status !== 'active') {
|
|
return new self(
|
|
isUpgrade: false,
|
|
allowedNow: false,
|
|
chargeCents: 0,
|
|
creditCents: 0,
|
|
effectiveAt: null,
|
|
remainingDays: 0,
|
|
termDays: 1,
|
|
);
|
|
}
|
|
|
|
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
|
|
|
|
// Direction comes from the plan's rank, never from the prices: a
|
|
// grandfathered business plan can cost less than today's team plan, and
|
|
// comparing those would call losing resources an "upgrade" and charge
|
|
// for it immediately. Prices decide the amount, not the direction.
|
|
$isUpgrade = (int) $target['tier'] > (int) $subscription->tier;
|
|
|
|
$termDays = max(1, (int) $subscription->current_period_start->diffInDays($subscription->current_period_end));
|
|
|
|
// 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
|
|
// ends — but once it HAS ended it must actually be applicable, or
|
|
// the job that is supposed to carry it out never can.
|
|
//
|
|
// When the move is already BOOKED, the date it comes due is the one
|
|
// stamped on the contract at the moment it was booked, not today's
|
|
// period end. Stripe moves the period end on every renewal, and a
|
|
// due date that moved with it could never arrive: each renewal
|
|
// pushed the downgrade out by another term and billed the customer
|
|
// again for the package they had asked to leave. An unbooked move —
|
|
// the preview on the shop page — has no stamp and is answered from
|
|
// the period the customer is in, which is what they are being shown.
|
|
$dueAt = $subscription->pending_plan === $targetPlan && $subscription->pending_effective_at !== null
|
|
? $subscription->pending_effective_at
|
|
: $subscription->current_period_end;
|
|
|
|
$termOver = $at->greaterThanOrEqualTo($dueAt);
|
|
|
|
return new self(
|
|
isUpgrade: false,
|
|
allowedNow: $termOver,
|
|
chargeCents: 0,
|
|
creditCents: 0,
|
|
effectiveAt: $termOver ? $at->copy() : $dueAt->copy(),
|
|
remainingDays: $remainingDays,
|
|
termDays: $termDays,
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
$newForRest = (int) round($target['price_cents'] * $remainingDays / $termDays);
|
|
|
|
return new self(
|
|
isUpgrade: true,
|
|
allowedNow: true,
|
|
chargeCents: max(0, $newForRest - $unusedOld),
|
|
creditCents: 0,
|
|
effectiveAt: $at,
|
|
remainingDays: $remainingDays,
|
|
termDays: $termDays,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Carry the custom domain over — or take it away — once a change has landed.
|
|
*
|
|
* The preview above says what a move would cost; this is the one thing that
|
|
* has to happen after it. A contract that no longer allows a custom domain
|
|
* loses it: the module stops being charged for and the instance goes back
|
|
* to its platform address. A contract that still allows one — because the
|
|
* new package includes it, or because the customer booked the module before
|
|
* moving — is left alone, which is exactly what "keeping it" means.
|
|
*
|
|
* The rule itself is not here. CustomDomainAccess owns it; this is the
|
|
* moment it is applied, sitting beside the preview so nobody changes what a
|
|
* plan change costs without seeing what it does.
|
|
*
|
|
* Returns whether anything was taken away.
|
|
*/
|
|
public static function settleCustomDomain(Subscription $subscription): bool
|
|
{
|
|
return app(CustomDomainAccess::class)->enforce($subscription);
|
|
}
|
|
|
|
/**
|
|
* What a mid-term downgrade would be worth, for the cases where we grant one
|
|
* anyway — a goodwill move or a support decision. Never offered to the
|
|
* customer as a self-service button, because a downgrade before the term is
|
|
* up is a change to the contract, not a setting.
|
|
*/
|
|
public static function goodwillCredit(Subscription $subscription, string $targetPlan, ?Carbon $at = null): int
|
|
{
|
|
$change = self::evaluate($subscription, $targetPlan, $at);
|
|
|
|
if ($change->isUpgrade) {
|
|
throw new RuntimeException('An upgrade is charged, not credited.');
|
|
}
|
|
|
|
// A sideways move at the same rank has no difference to credit.
|
|
if ((int) Subscription::snapshotFrom($targetPlan, $subscription->term)['tier'] === (int) $subscription->tier) {
|
|
return 0;
|
|
}
|
|
|
|
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
|
|
$difference = $subscription->price_cents - $target['price_cents'];
|
|
|
|
// 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));
|
|
}
|
|
}
|