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
Runs before a coupon is applied to the cart.
percent, amount, fixed), the rate or amount, the currency, the auto-apply and merge marks, the product limits.0; remember that when writing a rule about the account.items (the priced cart lines), subtotal, user_currency.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
Runs before an operator saves a coupon.
0 on a new one.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
Runs before the coupon data is written to the database.
status and the creation date are added after this filter, so what you write there is overwritten.0.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
Runs after a coupon's status changed.
0; this is how you tell a copy apart.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
Runs after an amount was converted into another currency. It runs on every conversion.
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
Runs after an amount was turned into text.
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
Runs after the exchange rates were updated.
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
Runs before a currency is switched on or off.
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
Runs when a coupon is created or edited. Both land on the same hook, and a parameter tells you which.
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
Runs after a coupon is deleted.
Hook::add('action:money.coupon.deleted', 10, function ($coupon_id, $coupon) {
Acme::retireCampaign($coupon['code'] ?? '');
});Following the base currency changing
Runs when the base currency of the system changes. This is a large change: every rate is now read against the new unit.
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
Runs before a tax rule is saved. A wrong rate reaches every new invoice, which makes a check here cheap.
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
Runs after a tax rule is saved.
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
Runs while a currency is saved, before only the changed fields are written. What you hold is the difference, not the whole record.
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
Runs while an amount is formatted. Every money value in the system passes through here: invoices, the basket, lists, documents.
null is skipped safely. But '', 0 and false are not skipped; they are assigned and the amount comes out blank.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
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.
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
Runs after the recurring expense rules are processed. It fires once for the whole round, not per expense.
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
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.
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.
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.
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
- Order Flow Hooks
- Invoice and Payment Hooks
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.