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)); } }