Multi-Currency & Tax
Currencies, exchange rates, and the tax resolution engine used across rentals, invoices, and purchase orders.
Overview
Signals ships a complete financial engine for multi-currency and tax: a catalogue of ISO 4217 currencies, dated exchange rates with lossless conversion, and a rule-based tax resolution engine. These are the building blocks the rest of the platform draws on when money is involved.
Phase 4 update. Multi-currency and tax engines are now wired end-to-end: rental line items carry currency context and resolved tax; checkout freezes a tax snapshot onto invoices; credit notes mirror invoice tax; purchase-order tax uses the purchase axis. This page covers the shared engine — see Invoicing & Payments and Tax & Compliance for application behaviour.
Currencies
Signals maintains a table of world currencies based on ISO 4217. Each currency records the data needed to store and format monetary values correctly.
| Field | Description |
|---|---|
code |
ISO 4217 three-letter code (e.g. GBP, USD, EUR) |
name |
Full currency name |
symbol |
Display symbol (e.g. £, $, €) |
decimal_places |
Number of minor-unit digits (2 for most currencies, 0 for JPY) |
symbol_position |
Whether the symbol renders before or after the amount |
thousand_separator / decimal_separator |
Grouping and decimal punctuation for display |
is_enabled |
Whether the currency is available for selection |
Currencies are reference data — they are read-only over the API. The Currencies API lists and retrieves them.
Base Currency
The base currency is configured under Settings → Company → Base Currency (company.base_currency, defaulting to GBP). It is the currency all conversions triangulate through when no direct exchange rate exists between two currencies.
Exchange Rates
Exchange rates are dated — each rate has an effective_at (and optional expires_at), so historical conversions stay accurate and you can schedule future rates. Rates are managed through the admin UI and the Exchange Rates API.
| Field | Description |
|---|---|
source_currency_code / target_currency_code |
The currency pair (must differ) |
rate |
The conversion rate from source to target |
inverse_rate |
Auto-computed as 1 / rate when not supplied |
source |
Where the rate came from (e.g. manual) |
effective_at / expires_at |
The window during which the rate applies |
Conversion and Triangulation
Conversions use brick/money with RationalMoney for lossless intermediate arithmetic, rounding only at the final step to the target currency's minor unit. When converting between two currencies with no direct or inverse rate on file, the engine triangulates through the base currency:
GBP → JPY = (GBP → base) × (base → JPY)
Triangulation is non-recursive — it resolves each leg directly and multiplies the two rates.
Tax Engine
Tax in Signals is resolved, not hard-coded. There is no single "tax rate" field on a catalogue item. Instead, the engine resolves the correct treatment for a given net amount in context, by matching the relevant tax types against a configurable rule matrix.
The moving parts (all managed under Tax types):
| Concept | Role |
|---|---|
| Catalogue item tax types | Categorise items by tax treatment (Standard, Reduced, Zero, Exempt) |
| Company tax types | Categorise accounts by tax status (Standard, Exempt, Reverse Charge) |
| Tax rates | Named percentages (e.g. "UK Standard" at 20%) |
| Tax rules | Map an company class + catalogue item class → a tax rate, with a priority |
Resolution
Given an company tax type and a catalogue item tax type, the engine selects the highest-priority active tax rule for that pair. If no exact rule matches, it falls back to the rule for the default company and catalogue item tax types. If no rule (or no active rate) applies, the result is zero tax. Tax is calculated on the net amount in minor units using bcmath, then rounded to the currency's minor unit per line.
Exclusive vs Inclusive Calculation
TaxCalculator resolves the rule and rate once, then offers two calculation modes that share the same rounding boundary. Both return a TaxResult carrying the resolved net, tax, and gross amounts (all in minor units), plus the rate name, percentage, rate ID, and rule ID.
| Mode | Method | Input | Computes |
|---|---|---|---|
| Exclusive (tax-additive) | calculate() |
a net amount | tax on top, so gross = net + tax |
| Inclusive (tax-from-gross) | calculateInclusive() |
a gross amount | the embedded tax, so net = gross − tax |
Exclusive mode treats the supplied amount as tax-exclusive. It computes tax = net × rate% (lossless intermediate, rounded once to the minor unit), then gross = net + tax. Use it when a price is quoted before tax — the normal case for B2B catalogue prices and rate cards.
Inclusive mode treats the supplied amount as tax-inclusive. It derives net = round(gross ÷ (1 + rate%)) and takes tax = gross − net as the remainder. Use it when a price already includes tax — common for consumer-facing (B2C) display prices, point-of-sale entry, and tax-inclusive imports.
Because inclusive mode rounds the net once and then takes the tax as the remainder, the net + tax == gross guarantee holds exactly — there is no double-rounding drift, and inclusive extraction is the lossless inverse of the exclusive addition. Both modes use the same round-half-away-from-zero strategy at the single currency minor-unit boundary, and both fall back to a zero-tax result when no taxable rule applies.
Application wiring
The engines above are now applied to transactional documents:
- Rentals — per-line
currency_code,exchange_rate, and resolved tax on each item; FX/tax locks freeze rates for quoting - Invoices — checkout copies a frozen tax snapshot; credit notes negate invoice tax
- Purchase orders — purchase-axis tax for sub-hire procurement
Zone-aware resolution for EU/UK compliance extends the rule matrix — see Tax & Compliance.