# Currency and Coupon Hooks

https://dev.wisecp.com/es/currency-and-coupon-hooks

The eight hooks over currency conversion, how amounts read, and coupons.

## Overview

The hooks here run on **every screen**. Currency conversion and amount formatting pass through every price the site shows, so heavy work here becomes **the whole site's** burden.

Coupons differ: they run rarely and touch **money directly**. The apply gate sits in the cart, and the save gate runs where an operator defines a coupon.

## Reference

### Stopping a coupon being applied

gatemoney.coupon_apply

`Coupon::validate()` zero for a guest

Runs before a coupon is applied to the cart.

Parameters 3

$couponarrayThe coupon record: the code, the type (`percent`, `amount`, `fixed`), the rate or amount, the currency, the auto-apply and merge marks, the product limits.

$uidintThe cart's owner. On a guest checkout it arrives as **`0`**; remember that when writing a rule about the account.

$contextarrayThe pricing context: `items` (the priced cart lines), `subtotal`, `user_currency`.

Return 1

stringA non-empty string **refuses** the coupon; the text is shown to the customer as the error.

Listener PHP

```php
Hook::add('gate:money.coupon_apply', 10, function ($coupon, $uid, $context) {
    // A guest arrives as 0: split the account rule on that first.
    if ($uid === 0) return 'Please sign in to use this coupon.';

    if (Acme::alreadyUsed($uid, $coupon['code'] ?? '')) return 'The coupon was used already.';

    return null;
});
```

### Stopping a coupon being saved

gatemoney.coupon.save

`AdminMoney` an operator defines it

Runs before an operator saves a coupon.

Parameters 4

$codestringThe coupon code.

$statearrayA snapshot of **every** value on the form: type, rate, amount, currency, limits. It is **not** the array about to be written; it is the raw form state.

$isEditboolWhether this is an edit or a new record.

$idintThe coupon id on an edit, `0` on a new one.

Return 1

stringA non-empty string **stops** the save.

Listener PHP

```php
Hook::add('gate:money.coupon.save', 10, function ($code, $state, $isEdit, $id) {
    // A discount above 90 per cent is usually a typing slip.
    if (($state['type'] ?? '') === 'percent' && (float) ($state['rate'] ?? 0) > 90)
        return 'A discount above 90 per cent wants approval.';

    return null;
});
```

### Changing the coupon about to be saved

filtermoney.coupon.save_data

`Hook::runRefs` some fields get overwritten

Runs before the coupon data is written to the database.

Parameters 3

$dataarrayrefThe coupon data about to be written: code, type, rate, amount, currency, limits. On a new record `status` and the creation date are added **after** this filter, so what you write there is **overwritten**.

$isEditboolWhether this is an edit or a new record.

$idintThe coupon id, or `0`.

Return 1

voidThe value changes **by reference**; the return is not read.

Listener PHP

```php
Hook::add('filter:money.coupon.save_data', 10, function (&$data, $isEdit, $id) {
    // Do not write status on a new record: it is overwritten AFTER this filter.
    $data['code'] = strtoupper((string) ($data['code'] ?? ''));
});
```

### Following a coupon status

actionmoney.coupon.status_changed

`AdminMoney` a copy carries its source

Runs after a coupon's status changed.

Parameters 3

$coupon_idintThe id of the coupon affected.

$source_idintThe id of the source it was copied from. Not a copy means `0`; this is how you tell a copy apart.

$statusstringThe new status.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:money.coupon.status_changed', 10,
    function ($coupon_id, $source_id, $status) {
        // A filled source means this coupon is a copy.
        if ($source_id > 0) Acme::linkCopy($coupon_id, $source_id);
    });
```

### Changing a currency conversion

filtermoney.exchange_rate

`Money::exChange()` it runs constantly

Runs after an amount was converted into another currency. It runs **on every conversion**.

Parameters 4

$convertedfloatrefThe result as worked out. What you write here is the **final** amount.

$amountfloatThe source amount.

$fromarrayThe source currency: code, rate, prefix, suffix.

$toarrayThe target currency: code, rate, prefix, suffix.

Return 1

voidThe value changes **by reference**; the return is not read.

Listener PHP

```php
Hook::add('filter:money.exchange_rate', 10,
    function (&$converted, $amount, $from, $to) {
        // It runs on EVERY conversion: no queries, no remote calls here.
        $converted = round($converted * (1 + Acme::MARGIN), 4);
    });
```

### Changing how an amount reads

filtermoney.format_output

`Money::formatter()` on every price

Runs after an amount was turned into text.

Parameters 2

$outputstringrefThe formatted text. A format you change on the server has to **match** what the browser side produces, or the figure visibly changes as the page loads.

$amountfloatThe raw amount being formatted.

Return 1

voidThe value changes **by reference**; the return is not read.

Listener PHP

```php
Hook::add('filter:money.format_output', 10, function (&$output, $amount) {
    // Where the browser side formats differently, the figure flickers on load.
    if ((float) $amount === 0.0) $output = Acme::freeLabel();
});
```

### Following a rate update

actionmoney.exchange_rates_updated

`cronjobs/ExchangeRates` the changed ones only

Runs after the exchange rates were updated.

Parameters 2

$changesarrayThe rates that **changed** in this run, keyed by currency code. With nothing changed an **empty array** arrives, and the hook still runs.

$localCodestringThe system's main currency code. The rates are expressed against it.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:money.exchange_rates_updated', 10, function ($changes, $localCode) {
    if (!$changes) return;                        // nothing moved this run

    Ops::note('fx', $localCode . ': ' . implode(',', array_keys($changes)));
});
```

### Stopping a currency being switched

gatemoney.currency.status_change

`AdminMoney` a wide effect

Runs before a currency is switched on or off.

Parameters 2

$currencyarrayThe currency record.

$statusstringThe state being asked for.

Return 1

stringA non-empty string **stops** the change.

Listener PHP

```php
Hook::add('gate:money.currency.status_change', 10, function ($currency, $status) {
    // Switching off a currency in use leaves prices with nowhere to resolve.
    if ($status !== 'active' && Acme::inUse($currency['id'] ?? 0))
        return 'Live services use this currency; it cannot be switched off.';

    return null;
});
```

### Following a coupon being saved

actioncoupon.saved

`AdminMoney` create and edit

Runs when a coupon is created or edited. Both land on the same hook, and a parameter tells you which.

Parameters 4

$idintThe id of the coupon.

$codestringThe coupon code.

$isEditboolTrue when an existing coupon was edited, false when a new one was made.

$dataarrayThe saved fields: type, rate, amount, currency, validity cycles and usage limit.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:coupon.saved', 10, function ($id, $code, $isEdit, $data) {
    // Announce only a new coupon to the campaign system.
    if (!$isEdit) Acme::publishCampaign($code, $data);
});
```

### Following a coupon being deleted

actionmoney.coupon.deleted

`AdminMoney` after deletion

Runs after a coupon is deleted.

Parameters 2

$coupon_idintThe id of the deleted coupon.

$couponarrayThe record before deletion. The coupon is gone: take the code from here if you need it.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:money.coupon.deleted', 10, function ($coupon_id, $coupon) {
    Acme::retireCampaign($coupon['code'] ?? '');
});
```

### Following the base currency changing

actionmoney.currency.local_changed

`AdminMoney` base currency

Runs when the base currency of the system changes. This is a large change: every rate is now read against the new unit.

Parameters 2

$idintThe id of the currency that became the base.

$currencyarrayThe record of that currency, **read before the change**.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:money.currency.local_changed', 10, function ($id, $currency) {
    // Every rate is now read against the new unit.
    Acme::rebaseReports($id);
});
```

### Stopping a tax rule being saved

gatemoney.tax_rule.save

`AdminMoney` before the write

Runs before a tax rule is saved. A wrong rate reaches every new invoice, which makes a check here cheap.

Parameters 3

$country_idintThe country of the rule.

$state_idintThe state or city; a **zero** means the rule covers the whole country.

$ratefloatThe total rate worked out.

Return 1

string|null**A non-empty text blocks the save** and is shown as the error. An empty return lets it carry on.

Listener PHP

```php
Hook::add('gate:money.tax_rule.save', 10, function ($country_id, $state_id, $rate) {
    // A rate outside the sane range is usually a typing slip.
    if ($rate < 0 || $rate > 40) return 'The tax rate falls outside the expected range.';

    return null;
});
```

### Following a tax rate change

actionmoney.tax_rates_changed

`AdminMoney` after the write

Runs after a tax rule is saved.

Parameters 3

$country_idintThe country of the rule.

$state_idintThe state or city; a zero covers the whole country.

$ratefloatThe new total rate.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:money.tax_rates_changed', 10, function ($country_id, $state_id, $rate) {
    Acme::syncTaxTable($country_id, $state_id, $rate);
});
```

### Adjusting a currency change

filtermoney.currency.save_data

`AdminMoney` passed by link

Runs while a currency is saved, before **only the changed fields** are written. What you hold is the difference, not the whole record.

Parameters 3

$setsarrayby linkThe difference about to be written. It **can arrive empty** when nothing changed; be ready for that.

$idintThe id of the currency.

$currencyarrayThe current record, with the values from before the difference.

Return 1

voidThe return is ignored; you write over the difference. A base currency switch is applied **after** this filter and adds further fields, so what you see here is not the final shape.

Listener PHP

```php
Hook::add('filter:money.currency.save_data', 10, function (&$sets, $id, $currency) {
    // The difference can be empty: look first.
    if (!$sets) return;

    // Pin the rate to your own source.
    if (isset($sets['rate'])) $sets['rate'] = Acme::officialRate($currency['code'] ?? '');
});
```

### Changing how an amount is written

filtermoney.digit

`Money::format` the last return wins

Runs while an amount is formatted. **Every** money value in the system passes through here: invoices, the basket, lists, documents.

Parameters 4

$amountfloatThe raw amount.

$currencyarrayThe resolved currency record.

$symbolboolWhether the symbol is shown.

$exchangemixedThe conversion target, or false when there is none.

Return 1

string|nullWhat you return **replaces** the format, and the last return wins. Return **nothing** for calls you do not care about: `null` is skipped safely. But `''`, `0` and `false` are not skipped; they are assigned and the amount **comes out blank**.

Listener PHP

```php
Hook::add('filter:money.digit', 10, function ($amount, $currency, $symbol, $exchange) {
    if (($currency['code'] ?? '') !== 'BTC') return;   // do NOT return '': it blanks the amount

    return number_format($amount, 8, '.', '');
});
```

### Changing the fetched rates

filtermoney.exchange_rates_fetch

`Money` passed by link

Runs after rates come in from the outside source and before they are saved. You can add one, change one, or drop one you do not trust.

Parameters 3

$ratesarrayby linkCode against rate, read against the base currency.

$localCodestringThe code of the base currency the rates are read against.

$targetsarrayThe currencies to be synced.

Return 1

voidThe return is ignored; you write over the list.

Listener PHP

```php
Hook::add('filter:money.exchange_rates_fetch', 10,
    function (&$rates, $localCode, $targets) {
        // Write your own official source over the outside service.
        $own = Acme::officialRates($localCode);
        foreach ($own as $code => $rate) $rates[$code] = $rate;
    });
```

### Following recurring expenses being recorded

actionexpense.recurring_recorded

`cronjobs` once per round

Runs after the recurring expense rules are processed. It fires once for **the whole round**, not per expense.

Parameters 2

$entriesarrayThe rules handled in this round, each with its rule and record id, description, amount, currency and status.

$recordedintHow many expenses were added **successfully** this round. The hook does not fire when nothing was added, so this is always at least one.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:expense.recurring_recorded', 10, function ($entries, $recorded) {
    // One call per round: send them to accounting in one batch.
    Acme::pushExpenses($entries);
});
```

## Pitfalls

> **The rate and format hooks run on every price**
> 
> A catalogue page shows hundreds of prices and these two hooks run **for each one**. A query, a file read or a remote call inside them slows the page hundreds of times over. Prepare what you need **once** and keep it in a static variable.

> **On a guest cart the account id is zero**
> 
> The account id at the coupon gate arrives as `0` on a checkout with nobody signed in. A rule like "has this customer used the coupon before" treats **every guest as one person**. Split the zero case out before writing an account rule.

> **Some fields are overwritten after the coupon filter**
> 
> On a new coupon the `status` and creation date are added **after the filter**. Values you write there vanish quietly — no error and no effect. To set a status, work from a hook that runs after the save.

> **Server and browser must format alike**
> 
> The format filter runs **on the server**. Some figures on a page are rewritten by the browser, and where the two format differently the customer watches the number **visibly change** as the page loads. Changing a format means changing both sides.

## Related Articles

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