141 lines
6.2 KiB
PHP
141 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\PlanFamily;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use App\Services\Billing\WithdrawalRight;
|
|
use Illuminate\Support\Number;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Component;
|
|
|
|
#[Layout('layouts.admin')]
|
|
class Customers extends Component
|
|
{
|
|
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
|
|
public function toggleSuspend(string $uuid): void
|
|
{
|
|
$this->authorize('customers.manage');
|
|
$customer = Customer::query()->where('uuid', $uuid)->first();
|
|
if ($customer === null) {
|
|
return;
|
|
}
|
|
|
|
// A closed account is terminal — the suspend/reactivate toggle must not
|
|
// resurrect it to active while closed_at is still set.
|
|
if ($customer->closed_at !== null || $customer->status === 'closed') {
|
|
return;
|
|
}
|
|
|
|
$customer->update([
|
|
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
|
|
]);
|
|
|
|
$this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated')));
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$locale = app()->getLocale();
|
|
$plans = app(PlanCatalogue::class)->sellable();
|
|
|
|
$customers = Customer::query()
|
|
->with(['instances.subscription'])
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$rows = $customers->map(function (Customer $c) use ($plans, $locale) {
|
|
$instance = $c->instances->sortByDesc('id')->first();
|
|
$planKey = $instance->plan ?? null;
|
|
// What this customer actually pays, off their contract — not what
|
|
// the plan costs today. Otherwise a price rise inflates reported
|
|
// revenue for every grandfathered customer overnight.
|
|
//
|
|
// Per MONTH, whatever the term: a yearly contract stores the whole
|
|
// year, and adding that to a monthly column would report twelve
|
|
// times the revenue for anyone who paid up front.
|
|
$contract = $instance?->subscription;
|
|
|
|
// The customer is already loaded on this row, so hand it to the
|
|
// contract rather than letting WithdrawalRight fetch it again —
|
|
// that is one query per customer on a page that lists all of them.
|
|
$contract?->setRelation('customer', $c);
|
|
|
|
$priceCents = (int) ($contract?->monthlyPriceCents()
|
|
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
|
|
|
|
return [
|
|
'uuid' => $c->uuid,
|
|
'name' => $c->name,
|
|
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
|
|
// A gift or a discount, not a sale — flagged here rather than
|
|
// inferred from the price, which a genuinely cheap plan could
|
|
// also show.
|
|
'granted' => (bool) $contract?->isGranted(),
|
|
// What this package is about to become. An operator answering a
|
|
// question about a customer's plan has to be able to see that
|
|
// it is booked to shrink at the end of the term, or they will
|
|
// quote today's package for a bill that is already scheduled to
|
|
// change. The MRR beside it is still today's, which is correct:
|
|
// nothing has moved yet.
|
|
'pending_change' => $contract?->hasPendingPlanChange()
|
|
? [
|
|
'plan' => __('billing.plan.'.$contract->pending_plan),
|
|
'at' => $contract->pending_effective_at->local()->isoFormat('LL'),
|
|
]
|
|
: null,
|
|
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
|
|
'instance' => $instance->subdomain ?? '—',
|
|
// Only an instance that exists can hand out an admin login.
|
|
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
|
|
// Whether a withdrawal taken by telephone or post could still be
|
|
// recorded for this customer. Only a hint for the button: the
|
|
// refusal that counts is made again inside WithdrawContract, so
|
|
// a stale list cannot let one through.
|
|
'withdrawal_open' => WithdrawalRight::for($contract)->open,
|
|
'closed' => $c->closed_at !== null || $c->status === 'closed',
|
|
'suspended' => $c->status === 'suspended',
|
|
'status' => match (true) {
|
|
$c->closed_at !== null || $c->status === 'closed' => 'closed',
|
|
$c->status === 'suspended' => 'suspended',
|
|
default => $instance->status ?? $c->status ?? 'provisioning',
|
|
},
|
|
];
|
|
})->all();
|
|
|
|
// Plan distribution for the doughnut — derived from live instances, and
|
|
// keyed by what customers are actually ON, not by what is on sale. A
|
|
// withdrawn plan still has customers, and dropping them from the chart
|
|
// would quietly understate the estate.
|
|
$labels = [];
|
|
$counts = [];
|
|
foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
|
|
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
|
|
if ($n > 0) {
|
|
$labels[] = __('billing.plan.'.$planKey);
|
|
$counts[] = $n;
|
|
}
|
|
}
|
|
|
|
return view('livewire.admin.customers', [
|
|
'rows' => $rows,
|
|
'plansChart' => [
|
|
'type' => 'doughnut',
|
|
'data' => [
|
|
'labels' => $labels,
|
|
'datasets' => [[
|
|
'data' => $counts,
|
|
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'],
|
|
'borderWidth' => 0,
|
|
]],
|
|
],
|
|
'options' => [
|
|
'cutout' => '62%',
|
|
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
}
|