> */ 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, ])]; }) ->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(); }); } /** * 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); }); } }