Invoice Amount Hooks
The thirteen 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 block to the invoice document
Runs while the invoice document is built and while the customer opens the invoice page. Add an image with a title and a caption here: an e-invoice or payment QR code, a verification stamp.
image, title and description. The image is required: a PNG, JPEG or GIF data URI, or the path of such a file on the server. A web address is refused. At most four blocks appear, and their text appears as plain text.pdf for the document the server builds (the invoice email attachment, the panel download), screen for the customer's invoice page. The customer's own PDF download is a capture of that page, so a block added for screen lands in it as well.Hook::add('filter:invoice.document_blocks', 10, function (&$blocks, &$invoice, &$surface) {
// Build the QR image here; a web address is not fetched.
$png = Acme::invoiceQr($invoice);
$blocks[] = [
'image' => 'data:image/png;base64,' . base64_encode($png),
'title' => 'E-invoice QR code',
'description' => 'Scan to verify this invoice.',
];
});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
شكرًا على ملاحظاتك!
فريق الدعم متاح على مدار الساعة لمساعدتك في كل ما لم تجده أعلاه.