294 lines
10 KiB
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),
|
|
]);
|
|
}
|
|
}
|