CluPilotCloud/app/Livewire/Admin/Revenue.php

226 lines
9.1 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Revenue, from the contracts and the commercial register.
*
* What was here before was a story: €7,842 MRR, 1.8 % churn, a twelve-month
* curve, four payments from customers who do not exist. All of it is gone.
*
* Two figures that used to be shown are not shown any more, because there is
* nothing to compute them from and an estimate would be indistinguishable from
* a measurement:
*
* - the MRR trend, which needs a monthly revenue history nobody records;
* - churn, which needs cancellations over a period, and cancelled_at alone
* does not say what the base was.
*
* ARR is shown, but only as what it is: this month's recurring revenue times
* twelve, labelled as a projection rather than as measured turnover.
*/
#[Layout('layouts.admin')]
class Revenue extends Component
{
public function render()
{
$locale = app()->getLocale();
$totals = $this->recurringTotals();
return view('livewire.admin.revenue', [
'kpis' => [
[
'label' => __('admin.rev.mrr'),
'value' => $this->format($totals, fn (array $t) => $t['cents'] / 100, $locale),
'sub' => __('admin.rev.mrr_sub'),
],
[
'label' => __('admin.rev.arr'),
'value' => $this->format($totals, fn (array $t) => $t['cents'] * 12 / 100, $locale),
'sub' => __('admin.rev.arr_sub'),
],
[
'label' => __('admin.rev.arpu'),
'value' => $this->format(
$totals,
fn (array $t) => count($t['customers']) > 0 ? $t['cents'] / count($t['customers']) / 100 : 0,
$locale,
),
'sub' => __('admin.rev.arpu_sub'),
],
[
'label' => __('admin.rev.contracts'),
'value' => (string) array_sum(array_column($totals, 'contracts')),
'sub' => __('admin.rev.contracts_sub'),
],
[
// Shown separately rather than folded into the figures
// above: a granted contract at 0 must not drag ARPU down
// or read as revenue, and the owner measures himself on
// this number too.
'label' => __('admin.rev.granted'),
'value' => (string) $this->grantedCount(),
'sub' => __('admin.rev.granted_sub'),
],
],
'planCharts' => $this->planCharts(),
'payments' => $this->recentPayments($locale),
]);
}
/**
* Recurring revenue per currency, off the PAYING contracts.
*
* Never summed across currencies. Frozen contract prices, not the
* catalogue, so a price rise does not retroactively inflate what
* grandfathered customers are reported to pay. Add-ons included and yearly
* terms divided — totalMonthlyCents() owns both of those rules.
*
* Granted contracts are excluded here, not merely zeroed: a contract with
* `granted_at` set is a gift or a discount an operator handed out, not a
* sale, and counting it — even at 0 — would inflate the contracts count
* this average is divided by and understate ARPU for everyone else.
*
* @return array<string, array{cents:int, customers:array<int,bool>, contracts:int}>
*/
private function recurringTotals(): array
{
$totals = [];
Subscription::query()
->where('status', 'active')
->whereNull('granted_at')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$totals) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$totals[$currency] ??= ['cents' => 0, 'customers' => [], 'contracts' => 0];
$totals[$currency]['cents'] += $subscription->totalMonthlyCents();
$totals[$currency]['contracts']++;
// A customer with two contracts is one customer — ARPU
// divided by contracts would understate it.
$totals[$currency]['customers'][(int) $subscription->customer_id] = true;
}
});
return $totals;
}
/**
* @param array<string, array{cents:int, customers:array<int,bool>, contracts:int}> $totals
* @param callable(array{cents:int, customers:array<int,bool>, contracts:int}): float $amount
*/
private function format(array $totals, callable $amount, string $locale): string
{
if ($totals === []) {
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
}
return collect($totals)
->map(fn (array $t, string $currency) => Number::currency($amount($t), in: $currency, locale: $locale))
->implode(' · ');
}
/**
* Recurring revenue split by plan — one chart per currency.
*
* By what customers are ON, not by what is on sale: a withdrawn plan still
* bills, and leaving it out would understate the total the chart sits next
* to. Granted contracts are excluded — see recurringTotals().
*
* Per currency, because a doughnut adds its slices together. Two currencies
* in one ring produces a total that is not an amount of anything, and it
* looks exactly as convincing as a correct one.
*
* @return array<int, array{currency:string, config:array<string,mixed>}>
*/
private function planCharts(): array
{
$byCurrency = [];
Subscription::query()
->where('status', 'active')
->whereNull('granted_at')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$plan = (string) $subscription->plan;
$byCurrency[$currency][$plan] = ($byCurrency[$currency][$plan] ?? 0)
+ $subscription->totalMonthlyCents();
}
});
$charts = [];
foreach ($byCurrency as $currency => $byPlan) {
arsort($byPlan);
$charts[] = [
'currency' => $currency,
'config' => [
'type' => 'doughnut',
'data' => [
'labels' => array_map(fn (string $key) => __('billing.plan.'.$key), array_keys($byPlan)),
'datasets' => [[
'data' => array_map(fn (int $cents) => round($cents / 100, 2), array_values($byPlan)),
'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
];
}
return $charts;
}
/** How many active contracts are gifts or discounts, not sales. */
private function grantedCount(): int
{
return Subscription::query()->where('status', 'active')->whereNotNull('granted_at')->count();
}
/**
* The last payments actually recorded.
*
* Both kinds count: an ordinary billing cycle is written as `renewal`, and
* `invoice_paid` is reserved for everything else Stripe charges for — a
* proration, a manual invoice. Listing only the second would show a
* payments panel with no ordinary payments in it.
*
* Gross, because that is what left the customer's account. The register
* holds net and tax separately for the books.
*
* @return array<int, array<string, string>>
*/
private function recentPayments(string $locale): array
{
return SubscriptionRecord::query()
->whereIn('event', [SubscriptionRecord::EVENT_RENEWAL, SubscriptionRecord::EVENT_INVOICE_PAID])
->orderByDesc('occurred_at')
->limit(8)
->get()
->map(fn (SubscriptionRecord $r) => [
'customer' => $r->customer_name ?: '—',
'amount' => Number::currency(
(int) ($r->gross_cents ?: $r->net_cents) / 100,
in: strtoupper((string) ($r->currency ?: Subscription::catalogueCurrency())),
locale: $locale,
),
'when' => $r->occurred_at?->local()->translatedFormat('d. MMM') ?? '—',
])
->all();
}
}