Files
itflow/agent/reports/recurring_by_client.php
johnnyq 94413424e4 Expenses stage 2: recurring expenses on a real schedule - monthly, quarterly or yearly on the day of the month they started on with the 31st falling back to a shorter month's last day, an optional end date for leases and financing after which they stop by themselves, the nightly run recording each one due today or missed by up to 7 nights dated the day it was due and telling the team about each with a link to it, one notice the day one falls too far behind in place of the nightly nag, Record Now dated today when early or the due date when late and safe to press twice, Move On to skip missed runs, Pause and Resume, a rebuilt list with cost a month, the next 30 days, running and need a look along the top, Active, Next 7 days, Late, Paused, Ended and All pills and vendor, category and client filters, a quick look with the schedule in words, the next dates, this year's total and the expenses it recorded, one New and Edit form that says the schedule as it is set and fills in from the vendor's last expense, recorded expenses carrying the payment method and reference and linked back with a filter and icon on the expenses list and a Recorded by line in their quick look, the dashboard figure, cash flow forecast and expense, recurring revenue and spend by vendor reports counting quarterly ones and end dates, the vendor page opening the quick look, demo data standing costs on their own days with insurance quarterly and linked to their history, fixes for edits moving the next date, new monthly ones starting in the picked month, recorded expenses losing the payment method, no way to pause and the list's date filter using the creation date, delete now needing full financial access, database 3.0.0 with frequency as month, quarter or year, anchor day and end date, the old day and month columns dropped and expense_recurring_expense_id backfilled, CHANGELOG updated
2026-10-16 18:02:00 -04:00

199 lines
11 KiB
PHP

<?php
/*
* Recurring Revenue - what active recurring invoices bring in, as monthly
* recurring revenue (a yearly invoice counts a twelfth a month) and a year
* of it: per client, per income category, and every recurring invoice with
* when it next bills. With financial access, recurring expenses come off it
* for what is left each month. The same figure as the dashboard's
* Recurring revenue.
*/
require_once "includes/inc_report.php";
reportEnforce('recurring_by_client');
$client_id = reportClientId();
$can_see_costs = reportPermission('module_financial') >= 1;
$invoices = [];
$sql = mysqli_query($mysqli, "SELECT recurring_invoice_id, recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_scope, recurring_invoice_frequency,
recurring_invoice_next_date, recurring_invoice_amount, recurring_invoice_auto_send, recurring_invoice_created_at,
recurring_invoice_client_id, client_name, client_abbreviation, category_name, category_id
FROM recurring_invoices
JOIN clients ON recurring_invoice_client_id = client_id
LEFT JOIN categories ON recurring_invoice_category_id = category_id
WHERE recurring_invoice_status = 1 AND recurring_invoice_archived_at IS NULL
" . reportClientSql('recurring_invoice_client_id', $client_id) . "
ORDER BY client_name ASC, recurring_invoice_next_date ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$amount = floatval($row['recurring_invoice_amount']);
$row['monthly'] = recurringInvoiceMonthly($amount, $row['recurring_invoice_frequency']);
$invoices[] = $row;
}
$clients = [];
$categories = [];
foreach ($invoices as $invoice) {
$id = intval($invoice['recurring_invoice_client_id']);
$clients[$id] = $clients[$id] ?? ['client_id' => $id, 'client' => $invoice['client_name'], 'abbr' => $invoice['client_abbreviation'],
'count' => 0, 'mrr' => 0.0, 'next' => null, 'since' => null];
$clients[$id]['count']++;
$clients[$id]['mrr'] += $invoice['monthly'];
$clients[$id]['next'] = $clients[$id]['next'] === null ? $invoice['recurring_invoice_next_date'] : min($clients[$id]['next'], $invoice['recurring_invoice_next_date']);
$since = substr($invoice['recurring_invoice_created_at'], 0, 10);
$clients[$id]['since'] = $clients[$id]['since'] === null ? $since : min($clients[$id]['since'], $since);
$cat = $invoice['category_name'] ?? 'No category';
$categories[$cat] = ($categories[$cat] ?? 0) + $invoice['monthly'];
}
$mrr = array_sum(array_column($clients, 'mrr'));
foreach ($clients as &$row) {
$row['arr'] = $row['mrr'] * 12;
$row['share'] = reportShare($row['mrr'], $mrr, 1);
}
unset($row);
uasort($clients, function ($a, $b) { return $b['mrr'] <=> $a['mrr']; });
$clients = array_values($clients);
$client_columns = [
'client' => ['label' => 'Client', 'html' => function ($row) {
return reportClientCell($row['client_id'], $row['client'], $row['abbr'], 'recurring_invoices.php');
}],
'count' => ['label' => 'Recurring invoices', 'type' => 'count'],
'mrr' => ['label' => 'Per month', 'type' => 'money', 'class' => 'fw-semibold'],
'arr' => ['label' => 'Per year', 'type' => 'money', 'class' => 'text-body-secondary'],
'share' => ['label' => 'Share', 'type' => 'percent', 'html' => function ($row) {
return '<div class="d-flex align-items-center justify-content-end gap-2"><div class="itflow-report-meter d-none d-lg-flex" style="width: 5rem"><span style="width: ' . min(100, floatval($row['share'])) . '%"></span></div>'
. '<span class="small text-body-secondary">' . reportPercent($row['share']) . '</span></div>';
}],
'next' => ['label' => 'Next bills', 'type' => 'date', 'class' => 'text-nowrap'],
'since' => ['label' => 'Recurring since', 'type' => 'date', 'class' => 'text-nowrap text-body-secondary'],
];
$invoice_columns = [
'number' => ['label' => 'Recurring invoice', 'csv' => function ($row) { return $row['recurring_invoice_prefix'] . $row['recurring_invoice_number']; }, 'html' => function ($row) {
return '<a class="font-monospace" href="/agent/recurring_invoice.php?client_id=' . intval($row['recurring_invoice_client_id']) . '&recurring_invoice_id=' . intval($row['recurring_invoice_id']) . '">' . escapeHtml($row['recurring_invoice_prefix'] . $row['recurring_invoice_number']) . '</a>';
}],
'client_name' => ['label' => 'Client', 'html' => function ($row) {
return reportClientCell($row['recurring_invoice_client_id'], $row['client_name'], $row['client_abbreviation'], 'recurring_invoices.php');
}],
'recurring_invoice_scope' => ['label' => 'Scope', 'class' => 'small itflow-report-text'],
'category_name' => ['label' => 'Category', 'class' => 'small text-body-secondary'],
'recurring_invoice_frequency' => ['label' => 'Every', 'csv' => function ($row) { return ucfirst(recurringInvoicePeriodWord($row['recurring_invoice_frequency'])); }, 'html' => function ($row) {
return ucfirst(recurringInvoicePeriodWord($row['recurring_invoice_frequency']));
}],
'recurring_invoice_amount' => ['label' => 'Amount', 'type' => 'money'],
'monthly' => ['label' => 'Per month', 'type' => 'money', 'class' => 'fw-semibold'],
'recurring_invoice_next_date' => ['label' => 'Next bills', 'type' => 'date', 'class' => 'text-nowrap'],
'auto' => ['label' => 'Sends', 'csv' => function ($row) { return intval($row['recurring_invoice_auto_send']) ? 'Automatically' : 'Draft for review'; }, 'html' => function ($row) {
return intval($row['recurring_invoice_auto_send']) ? '<span class="small text-body-secondary">Automatically</span>' : '<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis fw-normal">Draft first</span>';
}],
];
if (reportExporting('invoices')) {
reportCsv('Recurring Invoices', $invoice_columns, $invoices, ['number' => 'Total', 'monthly' => $mrr]);
}
if (reportExporting()) {
reportCsv('Recurring Revenue by Client', $client_columns, $clients, ['client' => 'Total', 'count' => count($invoices), 'mrr' => $mrr, 'arr' => $mrr * 12]);
}
// What comes off it every month
$costs = 0.0;
if ($can_see_costs && !$client_id) {
$costs = recurringExpenseMonthlyTotal();
}
// What bills in the next 30 days
$next_30 = 0.0;
$limit = date('Y-m-d', strtotime('+30 days'));
foreach ($invoices as $invoice) {
if ($invoice['recurring_invoice_next_date'] <= $limit) {
$next_30 += floatval($invoice['recurring_invoice_amount']);
}
}
require_once "includes/inc_report_layout.php";
arsort($categories);
$cat_rows = reportTopN(array_map(function ($label, $value) { return ['label' => $label, 'value' => $value]; }, array_keys($categories), array_values($categories)), 6);
$cat_colors = array_map(function ($i) { return reportPalette($i); }, array_keys($cat_rows));
foreach ($cat_rows as $i => $row) {
if ($row['label'] === 'Other') {
$cat_colors[$i] = 'faint';
}
}
$stats = [
['label' => 'Recurring revenue', 'value' => reportMoney($mrr), 'sub' => '<span class="text-body-tertiary">Per month</span>', 'url' => '/agent/recurring_invoices.php'],
['label' => 'A year of it', 'value' => reportMoney($mrr * 12), 'sub' => '<span class="text-body-tertiary">At today\'s recurring invoices</span>'],
['label' => 'Recurring invoices', 'value' => reportCount(count($invoices)), 'sub' => reportCount(count($clients)) . ' <span class="text-body-tertiary">client' . (count($clients) === 1 ? '' : 's') . ', ' . (count($clients) ? escapeHtml(reportMoney($mrr / count($clients))) : '-') . ' each a month</span>'],
];
if ($can_see_costs && !$client_id) {
$stats[] = ['label' => 'After recurring expenses', 'value' => reportMoney($mrr - $costs), 'tone' => $mrr - $costs < 0 ? 'danger' : '',
'sub' => escapeHtml(reportMoney($costs)) . ' <span class="text-body-tertiary">a month goes out</span>', 'url' => '/agent/recurring_expenses.php'];
} else {
$stats[] = ['label' => 'Bills in the next 30 days', 'value' => reportMoney($next_30), 'sub' => '<span class="text-body-tertiary">From these recurring invoices</span>'];
}
?>
<div class="itflow-report">
<?php reportHeader('recurring_by_client', [
'client' => $client_id,
'subtitle' => 'As of ' . date('M j, Y'),
'export' => ['clients' => 'By client', 'invoices' => 'Every recurring invoice'],
]); ?>
<?= reportStats($stats) ?>
<?php if ($invoices) { ?>
<div class="row g-3">
<div class="col-lg-4">
<?= reportSectionOpen('By category', 'Per month', 'h-100 mb-0') ?>
<div class="card-body">
<?= reportChart([
'type' => 'doughnut',
'labels' => array_column($cat_rows, 'label'),
'series' => [['label' => 'Per month', 'data' => array_map(function ($r) { return round($r['value'], 2); }, $cat_rows)]],
'colors' => $cat_colors,
'format' => 'money',
], 'donut', 'Recurring revenue by category') ?>
<div class="mt-3"><?= reportLegendHtml(array_column($cat_rows, 'label'), array_column($cat_rows, 'value'), $cat_colors) ?></div>
</div>
</div>
</div>
<div class="col-lg-8">
<?php $top = array_slice($clients, 0, 12); ?>
<?= reportSectionOpen('Biggest recurring clients', 'Per month', 'h-100 mb-0') ?>
<div class="card-body">
<?= reportChart([
'type' => 'bar',
'horizontal' => true,
'labels' => array_map(function ($c) { return $c['client']; }, $top),
'series' => [['label' => 'Per month', 'data' => array_map(function ($c) { return round($c['mrr'], 2); }, $top), 'tone' => 'accent']],
'format' => 'money',
'legend' => false,
], '', 'Recurring revenue per month for the biggest clients') ?>
</div>
</div>
</div>
</div>
<?php } ?>
<?= reportSectionOpen('By client', 'Most per month first') ?>
<?= reportTable($client_columns, $clients, [
'empty' => 'No active recurring invoices.',
'totals' => ['client' => 'Total', 'count' => count($invoices), 'mrr' => $mrr, 'arr' => $mrr * 12],
]) ?>
</div>
<?= reportSectionOpen('Every recurring invoice', reportCount(count($invoices)) . ' active', 'itflow-report-long') ?>
<?= reportTable($invoice_columns, $invoices, ['empty' => 'No active recurring invoices.', 'limit' => 300, 'export_table' => 'invoices', 'totals' => ['number' => 'Total', 'monthly' => $mrr]]) ?>
<div class="card-footer small text-body-secondary">
Active recurring invoices only. A yearly invoice counts a twelfth of its amount each month.
</div>
</div>
</div>
<?php require_once "../../includes/footer.php"; ?>
<script src="/js/reports.js"></script>