Payment Hooks

2 Aufrufe Markdown

The seven hooks where money truly arrives: the payment gates, the recorded payment row and automatic collection.

Overview

An invoice does not have to be paid in one go: part payments add up and the invoice turns paid on its own once the balance clears. So the payment hooks run more often than the invoice status hook.

The second point is who paid. On a payment made through a share link the payer has no identity; the account id the hook hands you is the invoice's owner, not the person paying.

Reference

Stopping a customer payment

gateinvoice.client_pay
ClientInvoicePay before money moves

Runs while a customer pays an invoice, before any money moves.

Parameters 4
$invoicearrayThe invoice record. The currency sits on the record itself; no separate parameter arrives.
$uidintThe invoice owner. Still the owner on a shared payment; it never points at the payer.
$methodarrayThe resolved payment method: the flow, the fee rate, the raw balance, bank accounts, gateway details.
$shareboolWhether this is a payment through a share link. With true there is no session and the payer is unknown.
Return 1
stringA non-empty string stops the payment: no fee is booked, no balance is taken, and no payment record is opened.
Listener
Hook::add('gate:invoice.client_pay', 10, function ($invoice, $uid, $method, $share) {
    // On a shared payment the payer is unknown: shape your risk rules for that.
    if ($share && (float) ($invoice['total'] ?? 0) > 5000)
        return 'This amount cannot be paid through a share link.';

    return null;
});

Stopping a bulk payment

gateinvoice.bulk_pay
ClientInvoicePay the currency arrives separately

Runs while a customer pays several invoices together.

Parameters 3
$invoicesarrayThe invoices about to be paid.
$cidintThe currency of the selection. Unlike the single payment gate it arrives as its own parameter, because the selection is gathered in one currency.
$methodarrayThe resolved payment method.
Return 1
stringA non-empty string stops the bulk payment.
Listener
Hook::add('gate:invoice.bulk_pay', 10, function ($invoices, $cid, $method) {
    if (count($invoices) > 50) return 'At most 50 invoices are paid at once.';

    return null;
});

Changing the payment row before it is written

filterinvoice.payment_data
Hook::runRefs by reference

Runs before the payment row is written. After that row the invoice can turn paid on its own.

Parameters 2
$payment_rowarrayrefThe payment about to be written: owner_id, amount_in, currency, rate, fees, pmethod, transaction_id, paid_at.
$invoicearrayThe invoice the payment applies to.
Return 1
voidThe value changes by reference; the return is not read. Changing the amount decides whether the invoice counts as paid.
Listener
Hook::add('filter:invoice.payment_data', 10, function (&$payment_row, $invoice) {
    // Carry your own reference; changing the amount decides the paid outcome.
    $payment_row['transaction_id'] = Acme::ref($payment_row['transaction_id'] ?? '');
});

Learning that a payment was recorded

actioninvoice.payment_recorded
Invoices it can be partial

Runs after the payment row was written. The invoice can still be unpaid at this point.

Parameters 3
$payment_idintThe id of the new payment record.
$invoice_idintThe invoice the payment went to.
$paymentarrayThe recorded payment: amount, currency, rate, method, transaction number, time, fees, who recorded it. The amount is not necessarily the whole invoice.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.payment_recorded', 10,
    function ($payment_id, $invoice_id, $payment) {
        // It can be partial: check the balance before saying "invoice closed".
        Accounting::received($invoice_id, (float) ($payment['amount_in'] ?? 0));
    });

Following a payment added

actioninvoice.payment_added
AdminInvoices flat parameters

Runs after a payment was added to an invoice. Unlike the previous hook the values arrive separately.

Parameters 5
$invoice_idintThe invoice id.
$amountfloatThe amount, in the invoice's currency.
$currencyIdintThe id of the invoice currency.
$pmethodstringThe payment method.
$txn_idstringThe external transaction number.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.payment_added', 10,
    function ($invoice_id, $amount, $currencyId, $pmethod, $txn_id) {
        Accounting::line($invoice_id, (float) $amount, (int) $currencyId, $pmethod);
    });

Following a payment deleted

actioninvoice.payment_deleted
AdminInvoices the invoice id can be 0

Runs after a payment record was deleted.

Parameters 2
$payment_idintThe id of the deleted payment.
$invoice_idintThe invoice it belonged to. Where the payment was not tied to one it arrives as 0.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.payment_deleted', 10, function ($payment_id, $invoice_id) {
    if ($invoice_id === 0) return;                 // tied to no invoice

    Accounting::reversed($invoice_id, (int) $payment_id);
});

Following an automatic collection

actioninvoice.auto_payment_attempted
cronjobs/InvoiceAutoPayment the attempt outcome

Runs after an automatic payment was attempted — whether it worked or not.

Parameters 3
$invoice_idintThe invoice attempted.
$final_statusstringThe outcome: paid or unpaid. Read the attempt's success from here.
$resultarrayThe step-by-step outcome: the balance step and the card step separately (outcome, amount, error), plus the starting and closing balance. Why a card failed is answered here.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.auto_payment_attempted', 10,
    function ($invoice_id, $final_status, $result) {
        // The card step's error arrives apart: tell the customer the real reason.
        if ($final_status !== 'paid')
            Dunning::failed($invoice_id, $result['card_step']['error'] ?? '');
    });

Following a bank transfer notice

actioninvoice.bank_transfer_notified
ClientInvoicePay the customer says so

Runs when a customer says they sent a transfer. No money has arrived: this is a claim, and the invoice stays unpaid.

Parameters 3
$idintThe id of the invoice the notice is about.
$uidintThe owner of the invoice. With a share link somebody else may have sent the notice; this value still points at the owner.
$transferarrayThe notice payload: bank id and name, sender name and the transfer reference. That reference is the key for matching a bank statement: the invoice number for a single payment, one shared value for a bulk one.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.bank_transfer_notified', 10, function ($id, $uid, $transfer) {
    // No money yet, only a claim: match the statement on the reference.
    Acme::watchStatement($transfer['rce'] ?? '', $id);
});

Following a cash record

actioninvoice.cash_recorded
AdminMoney cash record

Runs when an income or expense record is entered into the books by hand.

Parameters 4
$inex_idintThe id of the record created.
$typestringincome or expense.
$amountfloatThe amount, stripped of formatting.
$currencyintThe id of the currency.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.cash_recorded', 10,
    function ($inex_id, $type, $amount, $currency) {
        Acme::postToLedger($inex_id, $type, $amount, $currency);
    });

Following a refund through the gateway

actioninvoice.refunded_via_module
AdminInvoices through the gateway

Runs when an invoice is refunded through the payment gateway. The money really went back.

Parameters 2
$invoicearrayThe refunded invoice.
$pmethodstringThe payment module that handled it.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.refunded_via_module', 10, function ($invoice, $pmethod) {
    Acme::recordRefund((int) ($invoice['id'] ?? 0), $pmethod);
});

Following a payment method change

actioninvoice.gateway_changed
AdminInvoices method change

Runs after the payment method on an invoice changes.

Parameters 4
$idintThe invoice id.
$oldPmethodstringThe previous method; empty when none was set.
$newPmethodstringThe new method.
$invoicearrayThe invoice record as it was before the change.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:invoice.gateway_changed', 10,
    function ($id, $oldPmethod, $newPmethod, $invoice) {
        // Close the pending session at the old gateway.
        if ($oldPmethod !== '') Acme::dropPendingSession($id, $oldPmethod);
    });

Following the auto-payment order of a card

actionpayment.card_autopay_changed
AccountCards chain change

Runs when a card moves within the auto-payment chain. That chain decides which card is tried first at renewal.

Parameters 3
$uidintThe owner of the card.
$cardIdintThe id of the record.
$actionstringWhat happened: on joined the chain, off left it, promote moved to the front.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.card_autopay_changed', 10, function ($uid, $cardId, $action) {
    // An empty chain leaves the customer with no way to renew.
    if ($action === 'off') Acme::warnIfChainEmpty($uid);
});

Following a subscription charge

actionsubscription.payment_recorded
SubscriptionPoll from the agreement

Runs after a subscription charge from the gateway is processed. The result need not be a success: rejected and repeated notices land here too.

Parameters 2
$identifierstringThe subscription identifier from the gateway.
$resultarrayThe outcome: its status, the invoice and payment ids, and a reason when there is one. The status may be paid, but it may equally be partial, duplicated or rejected. Do not assume success.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:subscription.payment_recorded', 10, function ($identifier, $result) {
    // Do not assume success: the status may be a rejection.
    if (($result['status'] ?? '') !== 'paid') return;

    Acme::confirmCharge($identifier, (int) ($result['invoice_id'] ?? 0));
});

Pitfalls

A payment record does not mean the invoice closed

Part payments add up as separate rows and the hook runs on each. The invoice turns paid only once the balance clears, and that moment shows on the status hook. Writing "payment arrived, open the service" here opens it on an underpayment.

On a shared payment the payer is unknown

The account id on the payment gate is always the invoice owner. The person paying through a share link can be somebody else entirely, with no session. Answering "who paid this" from that id points at the wrong person.

The two payment events have different shapes

One hands the payment over in a single array, the other gives the amount, currency, method and transaction number as separate parameters. Writing the wrong one drops the listener and looks like "it does not work".

Automatic collection has two steps

The balance is tried first, then the card. The result array reports both separately. Before telling a customer "your card was declined", read what the balance step did: the invoice may have been partly cleared from it.

War das hilfreich?

Vielen Dank für Ihre Rückmeldung!

Brauchen Sie weitere Hilfe?

Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.