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'), ], ], 'planCharts' => $this->planCharts(), 'payments' => $this->recentPayments($locale), ]); } /** * Recurring revenue per currency, off the 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. * * @return array, contracts:int}> */ private function recurringTotals(): array { $totals = []; Subscription::query() ->where('status', 'active') ->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, contracts:int}> $totals * @param callable(array{cents:int, customers:array, 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. * * 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}> */ private function planCharts(): array { $byCurrency = []; Subscription::query() ->where('status', 'active') ->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; } /** * 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> */ 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(); } }