Currency and Coupon Hooks
The eighteen 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;
});Stopping a tax rule being deleted
Runs before a tax rule is removed, from the panel and from the admin API alike. It runs before the rule is looked up, so it also fires for a rule that does not exist. The country-wide rule of the installation's own country never reaches it.
$state_id, it is not an int — do not type-hint it as one.blocked_by_gate. Any other return lets it carry on.Hook::add('gate:money.tax_rule.delete', 10, function ($country_id, $state) {
// A rule kept in step with an outside tax service is removed there, not here.
if (Acme::managesTaxRule((int) $country_id, (string) $state)) return 'This tax rule is managed by the tax service.';
return null;
});Following a tax rate change
Runs after a tax rule is saved or deleted, from the panel and from the admin API alike. On a delete the rate arrives as zero.
Hook::add('action:money.tax_rates_changed', 10, function ($country_id, $state_id, $rate) {
// A zero rate is a deleted rule (or a rule saved at zero).
if ((float) $rate === 0.0) {
Acme::removeTaxRule($country_id, (string) $state_id);
return;
}
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. Since 5.0-beta.4 a non-numeric return ('', false, a word) is ignored and the current value is kept; up to 5.0-beta.3 returning '' broke the page. 0 is valid and shows the amount without decimals.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
¡Gracias por sus comentarios!
Nuestro equipo de soporte está disponible las 24 horas para ayudarle con lo que no encuentre aquí.