*/ 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'; public string $yearlyPrice = '588,00'; /** 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->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900); $this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800); } } 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')); } }], 'yearlyPrice' => ['required', 'string', $price], ]); $monthlyCents = Money::toCents($data['monthlyPrice']); $yearlyCents = Money::toCents($data['yearlyPrice']); $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 => $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 = 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(), ]); } }