getDirty()), self::FROZEN); if ($frozen !== []) { throw new RuntimeException( 'A subscription snapshot is immutable; tried to change: '.implode(', ', $frozen). '. Cancel this subscription and start a new one instead.' ); } }); } protected function casts(): array { return [ 'price_cents' => 'integer', 'quota_gb' => 'integer', 'traffic_gb' => 'integer', 'seats' => 'integer', 'ram_mb' => 'integer', 'cores' => 'integer', 'disk_gb' => 'integer', 'template_vmid' => 'integer', 'tier' => 'integer', 'started_at' => 'datetime', 'current_period_start' => 'datetime', 'current_period_end' => 'datetime', 'pending_effective_at' => 'datetime', // When the customer asked for the contract to end, and when it // actually did. Two facts, not one: a cancelled package is still a // paying customer until the term they bought runs out, so the first // is stamped the moment they confirm and the second only when Stripe // reports the subscription gone. 'cancel_requested_at' => 'datetime', 'cancelled_at' => 'datetime', // The fourteen days a consumer may change their mind in, and what // happened inside them. See App\Services\Billing\WithdrawalRight, // which is the one place that decides whether the window is open. 'withdrawal_ends_at' => 'datetime', // DEAD as a gate, kept as a record — see Order, which carries the // same column for the same reason. A withdrawing consumer now gets // the whole amount back whatever this says. 'immediate_start_consent_at' => 'datetime', 'withdrawn_at' => 'datetime', 'withdrawal_refund_cents' => 'integer', 'stripe_event_at' => 'datetime', 'stripe_price_synced_at' => 'datetime', // The swap that has not reached Stripe: the price it was meant to // move to, the proration behaviour its direction chose, the error // and how often it has been tried. Null once Stripe agrees with us. 'stripe_price_sync' => 'array', // The module items that never reached Stripe: the error, when it // failed and how often it has been tried. Null once Stripe bills // exactly the modules this contract holds. 'stripe_addon_sync' => 'array', 'granted_at' => 'datetime', 'granted_until' => 'datetime', 'catalogue_price_cents' => 'integer', ]; } public function uniqueIds(): array { return ['uuid']; } public function customer(): BelongsTo { return $this->belongsTo(Customer::class); } /** The operator who gave this away, when it was a grant. */ public function grantedBy(): BelongsTo { return $this->belongsTo(Operator::class, 'granted_by'); } /** * Whether this contract's own terms were set by a grant rather than a * purchase — the one fact Revenue and IssueInvoice both key off. */ public function isGranted(): bool { return $this->granted_at !== null; } /** A full gift: nothing was charged for it at all. */ public function isFreeGrant(): bool { return $this->isGranted() && (int) $this->price_cents === 0; } public function order(): BelongsTo { return $this->belongsTo(Order::class); } /** Which version of the plan this contract was sold under. */ public function planVersion(): BelongsTo { return $this->belongsTo(PlanVersion::class, 'plan_version_id'); } /** Modules booked onto this contract, each frozen at its booked price. */ public function addons(): HasMany { return $this->hasMany(SubscriptionAddon::class); } /** Every commercial event this contract has been through. */ public function records(): HasMany { return $this->hasMany(SubscriptionRecord::class); } public function instance(): BelongsTo { return $this->belongsTo(Instance::class); } /** Whether the catalogue still sells this plan at all. */ public static function knowsPlan(string $plan): bool { return app(PlanCatalogue::class)->isSellable($plan); } /** The single currency every catalogue price is expressed in. */ public static function catalogueCurrency(): string { return strtoupper((string) config('provisioning.currency', 'EUR')); } /** * Take the version on sale right now and freeze it onto a new subscription. * * Reads the catalogue tables, never config: there is one source, and it is * the one the owner can actually edit. An unknown, withdrawn or unpriced * plan throws rather than returning something plausible — a contract opened * from a guess is worse than a purchase that visibly fails. */ public static function snapshotFrom(string $plan, string $term = self::TERM_MONTHLY, ?int $versionId = null): array { // A typo would otherwise be priced monthly while carrying an unknown // term — a subscription whose price and billing period disagree. if (! in_array($term, [self::TERM_MONTHLY, self::TERM_YEARLY], true)) { throw new RuntimeException("Unknown term: {$term}"); } // Honour the version the customer was actually shown, even if it has // stopped being sold since — a checkout that completes just after a // scheduled transition must not contract them to terms they never saw. $version = $versionId !== null ? app(PlanCatalogue::class)->soldVersion($plan, $versionId) : app(PlanCatalogue::class)->currentVersion($plan); $price = $version->priceFor($term); if ($price === null) { throw new RuntimeException("Plan '{$plan}' is not sold on a {$term} term."); } return [ 'plan' => $plan, // Which version this contract was sold under. A historical // reference resolved by plan NAME would hand back today's terms. 'plan_version_id' => $version->id, 'term' => $term, 'price_cents' => $price->amount_cents, 'currency' => $price->currency, 'quota_gb' => $version->quota_gb, 'traffic_gb' => $version->traffic_gb, 'seats' => $version->seats, 'ram_mb' => $version->ram_mb, 'cores' => $version->cores, 'disk_gb' => $version->disk_gb, 'performance' => $version->performance, // Copied so provisioning never has to ask the catalogue what to // clone. Not in FROZEN — see the migration for why. 'template_vmid' => $version->template_vmid, // Frozen too: which direction a later change goes must not depend // on where the plan sits in today's catalogue. 'tier' => (int) $version->family->tier, ]; } /** * Move this contract onto another package — the one sanctioned exception to * the freeze above. * * The freeze exists so that nothing can rewrite what a customer is owed * behind their back: a price rise, an edited plan, a stray `update()`. A * plan change is the opposite of that. It is the customer asking for * different terms, it is priced and recorded before it lands, and afterwards * the contract has to state the package they are actually on — otherwise the * snapshot stops being the authority every step and every bill reads it as. * * So the exception is named and lives here rather than being a `saveQuietly()` * somewhere in an action, where it would read as a way around the guard * instead of a decision. Exactly one caller: App\Actions\ApplyPlanChange, * which refuses unless PlanChange says the move is allowed today and writes * the proof-register row in the same transaction. * * Only the snapshot's own columns move. `started_at` stays where it is — the * contract began when it began, and a change of package does not restart it * — and so do the period boundaries, which belong to the billing cycle and * not to the package. * * @param array $snapshot from snapshotFrom() */ public function applyPlanSnapshot(array $snapshot): void { $movable = array_diff([...self::FROZEN, 'template_vmid'], ['started_at']); $this->forceFill(array_intersect_key($snapshot, array_flip($movable))); // Quietly, because the guard in booted() is what is being excepted here. $this->saveQuietly(); // The version has moved, so anything already holding the old one is // holding the wrong package — the proof-register row written straight // after this reads planVersion for the version number it files under. $this->unsetRelation('planVersion'); } /** * Book a change of package for the end of the term the customer has already * paid for. * * The date is stamped, not derived. `current_period_end` is where it comes * FROM, but it is not where it can be READ from afterwards: Stripe pushes * that column forward on every renewal, so a downgrade whose due date was * re-read from it would be deferred by another whole term each time the * customer was billed — for the package they had asked to leave. Booked once * means booked once, on the date it was booked for. * * One at a time, deliberately. Choosing another package replaces the * booking rather than queueing behind it, exactly as the cart replaces the * pending order: two scheduled changes contradict each other and nothing * could resolve which one the customer meant. */ public function bookPendingPlanChange(string $plan): void { $this->update([ 'pending_plan' => $plan, 'pending_effective_at' => $this->current_period_end?->copy(), ]); } /** * Drop a booked change: the customer withdrew it, moved the other way, or * it has just landed. * * Always both columns. A plan with no date is a change that can never come * due, and a date with no plan is a contract that says it is going to shrink * into nothing. */ public function clearPendingPlanChange(): void { if ($this->pending_plan === null && $this->pending_effective_at === null) { return; } $this->update(['pending_plan' => null, 'pending_effective_at' => null]); } /** Is a change booked on this contract that has not landed yet? */ public function hasPendingPlanChange(): bool { return $this->pending_plan !== null && $this->pending_effective_at !== null; } /** * When the withdrawal window opened: the moment the contract was concluded. * * `started_at` and not `created_at`, and the two are the same instant today * — but `started_at` is in FROZEN and therefore cannot be moved by anything, * which is the property a statutory deadline needs. */ public function withdrawalOpenedAt(): ?Carbon { return $this->started_at; } /** Has this contract already been withdrawn from? */ public function isWithdrawn(): bool { return $this->withdrawn_at !== null; } /** * Is this contract over — cancelled at the end of its term, withdrawn from, * or deleted at Stripe? * * `status` and not `cancel_requested_at`: a customer who has cancelled keeps * everything they paid for until the term runs out, and treating them as * gone the moment they clicked would take away service they are owed. Only * `cancelled` is the end, and it is written by whichever of the three got * there — see ApplyStripeBillingEvent::subscriptionDeleted(). */ public function hasEnded(): bool { return $this->status === 'cancelled'; } public function isYearly(): bool { return $this->term === self::TERM_YEARLY; } /** * What this contract works out to per month. * * `price_cents` is the price of a TERM, and a yearly term holds the whole * year. Everything that prints a monthly figure — the plan card, the cloud * page, the revenue column — has to divide, and every one of them getting * it right separately is not something to rely on. */ public function monthlyPriceCents(): int { return $this->isYearly() ? (int) round($this->price_cents / 12) : (int) $this->price_cents; } /** * The whole monthly bill, net: the plan plus every module still booked. * * All of it frozen — that is the owner's rule. A total assembled from the * contract for the plan and from today's catalogue for the modules would * protect half a customer's price and quietly re-price the other half. */ public function totalMonthlyCents(): int { return $this->monthlyPriceCents() + $this->addons ->where('cancelled_at', null) ->sum(fn (SubscriptionAddon $addon) => $addon->monthlyCents()); } }