Multi-currency revenue recognition
PAID emits an append-only, hash-chained stream of ASC 606 / IFRS 15 recognition events, with FX converted at the point of revenue — not the point of collection. This guide covers the recognition model, how currency conversion works, and the export endpoints Finance pulls into the GL.
REVENUE_RECOGNITION_ENABLED) and is disabled by default. Until it is switched on
for your environment, every endpoint on this page — including the public /v1/fx_rates — returns 503 service_unavailable. Contact us before
building against it in production.The recognition model
Each recognition event is one GAAP-relevant moment, classified against the five-step ASC 606 /
IFRS 15 model and tagged with the verbatim FASB step wording. Events are produced from four
billing source types: subscription.cycle, invoice.finalized, refund.issued, and usage.recorded.
Timing follows the "when (or as)" split in Step 5: one-shot licences and usage records are
recognised point-in-time, and so are monthly, weekly, and quarterly subscription
cycles — one Step 5 event per cycle, dated at the cycle midpoint (the "pay monthly, recognise
monthly" SaaS pattern). Annual-upfront subscription cycles and implementation fees are
recognised over-time, pro-rata across the service period. The unearned remainder of
each cycle is tracked as a signed deferred_revenue_split delta, so an
annual-upfront subscription shows up as twelve monthly recognitions against a shrinking
deferred-revenue balance.
Events are append-only and hash-chained: each row commits to a SHA-256 over the previous hash plus a canonical encoding of every accounting-material field — amounts, the full FX triplet, timing window, and entity relationships. Rewriting any persisted field after emission breaks the chain, the same tamper-evidence model as PAID's hash-chained financial-event ledger.
The platform default recognition basis is accrual (recognise at period-end / over-time); a cash basis (recognise at collection) exists as a platform-level configuration.
How multi-currency works
Every event carries both sides of the conversion, so a multi-currency merchant can re-aggregate without re-running the engine:
native_amount+native_currency— what the customer was actually billed, in their currency, in minor units.presentation_amount+presentation_currency— the converted amount in your reporting currency (platform default USD).fx_rate, plus a source provider and timestamp — the rate snapshotted at the recognition moment, stored to 10 decimal places. This is the point-of-revenue vs point-of-collection distinction your auditor cares about: the rate is the one in effect when the performance obligation was satisfied, not whenever the cash landed.
Rates come from two reference providers, routed per currency pair: the European Central Bank daily reference feed for fiat, and Coinbase spot prices for crypto pairs (USDC and other supported stablecoin codes). A daily snapshot job persists the configured pairs so historical lookups are reproducible.
The staleness policy is deliberately two-tier: a rate older than 24 hours is still served but
logs a staleness warning (ECB publishes daily, so 24h covers weekends and outages); past a hard
72-hour ceiling the current-rates path (/v1/fx_rates) fails closed and
refuses the snapshot. Point-of-recognition lookups behave differently by design: they serve the
most recent snapshot at or before the recognition moment — however old — with a staleness
warning, so audit lookups for events recognised long ago still resolve; recognition halts only
when no prior snapshot exists at all. Presentation amounts are derived in exact big-number
arithmetic with banker's rounding (round-half-to-even), so rounding bias averages to zero across
large event populations instead of systematically over-stating revenue.
Export and reporting endpoints
All reporting endpoints require an API key with the reporting:read scope (or full_access) — see API keys. The current-rates
endpoint is public.
| Endpoint | Auth | Returns |
|---|---|---|
GET /v1/reports/revenue-recognition | reporting:read | Recognition events for a period, as CSV or JSON |
GET /v1/reports/revenue-recognition/summary | reporting:read | Per-month recognised + deferred buckets |
GET /v1/reports/deferred-revenue | reporting:read | Outstanding deferred-revenue balances per customer and contract |
GET /v1/fx_rates | Public | Current reference rates, cached 5 minutes |
GET /v1/fx_rates/historical | reporting:read | The rate in effect at a past timestamp |
Exporting recognition events
The export and summary endpoints share the same query parameters: period_start and period_end (both required, RFC3339; the end must be
strictly after the start and the window is capped at 366 days per request), an optional currency filter (uppercase ISO 4217, matched against the presentation currency),
and for the export, format=csv (default) or format=json.
# CSV export — column order matches QuickBooks / Xero / NetSuite
# import templates, so it drops straight into your GL.
curl "https://api.trustfabric.ai/v1/reports/revenue-recognition?period_start=2026-01-01T00:00:00Z&period_end=2026-06-30T00:00:00Z&format=csv" \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-o revenue-recognition.csvREVENUE_RECOGNITION_EXPORTED event to the audit chain recording the calling
account, period window, currency filter, format, and row count — so you can reconstruct who
exported what.CSV columns
Column order is locked (behind a golden-file test) to match the union of the QuickBooks Online, Xero, and NetSuite ARM import templates. In CSV, monetary columns are decimal strings ("50000.00"); in the JSON format the same fields are integer minor units.
| Column | Contents |
|---|---|
event_id | Recognition event id (re_…) |
event_type | Source event: subscription.cycle, invoice.finalized, refund.issued, usage.recorded |
asc606_step | Verbatim FASB five-step wording, e.g. "Step 5: Recognize revenue when (or as) the entity satisfies a performance obligation." |
customer_id | Customer the revenue belongs to |
subscription_id | Subscription, when applicable |
invoice_id | Invoice, when applicable |
recognised_at | The GAAP recognition moment (RFC3339, UTC) |
native_amount | Amount billed, in the customer's currency |
native_currency | ISO 4217 code of the billed currency |
fx_rate | Rate snapshotted at recognition, 10 decimal places |
presentation_amount | Converted amount in your reporting currency |
presentation_currency | Your reporting currency (platform default USD) |
deferred_revenue_split | Signed delta against the deferred-revenue balance, denominated in the native currency |
performance_obligation_id | ASC 606 Step 2 obligation, when modelled |
contract_id | Logical contract spanning subscriptions, when modelled |
notes | Free-form; carries the Step 5 "when (or as)" wording intact |
Monthly summary and deferred revenue
The summary endpoint aggregates events into per-month buckets keyed by month, presentation
currency, and native currency — deferred adjustments are native-denominated, so a
EUR-native and a GBP-native subscription under the same USD presentation never get summed as if
they were the same unit. Deferred-revenue balances are likewise reported per native currency: balance_cents is denominated in the row's native_currency.
# Per-month recognised revenue + deferred-revenue movement.
curl "https://api.trustfabric.ai/v1/reports/revenue-recognition/summary?period_start=2026-01-01T00:00:00Z&period_end=2026-06-30T00:00:00Z" \
-H "Authorization: Bearer $PAID_SECRET_KEY"
# → { "account_id": "...",
# "buckets": [ { "month": "2026-01", "currency": "USD",
# "native_currency": "EUR", "recognised_amount": 1250000,
# "deferred_revenue_adjustment": -104166, "event_count": 41 } ],
# "period": { ... } }FX rates
# Public — no auth. Served from a 5-minute cache.
curl https://api.trustfabric.ai/v1/fx_rates
# → { "rates": [ { "base_currency": "USD", "target_currency": "EUR",
# "rate": "0.9214000000", "source_provider": "ecb",
# "snapshotted_at": "2026-07-06T00:00:00Z" } ],
# "cached_at": "...", "ttl_seconds": 300 }Checkout-time conversion (DCC) is a separate surface
Recognition FX is reporting-only: reference rates, no markup, applied at the recognition moment.
If you want the buyer to pay in their own currency, that is Dynamic Currency Conversion
— a separate surface at POST /v1/platform/dcc/offers that quotes a payment intent into a
buyer currency with an explicit exchange_rate, markup_bps, and rate_source, and lets the buyer accept or decline the quoted conversion. Checkout sessions
and payment intents carry an ISO 4217 currency field either way; the recognition
event preserves whatever was billed as its native_currency.
Current limitations
- Flag-gated, off by default. All five endpoints return
503 service_unavailableuntil revenue recognition is enabled for your environment. This is a deliberate staged rollout, not an outage. - Automatic emission is still being wired. The ASC 606 mapper and recognition engine are built and constructed at boot, but the daily sweep that walks the financial-event stream is not yet connected, and the usage-metering micro-recognition emitter is currently disabled (usage itself is still recorded). Until that wiring lands, recognition events do not auto-populate from live payment activity.
- FX snapshot history is in-process today. The engine is designed for a database-backed snapshot store; the current store is in-memory, so historical rate lookups only cover snapshots taken since the last API restart.
- No export pagination. An export streams the full period in one response; the window cap of 366 days is the only bound. The JSON envelope reserves space for cursor pagination later.
- No dashboard UI for recognition reports. This surface is API and CSV only.
(The
/dashboard/revenuepage is a different subsystem — the revenue-layer pilot console for routing ROI reports and its kill switch, backed by a separate service.)