# Invoice Lifecycle Hooks

https://dev.wisecp.com/es/invoice-lifecycle-hooks

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_typestring`service` 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 PHP

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

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

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

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

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

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

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

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

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

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

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

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

```php
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|null**A non-empty text blocks the change** and is shown to the administrator as the error. An empty return lets it carry on.

Listener PHP

```php
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|null**A 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 PHP

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

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

## Related Articles

- Invoice and Payment Hooks
- [Service Renewal Hooks](https://dev.wisecp.com/en/service-renewal-hooks)
- Order Flow Hooks
