*/ public array $features = []; /** * In EUROS, as typed. The catalogue stores cents; asking the owner to type * them too turned €799 into "79900", where one slipped digit is a factor of * ten on an invoice. Money::toCents does the conversion, once. */ public string $monthlyPrice = '49,00'; /** * How many of the twelve months a year costs nothing. * * The yearly TOTAL used to be typed here as a second free-form figure, and * nothing then knew why 588 belonged to a package costing 49 — so no page * could say "two months free" without somebody working it out again by hand. * The total is derived from this (see yearlyCents), which is also the only * arrangement in which the sentence a customer reads and the amount Stripe * charges cannot drift apart. */ public int $freeMonths = 2; /** 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); $this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900); $this->freeMonths = (int) $latest->free_months; } } /** * What a year costs: the months that are paid for, at the monthly price. * * One place, static, so the preview on the form and the amount written to * the catalogue are the same arithmetic rather than two copies of it. */ public static function yearlyCents(int $monthlyCents, int $freeMonths): int { return $monthlyCents * (12 - max(0, min(12, $freeMonths))); } 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')))], // Euros with at most two decimals, either separator. The upper // bound lives in Money's pattern: nine whole digits is far past // anything we would charge and far short of overflowing the column. // Checked as a rule, not afterwards: a price fault has to be // reported alongside every other fault on the form, and a check // that runs after validate() never runs at all once some other // field has already thrown. 'monthlyPrice' => ['required', 'string', $price = function (string $attribute, mixed $value, callable $fail) { if (Money::toCents((string) $value) === null) { $fail(__('plans.price_invalid')); } }], // Nought is a legitimate answer — "a year costs twelve months, pay // it in one go" — and the upper bound stops at six, because eleven // free months is a typo and not a discount. 'freeMonths' => 'required|integer|min:0|max:6', ]); $monthlyCents = Money::toCents($data['monthlyPrice']); $yearlyCents = self::yearlyCents((int) $monthlyCents, (int) $data['freeMonths']); $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']), 'free_months' => (int) $data['freeMonths'], ], // 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 => $monthlyCents, Subscription::TERM_YEARLY => $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 = LocalTime::toField(now()); $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, LocalTime::fromField($this->availableFrom), LocalTime::fromField($this->availableUntil), ); } 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])); } /** * Put a closed version back on sale — the way out of a closed window. * * The likely refusal here is that a newer version has taken over, and the * catalogue says so in English, at the length a log entry deserves. Which * of its two refusals it was is read off the version itself rather than out * of the message text: a message is prose, and prose changes. */ public function reopen(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)->reopen($version); } catch (RuntimeException) { $this->addError('availableFrom', __( $version->isPublished() ? 'plans.reopen_blocked' : 'plans.reopen_draft' )); return; } $this->dispatch('notify', message: __('plans.reopened', ['version' => $version->version])); } #[On('plan-draft-deleted')] public function refresh(): void { // Livewire re-renders; the listener exists so the modal can say so. } /** * The version that publishing at the chosen moment would take off sale. * * Announced on the form, because publishing now ends a running version's * sale instead of being refused, and the owner should not have to work that * out from the version list afterwards. Asked of the catalogue rather than * worked out here, so the announcement and the act cannot name different * versions. * * @return array{version: int, at: string}|null */ private function handover(PlanFamily $family): ?array { if ($this->publishing === null) { return null; } $version = $family->versions()->where('uuid', $this->publishing)->first(); $from = LocalTime::fromField($this->availableFrom); if ($version === null || $from === null) { return null; } $predecessor = app(PlanCatalogue::class)->predecessorAt($version, $from); return $predecessor === null ? null : [ 'version' => $predecessor->version, 'at' => $from->local()->isoFormat('L LT'), ]; } 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, 'handover' => $this->handover($family), 'featureKeys' => array_keys((array) __('billing.feature')), 'performanceClasses' => (array) __('billing.perf'), 'currency' => Subscription::catalogueCurrency(), // What a year will cost, worked out here so the form shows the same // figure it is about to write. Null while the monthly price is not a // number yet — the field says so on its own, and a preview of // nothing is worse than no preview. 'yearlyPreview' => ($cents = Money::toCents($this->monthlyPrice)) === null ? null : self::yearlyCents((int) $cents, $this->freeMonths), ]); } }