feat(admin): a console for creating, pricing and scheduling plans

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 12:41:51 +02:00
parent 6387c747d0
commit f6ddf45dc0
18 changed files with 1338 additions and 5 deletions

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanVersion;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for discarding an unpublished plan version.
*
* Only a draft can be discarded at all a published version is what customers
* are contracted to, and the model refuses to delete one. So this is a small
* destructive action, and the modal says which one it is rather than leaving
* "delete" next to a row that might be either.
*/
class ConfirmDeletePlanDraft extends ModalComponent
{
public string $uuid;
public int $version;
public string $plan = '';
public function mount(string $uuid): void
{
// Modals are reachable without the page's guards, so this is the real
// check, not a convenience one.
$this->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');
}
}

View File

@ -0,0 +1,243 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use RuntimeException;
/**
* The versions of one plan: what it can do, what it costs, and when it sells.
*
* The page is built around the one rule that matters here a draft can be
* edited freely, a published version cannot be touched at all. So drafting and
* publishing are separate acts, and publishing says plainly that it is final.
*/
#[Layout('layouts.admin')]
class PlanVersions extends Component
{
public string $uuid;
/** The draft being written. */
public int $quotaGb = 100;
public int $trafficGb = 1000;
public int $seats = 5;
public int $ramMb = 4096;
public int $cores = 2;
public int $diskGb = 120;
public string $performance = 'standard';
public ?int $templateVmid = 9000;
/** @var array<int, string> */
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(),
]);
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The plan lines we sell, and whether each is currently on sale.
*
* Deliberately shallow: a family is a name and a rank, and everything that can
* be got wrong capabilities, prices, windows lives one level down on the
* versions, where publishing makes it permanent.
*/
#[Layout('layouts.admin')]
class Plans extends Component
{
#[Validate(['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'])]
public string $key = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|integer|min:0|max:255')]
public int $tier = 1;
public function mount(): void
{
// The page itself, not only its buttons. Hiding the nav entry and
// guarding the mutations still leaves the whole catalogue — prices,
// drafts, unreleased plans — readable to any operator who types the URL.
$this->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()),
]);
}
}

View File

@ -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<string, mixed> $capabilities
* @param array<string, int> $pricesByTerm
*/
public function draft(PlanFamily $family, array $capabilities, array $pricesByTerm): PlanVersion
{
$currency = Subscription::catalogueCurrency();
return DB::transaction(function () use ($family, $capabilities, $pricesByTerm, $currency) {
PlanFamily::query()->whereKey($family->getKey())->lockForUpdate()->firstOrFail();
$version = $family->versions()->create([
...$capabilities,
'version' => (int) $family->versions()->max('version') + 1,
// A placeholder: publication sets the real window, and until
// then the version is not available at any time anyway.
'available_from' => now(),
]);
foreach ($pricesByTerm as $term => $amount) {
$version->prices()->create([
'term' => $term,
'amount_cents' => $amount,
'currency' => $currency,
]);
}
return $version;
});
}
/**
* Discard a draft but only if it is still a draft.
*
* One statement, conditional on `published_at` still being null. Checking
* in PHP and deleting afterwards can delete a version that was published in
* between, and a published version must never be removed: contracts point
* at it. Returns false when the row was published in the meantime.
*/
public function discardDraft(PlanVersion $version): bool
{
return PlanVersion::query()
->whereKey($version->getKey())
->whereNull('published_at')
->delete() > 0;
}
/**
* Move a version's availability window.
*
@ -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);
});

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* `plans.manage` create plans, publish versions, set what is on sale and when,
* and pull a plan from the shop.
*
* Owner and Admin only. This is what the business charges for: publishing a
* version fixes terms that customers are then contracted to, and the kill
* switch decides whether anything can be bought at all.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->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();
}
};

View File

@ -11,6 +11,7 @@ return [
'instances' => 'Instanzen',
'hosts' => 'Hosts',
'datacenters' => 'Rechenzentren',
'plans' => 'Pakete',
'provisioning' => 'Provisioning',
'maintenance' => 'Wartungen',
'vpn' => 'VPN',

87
lang/de/plans.php Normal file
View File

@ -0,0 +1,87 @@
<?php
return [
'title' => '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)',
];

View File

@ -11,6 +11,7 @@ return [
'instances' => 'Instances',
'hosts' => 'Hosts',
'datacenters' => 'Datacenters',
'plans' => 'Plans',
'provisioning' => 'Provisioning',
'maintenance' => 'Maintenance',
'vpn' => 'VPN',

87
lang/en/plans.php Normal file
View File

@ -0,0 +1,87 @@
<?php
return [
'title' => '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)',
];

View File

@ -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)<span>{{ $label }}</span>@endif
{{ $slot }}

View File

@ -33,6 +33,7 @@
'box' => '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
'bell' => '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
'arrow-left' => '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
'tag' => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'rotate-ccw' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',

View File

@ -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],

View File

@ -0,0 +1,19 @@
<div class="p-6">
<div class="flex items-start gap-4">
<div class="grid size-10 shrink-0 place-items-center rounded-full border border-danger-border bg-danger-bg">
<x-ui.icon name="alert-triangle" class="size-5 text-danger" />
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">
{{ __('plans.discard_title', ['plan' => $plan, 'version' => $version]) }}
</h3>
<p class="mt-1 text-sm text-muted">{{ __('plans.discard_body') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('plans.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="delete" wire:loading.attr="disabled">
{{ __('plans.discard_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,195 @@
@php
$eur = fn (int $cents) => Illuminate\Support\Number::currency($cents / 100, in: $currency, locale: app()->getLocale());
@endphp
<div class="space-y-5">
<div class="animate-rise">
<a href="{{ route('admin.plans') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
<x-ui.icon name="arrow-left" class="size-3.5" />
{{ __('plans.back') }}
</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight text-ink">{{ $family->name }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('plans.versions_subtitle') }}</p>
</div>
@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. --}}
<div class="rounded-lg border border-warning-border bg-warning-bg p-4 text-sm text-body animate-rise">
{{ __('plans.family_withdrawn_note') }}
</div>
@endunless
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_380px] xl:items-start">
{{-- Versions --}}
<div class="space-y-4 animate-rise [animation-delay:60ms]">
@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
<div wire:key="v-{{ $version->uuid }}" class="rounded-xl border border-line bg-surface p-5 shadow-xs
{{ $live ? 'border-success-border' : '' }}">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h2 class="text-base font-semibold text-ink">{{ __('plans.version', ['n' => $version->version]) }}</h2>
@if ($live)
<x-ui.badge status="active">{{ __('plans.badge_live') }}</x-ui.badge>
@elseif ($windowOpen)
<x-ui.badge status="suspended">{{ __('plans.badge_withdrawn') }}</x-ui.badge>
@elseif ($scheduled)
<x-ui.badge status="pending">{{ __('plans.badge_scheduled') }}</x-ui.badge>
@elseif ($version->isPublished())
<x-ui.badge status="closed">{{ __('plans.badge_closed') }}</x-ui.badge>
@else
<x-ui.badge status="warning">{{ __('plans.badge_draft') }}</x-ui.badge>
@endif
</div>
<p class="mt-1 text-xs text-muted">
@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
</p>
</div>
<div class="flex gap-2">
@if (! $version->isPublished())
<x-ui.button size="sm" wire:click="choose('{{ $version->uuid }}')">{{ __('plans.publish') }}</x-ui.button>
<x-ui.button variant="secondary" size="sm"
x-on:click="Livewire.dispatch('openModal', { component: 'admin.confirm-delete-plan-draft', arguments: { uuid: '{{ $version->uuid }}' } })">
{{ __('plans.discard') }}
</x-ui.button>
@elseif ($windowOpen)
<x-ui.button variant="secondary" size="sm" wire:click="close('{{ $version->uuid }}')">
{{ __('plans.close_now') }}
</x-ui.button>
@endif
</div>
</div>
{{-- Publish form, inline under the draft it applies to --}}
@if ($publishing === $version->uuid)
<div class="mt-4 rounded-lg border border-line-strong bg-surface-2 p-4">
<p class="text-sm text-body">{{ __('plans.publish_warning') }}</p>
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<x-ui.input name="availableFrom" type="datetime-local"
:label="__('plans.available_from')" wire:model="availableFrom" />
<x-ui.input name="availableUntil" type="datetime-local"
:label="__('plans.available_until')" :hint="__('plans.available_until_hint')"
wire:model="availableUntil" />
</div>
<div class="mt-4 flex justify-end gap-2">
<x-ui.button variant="secondary" size="sm" wire:click="cancelPublish">{{ __('plans.cancel') }}</x-ui.button>
<x-ui.button size="sm" wire:click="publish" wire:loading.attr="disabled">{{ __('plans.publish_confirm') }}</x-ui.button>
</div>
</div>
@endif
<dl class="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-4">
@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])
<div>
<dt class="text-xs uppercase tracking-wide text-faint">{{ __($label) }}</dt>
<dd class="text-body">{{ $value }}</dd>
</div>
@endforeach
</dl>
<div class="mt-4 flex flex-wrap gap-4 border-t border-line pt-3 text-sm">
@foreach ($version->prices->sortBy('term') as $price)
<span class="text-body">
<span class="text-xs uppercase tracking-wide text-faint">{{ __('plans.term_'.$price->term) }}</span>
<span class="ml-1.5 font-semibold text-ink">{{ $eur($price->amount_cents) }}</span>
<span class="text-xs text-faint">{{ __('plans.net') }}</span>
</span>
@endforeach
</div>
@if ($version->features)
<div class="mt-3 flex flex-wrap gap-1.5">
@foreach ($version->features as $feature)
<span class="rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs text-muted">
{{ __('billing.feature.'.$feature) }}
</span>
@endforeach
</div>
@endif
</div>
@empty
<p class="rounded-xl border border-line bg-surface p-8 text-center text-sm text-muted shadow-xs">
{{ __('plans.no_versions') }}
</p>
@endforelse
</div>
{{-- New draft --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="text-base font-semibold text-ink">{{ __('plans.draft_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('plans.draft_body') }}</p>
<form wire:submit="draft" class="mt-4 space-y-4">
<div class="grid grid-cols-2 gap-3">
<x-ui.input name="quotaGb" type="number" min="1" max="1000000" :label="__('plans.f_storage')" wire:model="quotaGb" />
<x-ui.input name="trafficGb" type="number" min="0" max="10000000" :label="__('plans.f_traffic')" wire:model="trafficGb" />
<x-ui.input name="seats" type="number" min="1" max="100000" :label="__('plans.f_seats')" wire:model="seats" />
<div class="space-y-1.5">
<label for="performance" class="block text-sm font-medium text-body">{{ __('plans.f_performance') }}</label>
{{-- A list, not free text: a typo here would be published,
frozen, and shown to customers as a raw key. --}}
<select id="performance" wire:model="performance"
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink">
@foreach ($performanceClasses as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
@error('performance')<p class="text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.infra') }}</p>
<div class="grid grid-cols-2 gap-3">
<x-ui.input name="ramMb" type="number" min="512" max="4194304" :label="__('plans.f_ram')" wire:model="ramMb" />
<x-ui.input name="cores" type="number" min="1" max="512" :label="__('plans.f_cores')" wire:model="cores" />
<x-ui.input name="diskGb" type="number" min="1" max="1000000" :label="__('plans.f_disk')" :hint="__('plans.disk_hint')" wire:model="diskGb" />
<x-ui.input name="templateVmid" type="number" min="100" max="999999999" :label="__('plans.f_template')" wire:model="templateVmid" />
</div>
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.pricing') }}</p>
<div class="grid grid-cols-2 gap-3">
<x-ui.input name="monthlyCents" type="number" min="0" max="999999999" :label="__('plans.term_monthly')" :hint="__('plans.cents_hint')" wire:model="monthlyCents" />
<x-ui.input name="yearlyCents" type="number" min="0" max="999999999" :label="__('plans.term_yearly')" :hint="__('plans.cents_hint')" wire:model="yearlyCents" />
</div>
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.features') }}</p>
<div class="space-y-2">
@foreach ($featureKeys as $key)
<div wire:key="feat-{{ $key }}">
<x-ui.checkbox :name="'feature-'.$key" wire:model="features" value="{{ $key }}"
:label="__('billing.feature.'.$key)" />
</div>
@endforeach
</div>
<x-ui.button type="submit" class="w-full" wire:loading.attr="disabled">
{{ __('plans.draft_create') }}
</x-ui.button>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,93 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('plans.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('plans.subtitle') }}</p>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{{-- Plan lines --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
@if ($families->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('plans.empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('plans.plan') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('plans.tier') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('plans.live_version') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('plans.status') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('plans.actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($families as $family)
<tr wire:key="plan-{{ $family['uuid'] }}" class="border-b border-line last:border-0">
<td class="px-4 py-3">
<span class="font-semibold text-ink">{{ $family['name'] }}</span>
<span class="ml-1.5 font-mono text-xs text-faint">{{ $family['key'] }}</span>
@if ($family['drafts'] > 0)
<span class="ml-2 rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs text-muted">
{{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }}
</span>
@endif
</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $family['tier'] }}</td>
<td class="px-4 py-3 text-body">
@if ($family['live'])
v{{ $family['live']->version }}
@if ($family['next'])
<span class="block text-xs text-faint">
{{ __('plans.next_from', ['version' => $family['next']->version, 'date' => $family['next']->available_from->isoFormat('L')]) }}
</span>
@endif
@else
<span class="text-faint">{{ __('plans.no_live_version') }}</span>
@endif
</td>
<td class="px-4 py-3">
@php $selling = in_array($family['key'], $sellable, true); @endphp
<button type="button" wire:click="toggleSales('{{ $family['uuid'] }}')"
class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
{{ $selling ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $selling ? __('plans.on_sale') : ($family['sales_enabled'] ? __('plans.nothing_available') : __('plans.withdrawn_badge')) }}
</button>
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('admin.plans.versions', $family['uuid']) }}" wire:navigate>
<x-ui.button variant="secondary" size="sm">
{{ __('plans.manage_versions', ['count' => $family['versions_count']]) }}
</x-ui.button>
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
{{-- New plan line --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="text-base font-semibold text-ink">{{ __('plans.new_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('plans.new_body') }}</p>
<form wire:submit="save" class="mt-4 space-y-4">
<x-ui.input name="key" :label="__('plans.key')" :hint="__('plans.key_hint')"
wire:model="key" autocomplete="off" />
<x-ui.input name="name" :label="__('plans.name')" wire:model="name" autocomplete="off" />
<x-ui.input name="tier" type="number" min="0" max="255"
:label="__('plans.tier')" :hint="__('plans.tier_hint')" wire:model="tier" />
<x-ui.button type="submit" class="w-full" wire:loading.attr="disabled">
{{ __('plans.create') }}
</x-ui.button>
</form>
</div>
</div>
</div>

View File

@ -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');

View File

@ -0,0 +1,256 @@
<?php
use App\Livewire\Admin\ConfirmDeletePlanDraft;
use App\Livewire\Admin\PlanVersions;
use App\Livewire\Admin\Plans;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Billing\PlanCatalogue;
use Livewire\Livewire;
/**
* The console is where the owner creates and schedules plans. Everything the
* catalogue refuses has to be refused here too, visibly an admin page that
* lets someone try and then throws is worse than one that explains.
*/
function owner(): User
{
return User::factory()->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'));
});

View File

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