Jump to content

ConorBradley

Member
  • Posts

    13
  • Joined

  • Last visited

Everything posted by ConorBradley

  1. 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; } }; });
  2. 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 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.
  3. Thanks for the reply guys. My aim is to remove the URLs that you can see in the image rather than stopping the clients from selecting the languages. Its more for SEO related tasks, is this something we can do?
  4. Hi Guys, How to I remove the language URL's for each page? I'm only wanting English on there, I know about the Hook to hide the languages however they still appear. Thanks, Conor
  5. Thanks, That makes perfect sense, It there a way to set the status back to unpaid automatically or is this a manual task that would need to be done.
  6. Thanks for the reply :), My aim would be to rename each invoice so it would be something like #Invoice 23 - SEO Services for (company 1) as I am a white label with some of these clients. I wouldn't want all my customers to see a different invoice name which is why I want to manually edit each one. It can be hard when a client has x amount of unpaid invoices to figure out which invoice is which, without clicking on it.
  7. Hi Guys, When creating quotes for my SEO clients I create the invoices a a split (70% deposit & 30% Once completed). The due date for the second invoice is usually set at 1-3 months depending on the service. However because the invoice gets generated it can be seen in the client portal as unpaid months before its actually due. Is there a way to hide this from clients 14 days before its due? Thanks, Conor
  8. Hi Everyone, I'm looking for some help with making some invoices have a custom title instead of a number. The numbers make sense for hosting clients however i've been doing SEO services for people and it can become quite confusing when a client has 3+ invoices in their portal for different SEO services. I am creating these as quotes in WHMCS then converting them to a split invoice (70% Deposit then 30% when work is done). Is there a way I can rename these invoices to a custom title, even if its with an addon. Thanks, Conor
  9. Thank you for your help, I've found the file but still not sure how to change it to that date range. <?php namespace WHMCS\Module\Widget; use WHMCS\Module\AbstractWidget; /** * Billing Widget. * * @copyright Copyright (c) WHMCS Limited 2005-2021 * @license https://www.whmcs.com/eula/ WHMCS Eula */ class Billing extends AbstractWidget { protected $title = 'Billing'; protected $description = 'An overview of billing.'; protected $weight = 150; protected $cache = true; protected $requiredPermission = 'View Income Totals'; public function getData() { $incomeStats = getAdminHomeStats('income'); foreach ($incomeStats['income'] as $key => $value) { $incomeStats['income'][$key] = $value->toPrefixed(); } return $incomeStats; } public function generateOutput($data) { $incomeToday = $data['income']['today']; $incomeThisMonth = $data['income']['thismonth']; $incomeThisYear = $data['income']['thisyear']; $incomeAllTime = $data['income']['alltime']; return <<<EOF <div class="row"> <div class="col-sm-6 bordered-right"> <div class="item"> <div class="data color-green">{$incomeToday}</div> <div class="note">Today</div> </div> </div> <div class="col-sm-6"> <div class="item"> <div class="data color-orange">{$incomeThisMonth}</div> <div class="note">This Month</div> </div> </div> <div class="col-sm-6 bordered-right bordered-top"> <div class="item"> <div class="data color-pink">{$incomeThisYear}</div> <div class="note">This Year</div> </div> </div> <div class="col-sm-6 bordered-top"> <div class="item"> <div class="data">{$incomeAllTime}</div> <div class="note">All Time</div> </div> </div> </div> EOF; } }
  10. Hi, Is it possible to change WHMCS billing widget years from 1st Jan - December to something like April 2021 to April 22. In the UK our financial years are different and it would be good to see this in the current financial year. Thanks, Conor
  11. Hi Guys, Its my first post so please move if its in the incorrect location. I'm submitting my first end of year income & outgoings and was wondering what everyone uses to do this as WHMCS is quite confusing when you export the data. I've been adding all my outgoings in the WHMCS system as i felt it would be easier to have everything in 1 place. I've exported all the data for this year and all of my income from clients say "Invoice Payment" in the description, Is there a way to make it say the product name instead without manually renaming all the columns? Any advice would be amazing ! I'm from the UK if that helps. Many Thanks, Conor
  12. Hi Kian, Sorry I'm wanting to do the same however this is slightly different. I have a website developer that wants to use our hosting for his clients and our portal. I've set up everything (roles and user group) however the problem is he can see all of our clients. I thought of maybe creating a client group for him but theres no option to force viewing that group only. Do you know how to do that? Many Thanks
  13. Hi Guys, I want to offer free web hosting for my potential clients, However when they purchase it I would like them to type in their site name which would then be a subdomain of my site. For example (elliesbuisness.example.com). How would I automatically set this up so it creates it as a subdomain on go daddy or enom, then uses it on whmcs with the free hosting plan. Many Thanks, Conor
×
×
  • Create New...

Important Information

By using this site, you agree to our Terms of Use & Guidelines and understand your posts will initially be pre-moderated