> */ 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]; $yearly = $priced[Subscription::TERM_YEARLY]; return [$family->key => array_merge($version->capabilities(), [ 'name' => $family->name, 'price_cents' => $monthly->amount_cents, // The yearly figure travels with the monthly one. Both terms // are already required before a version can be sold at all // (requiredPrices above), so a shop showing one and hiding // the other was hiding a price it had in its hand — and // whoever wanted to show it would have gone looking for a // second source. 'yearly_price_cents' => $yearly->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|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 $capabilities * @param array $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(); }); } /** * Put a published version back on open-ended sale. * * The inverse of closing a window, and the reason it has to exist: closing * was one-way. An owner who took a version off sale before its replacement * was ready had no way back, and the plan stayed dark until someone edited * the table by hand. * * Expressed through schedule() rather than as its own update, so it passes * the same family lock and the same overlap check as every other window * change. That check is the point, not an obstacle: a version whose * successor is already selling must not come back and leave two on sale at * once. */ public function reopen(PlanVersion $version): PlanVersion { // Re-read first: a stale object could carry a start date the owner has // since moved, and reopening would then quietly restore the old one. $version->refresh(); if (! $version->isPublished()) { throw new RuntimeException( 'That version was never published, so it was never on sale and there is nothing to reopen. '. 'Publish it instead.' ); } return $this->schedule($version, $version->available_from, null); } /** * The other published version of this family that is on sale at `$at`. * * Public because the console announces a handover before the owner commits * to it, and an announcement computed from its own idea of "currently * running" would sooner or later name a different version than the one * publish() actually closes. * * Null when two are running at once: that is the data fault schedule() * exists to prevent, and picking one of them to close would decide by row * order which of the owner's plans quietly stops selling. */ public function predecessorAt(PlanVersion $version, Carbon $at): ?PlanVersion { $running = PlanVersion::query() ->where('plan_family_id', $version->plan_family_id) ->whereKeyNot($version->getKey()) ->whereNotNull('published_at') // Running at `$at`, not merely clashing with it. A version that // only starts later clashes too, but closing it at `$at` would end // it before it began — that one still belongs to the overlap check. ->where('available_from', '<=', $at) ->where(fn ($q) => $q ->whereNull('available_until') ->orWhere('available_until', '>', $at)) ->get(); return $running->count() === 1 ? $running->first() : null; } /** * 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, the handover and the new window 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. The same // goes the other way: a predecessor closed for a successor that never // arrived is a plan taken off sale by an action that failed. return DB::transaction(function () use ($version, $from, $until) { $from ??= now(); // The lock schedule() would take anyway, taken here instead: it has // to be held from before the predecessor is chosen until after the // successor's window is written, or a publish running alongside // this one could hand the same version over twice. PlanFamily::query()->whereKey($version->plan_family_id)->lockForUpdate()->firstOrFail(); // 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.'); } $predecessor = $this->predecessorAt($version, $from); if ($predecessor !== null) { // A handover, not an overlap: the version that was running // stops at the moment its successor starts, which is neither a // gap nor two versions on sale. Refusing here — as this used to // — forced the owner to close the running version first, and a // replacement that then failed to publish left the plan off // sale with no way back. That dead end is what this removes. // // Written by query for the same reason schedule() is: the // version being handed over is published and immutable, and // save() would offer every attribute on the object to that // guard. PlanVersion::query()->whereKey($predecessor->getKey())->update([ 'available_until' => $from, 'updated_at' => now(), ]); } return $this->schedule($version->refresh(), $from, $until); }); } }