SIGNALS Documentation
API Reference

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.