Exchange Rate
ExchangeRate represents a daily FX conversion rate between two ISO 4217 currencies for a given date. It is used primarily by the multi-currency accounting pipeline: invoices and invoice-transactions reference an ExchangeRate row via a FK to convert document-currency amounts into the workspace's accounting base currency. The workspace relation is nullable, allowing for global market-rate rows that are not scoped to a specific tenant, while workspace-specific overrides carry a workspace FK. It is a Supporting entity in the records graph, surfaced as the exchange_rates root and referenced as a relation from invoices and invoice_transactions.
| Naming | Value |
|---|---|
| Object | Exchange Rate |
Resource type (JSON:API type) | exchange_rate |
| Collection / records root | exchange_rates |
| REST base | /v1/exchange-rates |
| Entity class | ExchangeRate |
API operations
| Operation | Method & path | Status |
|---|---|---|
| List | GET /v1/exchange-rates | โ Implemented |
| Retrieve | GET /v1/exchange-rates/{id} | โ Implemented |
| Create | POST /v1/exchange-rates | ๐ก Planned |
| Update | PATCH /v1/exchange-rates/{id} | ๐ก Planned |
| Delete | DELETE /v1/exchange-rates/{id} | ๐ก Planned |
Data model
Attributes
| Field | Type | Required | Constraints | Allowed values | Description |
|---|---|---|---|---|---|
| exchange_rate_id | string, UUID | โ Yes | unique; default gen_random_uuid() | โ | Public stable identifier for this exchange rate row. Generated server-side via gen_random_uuid(); never set by the client. |
| source_currency | string (CurrencyCodeEnum) | โ Yes | NOT NULL; must differ from target_currency (CHECK source_currency != target_currency); maps to Postgres native enum currency_code_enum | ISO 4217 codes: USD, EUR, GBP, JPY, CHF, CAD, AUD, NZD โฆ +10 more | The currency being converted from. Together with target_currency and rate_date, forms part of the composite uniqueness key (scoped to workspace). |
| target_currency | string (CurrencyCodeEnum) | โ Yes | NOT NULL; must differ from source_currency (CHECK source_currency != target_currency); maps to Postgres native enum currency_code_enum | ISO 4217 codes: USD, EUR, GBP, JPY, CHF, CAD, AUD, NZD โฆ +10 more | The currency being converted to. The rate expresses how many target_currency units equal one source_currency unit. |
| rate | string (decimal, 18,8 precision) | โช No | nullable; DECIMAL(18,8); CHECK rate > 0 (enforced in migration DDL) | โ | The conversion rate from source_currency to target_currency on rate_date. Stored as a decimal string to preserve full precision. A null value indicates the rate was recorded but the numeric value is not yet resolved. |
| rate_date | Date (date column) | โ Yes | NOT NULL; columnType date (date-only, no time component); part of the composite uniqueness key (workspace, source_currency, target_currency, rate_date) | โ | The calendar date for which this rate is valid. One row per (workspace, source_currency, target_currency, date) tuple. |
| source | string | โช No | nullable; length 100 | โ | Free-text provenance label for the rate โ e.g. the feed name, connector slug, or manual entry identifier. Used to distinguish ECB daily rates from connector-sourced or user-overridden rates. |
| created_at | string (ISO 8601 datetime), ๐ system | โ Yes | set by MikroORM onCreate lifecycle hook; never writable after creation | โ | Timestamp when the row was first persisted. Set server-side only. |
| updated_at | string (ISO 8601 datetime), ๐ system | โช No | set by MikroORM onCreate and onUpdate lifecycle hooks | โ | Timestamp of the last mutation to this row. Auto-maintained by the ORM. |
| deleted_at | string (ISO 8601 datetime) | null | โช No | nullable; soft-delete sentinel; queries must filter deleted_at IS NULL | โ | Soft-delete timestamp. Non-null means this rate has been logically removed. All standard queries must include the deleted_at IS NULL predicate. |
Relationships
| Name | Type | Required | Description |
|---|---|---|---|
| workspace | to-one (workspace) | โช No (nullable) | The Workspace that owns this exchange rate row. Nullable: a null workspace_pk indicates a global market-rate record not scoped to a specific tenant. When set, combined with source_currency, target_currency, and rate_date forms the unique constraint. FK: exchange_rates.workspace_pk โ workspaces.pk. Index: idx_exchange_rates_workspace_date on (workspace_pk, rate_date). |
System-computed
- exchange_rate_id is generated server-side via gen_random_uuid() as the Postgres column default, with a JavaScript fallback randomUUID() set at entity construction time in the MikroORM entity. Never supplied by the client.
- created_at is set by the MikroORM onCreate lifecycle hook (new Date()) and never subsequently updated.
- updated_at is set by both onCreate and onUpdate lifecycle hooks, reflecting the timestamp of the most recent mutation.
- deleted_at is null by default. Soft-delete is applied by setting this field to a non-null timestamp; hard deletes are not used. All read queries must filter deleted_at IS NULL.
- Composite uniqueness constraint: UNIQUE(workspace_pk, source_currency, target_currency, rate_date) โ enforced at the database level. This makes the table an upsert target: the FX pipeline can safely attempt INSERT ... ON CONFLICT (workspace_pk, source_currency, target_currency, rate_date) DO UPDATE.
- rate carries a database-level CHECK (rate > 0) constraint added in Migration20260306100000 DDL, even though the column is nullable โ when rate is non-null, it must be strictly positive.
- The CHECK (source_currency != target_currency) constraint is enforced at the database level (Migration20260306100000). The MikroORM entity does not replicate this at the application layer; it is a DB-only guard.
- The workspace relation is nullable (nullable: true on the @ManyToOne decorator), allowing global rates to exist without a workspace owner. Workspace-scoped rates take precedence over global rates in the FX resolution service.
- Index idx_exchange_rates_workspace_date on (workspace_pk, rate_date) supports the FX rate lookup pattern: given a workspace and a date, resolve the applicable rate for a currency pair.
- Invoices reference ExchangeRate via invoices.exchange_rate_pk FK (added in Migration20260306100000). InvoiceTransaction rows reference ExchangeRate via invoice_transactions.exchange_rate_pk FK (added in Migration20260311100000_data_model_v2_accounting). ExchangeRate rows are pipeline-written; they are not created through the standard REST API by end users.
- The composites.yml file defines two composites for the exchange_rates root: composite_invoices_list (relation_list of linked invoices) and composite_invoice_transactions_list (relation_list of linked invoice_transactions). These are read-only aggregate views surfaced in the Records table.
- The workspace relation on exchange_rates is not guarded by the standard workspace-scoped Hasura RLS filter because the nullable workspace_pk means global rows have no workspace. This is an intentional architectural exception documented in the entity design.
Example
{
"data": {
"type": "exchange_rate",
"id": "c3e1b2f4-09d7-4a8e-b5e0-7f2a1d3c6890",
"attributes": {
"exchange_rate_id": "c3e1b2f4-09d7-4a8e-b5e0-7f2a1d3c6890",
"source_currency": "USD",
"target_currency": "EUR",
"rate": "0.92150000",
"rate_date": "2026-05-15",
"source": "ecb-daily-feed",
"created_at": "2026-05-15T08:00:14.000Z",
"updated_at": "2026-05-15T08:00:14.000Z",
"deleted_at": null
},
"relationships": {
"workspace": {
"data": {
"type": "workspace",
"id": "a1b2c3d4-1111-2222-3333-444455556666"
}
}
}
}
}apps/api/src/database/entities/ExchangeRate.ts ยท domain: financial-graph ยท tier: Supporting