Currencies
A store can sell in more than one currency. List the extra currencies, then type a price in each or let KahunaCart work the price out from your base price. Shoppers browse and pay in their own currency. The base currency is the currency setting, used for your original catalogue prices and costs. Turning extra currencies off stops new carts in them. Existing carts, orders and saved amounts keep their own currencies.
This page is one guide, in the order you would meet it: what it does and doesn't do, turning it on, how a shopper's currency is chosen, typing the prices, everything else that carries an amount per currency, switching with a cart open, paying, orders and refunds, reports, caching, the API and agents, the add-ons, exchange rates and converted prices, and what to check when something looks wrong. The last section is for developers.
What it does, and what it does not
- You choose typed or converted prices for each currency. A shopper in euro sees and pays the euro price. A price you typed always wins. A currency set to convert works out the rest from your base price. An untyped free base price stays free. See Converted prices.
- Each order is in exactly one currency, chosen when the cart is created. Every amount on it (lines, shipping, tax, payments, refunds) is in that currency. A cart is never a mix of two currencies, and a shopper cannot pay an order in a currency other than the one it was priced in. See Money conventions.
- Orders, refunds and order changes stay in the currency they were placed in. So do the invoice, the credit note and every email about the order.
- Reports show one currency at a time, or everything in the base currency. The "all" view uses the rate each order was placed at, and counts orders that have no rate separately. See Reports and totals.
- A currency can have its own company and payment account. Issued orders keep that company's details and its invoice book. See Selling through more than one company.
And what to know about prices:
- A currency that is set to type its prices needs an amount for each product. A priced variant with no amount in that currency says "Not available in EUR". A variant with a base price of 0 is free in every currency unless you type an amount for that currency. The typed amount wins.
- A currency that is set to convert needs a rate. With no rate, an unpriced product stays unavailable in that currency. KahunaCart never guesses a price.
Turning it on
In this order:
- List the currencies under
currencies.enabled(see The settings). Everything on this page switches on as soon as the list has a currency in it. - Type a price in each currency, or turn on conversion, for every product you want to sell there. To type them, see Pricing a product in another currency. Bulk edit and the CSV import fill a whole catalogue in one go. To convert, pick a rate source and then choose Convert from the base price for the currency on the Currencies screen. See Exchange rates and Converted prices.
- Type the other amounts that go with it: shipping rates, coupons, payment fees, and the order minimum if you have one. See Everything else with an amount per currency. Anything you leave empty means "doesn't apply in that currency", which is safe but sometimes surprising.
- Rebuild the catalog index:
bin/plugin kahunacart index --rebuild, or Rebuild everything on the Search screen. A second currency turns the index on, and until it has been rebuilt shoppers in the new currency get their listings sorted newest first with no price range. See Sorting and filtering by price. - Check there is a way to pay in each currency.
bin/plugin kahunacart statusand the Payments screen list, for every currency, which payment methods take it. See Paying in another currency. - Put the switcher in your theme's header. Core's own pages don't draw it. See The switcher and Theming.
- If you use a CDN or a full-page cache, read Caching and CDNs.
bin/plugin kahunacart seed --currencies=EUR,GBP does the second and third steps for a demo store and prints the config block for the first. See Demo store.
The settings
They live in Configuration → General → Selling in More Than One Currency, and in the currencies block of user/config/plugins/kahunacart.yaml:
currencies:
enabled: [EUR, GBP]
detect: [choice, address, header]
header: CF-IPCountry
map:
EUR: [AT, BE, DE, EE, ES, FI, FR, IE, IT, NL, PT]
GBP: [GB]
lock_to_country: false
missing_price: unavailable
| Key | Default | Effect |
|---|---|---|
currencies.enabled |
[] |
The extra currencies the store sells in, besides the base, as ISO 4217 codes. Empty stops new carts in extra currencies; existing carts, orders and saved rules keep their own currencies. The base currency is ignored if you list it, and a code that is not a currency is left out and written to the Grav log, so a typo costs you one currency and never the page. |
currencies.detect |
[choice, address, header] |
The three ways a visitor's currency can be worked out, tried in the order you list them. Leave one out to switch it off. See How the currency is chosen. |
currencies.header |
empty | The request header that carries the visitor's two-letter country code, such as CF-IPCountry on Cloudflare. Empty switches the header step off. |
currencies.map |
{} |
Which countries sell in which currency, as the example above shows. The settings screen takes the countries as text, DE, FR, NL. A country listed under two currencies belongs to the first, a country the map does not name is in the base currency, and a currency in the map that isn't in currencies.enabled is ignored. |
currencies.lock_to_country |
false |
On, the country on the order decides the currency at checkout, whatever the shopper picked. See Letting the shipping country decide. |
currencies.missing_price |
unavailable |
What a product with no price in the shopper's currency does: unavailable, or convert. With unavailable, each currency chooses typed or converted pricing on its own. convert makes every enabled currency that has a rate convert the amounts you haven't typed, keeping its markup and rounding. See Converted prices. |
currencies.rates.source |
manual |
Where the exchange rates between your currencies come from: manual (you type each one on the Currencies screen), ecb (the European Central Bank's daily rates, free, no key), frankfurter (central banks' daily rates for over 160 currencies, free, no key) or the code of a source an add-on registered. With anything but manual the worker fetches the rates once a day. See Exchange rates. |
currencies.rates.fallback |
frankfurter |
The source asked for the rates your source doesn't answer, and for all of them when your source can't be reached. none (or empty) means no fallback. Any registered source but manual. A fallback that is the source itself is ignored, and nothing reads it on the manual source. See Where the rates come from. |
currencies.rates.frankfurter_url |
https://api.frankfurter.dev/v2 |
Where Frankfurter's v2 API is, for a store that runs its own copy. A trailing slash is trimmed, and an address that isn't http:// or https:// reads as the default. The store only connects over https to a public address, so a copy on localhost or a private network can't be used. |
currencies.rates.frankfurter_providers |
empty | One Frankfurter provider id, such as ECB or NBP, to use that bank's own rate instead of Frankfurter's blend. Letters, digits and commas only. Anything else reads as empty. |
currencies.rates.hold_move_percent |
'10' |
How far, in percent, a fetched rate may move from the one in use before a refresh holds it for you to check, instead of writing it. A quoted decimal string, never less than 2. Anything that isn't a plain number reads as 10. The rest of the refresh is written as usual. Replaces max_change_factor, which is no longer read. See A big move is held for you to check. |
currencies.rates.manual_drift_percent |
'5' |
How far, in percent, a rate you typed may be from the market rate before the dashboard shows an information line. A quoted decimal string. Anything that isn't a plain number reads as 5. Only on a store that fetches its rates, and your typed rate is always the one used. See Typing a rate. |
currencies.rates.max_age_days |
3 |
How many working days (Monday to Friday) past the day its source published it a fetched rate may get before the dashboard shows an alert and you get a notification. Friday's rate isn't old on Monday. At least 1. A rate you typed never counts as old. |
currencies.pricing |
{} |
The pricing choices for each currency, saved on the Currencies screen. A currency with no entry is typed pricing with no markup and no extra rounding. See Converted prices. |
The map ships empty. The one in the example is a starting point to copy, not a default.
Checking the setup
bin/plugin kahunacart status has three lines for a store that sells in more than one currency. Catalog index, other currencies says whether the index has prices for each of them, or which were added since it was built. Payment methods by currency lists, for each currency, the payment methods that take it, and warns about any currency that nothing takes, because a shopper in it can't check out. The Payments screen shows the same coverage above the method cards. Exchange rates names the rate source and warns about any currency whose fetched rate is too old or missing. A store that reads a country header also gets a Currency and caching line. See CLI → status.
How the currency is chosen
For each request the first of these that answers wins:
- The cart. A shopper with a cart open that has something in it is in the cart's currency. Nothing else is looked at while that cart is open. A cart with nothing in it decides nothing: it does not hold the shopper to a currency they only picked by adding something once.
- What the visitor chose.
?currency=EURin the address, or thekc_currencycookie from an earlier choice. The address beats the cookie. A code the store does not sell in is ignored. - Their address. A signed-in customer's default shipping address, then their default billing address, looked up in
currencies.map. - The country header, when
currencies.headernames one, looked up incurrencies.map. - The base currency.
Steps 2 to 4 are the three entries of currencies.detect, and they run in the order that list gives. The shipped order is choice, address, header, so what a shopper picked always wins and the address and header only decide for someone who hasn't picked. List address first if a signed-in customer's address should decide whatever they pick. The cart and the base currency are not on the list: the cart always comes first and the base always comes last.
The cookie
kc_currency holds a visitor’s chosen currency for a year. It is set only after an explicit choice on a private response or a switcher redirect, when functional cookies are allowed. A public ?currency=EUR page sends no currency cookie. Browsing, an address or a country header never sets it. Neither does a crawler or a single-currency store. KahunaCart does not start a session just to remember a currency.
On a store that sells in more than one currency it is declared to grav-plugin-consent in the functional category, as "Chosen currency", and a visitor who withdraws functional consent has it expired on the server, the way the wishlist cookie is. A visitor who has not allowed functional cookies is not sent it at all. They still get the page in the currency their address names, and every link in the switcher carries the currency on its address, so declining costs them nothing but picking again on their next visit. A single-currency store declares nothing for it.
A store with no foreign-currency history
A store with no extra currencies or foreign-currency history uses the base currency. After a currency is disabled, existing carts and orders still use their own currency. Filters and report notes remain where the store has foreign-currency history.
Amounts in other currencies
Base amounts stay in their original fields. Other amounts live in kahunacart_currency_amounts, with one row per owner, field and currency. For example, variant 42, price_minor, EUR, 1850 means €18.50. Disabling a currency does not delete its rows. Editors preserve saved amounts when replacing their owners’ rows; deleting an owner removes them.
In typed pricing, a missing row means the rule does not apply in that currency. The one exception is a variant whose base price is exactly 0: it is free in every enabled currency unless an amount was typed for that currency, which wins, including a typed 0. This does not apply to compare-at prices or any other amount. A priced variant with no EUR amount has no EUR price, a fixed-amount coupon with no EUR amount does not apply to a EUR order, and a shipping method with no EUR rate is not offered. A missing amount never means "use the base number". A stored zero is a real zero: a method can be free in EUR and priced in the base currency. A currency set to convert fills in the missing amounts for you. See Converted prices.
Amounts are whole minor units of the currency they are stored under. A conversion works out the new amount from the real money value and the currency's own decimal places. It never copies minor units across. Currencies have different numbers of decimal places (JPY none, USD two, BHD three), so 1850 means 18.50 in euro and 1,850 in yen. Converted amounts are worked out when they are read and are never saved in this table. See Money conventions.
Price lists and quantity tiers do not use this table. They already carry a currency on each row, so a EUR trade price is a EUR row on the list. A price list is in one currency, chosen when it is made, so a store that sells trade prices in dollars and in euro has a "Trade" list and a "Trade EUR" list. A variant's quantity ladder is one ladder per currency. See Price lists and quantity tiers.
What carries an amount per currency
The last column is what happens with typed pricing. A currency set to convert works a missing amount out from the base amount instead. See Converted prices.
| What | Where you type it | With no amount in a currency |
|---|---|---|
| A variant's price and compare-at price | The product editor, the variants table, bulk edit, CSV, the catalogue file | A priced variant can't be bought there; the free variant rule above applies to its own price only. No compare-at amount means none. |
| A fixed price on an add-on option | The option's price boxes | That choice can't be picked. |
| Pay what you want: suggested and maximum | The product editor | No suggestion (the box starts at the minimum) and no maximum. |
| A fixed deposit | The product API | The product takes no deposit in that currency. |
| A fixed amount off in a sale, a fixed rung of a quantity ladder, a money argument of your own | The sale editor | The sale takes nothing off in that currency. |
| A price list | Made in one currency, chosen when you make it | The list does nothing for a cart in another currency. |
| A variant's quantity tiers | One ladder per currency | No quantity breaks in that currency. |
| A fixed coupon amount, its minimum and its maximum order total | The coupon editor | The code doesn't apply there. A missing minimum never means "no minimum". |
| A shipping method's rates, free-shipping minimum and clamps | The method editor | The method isn't offered there. |
| A collection point's charge | The collection point editor | A point that charges in the base currency isn't offered there. A free one is offered everywhere. |
| A payment method's fixed fee | The method editor | Only its percentage is charged. |
The store's order minimum and maximum, the flat shipping rate, the return fee and the shipping lines in policies |
The config file, as a map | No entry means no minimum or maximum, no flat rate (the store doesn't ship there) and no line in the structured data. |
| A subscription plan, a rental rate and its fees | The add-on's editors | See Add-ons. |
Gift cards have no table of denominations. A gift card is a product whose variants are the denominations, so each is priced like any other variant, and the value issued is what the line was paid, in the order's currency.
Amounts in the config file
The three amounts in the config file, shipping.flat_rate, cart.order_min_total and cart.order_max_total, take either a single value, which is in the base currency, or a map of currency to amount:
cart:
order_min_total: { USD: '50.00', EUR: '45.00' }
With typed pricing, a single value says nothing about any other currency, and a map says nothing about a currency it does not list. In both cases the answer for that currency is "no entry", which is the same rule as above. For these three that means no minimum, no maximum and no flat rate. A currency set to convert works an amount out from the base amount when its own entry is missing, and an entry you typed still wins. See Converted prices. Policy amounts (policies.returns.fee and the shipping lines) take a map the same way, see Structured data.
On a store that sells in more than one currency, Configuration has a box per currency for the order minimum, the order maximum and the flat shipping rate, with the base first. A store with one currency keeps one box for each.
Pricing a product in another currency
On a store with extra currencies each price has a box per currency. Type each amount under Price & stock. With typed pricing, an empty foreign-price box means a priced variant cannot be bought there. On a currency set to convert, an empty box shows the converted price in grey, as in €23.99 converted, and the product sells at that price until you type one. See Converted prices. A base price of 0 is free in every currency unless you type a foreign amount. A blank base price is 0; do not leave it blank on a product you intend to charge for. The free-price rule applies only to the variant’s price, never to compare-at prices, add-ons or other charges.
In the admin
- A product with one variant. Under Price & stock, a Prices in other currencies group holds a price and a compare-at price for each currency. The boxes take the currency's own decimal places: two for euro, none for yen, three for dinar.
- A product with several variants. Open a variant in the variants table to get the same boxes. The table also lists each variant's price in the other currencies under its base price, or "Not sold in EUR". Set for all variants takes a price per currency too, and an empty one there leaves the amounts alone.
- Add-ons. A fixed add-on price has an amount box per currency. Empty means the choice cannot be picked in that currency. A percentage add-on has no boxes, because a percentage is the same in every currency.
- Pay what you want. The suggested and maximum price have a box per currency. Empty suggested starts the price box at the minimum, and an empty maximum means no maximum.
- Sales. A fixed amount off, on a rule or a quantity tier, has a box per currency, and empty means the sale takes nothing off in that currency. A percentage has none. The sale editor keeps a rule's amounts when you save it again. A rule that prices through a quantity ladder shows its rungs as a table with a box per currency beside each fixed one, and an operation with a money argument of its own has a box per currency under that argument.
- Price lists and quantity tiers. A new price list has a Currency choice, fixed once the list is saved, and its prices are in that currency. The Quantity tiers tab shows one ladder per currency. See Price lists and quantity tiers.
- The deposit has no box in the product editor. A fixed deposit takes its amounts through the product API, as
currency_amountson the product (deposit, one per currency). The catalogue file doesn't carry deposits. A deposit with no amount in a currency takes no deposit there, and a percentage deposit needs no amount. - Finding what is not for sale yet. The product list has a Missing a price in filter. A product is listed when at least one of its variants has no price in that currency. It is a filter and not a badge on every row, because working that out for each row would cost the list a query per product.
Bulk edit
Merchandising → Bulk edit offers two more things to change on a store with extra currencies:
- Price in another currency sets the price, or the compare-at price, in one currency to an amount you type in that currency. Leave the amount empty to take the price away.
- Price worked out from the base price multiplies each variant's base price by a rate, such as
0.92, and rounds the answer. The Rate box starts at the store's own exchange rate for the chosen currency, when there is one, and follows the currency you pick until you type in it. It takes up to twelve decimal places. The rate is used for this run and is not saved anywhere. Changing it here doesn't change the store's exchange rate, and what it writes is an ordinary typed price. Rounding is one of: leave it as worked out, a whole number, the nearest price ending .99, or the nearest price ending .95. "Nearest" means it can go down: 18.43 becomes 17.99, and 18.60 becomes 18.99, with a tie going up. A price of zero stays zero. In a currency with no minor unit, such as yen, .99 and .95 have no cents to end in and change nothing.
Preview shows each variant's price in that currency before and after, and Apply writes it in one transaction with the rest of the run. A variant with no base price to work from (a compare-at price that was never set) is skipped.
In a CSV file
A products or variants file can map columns to price_EUR and compare_price_EUR, one pair for each currency the store sells in. They appear in the mapping screen beside the other fields. An empty cell leaves the amount alone, like every other column, and the word remove takes it away. A cell accepts the base price column’s currency symbols and grouping separators, but its number must fit its currency’s decimal places: 1,299.00 and 1299 both work in EUR, while 27.50 fails a JPY row instead of rounding it. A cell that is not an amount fails its row with a message and nothing else. A saved mapping that still names a currency you have since taken out fails its rows the same way. See Importing.
A new row with a foreign price also needs a base price. Enter 0 if it really is free. A row with no prices defaults to a zero base price. On an update, a blank base cell keeps the saved price. See Importing.
In the catalogue file
kahunacart export writes the amounts a product has and nothing else, so a catalogue with none exports byte for byte as it always did. A variant gets prices and compare_prices, a product gets pwyw_suggested_prices and pwyw_max_prices, and a fixed sale rule gets amounts, each a map of currency to a decimal string. A fixed rung of a quantity ladder in a rule's arguments gets amounts the same way (see Sales):
variants:
- sku: TEE-NAVY
price: '28.00'
prices: { EUR: '25.50', JPY: '4200' }
compare_prices: { EUR: '30.00' }
An import checks every amount before it writes anything: the currency has to be one the store sells in, and the amount has to be a string with that currency's decimal places. A file with an amount in a currency the store does not sell in is refused whole.
Sale operations with named money arguments use argument_amounts, for example {bonus: {EUR: "4.50"}, cap: {EUR: "18.00"}}. Each argument keeps its own amounts on catalogue export and import. The argument named amount uses the existing amounts map; fixed quantity-tier amounts stay beside their rungs. Unknown arguments or invalid amounts are refused before the import writes anything.
Through the API
Reads and writes carry a currency_amounts object on variants, add-on prices, sale rules and tiers, and on the product for pay what you want and the deposit, only on a store that sells in more than one currency. See Admin API.
What happens to the amounts
- Copying a product copies its amounts. A revision records them and restoring it puts back what was there then, and a scheduled change can carry a price per currency.
- Deleting a variant, an add-on, a sale or a product deletes its amounts with it.
- Taking a currency out of
currencies.enabledhides its amounts without deleting them. Add it back and every price you had is back, for the products still there. Editing a sale or an add-on price while extra currencies are disabled carries its saved amounts to the replacement rows, including sale tiers and fixed ladder rungs. Deleting an owner, or removing a rule, tier or add-on price, removes its amounts even while extra currencies are disabled. A product copied then does not get its amounts, and restoring a revision taken then leaves amounts alone. - Resetting the store keeps the amounts, because they are catalogue.
How a cart line is priced in another currency
A priced variant’s amount in another currency is a row in kahunacart_currency_amounts: owner variant, field price_minor. A compare-at price uses compare_price_minor. A zero base price is free there when no typed price exists. Every line in a cart is priced in that cart’s currency.
A variant with no price in the cart's currency cannot be bought in it. Adding it, or changing the quantity or options of a line that has lost its price, is refused with "X is not available in EUR." (PLUGIN_KAHUNACART.MISSING_PRICE), and nothing is written. Removing the line is always allowed. A product sold as a group is refused whole when one of its children has no price in the currency. The base number is never a stand-in: 20.00 dollars is not 20.00 euro, and not 20 yen.
The same rule runs through everything that works out a price for a cart line:
| What | In a currency other than the base |
|---|---|
| Price lists and quantity tiers | Read only in the cart's currency. See Price lists and quantity tiers. |
| Percentage sales | Worked out on the price in that currency. |
| Fixed-amount sales | A row for the rule or tier, or the rule gives nothing in the currency. See Sales. |
| Option prices | A row for the option's price; an option with none cannot be chosen. A refusal says PLUGIN_KAHUNACART.MISSING_OPTION_PRICE. |
| Pay what you want | The minimum is the variant's price; the suggested amount and the maximum are the product's own rows, and there is none without them. |
| Deposits | A fixed deposit is its row, or the product takes no deposit in the currency. A percentage needs no row. |
| Bundles | A bundle is offered only when it has a price in the cart's currency. |
| Bundle price of a set | One price for each currency, in the Bundle price boxes. A currency without one has no set price, and the parts cost their own prices there. See Bundle price. |
| Add-ons that price a line | They are handed the currency and can refuse with LinePriceAnswer::refuse(). See the onKahunaCartCartLinePrice event in the events reference. |
A cart line freezes the price it was added at. For a line in another currency the frozen record in snapshot_json.variant also carries currency, and a line in the base currency carries nothing new. A stage that finds a snapshot in a currency other than its order's prices the variant again instead of trusting it.
Restoring an abandoned cart puts its lines into a cart in the abandoned cart's currency, at today's prices in it, and drops a line that no longer has a price there. If the visitor already has a cart open in another currency with lines in it, the restore is refused and changes nothing, because a cart cannot hold two currencies. If that cart is empty it stands in nobody's way: the visitor lets go of it and the restore makes a new cart in the abandoned cart's currency. The empty cart is not changed into the new currency, because a cart's currency never changes once it has one. It stays until the worker's clean-up of idle carts (cart.purge_days) takes it, as it would any cart a visitor walked away from. Moving a wishlist into the cart leaves out the products with no price in the cart's currency, keeps them on the list and says which they were.
A product's cost to the store (cogs_minor) stays in the base currency whatever the order is in. See Money conventions.
What a shopper sees when a product has no price in their currency
A product nobody priced in euro cannot be bought in euro, and the storefront says so where the price would be instead of showing the dollar number or a zero. Every other price on the page is in the shopper's currency. A currency set to convert prices the product from the base price, so this happens there only when the currency has no rate. See Converted prices.
- The product page says "Not available in EUR." where the price and the add-to-cart form would be, and links to the same product in the store's own currency (
?currency=USD). There is no form, no price and no add-to-cart button. - A product with some variants priced and some not shows the priced ones as usual. The page opens on a priced variant, and a size or colour that only an unpriced variant offers is disabled in the picker with "Not available in EUR" beside it, the same way a combination that does not exist is. A link with
?variant=naming an unpriced one opens on the default instead. - Cards, rows and the shop's sections show a small muted "Not available in EUR" in place of the price, with no sale badge, and have no add button to take away: a card is a link to the product. A product whose default variant has no euro price but whose other variants do is priced on its card from the first variant that has one.
- Add-on options with a fixed price and no amount in the currency are disabled with "Not available in EUR" beside them, never shown with the base amount. A percentage add-on needs nothing, because it is a share of the price in the shopper's currency.
- The wishlist shows the same line in place of a price, leaves out the Move to cart button for that item, and leaves it out of the shipping and tax estimate. The item stays on the list: the shopper may switch currency, and the merchant may price it tomorrow.
- A sale is worked out on the euro price. A percentage works, a fixed amount works only where it has an amount in euro, and a compare-at price in dollars is not a compare-at price in euro, so a product with none in euro is not struck through.
- The structured data on a product page (JSON-LD) offers only the variants that have a price in the shopper's currency, and has no offer for a product with none, so a search engine never reads an unpriced product as a free one. The feeds, the storefront API and the agent catalogues follow the same rule in their own way. See Outside the browser.
How a theme draws the same thing is in Theming, and the list of every place a template is handed a price is in Storefront.
Sorting and filtering by price
A listing's price sort, its price range and its on-sale facet work in the currency the shopper is looking at. A shopper in euros who types 15 to 25 in the price boxes gets the products with a euro price between 15.00 and 25.00, and one in yen who types 3000 gets 3000 yen, because the boxes are read with the currency's own decimal places. Sorting by price puts the cheapest euro price first, which is not the order of the dollar prices.
- A product with no price in the currency is still listed, with no price and no add button, and it goes last when the listing is sorted by price in either direction. A price range leaves it out, because it matches no range.
- The base currency is unchanged. A shopper in the base currency, and a store that sells in one currency, get exactly the listing they always did.
- A second currency turns the catalog index on. The prices per currency live there, and the listing reads them from it whatever
catalog.index.modesays. - After you add a currency, rebuild the index:
bin/plugin kahunacart index --rebuild, or Rebuild everything on the Search screen. Until you do,bin/plugin kahunacart statusand the Search screen say which currencies the index does not have prices for, and shoppers in them get the listing sorted newest first with no price range, rather than one sorted on prices that are not there. - The same goes if the index is turned off or has never been built. A store with extra currencies needs it built.
The facet counts, the category counts and the related-products blocks are kept per currency too, so what one shopper sees in euros is never read back for another in dollars. On a store with one currency their cache keys are the ones they always had.
The storefront MCP's price filter works in the currency of the call: see The storefront API and agents.
Product text search is separate. Enabling a currency needs index --rebuild, not search:reindex. After copying or restoring a database whose product text differs from its search files, run bin/plugin kahunacart search:reindex on that copy.
Everything else with an amount per currency
With typed pricing the one rule is the same for all of them: no amount in a currency means the rule doesn't apply there, never zero and never the base number. A currency set to convert fills the gap from the base amount, and an amount you typed still wins. What "doesn't apply" comes to differs by what it is, so each gets its own paragraph.
Shipping in another currency
Shipping is priced in each currency the same way a product is: every amount is entered in that currency, and with typed pricing one that is missing means the shipping does not apply there. A currency set to convert works the missing amounts out from the base ones, to that currency's own decimal places. See What is rounded.
- A shipping method has its rates in each currency: the base rate, the per-item and per-kilogram rates, the free-shipping minimum, the clamps, the per-class costs, the weight bands and the cost expression. In the admin they sit under the base fields of the method, one box per currency. Through the API they are
currency_amountson a method, either with the zone or onPUT /kahunacart/shipping/methods/{id}/rates. A method that cannot be priced in the order's currency is not offered there and says so. See Shipping rates for what each missing amount does. - A collection point has a charge in each currency. One with a base charge and none in euro is not offered to a euro order, and a free one is offered everywhere. See Click and collect.
- The flat rate in the config file,
shipping.flat_rate, may be a map. A currency not in the map has no flat rate, and the store does not ship there. See Configuration. - Live carrier rates are in the currency the carrier quotes. A rate quoted in a currency other than the order's is dropped, never shown as if it were, and so is a rate that names no currency at all on an order that isn't in the base currency. The Shipping add-on adds an account currency for each carrier: see Add-ons.
- Shipping zones have no amounts of their own, so they do not change.
- A free-shipping minimum works out the cart in the order's currency and needs a minimum in that currency to be reached. A method with a dollar minimum and no euro one is not free in euro.
The amounts of methods and collection points travel with the Shipping section of the configuration export, where the export and import of amounts in other currencies are described.
If every enabled method in the address’s zone lacks a rate in the cart’s currency, checkout says that no shipping method is priced in that currency. Run bin/plugin kahunacart status to check each zone and currency, and any missing flat rate. It does not contact carriers or check a parcel’s weight or conditions. A free method with no minimum works in every currency.
Coupons and order limits
A coupon keeps its discount amount and its minimum and maximum cart totals in the base currency, and has its own in each other currency (owner coupon, fields amount_minor, min_total_minor and max_total_minor). A code with no amount in the order's currency does not apply there. A fixed code with no amount in euro is refused on a euro order, and a code with a minimum or maximum in the base currency and none in euro is refused too, because a missing minimum must never mean "no minimum". A percentage or free shipping code with no limits works everywhere.
The shopper is told "This code can't be used on an order in EUR.", a code already held is dropped, a code from a link says so in its banner and prints no amount, and a minted code carries the amounts of its campaign's template. The editor has a box per currency, and an empty one is a code that is not valid there. See Coupons → In another currency.
The store-wide minimum and maximum order total (cart.order_min_total, cart.order_max_total) can be a map of currency to amount. An order in a currency the map does not list has no limit of that kind, and a single amount is the base currency's. The refusal prints the limit in the order's currency. See Configuration.
Sales, pay what you want and deposits
A fixed amount off in a sale has an amount per currency, and a percentage needs none. Pay what you want has a suggested and a maximum amount per currency, and its minimum is the variant's price in that currency. A fixed deposit has an amount per currency and a percentage has none. Each is typed where the guide's pricing section says, and each follows the one rule. See Sales and Deposits.
Tax-inclusive prices per currency
A tax price preference's currency column limits a row to orders in that currency, and empty means every currency. A currency preference ("prices in EUR contain tax") matches on its value whatever its column says, so a store can price euro with tax in and dollars with tax added. Rows from before a second currency existed keep their meaning. See Tax → Prices that contain tax in one currency.
Paying in another currency
Three things decide whether a shopper can pay in their currency: which payment providers take it, which payment methods you offer in it, and whether a credit balance or gift card is in the same currency.
Payment providers
A payment method that cannot take an order's currency is not offered for it. Each provider says which currencies it takes, and a provider that says nothing takes every currency, so a store whose providers all take everything sees no difference. See Payment providers.
- A shopper in a currency nothing takes is told "No payment method is available for orders in EUR." at checkout and on the pay page, and not shown an empty payment step.
- You are told first.
bin/plugin kahunacart statusand the Payments screen list, for every currency the store sells in, which payment methods take it, and warn about any currency that nothing takes. Do not list a currency until at least one method takes it. - A platform that settles in another currency than the order's (a merchant of record that presents local prices, say) does not get its figure written into the order. The payment is recorded for the amount it was opened for, the platform's figure and currency are kept on the transaction and named in the order's history, and you get a notice. A single-currency store sees this too, in the one case where a platform reports a currency that is not the store's: it used to record the platform's amount as if it were in the store's currency.
- Catalogue sync pushes base-currency prices only and skips a pulled price in any other currency. See Catalog sync.
The provider side, with the two methods a driver overrides and the traps with decimal places, is in Provider developer guide.
Payment methods
A payment method can be limited to some currencies, and its fixed fee has an amount in each (owner payment_method, field fee_fixed_minor). A method with no list is offered in every currency, and one limited to euro is shown at checkout as unavailable on a dollar order. A method with a fixed fee in the base currency and none in the order's currency charges only its percentage there, unless the currency converts. A method can also use its own Stripe account for one currency. See Payment methods → In more than one currency and Selling through more than one company.
Choosing a payment method refreshes the fee, tax and total before Place order. The fee beside the method uses the same calculation and rounding as the fee charged. With JavaScript off, checkout explains that the chosen method’s fee is added to the displayed total. If no method accepts the cart’s currency, checkout shows the reason and no Place order button.
A condition on an amount of money (cart_subtotal, line_unit_price) is in one currency and is false on an order in any other, for a sale, a coupon's totals, a payment method, a price list rule and a checkout field. A condition with no currency is in the base currency, as every condition written before was. See Rules and conditions.
Disabling a currency does not prevent editing a saved sale, price list, quantity ladder, add-on price or money condition. The saved amounts and conditions keep their currencies. Deleted rules, rungs and add-on prices lose their amounts. New records still require the currency to be enabled.
A payment or refund with a ledger in the wrong currency is held for review. It records an internal note and an admin notice without changing the payment status. No refund is made against a mixed-currency ledger.
Store credit and gift cards
A credit account and a gift card are each in one currency, and nothing converts. Checkout tells a customer who holds credit only in another currency what they have and that it can be used on orders in that currency, a gift card in the wrong currency is refused by name ("This gift card is in USD and this order is in EUR."), and the account panel lists a balance per currency. A gift card sold in euro issues a euro card. See Store credit → In another currency.
Switching currency
A shopper picks a currency with the switcher or follows a ?currency=EUR link. With no cart or an empty one, that changes browsing prices. With lines in the cart, its currency wins until the shopper accepts a switch. After a hosted payment, starting a new cart is the way to use another currency.
The switcher
A store that sells in more than one currency can show its shoppers a small menu to pick the currency they browse and pay in. KahunaCart's own pages do not draw it, because it belongs in your theme's header next to the cart and account links. Put this where you want it:
{% include 'partials/kahunacart-currency-switcher.html.twig' %}
It draws nothing at all on a store that sells in one currency, so it is safe to include in a header on every store. On a store with more than one it is a <details> menu, so it opens and closes with no JavaScript, and the current currency is on the summary. Each currency in the list is one of two things:
- A link to the page the shopper is on with
?currency=CODEon it, andrel="nofollow"so a search engine does not follow it into a copy of every page for every currency. The rest of the address is kept, so a filter, a sort or a page number is not lost. Following it changes what the shopper browses in. The currency stays on navigation links; a public page does not set a cookie. See The cookie. - A button in a small form that posts to
{base}/cart/currency, shown for every other currency when the shopper has a cart open. Picking one moves the cart, which changes it, so it is a post with the page's usual security token and not a link. See Switching with a cart open.
The base currency is listed so a shopper can return to it. With currencies.lock_to_country on, the menu says that the order’s country decides at checkout. Once a payment page has been opened, the cart cannot change currency, even after cancellation. The menu explains this. A refused switch or country-locked checkout offers Start a new cart in EUR, using the required currency.
The names are the currency's name and symbol in the shopper's language, "Euro (€)", taken from the PHP intl extension. Without it the list shows the three-letter codes.
The menu works when the visitor has refused the cookie: the page is in the currency the address names, and every link in the menu says which currency it is for. Your theme can also read the list itself with kahunacart_currencies() and draw it any way it likes.
Starting a new cart after a hosted payment
Click Start a new cart in EUR to use the offered currency. The new cart becomes the shopper’s current cart. Each ordinary line is checked again for stock, options, add-ons and prices. Eligible lines keep their quantities and choices and get the current prices in the new currency. The notice lists each line that could not come along and why.
Pay-what-you-want lines do not come along. Add them again and enter an amount in the new currency. Free gifts are checked against the new cart’s promotions. Coupons and the chosen delivery method do not come along either.
The old cart, its choices and its payment ledger stay as they were. A payment finished later still completes that old order. Cancelling a payment locally does not remove the currency lock. Turning a currency off also leaves existing carts and orders priced in their own currency.
Switching with a cart open
Click the switcher’s button to move a cart to another currency. Every line is priced again. Unavailable lines and choices are removed and listed in a notice. The shopper returns to the page they were on, including a home page, article or bookings page. The return address must be on this site.
Every line goes back through the same code that adds a line to a cart, with the same product, quantity and choices, so the result is exactly the cart the shopper would have had if they had shopped in the new currency from the start: the same unit prices, price list, sale, option and add-on prices, deposit and totals. Nothing in the switch works out a price itself, so it cannot disagree with the cart about one.
What is taken off, and what the shopper reads:
- A line with no price in the new currency, or whose chosen add-on has none, is removed. The notice names it: "Removed: Wax Bar (not available in EUR)."
- A pay-what-you-want line is removed and named, because an amount typed in dollars is not an amount in euro and nothing converts it. They can add it again and type a new one.
- Coupon codes that do not apply in the new currency are taken off, and the notice says which: "The code SPRING10 can't be used in EUR." A fixed-amount code with no amount in the new currency goes, and so does any code whose minimum or maximum order total has no amount there. A percentage or free-shipping code with no limits stays, because it works in every currency. A gift card is never on a cart, since redeeming one pays its value into the customer's store credit, so there is nothing to take off.
- The shipping method, the collection point and the payment choice that are not on offer in the new currency are cleared, and the notice says to pick again. Checkout asks again. A shipping method goes when it charges something that has no amount in the new currency (its rate, a per-item or per-kilogram rate, a per-class cost, weight bands, a cost formula, or a free-shipping minimum), the same case where shipping refuses it as
currency_unpriced. A collection point goes when it has a charge and none in the new currency. A payment method goes when it is limited to other currencies or sits on a payment provider that does not take the new one, and a chosen provider goes when it does not take it. Whatever the store no longer knows about, such as a deleted method or a provider whose plugin is off, is left alone, because the new currency is not the reason it is a problem. - Free gifts are left alone. They cost nothing in any currency and the promotion decides on its own whether they stay.
Everything that does stay keeps its stock hold, and the cart keeps its link and its hash. The cart's history gets one line, order.currency_switched, saying what moved and who asked, and it is not shown to the customer.
It is all or nothing. The switch runs in one transaction with the cart's row locked, the way every other cart change does, so a second request cannot add a line to a cart that is half way through being priced again. If it fails for any reason nothing has changed. These are refused and change nothing: an order that is not a cart any more (awaiting payment or paid, which is never switched), a cart that has ever opened a payment page at a provider, a currency the store does not sell in, and a cart already in that currency. For the last two the choice only changes what the shopper browses in.
A cart that would lose every line is not emptied on a single click. The switch is undone and the shopper is taken to a page that says "Nothing in your cart can be bought in EUR, so switching would empty it", lists what would go, and offers two choices: empty the cart and switch, or keep the cart as it is. Nothing is written by loading that page.
Which coupons, shipping methods, collection points and payment methods apply in a currency is decided by the parts of the store that own those rules, through Currency\SwitchRules, so the switch asks the same questions checkout does. See For developers.
A link to another currency when a cart is open
A ?currency=EUR link works when there is no cart or the cart is empty. Adding a product then starts a cart in the currency the page showed.
With lines in the cart, its currency wins. The page explains why prices stayed in that currency and offers Switch my cart to EUR. That button reprices the cart and lists anything removed. A link the cart cannot follow does not set a currency cookie.
After a payment page has been opened, the cart cannot move. The notice offers Start a new cart in EUR instead. These notices are shown only on private pages. A theme with its own shop pages should include partials/kahunacart-currency-notice.html.twig below the messages.
Letting the shipping country decide
With currencies.lock_to_country on, the country on the order has the last word at checkout. The shopper can still pick a currency with the switcher and browse in it, but when they submit the checkout form KahunaCart looks at the country and the currencies.map:
- The country is the shipping country for a cart that ships, and the billing country for one that does not (a download, or an order collected at a counter).
- If the map names a currency for it and that is not the cart's, the cart is moved to it, the same way a shopper's own pick moves it, and checkout stops with a notice before anything is charged: "Your cart is now in EUR. Orders shipping to Germany are priced in EUR." What the shopper typed is kept, and they land on the step they pressed, so they read the new prices and carry on.
- If the map does not name the country, the cart's currency stands.
- If nothing in the cart can be bought in the new currency, checkout refuses with a sentence saying so and the cart is not changed. A cart is never emptied behind the shopper's back.
The storefront API's checkout shares this step and returns currency_changed, with the new currency and a request to review the cart and retry. UCP applies the same country rule before charging, including when the instrument first supplies the billing address at completion. A changed currency returns requires_escalation with currency_changed, so the buyer reviews the new totals before payment.
For an order that ships, the shipping country decides. For downloads or collection, the billing country decides. If a hosted-payment cart cannot move, checkout offers Start a new cart in EUR and stops before charging.
Orders in another currency
An order keeps its currency from the moment it is placed. The admin shows its total, lines, payments, refunds, invoice and credit notes in that currency’s decimal places. Currency controls remain for historical orders after a currency is disabled. A store with only base-currency records keeps its original order payloads.
- The order list shows each order's total in its own currency, with the currency code beside it, and puts a button for each currency above the list, after an All button (the store's own first, each with how many orders it has). A button narrows the list, its count and its search to that currency, and a link to
/orders?currency=EURopens it already narrowed. The list never adds up the totals of orders in different currencies, because 100 dollars and 100 yen are not 200 of anything. For the same reason, sorting by Total while the list shows every currency groups the orders by currency and sorts the amounts inside each group, in the list and in the refund requests queue. Narrowed to one currency it sorts the amounts alone. Deposits does the same for what is still owed: one figure per currency, side by side. - An order in a currency the store has stopped selling in is still an order. Its currency is offered after the others, so it can still be found.
- The order record prints every amount in the order's currency. The one exception is what a product cost you (
cogs_minoron a line), which is typed once, in the store's own currency, whatever the order is in. On an order in another currency the order's answer says so withcost_currency, so nobody reads a cost as an amount in the order's currency. - A hand-entered order asks for its currency first, above the customer. It is the store's own until you pick another, and every product on the order is priced in it. A product with no price in that currency is greyed out in the picker with "Not sold in EUR" beside it, and the server refuses it too. Changing the currency of an order you have started prices the lines again in the new one and takes off any line that has no price there, saying which. If the customer already has orders, the form notes the currency of their last one. It is a note, not a choice made for you. The currency cannot be changed once the order is created.
- A line added to a placed order is priced in the order's currency, from the product's amounts, the same way a cart line is. A product with no price in that currency cannot be added, even if you type a price for it. A price you do type is read with the currency's own decimal places, so
1500is 1500 yen and12.50is 12.50 euro, and an amount with more decimals than the currency has is refused before anything is sent. See Order changes. - A refund is typed, shown and sent in the order's currency, and the payment provider is asked for it in that currency. A refund to store credit goes into the customer's account in the order's currency. The customer's other accounts are not touched.
- An import keeps each order's own
currency, and reads that row's amounts with that currency's decimals. A currency that is neither the store's own nor one it sells in fails that row with a message saying so. See Importing. - The customer record lists each of the customer's orders in its own currency.
The routes behind this are in Admin API and MCP tools.
Reports and totals
The dashboard, Reports, Analytics, the customer list and record, cart recovery and the sales widget on the site dashboard each have a Currency picker on a store that sells in more than one currency, or has taken orders in one. It offers the base currency, each currency that has completed orders, and All, in USD (with your own base code). The choice is remembered for each admin user. A store that has only ever used one currency sees no picker, and its reports, queries and exports stay as they were.
- One currency. Pick a currency, or add
?currency=EURto an API call, and every money figure adds only the orders in that currency. Leave it out and you get the base currency. The currency is named beside the figure, and in every API answer ascurrency. Under a total the screen says "All amounts are in USD. 14 orders in other currencies are not included." and the API carries the count asother_currencies, a list of{ "currency": "EUR", "orders": 11 }. A code the store neither sells in nor has taken an order in is a422, and the message lists the ones that work. A currency you have since switched off still works for old orders. - All, in the base currency.
?currency=alladds every order up in the base currency. Each order is read in its own currency and multiplied by the rate it was placed at (itsfx_to_base), never by today's rate. The amounts are added exactly and each figure is rounded once at the end. An order with no recorded rate is left out and counted: "12 orders without a recorded rate are left out." That includes orders from before KahunaCart recorded rates, base-currency ones too. Refunds without a rate are counted separately, and each refund uses its order's rate. An open recovered cart has no rate yet, so its value is left out and counted too. The API saysconverted: trueandcurrency_selection: all, names the base currency incurrency, and lists what it left out inexcluded_ordersandexcluded_currencies. - A table per currency. After you choose All, a small table under the dashboard, Sales and the site widget shows revenue, order count and average order for each currency, with its base equivalent in a muted column. A currency with no rated orders shows no equivalent. Through the API, add
currency_table=1to the summary route and the answer gainsby_currency. - Rows may not add up to the headline. Every day, product and currency row is rounded on its own, so adding the rounded rows can differ a little from the total at the top.
- Cost and margin are the base currency's. The cost of a product is entered in the base currency. In a view of another single currency they are absent (a dash on screen,
nullin the API), never a number that mixes the two. In the All view they are worked out over the orders that have a rate: the base-currency cost is added unchanged and taken off the converted revenue. In a breakdown by currency, only the base row has cost or margin. Rows are in currency-code order, their money columns can't be compared by sorting, and they are never folded into an "other" row. - The abandoned carts list groups by currency. Sorting it by Value, which is also how it opens, puts the carts of each currency together and the biggest first inside each, rather than comparing 500 yen with 500 cents. The activity sort is by time and is not affected.
- A customer's lifetime total follows the picker. In a single currency, the customer list and the record show what the customer spent in it and, for a customer who also ordered in other currencies, what they spent in each of those beside it, as separate amounts. In the All view the total is in the base currency, using each order's rate, and orders without a rate are counted beside it. The list sorts by the total you are looking at. The order count is every order, because a count is not money. Through the API, the answer adds
total_spent_base,base_currency,converted_orders,excluded_ordersandexcluded_currencies. - A report filter on money applies to the report's currency. A
totalfilter is in minor units, sototal over 5000means 50.00 in the currency the report is in. In the All view it compares each order's converted total with a threshold in the base currency. A saved view remembers your choice. An older view with none opens in the base currency. A filter on thecurrencyfield narrows inside the report's currency, so to look at euro orders choose EUR and do not filter by currency. A breakdown by currency lists every currency side by side, each row printed in its own. - Exports show the rate. On a store with more than one currency, the report ledger CSV and the order and line item exports (CSV and JSON) have a
currencycolumn and anfx_to_basecolumn, even when you pick your own columns. An order with no rate has an empty cell. The order exports also havesettlement_currency,settlement_amountandsettlement_rate, what the payment provider paid out for the order (see What the payment provider paid out). They are empty when there is none, and the report ledger CSV doesn't carry them. Amounts stay in the order's own currency: the picker changes the totals and the sales PDF, not the ledger rows. The PDF follows your choice, says how it converted and how many orders had no rate. A store with one currency gets the exports it always had. - The API remembers your choice too.
GET /kahunacart/reports/currencyandPUT /kahunacart/reports/currencyread and save the signed-in admin's choice (MCP toolsget_report_currencyandset_report_currency). A call that names acurrencyalways wins, and the API never applies the saved choice on its own. The report tools takecurrency(report_summary,report_products,report_customers,report_taxes,report_refunds,get_report_comparison,report_recovery,list_customers,get_customer). See Admin API.
The All view reads the orders themselves and not the daily rollup, because one day can hold orders placed at different rates. So there is nothing to rebuild when you turn it on. See Reports and the rollup and ReportCurrency for the developer contract.
A store with only base-currency records gets no currency metadata in report answers. A foreign cart or order records reports.other_currencies when written. The nightly rollup also records it for older foreign orders. Reports keep their currencies separate even after all extra currencies are disabled.
Caching and CDNs
A store that sells in more than one currency draws its prices on the server, in the currency of the visitor it is answering, and nothing is worked out in the browser. So a page is one currency's page, and a cache that keeps it must never hand it to a visitor in another. A CDN or a proxy keys on the URL, and Cloudflare ignores Vary on HTML, so KahunaCart doesn't lean on Vary and leaves the one it has alone. It follows one rule instead: a page is public only when its URL alone decides its currency.
| The visitor | What decided the currency | The page is |
|---|---|---|
| No cookie, no saved address, nothing in the address bar, and no country header in use | The base currency | Public, with Grav's own expires, as on a store with one currency |
/shop/product/tee?currency=EUR or ?currency=USD |
The URL | Public. A cookie that says something else loses to the URL, so everybody who asks for that address gets the same page |
A kc_currency cookie, on a URL with no ?currency= |
The cookie | private, no-cache, max-age=0 |
Any visitor, on a URL with no ?currency=, when currencies.header is set and header is one of the detect steps |
The country header, or the base currency by default | private, no-cache, max-age=0, whatever the header says for this visitor, because the next visitor to that URL may be in another country |
| A signed-in customer whose saved address decides it | The address | private, no-cache, max-age=0 |
| Anyone with a cart | The cart | private, no-cache, max-age=0, which is what a cart has always done to a page |
The cart, checkout, account and order pages are never stored by anyone, whatever the currency, as before. A store with only one currency sends exactly the headers it always did.
Cookies on a page a CDN may keep
A public ?currency=EUR page sends no kc_currency cookie. A shared cache must not replay one visitor’s cookie to another. Private responses and switcher redirects can remember the choice.
Grav can still start a session and send its own cookie on every page. On Grav 2.2, set system.session.lazy: true so browsing alone does not start one. bin/plugin kahunacart status warns when lazy sessions are off on a store with extra currencies.
Keeping shoppers on a cacheable URL
A shopper who arrives by cookie, by country header or by their address gets a private page, which a CDN doesn't keep. To put their second page view on a URL it can keep, every link to a product, a category, the search page and the shop's front door on that page carries ?currency=EUR, the currency they are in. That covers the cards and the related products, the category tree and the breadcrumbs, every filter chip and the clear link, the pager, the tag links and the way back to the shop. The filter box and the search box are forms that submit by GET, which throws away anything on the form's address, so they carry the currency as a hidden field instead.
Nothing is added for a shopper in the base currency, so their links are the ones they always had, and nothing is added on a store with one currency. The exception is a store that reads a country header (see When the country header is on): its plain URLs are always private, so a shopper in the base currency gets ?currency=USD on their links too, and their next page view is on an address a cache can keep. Links to the account, checkout and order pages are left alone: those pages are private whatever their address says.
The canonical link on a page, its og:url, the rel=prev and rel=next of a paged listing, the structured data and the sitemap all name the page and never the currency it was opened in, so a search engine sees one address for a product however many currencies it is sold in. There are no per-currency hreflang alternates.
A cart is made in the currency the shopper was shown
Every form that can start a cart posts to an address that names the page's currency: /shop/cart?currency=EUR for the add to cart button and the kit form, and the same on the wishlist's Move to cart. The request that creates the cart reads the currency from that address the same way the page did, so a cart started from a euro page is a euro cart at the euro prices. It does not depend on the kc_currency cookie, which a visitor who declined functional cookies never has. A shopper who already has a cart with something in it keeps the cart's currency whatever the address says (the page tells them, see A link to another currency when a cart is open), a cart with nothing in it is let go of and replaced by one in the page's currency, and a currency the store doesn't sell in is ignored. Forms and redirects inside the storefront keep the currency the same way: a review or a wishlist heart on a product page comes back to the product with ?currency=EUR.
Content pages and Grav's own page cache
A content page with a product grid on it ( A complete store for Grav: catalog, cart, checkout, orders, customers, tax, shipping, coupons and digital delivery, with a year of updates and support. $249.00 ,
,
KahunaCart
Extras
Payment providers
or ) is not kept in Grav's page cache on a store that sells in more than one currency, because the cached copy would hold the prices and links of whoever rendered it first. It is rendered on every request, the way a page with always has been. A store with one currency still caches them. A page with only stays cached, because a gallery has no price or link in it.
For the page's headers there is one more rule, for pages the storefront doesn't build, such as a home page whose template draws featured products. When a page has drawn a price or a switcher, and a cookie, a country header or a customer's address decided the currency rather than the URL, it goes out private, no-cache. A page that never asked what currency the visitor is in, such as an About page, is left as Grav has it.
When the country header is on
With currencies.header set and header among the detect steps, one plain catalogue URL is a different currency for different visitors: a visitor from Germany is in euro, one from the US, or from a country the map doesn't name, is in dollars, and a CDN that keys on the URL can't tell them apart. So on a store that reads the header, a page whose address has no ?currency= is always private, whatever the header says for this visitor. That includes the visitor the header matched nothing for. Their base-currency page is only the base currency's because of what the header said about them, and the cache can't know that.
What stays public is a page whose address names the currency: /shop/product/tee?currency=EUR, and ?currency=USD for the base currency. Every catalogue link on the page carries it, the base currency's too (see Keeping shoppers on a cacheable URL), so the first page a shopper lands on is private and every page after it can be kept. If choice isn't one of the detect steps, ?currency= is never read, nothing in the address can decide the currency, and every catalogue page is private. Keep choice in the list if you want any of it cached.
A store with no country header, or one whose detect list doesn't use it, is unchanged: the base currency is public, because nothing can tell a visitor into another currency without a cookie or an address.
KahunaCart can't see what sits in front of it, so this is the part to check with your CDN or full-page cache. Two things have to be true of it:
- It uses the
Cache-Controlthe page sends. It must not cache a page that saysprivate, which is every catalogue page at an address with no?currency=. - It bypasses its cache when the
kc_currencycookie is present. A shopper who picked a currency before gets that cookie, and the page they get on a plain address is theirs, not the one a cache holds for the base currency. A CDN that caches HTML on a rule of its own, ignoring what the origin says, would hand them the cached base page.
Cloudflare caches HTML only when a Cache Rule (or an old Page Rule) tells it to, with "Eligible for cache" or "Cache Everything". If you haven't set one up for the shop, Cloudflare isn't caching your pages and there is nothing to do. If you have, set it to use the origin's Cache-Control header (the "use cache-control header if present" choice for the edge TTL) and not to override it, or Cloudflare will keep the private pages too, and add a rule that bypasses the cache when the request has a kc_currency cookie. Cloudflare sends the visitor's two-letter country in the CF-IPCountry header on every request it forwards while IP Geolocation is on (Network in the dashboard), so currencies.header: CF-IPCountry is the setting to use with it. To keep plain catalogue URLs out of the cache altogether, add a Cache Rule for the shop's address that bypasses the cache when the query string has no currency= in it.
bin/plugin kahunacart status says all of this in one line when the store sells in more than one currency and reads the country header. It can't see a CDN, so it names the rule and sends you here.
On a store that does not read the header there is one rule for a CDN that caches HTML on a rule of its own and ignores Cache-Control: bypass its cache when the kc_currency cookie is present. Without it a returning shopper who lands on a plain address gets the base-currency page the cache holds, not the one KahunaCart would have sent them.
Outside the browser
Everything outside the browser follows the rule the shop does: a cart, a checkout or an answer is in one currency, and says which.
The storefront API and agents
Each carries its code in currency and its amounts as whole minor units of that currency, so 2600 is 26.00 EUR and 3900 is 3900 JPY.
- The storefront JSON API takes
currencyonPOST /cart/itemsandGET /cart, to start a cart in it. A cart stays in the currency it was made in; naming another for a cart that exists is a422 cart_currencythat points atPATCH /cartwith{"currency": "USD"}, which moves it. Withcurrencies.lock_to_countryon, the checkout moves a cart whose shipping country maps to another currency, and answers422 currency_changedso the client shows the new totals before it asks to pay again. - The storefront MCP answers each catalog call in the currency
context.currencynames, else the onecurrencies.mapgivescontext.address_country, else the store's own. Itsinitializeinstructions name the store's currencies. The price filter is measured in the call's currency through the catalog index. - UCP makes a cart or checkout in the currency the buyer's country gives, else the one the platform asked for, else the store's own. It stays in it, except that
lock_to_countrycan move a checkout when its address arrives, with a message that sends the buyer to review the new totals. UCP's order webhooks carry the order's own currency. - A variant with no price in the currency is not sold there. A catalog call leaves it out and says so, a cart or checkout leaves the line out with an
item_unavailablemessage, and a product with none answersunavailable_in_currency. - A store that sells in one currency changes nothing. No new key, no query, no header.
Amounts that are plain numbers in policies (the return fee, a shipping rate, a free-shipping threshold) are the store's own currency's. Structured data and the feeds read the amount for each currency from a map and leave a line out where there is none.
Product feeds
A store that sells in more than one currency has one feed file per currency and language, because Google wants one feed per country and currency. The store's own currency keeps the URL it has always had; every other currency is the same URL with ?currency=EUR. Each file is built from the prices set for its currency, a sale is worked out on the price in it, and a product with no price in a currency is not in that currency's file. The build, the rebuild job and bin/plugin kahunacart feeds:build cover every currency, --currency=EUR builds one, and a currency the store stops selling in has its files removed by the next full build. See Product feeds for the URLs and for how to register each currency in Google Merchant Center.
Structured data
The Product markup on a product page is in the currency the page is shown in: priceCurrency, price, and for a product with a range lowPrice and highPrice. A variant with no price there is left out, and a product with none has no Offer at all, since an offer at a wrong price is worse than none. The shipping rates and the return fee come from policies, which accept a plain amount for the store's own currency or a map from currency to amount:
policies:
returns:
fees: fixed
fee: { USD: '4.50', EUR: '4.00' }
shipping_summary:
- country: US
rate: { USD: '5.99', EUR: '5.49' }
free_over: { USD: '50.00', EUR: '45.00' }
A shipping line with no amount in the page's currency is left out of that page's markup, in the same way it is left out of the feed for that currency and of the UCP profile; a free_over with none is no threshold. See Product feeds → Policies in more than one currency.
Back-in-stock alerts
An alert remembers the currency the shopper was looking at when they asked. The email that says the product is back prints the price in that currency, with a sale worked out on it, and has no price line when the product has none there. See Back-in-stock alerts.
The alert form carries the product page’s currency in its URL and a hidden field. It does not depend on a cookie. A cart with lines still decides the currency.
Admin alert rows include the saved currency. Older alerts may have none.
Emails and documents
Every email, invoice, credit note and receipt prints the currency of the order it is about, never the store's own. The order confirmation, the payment and shipping notices, the refund notice, the invoice and the credit note of a euro or a yen order are covered by tests that fail on a single dollar figure.
Owner notifications
An amount in a notification to the owner is in the order's currency, and for an order in a currency other than the store's own it carries its code, so ¥7,800 JPY is not mistaken for dollars in Slack, Discord, Telegram, Pushover, ntfy, Gotify, a text message or an email. See Notifications.
Selling through more than one company
One catalogue can sell through a UK company in GBP and an EU company in EUR. Each currency can use its own company details, its own invoice book and credit-note book, and its own payment account. See Selling through more than one company.
Add-ons
Each add-on that has an amount of its own, or that prints one, was changed to follow the same rules. Update the add-ons along with core: the versions that sell in more than one currency are the ones that ask for KahunaCart 1.4.0, and an add-on that hasn't been updated knows nothing about other currencies. A store that sells in one currency sees no difference in any of them.
Subscriptions (Selling in more than one currency). A plan with a price of its own has an amount box per currency in the plan editor, and the Plans table lists them under the price. A plan can be bought only in the currencies that have an amount: in any other the buy button draws the option greyed out as "Not available in EUR", and the cart refuses it naming the plan. A plan with no price of its own sells at the variant's price in that currency, so the variant needs a price in the currency too. A subscription renews in the currency it started in, at the amount it was sold at, in invoice mode and for a payment platform's renewal. A renewal the store can't price there (the currency was taken out of currencies.enabled, or the variant has no price in it) is never built in another currency. It fails like a failed payment: a failed attempt and a note on the timeline, "Waiting on a price in EUR" beside the status, a line on the dashboard's alert strip, and one high priority notification to you naming the plan, the currency and the subscription number. The customer isn't emailed. It clears itself when a renewal goes through, and in invoice mode the next sweep builds the invoice once the price is back. A platform that bills in a different currency from the subscription's has that amount left out of the record, with one note on the timeline. Monthly recurring revenue is counted per currency and never added up. The subscribe panel on a paywalled article prices its plans in the visitor's currency and leaves out a plan with no amount there.
Bookings (Selling in more than one currency). A bookable is priced by its variant, so a class or a service has a price in each currency the way a product does. A price nobody set in a currency is never free: the picker and the class list say "Not available in EUR", it can't be held, and the front desk and the admin refuse it. A base price of 0 is free in every currency, and a 0 you typed for a currency is free there. A held time is quoted in the currency the visitor was browsing in, and a basket in another currency refuses it with "pick it again". Everything after a booking is paid is in the order's currency: the deposit, the balance, the receipt, the emails, the refund a cancellation offers and the price of a move. The front desk has a currency button for each currency on the New booking sheet, and an amount typed there is read with that currency's decimal places, so 9,000 yen is 9,000. Bookings has no fees or deposit amounts of its own, so there is nothing more to type.
Rentals (Selling in more than one currency). The nightly rate on the property, each rate band's nightly and weekend amounts, and the fees (cleaning, extra guest, damage deposit and monthly rate) each take an amount per currency, on the property's booking rule and on the Rates tab. How a missing amount counts is strict. A band that governs a night but has no amount in the stay's currency makes that night, and any stay across it, not available: it is not skipped and does not fall through to a lower band or to the property's own price, because that would drop the band's minimum stay and arrival days and undercharge. A fee with no amount in a currency is no such charge there and the stay is still sold. A percentage band is a share of the property's price in that currency. A night no band covers uses the property's price in that currency. The quote route takes currency, and bin/plugin kahunacart-rentals nights <product> --currency=EUR prints the nights in it. The beach house showcase is seeded with euro and sterling amounts when those currencies are on.
Shipping, carriers and labels (Selling in more than one currency). A carrier account is billed in one currency, so each driver has an Account currency setting (shippo.account_currency, easypost.account_currency, shipengine.account_currency, sendcloud.account_currency and demo_carrier.account_currency). Empty is the store's base currency, and for the demo carrier it means whatever currency the cart is in. A cart in the account's currency gets live rates as before. On a cart in any other currency the carrier isn't asked, its methods show as unavailable with the reason "This service cannot be quoted in the currency of this order", and your fallback method carries the order, so give the fallback method an amount in each currency. Nothing is converted. A label is bought in the account's currency whatever the order is in, and its cost is printed in that currency. A customs form says the order's currency; before this release it said USD on every store. Changing a driver’s account currency selects a different quote-cache entry immediately, including when the earlier entry was a refusal.
Newsletters (Segments). The total_spent segment field adds up base-currency orders only, and on a store that sells in more than one currency its label says so ("Total spent, base currency orders only"), so a segment on a spend threshold never adds euro and dollars. A coupon code minted from a campaign carries the amounts of its template. The link in the email is ?coupon=CODE with no currency, so a recipient shopping in a currency the template has no amount for finds the code doesn't apply there.
Licenses prints no amounts and needed no change.
Payment provider add-ons. Each gateway says which currencies it takes. See Payment providers. The Test Payment provider (the dummy) has Only Take These Currencies to try a provider that refuses a currency, and Report a Different Currency to try a platform that settles in another one, which is how the mismatch handling above is tested.
An add-on you write follows the same few rules, in For developers.
Exchange rates and converted prices
Exchange rates
An exchange rate says how many of one currency another buys. KahunaCart keeps one rate for each currency the store sells in, always against the base currency. On a dollar store, 0.92 for euros means one dollar buys 0.92 euros. Rates do three jobs. Every order records the rate it was placed at (see The rate an order records). A quote in one currency can be turned into the shopper's (see ExchangeRates). And a price can be worked out from your base price when you haven't typed one. A store that sells in more than one currency and never picks a rate source or types a rate has no rates, and nothing about it changes.
Where the rates come from
Pick a source under Configuration → General → Selling in More Than One Currency → Exchange Rate Source, or set currencies.rates.source. Beside it is the Fallback Rate Source, or currencies.rates.fallback.
| Source | Setting | What it does |
|---|---|---|
| Rates I type | manual |
The default. Nothing is fetched, and you type each rate on the Currencies screen. |
| European Central Bank | ecb |
The worker fetches the ECB's daily euro reference rates once a day. Free, and no key. |
| Frankfurter | frankfurter |
The worker fetches daily rates from Frankfurter, which gathers the reference rates of over a hundred central banks and official sources. Free, and no key. It is also the fallback unless you say otherwise. |
| An add-on's | the code the add-on gives it | A keyed service, such as Open Exchange Rates, that an add-on has registered. It shows up in the same select. See Writing a rate source. |
The fallback. On any source but manual, a refresh asks your source first, then asks the fallback for the currencies your source didn't answer. With the ECB as your source and Frankfurter as the fallback, which is the default, the ECB answers its thirty currencies and Frankfurter fills in the rest. A store that sells in the UAE dirham, the Saudi riyal or the Nigerian naira gets those rates fetched too. When your source can't be reached at all, the fallback is asked for every currency, the refresh still counts as having worked, and the Currencies screen says so: "European Central Bank could not be reached, so Frankfurter answered for AED, EUR, GBP." Each rate is stored with the source that answered it, so the screen shows "AED · Frankfurter" beside "EUR · European Central Bank". Only when both fail is it a failed refresh, and then every old rate is kept. Set currencies.rates.fallback to none to use your source alone. The fallback is never read on the manual source, and a fallback that is the same as the source is no fallback.
What Frankfurter gives you. Frankfurter is a free, open-source rates service that needs no key. It blends the daily reference rates of 104 central banks and official sources into one rate for each of more than 160 currencies. That covers the dirham, the riyal, the naira, the Egyptian pound, the Argentine peso and the other currencies the ECB leaves out. Each rate comes with the day its bank published it. To use one bank's official rate instead of the blend, put its provider id in currencies.rates.frankfurter_providers (Frankfurter Provider): ECB for the European Central Bank, NBP for the National Bank of Poland, and so on through Frankfurter's list. A currency that bank doesn't publish then gets no rate from Frankfurter.
Running your own Frankfurter. Frankfurter can be self-hosted with docker run -p 8080:8080 lineofflight/frankfurter. Put its address in currencies.rates.frankfurter_url (Frankfurter Address, https://api.frankfurter.dev/v2 by default). The store reaches it the way it reaches every outside service. It only connects over https and refuses loopback, private and other internal addresses, so a copy at http://localhost:8080 or on a private network can't be used. It has to be reachable over https at a public address.
What the ECB gives you. It publishes one file of rates on each working day, around 16:00 Central European Time, so a rate fetched on a Saturday is Friday's. It lists about thirty currencies: AUD, BRL, CAD, CHF, CNY, CZK, DKK, GBP, HKD, HUF, IDR, ILS, INR, ISK, JPY, KRW, MXN, MYR, NOK, NZD, PHP, PLN, RON, SEK, SGD, THB, TRY, USD, ZAR and the euro. A currency it doesn't list gets no rate from it, which is what the fallback is for. With no fallback, a store that sells in the UAE dirham has to type that rate.
Every ECB rate is against the euro, so a pair without the euro in it is worked out by going through the euro. That sum is rounded once, half up, to twelve decimal places, and the result is what is stored. If your base currency isn't on the ECB's list, none of the rates can be worked out this way, so with the ECB as your source every rate comes from the fallback.
Typing a rate
The Currencies screen has a box for each currency's rate. Type a rate and press Save. It is kept exactly as you typed it, up to twelve decimal places, and it is pinned: a refresh never overwrites it, and it never counts as old, because you decide when it changes. Press Remove and the currency goes back to the source at the next refresh. That is how you cover a currency the source doesn't list, and how you hold a rate your bank gave you.
The market rate beside a typed one. On a store that fetches its rates, the refresh also asks the source (then the fallback) about the rates you typed. It makes separate calls for them. If no source can answer for a typed currency, that currency just has no market rate to show. The rates you fetch are still saved, and the refresh doesn't count as failed. The answer is never written. It is kept as a reference, and the Currencies screen shows it under your rate: "You typed 0.92 · market 0.86 (6.5% away)". Use market rate puts the market number in the box. You still press Save, and it is still a typed, pinned rate. The screen rounds market and held rates to six significant digits so they read at a glance (0.756407, 158.255). The button, and Accept, use the full stored number. When a typed rate is further from the market than currencies.rates.manual_drift_percent (Typed Rate Drift (%), 5 by default), the dashboard shows an information line naming the currency and how far away it is. It is advice, not a problem. Your typed rate is the one used, it never sends a notification, and dismissing it keeps it away for a week.
Look up. A store on the manual source fetches nothing, so the screen has a Look up button instead. It asks Frankfurter once for today's rates for your base and every currency you sell in, using the Frankfurter address and provider in your settings, and shows each one beside its row with the same Use market rate button. Nothing is written. The answer is kept for an hour, so pressing the button again asks nothing new. It is the only request a store on the manual source ever makes, and only when somebody presses the button. A store that fetches gets the button too when a currency has no rate, or when a typed rate has no market rate from the last refresh. When the last refresh and a Look up both have a market rate for a typed rate, the screen shows the one for the later day. On the same day it shows the Look up, since you asked for it just now. How far a typed rate is from the answer is worked out on the server, in exact decimals, so the screen never does that sum itself. The route behind it is GET /kahunacart/currencies/rates/lookup (see Admin API).
The daily refresh
A store that sells in more than one currency and picked a source other than manual has the worker fetch the rates once a day. The worker books the next fetch 23 hours after one that worked, and 3 hours after one that failed, so an outage at the source is tried again the same day. It is a queued job like the others, so the worker has to be running (see CLI → work), and it never runs inside a shopper's request.
A fetch that fails keeps every old rate. Nothing changes, the reason is written to the Grav log, and the Currencies screen and bin/plugin kahunacart rates show it under the last refresh. A currency no source covers keeps the rate it had, which then ages like any other. A rate you typed is left alone and named in the result.
Every fetched rate keeps the day it is for. The ECB's file says which day its rates are for, Frankfurter dates each rate, and a source that says nothing gets the day of the fetch. That day is stored with the rate, and it is what the rate's age is measured from. The Currencies screen shows it as "Rate of Oct 9", with when the store last checked it under it. When a refresh brings the same number again, it only marks the rate as checked now. The day moves only when the answer carries a newer one. The same holds when the number is the same but the source changed, say the ECB's EUR rate replaced by Frankfurter's: the new source's name is stored, and the day stays the later of the two. So a source that stops publishing and keeps sending last week's number with last week's date shows as old after the allowed number of working days, however often it is asked. Before this, a same-number answer counted as fresh, and a frozen source never looked old.
Refresh now on the Currencies screen, and bin/plugin kahunacart rates --refresh, do the same fetch on the spot. The button runs it while you wait for the answer, which the daily job never does. Only one refresh runs at a time. If you press it while another is running, it says "A refresh is already running." A refresh that crashes holds its place for an hour at most. You can still type rates while a fetch is running.
There is no history table of old rates. An order keeps the rate it was placed at, and that is the record that matters.
A big move is held for you to check
A fetched rate that moved more than currencies.rates.hold_move_percent (Hold Moves Over (%), 10 by default, never less than 2) from the rate in use is held. It isn't written. The old rate and its day stay in use, and every other currency in the same refresh is written as usual. The comparison is exact, on every digit of both rates. A move a hair past the limit is held even when the percent shown rounds to the limit, and a move of exactly the limit is not. A currency with no rate yet is never held, and neither is a rate you typed, because a typed rate is never fetched. Devaluations are real (the Argentine peso, the Egyptian pound and the naira have each moved 20 to 50% overnight), and so are broken numbers, so a person decides.
A bad file is still turned down whole, so it never changes your prices. The ECB file is refused if any rate is outside 0.01 to 1,000,000 units per euro, a currency shows up twice, the euro shows up as a quote, or a rate isn't a valid number. Frankfurter's answer is refused on the same kind of fault. The stored rates and their dates stay as they were.
The Currencies screen shows a held currency with the old rate, the new one and how far it moved ("The fetched rate moved too far to use without a check: 47.1 → 58.4 (24%)"), and two buttons:
- Accept writes the fetched rate with the source and day it came with. It isn't pinned, so it keeps updating from the source like any fetched rate, and the catalog index is told the way a refresh tells it. If a refresh has replaced or settled the hold since the screen was drawn, or somebody has typed a rate for that currency since, nothing is written and the screen says to look again. Accept never writes over a typed rate.
- Keep old rate lets the hold go. The old rate stays, and the next refresh holds the rate again if the move is still there. To stop being asked, type the rate you want. A typed rate clears the hold and is pinned until you remove it.
Each refresh looks again at the currencies it fetched. One that comes back within the limit is written and its hold goes. One still beyond it keeps its hold, with the newest answer. Taking a currency off the store, or typing its rate, clears its hold. The dashboard alert names held currencies and how far they moved, before any old or missing ones, and the Exchange rates out of date notification names them too. It is still one message when the problem starts and one when it is cleared. bin/plugin kahunacart rates lists them, and rates --accept=EGP and rates --keep=EGP do what the buttons do. A store that still has currencies.rates.max_change_factor in its settings from before isn't affected by it. That setting is no longer read.
When rates get old
A fetched rate counts as old when it is more than currencies.rates.max_age_days working days (3 by default, Rate Age Warning (working days) in the settings) past the end of the day its source published it. Only Monday to Friday count, because the banks publish nothing at the weekend. Friday's ECB rate isn't old on Monday, and Thursday's is still fine on the Tuesday after Easter, when Good Friday and Easter Monday brought nothing new. A rate stored before rates were dated is measured from when it was fetched, in calendar days, until the next refresh dates it. A currency with no rate at all counts as missing. A held rate and the two above are said in three places:
- The dashboard shows an Exchange rates are out of date alert with a button to open the Currencies screen. A rate held for a check is named first.
- You get the Exchange rates out of date notification: one message when it begins and one when every rate is fresh again. It names held currencies too.
bin/plugin kahunacart statusprints a warning.
An old rate is still used while this is on the screen, so the store keeps selling. The alert is there so you look at why the source isn't answering. Only a store that fetches its rates is told. A rate you typed never counts as old, and a store on the manual source gets no alert, no notification and no warning.
The rate an order records
Every order that completes records the rate from its currency to the base currency at that moment, as fx_to_base in the order's data. It is a decimal string and never a number: "1.086956521739" for a euro order when one dollar buys 0.92 euros, and "1" for an order in the base currency. It is written once, as the order completes, and never changed. A report in the base currency adds up each order with the rate it was placed at.
When the store has no rate for the order's currency, the rate is left out and never guessed. Orders from before this existed have none. A store that sells in one currency records nothing, so its orders are exactly what they were.
What the payment provider paid out
fx_to_base is the store's own reference rate, and it is the one reports use. It isn't what you were paid. When a customer pays in euros and your Stripe or PayPal account is in dollars, the provider converts the money at its own rate, with its margin in it, and pays out dollars. To check a payout against your orders you need that second rate, so the store asks for it.
On a store that sells in more than one currency, every sale or capture taken through a provider that can say (Stripe and PayPal do) books one payments.settlement job on the worker. It is never booked from inside an open database transaction: a payment written inside one is booked once the transaction is over, and not at all if it rolled back. The job asks the provider what it converted that charge at and paid out, using the same payment account the charge was taken on, and writes the answer onto the transaction's payload_json under settlement:
"settlement": {"currency": "USD", "amount_minor": 10090, "rate": "1.1202", "reference": "txn_3Q…", "recorded_at": 1791676800}
currencyis the currency the provider paid out in.amount_minoris what reached your account, after the provider's fee, in that currency's minor units. That is the number to match against a payout. Stripe's is the balance transaction'snetand PayPal's is itsreceivable_amount.rateis the provider's conversion rate, as a decimal string of at most twelve places. An amount in the charge's currency timesrateis the converted amount in the payout currency before the provider's fee, which is more thanamount_minor. That is the way round Stripe and PayPal both state it. It isn't the same direction asfx_to_baseunless the payout currency is the base.referenceis the provider's id for the payout line, such as Stripe's balance transaction, or null.
That JSON is a €94.00 Stripe charge converted at 1.1202 to $105.30, with $100.90 reaching the account after Stripe's $4.40 fee. The charge times the rate (€94.00 × 1.1202 = $105.30) is the converted amount before the fee, so it is more than amount_minor by the fee. It is written once and never changed. A charge taken in the account's own currency wasn't converted, so nothing is written. A provider that hasn't settled a charge yet is asked again ten minutes, an hour, four hours, twelve hours and a day after the charge, then the store stops asking and writes a line to the log. A lookup that goes wrong is logged and not retried. It never fails the job or touches the order. None of this happens inside the customer's checkout or the provider's webhook.
The order screen shows it under the transaction's amount as one short line: a €94.00 charge reads "Paid out as $100.90 at 1.1202". GET /kahunacart/orders/{id} adds settlement to a transaction that has one, and the order exports carry it in three columns, settlement_currency, settlement_amount and settlement_rate (see Reports and totals). An order paid in more than one charge exports the first succeeded sale or capture that has a settlement, and the others stay on its transactions. settlement_amount is a decimal in the payout currency's own decimal places. Reports keep using fx_to_base, so nothing in the converted views changes.
A store that sells in one currency books no job, makes no call to the provider and writes nothing. A payment provider add-on can offer the same thing by implementing SettlementLookupInterface. See Payout lookup in the provider contract.
The Currencies screen
A store that sells in more than one currency has a Currencies screen under Setup. Its Exchange rates panel names the source and the fallback, with a link to change them in the settings. For each currency it shows the rate, where it came from ("Typed by you" or the source, as in "AED · Frankfurter"), the day it is for ("Rate of Oct 9", with when the store last checked it under it) and an Old tag when that is too old. A held rate shows under the rate with Accept and Keep old rate, and a typed rate shows the market rate beside it with Use market rate. A currency with no rate says No rate. A box beside each takes a rate to type, Save keeps it and Remove takes it away. Refresh now at the top fetches from the source, and says when the fallback answered because the source couldn't. It is greyed out on the manual source, where Look up takes its place. Under the table, the screen says how the last fetch went, and why when it failed. The Prices in each currency panel below it is where you choose converted prices.
The routes behind the screen are in Admin API and MCP tools.
From the command line and the API
bin/plugin kahunacart rates prints the rates and rates --refresh fetches them. rates --accept=EGP and rates --keep=EGP answer a held rate. See CLI → rates. GET /kahunacart/currencies/rates answers the same document as the screen, and PUT, DELETE, POST …/refresh, POST …/{quote}/accept and DELETE …/{quote}/held change it. GET …/lookup asks Frankfurter for today's rates and writes nothing.
Converted prices
On Currencies, the Prices in each currency panel lets you choose, for each currency, Prices I type (manual, the default) or Convert from the base price (converted). Conversion fills in an amount you haven't typed. The sum is the base amount times the exchange rate times (1 plus the markup divided by 100). It is rounded half up to that currency's decimal places, and then by the currency's rounding rule where the table below calls the amount a price.
A price you typed, zero included, always wins. With no rate, a product with no typed price stays unavailable, and the other amounts follow the same missing-amount rules as typed pricing. Rates can be typed or fetched, and an old rate is still used until it is replaced.
Each currency has three choices, saved with Save pricing:
- Pricing: Prices I type or Convert from the base price.
- Markup (%): from -90 to 1000, with up to four decimal places,
0by default. It applies to every converted money amount, discounts, thresholds and fees too, so those stay in proportion to the converted price. In the config file, store it as a quoted decimal string. - Round prices to:
none(shown as "Currency decimal places"),whole,99,95ornearest_005. See What is rounded.
The base currency is never converted. A mode that isn't manual or converted is read as manual, a bad markup as zero and a bad rounding rule as none.
currencies:
enabled: [EUR, JPY]
missing_price: unavailable
pricing:
EUR: {mode: converted, markup_percent: '2.5', rounding: '99'}
JPY: {mode: manual, markup_percent: '0', rounding: none}
Under the table is one store-wide choice, With no typed price in a typed-price currency:
- Not available (
currencies.missing_price: unavailable) leaves each currency's own choice in charge. Use it to pick conversion for one currency at a time. - Convert every currency with a rate (
currencies.missing_price: convert) makes every enabled currency convert, even one set to Prices I type. Each currency keeps its markup and rounding. It still needs a rate, and a typed amount still wins.
With every currency on typed prices and the default store-wide choice, prices and missing-amount behaviour are exactly what they were.
In the product editor
An empty price or compare-at box for another currency shows what the saved base price converts to, in grey, such as €23.99 converted. A free base price shows zero without the converted label. The box stays empty until you type a price of your own, and leaving it empty keeps the conversion. The hint is worked out from the saved base price and the current rate when the editor loads, so save a changed base price to see its new conversion. It is the catalogue price, before any price list or sale a shopper might get. Through the API the hints are in variants[].converted_amounts, apart from the typed currency_amounts, so saving a product never turns a hint into a typed price.
What is rounded
Every converted money amount includes the markup and is rounded half up to the currency's decimal places first (yen has none, euro two, dinar three). Only prices then get the price rule you chose. Discounts, deposits, thresholds, limits and fees keep the decimal-places result. Percentages never convert.
| Amount | Rounded to |
|---|---|
| A variant's price and compare-at price, an add-on option's price, a pay what you want suggested price, a subscription plan's price, a rental rate | The currency's decimal places, then the price rule |
| A pay what you want maximum (the variant's price is the minimum), a fixed deposit | The currency's decimal places only |
| A fixed amount off in a sale, a money argument of a sale operation, a fixed rung of a quantity ladder inside a sale | The currency's decimal places only, even in an operation that sets a price, so its money argument stays exact |
| A coupon's amount, minimum and maximum order total | The currency's decimal places only |
| A shipping method's rates, per-item and per-kilogram rates, free-shipping minimum and order total limits, per-class costs and weight band amounts | The currency's decimal places only. A free minimum you typed under either name wins |
| A collection point's charge, a payment method's fixed fee | The currency's decimal places only |
shipping.flat_rate, cart.order_min_total, cart.order_max_total, policies.returns.fee and the shipping lines in policies |
The currency's decimal places only, when the map has no entry for the currency and a base amount exists |
A condition on money, such as cart_subtotal or line_unit_price |
The currency's decimal places only. A bare threshold, or one that names the base currency, converts in a converting currency. A condition that names another currency stays limited to that currency |
| Any percentage | Never converted |
| Price lists and a variant's quantity ladders | Not converted. They already have their own currency |
Shipping has three more rules. A class amount you typed beats the converted one. A list of weight bands you typed replaces the whole base list, because the bands decide which parcels are allowed. With no typed list, conversion keeps the base gram ranges and converts every band's amount. A method that uses a cost_expression can't be converted safely, so it needs an expression of its own for the currency, or it isn't offered there. Shipping zones have no amounts of their own.
The price rule has five choices:
| Price rule | What it does |
|---|---|
none |
Keeps the result at the currency's decimal places |
whole |
The nearest whole unit, upwards on a tie |
99 |
The nearest price ending in .99 (.990 for dinar), upwards on a tie. Whole yen stay whole yen |
95 |
The nearest price ending in .95 (.950 for dinar), upwards on a tie. Whole yen stay whole yen |
nearest_005 |
The nearest 0.05 of a unit, upwards on a tie. A currency with fewer than two decimal places is left as it is |
A free base price stays free. A positive base price that would convert or round to zero becomes one minor unit instead, so a conversion can never make a paid product free. An amount too large to store is unavailable. For example, $19.99 at 0.92 becomes €18.39 with none, €18.00 with whole, €17.99 with 99, €17.95 with 95, or €18.40 with nearest_005.
Rate changes, the catalog index and carts
Listings and product pages work out prices from the current rate, and a line added at that rate gets the same price. A rebuild of the catalog index and the worker's incremental updates use the same sum.
- The index follows the rate. A refresh or a rate you type queues an index update when a converting currency's rate moves by 0.5% or more. It is measured against the last full rebuild or queued update, so a run of small moves adds up against that same starting point. A new rate, a removed rate, or pricing saved on the Currencies screen queues an update too. The worker handles the queue in batches. Sorting and filtering read the stored index prices, so they can lag until the worker catches up. A price-ending rule can turn even a small rate move into a larger price jump, and while the index is behind there is no promise of a cent or a fixed percentage. The dirty list and the saved rate it is measured against are written together. If either write fails, the worker keeps the old starting point and tries again on its next check.
- After a change outside the screen, run
bin/plugin kahunacart index --rebuild. That means pricing edited directly in YAML, a currency you added, or conversion turned off outside the Currencies screen. - An open cart keeps the price it was quoted. Each line keeps the price and the sale amounts it was quoted at, including markup and rounding. A fixed sale rule, a quantity tier or a money argument on a line already in the cart uses that line's quote, even if the rate changes or the rate is removed. Sale expiry and eligibility still apply. Lines added at different rates keep their own quotes. Turning a currency off keeps its pricing choices for the carts, orders and conditions that already use it.
- Some things are worked out again. Coupons, shipping and payment fees are recalculated with the current amounts and rates, the same as after typed amounts change. A fixed deposit reads the current amount when it is asked for a quote, and a deposit session that has opened keeps the amount it agreed.
- Loading a cart and checking out don't reprice it. Loading an open cart and checking out leave a line's quoted price alone when a rate changes, the same as when a base price changes. New lines use the current rate.
- Restoring or switching reprices. Restoring an abandoned cart, or switching a cart's currency, prices the lines again at the current rate and tells the shopper what changed. A line that can no longer be bought follows the same refusal and removal rules as one with a typed price. Checkout still does its usual stock, coupon, shipping and payment checks.
Troubleshooting
A product says "Not available in EUR". Its priced variants have no euro amount; a free variant follows the exception above. If euro is set to convert, there is no euro rate yet: see Exchange rates. Otherwise type one (see Pricing a product in another currency), or find every product that lacks one with the Missing a price in filter on the product list. The page's link "See it in USD" is the way back.
A currency isn't in the switcher. It isn't in currencies.enabled. A code that isn't a currency is left out and written to the Grav log, so a typo costs you that currency and nothing else. The switcher is also empty if your theme doesn't include it: core's pages don't draw it.
A shopper opens ?currency=EUR and gets dollars. Their cart has something in it and is in dollars, and a cart's currency always wins. The page says so and has a button to move the cart (see A link to another currency when a cart is open), and so does the switcher (see Switching with a cart open). A cart with nothing in it doesn't do this. Or the store doesn't list choice in currencies.detect, which is the step that reads ?currency=.
A listing sorts newest first, or has no price range, in a new currency. The catalog index hasn't been rebuilt since the currency was added. Run bin/plugin kahunacart index --rebuild. bin/plugin kahunacart status says which currencies the index is missing.
Checkout says "No payment method is available for orders in EUR". Nothing takes that currency. bin/plugin kahunacart status lists the payment methods for each currency. A provider may not take it, or every method that does may be limited to other currencies. See Paying in another currency. Don't list a currency until at least one method takes it.
A shipping method is missing in euro. It has no rate in euro (or no per-item, per-kilogram, per-class, weight-band, cost-formula or free-minimum amount that its cost needs). Checkout says "This option is not available for orders in this currency." Type the amounts on the method. A carrier method shows the reason in the Shipping add-on's words when its account is in another currency.
A coupon says "This code can't be used on an order in EUR". It has no amount in euro, or a minimum or maximum order total in dollars and none in euro. Type them on the coupon.
The order minimum or maximum isn't enforced in euro. A single amount is the base currency's. Use a map, { USD: '50.00', EUR: '45.00' }, in the config file, and an order in a currency the map doesn't list has no limit.
A cart changed when the shopper switched currency. That's the switch repricing it. The notice names every line it removed and every code and choice it cleared. See Switching with a cart open.
A renewal is waiting on a price. See the Subscriptions entry under Add-ons: put the price back, or add the currency back to currencies.enabled.
Prices came back after I removed a currency and added it again. Taking a currency out stops new carts in it and doesn't delete its amounts. Existing carts and orders keep their own currency and use its prices, coupons, fees and pickup charges, even when no extra currencies remain enabled. That's by design.
A shopper sees the wrong currency from a cache or a CDN. A page is public only when its URL decides its currency. See Caching and CDNs, especially the cookie bypass rule.
A configuration import was refused naming a currency. The file has amounts in a currency the store doesn't sell in. Add the currency under currencies.enabled and import again. See Moving a configuration between stores.
The demo seed printed YAML and no prices show. seed --currencies writes prices and never edits your configuration. Add the currencies: block it printed, then rebuild the index. See Demo store.
A back-in-stock email has the dollar price for a euro shopper. See the limit under Back-in-stock alerts.
For developers
KahunaCart::instance() hands out the services.
| Call | What it gives you |
|---|---|
baseCurrency() |
The store's own currency, the currency setting. Use it for admin defaults, catalogue entry, cost prices and seeding. |
currency() |
A deprecated alias for baseCurrency(), kept so add-ons written for 1.3 keep working. It does not mean "the currency of this order" and never did. |
currencyContext() |
A Currency\CurrencyContext: which currency this request is in, and the extra currencies the store sells in. |
currencyAmounts() |
A Currency\CurrencyAmounts: the table above, with reads held for the request. |
variantPrices() |
A Pricing\VariantPrices: what a variant costs in a currency. See VariantPrices. |
rates() |
A Currency\Rates\ExchangeRates: the store's rates, and the call that converts an amount between two currencies. See ExchangeRates. |
rateSources() |
The Currency\Rates\RateSources registry: manual, ecb, frankfurter and whatever an add-on registered. See Writing a rate source. |
cartCurrencySwitch() |
A Currency\CartCurrencySwitch: moves an open cart to another currency. See CartCurrencySwitch. |
orderBuilder($currency) |
A builder for an order nobody checked out. With no argument, or null, the order is in the base currency, as it always was. A code the store does not sell in throws an \InvalidArgumentException. Its lines are priced in that currency, from the variants' rows in it, and a variant with no price there throws a KahunaCartException, so a EUR order of a product with no EUR price cannot be built. |
Anything with an order in front of it uses the order's own currency column. The context is for a request that has no order yet.
CurrencyContext
| Method | Answers |
|---|---|
base() |
The base currency. |
enabled() |
The extra currencies, validated, upper case, the base left out. |
all() |
The base first, then the extra ones. |
isMulti() |
Whether there is a second currency to choose between. |
isEnabled($code) |
True for the base and for each extra currency. |
current() |
The currency this request is in, resolved once and held. |
source() |
Which step decided: cart, choice, address, header or base. |
choiceOrigin() |
For a choice, whether it came from the address bar (url) or the cookie (cookie). A page whose currency is in its own URL can be cached under that URL, and one whose currency is in a cookie cannot. |
wasAsked() |
Whether anything has asked for the currency during this request, which a page that drew a price or a switcher has and an About page has not. It doesn't resolve anything, and forgetting the answer doesn't clear it. |
cacheKey() |
What to add to a cache key. The empty string on a single-currency store, so existing keys come out unchanged, and a hyphen plus the current currency otherwise: $name . $context->cacheKey(). |
forCountry($code) |
The currency a country sells in, through currencies.map, or null. |
choose($code) |
Records an explicit choice: validates the code, sets the kc_currency cookie (when the visitor's consent allows it), and returns false without setting anything for a single-currency store or a currency the store does not sell in. |
locksToCountry() |
Whether currencies.lock_to_country is on. |
readsHeader() |
Whether a country header decides anyone's currency: currencies.header names a header and header is one of the detect steps. When it does, only a ?currency= in the address makes a page public. |
forget() |
Drops what has been worked out, so current() looks again. Called when the cart or the customer changes: a cart is made or forgotten, or a customer signs in or out. |
configAmount($value, $currency) |
One config amount (a single value or a map) in one currency, or null when there is none. |
There is no request in a job or a CLI command, so current() there is always the base currency. A job works from its order's own currency and does not need the context.
Links that name the currency
Frontend\CurrencyLinks is the one place the ?currency= parameter is written. CurrencyLinks::carry($url, 'EUR') puts it on a URL, in place of any currency the URL already had and after the rest of the query, ahead of a #fragment. CurrencyLinks::strip($url) takes it off again, which is what a canonical link and a sitemap entry want. A feed’s product link carries the feed’s currency instead, including the base currency when the store reads a country header. CurrencyLinks::carries($path) says whether a storefront path carries it on its own: a product, a category, the search page and the cart do.
On the frontend, currencyParam() answers the code a link should name, or null for a visitor in the base currency and on a store with one currency. A store that reads a country header names the base currency too. linkUrl($path, $carry = null) is what the Twig kahunacart_url() function calls, and catalogUrl($path) is a catalogue URL with the currency on it. Code that builds a storefront URL for a page, a form or an email to a shopper calls those and not url(), which stays the plain address. A URL meant for somebody else (a share link) or for a search engine uses url() or the SEO builder, which never name a currency.
CartCurrencySwitch
KahunaCart::instance()->cartCurrencySwitch()->switch($orderId, 'EUR') moves a cart, and preview($orderId, 'EUR') runs the same work in a transaction that is then undone, to find out what a switch would do. Both answer a Currency\SwitchResult: switched, refusal (one of the SwitchResult constants NOT_A_CART, PAYMENT_OPEN, NOT_ENABLED, ALREADY, NO_ORDER or WOULD_EMPTY, or null), from, to, repriced (how many lines), removed (each with title, reason and detail), droppedCodes, shippingReset and paymentReset. A caller that has told the shopper every line would go passes confirmEmpty: true. It is the one place kahunacart_orders.currency is ever updated. restart($orderId, $sessionHash, 'EUR') instead creates a new cart for an owned cart with a hosted payment, returning order, source and removed (title, reason, detail); it never rewrites the source cart.
What applies in a currency is asked through Currency\SwitchRules, which ClosureSwitchRules implements with one optional closure for each question: dropCodes(orderId, currency) returns the codes it took off, and shippingMethodOffered, pickupLocationOffered, paymentMethodOffered and providerOffered answer whether a choice still applies. KahunaCart connects each one to the code that owns the rule: the coupon currency gate, the shipping method's amounts in that currency, the collection point's offer, a payment method's currencies list and its provider, and the provider's supportsCurrency(). A ClosureSwitchRules built with no closures, which is what a test or an add-on's own switch gets, answers yes to every question and drops nothing.
Prices in templates
kahunacart_price(minor) with no second argument formats in the currency this request is in, which is what a catalogue price wants, and a null amount prints nothing. kahunacart_current_currency() answers the code of that currency and the base's, for a line like "Not available in EUR". The rows a template is handed carry unavailable_in_currency: true where a variant or a product has no price in it (see What a shopper sees). Anything that belongs to an order, a refund or a subscription is in that record's own currency, whatever the visitor is browsing in, so pass it: kahunacart_price(order.total_minor, order.currency). An account page or an email that leaves it out prints the right number with the wrong currency as soon as a customer has orders in two.
CurrencyAmounts
resolve($ownerType, $ownerId, $field, $baseMinor, $currency) answers the base currency from the amount it is handed without touching the database, and any other currency from its row, or null. load($ownerType, $ownerIds) reads every row for a list of owners in one query and holds them, so a listing of 48 products costs one query per owner type, not one per line. set(), setMany(), all(), deleteOwner(), deleteOwners() and copyOwner() cover the editors, exports, deletes and duplicates. hasRows() checks whether any saved amounts need to be kept or deleted, regardless of the enabled list. Repositories share that check lazily, on the first edit that replaces rows which can own amounts or on an owner delete, never when they are built or on a read path; an empty store remembers the answer until an amount is written, so its later edits and deletes run their original statements. A check made inside a transaction is not remembered after it. A write refuses the base currency, which lives in the base column, and any code that is not a currency. The owner types are constants on the class, so nobody invents their own spelling.
VariantPrices
variantPrices() is the one place that answers what a variant costs in a currency. Pass it the variant row, as the repositories return it, and a currency code.
| Method | Answers |
|---|---|
priceOf($variant, $currency) |
The price in minor units, or null when the variant has none in that currency. The base currency answers from the row with no query. |
compareOf($variant, $currency) |
The compare-at price, or null. |
isPriced($variant, $currency) |
Whether priceOf() is not null. A stored zero is a price. |
rowIn($variant, $currency) |
The row with price_minor and compare_price_minor replaced by the currency's amounts (null where there are none) and a currency key added. For the base currency the row comes back unchanged, with no currency key. |
rowsIn($variants, $currency) |
rowIn() for a list, with one query for the whole list, keys and order kept. |
forVariants($variants, $currency) |
A map of variant id to price_minor and compare_price_minor, in one query. |
isBase($currency) |
Whether the currency is the base. |
A listing of 48 products costs one query however many of them have prices in the currency, and the base currency costs none.
Resolving a row twice is the same as resolving it once. A row that already carries a currency equal to the one asked for comes back as it is, with no lookup, so a row that has been through rowIn() can be handed to another function that calls it again. A row carrying a different currency than the one asked for is a bug in the caller, because its price_minor is not an amount in the currency being asked about, and it throws a LogicException. rowsIn() checks every row the same way before it reads anything.
ReportCurrency
Report\ReportCurrency::for($connection) is the one place that says which currency a report's money is in. Every read-side sum asks it, and KahunaCart::reportCurrency() is the store's own.
| Method | Answers |
|---|---|
select($code) |
Chooses the currency for this request. Null, an empty string or the base puts it back on the base. A code the store neither sells in nor has orders in throws \InvalidArgumentException, which a controller answers with a 422. |
code() |
The currency the figures are in. |
isBase() |
Whether that is the base currency. |
active() |
Whether a sum has to say which currency it adds up. False for a store that has only ever sold in one currency, and then nothing below adds a predicate. |
and($column) |
AND o.currency = ? and its parameter, or nothing when not active. |
where($column) |
The same without the leading AND. |
hasCost() |
Whether cost and margin can be measured here: true for the base currency and for a store that is not scoped. |
CountableOrders::scoped() and scopedJoin() are clause() and joinClause() with the predicate added, and every report that sums a live scan uses them. The rollup builders keep using clause(), because they group by currency themselves. The rollup tables already hold one row per day, currency and provider, and a rebuild from orders in two currencies writes a row for each.
select('all'), isConverted() and selection() identify the converted view. ConvertedReport handles it explicitly. and() stays a native-currency predicate, so no other reader can add currencies by accident. choices() reads completed order history only when the picker is asked for. Exact sums use Money::convertedDecimal() and Money::sumDecimalMinor(). CustomerRepository::statsInBase() gives the same totals for customers. The converted customer total is also what CustomerSpend::FIELD (total_spent_base) names for add-on segments, and CustomerSpend::forMany() supplies its values and its missing-rate counts. An add-on that wants it, such as Newsletters, has to register and resolve that field itself. Its existing total_spent still means base-currency orders only.
What an add-on or a provider does
- A payment provider answers
supportsCurrency($currency)and, if it has a fixed list,supportedCurrencies(), and reports the currency a platform settled in ascurrencyon a webhook result. See Provider developer guide → Currencies and Testing a payment provider. - An add-on that prices a line listens to
onKahunaCartCartLinePrice, which carries the cart'scurrency, and can refuse withLinePriceAnswer::refuse()when it has no amount in that currency. See theonKahunaCartCartLinePriceevent in the events reference. - An add-on with amounts of its own names its own owner type in
kahunacart_currency_amounts(the Subscriptions, Bookings and Rentals add-ons usesubscription_plan,rental_ruleandrental_rate) and reads throughCurrencyAmounts, so a missing amount is null and never the base number. TheRepository\DropsCurrencyAmountstrait removes an owner's amounts when the owner is deleted. - An add-on admin section is handed
ctx.currencies, andctx.ui.money(minor, currency)formats an amount in its own currency. See Admin sections. - Anything that builds an order passes the currency to
orderBuilder($currency), and every template that prints an order's money passesorder.currencytokahunacart_price(). - A theme follows Theming.
ExchangeRates
KahunaCart::instance()->rates() is the one place that reads a rate and turns an amount from one currency into another's. Every rate is a decimal string with at most twelve places, and nothing in it is a float.
| Method | Answers |
|---|---|
rate($from, $to) |
How many units of $to one unit of $from buys, as a canonical decimal string, or null when the store has no rate for the pair. A currency to itself is "1" with no query. A pair that doesn't involve the base currency is worked out through it: the quote's rate divided by the other's, rounded once, half up, to twelve places. |
toBase($currency) |
The rate from a currency to the base, which is what fx_to_base records: one over the stored rate, rounded the same way. "1" for the base. |
convert($minor, $from, $to) |
An amount in $from's minor units as $to's, or null when there is no rate. The rate is applied to the exact amount and rounded once, half up, at the target currency's own decimal places, so dollars into yen come out in whole yen and yen into dollars in cents. The same currency returns the amount untouched. |
all(), row($quote) |
The stored rows: rate, source, fetched_at (a Unix timestamp: when the store last wrote or checked it) and rate_date (the day the source says the rate is for, YYYY-MM-DD; null for a typed rate and for a row from before rates were dated). |
staleness($quotes, $maxAgeDays) |
The currencies whose fetched rate is older than the limit (with the age in seconds) or have no rate (null). A dated rate is measured in working days from the end of its day (workingAge($day, $now), Monday to Friday UTC), an undated one from fetched_at. A typed rate is never in it. |
set($quote, $rate, $source, $at, $date), setMany($rates, $source, $at, $dates), touch($quotes, $at, $dates), remove($quote) |
Writes. $date and $dates (quote => YYYY-MM-DD) are the day the source says each rate is for. A typed rate stores none. touch() marks rates as checked and moves a day only forward. A rate that isn't a positive decimal with at most twelve places, or a day that isn't a real one, throws an \InvalidArgumentException and nothing is written. |
An add-on that quotes in another currency, such as a carrier account, can turn the quote into the cart's currency with rates()->convert($minor, $carrierCurrency, $cartCurrency). It answers whole minor units, or null when the store has no rate. Treat a null as "not offered", never as "free". Every currency the store sells in has a rate against the base, so a carrier account in the base currency or in any currency the store sells in can be converted. One in a currency the store neither sells in nor has a rate for can't.
After a refresh writes at least one rate whose number changed, onKahunaCartExchangeRatesRefreshed fires with source, base and changes, a list of quote, old (null for a pair that had no rate), new and moved_percent. Nothing fires when the fetch failed or every rate came back the same. Accepting a held rate fires it too, with that one change and the code of the source the rate came from. A held rate isn't in a refresh's changes, because it wasn't written. The catalog index listens to it to queue its update. See the events reference.
Converted prices in code
CurrencyAmounts::resolve() checks the typed row first, and then Currency\Conversion\RateConverter::convert(), which implements AmountConverter. A value with no row goes through CurrencyAmounts::convertBase() or convertBaseDecimal() and reaches the same converter. Config and condition readers pass their field names, so those values get the decimal-places-only rounding. Typed rate writes and the worker's tick call ConvertedIndexSync::reconcile(), and a refresh event reaches ConvertedIndexSync::ratesChanged(). catalog.index_rates is a runtime baseline in the settings table, left out of configuration transfers. The Currencies API puts pricing beside the rates and saves it with PUT /kahunacart/currencies/pricing (set_currency_pricing). See Admin API.
Writing a rate source
An add-on registers a source on onKahunaCartRegisterRateSources, which carries the registry:
use Grav\Plugin\KahunaCart\Currency\Rates\RateFetchException;
use Grav\Plugin\KahunaCart\Currency\Rates\RateSource;
public function onKahunaCartRegisterRateSources(Event $event): void
{
$event['registry']->add(new OpenRatesSource($this->config->get('plugins.openrates.app_id')));
}
final class OpenRatesSource implements RateSource
{
public function code(): string { return 'openrates'; }
public function label(): string { return 'Open Exchange Rates'; }
public function needsKey(): bool { return true; }
/**
* One unit of $base buys this many of each quote, as a decimal string.
*/
public function fetch(string $base, array $quotes): array
{
$answer = $this->get($base); // your HTTP call; throw RateFetchException when it fails
$rates = [];
foreach ($quotes as $quote) {
if (isset($answer[$quote])) {
$rates[$quote] = (string)$answer[$quote];
}
}
return $rates;
}
}
The store owner then picks openrates in the Exchange Rate Source select, and the worker calls fetch() once a day.
Saying which day a rate is for. A service that dates its rates should implement Currency\Rates\DatedRateSource, which extends RateSource with one method:
use Grav\Plugin\KahunaCart\Currency\Rates\DatedRateSource;
final class OpenRatesSource implements DatedRateSource
{
// code(), label(), needsKey() and fetch() as above, then:
/** @return array<string, array{rate: string, date: string}> */
public function fetchDated(string $base, array $quotes): array
{
$answer = $this->get($base);
$rates = [];
foreach ($quotes as $quote) {
if (isset($answer['rates'][$quote])) {
$rates[$quote] = ['rate' => (string)$answer['rates'][$quote], 'date' => $answer['date']]; // "2026-10-09"
}
}
return $rates;
}
}
The refresh then calls fetchDated() instead of fetch(), and each rate is stored with its day. So the stale-rate alert fires when your service stops publishing, even if it keeps answering. A source that only implements RateSource keeps working as before, and its rates are stored with the day of the fetch. A day that isn't a real YYYY-MM-DD, or is more than a day ahead, leaves that rate out, the way an invalid rate is.
- Answer decimal strings in major units, quote per one unit of the base:
"0.92"for euros on a dollar store,"150"for yen. A JSON number you read from a service should be cast to a string from the raw text, never rounded through a float on the way. The store turns it into minor units, because it knows each currency's decimal places. - Leave out a quote you have no rate for. That isn't an error: the store keeps the pair's old rate, says the source doesn't cover it, and a rate can be typed. A value that isn't a positive number with at most twelve decimal places is treated the same way.
- Throw
RateFetchExceptionwith a sentence a store owner can read when you can't answer at all. The store keeps every old rate and shows your message as the reason. - A code is lower case, starts with a letter, and can't be one that is taken.
manual,ecbandfrankfurterare KahunaCart's own. - The refresh fetches only the rates it uses, so your
$quotesleaves out a rate someone typed. Then it calls you again, on its own, with just the typed currencies, to show how far a typed rate is from the market. Whatever you answer there is kept for display and never written. An exception or a refused answer only means there is no market rate to show for them. - A key belongs in your plugin's own settings, encrypted the way a provider's is (see Provider developer guide). The store asks nothing of it:
needsKey()is only a label.
The ECB source (Currency\Rates\EcbSource) and the Frankfurter source (Currency\Rates\FrankfurterSource) are the worked examples. Both take their HTTP call as a closure so a test can hand them a recorded answer, and Frankfurter's shows how to read a rate that arrives as a JSON number without it ever becoming a float. A source an add-on registers can also be picked as the fallback.
The fields of kahunacart_currency_amounts
The typed amounts written under each owner type. The admin and the importers write a separate row for each currency. Converted amounts are worked out when they are read and are never stored in this table.
| Owner type | Owner id | Field |
|---|---|---|
variant |
The variant | price_minor, compare_price_minor |
option_price |
The option price row (its id changes whenever the option is saved, so the amounts are rewritten in the same save) | amount_minor |
sale_rule |
The sale rule (its id changes whenever the sale is saved) | amount_minor, args.<key> for each money argument of an operation (args.amount_minor for one with a single amount), args.tiers.<min>.amount_minor |
sale_tier |
The sale tier | amount_minor |
product_pwyw |
The product | pwyw_suggested_minor, pwyw_max_minor |
product_deposit |
The product | deposit_minor |
coupon |
The coupon | amount_minor, min_total_minor, max_total_minor |
payment_method |
The payment method | fee_fixed_minor |