CluPilotCloud/app/Livewire/Admin/GrantPlan.php

294 lines
10 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Actions\GrantAddon;
use App\Actions\GrantSubscription;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\PlanCatalogue;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
use RuntimeException;
use Throwable;
/**
* Gives a customer a package, a module, or a discount — without a payment.
*
* Reuses GrantSubscription / GrantAddon for the actual mutation: this
* component is only the form. What it grants goes through the exact Order →
* Subscription/SubscriptionAddon rows a Stripe purchase would, so everything
* downstream (provisioning, quotas, cancellation, revenue) reads it the same
* way as a sale.
*/
class GrantPlan extends ModalComponent
{
public string $customerUuid = '';
public string $customerName = '';
/** Which form is on screen: a whole package, or a single module. */
public string $kind = 'package';
public bool $hasSubscription = false;
// Package fields.
public string $plan = '';
public string $term = Subscription::TERM_MONTHLY;
public string $datacenter = '';
public string $bonusQuotaGb = '';
public string $bonusSeats = '';
public string $bonusTrafficGb = '';
// Add-on fields.
public ?int $subscriptionId = null;
public string $addonKey = '';
public int $quantity = 1;
// Shared.
public string $priceEuros = '0';
public string $note = '';
/** A plain date — "until when", not "until what time". */
public string $until = '';
public function mount(string $uuid): void
{
$this->authorize('customers.grant_plan'); // modals are reachable without the route middleware
$customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
$this->customerUuid = $uuid;
$this->customerName = (string) $customer->name;
$this->datacenter = (string) (Datacenter::query()->active()->orderBy('code')->value('code') ?? '');
$subscription = Subscription::query()
->where('customer_id', $customer->id)
->where('status', 'active')
->latest('id')
->first();
$this->hasSubscription = $subscription !== null;
$this->subscriptionId = $subscription?->id;
// A customer with nothing yet can only be given a package — there is
// no contract to hang a module off.
$this->kind = $this->hasSubscription ? 'addon' : 'package';
}
/** Today's price for whatever is picked, so a discount reads as one. */
public function cataloguePriceCents(): ?int
{
try {
if ($this->kind === 'package' && $this->plan !== '') {
return app(PlanCatalogue::class)->currentVersion($this->plan)->priceFor($this->term)?->amount_cents;
}
if ($this->kind === 'addon' && $this->addonKey !== '') {
return app(AddonCatalogue::class)->priceCents($this->addonKey);
}
} catch (Throwable) {
return null;
}
return null;
}
public function grant(): void
{
$this->authorize('customers.grant_plan');
$operator = auth('operator')->user();
abort_if($operator === null, 403);
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
if ($this->kind === 'package') {
$this->grantPackage($customer, $operator);
} else {
$this->grantAddon($customer, $operator);
}
}
private function grantPackage(Customer $customer, $operator): void
{
$priceRule = function (string $attribute, mixed $value, \Closure $fail): void {
if (Money::toCents((string) $value) === null) {
$fail(__('admin.grant.price_invalid'));
}
};
$data = $this->validate([
'plan' => ['required', Rule::in(array_keys(app(PlanCatalogue::class)->sellable()))],
'term' => ['required', Rule::in([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY])],
'datacenter' => ['required', Rule::exists('datacenters', 'code')->where('active', true)],
'priceEuros' => ['required', 'string', $priceRule],
'bonusQuotaGb' => 'nullable|integer|min:0|max:1000000',
'bonusSeats' => 'nullable|integer|min:0|max:100000',
'bonusTrafficGb' => 'nullable|integer|min:0|max:10000000',
'note' => 'nullable|string|max:1000',
]);
try {
app(GrantSubscription::class)(
customer: $customer,
grantedBy: $operator,
plan: $data['plan'],
term: $data['term'],
datacenter: $data['datacenter'],
priceCents: Money::toCents($data['priceEuros']),
bonus: array_filter([
'quota_gb' => $data['bonusQuotaGb'] !== null && $data['bonusQuotaGb'] !== '' ? (int) $data['bonusQuotaGb'] : null,
'seats' => $data['bonusSeats'] !== null && $data['bonusSeats'] !== '' ? (int) $data['bonusSeats'] : null,
'traffic_gb' => $data['bonusTrafficGb'] !== null && $data['bonusTrafficGb'] !== '' ? (int) $data['bonusTrafficGb'] : null,
], fn ($value) => $value !== null),
note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null,
until: $this->parseUntil($this->until),
);
} catch (RuntimeException $e) {
$this->addError('priceEuros', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('admin.grant.granted'));
$this->closeModal();
}
private function grantAddon(Customer $customer, $operator): void
{
$priceRule = function (string $attribute, mixed $value, \Closure $fail): void {
if (Money::toCents((string) $value) === null) {
$fail(__('admin.grant.price_invalid'));
}
};
$data = $this->validate([
'addonKey' => ['required', Rule::in(array_keys((array) config('provisioning.addons')))],
'quantity' => 'required|integer|min:1|max:100',
'priceEuros' => ['required', 'string', $priceRule],
'note' => 'nullable|string|max:1000',
]);
// Re-resolved and re-checked, never trusted from the hydrated
// property: a forged request must not be able to book a module onto
// someone else's contract.
$subscription = Subscription::query()
->where('customer_id', $customer->id)
->whereKey($this->subscriptionId)
->first();
if ($subscription === null) {
$this->addError('addonKey', __('admin.grant.no_subscription'));
return;
}
try {
app(GrantAddon::class)(
subscription: $subscription,
grantedBy: $operator,
addonKey: $data['addonKey'],
priceCents: Money::toCents($data['priceEuros']),
quantity: $data['quantity'],
note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null,
until: $this->parseUntil($this->until),
);
} catch (RuntimeException $e) {
$this->addError('priceEuros', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('admin.grant.granted'));
$this->closeModal();
}
/**
* A date, read in the operator's own zone — the same pairing LocalTime
* keeps for a datetime-local field, minus the time of day this form does
* not ask for. "Until 15 August" means through the end of that day where
* the operator is sitting, not UTC midnight.
*/
private function parseUntil(string $value): ?Carbon
{
if (trim($value) === '') {
return null;
}
try {
return Carbon::parse($value, config('app.display_timezone'))->endOfDay()->utc();
} catch (Throwable) {
return null;
}
}
/** @return array<int, array<string, mixed>> */
private function existingGrants(int $customerId): array
{
$fromSubscriptions = Subscription::query()
->where('customer_id', $customerId)
->whereNotNull('granted_at')
->with('grantedBy')
->get()
->map(fn (Subscription $s) => [
'kind' => 'package',
'label' => __('billing.plan.'.$s->plan),
'price_cents' => $s->price_cents,
'catalogue_price_cents' => $s->catalogue_price_cents,
'granted_by' => $s->grantedBy?->name ?? '—',
'granted_at' => $s->granted_at,
'granted_until' => $s->granted_until,
'note' => $s->grant_note,
]);
$fromAddons = SubscriptionAddon::query()
->whereHas('subscription', fn ($q) => $q->where('customer_id', $customerId))
->whereNotNull('granted_at')
->with('grantedBy')
->get()
->map(fn (SubscriptionAddon $a) => [
'kind' => 'addon',
'label' => __('billing.addon.'.$a->addon_key.'.name'),
'price_cents' => $a->price_cents,
'catalogue_price_cents' => $a->catalogue_price_cents,
'granted_by' => $a->grantedBy?->name ?? '—',
'granted_at' => $a->granted_at,
'granted_until' => $a->granted_until,
'note' => $a->grant_note,
]);
return $fromSubscriptions->concat($fromAddons)
->sortByDesc('granted_at')
->values()
->all();
}
public function render()
{
// Read again rather than off the mounted properties: a second grant
// can have happened while this modal sat open (EditDatacenter's
// pattern), and the existing-grants list has to show it.
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
return view('livewire.admin.grant-plan', [
'plans' => app(PlanCatalogue::class)->sellable(),
'addons' => (array) config('provisioning.addons'),
'datacenters' => Datacenter::query()->active()->orderBy('code')->pluck('name', 'code'),
'cataloguePriceCents' => $this->cataloguePriceCents(),
'grants' => $this->existingGrants($customer->id),
]);
}
}