CluPilotCloud/app/Services/Billing/PlanCatalogue.php

411 lines
16 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* The one place the plan catalogue is read.
*
* There is deliberately **no fallback to config**. "Use the DB, and config if
* the DB has no row" would resurrect a plan the owner had just switched off,
* and would leave commerce reading one source while provisioning read another —
* the exact split-brain this whole rebuild exists to close. An empty catalogue
* is an outage, and it should look like one.
*
* Availability is computed here, on every read. Nothing schedules a plan into
* or out of sale, because a job that fails to run is a plan that silently
* misbehaves, and "the launch didn't happen because a worker was down" is not
* something you can tell a customer.
*/
final class PlanCatalogue
{
/**
* Every plan on sale right now, keyed by family key, in the array shape the
* app has always used.
*
* @return array<string, array<string, mixed>>
*/
public function sellable(?Carbon $at = null): array
{
$at ??= now();
$currency = Subscription::catalogueCurrency();
return PlanFamily::query()
->where('sales_enabled', true)
->with(['versions' => fn ($q) => $q->available($at)->with('prices')])
->orderBy('tier')
->get()
->mapWithKeys(function (PlanFamily $family) use ($currency) {
// sole() semantics, kept here so a caller listing the shop hits
// the same loud failure as a caller resolving one plan.
if ($family->versions->count() > 1) {
throw new RuntimeException(
"Plan '{$family->key}' has {$family->versions->count()} versions on sale at once. ".
'Overlapping availability windows must be fixed before anything can be sold.'
);
}
$version = $family->versions->first();
if ($version === null) {
return [];
}
// Every supported term, or the plan is not shown at all. Listing
// one that is priced monthly but not yearly would let a
// customer pick it, pay, and land on a contract that cannot be
// opened — the shop and the checkout must agree on this.
$priced = $this->requiredPrices($version, $currency);
if ($priced === null) {
return [];
}
$monthly = $priced[Subscription::TERM_MONTHLY];
return [$family->key => array_merge($version->capabilities(), [
'name' => $family->name,
'price_cents' => $monthly->amount_cents,
'currency' => $monthly->currency,
'plan_version_id' => $version->id,
// Marketing presentation, not a capability: it lives on the
// family (see the migration), and every reader of the shop
// gets it from the same place rather than each keeping its
// own copy of who a plan is for.
'audience' => $family->audience,
'note' => $family->note,
'recommended' => (bool) $family->is_recommended,
])];
})
->all();
}
/**
* The version a purchase of this family right now would be sold under.
*
* Fails closed and loudly: an unknown or unsold plan throws, and so does an
* overlap. Picking one of two overlapping versions would decide a customer's
* terms by row order.
*/
public function currentVersion(string $familyKey, ?Carbon $at = null): PlanVersion
{
$family = PlanFamily::query()->where('key', $familyKey)->first();
if ($family === null) {
throw new RuntimeException("Unknown plan: {$familyKey}");
}
if (! $family->sales_enabled) {
throw new RuntimeException("Plan '{$familyKey}' is not on sale.");
}
try {
$version = $family->versions()->available($at)->sole();
} catch (ModelNotFoundException) {
throw new RuntimeException("Plan '{$familyKey}' has no version on sale.");
}
$version->setRelation('family', $family);
return $version;
}
/**
* Whether a purchase of this family could actually be completed right now.
*
* Pricing is part of the question, not a detail left to the checkout. A
* version whose price row has been deleted is still inside its window, and
* answering "yes" here would send an already-paid webhook into a contract
* it cannot open.
*/
public function isSellable(string $familyKey, ?Carbon $at = null): bool
{
try {
$version = $this->currentVersion($familyKey, $at);
} catch (RuntimeException) {
return false;
}
return $this->requiredPrices($version, Subscription::catalogueCurrency()) !== null;
}
/**
* Every term we sell on, priced in this currency — or null if any is
* missing.
*
* The single definition of "this version can actually be bought", so the
* shop, the checkout and the consistency command can never disagree about
* which plans are real.
*
* @return array<string, \App\Models\PlanPrice>|null
*/
private function requiredPrices(PlanVersion $version, string $currency): ?array
{
$found = [];
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
// relationLoaded() so a listing that eager-loaded prices does not
// fire a query per plan per term.
$price = $version->relationLoaded('prices')
? $version->prices->first(fn ($p) => $p->term === $term && $p->currency === $currency)
: $version->priceFor($term, $currency);
if ($price === null) {
return null;
}
$found[$term] = $price;
}
return $found;
}
/**
* The exact version a customer was sold, checked against the plan they
* bought.
*
* Used when the purchase carried its own version — a checkout that started
* before a scheduled transition and finished after it. A version that has
* closed is still honoured, because the customer saw and paid for it; one
* that was never published is not, because nothing was ever promised.
*/
public function soldVersion(string $familyKey, int $versionId): PlanVersion
{
$version = $this->version($versionId);
if ($version->family->key !== $familyKey) {
throw new RuntimeException(
"Version {$versionId} belongs to '{$version->family->key}', not to '{$familyKey}'."
);
}
if (! $version->isPublished()) {
throw new RuntimeException("Version {$versionId} was never published.");
}
return $version;
}
/**
* Whether a contract can still be opened on a version a customer was quoted.
*
* Deliberately NOT the same question as "is this plan on sale". A checkout
* that began while the version was available is owed that version, even if
* the window has closed or the owner has withdrawn the plan since — they
* paid for what they were shown. Only realness and pricing matter here.
*/
public function isDeliverable(string $familyKey, int $versionId): bool
{
try {
$version = $this->soldVersion($familyKey, $versionId);
} catch (RuntimeException|ModelNotFoundException) {
return false;
}
return $this->requiredPrices($version, Subscription::catalogueCurrency()) !== null;
}
/**
* A historical reference, resolved by version id — never by family key.
*
* Looking a past contract up by name would hand back today's terms, which
* is the same mistake the snapshot exists to prevent, one level up.
*/
public function version(int $id): PlanVersion
{
return PlanVersion::query()->with('family')->findOrFail($id);
}
/**
* Write a new draft version of a family.
*
* The version number is allocated under a lock on the family: two admins
* drafting at the same moment would otherwise both read the same maximum,
* and the unique index would turn one perfectly valid action into a
* database error — after the other had already succeeded.
*
* @param array<string, mixed> $capabilities
* @param array<string, int> $pricesByTerm
*/
public function draft(PlanFamily $family, array $capabilities, array $pricesByTerm): PlanVersion
{
$currency = Subscription::catalogueCurrency();
return DB::transaction(function () use ($family, $capabilities, $pricesByTerm, $currency) {
PlanFamily::query()->whereKey($family->getKey())->lockForUpdate()->firstOrFail();
$version = $family->versions()->create([
...$capabilities,
'version' => (int) $family->versions()->max('version') + 1,
// A placeholder: publication sets the real window, and until
// then the version is not available at any time anyway.
'available_from' => now(),
]);
foreach ($pricesByTerm as $term => $amount) {
$version->prices()->create([
'term' => $term,
'amount_cents' => $amount,
'currency' => $currency,
]);
}
return $version;
});
}
/**
* Discard a draft — but only if it is still a draft.
*
* One statement, conditional on `published_at` still being null. Checking
* in PHP and deleting afterwards can delete a version that was published in
* between, and a published version must never be removed: contracts point
* at it. Returns false when the row was published in the meantime.
*/
public function discardDraft(PlanVersion $version): bool
{
return PlanVersion::query()
->whereKey($version->getKey())
->whereNull('published_at')
->delete() > 0;
}
/**
* Move a version's availability window.
*
* Under a lock on the family, because two admins scheduling at the same
* moment would each see a clean check and both commit — leaving two
* versions on sale and every read of that family throwing.
*/
public function schedule(PlanVersion $version, Carbon $from, ?Carbon $until = null): PlanVersion
{
if ($until !== null && $until->lessThanOrEqualTo($from)) {
throw new RuntimeException('A plan cannot stop being sold before it starts.');
}
return DB::transaction(function () use ($version, $from, $until) {
PlanFamily::query()->whereKey($version->plan_family_id)->lockForUpdate()->firstOrFail();
$clash = PlanVersion::query()
->where('plan_family_id', $version->plan_family_id)
->whereKeyNot($version->getKey())
// Only published versions can clash: a draft is not on sale, so
// its provisional window must not block the owner from
// rescheduling the version that customers can actually buy.
->whereNotNull('published_at')
// Half-open overlap: a starts before b ends AND b starts before
// a ends. A null end is "never ends".
->where(fn ($q) => $q
->whereNull('available_until')
->orWhere('available_until', '>', $from))
->when($until !== null, fn ($q) => $q->where('available_from', '<', $until))
->exists();
if ($clash) {
throw new RuntimeException(
'That window overlaps another version of this plan. Two versions on sale at once '.
'would leave the price a customer pays decided by row order.'
);
}
// Written by query, not by save(): a model handed to us may carry
// unsaved edits, and rescheduling a window must never be the thing
// that quietly persists a change to what the plan promises.
PlanVersion::query()->whereKey($version->getKey())->update([
'available_from' => $from,
'available_until' => $until,
'updated_at' => now(),
]);
return $version->refresh();
});
}
/**
* Mark exactly one plan family as recommended, clearing every other one.
*
* Singular by nature: a second recommendation is not two recommendations,
* it is none. Every family row is locked for the length of the
* transaction, so two operators recommending two different plans at the
* same moment cannot both win and leave two marked rows instead of one.
*/
public function recommend(PlanFamily $family): void
{
DB::transaction(function () use ($family) {
PlanFamily::query()->lockForUpdate()->get();
PlanFamily::query()->whereKeyNot($family->getKey())->update(['is_recommended' => false]);
PlanFamily::query()->whereKey($family->getKey())->update(['is_recommended' => true]);
});
}
/**
* Publish a draft: lock its capabilities and put it on sale.
*
* Publication is the promise. From here the version describes what its
* customers are owed, and changing it would rewrite their contract.
*/
public function publish(PlanVersion $version, ?Carbon $from = null, ?Carbon $until = null): PlanVersion
{
// Re-read before deciding anything. A previous attempt that was rolled
// back leaves this object claiming a publication the database never
// kept, and the owner would then be told a draft they can still see is
// already published.
$version->refresh();
if ($version->isPublished()) {
throw new RuntimeException('That version is already published.');
}
// Nothing goes on sale that provisioning cannot build. Without a
// blueprint the pipeline fails at CloneVirtualMachine with
// `template_missing` — after the customer has paid.
if ($version->template_vmid === null) {
throw new RuntimeException(
'A version cannot go on sale without a VM template; provisioning would have nothing to clone.'
);
}
$currency = Subscription::catalogueCurrency();
// Both terms, or neither. A version priced only yearly passes as
// sellable and then fails at the checkout of anyone who picks monthly —
// and once published its capabilities are frozen, so the mistake cannot
// simply be edited away.
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
$priced = $version->prices()->where('term', $term)->where('currency', $currency)->exists();
if (! $priced) {
throw new RuntimeException("A version cannot go on sale without a {$term} price in {$currency}.");
}
}
// Publication and scheduling together, or not at all. Publishing first
// and then failing the overlap check would leave the version frozen but
// unscheduled — and publish() refuses it from then on, so nothing short
// of a manual repair could rescue it.
return DB::transaction(function () use ($version, $from, $until) {
// Claim the publication conditionally, so of two simultaneous
// attempts exactly one proceeds. Unconditional, the second would
// sail past and overwrite the window the first had just set.
$claimed = PlanVersion::query()
->whereKey($version->getKey())
->whereNull('published_at')
->update(['published_at' => now(), 'updated_at' => now()]);
if ($claimed === 0) {
throw new RuntimeException('That version is already published.');
}
return $this->schedule($version->refresh(), $from ?? now(), $until);
});
}
}