CluPilotCloud/app/Services/Billing/PlanChange.php

132 lines
5.2 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* What a plan change costs, and whether it is allowed yet.
*
* 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();
$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.
$termOver = $at->greaterThanOrEqualTo($subscription->current_period_end);
return new self(
isUpgrade: false,
allowedNow: $termOver,
chargeCents: 0,
creditCents: 0,
effectiveAt: $termOver ? $at->copy() : $subscription->current_period_end->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,
);
}
/**
* 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));
}
}