Invoice Amount Hooks
The eight hooks touching the numbers on an invoice: the items, the totals, the late fee, the renewal price and formalising.
Overview
These hooks decide what the customer pays. They all work by reference and the value you write goes straight onto the invoice; a mistake in arithmetic here becomes money.
The last two differ: formalising turns an invoice into a legal record. After that the amount cannot change, the invoice cannot be deleted and its number is fixed.
Reference
Changing the invoice totals
Runs after the totals were worked out, before they are written to the invoice.
subtotal, tax, additional_tax, pmethod_commission, total, discounts. Changing one alone leaves the figures out of step; touching the subtotal means fixing the grand total too.Hook::add('filter:invoice.totals', 10, function (&$totals, $invoice, $items) {
// Having changed one figure, go over the GRAND TOTAL as well.
if (Acme::roundUp($invoice)) {
$totals['total'] = ceil((float) $totals['total']);
}
});Changing the invoice discounts
Runs while an invoice is edited, before the discounts are saved.
Hook::add('filter:invoice.items', 10,
function ($id, &$invDiscounts, $pendingCustomDiscounts, $invoice) {
// The name misleads: this is the discount structure, not the items.
Acme::capDiscounts($invDiscounts, (float) ($invoice['subtotal'] ?? 0));
});Changing the late fee
Runs after the late fee was worked out, before it is added to the invoice as an item.
Hook::add('filter:invoice.late_fee_amount', 10, function (&$fee, $invoice, $cycle) {
// On a daily cycle this runs every day: hold it under a ceiling.
$cap = (float) ($invoice['subtotal'] ?? 0) * 0.2;
if ($fee > $cap) $fee = $cap;
});Following a late fee added
Runs after the late fee was added to the invoice as an item.
percentage or fixed.Hook::add('action:invoice.late_fee_applied', 10,
function ($invoice_id, $fee_amount, $fee_type, $item_id) {
// Tell the customer the invoice grew; a silent rise becomes a complaint.
Notify::lateFee($invoice_id, (float) $fee_amount);
});Changing the renewal price
Runs after a service's renewal price was resolved.
amount (the unit price), quantity, currency, taxexempt, additional_taxes, discounts, pricing_source, period_time. The amount is per unit; the total is multiplied afterwards.Hook::add('filter:invoice.renewal_amount', 10, function (&$result) {
// amount is the UNIT price: the core multiplies by quantity.
if (($result['pricing_source'] ?? '') === 'locked') return;
$result['amount'] = Acme::loyaltyPrice((float) $result['amount']);
});Changing the renewal description
Runs after the renewal item's description was built.
Hook::add('filter:invoice.renewal_description', 10, function (&$description) {
$description .= ' — ' . Acme::periodNote();
});Stopping an invoice being formalised
Runs before an invoice is formalised. After this step it can be neither changed nor deleted.
Hook::add('gate:invoice.formalize', 10, function ($invoice, $user_id) {
// Formalising cannot be undone: not with tax details missing.
if (!Acme::taxDetailsComplete($invoice))
return 'Complete the tax details before formalising.';
return null;
});Following a formalised invoice
Runs after the invoice was formalised.
Hook::add('action:invoice.formalized', 10, function ($invoice, $user_id) {
// A legal record now: send it to the books here, not earlier.
Accounting::submit($invoice);
});Changing the list summary cards
Runs after the summary cards above the invoice list are worked out. What you change reaches both the cards and the visual hook that adds to that area.
Hook::add('filter:invoice.list_stats', 10, function (&$initial_stats, $stats_cards) {
// Keep reseller invoices out of the summary.
$initial_stats['unpaid']['formatted'] = Acme::excludeResellers($initial_stats['unpaid']);
});Changing the invoice document font
Runs while the invoice document is built. The default font cannot draw letters outside the Latin alphabet, so this is where you supply the right one.
Hook::add('filter:invoice.pdf_font', 10, function ($pdf, $invoice) {
// The default font cannot draw letters outside the Latin alphabet.
if (($invoice['user_data']['lang'] ?? '') === 'ru') $pdf->setDefaultFont('dejavusans');
});Adding a payment method logo
Runs while the payment method badges are drawn on the invoice screen. Add the logo of your own method here.
null when you have no logo to show. Empty text, zero and false are filtered out anyway.Hook::add('register:invoice.module_logos', 10, function () {
// Return a single address, not markup.
return Acme::assetUrl('acme-pay.svg');
});Following a customer applying a coupon
Runs after a customer applies a coupon to their own invoice. The discount is already worked in.
Hook::add('action:invoice.coupon_applied_by_client', 10,
function ($uid, $id, $coupon, $couponCtx) {
// The invoice row is left out on purpose: read it fresh if you need it.
Acme::trackDiscount($uid, $coupon['code'] ?? '', (float) ($couponCtx['discount'] ?? 0));
});Pitfalls
Changing the subtotal on the totals filter and leaving the grand total as it was makes the invoice disagree with itself: the customer reads one figure and the payment asks for another. Touching one field means going over the ones tied to it.
The name suggests items, and yet this filter hands you the discount structure. A listener expecting a list of items finds an array it does not know and quietly writes in the wrong place. Check what the second parameter is from its entry.
Where the cycle is daily, the filter and the event run again each day. A rule with no ceiling turns an unpaid invoice unpayable within weeks. Sending a notification, mind that it does not go out daily either.
A formalised invoice cannot be deleted and its amount cannot change. Sending to the books, reserving a number and archiving belong on the formalised event; work written earlier treats an invoice that is later cancelled as a legal record.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.