|
| 1 | +--- |
| 2 | +title: Currency fields — dynamic, fixed and record modes |
| 3 | +navTitle: Currency |
| 4 | +description: How a currency amount gets its currency — the tenant default, one fixed code, or each record's own ISO 4217 code in a sibling field — and the writes, aggregates and declarations the platform refuses for a record-currency amount |
| 5 | +--- |
| 6 | + |
| 7 | +A `currency` field stores a **bare number** in every mode, never a `{ value, currency }` |
| 8 | +object (ADR-0104 D1). What the field's `currencyConfig` decides is where that number's |
| 9 | +currency comes from. There are three modes, and the record mode is the one to use when |
| 10 | +different records of the same object are in different currencies: an invoice in USD next |
| 11 | +to an invoice in EUR (ADR-0144). |
| 12 | + |
| 13 | +## The three modes |
| 14 | + |
| 15 | +| `currencyMode` | The amount's currency | Keys it reads | |
| 16 | +|:---|:---|:---| |
| 17 | +| `dynamic` (the default) | The tenant default currency, the `localization.currency` setting. With no setting, the amount is a plain number with no symbol. | none — `defaultCurrency` is not read | |
| 18 | +| `fixed` | One currency for the whole column. | `defaultCurrency`, required | |
| 19 | +| `record` | Each record's own currency: the ISO 4217 code stored in a sibling field of the same record. | `currencyField`, required | |
| 20 | + |
| 21 | +```typescript |
| 22 | +// dynamic: displays in the organization's currency |
| 23 | +budget: Field.currency({ label: 'Budget' }), |
| 24 | + |
| 25 | +// fixed: every row is in USD |
| 26 | +list_price: Field.currency({ |
| 27 | + label: 'List Price', |
| 28 | + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, |
| 29 | +}), |
| 30 | + |
| 31 | +// record: every row names its own currency in `currency_code` |
| 32 | +amount: Field.currency({ |
| 33 | + label: 'Amount', |
| 34 | + currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }, |
| 35 | +}), |
| 36 | +``` |
| 37 | + |
| 38 | +Pick by asking what the currency belongs to. If it belongs to the organization, use |
| 39 | +`dynamic`. If it belongs to the column, such as a price list kept in one currency, use |
| 40 | +`fixed`. If it belongs to the document, such as an invoice billed in the customer's |
| 41 | +currency, use `record`. |
| 42 | + |
| 43 | +The modes do not mix in one config. `fixed` needs its code and `record` needs its pointer. |
| 44 | +Nothing turns multi-currency on for an organization or a deployment: declaring |
| 45 | +`currencyMode: 'record'` on a field is the switch, and the field is the only place it |
| 46 | +applies. |
| 47 | + |
| 48 | +## The record mode |
| 49 | + |
| 50 | +### The code field |
| 51 | + |
| 52 | +A record-mode amount names, through `currencyConfig.currencyField`, a field **on the same |
| 53 | +object** that holds each record's currency. That field must be a `text` field declaring |
| 54 | +`valueDomain: 'iso_4217_currency'`, so every code written to it is checked against the |
| 55 | +ISO 4217 list. |
| 56 | + |
| 57 | +```typescript |
| 58 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 59 | + |
| 60 | +export const Invoice = ObjectSchema.create({ |
| 61 | + name: 'invoice', |
| 62 | + label: 'Invoice', |
| 63 | + fields: { |
| 64 | + name: Field.text({ label: 'Number', required: true }), |
| 65 | + currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), |
| 66 | + amount: Field.currency({ |
| 67 | + label: 'Amount', |
| 68 | + currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }, |
| 69 | + }), |
| 70 | + tax: Field.currency({ |
| 71 | + label: 'Tax', |
| 72 | + currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }, |
| 73 | + }), |
| 74 | + }, |
| 75 | +}); |
| 76 | +``` |
| 77 | + |
| 78 | +`currency_code` is a **convention, not a contract**. The platform never injects a code |
| 79 | +column and never looks one up by name: the code field is whatever field you declare and |
| 80 | +point at. Several amounts may share one code field, as `amount` and `tax` do above, and an |
| 81 | +object may carry two code fields when a record really holds amounts in two currencies. |
| 82 | + |
| 83 | +A `formula` field with `returnType: 'currency'` that computes from record-mode amounts |
| 84 | +carries the same config, `{ currencyMode: 'record', currencyField: … }`, naming the code |
| 85 | +field its source amounts name. `inferFormulaReturn` in `@objectstack/formula` answers it |
| 86 | +for a formula it can prove is money. |
| 87 | + |
| 88 | +### What the declaration refuses |
| 89 | + |
| 90 | +`ObjectSchema.create()`, `os validate` and publish refuse each of these, with the fix in the |
| 91 | +message: |
| 92 | + |
| 93 | +| Declaration | Refused at | Fix | |
| 94 | +|:---|:---|:---| |
| 95 | +| `currencyMode: 'record'` with no `currencyField` | `currencyConfig.currencyField` | Name the code field, or use `fixed` (one currency) or `dynamic` (the tenant default). | |
| 96 | +| `currencyField` under `dynamic` or `fixed` | `currencyConfig.currencyField` | Set `currencyMode: 'record'`, or delete `currencyField`. | |
| 97 | +| `defaultCurrency` under `record` | `currencyConfig.defaultCurrency` | Delete `defaultCurrency`, or use `fixed` if the field has one currency. | |
| 98 | +| `currencyField: ''` | `currencyConfig.currencyField` | Name a field. | |
| 99 | +| `currencyField` names no other field of the object, or names the amount itself | `fields.<amount>.currencyConfig.currencyField` | Point at a declared code field. The message lists the object's candidates. | |
| 100 | +| `currencyField` names a field that is not `text` | same | Point at a `text` field. | |
| 101 | +| `currencyField` names a `text` field without `valueDomain: 'iso_4217_currency'` | same | Add `valueDomain: 'iso_4217_currency'` to that field. | |
| 102 | + |
| 103 | +### Writing a record |
| 104 | + |
| 105 | +When a record-mode amount holds a value, its code field must hold a code in the same |
| 106 | +write. An insert, or an update that leaves the amount beside a blank code (absent, `null` |
| 107 | +or white space only), is refused with **`ERR_CURRENCY_CODE_REQUIRED` / 400** and nothing |
| 108 | +is written. The error names the code field in `field` and the amount in `amountField`. |
| 109 | +Fix it by writing the record's code, for example `'USD'`, in the same write, or by leaving |
| 110 | +the amount empty. |
| 111 | + |
| 112 | +- A blank amount needs no code. |
| 113 | +- On an update, only the amount and code pairs the payload touches are judged, so editing |
| 114 | + an unrelated field of an older row is not refused. |
| 115 | +- A code that is not an ISO 4217 code is refused by the code field's own `valueDomain`, |
| 116 | + not by this rule. |
| 117 | + |
| 118 | +**The platform never fills in a code.** No server default, no tenant fallback and no |
| 119 | +"most recent currency" is ever stored on your behalf. In the Console, a create form opens |
| 120 | +with the code field pre-filled with the organization's currency, and the user submits it |
| 121 | +as an explicit value. A `defaultValue` you declare on the code field yourself is an |
| 122 | +ordinary field default: the engine applies it before the check, so it satisfies the |
| 123 | +rule, because you wrote it. |
| 124 | + |
| 125 | +### Aggregating a record-mode amount |
| 126 | + |
| 127 | +Amounts in different currencies do not add. A `sum`, `avg`, `min` or `max` over a |
| 128 | +record-mode amount is refused with **`INVALID_FIELD` / 400** unless the same query groups |
| 129 | +by the amount's code field, and the query is not run. Grouped by the code, every group |
| 130 | +holds one currency and each figure is right. |
| 131 | + |
| 132 | +```typescript |
| 133 | +// Refused: USD 100 and EUR 100 would answer 200 under one label. |
| 134 | +{ aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }] } |
| 135 | + |
| 136 | +// Served: one total per currency. |
| 137 | +{ groupBy: ['currency_code'], aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }] } |
| 138 | +``` |
| 139 | + |
| 140 | +The rule holds at two doors: |
| 141 | + |
| 142 | +- **The ObjectQL aggregate**, so it covers every caller that reaches the engine: the REST |
| 143 | + query API, flows, hooks and the analytics strategy. |
| 144 | +- **The analytics cube door**, ahead of strategy selection: dataset queries (the main |
| 145 | + grid, every `totals` grouping including the grand total, and the `compareTo` pass), |
| 146 | + cube queries over a declared or inferred cube, and the dataset draft preview. |
| 147 | + |
| 148 | +A `count` reads no amount and is always served, grouped or not, and so is |
| 149 | +`count_distinct`. A `where` that happens to select one currency does not count as |
| 150 | +grouping: the rule is the grouping alone. |
| 151 | + |
| 152 | +In an analytics answer, a measure column over a record-mode amount, grouped by the code, |
| 153 | +carries **`currencyField`** instead of `currency`. It names the dimension column of the |
| 154 | +same answer that groups by the code field, and each row's currency is that row's value of |
| 155 | +that column. A `count` column never carries `currencyField`. See the |
| 156 | +[analytics result columns](/docs/api/data-api) for the full `fields[]` surface. |
| 157 | + |
| 158 | +### Roll-ups and datasets |
| 159 | + |
| 160 | +- **A `summary` roll-up** whose `sum`, `avg`, `min` or `max` reads a record-mode amount of |
| 161 | + the child object is refused when the object registers, with **`INVALID_METADATA` / |
| 162 | + 422**. A roll-up stores one number with no currency, so child rows in different |
| 163 | + currencies would be added together. Use `function: 'count'`. The pair is judged at |
| 164 | + whichever registration completes it, the parent's or the child's. |
| 165 | +- **A dataset measure that declares `currency` over a record-mode amount** is refused at |
| 166 | + compile with **`DATASET_INVALID` / 400**, and so is a `currency` on a derived measure that |
| 167 | + sums record-mode amounts. One code cannot label amounts in several currencies: delete |
| 168 | + the measure's `currency` and select the dimension over the code field, so each row |
| 169 | + carries its own. |
| 170 | +- **A derived measure that combines a record-mode amount with an amount in another |
| 171 | + currency** (another code field, a `fixed` code, an explicit measure code or the tenant |
| 172 | + currency) is refused at compile with `DATASET_INVALID` / 400, whatever the operation. |
| 173 | + A derived measure over a `count`, such as an amount per invoice, stays legal. |
| 174 | + |
| 175 | +### In the Console |
| 176 | + |
| 177 | +Each record-mode amount shows the symbol and minor-unit decimals of its own record's code. |
| 178 | +A record whose code is blank shows a plain number. Grouped analytics answers do the same |
| 179 | +per row: the dashboard table, the report table and its export, and the Studio dataset |
| 180 | +preview each take a row's currency from its code column. A place that spans many records |
| 181 | +and is not grouped by the code has no one currency to show: the grid footer draws no |
| 182 | +`sum` / `avg` / `min` / `max` for that column, a group header draws them only when the |
| 183 | +grouping includes the code field, a report's grand total on such a column shows no |
| 184 | +symbol, and the counts always stand. |
| 185 | + |
| 186 | +## The home currency |
| 187 | + |
| 188 | +The organization's home currency is the existing `localization.currency` setting. |
| 189 | +**Metadata never names it**: no field, object or dataset declares the home currency, and a |
| 190 | +`dynamic` amount displays in it because that is what `dynamic` means. |
| 191 | + |
| 192 | +Consolidating record-mode amounts into one home-currency total, through dated exchange |
| 193 | +rates and a stored home amount, comes in a later release. Until then, total a record-mode |
| 194 | +amount per currency by grouping by its code field. |
| 195 | + |
| 196 | +## See also |
| 197 | + |
| 198 | +- [Field Type Gallery: `currency`](/docs/data-modeling/field-types#currency) — the field's |
| 199 | + properties and decimal places. |
| 200 | +- [Validation rules: `currency`](/docs/data-modeling/validation-rules#currency) — the value |
| 201 | + shape a write is judged against. |
| 202 | +- [Analytics](/docs/data-modeling/analytics) — datasets, measures and dimensions. |
0 commit comments