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.
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.
Scale changes are locked once posted documents exist
Amounts are stored as integer minor units. An amount that does not record the scale it was written at is read at the current decimal_places, so a new scale would read it as a different amount. A change to decimal_places is therefore refused with the code currency_scale_locked while any of these rows uses the currency as its document currency or its recorded base currency. The message gives the count for each blocking type.
- Invoices and credit notes that are issued, including those voided after issue.
- Purchase orders that are sent or past draft. A draft cancelled before it was sent does not block.
- Payments on invoices in the currency, payment settlements, write-offs, and financial postings.
- Portal payment attempts that no settlement has claimed yet.
- Active credit reservations, active unexpired credit overrides with an amount limit, and credit decisions (they record exposure and limit amounts).
- Billing run items in an open run (draft, reviewing or issuing).
- Confirmed and received virtual stock intakes.
- Rentals and rental costs that do not record their money units for that currency. Older rows, and virtual stock costs added by apportionment, do not record them.
- Rental versions. They never record their money units.
- Asset costs, asset valuations, asset retirements, and assets with a purchase cost.
Draft invoices, credit notes and purchase orders do not block, because they are converted again when they are posted.
Reference pricing does not block: catalogue item rates, surcharges, bundle rules and supplier costs are reference data, not posted documents. Some catalogue prices are stored without a currency column and are read in the base currency. A change to the scale of the base currency reinterprets all of these stored amounts. In practice the lock already refuses that change once any posted row records the base currency, which happens as soon as a document is posted. Changes to the other currency fields (name, symbol, symbol position, separators, enabled flag) are always allowed, and so is a request that repeats the stored decimal_places. The lock applies to the currency update command, not to the database, so a repair script that writes the column directly is not refused; that path still stamps precision_changed_at on a model save.
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.
Changing the base currency
Company settings shows a read-only preflight before a proposed change: counts for all eight document families (including deleted history), affected and unresolved counts, and paginated document IDs with missing identity/rate/date or differing captured-base reasons. Stored financial projections and retained source events also prevent a tenant being classified as empty.
An unchanged base is allowed. A new base is allowed only for a tenant without any of the eight document families or recorded financial postings. Changing the base currency of a tenant that already has financial records is not yet available. signals:rebuild-base-amounts refreshes display projections only and does not change the base currency. Posted amounts and their captured base identities stay unchanged.
The guard runs before settings writes through the settings service, Company settings, REST PUT /api/v1/settings/company, and the installed signals:api call api.v1.settings.update --param=group=company CLI operation. Cloud preflight requires the active tenant database; a config-only, mismatched or resetting scope is refused. A separately configured event connection is refused before any inventory query.
Historical base currency identity
NULL identities are resolved by signals:base-currency-identity:backfill. Already recorded identities are skipped. Sources are checked in order: the row's own replayable events; recorded base money units; the accepted rental pricing manifest or a credit note's invoice; an open rental cost’s resolved parent rental (source parent_rental); then unchanged contemporaneous settings. Settled costs use their own historical sources and must agree with a recorded parent identity. Every source found must agree. Events after issue, send or lock naming different bases are ambiguous.
Settings prove identity only when the persisted company.base_currency row names a valid ISO currency, its update precedes the document's creation by at least one second in UTC, and no later settings.updated audit touches that setting. Audit history alone is a hint because logs can be pruned. A missing setting is unresolved. Today's base is used only for open rows without historical data: draft or voided-before-issue documents, unsent purchase orders (including cancellation before send) and unlocked enquiries or quotes that have never reached an order/lock boundary and have no issued invoice or posted cost. Previously settled rentals remain historical even if their locks were later released.
Resolved identities from events can be stamped directly because replay writes the same code. Other sources on event-sourced documents are persisted in identity-recorded events. Asset stamps also create an audit entry. Unresolved or conflicting evidence leaves NULL in place and writes the reason to base_currency_identity_findings; readers refuse that missing identity. Payment evidence is append-only, so it is reported with its invoice candidate and never updated. Earlier billing-run bases are reported with status assumption and reason migration_assumption and can only confirm or contradict other evidence on settled rows. Open rows retain billing-run context as informational evidence.
Operator-confirmed identities use source operator, a required reason and system actor evidence; they never relabel an already recorded identity.
Adding or editing rental costs, including virtual-stock apportionment, returns a coded 422 rental_base_currency_unresolved while the rental's base identity is NULL. Apportionment skips unchanged existing costs before this check.
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, or fetched daily from the configured ECB or Open Exchange Rates provider.
| 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.
When a foreign invoice is issued at a market rate, the rate it froze as a draft must be the stored rate from the document currency to the base currency, not the inverse, and it must match the rate the draft's events recorded. An older draft whose events never recorded a rate is refused with the code invoice_draft_rate_stale: edit or re-save one of its lines so the rate is recorded, then issue it again.
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.
Warehouse-bound rental currency
A rental's currency is the warehouse's effective currency (Warehouse::currency() — the warehouse's currency_code, or the company base when that column is null). Create rejects an explicit payload currency that differs (API 422 / form validation), and currency is no longer defaulted on the create payload — omit it and let the warehouse decide. Changing warehouse after create is blocked when the new currency would differ and the rental is FX-locked, tax-locked, or has any invoice; otherwise the header currency and exchange rate are re-snapshotted and the rental is repriced.
Catalogue item rates must be tagged in the same currency as their scope: a warehouse-scoped row matches that warehouse; an all-warehouse row (warehouse_id null) matches the company base. The rate resolver skips a mismatched row (logged warning) and falls through to the next candidate rather than silently mispricing.
Auditing rate currencies
php artisan signals:audit-rate-currencies lists every catalogue item rate whose currency does not match its scope.
| Invocation | Behaviour |
|---|---|
| (no option) | Read-only. Prints a table of Rate ID, item, warehouse, rate currency, and expected currency. |
--fix |
Still writes nothing. Additionally names the rows whose retag would be unambiguous — the two currencies have the same number of minor-unit digits and an exchange rate exists. |
--fix=retag |
Stamps the expected currency onto every violating row while keeping the stored number. This changes real prices and is only correct for rows that were mis-tagged all along. |
The same guard runs ahead of a warehouse currency change and a currency deletion — see Warehouses.
Reporting
Dashboards, the report builder, project rollups, and exports read stored *_base columns or stored document rates for their original financial values. Optional report presentation conversion adds separate dated display columns, described below. Base columns and rates are frozen when the document is created or updated (and base columns can be rebuilt with signals:rebuild-base-amounts).
| Surface | What it sums | Label |
|---|---|---|
| Dashboard KPIs and money widgets | COALESCE(*_base, raw) on invoices, credit notes, and rentals; invoice-line mix converts each line with its parent invoice's stored base_exchange_rate, rescaling source/base minor-unit exponents before rounding |
Company base currency |
| Account Overview analytics and aggregate registers | Stored base invoice, credit-note, rental, and invoice-line values; individual open/settled invoice rows retain their original amount and carry their own currency_code |
Aggregates and credit terms use company base currency; invoice rows use document currency |
| Report builder SUM/AVG/MIN/MAX | The same stored base columns on invoices, credit notes, purchase orders, rentals, and rental costs; invoice-tax snapshots convert each row with its parent invoice's stored base_exchange_rate, rescaling source/base minor-unit exponents before rounding |
Company base currency |
| Project rollups | Converted *_base_minor alongside the never-blend per-currency breakdown |
Base total is optional; per-currency remains primary |
| Exports and list columns | The original amount, plus a base-amount column where the entity's column registry exposes one — currently credit notes (total_base), purchase orders (total_base, cost-gated), and rental costs (amount_base alongside currency_code) |
Original in the document currency; *_base in the row's recorded base currency |
Base-money readers require a recorded base_currency_code on every contributing row, including same-currency and zero-amount rows. A row with a missing or blank base identity is never read under today's company base, even if it has a stored base amount. A same-currency row with a recorded identity equal to the company base can still use its document amount when the stored base amount is absent; readers such as TCO retain their full frozen-snapshot requirements.
Invoice and rental base-money measures on GET /api/v1/aggregate/{model}, POST /api/v1/reports/execute, and POST /api/v1/reports/{report}/execute return HTTP 200 with partial results when individual rows fail base-money admission using the existing currency, precision, provenance or conversion-evidence validators. Reports selecting at least one base-money field use the same admission policy. Only money SUM/AVG/MIN/MAX measures exclude these rows. Counts (including counts of money fields), non-money measures and their group populations still include every scoped, filtered row. If any selected money field fails admission, that row is excluded from all selected money measures. No stored amounts, currencies or rates are repaired by a read.
For partial results, the enclosing aggregate.meta or report_result.meta contains complete: false, incomplete_reason: "money_rows_excluded", and excluded_money_rows: a list of objects with id, source, code, reason, and message. Reports also include window (current, comparison, or an additional-window name). code preserves the evidence validator's refusal code; reason is foreign_rate_provenance_missing or stored_money_evidence_invalid; message names the affected money fields and explains the exclusion. For example:
{"complete":false,"incomplete_reason":"money_rows_excluded","excluded_money_rows":[{"id":19,"source":"rentals","code":"base_amount_evidence_incomplete","reason":"foreign_rate_provenance_missing","message":"Excluded charge_total from money totals: the foreign exchange rate has no usable recorded provenance.","window":"current"}]}
Report financial metadata preserves the incomplete status; CSV and PDF exports identify each excluded source and row. When there are no exclusions, excluded_money_rows and the money-exclusion status fields are absent (other report completeness metadata may still apply). Consumers must inspect completeness before treating money totals as the full population. Groups containing only excluded money contributors remain present in reports, with their counts/non-money values; empty money sums display zero and empty averages/minima/maxima remain null.
Invoice-header measures no longer return 422 invoice_base_evidence_invalid; failing rows are excluded instead. This partial-result policy is limited to invoice and rental header measures. Rental measures without a captured base column (rental_charge_total, sale_charge_total, service_charge_total, deal_total, and deal_price_original_total) use the same per-row exclusion contract, including when selected without a mapped header measure. Their existing unconverted-money evidence checks remain authoritative: foreign category money is excluded even when the header has a valid frozen rate. This policy does not convert those fields or relax statement-time checks. Unconverted invoice-header measures retain their existing refusal contracts. Child aggregates still refuse invalid parent evidence with invoice_parent_base_evidence_invalid, rental_parent_base_evidence_invalid, or purchase_order_parent_base_evidence_invalid; other sources and cross-row/statement-time evidence checks retain their existing 422 refusal contracts. Dashboards, account insights, credit exposure and project rollups report incomplete money; a list column shows Incomplete and an export leaves the base cell empty.
An account with an unresolved open invoice or active order receives credit_currency_incomplete at the credit boundary: an approved credit override is required until the backfill or an operator resolves the recorded base identity and frozen conversion evidence. This fails closed in report, warn and enforce rollout modes; a partial exposure total is never treated as available headroom. An override permits the scoped commercial transition and does not repair the underlying money evidence.
The base-identity backfill and these reader refusals ship together. Before upgrading, self-hosters should run the backfill as a dry run with a report to see how many rows and accounts with open invoices or active orders cannot be resolved; those accounts will need a credit override or operator repair. Rows whose identity cannot be established keep NULL and remain incomplete; the current company base is not assumed for historical rows.
Billing runs follow the same rule: an item whose base amount cannot be resolved stores NULL, and the run shows its base total as incomplete — see Billing runs.
The API exchange_rate on invoices, purchase orders and rentals is null for a foreign-currency document whose base rate is not resolved; read base_rate_source for the reason. Same-currency documents keep exchange_rate 1.
Optional report presentation currency
A report definition may set presentation_currency together with an explicit presentation_date (YYYY-MM-DD, no later than today). The report first reads its captured financial amounts under the usual permissions and evidence checks. It then adds separately labelled *_presentation columns using the dated exchange-rate service, with half-up rounding at the target currency scale. Omitting both options leaves the original definition and result shape unchanged.
Original amounts, currency labels and captured conversion metadata stay intact. meta.presentation_conversion identifies the display-only purpose, requested date, target currency and rates used. CSV and other report exports retain these separate labels and rate metadata. Missing quotes or incomplete source evidence leave presentation money explicitly Unavailable, including a zero balance; captured values remain visible and never become nominal converted totals. The source reader still refuses missing captured amounts or conversion evidence before presentation can run.
Presentation conversion creates no financial posting, changes no historical posting rate and does not authorize a base-currency change. Period-end revaluation is separate (below); these display columns are not revaluation entries.
Explicit period-end revaluation
RevalueCurrency admits a revaluation only for an explicitly admitted, open period, an installed monetary ledger account key (receivables, payables, manual_cash_control or provider_clearing), and a foreign currency. Period admission and closure require settings.manage and access to all warehouses. These records govern revaluation admission; they do not close the whole ledger. Missing or closed period records refuse without an event or posting.
The producer captures verified monetary posting balances through period end, preserving their original currency and base minor units. It requires a positive direct closing quote effective on that exact date. Missing quotes, missing original units or incompatible base identities refuse without replacing historical evidence. A nonzero difference creates a dedicated balanced unrealised FX adjustment; zero difference creates no financial posting. The original transactions, rates and postings remain unchanged.
The tenant-aware ReverseDueCurrencyRevaluations scheduled job runs every minute and records an idempotent reversal when the next period's first day is due. It uses the original captured difference and currency identities, even if rates or the configured base currency have changed. No future inverse is booked early, because accounting exports and general cash readers do not automatically filter future posting dates. Delayed or interrupted queue delivery is recovered from the persisted pending revaluation on a later tick.
A later period refuses with revaluation_prior_reversal_pending until every earlier revaluation in the same account and currency has its due reversal recorded. This guard also runs when a dedicated event validates its captured exposure. Once the scheduled reversal is recorded, capture includes all monetary postings through the period-end calendar date.
Repeating the same period/account/currency returns its original receipt; a different scope has a separate identity. Replaying the dedicated events verifies the original posting or inverse without creating a second adjustment or scheduling another effect. These actions are separate from report presentation conversion, depreciation and base-amount rebuilds.
Rebuilding base amounts
Base columns are frozen at write time, so adding a missing exchange rate afterwards does not retro-correct documents that were already stored. Recompute them with:
php artisan signals:rebuild-base-amounts
| Option | Behaviour |
|---|---|
--table= |
Limit to one of assets, rentals, invoices, credit_notes, purchase_orders, rental_costs, asset_costs, asset_retirements. |
--report= |
Write the reported rows (no usable rate, or refused, with the problem) to a JSON file. |
--dry-run |
Write nothing. Say how many rows would change. |
--strict |
Exit 1 when any row is reported. |
The rebuild is display conversion only and never uses a rate of 1 in place of a missing rate:
- A posted document, or a draft with a rate source, keeps its own frozen rate and base currency, so the rebuild writes what a replay of its events writes.
- A document with no rate source uses its stored rate. A foreign stored rate of exactly 1 is the old fallback (unless its events say it was a real rate), so the row gets empty base amounts and is reported.
- The base currency of a posted document comes from the document, else from its events (a credit note can also take its invoice's). The current
company.base_currencyis never used for it: a posted document with none of these is reported and keeps what it had. A posted document created before base amounts were introduced records none, so it is refused on every run, and--strictthen always exits1; the report lists these rows and there is no automatic fix. A foreign draft voided before issue with no stored rate or rate date is also reported on every run, because a void document is never priced at a live rate. - The paid and outstanding base amounts of an invoice with no rate source and with payments or credits follow the same rules on replay and on rebuild, so both write the same values, read in the invoice's recorded money units (a foreign balance without recorded units after a scale change is empty). Two cases are not the same: a foreign rate of exactly 1 that only the events confirm as real (the rebuild converts at it, a replay writes empty base balances), and a posted invoice whose base currency is recorded only on its events (the rebuild uses it, a replay writes empty base balances). A voided invoice owes nothing, so its base outstanding is zero (empty when it had no base money).
- A draft with no stored rate is priced at its transaction-date rate into the base currency the invoice records, else the company base currency (a draft credit into its invoice's base currency), and that base currency is recorded, so a second run changes nothing.
- A foreign document without recorded money units is refused, reported and left unchanged when a currency's minor-unit scale is no longer its ISO scale. A same-currency document is never refused.
- A draft whose events recorded no rate keeps empty base amounts. When a rate exists now, the output says to re-save the draft.
Add a missing rate under Admin → Money & Pricing → Exchange Rates, then run the command again; a second run changes nothing it has already rebuilt. Long lists are truncated in the console; use --report to capture all of them.
Exit codes: 0 when the run completes (reported rows included), 1 with --strict when any row is reported, and always 1 when a row fails for an unexpected reason (for example a database error on its write). Such a row is reported with the error and left unchanged, and the run continues.
Changing company.base_currency while transactional documents exist saves successfully but leaves every stored base amount computed against the old base. The admin Company Details save toast becomes a warning naming this command, and the audit event records rebuild_required. Run the rebuild before trusting any base-labelled figure afterwards.
Grouping a report by currency_code still works. Per-document original amount and original currency stay selectable where their list or export registry exposes them. Invoice-tax and invoice-line snapshots have no base columns, so their base-labelled aggregates multiply each row by the parent invoice's frozen rate, rescale between the document and company-base minor-unit exponents, and round to base minor units before aggregation.
Purchase order documents
Purchase order documents are append-only HTML/CSS snapshots of a sent PO. The Finance read API exposes them as purchase_order_documents:
| Action | Route | HTTP |
|---|---|---|
| List snapshots | GET /api/v1/purchase_order_documents (api.v1.purchase_order_documents.index) |
200 JSON |
| Show one snapshot | GET /api/v1/purchase_order_documents/{purchase_order_document} (api.v1.purchase_order_documents.show) |
200 JSON |
| Stream PDF | GET /api/v1/purchase_order_documents/{purchase_order_document}/pdf (api.v1.purchase_order_documents.pdf) |
200 application/pdf |
| Stream PDF (suffix) | GET /api/v1/purchase_order_documents/{purchase_order_document}.pdf (api.v1.purchase_order_documents.pdf_suffix) |
200 application/pdf |
Both PDF routes re-render the stored snapshot through PdfDriverManager and stream bytes. They do not redirect and they do not return 202. Requires purchase_orders.view and the purchase_orders:read token ability, plus view access on the parent purchase order. The UI Documents card on a PO show page is the same projection — see Sub-Hire & Procurement.