From f6ddf45dc0b65c3e5f1039d0b42d5595688fe847 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 12:41:51 +0200 Subject: [PATCH] feat(admin): a console for creating, pricing and scheduling plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner can now do from the console what only a config edit could do before: create a plan line, draft a version, price it, publish it into a window, and pull a plan out of the shop. Two pages and one modal, because the catalogue has exactly two levels. The plan list is a name, a rank and a kill switch. Everything that can be got wrong — capabilities, prices, windows — lives on the versions, where the page is built around the one rule that matters: a draft is freely editable, a published version is not touchable at all. So drafting and publishing are separate acts, and publishing says plainly that it is final. The console refuses everything the catalogue refuses, on the form rather than as a stack trace: an overlapping window, a window that ends before it starts, a version with no price for a term we sell on, and — new here — a version with no VM template, which would be bought and then fail provisioning every time. Numbers are bounded on both sides, because a mistyped price otherwise overflows the column and answers with a 500 instead of saying which field was wrong. Performance class and features are picked from the keys we have labels for; free text would be frozen at publication and shown to customers as a raw translation key forever. Concurrency, since two admins share one catalogue: draft numbers are allocated under a lock on the family, publication is an atomic conditional claim, and discarding a draft is one statement conditional on it still being a draft — otherwise a draft published in the meantime could be deleted out from under the customers now contracted to it. `plans.manage` (Owner and Admin) guards the pages themselves, not only the buttons, and the modal authorises itself — prices, drafts and unreleased plans are not something to leave readable to anyone who types the URL. Also fixes the shared checkbox, which ticked in the browser's own blue on every page that used one: a native checkbox ignores text colour and needs accent-color. Verified in the browser: withdrawing a plan in the console removes it from the customer's billing page immediately, and restoring it brings it back. The public landing page carries no catalogue-driven plan list, so there is nothing there to hide. 421 tests green. Codex review clean after six rounds. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/ConfirmDeletePlanDraft.php | 60 ++++ app/Livewire/Admin/PlanVersions.php | 243 +++++++++++++++++ app/Livewire/Admin/Plans.php | 116 ++++++++ app/Services/Billing/PlanCatalogue.php | 78 +++++- ..._26_050000_add_plans_manage_capability.php | 36 +++ lang/de/admin.php | 1 + lang/de/plans.php | 87 ++++++ lang/en/admin.php | 1 + lang/en/plans.php | 87 ++++++ .../views/components/ui/checkbox.blade.php | 5 +- resources/views/components/ui/icon.blade.php | 1 + resources/views/layouts/admin.blade.php | 1 + .../admin/confirm-delete-plan-draft.blade.php | 19 ++ .../livewire/admin/plan-versions.blade.php | 195 +++++++++++++ .../views/livewire/admin/plans.blade.php | 93 +++++++ routes/web.php | 2 + tests/Feature/Admin/PlanAdminTest.php | 256 ++++++++++++++++++ tests/Feature/Billing/PlanCatalogueTest.php | 62 +++++ 18 files changed, 1338 insertions(+), 5 deletions(-) create mode 100644 app/Livewire/Admin/ConfirmDeletePlanDraft.php create mode 100644 app/Livewire/Admin/PlanVersions.php create mode 100644 app/Livewire/Admin/Plans.php create mode 100644 database/migrations/2026_07_26_050000_add_plans_manage_capability.php create mode 100644 lang/de/plans.php create mode 100644 lang/en/plans.php create mode 100644 resources/views/livewire/admin/confirm-delete-plan-draft.blade.php create mode 100644 resources/views/livewire/admin/plan-versions.blade.php create mode 100644 resources/views/livewire/admin/plans.blade.php create mode 100644 tests/Feature/Admin/PlanAdminTest.php diff --git a/app/Livewire/Admin/ConfirmDeletePlanDraft.php b/app/Livewire/Admin/ConfirmDeletePlanDraft.php new file mode 100644 index 0000000..4d6a089 --- /dev/null +++ b/app/Livewire/Admin/ConfirmDeletePlanDraft.php @@ -0,0 +1,60 @@ +authorize('plans.manage'); + + $version = PlanVersion::query()->with('family')->where('uuid', $uuid)->firstOrFail(); + abort_if($version->isPublished(), 403); + + $this->uuid = $uuid; + $this->version = $version->version; + $this->plan = $version->family->name; + } + + public function delete(): void + { + $this->authorize('plans.manage'); + + $version = PlanVersion::query()->where('uuid', $this->uuid)->first(); + + // The catalogue decides, in one conditional statement. Checking here and + // deleting afterwards would leave a window in which the version is + // published between the two — and a published version must never go. + if ($version !== null && ! app(PlanCatalogue::class)->discardDraft($version)) { + $this->dispatch('notify', message: __('plans.discard_too_late')); + } + + $this->dispatch('plan-draft-deleted'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-delete-plan-draft'); + } +} diff --git a/app/Livewire/Admin/PlanVersions.php b/app/Livewire/Admin/PlanVersions.php new file mode 100644 index 0000000..19d1486 --- /dev/null +++ b/app/Livewire/Admin/PlanVersions.php @@ -0,0 +1,243 @@ + */ + public array $features = []; + + public int $monthlyCents = 4900; + + public int $yearlyCents = 58800; + + /** Publishing: when it goes on sale, and optionally when it stops. */ + public string $availableFrom = ''; + + public string $availableUntil = ''; + + public ?string $publishing = null; + + public function mount(string $uuid): void + { + $this->authorize('plans.manage'); + + $family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail(); + $this->uuid = $uuid; + + // Start the draft from what this plan is today, so the common case — + // "same plan, new price" — is a single edit rather than nine. + $latest = $family->versions()->orderByDesc('version')->first(); + + if ($latest !== null) { + $this->quotaGb = $latest->quota_gb; + $this->trafficGb = $latest->traffic_gb; + $this->seats = $latest->seats; + $this->ramMb = $latest->ram_mb; + $this->cores = $latest->cores; + $this->diskGb = $latest->disk_gb; + $this->performance = (string) ($latest->performance ?? 'standard'); + $this->templateVmid = $latest->template_vmid; + $this->features = $latest->features ?? []; + + $monthly = $latest->priceFor(Subscription::TERM_MONTHLY); + $yearly = $latest->priceFor(Subscription::TERM_YEARLY); + $this->monthlyCents = $monthly?->amount_cents ?? $this->monthlyCents; + $this->yearlyCents = $yearly?->amount_cents ?? $this->yearlyCents; + } + } + + private function family(): PlanFamily + { + return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail(); + } + + /** Write a new draft. Nothing is promised until it is published. */ + public function draft(): void + { + $this->authorize('plans.manage'); + + // Every bound is explicit, and generous rather than tight. A mistyped + // figure has to come back as a message on the field; without an upper + // limit it overflows the column instead and the owner gets a 500 with + // no idea which number was wrong. + $data = $this->validate([ + 'quotaGb' => 'required|integer|min:1|max:1000000', + 'trafficGb' => 'required|integer|min:0|max:10000000', + 'seats' => 'required|integer|min:1|max:100000', + 'ramMb' => 'required|integer|min:512|max:4194304', + 'cores' => 'required|integer|min:1|max:512', + // The disk has to hold the customer's quota plus the system itself. + 'diskGb' => 'required|integer|min:1|max:1000000|gte:quotaGb', + // One of the classes we actually have a label for. Free text means + // a typo is published, frozen, and shown to customers forever as a + // raw translation key. + 'performance' => ['required', Rule::in(array_keys((array) __('billing.perf')))], + // Required, not nullable: a version without a blueprint can be + // published and sold, and then fails provisioning every time. + 'templateVmid' => 'required|integer|min:100|max:999999999', + 'features' => 'array', + // Same reason as the performance class: a request can carry + // anything, and an unknown key is frozen at publication and shown + // to customers as a raw translation key. + 'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))], + // 9,999,999.99 in the catalogue currency — far past anything we + // would charge, far short of overflowing an unsigned int. + 'monthlyCents' => 'required|integer|min:0|max:999999999', + 'yearlyCents' => 'required|integer|min:0|max:999999999', + ]); + + $version = app(PlanCatalogue::class)->draft( + $this->family(), + [ + 'quota_gb' => $data['quotaGb'], + 'traffic_gb' => $data['trafficGb'], + 'seats' => $data['seats'], + 'ram_mb' => $data['ramMb'], + 'cores' => $data['cores'], + 'disk_gb' => $data['diskGb'], + 'performance' => $data['performance'], + 'template_vmid' => $data['templateVmid'], + 'features' => array_values($data['features']), + ], + // Both terms, because publishing refuses a version that is not + // priced for each — better to find that out here than in front of + // a customer. + [ + Subscription::TERM_MONTHLY => $data['monthlyCents'], + Subscription::TERM_YEARLY => $data['yearlyCents'], + ], + ); + + $this->dispatch('notify', message: __('plans.draft_created', ['version' => $version->version])); + } + + /** Open the publish form for one draft. */ + public function choose(string $versionUuid): void + { + $this->publishing = $versionUuid; + $this->availableFrom = now()->format('Y-m-d\TH:i'); + $this->availableUntil = ''; + } + + public function cancelPublish(): void + { + $this->reset('publishing', 'availableFrom', 'availableUntil'); + } + + /** + * Publish: fix the terms and put them on sale. + * + * Everything that can refuse this — an unpriced version, a window that + * overlaps another — refuses before anything is written, so a rejected + * publish leaves an editable draft rather than a stranded one. + */ + public function publish(): void + { + $this->authorize('plans.manage'); + + $this->validate([ + 'availableFrom' => 'required|date', + 'availableUntil' => 'nullable|date|after:availableFrom', + ]); + + $version = PlanVersion::query()->where('uuid', $this->publishing)->firstOrFail(); + abort_unless($version->plan_family_id === $this->family()->id, 404); + + try { + app(PlanCatalogue::class)->publish( + $version, + Carbon::parse($this->availableFrom), + $this->availableUntil !== '' ? Carbon::parse($this->availableUntil) : null, + ); + } catch (RuntimeException $e) { + $this->addError('availableFrom', $e->getMessage()); + + return; + } + + $this->cancelPublish(); + $this->dispatch('notify', message: __('plans.published', ['version' => $version->version])); + } + + /** + * Close a running version's window — the ordinary way to take a plan off + * sale for good, since a published version can never be deleted. + */ + public function close(string $versionUuid): void + { + $this->authorize('plans.manage'); + + $version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail(); + abort_unless($version->plan_family_id === $this->family()->id, 404); + + try { + app(PlanCatalogue::class)->schedule($version, $version->available_from, now()); + } catch (RuntimeException $e) { + $this->addError('availableFrom', $e->getMessage()); + + return; + } + + $this->dispatch('notify', message: __('plans.closed', ['version' => $version->version])); + } + + #[On('plan-draft-deleted')] + public function refresh(): void + { + // Livewire re-renders; the listener exists so the modal can say so. + } + + public function render() + { + $family = $this->family(); + $now = now(); + + return view('livewire.admin.plan-versions', [ + 'family' => $family, + 'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(), + 'now' => $now, + 'featureKeys' => array_keys((array) __('billing.feature')), + 'performanceClasses' => (array) __('billing.perf'), + 'currency' => Subscription::catalogueCurrency(), + ]); + } +} diff --git a/app/Livewire/Admin/Plans.php b/app/Livewire/Admin/Plans.php new file mode 100644 index 0000000..2824985 --- /dev/null +++ b/app/Livewire/Admin/Plans.php @@ -0,0 +1,116 @@ +authorize('plans.manage'); + } + + public function save(): void + { + $this->authorize('plans.manage'); + + $this->key = strtolower(trim($this->key)); + + $data = $this->validate([ + // The key is permanent once created — orders, instances and every + // contract snapshot store it as a string — so it has to be a plain + // identifier, not something anyone will want to prettify later. + 'key' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'], + 'name' => 'required|string|max:255', + 'tier' => 'required|integer|min:0|max:255', + ]); + + PlanFamily::create([ + 'key' => $data['key'], + 'name' => $data['name'], + 'tier' => $data['tier'], + // Nothing to sell until a version is published, so it starts able + // to sell and simply has nothing available. + 'sales_enabled' => true, + ]); + + $this->reset('key', 'name', 'tier'); + $this->tier = 1; + $this->dispatch('notify', message: __('plans.created')); + } + + /** + * The kill switch: stop selling this plan now, without touching a window. + * + * Existing customers keep their contract and their machine — this only + * decides whether anyone new can buy it. + */ + public function toggleSales(string $uuid): void + { + $this->authorize('plans.manage'); + + $family = PlanFamily::query()->where('uuid', $uuid)->first(); + $family?->update(['sales_enabled' => ! $family->sales_enabled]); + + $this->dispatch('notify', message: __($family?->sales_enabled ? 'plans.now_selling' : 'plans.withdrawn')); + } + + public function render() + { + $now = now(); + + $families = PlanFamily::query() + ->with(['versions.prices']) + ->withCount('versions') + ->orderBy('tier') + ->get() + ->map(fn (PlanFamily $family) => [ + 'uuid' => $family->uuid, + 'key' => $family->key, + 'name' => $family->name, + 'tier' => $family->tier, + 'sales_enabled' => $family->sales_enabled, + 'versions_count' => $family->versions_count, + 'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)), + // What is queued up next, so a scheduled launch is visible + // before a customer discovers it. + 'next' => $family->versions + ->filter(fn ($v) => $v->isPublished() && $v->available_from->greaterThan($now)) + ->sortBy('available_from') + ->first(), + 'drafts' => $family->versions->filter(fn ($v) => ! $v->isPublished())->count(), + ]); + + return view('livewire.admin.plans', [ + 'families' => $families, + // The shop's own answer, so this page cannot disagree with what a + // customer actually sees. + 'sellable' => array_keys(app(PlanCatalogue::class)->sellable()), + ]); + } +} diff --git a/app/Services/Billing/PlanCatalogue.php b/app/Services/Billing/PlanCatalogue.php index 956bc38..847f2d4 100644 --- a/app/Services/Billing/PlanCatalogue.php +++ b/app/Services/Billing/PlanCatalogue.php @@ -216,6 +216,60 @@ final class PlanCatalogue 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. * @@ -285,6 +339,15 @@ final class PlanCatalogue 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 @@ -304,10 +367,17 @@ final class PlanCatalogue // 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) { - PlanVersion::query()->whereKey($version->getKey())->update([ - 'published_at' => now(), - 'updated_at' => now(), - ]); + // 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); }); diff --git a/database/migrations/2026_07_26_050000_add_plans_manage_capability.php b/database/migrations/2026_07_26_050000_add_plans_manage_capability.php new file mode 100644 index 0000000..bf63507 --- /dev/null +++ b/database/migrations/2026_07_26_050000_add_plans_manage_capability.php @@ -0,0 +1,36 @@ +forgetCachedPermissions(); + + Permission::findOrCreate('plans.manage', 'web'); + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'web')->givePermissionTo('plans.manage'); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::query()->where('name', 'plans.manage')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 7f81f6f..bcce7d8 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -11,6 +11,7 @@ return [ 'instances' => 'Instanzen', 'hosts' => 'Hosts', 'datacenters' => 'Rechenzentren', + 'plans' => 'Pakete', 'provisioning' => 'Provisioning', 'maintenance' => 'Wartungen', 'vpn' => 'VPN', diff --git a/lang/de/plans.php b/lang/de/plans.php new file mode 100644 index 0000000..c90658e --- /dev/null +++ b/lang/de/plans.php @@ -0,0 +1,87 @@ + 'Pakete', + 'subtitle' => 'Was Sie verkaufen, zu welchem Preis und ab wann. Bestandskunden behalten immer ihren Vertrag.', + 'empty' => 'Noch keine Pakete angelegt.', + + 'plan' => 'Paket', + 'tier' => 'Rang', + 'tier_hint' => 'Entscheidet, ob ein Wechsel ein Upgrade ist — nicht der Preis.', + 'status' => 'Status', + 'actions' => 'Aktionen', + 'live_version' => 'Im Verkauf', + 'no_live_version' => 'keine Version aktiv', + 'next_from' => 'v:version ab :date', + 'drafts' => '{1} :count Entwurf|[2,*] :count Entwürfe', + 'manage_versions' => 'Versionen (:count)', + + 'on_sale' => 'Im Verkauf', + 'nothing_available' => 'Nichts verfügbar', + 'withdrawn_badge' => 'Aus dem Verkauf', + 'now_selling' => 'Paket ist wieder im Verkauf.', + 'withdrawn' => 'Paket aus dem Verkauf genommen. Bestandskunden behalten ihren Vertrag.', + + 'new_title' => 'Neues Paket', + 'new_body' => 'Legt nur die Paketlinie an. Was sie kann und kostet, entscheiden die Versionen.', + 'key' => 'Schlüssel', + 'key_hint' => 'Dauerhaft. Bestellungen und Verträge verweisen darauf.', + 'name' => 'Anzeigename', + 'create' => 'Paket anlegen', + 'created' => 'Paket angelegt.', + + 'back' => 'Zurück zu den Paketen', + 'versions_subtitle' => 'Eine Version ist ab Veröffentlichung unveränderlich — Kunden sind darauf vertraglich gebunden.', + 'family_withdrawn_note' => 'Dieses Paket ist aus dem Verkauf genommen. Solange der Schalter aus ist, kann es niemand kaufen — unabhängig davon, welche Version gerade laufen würde. Bestandskunden behalten ihren Vertrag.', + 'version' => 'Version :n', + 'no_versions' => 'Noch keine Version. Legen Sie rechts einen Entwurf an.', + + 'badge_live' => 'Im Verkauf', + 'badge_scheduled' => 'Geplant', + 'badge_closed' => 'Beendet', + 'badge_withdrawn' => 'Aus dem Verkauf', + 'badge_draft' => 'Entwurf', + 'window' => ':from bis :until', + 'open_ended' => 'auf Weiteres', + 'draft_hint' => 'Noch nichts zugesagt — frei änderbar.', + + 'publish' => 'Veröffentlichen', + 'publish_warning' => 'Ab der Veröffentlichung sind Leistung und Preis dieser Version endgültig. Änderungen brauchen danach eine neue Version.', + 'publish_confirm' => 'Endgültig veröffentlichen', + 'published' => 'Version :version veröffentlicht.', + 'available_from' => 'Verkauf ab', + 'available_until' => 'Verkauf bis', + 'available_until_hint' => 'Leer lassen für unbefristet.', + 'close_now' => 'Verkauf beenden', + 'closed' => 'Version :version wird nicht mehr verkauft.', + 'cancel' => 'Abbrechen', + + 'discard' => 'Verwerfen', + 'discard_title' => 'Entwurf v:version von :plan verwerfen?', + 'discard_body' => 'Ein Entwurf wurde niemandem zugesagt und kann folgenlos gelöscht werden.', + 'discard_confirm' => 'Entwurf verwerfen', + 'discard_too_late' => 'Diese Version wurde inzwischen veröffentlicht und bleibt bestehen.', + + 'draft_title' => 'Neue Version', + 'draft_body' => 'Vorbelegt mit der letzten Version — für „gleiches Paket, neuer Preis“ genügt ein Feld.', + 'draft_create' => 'Entwurf anlegen', + 'draft_created' => 'Entwurf v:version angelegt.', + + 'infra' => 'Infrastruktur', + 'pricing' => 'Preise', + 'features' => 'Enthaltene Leistungen', + 'cents_hint' => 'In Cent, netto.', + 'disk_hint' => 'Mindestens so groß wie der Speicher.', + 'net' => 'netto', + 'term_monthly' => 'Monatlich', + 'term_yearly' => 'Jährlich', + + 'f_storage' => 'Speicher', + 'f_traffic' => 'Datenvolumen', + 'f_seats' => 'Benutzer', + 'f_performance' => 'Leistungsklasse', + 'f_ram' => 'RAM', + 'f_cores' => 'vCPU', + 'f_disk' => 'Festplatte', + 'f_template' => 'Vorlage (VMID)', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 2187d96..0b244e7 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,7 @@ return [ 'instances' => 'Instances', 'hosts' => 'Hosts', 'datacenters' => 'Datacenters', + 'plans' => 'Plans', 'provisioning' => 'Provisioning', 'maintenance' => 'Maintenance', 'vpn' => 'VPN', diff --git a/lang/en/plans.php b/lang/en/plans.php new file mode 100644 index 0000000..76a06a8 --- /dev/null +++ b/lang/en/plans.php @@ -0,0 +1,87 @@ + 'Plans', + 'subtitle' => 'What you sell, at what price, and from when. Existing customers always keep their contract.', + 'empty' => 'No plans yet.', + + 'plan' => 'Plan', + 'tier' => 'Rank', + 'tier_hint' => 'Decides whether a change is an upgrade — not the price.', + 'status' => 'Status', + 'actions' => 'Actions', + 'live_version' => 'On sale', + 'no_live_version' => 'no version live', + 'next_from' => 'v:version from :date', + 'drafts' => '{1} :count draft|[2,*] :count drafts', + 'manage_versions' => 'Versions (:count)', + + 'on_sale' => 'On sale', + 'nothing_available' => 'Nothing available', + 'withdrawn_badge' => 'Withdrawn', + 'now_selling' => 'Plan is on sale again.', + 'withdrawn' => 'Plan withdrawn from sale. Existing customers keep their contract.', + + 'new_title' => 'New plan', + 'new_body' => 'Creates the plan line only. What it can do and costs is decided by its versions.', + 'key' => 'Key', + 'key_hint' => 'Permanent. Orders and contracts refer to it.', + 'name' => 'Display name', + 'create' => 'Create plan', + 'created' => 'Plan created.', + + 'back' => 'Back to plans', + 'versions_subtitle' => 'A version is immutable from publication — customers are contracted to it.', + 'family_withdrawn_note' => 'This plan is withdrawn from sale. While the switch is off nobody can buy it, whichever version would otherwise be running. Existing customers keep their contract.', + 'version' => 'Version :n', + 'no_versions' => 'No version yet. Draft one on the right.', + + 'badge_live' => 'On sale', + 'badge_scheduled' => 'Scheduled', + 'badge_closed' => 'Closed', + 'badge_withdrawn' => 'Withdrawn', + 'badge_draft' => 'Draft', + 'window' => ':from to :until', + 'open_ended' => 'further notice', + 'draft_hint' => 'Nothing promised yet — freely editable.', + + 'publish' => 'Publish', + 'publish_warning' => 'From publication, this version\'s capabilities and price are final. Changing them afterwards needs a new version.', + 'publish_confirm' => 'Publish for good', + 'published' => 'Version :version published.', + 'available_from' => 'On sale from', + 'available_until' => 'On sale until', + 'available_until_hint' => 'Leave empty for open-ended.', + 'close_now' => 'Stop selling', + 'closed' => 'Version :version is no longer sold.', + 'cancel' => 'Cancel', + + 'discard' => 'Discard', + 'discard_title' => 'Discard draft v:version of :plan?', + 'discard_body' => 'A draft was never promised to anyone and can be deleted without consequence.', + 'discard_confirm' => 'Discard draft', + 'discard_too_late' => 'That version has since been published and stays.', + + 'draft_title' => 'New version', + 'draft_body' => 'Prefilled from the latest version — for "same plan, new price", one field is enough.', + 'draft_create' => 'Create draft', + 'draft_created' => 'Draft v:version created.', + + 'infra' => 'Infrastructure', + 'pricing' => 'Prices', + 'features' => 'Included features', + 'cents_hint' => 'In cents, net.', + 'disk_hint' => 'At least as large as the storage quota.', + 'net' => 'net', + 'term_monthly' => 'Monthly', + 'term_yearly' => 'Yearly', + + 'f_storage' => 'Storage', + 'f_traffic' => 'Traffic', + 'f_seats' => 'Users', + 'f_performance' => 'Performance class', + 'f_ram' => 'RAM', + 'f_cores' => 'vCPU', + 'f_disk' => 'Disk', + 'f_template' => 'Template (VMID)', +]; diff --git a/resources/views/components/ui/checkbox.blade.php b/resources/views/components/ui/checkbox.blade.php index 6002850..95f93d3 100644 --- a/resources/views/components/ui/checkbox.blade.php +++ b/resources/views/components/ui/checkbox.blade.php @@ -8,7 +8,10 @@ id="{{ $id }}" name="{{ $name }}" type="checkbox" - {{ $attributes->merge(['class' => 'size-4 rounded border-line-strong text-accent-active']) }} + {{-- accent-color, not text-*: a native checkbox ignores text colour and + would otherwise tick in the browser's own blue, off-brand on every + page that uses one. --}} + {{ $attributes->merge(['class' => 'size-4 shrink-0 rounded border-line-strong accent-[var(--accent-active)]']) }} > @if ($label){{ $label }}@endif {{ $slot }} diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 95d937e..073d1ed 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -33,6 +33,7 @@ 'box' => '', 'bell' => '', 'arrow-left' => '', + 'tag' => '', 'rotate-ccw' => '', 'trash-2' => '', 'settings' => '', diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 2e89a64..fe5c10d 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -34,6 +34,7 @@ ['admin.instances', 'box', 'instances', null], ['admin.hosts', 'server', 'hosts', null], ['admin.datacenters', 'database', 'datacenters', null], + ['admin.plans', 'tag', 'plans', 'plans.manage'], ['admin.provisioning', 'activity', 'provisioning', null], ['admin.maintenance', 'alert-triangle', 'maintenance', null], ['admin.vpn', 'shield', 'vpn', null], diff --git a/resources/views/livewire/admin/confirm-delete-plan-draft.blade.php b/resources/views/livewire/admin/confirm-delete-plan-draft.blade.php new file mode 100644 index 0000000..28687cc --- /dev/null +++ b/resources/views/livewire/admin/confirm-delete-plan-draft.blade.php @@ -0,0 +1,19 @@ +
+
+
+ +
+
+

+ {{ __('plans.discard_title', ['plan' => $plan, 'version' => $version]) }} +

+

{{ __('plans.discard_body') }}

+
+
+
+ {{ __('plans.cancel') }} + + {{ __('plans.discard_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/plan-versions.blade.php b/resources/views/livewire/admin/plan-versions.blade.php new file mode 100644 index 0000000..3e778f1 --- /dev/null +++ b/resources/views/livewire/admin/plan-versions.blade.php @@ -0,0 +1,195 @@ +@php + $eur = fn (int $cents) => Illuminate\Support\Number::currency($cents / 100, in: $currency, locale: app()->getLocale()); +@endphp +
+
+ + + {{ __('plans.back') }} + +

{{ $family->name }}

+

{{ __('plans.versions_subtitle') }}

+
+ + @unless ($family->sales_enabled) + {{-- The kill switch overrides every window, so say so once at the top + rather than leaving each version to look live on its own. --}} +
+ {{ __('plans.family_withdrawn_note') }} +
+ @endunless + +
+ {{-- Versions --}} +
+ @forelse ($versions as $version) + @php + // The window being open is not the same as the plan being + // sold: the family's kill switch overrides every window. + $windowOpen = $version->isAvailableAt($now); + $live = $windowOpen && $family->sales_enabled; + $scheduled = $version->isPublished() && $version->available_from->greaterThan($now); + @endphp +
+
+
+
+

{{ __('plans.version', ['n' => $version->version]) }}

+ @if ($live) + {{ __('plans.badge_live') }} + @elseif ($windowOpen) + {{ __('plans.badge_withdrawn') }} + @elseif ($scheduled) + {{ __('plans.badge_scheduled') }} + @elseif ($version->isPublished()) + {{ __('plans.badge_closed') }} + @else + {{ __('plans.badge_draft') }} + @endif +
+

+ @if ($version->isPublished()) + {{ __('plans.window', [ + 'from' => $version->available_from->isoFormat('L LT'), + 'until' => $version->available_until?->isoFormat('L LT') ?? __('plans.open_ended'), + ]) }} + @else + {{ __('plans.draft_hint') }} + @endif +

+
+ +
+ @if (! $version->isPublished()) + {{ __('plans.publish') }} + + {{ __('plans.discard') }} + + @elseif ($windowOpen) + + {{ __('plans.close_now') }} + + @endif +
+
+ + {{-- Publish form, inline under the draft it applies to --}} + @if ($publishing === $version->uuid) +
+

{{ __('plans.publish_warning') }}

+
+ + +
+
+ {{ __('plans.cancel') }} + {{ __('plans.publish_confirm') }} +
+
+ @endif + +
+ @foreach ([ + ['plans.f_storage', $version->quota_gb.' GB'], + ['plans.f_traffic', $version->traffic_gb.' GB'], + ['plans.f_seats', $version->seats], + ['plans.f_performance', __('billing.perf.'.($version->performance ?? 'standard'))], + ['plans.f_ram', $version->ram_mb.' MB'], + ['plans.f_cores', $version->cores], + ['plans.f_disk', $version->disk_gb.' GB'], + ['plans.f_template', $version->template_vmid ?? '—'], + ] as [$label, $value]) +
+
{{ __($label) }}
+
{{ $value }}
+
+ @endforeach +
+ +
+ @foreach ($version->prices->sortBy('term') as $price) + + {{ __('plans.term_'.$price->term) }} + {{ $eur($price->amount_cents) }} + {{ __('plans.net') }} + + @endforeach +
+ + @if ($version->features) +
+ @foreach ($version->features as $feature) + + {{ __('billing.feature.'.$feature) }} + + @endforeach +
+ @endif +
+ @empty +

+ {{ __('plans.no_versions') }} +

+ @endforelse +
+ + {{-- New draft --}} +
+

{{ __('plans.draft_title') }}

+

{{ __('plans.draft_body') }}

+ +
+
+ + + +
+ + {{-- A list, not free text: a typo here would be published, + frozen, and shown to customers as a raw key. --}} + + @error('performance')

{{ $message }}

@enderror +
+
+ +

{{ __('plans.infra') }}

+
+ + + + +
+ +

{{ __('plans.pricing') }}

+
+ + +
+ +

{{ __('plans.features') }}

+
+ @foreach ($featureKeys as $key) +
+ +
+ @endforeach +
+ + + {{ __('plans.draft_create') }} + +
+
+
+
diff --git a/resources/views/livewire/admin/plans.blade.php b/resources/views/livewire/admin/plans.blade.php new file mode 100644 index 0000000..588e1dd --- /dev/null +++ b/resources/views/livewire/admin/plans.blade.php @@ -0,0 +1,93 @@ +
+
+

{{ __('plans.title') }}

+

{{ __('plans.subtitle') }}

+
+ +
+ {{-- Plan lines --}} +
+ @if ($families->isEmpty()) +

{{ __('plans.empty') }}

+ @else +
+ + + + + + + + + + + + @foreach ($families as $family) + + + + + + + + @endforeach + +
{{ __('plans.plan') }}{{ __('plans.tier') }}{{ __('plans.live_version') }}{{ __('plans.status') }}{{ __('plans.actions') }}
+ {{ $family['name'] }} + {{ $family['key'] }} + @if ($family['drafts'] > 0) + + {{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }} + + @endif + {{ $family['tier'] }} + @if ($family['live']) + v{{ $family['live']->version }} + @if ($family['next']) + + {{ __('plans.next_from', ['version' => $family['next']->version, 'date' => $family['next']->available_from->isoFormat('L')]) }} + + @endif + @else + {{ __('plans.no_live_version') }} + @endif + + @php $selling = in_array($family['key'], $sellable, true); @endphp + + + + + {{ __('plans.manage_versions', ['count' => $family['versions_count']]) }} + + +
+
+ @endif +
+ + {{-- New plan line --}} +
+

{{ __('plans.new_title') }}

+

{{ __('plans.new_body') }}

+ +
+ + + + + + + + {{ __('plans.create') }} + + +
+
+
diff --git a/routes/web.php b/routes/web.php index 7f2c62e..f561059 100644 --- a/routes/web.php +++ b/routes/web.php @@ -80,6 +80,8 @@ Route::middleware(['admin.host', 'auth', 'admin'])->prefix('admin')->name('admin Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); + Route::get('/plans', Admin\Plans::class)->name('plans'); + Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions'); // POST so the identity change is CSRF-protected (a GET could be forced cross-site). Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php new file mode 100644 index 0000000..4b7fc79 --- /dev/null +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -0,0 +1,256 @@ +operator()->create(); +} + +function teamFamily(): PlanFamily +{ + return PlanFamily::query()->where('key', 'team')->sole(); +} + +it('is closed to operators without the capability', function () { + $viewer = User::factory()->operator('Support')->create(); + + // The route middleware lets any operator into the console; the capability + // is what decides who can see and change what the business sells. + Livewire::actingAs($viewer)->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + ->assertForbidden(); + + // Including the pages themselves — prices, drafts and unreleased plans are + // not something to leave readable to anyone who types the URL. + $this->actingAs($viewer)->get(route('admin.plans'))->assertForbidden(); + $this->actingAs($viewer)->get(route('admin.plans.versions', teamFamily()->uuid))->assertForbidden(); + + $this->actingAs(owner())->get(route('admin.plans'))->assertOk(); +}); + +it('creates a plan line, and refuses a key that is not an identifier', function () { + Livewire::actingAs(owner())->test(Plans::class) + ->set('key', 'Agentur Paket') + ->set('name', 'Agentur') + ->set('tier', 5) + ->call('save') + ->assertHasErrors('key'); + + Livewire::actingAs(owner())->test(Plans::class) + ->set('key', 'agency') + ->set('name', 'Agentur') + ->set('tier', 5) + ->call('save') + ->assertHasNoErrors(); + + $family = PlanFamily::query()->where('key', 'agency')->sole(); + + expect($family->name)->toBe('Agentur') + ->and($family->tier)->toBe(5) + // Nothing to sell yet: a family without a published version is not + // in the shop, whatever its switch says. + ->and(app(PlanCatalogue::class)->isSellable('agency'))->toBeFalse(); +}); + +it('takes a plan out of the shop without touching anyone\'s contract', function () { + $contract = Subscription::factory()->plan('team')->create(); + + Livewire::actingAs(owner())->test(Plans::class)->call('toggleSales', teamFamily()->uuid); + + expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse() + ->and(teamFamily()->sales_enabled)->toBeFalse() + // The customer keeps everything they bought. + ->and($contract->fresh()->price_cents)->toBe(17900) + ->and($contract->fresh()->status)->toBe('active'); + + Livewire::actingAs(owner())->test(Plans::class)->call('toggleSales', teamFamily()->uuid); + expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue(); +}); + +it('drafts a version prefilled from the last one, priced for both terms', function () { + $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + + // Prefilled, so "same plan, new price" is one edit rather than nine. + $page->assertSet('ramMb', 8192) + ->assertSet('quotaGb', 500) + ->assertSet('monthlyCents', 17900); + + $page->set('monthlyCents', 19900)->set('yearlyCents', 238800)->call('draft')->assertHasNoErrors(); + + $draft = teamFamily()->versions()->where('version', 2)->sole(); + + expect($draft->isPublished())->toBeFalse() + ->and($draft->ram_mb)->toBe(8192) + ->and($draft->priceFor('monthly')->amount_cents)->toBe(19900) + ->and($draft->priceFor('yearly')->amount_cents)->toBe(238800) + // A draft is not on sale, so the shop is unchanged. + ->and(app(PlanCatalogue::class)->sellable()['team']['price_cents'])->toBe(17900); +}); + +it('refuses a mistyped figure with a message instead of overflowing the column', function () { + Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + // A stray keystroke on the price field: unbounded, this overflows an + // unsigned int and the owner gets a 500 with no idea which number + // was wrong. + ->set('monthlyCents', 1790019900) + ->set('ramMb', 999999999) + ->call('draft') + ->assertHasErrors(['monthlyCents', 'ramMb']); + + expect(teamFamily()->versions()->count())->toBe(1); +}); + +it('does not call a version live when the plan is withdrawn', function () { + PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]); + + // The window is still open, but the kill switch overrides it — saying + // "on sale" here would contradict what the customer is actually offered. + Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + ->assertSee(__('plans.badge_withdrawn')) + ->assertSee(__('plans.family_withdrawn_note')) + ->assertDontSee(__('plans.badge_live')); +}); + +it('refuses a feature key we have no label for', function () { + Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + // The checkboxes only offer real keys, but a request can carry anything. + ->set('features', ['managed_updates', 'free_unicorns']) + ->call('draft') + ->assertHasErrors('features.1'); +}); + +it('refuses a performance class we have no label for', function () { + // Published, this would be frozen and shown to customers as a raw + // translation key for the life of the version. + Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + ->set('performance', 'enhnaced') + ->call('draft') + ->assertHasErrors('performance'); +}); + +it('refuses a disk smaller than the storage it has to hold', function () { + Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) + ->set('quotaGb', 500) + ->set('diskGb', 100) + ->call('draft') + ->assertHasErrors('diskGb'); +}); + +it('publishes a draft into a window, and refuses one that overlaps', function () { + $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page->set('monthlyCents', 19900)->call('draft'); + + $draft = teamFamily()->versions()->where('version', 2)->sole(); + + // Straight into the running version's window: refused, and said so on the + // form rather than thrown. + $page->call('choose', $draft->uuid) + ->set('availableFrom', now()->format('Y-m-d\TH:i')) + ->call('publish') + ->assertHasErrors('availableFrom'); + + expect($draft->fresh()->isPublished())->toBeFalse(); + + // Close the running one first, then the successor lands cleanly. + $live = app(PlanCatalogue::class)->currentVersion('team'); + $page->call('close', $live->uuid); + + $page->call('choose', $draft->uuid) + ->set('availableFrom', now()->addMinute()->format('Y-m-d\TH:i')) + ->call('publish') + ->assertHasNoErrors(); + + expect($draft->fresh()->isPublished())->toBeTrue() + ->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHour())->version)->toBe(2); +}); + +it('will not publish a window that ends before it starts', function () { + $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page->call('draft'); + + $draft = teamFamily()->versions()->where('version', 2)->sole(); + + $page->call('choose', $draft->uuid) + ->set('availableFrom', now()->addWeek()->format('Y-m-d\TH:i')) + ->set('availableUntil', now()->format('Y-m-d\TH:i')) + ->call('publish') + ->assertHasErrors('availableUntil'); +}); + +it('discards a draft through the modal, and never a published version', function () { + $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page->call('draft'); + + $draft = teamFamily()->versions()->where('version', 2)->sole(); + + Livewire::actingAs(owner())->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]) + ->call('delete'); + + expect(PlanVersion::query()->whereKey($draft->id)->exists())->toBeFalse(); + + // The published one cannot even open the dialog — customers are on it. + $live = app(PlanCatalogue::class)->currentVersion('team'); + Livewire::actingAs(owner())->test(ConfirmDeletePlanDraft::class, ['uuid' => $live->uuid]) + ->assertForbidden(); +}); + +it('does not discard a draft that was published while the dialog was open', function () { + $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page->call('draft'); + + $draft = teamFamily()->versions()->where('version', 2)->sole(); + + // The dialog opens on a draft... + $modal = Livewire::actingAs(owner())->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]); + + // ...and someone publishes it before the button is clicked. + $live = app(PlanCatalogue::class)->currentVersion('team'); + app(PlanCatalogue::class)->schedule($live, $live->available_from, now()); + app(PlanCatalogue::class)->publish($draft, now()); + + $modal->call('delete'); + + // Customers can be contracted to it by now, so it has to survive. + expect(PlanVersion::query()->whereKey($draft->id)->exists())->toBeTrue(); +}); + +it('authorises the modal itself, not just the page that opens it', function () { + $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page->call('draft'); + + $draft = teamFamily()->versions()->where('version', 2)->sole(); + $viewer = User::factory()->operator('Support')->create(); + + // Modals are reachable without the page's guards. + Livewire::actingAs($viewer)->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]) + ->assertForbidden(); +}); + +it('lists a withdrawn plan as withdrawn, not as missing', function () { + PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]); + + Livewire::actingAs(owner())->test(Plans::class) + ->assertSee('team') + ->assertSee(__('plans.withdrawn_badge')); +}); + +it('shows the console entry only to those who may use it', function () { + $this->actingAs(owner())->get(route('admin.overview'))->assertSee(__('admin.nav.plans')); + + $this->actingAs(User::factory()->operator('Support')->create()) + ->get(route('admin.overview')) + ->assertDontSee(__('admin.nav.plans')); +}); diff --git a/tests/Feature/Billing/PlanCatalogueTest.php b/tests/Feature/Billing/PlanCatalogueTest.php index aa9f04f..7f08b59 100644 --- a/tests/Feature/Billing/PlanCatalogueTest.php +++ b/tests/Feature/Billing/PlanCatalogueTest.php @@ -118,6 +118,51 @@ it('fixes the price of a published version, so a checkout cannot be repriced mid ->and($price->fresh()->amount_cents)->toBe(17900); }); +it('publishes a version once, even if two attempts arrive together', function () { + $catalogue = app(PlanCatalogue::class); + $family = PlanFamily::query()->where('key', 'team')->sole(); + $currency = Subscription::catalogueCurrency(); + + $live = $catalogue->currentVersion('team'); + $catalogue->schedule($live, $live->available_from, now()); + + $draft = $catalogue->draft($family, [ + 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, + 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, + 'features' => [], + ], ['monthly' => 18900, 'yearly' => 226800]); + + $catalogue->publish($draft, now(), now()->addYear()); + + // A second attempt on the same version must not sail past and overwrite the + // window the first one just set. + $stale = PlanVersion::query()->findOrFail($draft->id); + $stale->published_at = null; // as a racing request would still see it + + expect(fn () => $catalogue->publish($stale, now(), null)) + ->toThrow(RuntimeException::class, 'already published'); + + expect($draft->fresh()->available_until)->not->toBeNull(); +}); + +it('numbers drafts in sequence without colliding', function () { + $catalogue = app(PlanCatalogue::class); + $family = PlanFamily::query()->where('key', 'team')->sole(); + + $capabilities = [ + 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, + 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, + 'features' => [], + ]; + + $first = $catalogue->draft($family, $capabilities, ['monthly' => 18900, 'yearly' => 226800]); + $second = $catalogue->draft($family, $capabilities, ['monthly' => 19900, 'yearly' => 238800]); + + expect($first->version)->toBe(2) + ->and($second->version)->toBe(3) + ->and($second->priceFor('monthly')->amount_cents)->toBe(19900); +}); + it('refuses to delete a published version or its price, but lets a draft go', function () { $published = app(PlanCatalogue::class)->currentVersion('team'); @@ -284,6 +329,23 @@ it('lets an unpublished draft be edited freely', function () { expect($draft->fresh()->ram_mb)->toBe(12288); }); +it('will not sell a version that provisioning could not build', function () { + $catalogue = app(PlanCatalogue::class); + $family = PlanFamily::query()->where('key', 'team')->sole(); + + $draft = $catalogue->draft($family, [ + 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, + 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', + 'template_vmid' => null, // no blueprint + 'features' => [], + ], ['monthly' => 18900, 'yearly' => 226800]); + + // Otherwise it goes on sale, a customer pays, and the pipeline stops at + // CloneVirtualMachine with template_missing. + expect(fn () => $catalogue->publish($draft, now())) + ->toThrow(RuntimeException::class, 'VM template'); +}); + it('will not publish a version that is not priced for every term', function () { $family = PlanFamily::query()->where('key', 'team')->sole(); $draft = PlanVersion::query()->create([