411 lines
16 KiB
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);
|
|
});
|
|
}
|
|
}
|