@extends('layouts.dashboard') @section('content') @php /** * Super-admin payouts — the settlement ledger. * * Three readings of the same money: what is still owed to restaurants, what has been gathered * onto a statement but not transferred, and what has actually been paid out this month. Every * figure comes from App\Models\Payout, which reads the commission stamped on each order rather * than the restaurant's current plan rate. * * Mirrors admin/subscriptions.php and admin/analytics-earnings.php — same KPI band, .qm-table * and modal idiom, shared component classes, no new CSS. * * @var array $runs payout statements, newest first * @var array $pager pagination metadata for $runs * @var array $owed restaurants with an unsettled balance, largest first * @var array $owedPager pagination metadata for $owed (its own ?owed_page cursor) * @var array $unpayable restaurants whose balance no run can send, with the reason code * @var array $outstanding platform-wide unsettled totals * @var array $inFlight statements prepared but not transferred * @var array $settled statements transferred since $monthStart * @var string $monthStart first day of the current month * @var string $status active status filter ('' = all) * @var array $trendChart settled value over the opening period * @var array $trendOpts period switcher: every window's series, swapped without a reload * @var array $trendMeta chart data flag + accessible summary */ $statusPill = static function (string $s): string { return match ($s) { 'paid' => 'qm-pill-green', 'issued' => 'qm-pill-blue', 'disputed' => 'qm-pill-red', default => 'qm-pill-amber', }; }; $filters = ['' => t_raw('common.all'), 'draft' => status_label('payout', 'draft'), 'issued' => status_label('payout', 'issued'), 'paid' => status_label('payout', 'paid'), 'disputed' => status_label('payout', 'disputed')]; // The money model decides what this page is. Owned installs settle with nobody, so the page // says so and points at consolidated revenue instead of pretending there are payouts to run. // Split payments overlays every model with the payout flow (the platform collects and disburses // each restaurant's share), so while it is on the page never shows the invoice or owned variants. $businessModel = $businessModel ?? 'marketplace'; $splitPayments = $splitPayments ?? false; $producesInvoices = ($producesInvoices ?? false) && !$splitPayments; $isOwned = $businessModel === 'owned' && !$splitPayments; $unpayable = $unpayable ?? []; // Two reasons a balance cannot be sent, and they have different owners: the restaurant fills in // its own payee, only the operator can turn a payout rail back on. The pill says whose move it is // — amber is waiting on the restaurant, red is waiting on us — reusing the status pill palette. /* rail_unfunded is nobody's mistake — the payee is right and the money came in elsewhere — so it takes the neutral grey rather than a colour that reads as a fault to be chased. */ $reasonPill = static fn (string $r): string => $r === 'gateway_disabled' ? 'qm-pill-red' : ($r === 'rail_unfunded' ? 'qm-pill-grey' : 'qm-pill-amber'); // The statements ledger below carries the active status filter onto its export link. $qs = http_build_query(array_filter(['status' => $status], static fn ($v) => (string) $v !== '')); @endphp @php /* .qm-d2: the dashboard density standard (app.css §35) */ @endphp
@if ($isOwned)

{{ t_raw('admin.payouts.owned_title') }}

{{ t_raw('admin.payouts.owned_sub') }}

{{ t_raw('nav.sales') }}
@php return; @endphp @endif @php /* KPIs lead the page; the section banner introduces the detail below them. The band reads the money model exactly as that banner does, off the same $producesInvoices: a franchise install is OWED a royalty by each restaurant — it owes them nothing and withholds nothing, because the franchisee collected on its own gateway — so the marketplace words state the inverse of the truth on the most-read part of the page, above a ledger that already says "Royalty invoices". Only the wording branches: the FIGURES are model-aware already, one layer down in Payout::eligibleWhere() / netExpr() / currentKind(), so the cards and the rows they summarise are counting the same money in every model. $producesInvoices carries the split-payments overlay (it is ANDed with !$splitPayments above), which is what keeps split-on franchise — where the platform collects and disburses each share — wearing the payout wording. */ @endphp
@partial('kpi-card', ['variant' => 'panel', 'accent' => 'rose', 'icon' => 'fa-hand-holding-usd', 'label' => t_raw($producesInvoices ? 'admin.payouts.kpi_owed_invoice' : 'admin.payouts.kpi_owed'), 'value' => money($outstanding['net_owed']), 'meta' => '' . t('admin.payouts.kpi_owed_cap', [':orders' => number_format($outstanding['orders_count'])]) . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'blue', 'icon' => 'fa-store', 'label' => t_raw($producesInvoices ? 'admin.payouts.kpi_owed_count_invoice' : 'admin.payouts.kpi_owed_count'), 'value' => number_format((int) ($owedPager['total'] ?? count($owed))), 'meta' => '' . t('admin.payouts.kpi_owed_count_cap') . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'amber', 'icon' => 'fa-file-invoice-dollar', 'label' => t_raw('admin.payouts.kpi_in_flight'), 'value' => money($inFlight['net_payable']), 'meta' => '' . t($producesInvoices ? 'admin.payouts.kpi_in_flight_cap_invoice' : 'admin.payouts.kpi_in_flight_cap', [':runs' => number_format($inFlight['runs'])]) . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'green', 'icon' => 'fa-money-check-alt', 'label' => t_raw('admin.payouts.kpi_settled'), 'value' => money($settled['net_payable']), 'meta' => '' . t($producesInvoices ? 'admin.payouts.kpi_settled_cap_invoice' : 'admin.payouts.kpi_settled_cap', [':runs' => number_format($settled['runs']), ':since' => fmt_day($monthStart, true)]) . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'violet', 'icon' => 'fa-percentage', 'label' => t_raw($producesInvoices ? 'admin.payouts.kpi_commission_invoice' : 'admin.payouts.kpi_commission'), 'value' => money($outstanding['commission_total']), 'meta' => '' . t('admin.payouts.kpi_commission_cap') . ''])

{{ t_raw($producesInvoices ? 'admin.payouts.title_invoice' : 'admin.payouts.title') }}

{{ t_raw($producesInvoices ? 'admin.payouts.intro_invoice' : 'admin.payouts.intro') }}

@if ($splitPayments)

{{ t_raw('admin.payouts.split_note') }}

@endif
@php /* The bars are Payout::settledTotals(), which is kind-scoped to what this install produces — so under franchise they are royalty INVOICES the restaurants settled, not value the platform transferred out. Same $producesInvoices branch as the KPI band; the chart's own accessible summary ($trendMeta['label']) is re-pointed in the controller for the same reason. */ @endphp @partial('chart-card', ['heading' => t_raw($producesInvoices ? 'admin.payouts.chart_settled_heading_invoice' : 'admin.payouts.chart_settled_heading'), 'sub' => t_raw($producesInvoices ? 'admin.payouts.chart_settled_sub_invoice' : 'admin.payouts.chart_settled_sub'), 'cfg' => $trendChart, 'meta' => $trendMeta, 'opts' => $trendOpts, 'icon' => 'fa-money-check-alt', 'accent' => 'green'])

{{ t_raw('admin.payouts.owed_title') }}

{{ t_raw('admin.payouts.owed_sub') }}

@if ($splitPayments && $owed)
@csrf
@endif
@php /* The figure under this head is Payout::netExpr() — takings minus commission in marketplace (owed TO the restaurant), the royalty itself in franchise (owed BY it, to the platform). "Net owed" leaves the direction to the reader and the reader assumes the payout one, so the franchise head names the money instead. */ @endphp @if ($owed) @foreach ($owed as $row) @php // Under split payments the primary action sends the restaurant its share now // (prepare + disburse); Prepare stays available to gather without sending. $rowActions = []; if ($splitPayments) { $rowActions[] = ['label' => t_raw('admin.payouts.pay_now'), 'icon' => 'fa-paper-plane', 'primary' => true, 'form' => [ 'action' => '/admin/payouts/dispatch', 'fields' => ['restaurant_id' => (int) $row['restaurant_id']], 'confirm' => t_raw('admin.payouts.confirm_pay_now'), ]]; } $rowActions[] = ['label' => t_raw('admin.payouts.prepare_btn'), 'icon' => 'fa-layer-group', 'primary' => !$splitPayments, 'form' => [ 'action' => '/admin/payouts/prepare', 'fields' => ['restaurant_id' => (int) $row['restaurant_id']], ]]; @endphp @endforeach @else @partial('table-empty', ['colspan' => 7, 'icon' => 'fa-check-circle', 'msg' => t('admin.payouts.owed_empty')]) @endif
{{ t_raw('admin.payouts.owed_title') }}
{{ t_raw('common.restaurant') }} {{ t_raw('common.orders') }} {{ t_raw('admin.payouts.col_gross') }} {{ t_raw('common.commission') }}{{ t_raw($producesInvoices ? 'admin.payouts.col_net_owed_invoice' : 'admin.payouts.col_net_owed') }} {{ t_raw('admin.payouts.col_oldest_order') }} {{ t_raw('common.actions') }}
{!! restaurant_brandmark($row, 'md') !!}
{{ (string) $row['restaurant_name'] }}
@if (!empty($row['plan_name'])){!! plan_tag((string) ($row['plan_slug'] ?? ''), (string) $row['plan_name']) !!}@else{{ t_raw('admin.subscriptions.no_plan') }}@endif @if (!empty($row['open_payout_id'])) {{ t_raw('admin.payouts.draft_open_link') }} @endif
{{ number_format((int) $row['orders_count']) }} {{ money($row['gross_sales']) }} {{ money($row['commission_total']) }} {{ money($row['net_owed']) }} {!! fmt_day($row['oldest_order_at']) !!} @partial('row-actions', ['inline' => 1, 'actions' => $rowActions])
@partial('table-toolbar', ['pager' => $owedPager, 'base' => '/admin/payouts'])
@php /* The other half of the balances above: money that is owed but that no run can send, with the reason it is stuck. A restaurant with no payee is skipped by dispatch (one broken payee must never stall the rest of the run), which used to make it disappear — its share sat on a draft statement with nothing anywhere saying why. Rendered whenever there is something stuck, and always while split payments is disbursing, where "nothing is stuck" is itself worth stating; Payout::unpayableVendors() reports only in that mode. */ @endphp @if ($splitPayments || $unpayable)

{{ t_raw('admin.payouts.unpayable_title') }}

{{ t_raw('admin.payouts.unpayable_sub') }}

@if ($unpayable) @foreach ($unpayable as $row) @php $reason = (string) $row['reason'] === 'gateway_disabled' ? 'gateway_disabled' : 'no_payee'; @endphp @endforeach @else @partial('table-empty', ['colspan' => 4, 'icon' => 'fa-check-circle', 'msg' => t('admin.payouts.unpayable_empty')]) @endif
{{ t_raw('admin.payouts.unpayable_title') }}
{{ t_raw('common.restaurant') }} {{ t_raw('admin.payouts.col_reason') }} {{ t_raw('common.amount') }} {{ t_raw('common.actions') }}
{{ (string) $row['restaurant_name'] }} @php // What is actually on file, so the operator can see the difference between "nothing set" and "set to a rail we cannot send on". @endphp
{!! (string) $row['payout_account'] !== '' ? e((string) $row['payout_account']) : t('admin.restaurantdetail.payee_missing') !!}
{{ t_raw('admin.payouts.unpayable_' . $reason) }}
{{ t_raw('admin.payouts.unpayable_' . $reason . '_hint') }}
{{ money($row['amount']) }} @php // The payee lives on the restaurant's own record, which is where this is chased from. @endphp @partial('row-actions', ['inline' => 1, 'actions' => [ ['label' => t_raw('common.view'), 'icon' => 'fa-store', 'href' => '/admin/restaurants/' . (int) $row['restaurant_id'], 'primary' => true], ]])
@endif
@php /* Payout::runs() is scoped to the kind this install produces, so this card lists royalty invoices under franchise and payout statements under marketplace. The card head has to say which, or the ledger keeps the payout wording over rows that are the restaurant's debt to the platform — the same contradiction the kind scoping removed from the rows themselves. */ @endphp

{{ t_raw($producesInvoices ? 'admin.payouts.runs_title_invoice' : 'admin.payouts.runs_title') }}

{{ t_raw($producesInvoices ? 'admin.payouts.runs_sub_invoice' : 'admin.payouts.runs_sub') }}

{{ t_raw('common.export_csv') }}
@php /* Period is a date range (two values), not one Date.parse-able cell, so that carries no data-sort — the same rule admin/subscriptions.php follows. */ @endphp @if ($runs) @foreach ($runs as $run) @php $st = (string) $run['status']; @endphp @endforeach @else @partial('table-empty', ['colspan' => 9, 'icon' => 'fa-money-check-alt', 'msg' => t($producesInvoices ? 'admin.payouts.runs_empty_invoice' : 'admin.payouts.runs_empty')]) @endif
{{ t_raw('admin.payouts.runs_title') }}
{{ t_raw('admin.payouts.col_run') }} {{ t_raw('common.restaurant') }} {{ t_raw('common.period') }} {{ t_raw('common.orders') }} {{ t_raw('admin.payouts.col_gross') }} {{ t_raw('common.commission') }} {{ t_raw('admin.payouts.col_net_payable') }} {{ t_raw('common.status') }} {{ t_raw('common.actions') }}
#{{ (int) $run['id'] }}
{!! restaurant_brandmark($run, 'md') !!}
{{ (string) $run['restaurant_name'] }}
{!! or_dash($run['payment_ref'] ?? null) !!}
{!! fmt_day($run['period_start']) !!} – {!! fmt_day($run['period_end']) !!} {{ number_format((int) $run['orders_count']) }} {{ money($run['gross_sales']) }} {{ money($run['commission_total']) }} {{ money($run['net_payable']) }} {{ status_label('payout', $st) }} @if ($st === 'paid' && !empty($run['paid_at'])){!! fmt_day($run['paid_at']) !!}@endif @partial('row-actions', ['actions' => [ ['label' => t_raw('admin.payouts.view_run'), 'icon' => 'fa-eye', 'href' => '/admin/payouts/' . (int) $run['id'], 'primary' => true], $st === 'draft' ? ['label' => t_raw('admin.payouts.issue_btn'), 'icon' => 'fa-paper-plane', 'form' => [ 'action' => '/admin/payouts/settle', 'fields' => ['payout_id' => (int) $run['id'], 'status' => 'issued'], 'confirm' => t_raw('admin.payouts.confirm_issue'), ]] : null, ]])
@partial('table-toolbar', ['pager' => $pager, 'base' => '/admin/payouts'])
@php /* /.qm-d2 */ @endphp @endsection