Invoice Amount Hooks

1 views Markdown

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

filterinvoice.totals
Invoices before they are saved

Runs after the totals were worked out, before they are written to the invoice.

Parameters 3
$totalsarrayrefThe totals about to be written: 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.
$invoicearrayThe invoice row: the tax rate, whether it is formalised, the currency, the commission rate. Context you cannot change.
$itemsarrayThe items used in the arithmetic. Context you cannot change.
Return 1
voidThe value changes by reference; the return is not read.
Listener
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

filterinvoice.items
AdminInvoices the discount payload

Runs while an invoice is edited, before the discounts are saved.

Parameters 4
$idintThe id of the invoice being edited.
$invDiscountsarrayrefThe discounts about to be saved, including the per-item custom ones. Despite the name this is the discount structure, not a list of items.
$pendingCustomDiscountsarrayThe custom discounts keyed by item id.
$invoicearrayThe current invoice row.
Return 1
voidThe value changes by reference; the return is not read.
Listener
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

filterinvoice.late_fee_amount
cronjobs/InvoiceLateFee rounded afterwards

Runs after the late fee was worked out, before it is added to the invoice as an item.

Parameters 3
$feefloatrefThe raw late fee in the invoice's currency. It is rounded again after the filter, so no need to fuss over the last decimal here.
$invoicearrayThe invoice row: the subtotal, currency and owner.
$cyclestringThe fee cycle: once, or daily. On a daily cycle this hook runs every day.
Return 1
voidThe value changes by reference; the return is not read.
Listener
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

actioninvoice.late_fee_applied
cronjobs/InvoiceLateFee an item was created

Runs after the late fee was added to the invoice as an item.

Parameters 4
$invoice_idintThe invoice the fee went on.
$fee_amountfloatThe amount added — the final value after the filter.
$fee_typestringpercentage or fixed.
$item_idintThe id of the created invoice item.
Return 1
voidThe return is ignored.
Listener
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

filterinvoice.renewal_amount
Invoices the unit price

Runs after a service's renewal price was resolved.

Parameters 1
$resultarrayrefThe price result: amount (the unit price), quantity, currency, taxexempt, additional_taxes, discounts, pricing_source, period_time. The amount is per unit; the total is multiplied afterwards.
Return 1
voidThe value changes by reference; the return is not read.
Listener
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

filterinvoice.renewal_description
Invoices it shows on the invoice

Runs after the renewal item's description was built.

Parameters 1
$descriptionstringrefThe item description. The text the customer reads on the invoice, so write it in their language.
Return 1
voidThe value changes by reference; the return is not read.
Listener
Hook::add('filter:invoice.renewal_description', 10, function (&$description) {
    $description .= ' — ' . Acme::periodNote();
});

Stopping an invoice being formalised

gateinvoice.formalize
AdminInvoices no way back

Runs before an invoice is formalised. After this step it can be neither changed nor deleted.

Parameters 2
$invoicearrayThe invoice about to be formalised.
$user_idintThe id of the person acting.
Return 1
stringA non-empty string stops the formalising; the text is thrown as the error.
Listener
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

actioninvoice.formalized
AdminInvoices read afresh

Runs after the invoice was formalised.

Parameters 2
$invoicearrayThe invoice read afresh with its formalised mark, including the document file where one was produced.
$user_idintThe id of the person who acted.
Return 1
voidThe return is ignored.
Listener
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

filterinvoice.list_stats
admin/invoices passed by link

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.

Parameters 2
$initial_statsarrayby linkThe card data: unpaid, paid and overdue, each with a formatted total and a count.
$stats_cardsarrayThe card setup: type and period.
Return 1
voidThe return is ignored; you write over the array.
Listener
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

filterinvoice.pdf_font
Invoices::create_pdf through the object

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.

Parameters 2
$pdfobjectThe document builder. Being an object it changes without a by-link mark: calling a method on it is enough.
$invoicearrayThe invoice record. It carries the customer’s stored language; it is a copy, so changing it has no effect.
Return 1
voidThe return is ignored. You make the change by calling a method on the object.
Listener
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

registerinvoice.module_logos
templates/admin returns one URL

Runs while the payment method badges are drawn on the invoice screen. Add the logo of your own method here.

Parameters 0
It takes no parameters.
Return 1
string|nullYou return a single address, not markup: the template places it in an image. Return null when you have no logo to show. Empty text, zero and false are filtered out anyway.
Listener
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

actioninvoice.coupon_applied_by_client
ClientInvoices applied by the customer

Runs after a customer applies a coupon to their own invoice. The discount is already worked in.

Parameters 4
$uidintThe customer who applied it.
$idintThe invoice id. The invoice itself is left out on purpose: by the time the hook runs the totals have moved, so a copy would be stale. Read it fresh if you need it.
$couponarrayThe coupon record. Its amount field may have shifted during the application.
$couponCtxarrayWhat the application produced: the discount, the currency and the lines it touched.
Return 1
voidThe return is ignored.
Listener
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 one figure does not fix the rest

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 discount filter is mistaken for an item list

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.

A daily late fee runs every day

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.

Formalising is a point of no return

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.