Invoice Lifecycle Hooks

2 vues Markdown

Eight hooks from an invoice being raised to it being deleted: the creation gate, two separate creation events, status changes and deletion.

Overview

The first thing to watch on the invoice hooks is that there are two separate creation events. One runs on renewal invoices alone, the other on every invoice. Their names look alike and they are often mixed up.

The second is where the money sits: raising an invoice is not taking payment. Payment arrives on its own hooks and the status settles when it turns paid.

Reference

Skipping a renewal invoice

gateinvoice.create
cronjobs/InvoiceGenerate the job returns cancelled

Runs before a renewal invoice is raised. This gate sits in a scheduled task, so stopping it raises no error and skips the job.

Parameters 3
$target_typestringservice or addon.
$target_idintThe id of the record being invoiced.
$duedatestringThe renewal due date.
Return 1
stringA non-empty string skips the invoice: the job ends as cancelled and your text is recorded as the reason. The customer sees nothing; the invoice is never raised.
Listener
Hook::add('gate:invoice.create', 10, function ($target_type, $target_id, $duedate) {
    // Raising a renewal while a cancellation is open only annoys the customer.
    if ($target_type === 'service' && Acme::cancelPending($target_id))
        return 'A cancellation is open; no renewal invoice was raised.';

    return null;
});

Changing the invoice record before it is written

filterinvoice.create_payload
Hook::runRefs by reference

Runs before the invoice row is written to the database.

Parameters 1
$dataarrayrefThe record about to be written: user_id, user_data, currency, status, the totals, pmethod. The JSON fields are already encoded here; do not expect a raw array.
Return 1
voidThe value changes by reference; the return is not read.
Listener
Hook::add('filter:invoice.create_payload', 10, function (&$data) {
    // Add your own reference; the JSON fields arrive encoded already.
    $data['notes'] = trim(($data['notes'] ?? '') . ' ' . Acme::stamp());
});

Catching every invoice

actioninvoice.created.any
Invoices::create() all of them

Runs after every invoice is written: renewals, manual ones, order invoices, all of them.

Parameters 2
$invoice_idintThe new invoice id. Where the write failed it arrives as 0, so check before using it.
$dataarrayThe data that was written, with its JSON fields encoded.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.created.any', 10, function ($invoice_id, $data) {
    if ($invoice_id === 0) return;                 // the write failed

    Accounting::mirror($invoice_id, $data);
});

Catching a renewal invoice

actioninvoice.created
cronjobs/InvoiceGenerate renewals only

Runs after a renewal invoice was raised. Not for every invoice: manual and order invoices do not arrive here.

Parameters 1
$invoice_idintThe id of the invoice raised or merged into. Several renewals can have been merged into one.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.created', 10, function ($invoice_id) {
    // These are renewals ONLY; for all invoices use the .any hook.
    Dunning::scheduleReminder((int) $invoice_id);
});

Catching a manually raised invoice

actioninvoice.created_manually
AdminInvoices an operator raised it

Runs where an operator raised an invoice from the panel.

Parameters 2
$invoice_idintThe id of the raised invoice.
$dataarrayThe invoice data.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.created_manually', 10, function ($invoice_id, $data) {
    // A manual invoice is reported apart from the automatic ones.
    Accounting::tagManual((int) $invoice_id);
});

Stopping an invoice status change

gateinvoice.status_change
Invoices::change_status() both states

Runs before an invoice status changes.

Parameters 4
$invoicearrayThe invoice record, decoded.
$statusstringThe target: paid, unpaid, cancelled, refund, waiting.
$old_statusstringThe current status.
$optionsarrayThe transition options: notification, payment method, refund route, who is acting. Context you cannot change.
Return 1
stringA non-empty string stops the change; the text is thrown as the error.
Listener
Hook::add('gate:invoice.status_change', 10,
    function ($invoice, $status, $old_status, $options) {
        // Taking a paid invoice back breaks the books.
        if ($old_status === 'paid' && $status === 'unpaid')
            return 'A paid invoice cannot go back to unpaid.';

        return null;
    });

Following an invoice status

actioninvoice.status_changed
Invoices::change_status() a freshly read record

Runs after the status was written.

Parameters 4
$invoicearrayThe invoice read afresh after the write. Unlike many other hooks, the record here is current.
$statusstringThe new status.
$old_statusstringThe previous status.
$optionsarrayThe transition options: payment method, refund route, who acted, notification.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.status_changed', 10,
    function ($invoice, $status, $old_status, $options) {
        // Did money REALLY arrive? React to the move into paid alone.
        if ($status === 'paid' && $old_status !== 'paid')
            Accounting::settled((int) ($invoice['id'] ?? 0), $options['pmethod'] ?? '');
    });

Stopping an invoice delete

gateinvoice.delete
Invoices::delete() it may be a legal record

Runs before an invoice is deleted.

Parameters 2
$idintThe id of the invoice to be deleted.
$invoicearrayThe invoice record: status, taxed, total. Deleting a formalised invoice leaves no legal trail; check these fields.
Return 1
stringA non-empty string stops the delete; the text is thrown as the error.
Listener
Hook::add('gate:invoice.delete', 10, function ($id, $invoice) {
    // A formalised invoice is not deleted; it is cancelled.
    if (!empty($invoice['taxed'])) return 'A formalised invoice cannot be deleted.';

    return null;
});

Following an invoice deletion

actioninvoice.deleted
Invoices::delete after deletion

Runs after an invoice is deleted. The record is gone, so the snapshot you hold is the last copy of it.

Parameters 2
$idintThe id of the deleted invoice.
$invoicearrayThe snapshot from before the deletion. You cannot go back to the database for it; take what you need from here.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.deleted', 10, function ($id, $invoice) {
    // The record is gone: take what you need from the snapshot.
    Acme::voidInAccounting($id, $invoice['number'] ?? '');
});

Following lines being split off

actioninvoice.items_split
AdminInvoices two invoices

Runs after lines are moved from one invoice into a new one. There are two invoices now.

Parameters 3
$source_invoice_idintThe invoice the lines came from.
$new_invoice_idintThe newly created invoice.
$item_idsarrayThe ids of the lines that moved.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.items_split', 10,
    function ($source_invoice_id, $new_invoice_id, $item_ids) {
        // Both invoices need to reach accounting separately.
        Acme::resync($source_invoice_id);
        Acme::resync($new_invoice_id);
    });

Following a renewal invoice being made

actioninvoice.renewal_generated
generate_renewal renewal

Runs after a renewal invoice is made, or after a line joins one that already exists. Both services and add-ons land here.

Parameters 3
$invoice_idintThe id of the invoice made or joined.
$target_typestringservice or addon.
$targetarrayThe record being renewed. One invoice can take several lines: the hook fires per line while the invoice id stays the same.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.renewal_generated', 10,
    function ($invoice_id, $target_type, $target) {
        // The same invoice can arrive more than once: make this safe to repeat.
        Acme::noteRenewal($invoice_id, $target_type, (int) ($target['id'] ?? 0));
    });

Following a payment reminder

actioninvoice.reminder_sent
cronjobs on each reminder

Runs after a payment reminder goes out. It can fire more than once for the same invoice, because reminders repeat.

Parameters 1
$invoicearrayThe invoice reminded about: its number, owner, total, status and due date.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.reminder_sent', 10, function ($invoice) {
    // Nudge over a second channel as well.
    Acme::smsReminder((int) ($invoice['user_id'] ?? 0), $invoice['number'] ?? '');
});

Following a customer opening an invoice

actioninvoice.viewed_by_client
website/invoices on every opening

Runs when a customer views an invoice. Knowing an invoice was opened is a useful signal for reminder and collection flows.

Parameters 3
$invoicearrayThe invoice record.
$idintThe id of the invoice.
$ctxarrayThe viewing context. To add data to the template use the view data filter that runs right after, not this hook.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.viewed_by_client', 10, function ($invoice, $id, $ctx) {
    // Knowing it was read softens the collection flow.
    Acme::markSeen($id, (int) ($invoice['user_id'] ?? 0));
});

Stopping a status change

gateinvoice.update_status
AdminInvoices before the write

Runs while an administrator changes an invoice status by hand. Marking paid, cancelling and refunding all pass through this gate.

Parameters 4
$idintThe invoice id.
$statusstringThe target status: paid, unpaid, refund or cancelled.
$pmethodstringThe method chosen when marking as paid.
$refund_methodstringThe method chosen when refunding.
Return 1
string|nullA non-empty text blocks the change and is shown to the administrator as the error. An empty return lets it carry on.
Listener
Hook::add('gate:invoice.update_status', 10,
    function ($id, $status, $pmethod, $refund_method) {
        // No manual refunds once the period is closed.
        if ($status === 'refund' && Acme::periodClosed($id))
            return 'This period is closed, so the refund belongs in accounting.';

        return null;
    });

Stopping a share link

gateinvoice.share_view
ClientInvoices access without login

Runs when an invoice is opened through a share link. Whoever opens it may not be logged in: the only thing standing behind that access is the secret in the address.

Parameters 5
$invoicearrayThe resolved invoice record.
$idintThe invoice id.
$ownerintThe account that owns the invoice.
$tokenstringThe share value. Keep it out of your own records: whoever sees it can open both the invoice and the payment page.
$isOwnerboolWhether the opener is the signed-in owner. A false here means access without a session; tighten your check accordingly.
Return 1
string|nullA non-empty text cuts the access: the view becomes a not-found page and the payment is blocked. An empty return lets it carry on.
Listener
Hook::add('gate:invoice.share_view', 10,
    function ($invoice, $id, $owner, $token, $isOwner) {
        // Hold access without a session to your own address list.
        if (!$isOwner && !Acme::ipAllowed()) return 'access denied';

        return null;
    });

Changing the customer details on an invoice

filterinvoice.client_details
AdminInvoices passed by link

Runs once the customer and address details are prepared for an invoice, before they are saved. What you write here becomes a permanent part of the invoice.

Parameters 2
$merged_dataarrayby linkWhat goes onto the invoice: name, surname, email, tax number, address.
$invoicearrayThe invoice being edited.
Return 1
voidThe return is ignored; you write over the array. An invoice is an archive document: a wrong value written here does not correct itself later, it stays with the invoice.
Listener
Hook::add('filter:invoice.client_details', 10, function (&$merged_data, $invoice) {
    // Match the company title to the official name in accounting.
    $official = Acme::officialName($merged_data['company_tax_number'] ?? '');
    if ($official !== '') $merged_data['company'] = $official;
});

Pitfalls

Two creation events, two different scopes

The plainly named event runs on renewals only, while the one ending in .any runs on every invoice. An accounting mirror, an outside sync or anything wanting a full count belongs on .any — otherwise manual and order invoices slip past quietly.

A zero id means the write failed

The .any event runs even where the database write failed, with the id arriving as 0. Using it unchecked means writing against an invoice that does not exist.

Raising an invoice is not taking money

On the creation hooks the invoice is unpaid. Opening a service, switching an add-on on or saying "thank you" does not belong here. Money arrives when the status turns paid.

A change to the same status still runs the hook

The status event can run even where the new and old statuses are the same. You see it where a payment page is called twice or a job runs again. A listener reacting without comparing the two does its work twice.

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.