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([