226 lines
9.1 KiB
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();
|
|
}
|
|
}
|