263 lines
9.7 KiB
PHP
263 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Support\LocalTime;
|
|
use App\Models\PlanFamily;
|
|
use App\Models\PlanVersion;
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use App\Support\Money;
|
|
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 = [];
|
|
|
|
/**
|
|
* 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 = 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]));
|
|
}
|
|
|
|
#[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(),
|
|
]);
|
|
}
|
|
}
|