Currency and Coupon Hooks

1 views Markdown

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
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
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
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
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
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
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
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
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
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
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
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|nullA non-empty text blocks the save and is shown as the error. An empty return lets it carry on.
Listener
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
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
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
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
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
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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.