Skip to content

Commit 2d3501f

Browse files
committed
wip: currency.mdx homeAmount passage (#27192)
Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03cab4a commit 2d3501f

1 file changed

Lines changed: 102 additions & 3 deletions

File tree

‎content/docs/data-modeling/currency.mdx‎

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,108 @@ The organization's home currency is the existing `localization.currency` setting
201201
**Metadata never names it**: no field, object or dataset declares the home currency, and a
202202
`dynamic` amount displays in it because that is what `dynamic` means.
203203

204-
Consolidating record-mode amounts into one home-currency total, through dated exchange
205-
rates and a stored home amount, comes in a later release. Until then, total a record-mode
206-
amount per currency by grouping by its code field.
204+
To total record-mode amounts across currencies, store each one converted into the home
205+
currency when it is written, as the next section shows, and aggregate the stored home
206+
amount.
207+
208+
## Converting into the home currency
209+
210+
### Declaring the home amount
211+
212+
A record-mode amount declares `currencyConfig.homeAmount` to have the engine store its
213+
value converted into the home currency, together with the rate it used. The declaration
214+
names three fields of the same object, all required:
215+
216+
| Pointer | Names | Declared as |
217+
|:---|:---|:---|
218+
| `field` | where the home amount is stored | a `currency` field in `dynamic` mode (it displays in the home currency), `readonly: true` |
219+
| `rateDate` | the record's document date, which picks the rate | a `date` or `datetime` field. There is no default: the engine never uses `created_at` in its place. |
220+
| `rateField` | where the rate used is stored | a `number` field, `readonly: true` |
221+
222+
```typescript
223+
export const Invoice = ObjectSchema.create({
224+
name: 'invoice',
225+
label: 'Invoice',
226+
fields: {
227+
name: Field.text({ label: 'Number', required: true }),
228+
currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }),
229+
invoice_date: Field.date({ label: 'Invoice Date' }),
230+
amount: Field.currency({
231+
label: 'Amount',
232+
currencyConfig: {
233+
currencyMode: 'record',
234+
currencyField: 'currency_code',
235+
homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' },
236+
},
237+
}),
238+
amount_home: Field.currency({ label: 'Amount (home currency)', readonly: true }),
239+
amount_rate: Field.number({ label: 'Exchange rate', readonly: true }),
240+
},
241+
});
242+
```
243+
244+
The names `amount_home` and `amount_rate` are a convention, not a contract: the platform
245+
never injects these columns and never looks them up by name.
246+
247+
### The exchange rates
248+
249+
Rates live in the organization's **Exchange Rates** (Setup, the `sys_exchange_rate`
250+
object): one row per currency and effective date, entered by an organization
251+
administrator, imported, or written by an app flow that fetches a provider feed. A rate
252+
states how many units of the foreign currency **one unit of the home currency** buys:
253+
with home currency USD, an EUR row of `0.92` means 1 USD = 0.92 EUR. A rate keeps up to
254+
twelve decimals.
255+
256+
### What a write stores
257+
258+
When a record is inserted, and whenever an update changes its amount, its code or its
259+
document date, the engine computes the home amount:
260+
261+
- **The rate** is the row for the record's currency with the latest effective date **on
262+
or before** the document date. A row dated after the document never applies, and
263+
neither does "the most recent rate" when it is dated later. A `datetime` document date
264+
is read as its calendar day in UTC.
265+
- **The home amount** is the amount **divided by** the rate, computed in exact decimal
266+
arithmetic and rounded once, half away from zero, to the home currency's minor units:
267+
0 decimals for JPY, 2 for USD and CNY, 3 for KWD. USD 10,000 at a rate of 0.1389 into
268+
CNY stores ¥71,994.24.
269+
- **The rate field** stores the rate row's own value, unrounded, so the stored home
270+
amount always reproduces from the stored amount and rate.
271+
- **An amount in the home currency itself** stores rate `1` and needs no rate row.
272+
- **A blank amount** stores a blank home amount and a blank rate.
273+
274+
The home and rate fields are **the engine's alone**. A value a caller sends for either is
275+
dropped like any `readonly` value (or refused under `strictReadonlyWrites`); a
276+
`defaultValue` on either never stands; and a value a system write or a historical import
277+
supplies is computed over. A batch insert and an import convert every row through the
278+
same path as a single write.
279+
280+
A rate row corrected later does not rewrite the amounts already converted with it: an
281+
update that does not touch the amount, its code or its document date leaves the stored
282+
home amount and rate as they are.
283+
284+
An update by filter (`multi: true`) writes one patch to every matched row, so it is
285+
refused, with `VALIDATION_FAILED`, when the patch would convert the rows to different home
286+
amounts. Update those rows by id instead, and each is converted from its own row.
287+
288+
### What a write refuses
289+
290+
Each refusal is answered before anything is written, and the row is not stored:
291+
292+
| Refusal | When | Fix |
293+
|:---|:---|:---|
294+
| **`ERR_EXCHANGE_RATE_MISSING` / 400** | No rate row for the record's currency has an effective date on or before its document date. The message names the currency and the date. | Add a rate for that currency effective on or before that date, then write again. The engine never stores a rate of 1, a later rate or an empty home amount in its place. |
295+
| **`ERR_HOME_CURRENCY_UNSET` / 400** | The organization has no home currency: `localization.currency` is not set. | Set **Localization → Default currency**, then write again. The platform never assumes a home currency. |
296+
| `VALIDATION_FAILED` / 400, `required` on the date field | A foreign-currency amount has a blank document date. | Write the document date in the same write. |
297+
298+
The `validate()` preview, and an import's dry run, answer the same refusals for the same
299+
rows.
300+
301+
### Totals in the home currency
302+
303+
The home field is an ordinary stored `currency` field, so every aggregate reads it as it
304+
reads any amount: a `sum` over `amount_home` is the consolidated total, with no grouping,
305+
and a `summary` roll-up, a dataset measure or a grid footer over it needs nothing special.
207306

208307
## See also
209308

0 commit comments

Comments
 (0)