ConorBradley Posted Tuesday at 03:08 PM Share Posted Tuesday at 03:08 PM (edited) Hi All, I put together a small WHMCS Admin Home Widget that summarises profit/loss from your WHMCS transactions and presents it in your base currency, including UK tax-year reporting and a simple toggle for invoice-only vs all transactions. I noticed that there was no way to do this in WHMCS as it uses January-December as the year. What it shows The widget displays totals (base currency) for: This Month (month-to-date) Previous Month (full month) Tax Year to Date (UK tax year: 6 Apr → 5 Apr) Last Year’s Tax YTD (same “elapsed point” comparison) It also shows an “Average per month” row for Income / Expenses / Profit: This Month: month-to-date totals (shown as “per month” for consistency; updates daily) Previous Month: totals for the full month Tax YTD: divided by tax months elapsed (UK tax months run 6th → 5th) Last Year’s Tax YTD: calculated as a full-year average (÷ 12) (so you get a completed-year monthly average) How the numbers are calculated It pulls data from tblaccounts and uses: Income = Amount In Expenses = Fees + Amount Out Profit = Amount In − Fees − Amount Out Important: This is based on what’s recorded in WHMCS transactions. If your real business expenses live in Xero/QuickBooks/bank statements and aren’t entered into WHMCS as “Amount Out”, they won’t appear here. Multi-currency handling WHMCS stores transactions per client currency; the widget groups by currency and converts everything back into your base currency using tblcurrencies.rate, then displays base totals. Mode toggle (Invoice-linked vs All transactions) At the top of the widget there’s a mode toggle: Invoice-linked: includes only transactions linked to an invoice (invoiceid > 0) Usually represents customer payments/refunds + gateway fees. All transactions: includes everything in tblaccounts (including manual entries / “Amount Out” etc.) The selection persists via a cookie/session (pl_mode) and the widget is set to no cache so switching updates instantly. UK tax year behaviour UK tax year is 6 April to 5 April The widget calculates Tax YTD from the current tax-year start to “now” For comparison, “Last Year’s Tax YTD” uses the same elapsed point into the previous tax year (e.g., if we’re 10 tax months into the current year, it compares the first 10 tax months of last year) The “tax months elapsed” counter follows UK tax-month boundaries (6th → 5th), so the elapsed count can change on the 6th of each month Installation Create a file like: whmcs/includes/hooks/profit_loss_widget.php Paste the code in. Visit the WHMCS admin dashboard (Home). You can drag/reorder widgets as normal. Code Is Below: <?php if (!defined("WHMCS")) { die("No direct access"); } use WHMCS\Database\Capsule; use Carbon\Carbon; add_hook('AdminHomeWidgets', 1, function () { return new class extends \WHMCS\Module\AbstractWidget { protected $title = 'Profit / Loss (Month, Prev Month, UK Tax Year)'; protected $description = 'Base-currency totals only. Amount In − Fees − Amount Out. UK tax year 6 Apr – 5 Apr.'; protected $weight = 150; protected $columns = 2; // wider widget (set 1/2/3 to suit your dashboard) protected $cache = false; protected $cacheExpiry = 0; protected $requiredPermission = ''; public function getData() { try { $now = Carbon::now(); // Mode: 'invoice' (payments & refunds only) or 'all' (includes withdrawals/expenditures) $mode = $this->getMode(); $onlyInvoiceLinked = ($mode === 'invoice'); $opts = ['onlyInvoiceLinked' => $onlyInvoiceLinked]; // Calendar months $startMonth = $now->copy()->startOfMonth(); $prevMonthStart = $now->copy()->subMonth()->startOfMonth(); $prevMonthEnd = $now->copy()->subMonth()->endOfMonth(); // UK tax year (6 Apr – 5 Apr) $tyStart = $this->ukTaxYearStart($now); $tyEnd = $tyStart->copy()->addYear()->subDay()->endOfDay(); // 5 Apr end-of-day // Last tax year (full) $lastTyStart = $tyStart->copy()->subYear(); $lastTyEnd = $lastTyStart->copy()->addYear()->subDay()->endOfDay(); // 5 Apr end-of-day // Query per-currency then convert to base totals $monthRows = $this->sumProfitByCurrency($startMonth, $now, $opts); // this month-to-date $prevMonthRows = $this->sumProfitByCurrency($prevMonthStart, $prevMonthEnd, $opts); // full previous month // Current tax year is "to date" (can’t include future) $taxYearToDateRows = $this->sumProfitByCurrency($tyStart, $now, $opts); // Last tax year is full 12 months $lastTaxYearFullRows = $this->sumProfitByCurrency($lastTyStart, $lastTyEnd, $opts); $base = Capsule::table('tblcurrencies')->where('default', 1)->first(); // --- Averages per month (income, expenses, profit) --- // Month periods: treat as 1 month each (this month-to-date still counts as a month per your request) $monthTotalsMulti = $this->toBaseTotalsMulti($monthRows, $base); $prevMonthTotalsMulti = $this->toBaseTotalsMulti($prevMonthRows, $base); $monthAvg = [ 'income' => $this->avgPerMonth($monthTotalsMulti['amount_in'], 1), 'expenses' => $this->avgPerMonth($monthTotalsMulti['fees'] + $monthTotalsMulti['amount_out'], 1), 'profit' => $this->avgPerMonth($monthTotalsMulti['profit'], 1), 'months' => 1, ]; $prevMonthAvg = [ 'income' => $this->avgPerMonth($prevMonthTotalsMulti['amount_in'], 1), 'expenses' => $this->avgPerMonth($prevMonthTotalsMulti['fees'] + $prevMonthTotalsMulti['amount_out'], 1), 'profit' => $this->avgPerMonth($prevMonthTotalsMulti['profit'], 1), 'months' => 1, ]; // Current tax year average uses UK tax-months elapsed (6th → 5th) $tyMonths = $this->ukTaxMonthsElapsed($tyStart, $now); $taxYearTotalsMulti = $this->toBaseTotalsMulti($taxYearToDateRows, $base); $lastTaxYearTotalsMulti = $this->toBaseTotalsMulti($lastTaxYearFullRows, $base); $taxYearAvg = [ 'income' => $this->avgPerMonth($taxYearTotalsMulti['amount_in'], $tyMonths), 'expenses' => $this->avgPerMonth($taxYearTotalsMulti['fees'] + $taxYearTotalsMulti['amount_out'], $tyMonths), 'profit' => $this->avgPerMonth($taxYearTotalsMulti['profit'], $tyMonths), 'months' => $tyMonths, ]; // Last tax year is a completed year: always 12 months $lastTaxYearAvg = [ 'income' => $this->avgPerMonth($lastTaxYearTotalsMulti['amount_in'], 12), 'expenses' => $this->avgPerMonth($lastTaxYearTotalsMulti['fees'] + $lastTaxYearTotalsMulti['amount_out'], 12), 'profit' => $this->avgPerMonth($lastTaxYearTotalsMulti['profit'], 12), 'months' => 12, ]; // --- /Averages --- return [ 'totals' => [ 'month' => $this->toBaseTotal($monthRows, $base), 'prev_month' => $this->toBaseTotal($prevMonthRows, $base), 'tax_year' => $this->toBaseTotal($taxYearToDateRows, $base), // current tax year to date 'last_tax_year' => $this->toBaseTotal($lastTaxYearFullRows, $base), // last tax year full 'base' => $base ? (array)$base : ['code'=>'','prefix'=>'','suffix'=>''], ], 'averages' => [ 'month' => $monthAvg, 'prev_month' => $prevMonthAvg, 'tax_year' => $taxYearAvg, 'last_tax_year' => $lastTaxYearAvg, ], 'meta' => [ 'month_label' => $startMonth->format('M Y'), 'prev_month_label' => $prevMonthStart->format('M Y'), 'tax_year_label' => $this->formatTaxYearLabel($tyStart), 'tax_year_full_range' => $tyStart->format('j M Y') . ' – ' . $tyEnd->format('j M Y'), 'tax_year_asof' => $now->format('j M Y'), 'last_tax_year_label' => $this->formatTaxYearLabel($lastTyStart), 'last_tax_year_full_range' => $lastTyStart->format('j M Y') . ' – ' . $lastTyEnd->format('j M Y'), 'generated' => $now->toDayDateTimeString(), 'mode' => $mode, // 'invoice' or 'all' ], ]; } catch (\Throwable $e) { return ['error' => $e->getMessage(), 'meta' => ['generated' => date('r')]]; } } /** Persist/return the current mode (invoice|all) using GET → session/cookie. */ private function getMode() { $valid = ['invoice', 'all']; $get = isset($_GET['pl_mode']) ? strtolower((string)$_GET['pl_mode']) : null; if ($get && in_array($get, $valid, true)) { if (isset($_SESSION)) { $_SESSION['pl_mode'] = $get; } @setcookie('pl_mode', $get, time() + 31536000, '/'); // 1 year return $get; } if (isset($_SESSION['pl_mode']) && in_array($_SESSION['pl_mode'], $valid, true)) { return $_SESSION['pl_mode']; } if (isset($_COOKIE['pl_mode']) && in_array($_COOKIE['pl_mode'], $valid, true)) { return $_COOKIE['pl_mode']; } return 'invoice'; } /** Build a link to switch mode while preserving the current path & query. */ private function toggleUrl($toMode) { $uri = $_SERVER['REQUEST_URI'] ?? 'index.php'; $parts = parse_url($uri); $path = $parts['path'] ?? 'index.php'; $q = []; if (!empty($parts['query'])) { parse_str($parts['query'], $q); } $q['pl_mode'] = $toMode; $qs = http_build_query($q); return $path . ($qs ? '?' . $qs : ''); } private function ukTaxYearStart(Carbon $date) { $tyStart = Carbon::create($date->year, 4, 6, 0, 0, 0, $date->timezone); if ($date->lt($tyStart)) { $tyStart->subYear(); } return $tyStart->startOfDay(); } private function formatTaxYearLabel(Carbon $tyStart) { $startYear = (int)$tyStart->format('Y'); $endYY = (int)$tyStart->copy()->addYear()->format('y'); return sprintf('%d/%02d', $startYear, $endYY); } /** * UK tax months elapsed in the tax year (tax months run 6th → 5th). * Returns an integer 1..12. */ private function ukTaxMonthsElapsed(Carbon $tyStart, Carbon $to) { if ($to->lt($tyStart)) { return 0; } $startY = (int)$tyStart->format('Y'); $startM = 4; // tax year starts in April $toY = (int)$to->format('Y'); $toM = (int)$to->format('n'); $toD = (int)$to->format('j'); $diffMonths = (($toY - $startY) * 12) + ($toM - $startM); $monthsElapsed = $diffMonths + (($toD >= 6) ? 1 : 0); if ($monthsElapsed < 1) { $monthsElapsed = 1; } if ($monthsElapsed > 12) { $monthsElapsed = 12; } return $monthsElapsed; } /** Aggregate per currency for a period; optionally invoice-linked only. */ private function sumProfitByCurrency(Carbon $from, Carbon $to, array $opts = []) { $onlyInvoices = !empty($opts['onlyInvoiceLinked']); $q = Capsule::table('tblaccounts as a') ->leftJoin('tblclients as cl', 'cl.id', '=', 'a.userid') ->leftJoin('tblcurrencies as cur', 'cur.id', '=', 'cl.currency') ->whereBetween('a.date', [$from->toDateTimeString(), $to->toDateTimeString()]); if ($onlyInvoices) { $q->where('a.invoiceid', '>', 0); } $rows = $q->groupBy('cl.currency', 'cur.code', 'cur.prefix', 'cur.suffix', 'cur.rate') ->orderBy('cur.code') ->get([ Capsule::raw('cl.currency as currencyId'), Capsule::raw('cur.code as code'), Capsule::raw('cur.prefix as prefix'), Capsule::raw('cur.suffix as suffix'), Capsule::raw('cur.rate as rate'), Capsule::raw('SUM(a.amountin) as amount_in'), Capsule::raw('SUM(a.fees) as fees'), Capsule::raw('SUM(a.amountout) as amount_out'), Capsule::raw('SUM(a.amountin - a.fees - a.amountout) as profit'), ]); $defaultCurrency = Capsule::table('tblcurrencies')->where('default', 1)->first(); return $rows->map(function ($r) use ($defaultCurrency) { $code = $r->code ?? ($defaultCurrency->code ?? 'BASE'); $prefix = $r->prefix ?? ($defaultCurrency->prefix ?? ''); $suffix = $r->suffix ?? ($defaultCurrency->suffix ?? ''); $rate = isset($r->rate) ? (float)$r->rate : (float)($defaultCurrency->rate ?? 1.0); $cid = isset($r->currencyId) ? (int)$r->currencyId : (int)($defaultCurrency->id ?? 0); return [ 'currencyId' => $cid, 'code' => (string)$code, 'prefix' => (string)$prefix, 'suffix' => (string)$suffix, 'rate' => (float)$rate, 'amount_in' => (float)$r->amount_in, 'fees' => (float)$r->fees, 'amount_out' => (float)$r->amount_out, 'profit' => (float)$r->profit, ]; })->all(); } /** Convert to base-currency total (profit only). */ private function toBaseTotal(array $rows, $baseCurrency) { if (!$baseCurrency) { return ['amount' => 0.0, 'prefix' => '', 'suffix' => '', 'code' => '']; } $baseId = (int)$baseCurrency->id; $sum = 0.0; foreach ($rows as $r) { $profit = (float)$r['profit']; $sum += ((int)$r['currencyId'] === $baseId) ? $profit : ($profit / ((float)$r['rate'] ?: 1.0)); } return [ 'amount' => $sum, 'prefix' => (string)$baseCurrency->prefix, 'suffix' => (string)$baseCurrency->suffix, 'code' => (string)$baseCurrency->code, ]; } /** Convert rows to base totals for multiple fields (amount_in, fees, amount_out, profit). */ private function toBaseTotalsMulti(array $rows, $baseCurrency) { if (!$baseCurrency) { return [ 'amount_in' => 0.0, 'fees' => 0.0, 'amount_out' => 0.0, 'profit' => 0.0, ]; } $baseId = (int)$baseCurrency->id; $totals = [ 'amount_in' => 0.0, 'fees' => 0.0, 'amount_out' => 0.0, 'profit' => 0.0, ]; foreach ($rows as $r) { $rate = (float)($r['rate'] ?? 1.0); if ($rate == 0.0) { $rate = 1.0; } $isBase = ((int)$r['currencyId'] === $baseId); $conv = function ($v) use ($isBase, $rate) { $v = (float)$v; return $isBase ? $v : ($v / $rate); }; $totals['amount_in'] += $conv($r['amount_in'] ?? 0); $totals['fees'] += $conv($r['fees'] ?? 0); $totals['amount_out'] += $conv($r['amount_out'] ?? 0); $totals['profit'] += $conv($r['profit'] ?? 0); } return $totals; } /** Safe average helper. */ private function avgPerMonth($total, $months) { $months = (int)$months; if ($months <= 0) { return 0.0; } return (float)$total / $months; } public function generateOutput($data) { if (!empty($data['error'])) { return '<div class="widget-content-padded"><strong>Error:</strong> ' . htmlspecialchars($data['error'], ENT_QUOTES, 'UTF-8') . '<br><small>Generated ' . htmlspecialchars($data['meta']['generated'], ENT_QUOTES, 'UTF-8') . '</small></div>'; } $fmt = function ($prefix, $amount, $suffix) { return sprintf('%s%s%s', htmlspecialchars($prefix ?? '', ENT_QUOTES, 'UTF-8'), number_format((float)$amount, 2), htmlspecialchars($suffix ?? '', ENT_QUOTES, 'UTF-8') ); }; $t = $data['totals']; $m = $data['meta']; $avg = $data['averages'] ?? []; $monthBase = $fmt($t['base']['prefix'] ?? '', $t['month']['amount'] ?? 0, $t['base']['suffix'] ?? ''); $prevMonthBase = $fmt($t['base']['prefix'] ?? '', $t['prev_month']['amount'] ?? 0, $t['base']['suffix'] ?? ''); $tyBase = $fmt($t['base']['prefix'] ?? '', $t['tax_year']['amount'] ?? 0, $t['base']['suffix'] ?? ''); $lastTyBase = $fmt($t['base']['prefix'] ?? '', $t['last_tax_year']['amount'] ?? 0, $t['base']['suffix'] ?? ''); $mAvgIncome = $fmt($t['base']['prefix'] ?? '', $avg['month']['income'] ?? 0, $t['base']['suffix'] ?? ''); $mAvgExpenses = $fmt($t['base']['prefix'] ?? '', $avg['month']['expenses'] ?? 0, $t['base']['suffix'] ?? ''); $mAvgProfit = $fmt($t['base']['prefix'] ?? '', $avg['month']['profit'] ?? 0, $t['base']['suffix'] ?? ''); $pmAvgIncome = $fmt($t['base']['prefix'] ?? '', $avg['prev_month']['income'] ?? 0, $t['base']['suffix'] ?? ''); $pmAvgExpenses = $fmt($t['base']['prefix'] ?? '', $avg['prev_month']['expenses'] ?? 0, $t['base']['suffix'] ?? ''); $pmAvgProfit = $fmt($t['base']['prefix'] ?? '', $avg['prev_month']['profit'] ?? 0, $t['base']['suffix'] ?? ''); $tyAvgIncome = $fmt($t['base']['prefix'] ?? '', $avg['tax_year']['income'] ?? 0, $t['base']['suffix'] ?? ''); $tyAvgExpenses = $fmt($t['base']['prefix'] ?? '', $avg['tax_year']['expenses'] ?? 0, $t['base']['suffix'] ?? ''); $tyAvgProfit = $fmt($t['base']['prefix'] ?? '', $avg['tax_year']['profit'] ?? 0, $t['base']['suffix'] ?? ''); $lastTyAvgIncome = $fmt($t['base']['prefix'] ?? '', $avg['last_tax_year']['income'] ?? 0, $t['base']['suffix'] ?? ''); $lastTyAvgExpenses = $fmt($t['base']['prefix'] ?? '', $avg['last_tax_year']['expenses'] ?? 0, $t['base']['suffix'] ?? ''); $lastTyAvgProfit = $fmt($t['base']['prefix'] ?? '', $avg['last_tax_year']['profit'] ?? 0, $t['base']['suffix'] ?? ''); $tyMonths = (int)($avg['tax_year']['months'] ?? 0); $isInvoice = ($m['mode'] === 'invoice'); $modeLabel = $isInvoice ? 'Invoice-linked' : 'All transactions'; $switchTo = $isInvoice ? 'all' : 'invoice'; $switchTxt = $isInvoice ? 'Switch to All transactions' : 'Switch to Invoice-linked'; $switchUrl = $this->toggleUrl($switchTo); return <<<HTML <div class="widget-content-padded"> <div class="clearfix"> <div class="pull-left"> <strong>Totals in Base ({$t['base']['code']})</strong> <span class="label label-info" style="margin-left:8px;">Mode: {$modeLabel}</span> <a href="{$switchUrl}" style="margin-left:8px;">{$switchTxt}</a> </div> <div class="pull-right text-muted" style="font-size:12px"> Generated {$m['generated']} </div> </div> <table class="table table-condensed" style="margin-top:10px"> <thead> <tr> <th></th> <th class="text-right">This Month<br><small>({$m['month_label']})</small></th> <th class="text-right">Previous Month<br><small>({$m['prev_month_label']})</small></th> <th class="text-right"> Current Tax Year (to date) <br><small>({$m['tax_year_label']}: {$m['tax_year_full_range']})</small> <br><small class="text-muted">As of {$m['tax_year_asof']}</small> </th> <th class="text-right"> Last Tax Year (full) <br><small>({$m['last_tax_year_label']}: {$m['last_tax_year_full_range']})</small> </th> </tr> </thead> <tbody> <tr> <th>Total (Base {$t['base']['code']})</th> <td class="text-right">{$monthBase}</td> <td class="text-right">{$prevMonthBase}</td> <td class="text-right">{$tyBase}</td> <td class="text-right">{$lastTyBase}</td> </tr> <tr> <th> Average per month (Income, Expenses, Profit) <br><small class="text-muted">Current tax year: {$tyMonths}/12 tax months elapsed; last tax year: 12/12</small> </th> <td class="text-right"> <div><small class="text-muted">Income</small> {$mAvgIncome}</div> <div><small class="text-muted">Expenses</small> {$mAvgExpenses}</div> <div><small class="text-muted">Profit</small> {$mAvgProfit}</div> </td> <td class="text-right"> <div><small class="text-muted">Income</small> {$pmAvgIncome}</div> <div><small class="text-muted">Expenses</small> {$pmAvgExpenses}</div> <div><small class="text-muted">Profit</small> {$pmAvgProfit}</div> </td> <td class="text-right"> <div><small class="text-muted">Income</small> {$tyAvgIncome}</div> <div><small class="text-muted">Expenses</small> {$tyAvgExpenses}</div> <div><small class="text-muted">Profit</small> {$tyAvgProfit}</div> </td> <td class="text-right"> <div><small class="text-muted">Income</small> {$lastTyAvgIncome}</div> <div><small class="text-muted">Expenses</small> {$lastTyAvgExpenses}</div> <div><small class="text-muted">Profit</small> {$lastTyAvgProfit}</div> </td> </tr> </tbody> </table> <p class="text-muted" style="font-size:12px;margin-top:8px;"> Formula: <code>Amount In − Fees − Amount Out</code>. UK tax year runs 6 Apr – 5 Apr. <br>Average per month uses: <code>Income = Amount In</code>, <code>Expenses = Fees + Amount Out</code>, <code>Profit = Amount In − Fees − Amount Out</code>. <br><small>Note: “This Month” is month-to-date, so it updates daily.</small> </p> </div> HTML; } }; }); This code and guidance are provided as-is for general informational purposes only. I’m not an accountant, tax adviser, or solicitor, and this widget should not be relied on as professional financial, accounting, or tax advice. Use at your own risk. No liability is accepted for any loss, damage, or issues arising from the use of this code. Edited Tuesday at 03:20 PM by ConorBradley 0 Quote Link to comment Share on other sites More sharing options...
pjs32 Posted yesterday at 05:11 PM Share Posted yesterday at 05:11 PM Great widget - thanks! Is it possible to show without VAT being included on the totals? 0 Quote Link to comment Share on other sites More sharing options...
ConorBradley Posted yesterday at 06:32 PM Author Share Posted yesterday at 06:32 PM 1 hour ago, pjs32 said: Great widget - thanks! Is it possible to show without VAT being included on the totals? Hi, Thank you 😄 Try this code. I haven't tested it on my install, as I need it included in my totals, so you'll have to let me know (please test it NOT on a live install). You'll also need to switch to invoice-only mode as WHMCS doesn’t store “VAT” as a field on tblaccounts transactions. <?php if (!defined("WHMCS")) { die("No direct access"); } use WHMCS\Database\Capsule; use Carbon\Carbon; add_hook('AdminHomeWidgets', 1, function () { return new class extends \WHMCS\Module\AbstractWidget { protected $title = 'Profit / Loss (Month, Prev Month, UK Tax Year)'; protected $description = 'Invoice-linked only. Base-currency totals. Optional Excl. VAT. UK tax year 6 Apr – 5 Apr.'; protected $weight = 150; protected $columns = 2; // set 1/2/3 to suit your dashboard protected $cache = false; protected $cacheExpiry = 0; protected $requiredPermission = ''; public function getData() { try { $now = Carbon::now(); // Invoice-linked only (as requested) $opts = ['onlyInvoiceLinked' => true]; // VAT mode: inc|ex $vatMode = $this->getVatMode(); $excludeVat = ($vatMode === 'ex'); // Calendar months $startMonth = $now->copy()->startOfMonth(); $prevMonthStart = $now->copy()->subMonth()->startOfMonth(); $prevMonthEnd = $now->copy()->subMonth()->endOfMonth(); // UK tax year (6 Apr – 5 Apr) $tyStart = $this->ukTaxYearStart($now); $tyEnd = $tyStart->copy()->addYear()->subDay()->endOfDay(); // 5 Apr end-of-day // Last tax year (full) $lastTyStart = $tyStart->copy()->subYear(); $lastTyEnd = $lastTyStart->copy()->addYear()->subDay()->endOfDay(); // 5 Apr end-of-day // Query per-currency (invoice-linked only) $monthRows = $this->sumProfitByCurrency($startMonth, $now, $opts); // month-to-date $prevMonthRows = $this->sumProfitByCurrency($prevMonthStart, $prevMonthEnd, $opts); // full prev month $taxYearRows = $this->sumProfitByCurrency($tyStart, $now, $opts); // current tax year to date $lastTaxYearRows = $this->sumProfitByCurrency($lastTyStart, $lastTyEnd, $opts); // last tax year full $base = Capsule::table('tblcurrencies')->where('default', 1)->first(); // VAT totals by period (deduped per invoice) $monthVatRows = $this->sumInvoiceVatByCurrencyDistinctInvoices($startMonth, $now); $prevMonthVatRows = $this->sumInvoiceVatByCurrencyDistinctInvoices($prevMonthStart, $prevMonthEnd); $taxVatRows = $this->sumInvoiceVatByCurrencyDistinctInvoices($tyStart, $now); $lastTaxVatRows = $this->sumInvoiceVatByCurrencyDistinctInvoices($lastTyStart, $lastTyEnd); $monthVatBase = $this->toBaseVatTotal($monthVatRows, $base); $prevMonthVatBase = $this->toBaseVatTotal($prevMonthVatRows, $base); $taxVatBase = $this->toBaseVatTotal($taxVatRows, $base); $lastTaxVatBase = $this->toBaseVatTotal($lastTaxVatRows, $base); // Profit totals (base) $monthBaseProfit = $this->toBaseTotal($monthRows, $base); $prevMonthBaseProfit = $this->toBaseTotal($prevMonthRows, $base); $taxBaseProfit = $this->toBaseTotal($taxYearRows, $base); $lastTaxBaseProfit = $this->toBaseTotal($lastTaxYearRows, $base); if ($excludeVat) { // VAT is part of gross invoice totals; remove it from profit $monthBaseProfit['amount'] -= $monthVatBase; $prevMonthBaseProfit['amount'] -= $prevMonthVatBase; $taxBaseProfit['amount'] -= $taxVatBase; $lastTaxBaseProfit['amount'] -= $lastTaxVatBase; } // Multi-field totals (base) for averages row $monthTotalsMulti = $this->toBaseTotalsMulti($monthRows, $base); $prevMonthTotalsMulti = $this->toBaseTotalsMulti($prevMonthRows, $base); $taxTotalsMulti = $this->toBaseTotalsMulti($taxYearRows, $base); $lastTaxTotalsMulti = $this->toBaseTotalsMulti($lastTaxYearRows, $base); if ($excludeVat) { // Remove VAT from income and profit (leave fees/amount_out untouched) $monthTotalsMulti['amount_in'] -= $monthVatBase; $monthTotalsMulti['profit'] -= $monthVatBase; $prevMonthTotalsMulti['amount_in'] -= $prevMonthVatBase; $prevMonthTotalsMulti['profit'] -= $prevMonthVatBase; $taxTotalsMulti['amount_in'] -= $taxVatBase; $taxTotalsMulti['profit'] -= $taxVatBase; $lastTaxTotalsMulti['amount_in'] -= $lastTaxVatBase; $lastTaxTotalsMulti['profit'] -= $lastTaxVatBase; } // Averages $tyMonths = $this->ukTaxMonthsElapsed($tyStart, $now); $monthAvg = [ 'income' => $this->avgPerMonth($monthTotalsMulti['amount_in'], 1), 'expenses' => $this->avgPerMonth($monthTotalsMulti['fees'] + $monthTotalsMulti['amount_out'], 1), 'profit' => $this->avgPerMonth($monthTotalsMulti['profit'], 1), 'months' => 1, ]; $prevMonthAvg = [ 'income' => $this->avgPerMonth($prevMonthTotalsMulti['amount_in'], 1), 'expenses' => $this->avgPerMonth($prevMonthTotalsMulti['fees'] + $prevMonthTotalsMulti['amount_out'], 1), 'profit' => $this->avgPerMonth($prevMonthTotalsMulti['profit'], 1), 'months' => 1, ]; $taxYearAvg = [ 'income' => $this->avgPerMonth($taxTotalsMulti['amount_in'], $tyMonths), 'expenses' => $this->avgPerMonth($taxTotalsMulti['fees'] + $taxTotalsMulti['amount_out'], $tyMonths), 'profit' => $this->avgPerMonth($taxTotalsMulti['profit'], $tyMonths), 'months' => $tyMonths, ]; $lastTaxYearAvg = [ 'income' => $this->avgPerMonth($lastTaxTotalsMulti['amount_in'], 12), 'expenses' => $this->avgPerMonth($lastTaxTotalsMulti['fees'] + $lastTaxTotalsMulti['amount_out'], 12), 'profit' => $this->avgPerMonth($lastTaxTotalsMulti['profit'], 12), 'months' => 12, ]; return [ 'totals' => [ 'month' => $monthBaseProfit, 'prev_month' => $prevMonthBaseProfit, 'tax_year' => $taxBaseProfit, // current tax year to date 'last_tax_year' => $lastTaxBaseProfit, // last tax year full 'base' => $base ? (array)$base : ['code'=>'','prefix'=>'','suffix'=>''], ], 'averages' => [ 'month' => $monthAvg, 'prev_month' => $prevMonthAvg, 'tax_year' => $taxYearAvg, 'last_tax_year' => $lastTaxYearAvg, ], 'meta' => [ 'month_label' => $startMonth->format('M Y'), 'prev_month_label' => $prevMonthStart->format('M Y'), 'tax_year_label' => $this->formatTaxYearLabel($tyStart), 'tax_year_full_range' => $tyStart->format('j M Y') . ' – ' . $tyEnd->format('j M Y'), 'tax_year_asof' => $now->format('j M Y'), 'last_tax_year_label' => $this->formatTaxYearLabel($lastTyStart), 'last_tax_year_full_range' => $lastTyStart->format('j M Y') . ' – ' . $lastTyEnd->format('j M Y'), 'generated' => $now->toDayDateTimeString(), 'vat_mode' => $vatMode, // inc|ex ], ]; } catch (\Throwable $e) { return ['error' => $e->getMessage(), 'meta' => ['generated' => date('r')]]; } } /** VAT mode toggle: inc|ex using GET → session/cookie. */ private function getVatMode() { $valid = ['inc', 'ex']; $get = isset($_GET['pl_vat']) ? strtolower((string)$_GET['pl_vat']) : null; if ($get && in_array($get, $valid, true)) { if (isset($_SESSION)) { $_SESSION['pl_vat'] = $get; } @setcookie('pl_vat', $get, time() + 31536000, '/'); // 1 year return $get; } if (isset($_SESSION['pl_vat']) && in_array($_SESSION['pl_vat'], $valid, true)) { return $_SESSION['pl_vat']; } if (isset($_COOKIE['pl_vat']) && in_array($_COOKIE['pl_vat'], $valid, true)) { return $_COOKIE['pl_vat']; } return 'inc'; } /** Build a link to set query params while preserving the current path & query. */ private function toggleUrl(array $set) { $uri = $_SERVER['REQUEST_URI'] ?? 'index.php'; $parts = parse_url($uri); $path = $parts['path'] ?? 'index.php'; $q = []; if (!empty($parts['query'])) { parse_str($parts['query'], $q); } foreach ($set as $k => $v) { $q[$k] = $v; } $qs = http_build_query($q); return $path . ($qs ? '?' . $qs : ''); } private function ukTaxYearStart(Carbon $date) { $tyStart = Carbon::create($date->year, 4, 6, 0, 0, 0, $date->timezone); if ($date->lt($tyStart)) { $tyStart->subYear(); } return $tyStart->startOfDay(); } private function formatTaxYearLabel(Carbon $tyStart) { $startYear = (int)$tyStart->format('Y'); $endYY = (int)$tyStart->copy()->addYear()->format('y'); return sprintf('%d/%02d', $startYear, $endYY); } /** * UK tax months elapsed in the tax year (tax months run 6th → 5th). * Returns an integer 1..12. */ private function ukTaxMonthsElapsed(Carbon $tyStart, Carbon $to) { if ($to->lt($tyStart)) { return 0; } $startY = (int)$tyStart->format('Y'); $startM = 4; // tax year starts in April $toY = (int)$to->format('Y'); $toM = (int)$to->format('n'); $toD = (int)$to->format('j'); $diffMonths = (($toY - $startY) * 12) + ($toM - $startM); $monthsElapsed = $diffMonths + (($toD >= 6) ? 1 : 0); if ($monthsElapsed < 1) { $monthsElapsed = 1; } if ($monthsElapsed > 12) { $monthsElapsed = 12; } return $monthsElapsed; } /** Aggregate per currency for a period; invoice-linked only in this widget. */ private function sumProfitByCurrency(Carbon $from, Carbon $to, array $opts = []) { $q = Capsule::table('tblaccounts as a') ->leftJoin('tblclients as cl', 'cl.id', '=', 'a.userid') ->leftJoin('tblcurrencies as cur', 'cur.id', '=', 'cl.currency') ->whereBetween('a.date', [$from->toDateTimeString(), $to->toDateTimeString()]) ->where('a.invoiceid', '>', 0); // invoice-linked only $rows = $q->groupBy('cl.currency', 'cur.code', 'cur.prefix', 'cur.suffix', 'cur.rate') ->orderBy('cur.code') ->get([ Capsule::raw('cl.currency as currencyId'), Capsule::raw('cur.code as code'), Capsule::raw('cur.prefix as prefix'), Capsule::raw('cur.suffix as suffix'), Capsule::raw('cur.rate as rate'), Capsule::raw('SUM(a.amountin) as amount_in'), Capsule::raw('SUM(a.fees) as fees'), Capsule::raw('SUM(a.amountout) as amount_out'), Capsule::raw('SUM(a.amountin - a.fees - a.amountout) as profit'), ]); $defaultCurrency = Capsule::table('tblcurrencies')->where('default', 1)->first(); return $rows->map(function ($r) use ($defaultCurrency) { $rate = isset($r->rate) ? (float)$r->rate : (float)($defaultCurrency->rate ?? 1.0); $cid = isset($r->currencyId) ? (int)$r->currencyId : (int)($defaultCurrency->id ?? 0); return [ 'currencyId' => $cid, 'rate' => (float)$rate, 'amount_in' => (float)$r->amount_in, 'fees' => (float)$r->fees, 'amount_out' => (float)$r->amount_out, 'profit' => (float)$r->profit, ]; })->all(); } /** * Sum VAT (tax+tax2) from invoices linked to tblaccounts in date range, * counting each invoice once (prevents double-counting on partial/multiple payments). */ private function sumInvoiceVatByCurrencyDistinctInvoices(Carbon $from, Carbon $to) { $q = Capsule::table('tblaccounts as a') ->join('tblinvoices as i', 'i.id', '=', 'a.invoiceid') ->leftJoin('tblclients as cl', 'cl.id', '=', 'a.userid') ->leftJoin('tblcurrencies as cur', 'cur.id', '=', 'cl.currency') ->whereBetween('a.date', [$from->toDateTimeString(), $to->toDateTimeString()]) ->where('a.invoiceid', '>', 0) ->groupBy('a.invoiceid', 'cl.currency', 'cur.rate'); // One row per invoice per currency $invoiceRows = $q->get([ Capsule::raw('a.invoiceid as invoiceId'), Capsule::raw('cl.currency as currencyId'), Capsule::raw('cur.rate as rate'), Capsule::raw('MAX(COALESCE(i.tax,0) + COALESCE(i.tax2,0)) as vat_total'), ]); $defaultCurrency = Capsule::table('tblcurrencies')->where('default', 1)->first(); // Reduce to per-currency totals in PHP $byCurrency = []; foreach ($invoiceRows as $r) { $cid = isset($r->currencyId) ? (int)$r->currencyId : (int)($defaultCurrency->id ?? 0); $rate = isset($r->rate) ? (float)$r->rate : (float)($defaultCurrency->rate ?? 1.0); $vat = (float)$r->vat_total; if (!isset($byCurrency[$cid])) { $byCurrency[$cid] = ['currencyId' => $cid, 'rate' => $rate, 'vat_total' => 0.0]; } $byCurrency[$cid]['vat_total'] += $vat; } return array_values($byCurrency); } /** Convert VAT rows to base-currency total. */ private function toBaseVatTotal(array $rows, $baseCurrency) { if (!$baseCurrency) { return 0.0; } $baseId = (int)$baseCurrency->id; $sum = 0.0; foreach ($rows as $r) { $vat = (float)($r['vat_total'] ?? 0); $rate = (float)($r['rate'] ?? 1.0); if ($rate == 0.0) { $rate = 1.0; } $sum += ((int)$r['currencyId'] === $baseId) ? $vat : ($vat / $rate); } return $sum; } /** Convert to base-currency total (profit only). */ private function toBaseTotal(array $rows, $baseCurrency) { if (!$baseCurrency) { return ['amount' => 0.0, 'prefix' => '', 'suffix' => '', 'code' => '']; } $baseId = (int)$baseCurrency->id; $sum = 0.0; foreach ($rows as $r) { $profit = (float)$r['profit']; $rate = (float)($r['rate'] ?? 1.0); if ($rate == 0.0) { $rate = 1.0; } $sum += ((int)$r['currencyId'] === $baseId) ? $profit : ($profit / $rate); } return [ 'amount' => $sum, 'prefix' => (string)$baseCurrency->prefix, 'suffix' => (string)$baseCurrency->suffix, 'code' => (string)$baseCurrency->code, ]; } /** Convert rows to base totals for multiple fields (amount_in, fees, amount_out, profit). */ private function toBaseTotalsMulti(array $rows, $baseCurrency) { if (!$baseCurrency) { return [ 'amount_in' => 0.0, 'fees' => 0.0, 'amount_out' => 0.0, 'profit' => 0.0, ]; } $baseId = (int)$baseCurrency->id; $totals = [ 'amount_in' => 0.0, 'fees' => 0.0, 'amount_out' => 0.0, 'profit' => 0.0, ]; foreach ($rows as $r) { $rate = (float)($r['rate'] ?? 1.0); if ($rate == 0.0) { $rate = 1.0; } $isBase = ((int)$r['currencyId'] === $baseId); $conv = function ($v) use ($isBase, $rate) { $v = (float)$v; return $isBase ? $v : ($v / $rate); }; $totals['amount_in'] += $conv($r['amount_in'] ?? 0); $totals['fees'] += $conv($r['fees'] ?? 0); $totals['amount_out'] += $conv($r['amount_out'] ?? 0); $totals['profit'] += $conv($r['profit'] ?? 0); } return $totals; } /** Safe average helper. */ private function avgPerMonth($total, $months) { $months = (int)$months; if ($months <= 0) { return 0.0; } return (float)$total / $months; } public function generateOutput($data) { if (!empty($data['error'])) { return '<div class="widget-content-padded"><strong>Error:</strong> ' . htmlspecialchars($data['error'], ENT_QUOTES, 'UTF-8') . '<br><small>Generated ' . htmlspecialchars($data['meta']['generated'], ENT_QUOTES, 'UTF-8') . '</small></div>'; } $fmt = function ($prefix, $amount, $suffix) { return sprintf('%s%s%s', htmlspecialchars($prefix ?? '', ENT_QUOTES, 'UTF-8'), number_format((float)$amount, 2), htmlspecialchars($suffix ?? '', ENT_QUOTES, 'UTF-8') ); }; $t = $data['totals']; $m = $data['meta']; $avg = $data['averages'] ?? []; $monthBase = $fmt($t['base']['prefix'] ?? '', $t['month']['amount'] ?? 0, $t['base']['suffix'] ?? ''); $prevMonthBase = $fmt($t['base']['prefix'] ?? '', $t['prev_month']['amount'] ?? 0, $t['base']['suffix'] ?? ''); $tyBase = $fmt($t['base']['prefix'] ?? '', $t['tax_year']['amount'] ?? 0, $t['base']['suffix'] ?? ''); $lastTyBase = $fmt($t['base']['prefix'] ?? '', $t['last_tax_year']['amount'] ?? 0, $t['base']['suffix'] ?? ''); $mAvgIncome = $fmt($t['base']['prefix'] ?? '', $avg['month']['income'] ?? 0, $t['base']['suffix'] ?? ''); $mAvgExpenses = $fmt($t['base']['prefix'] ?? '', $avg['month']['expenses'] ?? 0, $t['base']['suffix'] ?? ''); $mAvgProfit = $fmt($t['base']['prefix'] ?? '', $avg['month']['profit'] ?? 0, $t['base']['suffix'] ?? ''); $pmAvgIncome = $fmt($t['base']['prefix'] ?? '', $avg['prev_month']['income'] ?? 0, $t['base']['suffix'] ?? ''); $pmAvgExpenses = $fmt($t['base']['prefix'] ?? '', $avg['prev_month']['expenses'] ?? 0, $t['base']['suffix'] ?? ''); $pmAvgProfit = $fmt($t['base']['prefix'] ?? '', $avg['prev_month']['profit'] ?? 0, $t['base']['suffix'] ?? ''); $tyAvgIncome = $fmt($t['base']['prefix'] ?? '', $avg['tax_year']['income'] ?? 0, $t['base']['suffix'] ?? ''); $tyAvgExpenses = $fmt($t['base']['prefix'] ?? '', $avg['tax_year']['expenses'] ?? 0, $t['base']['suffix'] ?? ''); $tyAvgProfit = $fmt($t['base']['prefix'] ?? '', $avg['tax_year']['profit'] ?? 0, $t['base']['suffix'] ?? ''); $lastTyAvgIncome = $fmt($t['base']['prefix'] ?? '', $avg['last_tax_year']['income'] ?? 0, $t['base']['suffix'] ?? ''); $lastTyAvgExpenses = $fmt($t['base']['prefix'] ?? '', $avg['last_tax_year']['expenses'] ?? 0, $t['base']['suffix'] ?? ''); $lastTyAvgProfit = $fmt($t['base']['prefix'] ?? '', $avg['last_tax_year']['profit'] ?? 0, $t['base']['suffix'] ?? ''); $tyMonths = (int)($avg['tax_year']['months'] ?? 0); // VAT toggle UI $isVatEx = (($m['vat_mode'] ?? 'inc') === 'ex'); $vatLabel = $isVatEx ? 'Excl. VAT' : 'Incl. VAT'; $vatTo = $isVatEx ? 'inc' : 'ex'; $vatTxt = $isVatEx ? 'Switch to Incl. VAT' : 'Switch to Excl. VAT'; $vatUrl = $this->toggleUrl(['pl_vat' => $vatTo]); return <<<HTML <div class="widget-content-padded"> <div class="clearfix"> <div class="pull-left"> <strong>Totals in Base ({$t['base']['code']})</strong> <span class="label label-info" style="margin-left:8px;">Mode: Invoice-linked</span> <span class="label label-default" style="margin-left:8px;">{$vatLabel}</span> <a href="{$vatUrl}" style="margin-left:8px;">{$vatTxt}</a> </div> <div class="pull-right text-muted" style="font-size:12px"> Generated {$m['generated']} </div> </div> <table class="table table-condensed" style="margin-top:10px"> <thead> <tr> <th></th> <th class="text-right">This Month<br><small>({$m['month_label']})</small></th> <th class="text-right">Previous Month<br><small>({$m['prev_month_label']})</small></th> <th class="text-right"> Current Tax Year (to date) <br><small>({$m['tax_year_label']}: {$m['tax_year_full_range']})</small> <br><small class="text-muted">As of {$m['tax_year_asof']}</small> </th> <th class="text-right"> Last Tax Year (full) <br><small>({$m['last_tax_year_label']}: {$m['last_tax_year_full_range']})</small> </th> </tr> </thead> <tbody> <tr> <th>Total Profit (Base {$t['base']['code']})</th> <td class="text-right">{$monthBase}</td> <td class="text-right">{$prevMonthBase}</td> <td class="text-right">{$tyBase}</td> <td class="text-right">{$lastTyBase}</td> </tr> <tr> <th> Average per month (Income, Expenses, Profit) <br><small class="text-muted">Current tax year: {$tyMonths}/12 tax months elapsed; last tax year: 12/12</small> </th> <td class="text-right"> <div><small class="text-muted">Income</small> {$mAvgIncome}</div> <div><small class="text-muted">Expenses</small> {$mAvgExpenses}</div> <div><small class="text-muted">Profit</small> {$mAvgProfit}</div> </td> <td class="text-right"> <div><small class="text-muted">Income</small> {$pmAvgIncome}</div> <div><small class="text-muted">Expenses</small> {$pmAvgExpenses}</div> <div><small class="text-muted">Profit</small> {$pmAvgProfit}</div> </td> <td class="text-right"> <div><small class="text-muted">Income</small> {$tyAvgIncome}</div> <div><small class="text-muted">Expenses</small> {$tyAvgExpenses}</div> <div><small class="text-muted">Profit</small> {$tyAvgProfit}</div> </td> <td class="text-right"> <div><small class="text-muted">Income</small> {$lastTyAvgIncome}</div> <div><small class="text-muted">Expenses</small> {$lastTyAvgExpenses}</div> <div><small class="text-muted">Profit</small> {$lastTyAvgProfit}</div> </td> </tr> </tbody> </table> <p class="text-muted" style="font-size:12px;margin-top:8px;"> Profit formula: <code>Amount In − Fees − Amount Out</code>. UK tax year runs <strong>6 Apr – 5 Apr</strong>. <br>Average per month uses: <code>Income = Amount In</code>, <code>Expenses = Fees + Amount Out</code>, <code>Profit = Amount In − Fees − Amount Out</code>. <br><small>When “Excl. VAT” is enabled, invoice VAT (tax + tax2) is subtracted from Income and Profit. This is invoice-linked only.</small> </p> </div> HTML; } }; }); 0 Quote Link to comment Share on other sites More sharing options...
pjs32 Posted 11 hours ago Share Posted 11 hours ago Thats great - work well - thank you!😀 0 Quote Link to comment Share on other sites More sharing options...
Recommended Posts
Join the conversation
You can post now and register later. If you have an account, sign in now to post with your account.