353 lines
13 KiB
PHP
353 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Support\Carbon;
|
|
use RuntimeException;
|
|
|
|
class Subscription extends Model
|
|
{
|
|
use HasFactory;
|
|
use HasUuids;
|
|
|
|
public const TERM_MONTHLY = 'monthly';
|
|
|
|
public const TERM_YEARLY = 'yearly';
|
|
|
|
/**
|
|
* The conditions the customer is owed. Changing any of these after the fact
|
|
* would rewrite a contract, so the model refuses — a price rise applies to
|
|
* new subscriptions, never to existing ones.
|
|
*/
|
|
public const FROZEN = [
|
|
'plan', 'plan_version_id', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb',
|
|
'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'tier', 'started_at',
|
|
];
|
|
|
|
protected $guarded = [];
|
|
|
|
protected static function booted(): void
|
|
{
|
|
static::updating(function (self $subscription) {
|
|
$frozen = array_intersect(array_keys($subscription->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',
|
|
'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<string, mixed> $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;
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|