244 lines
8.8 KiB
PHP
244 lines
8.8 KiB
PHP
<?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(),
|
|
]);
|
|
}
|
|
}
|