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.

Staged rollout. This surface ships behind a platform flag (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.

EndpointAuthReturns
GET /v1/reports/revenue-recognitionreporting:readRecognition events for a period, as CSV or JSON
GET /v1/reports/revenue-recognition/summaryreporting:readPer-month recognised + deferred buckets
GET /v1/reports/deferred-revenuereporting:readOutstanding deferred-revenue balances per customer and contract
GET /v1/fx_ratesPublicCurrent reference rates, cached 5 minutes
GET /v1/fx_rates/historicalreporting:readThe 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.

export.sh
# 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.csv
Every export is itself audited: the platform appends a REVENUE_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.

ColumnContents
event_idRecognition event id (re_…)
event_typeSource event: subscription.cycle, invoice.finalized, refund.issued, usage.recorded
asc606_stepVerbatim FASB five-step wording, e.g. "Step 5: Recognize revenue when (or as) the entity satisfies a performance obligation."
customer_idCustomer the revenue belongs to
subscription_idSubscription, when applicable
invoice_idInvoice, when applicable
recognised_atThe GAAP recognition moment (RFC3339, UTC)
native_amountAmount billed, in the customer's currency
native_currencyISO 4217 code of the billed currency
fx_rateRate snapshotted at recognition, 10 decimal places
presentation_amountConverted amount in your reporting currency
presentation_currencyYour reporting currency (platform default USD)
deferred_revenue_splitSigned delta against the deferred-revenue balance, denominated in the native currency
performance_obligation_idASC 606 Step 2 obligation, when modelled
contract_idLogical contract spanning subscriptions, when modelled
notesFree-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.

summary.sh
# 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

fx-rates.sh
# 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_unavailable until 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/revenue page is a different subsystem — the revenue-layer pilot console for routing ROI reports and its kill switch, backed by a separate service.)
Building your close process against this API? Start with the JSON export in a test-mode environment and diff it against the CSV — the field names and column order are identical by design, so whichever format your GL tooling prefers, the numbers reconcile.