# Payment Hooks

https://dev.wisecp.com/es/payment-hooks

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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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.

$typestring`income` or `expense`.

$amountfloatThe amount, stripped of formatting.

$currencyintThe id of the currency.

Return 1

voidThe return is ignored.

Listener PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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.

## Related Articles

- [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks)
- Invoice and Payment Hooks
- Order Flow Hooks
