All Activity
- Today
-
WHMCS Admin Widget: UK TAX Year Income/Profit/Loss
ConorBradley replied to ConorBradley's topic in Developer Corner
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; } }; }); -
pjs32 started following WHMCS Admin Widget: UK TAX Year Income/Profit/Loss
-
WHMCS Admin Widget: UK TAX Year Income/Profit/Loss
pjs32 replied to ConorBradley's topic in Developer Corner
Great widget - thanks! Is it possible to show without VAT being included on the totals? -
fupwork joined the community
-
kwood started following Digital Downloads
-
Hi, I am wanting to create a system in my WHMCS where specific clients can access digital downloads in their client area. I only want it available to clients that either purchase each digital download or if they purchase a specific package. Is this something that I can do? Or is already available?
-
raoraorao joined the community
-
Wanderzode joined the community
-
uupwork joined the community
-
Car With Drivers joined the community
-
malvigajjar joined the community
-
michtaylor started following WHMCS 7.8.3 with GoCardless
-
This is a problem with WHMCS 7.8.3, not a patch that isn't there. GoCardless changed the format of their redirect URL, and older versions of WHMCS won't accept it because it says "invalid filename." You can't backport module-7849. The only way to really fix it is to upgrade WHMCS to 8.5.2 or higher, or stop using GoCardless. Any core hack on 7.8.3 will be weak and not supported.
-
Benrus3601 joined the community
- Yesterday
-
ConorBradley started following WHMCS Admin Widget: UK TAX Year Income/Profit/Loss
-
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.
-
residentialfitoutdubai joined the community
-
Good day, In the WHMCS client area, invoices are currently listed with the oldest invoice at the top and the newest at the bottom. How can I change this so that the most recent invoice appears at the top of the list? Thanks
-
Excellent! Thank you!
-
Hi @kon, I'm pleased to hear you like the direction we're going with Nexus cart! We're going to continue with this work in 9.1 and bring this same design language to the rest of the purchase flow. The RDAP implementation has been rescheduled to v9.2. This is because we have prioritised security and financial compliance features in releasing 9.0 and will continue this focus in the subsequent 9.1 (eg. credit notes, e-invoicing, improved VAT compliance).
-
wtools started following Registrar connection error
-
Can you tell me using which domain registrar module? Also please message me the out put of the below commands from your terminal. 1) openssl version 2) php -i | grep -i openssl this will help to understand the openssl versions used by php and terminal, to know there is any issue to openssl versions.
- Last week
-
hmaddy started following Registrar connection error
-
i cant connect to my domain registrar through their module. showing handshake error. but when i try to check it through terminal manually its connecting. so how to check it and fix it. i checked it with multiple registrars like namesilo, resellerclub and connectreseller
-
virtual sky host changed their profile photo
-
Website: https://www.virtualskyhost.com Billing & Automation Platform: WHMCS Hello WHMCS Community, Iβd like to showcase Virtual Sky Host, a hosting provider offering a range of fully automated hosting solutions built around WHMCS. Our current services include: Web Hosting WordPress Hosting WooCommerce Hosting Reseller Hosting Automated VPS Hosting WHMCS is used for client onboarding, automated provisioning, billing, invoicing, and service management across all of our offerings. Our focus is on providing a simple, reliable, and transparent hosting experience, with room to scale as customer needs grow. Weβre continuously refining our WHMCS setup and backend infrastructure as the platform evolves. Feedback and suggestions from the WHMCS community are always welcome. Thanks for taking the time to check out our site.
-
As long as that meets data privacy requirements for wherever you are located, I'm happy for you. Even more so if you fully trust the platform. Do what's right for you and your clients.
-
This is something I could do, but I have never done this in the past and the numbering worked fine. I do not understand why it would have changed. I use to just generate a report and the invoice displayed the correct number, but they are blank if I do not add the number in the 'options' section.
-
fooddeme changed their profile photo
-
I am simply expressing my feedback. The price rise is unjust, I feel. Now, in the Australian dollar it is now $52 /mo What exactly has been done to the software that justifies the price increase?
-
Help!!! WHMCS Find My Shared Mailbox, Please! - M365 import
SVCode replied to BENELUX's topic in Troubleshooting Issues
Appreciate the reply - but my (ChatGPT) hook is doing what it needs to for me i.e stripping `&prompt=consent` π -
Data export, deletion requests, consent logging, cookie banner, DPA management, auto-anonymization. Features Data export - Clients export their data (CSV/JSON). Admin can export any client. Deletion requests - Client requests, you approve, system anonymizes. Invoices kept for legal. Consent logging - IP/user agent tracked. Auto-log on registration optional. Cookie banner - Styling, position, animations. Cookie policy page included. DPA management - Track subprocessors. Client DPA acceptance with version control. Auto-anonymization - Closed inactive accounts anonymized after X years. Warning email first. Audit trail - Everything logged. Not included Legal advice Your privacy policy Compliance guarantees Compatibility WHMCS 8.x, 9.x. Templates: Six, Twenty-One, Lagom, Starter. Client DPA Enable, set version, add PDF URLs per language. Clients see banner until accepted. Change version = re-acceptance required. 26 languages. Auto-Anonymization Finds closed accounts with no login, no services, no activity for X years. Warning email. No login during warning period = anonymized. Exclude specific clients if needed. Anonymization Personal data hashed. Tickets redacted. IPs cleared. Account closed. Invoices kept (7 years Belgian law, check yours). Languages EN, NL, DE, FR, IT, ES, RU. Links Marketplace: marketplace.whmcs.com/product/8376-gdpr-suite (might still be under review) Order: arkhost.com/store/whmcs-modules/gdpr-suite Questions here or support@arkhost.com.
-
Hello guys, First of all thank you brian for great idea and i wish you are still around here. I like this whole idea and i was using it for some time, but it was missing some features and links were not working properly based on links setup. So i went forward and extended this idea into WHMCS addon module with extended features. https://builtbybit.com/resources/admin-info-bar-whmcs-module.88178/ ## Key Benefits Elevates the admin experience with a clean, informative stats bar Speeds up operations via live configuration and instant visual feedback Reduces theme inconsistencies with Bootstrap-friendly components Works across all admin templates and Friendly URLs modes ## Core Features - Customizable appearance - Color pickers for bar background, text, and count highlight - Font family selector (system, serif, sans, mono, inherit...) - Font size presets (em/px) - Live preview updates as you change settings - Rich metrics (toggle on/off per metric) - Pending Orders - Overdue Invoices - Tickets Awaiting Reply - Todo Items Due - Active Clients - Active Services - Pending Cancellations - Expired Domains - Current Date & Time - Icon customization - Set per-metric icons (Font Awesome class names) - Consistent spacing and visibility within the bar - Bootstrap-aligned admin UI - Tabs, panels, forms, alerts, and buttons match WHMCS styling - Responsive layout that adapts to mobile and wide screens - Cross-template compatibility - No restriction to a single admin theme; runs on blend, lara, and customs - Friendly URLs awareness - Detects Full Friendly Rewrite vs index.php routes automatically - Generates correct admin links for both modes ## Smart persistence - One-click Save for all settings - Live preview updates without saving until you click Save - Safe and consistent - Sanitized hex colors with validation - Whitelisted font families and sizes
-
Help!!! WHMCS Find My Shared Mailbox, Please! - M365 import
BENELUX replied to BENELUX's topic in Troubleshooting Issues
Hi @SVCode I received a follow-up from WHMCS Support regarding this issue. They have confirmed that the `prompt=consent` parameter is the culprit preventing connections. They provided me with a modified `MicrosoftAuthProvider.php` file that removes this parameter, and I can confirm this fixes the issue for us permanently on WHMCS 8.13.1. Just to clarify on the workaround mentioned earlier: we did not copy the URL to a new tab (which likely breaks the session/state); we simply edited the URL directly in the current browser tab to remove `&prompt=consent` and hit enter. However, for a permanent fix without manual URL editing, you will need the patched file. Since the file is IonCube encoded, you cannot easily edit it yourself. The Solution:β¨I recommend opening a ticket with WHMCS support and referencing case WHMCS-24661. They should be able to provide you with the same 'MicrosoftAuthProvider.php' patch for version 8.13.1 that they sent me. Once you have the file: 1. Backup your original /vendor/whmcs/whmcs-foundation/lib/Mail/Incoming/Provider/MicrosoftAuthProvider.php 2. Upload the patched file to the same location. 3. Re-authenticate your department using the Microsoft 365 licensed user. Hope this helps you get yours connected! MicrosoftAuthProvider.php -
Help!!! WHMCS Find My Shared Mailbox, Please! - M365 import
SVCode replied to BENELUX's topic in Troubleshooting Issues
ChatGPT to the rescue - I had to use a hook to strip it out, all browsers I tried blocked editing the address bar (even after trying to avoid it with browser settings): <?php /** * Strip prompt=consent from Microsoft OAuth authorize URLs opened by WHMCS popups. * Upgrade-safe: lives in /includes/hooks */ add_hook('AdminAreaHeadOutput', 1, function ($vars) { return <<<HTML <script> (function () { function stripPromptConsent(url) { try { var u = new URL(url, window.location.href); // Only touch Microsoft login/authorize hosts var host = (u.hostname || "").toLowerCase(); var isMicrosoftLogin = host === "login.microsoftonline.com" || host.endsWith(".login.microsoftonline.com") || host.endsWith(".microsoftonline.com"); if (!isMicrosoftLogin) return url; var prompt = u.searchParams.get("prompt"); if (prompt && prompt.toLowerCase() === "consent") { u.searchParams.delete("prompt"); return u.toString(); } return url; } catch (e) { return url; // if URL parsing fails, do nothing } } // Patch window.open (most popup flows use this) var _open = window.open; window.open = function (url, name, specs, replace) { if (typeof url === "string") { url = stripPromptConsent(url); } return _open.call(this, url, name, specs, replace); }; // Also patch location.assign/replace in case WHMCS uses those try { var _assign = window.location.assign.bind(window.location); window.location.assign = function (url) { if (typeof url === "string") url = stripPromptConsent(url); return _assign(url); }; } catch (e) {} try { var _replace = window.location.replace.bind(window.location); window.location.replace = function (url) { if (typeof url === "string") url = stripPromptConsent(url); return _replace(url); }; } catch (e) {} })(); </script> HTML; }); -
Help!!! WHMCS Find My Shared Mailbox, Please! - M365 import
SVCode replied to BENELUX's topic in Troubleshooting Issues
@BENELUX How do you strip the `&prompt=consent` in the URL? The address box isn't editable in the pop up - tried a few browsers - and if I copy/paste it into a new window, it doesn't seem to update the WHMCS window with the token. -
π Introducing Hostina β The Best WHMCS Hosting Template (LTR & RTL) Great news! After months of professional design and development, weβre proud to announce the public release of Hostina β a premium WHMCS hosting template built for modern hosting businesses. Hostina is a fully responsive, bilingual (English LTR + Arabic RTL) WHMCS theme, designed specifically for web hosting companies, domain registrars, resellers, and cloud providers who want a clean, fast, and high-converting client experience. Combining a modern interface, a custom WHMCS dashboard, and a fully styled orderform checkout, Hostina delivers a smooth and professional user experience across all devices. β¨ Key Features - Premium modern & responsive WHMCS design - Full Arabic (RTL) + English (LTR) bilingual support - SEO optimized structure (Core Web Vitals ready) - Custom WHMCS client dashboard with sidebar navigation - Styled orderform & checkout pages - Easy installation β upload, activate, and launch in minutes π§ Frontend Pages Included - Homepage (features, pricing, testimonials, FAQ) - Domain Search & Domain Transfer - Hosting Plans Pages: Shared Hosting, WordPress Hosting, Reseller Hosting, VPS, Dedicated Servers, Game Servers - Cart & Checkout (Styled WHMCS Orderform Flow) - Blog & Blog Details - Contact Page (form + map) πΌ WHMCS Client Area - Login & Registration - Custom Dashboard with Sidebar - My Services & Products - Domain Management (renewals, auto-renew, DNS) - Invoices & Billing - Support Tickets - User & Membership Management βοΈ Compatibility - WHMCS: 8.0 β 8.13+ - PHP: 8.1 β 8.3 - Compatible with cPanel, Plesk, DirectAdmin, CloudLinux, LiteSpeed, Apache, Nginx πΌοΈ Screenshots Preview https://hostk.com/hostina-template π§ͺ Live Demo Frontend Demo: https://demo.hostk.com/hostina/ English Version: https://demo.hostk.com/hostina/en Arabic Version (RTL): https://demo.hostk.com/hostina/ar WHMCS Client Area Demo: https://demo.hostk.com/index.php?systpl=hostina Login: test@hostk.com Password: test π Purchase Hostina WHMCS Template Hostina is a complete WHMCS solution β modern template, custom dashboard, and styled checkout. If youβre looking for the best WHMCS hosting template with Arabic & English support, Hostina is built for you.
-
- whmcs template
- whmcs theme
-
(and 3 more)
Tagged with:
-
This is the top 10 WHMCS Services modules of the year 2025 01) Email 2FA 02) SMS Manager 03) Email Verification Pro 04) Services Fee 05) Support Pin 06) Affiliates Plus 07) Agree Terms 08) Client Manager 09) Discount Manager 10) Refer A Friend
-
Hello, A couple of notes for feedback for @WHMCS John: The Nexus Cart is amazing! It's what we've been asking for in the recent years. New and fresh look that users will like to interact with. Thank you! For the Expanded API Coverage, please kindly include more examples in the documentation. If we have examples (even simple ones) on how we can interact with the API on each occasion, it will be hands down a much more easier use of it for us, Now, for the main subject of this thread. We're following the feature requests a lot. One of the expected additions for us on the newest release was RDAP and the feature request is here: https://requests.whmcs.com/idea/rdap-whois-query-integration Didn't see anything in the Release Notes of 8.13 and 9.0 and this is why i ask. The comment in the feature request was saying "RDAP support is currently planned for the second release of 2025 (after 8.13). " Please correct me if we've missed it. Could you please let us know if it will be part of v9.0?
-
Hi @BENELUX, 9.0 stable release will be made in January 2026. The exact date will depend on the feedback we get and progress addressing cases.
-
I asked via ticket and was told early January. So I would assume that means between now and the 10th.
