Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3a39df5
feat(spec): `currencyConfig.homeAmount: { field, rateDate, rateField …
claude Oct 30, 2026
75c7a09
docs(skills): teach `currencyConfig.homeAmount`; changeset for the ho…
claude Oct 30, 2026
49b1934
Merge remote-tracking branch 'origin/main' into claude/issue-27191-ho…
claude Oct 30, 2026
6db7ac8
chore(spec): regenerate the authorable surface, the field references …
claude Oct 30, 2026
361f8f2
test(spec): the field form omits `currencyConfig.homeAmount` until th…
claude Oct 30, 2026
6c39365
Merge remote-tracking branch 'origin/main' into claude/issue-27191-ho…
claude Oct 31, 2026
5b1430f
chore(spec): regenerate the authorable surface and references after m…
claude Oct 31, 2026
cf13692
chore(docs): recount the unknown-key strictness ledger after merging …
claude Oct 31, 2026
b71e44a
wip(objectql): home-amount projection module; register ERR_EXCHANGE_R…
claude Oct 31, 2026
86544d4
wip(objectql): wire the home projection into insert, by-id and predic…
claude Oct 31, 2026
2a87bd5
wip(objectql): home projection tests (#27192)
claude Oct 31, 2026
89c3c18
wip(objectql): home projection engine tests green on the in-memory fa…
claude Oct 31, 2026
e62d038
wip(dogfood): home projection rounding pins on better-sqlite3, sqlite…
claude Oct 31, 2026
03cab4a
wip: write-path (import/REST) test; float-rounding pins (#27192)
claude Oct 31, 2026
2d3501f
wip: currency.mdx homeAmount passage (#27192)
claude Oct 31, 2026
88b8a2b
wip: changesets; regenerated spec api-surface and reference pages (#2…
claude Oct 31, 2026
1421dd9
merge main (#28096 landed) into the projection branch
claude Oct 31, 2026
fe3f18a
chore(spec): regenerate api-surface and export-origins after merging …
claude Oct 31, 2026
c37e52a
fix: drop the merge's duplicate skills paragraph; the write-path test…
claude Oct 31, 2026
2fe4a2c
test: rates are per organization on the real drivers; CLDR fraction d…
claude Oct 31, 2026
321991c
feat(objectql): a datetime document date is dated in the organization…
claude Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/27192-home-amount-error-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
---

feat(spec): the error-code ledger registers `ERR_EXCHANGE_RATE_MISSING` and `ERR_HOME_CURRENCY_UNSET`, and `@objectstack/spec/data` exports `CURRENCY_FRACTION_DIGITS` (ADR-0144 D5, Open items 3 and 9)

Clause-②: yes (widening: two new error codes under `@objectstack/objectql` in `ERROR_CODE_LEDGER`; the `CURRENCY_FRACTION_DIGITS` table, already in the package, is now exported from `@objectstack/spec/data`. No Zod schema shape and no table value changes)
Metadata-protocol: none

⛔ Not released alone: these are part of the R2 core (ADR-0144 D9) and ship with R1 in 18.3.0, together with the write-time home-currency projection that answers them, the `sys_exchange_rate` table (#27185) and the `localization.currency` lock (#27198).

- `ERR_EXCHANGE_RATE_MISSING` (400): a write that must convert an amount into the home currency found no exchange rate in force on the record's document date.
- `ERR_HOME_CURRENCY_UNSET` (400): the same write found no home currency, because the organization's `localization.currency` setting is not set.
- `CURRENCY_FRACTION_DIGITS` (CLDR fraction digits per code, which differ from ISO 4217's minor units for some codes such as IQD, HUF, COP, IDR and PKR) is the table the engine rounds a home amount with, exported so a consumer rounds with the same table instead of a copy.
21 changes: 21 additions & 0 deletions .changeset/27192-home-amount-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/objectql': patch
---

feat(objectql): a record-mode amount that declares `currencyConfig.homeAmount` stores its value converted into the home currency, with the rate used, on every write (ADR-0144 D5)

Clause-②: yes (widening: `ObjectQL.setHomeLocalizationResolver(resolver)` is new public engine surface; the write refusals below reach only an object that declares `currencyConfig.homeAmount`, a key that ships in this same release, so no released accept set narrows)
Metadata-protocol: none

⛔ Not released alone: this is the engine half of the R2 core (ADR-0144 D9). It ships with R1 in 18.3.0, together with the `currencyConfig.homeAmount` key (#27191), the `sys_exchange_rate` table (#27185) and the `localization.currency` lock (#27198).

When a record is inserted, and when an update changes its amount, its currency code or its document date, the engine writes the two fields `homeAmount` names:

- **The rate** is the organization's `sys_exchange_rate` row for the record's currency with the latest `effective_date` on or before the document date (`homeAmount.rateDate`). A `datetime` document date is dated in the organization's `localization.timezone` (UTC when unset): an invoice written at 01:00 in Shanghai takes that Shanghai day's rate. A `date` is its own day. The row's rate is stored in `homeAmount.rateField` as it is, unrounded.
- **The home amount** is the amount divided by the rate (a rate states how many units of the foreign currency one unit of the home currency buys), computed in exact decimal arithmetic and rounded once, half away from zero, to the home currency's CLDR fraction digits (0 for JPY, 2 for USD and CNY, 3 for KWD; for a few codes, such as IQD, HUF, COP, IDR and PKR, CLDR's count differs from ISO 4217's minor units, and CLDR's applies). USD 10,000 at 0.1389 into CNY stores 71994.24.
- **Same currency as home:** rate `1`, no rate read. **Blank amount:** blank home amount and rate.
- **The home currency** is the `localization.currency` setting, read for the row's organization through the `settings` service, in one grouped read with `localization.timezone`. `ObjectQLPlugin` wires it; an engine booted without the plugin answers both through `setHomeLocalizationResolver`.
- **The engine's alone.** A value a caller sends for either field is dropped (or refused under `strictReadonlyWrites`) as before; a `defaultValue`, a system write or a historical (`preserveAudit`) import of either field is computed over.
- **Refusals, before anything is written:** `ERR_EXCHANGE_RATE_MISSING` / 400 when no rate row is in force on the document date (the message names the currency and the date; never a rate of 1, a later rate or an empty home amount); `ERR_HOME_CURRENCY_UNSET` / 400 when the organization has no `localization.currency` (never an assumed home currency); `VALIDATION_FAILED` `required` on the date field when a foreign-currency amount has no document date. A multi-row insert (`insertMany`, the import) refuses only the offending row; an update by filter is refused when its one patch would convert the matched rows to different home amounts.
- **Every door, one path.** REST create and update, the import (batched and per row, dry run included) and `validate()` reach the same insert and update seam, so they convert and refuse alike.
- **No migration.** Nothing changes for an amount that does not declare `homeAmount`.
109 changes: 106 additions & 3 deletions content/docs/data-modeling/currency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,112 @@ The organization's home currency is the existing `localization.currency` setting
**Metadata never names it**: no field, object or dataset declares the home currency, and a
`dynamic` amount displays in it because that is what `dynamic` means.

Consolidating record-mode amounts into one home-currency total, through dated exchange
rates and a stored home amount, comes in a later release. Until then, total a record-mode
amount per currency by grouping by its code field.
To total record-mode amounts across currencies, store each one converted into the home
currency when it is written, as the next section shows, and aggregate the stored home
amount.

## Converting into the home currency

### Declaring the home amount

A record-mode amount declares `currencyConfig.homeAmount` to have the engine store its
value converted into the home currency, together with the rate it used. The declaration
names three fields of the same object, all required:

| Pointer | Names | Declared as |
|:---|:---|:---|
| `field` | where the home amount is stored | a `currency` field in `dynamic` mode (it displays in the home currency), `readonly: true` |
| `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. |
| `rateField` | where the rate used is stored | a `number` field, `readonly: true` |

```typescript
export const Invoice = ObjectSchema.create({
name: 'invoice',
label: 'Invoice',
fields: {
name: Field.text({ label: 'Number', required: true }),
currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }),
invoice_date: Field.date({ label: 'Invoice Date' }),
amount: Field.currency({
label: 'Amount',
currencyConfig: {
currencyMode: 'record',
currencyField: 'currency_code',
homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' },
},
}),
amount_home: Field.currency({ label: 'Amount (home currency)', readonly: true }),
amount_rate: Field.number({ label: 'Exchange rate', readonly: true }),
},
});
```

The names `amount_home` and `amount_rate` are a convention, not a contract: the platform
never injects these columns and never looks them up by name.

### The exchange rates

Rates live in the organization's **Exchange Rates** (Setup, the `sys_exchange_rate`
object): one row per currency and effective date, entered by an organization
administrator, imported, or written by an app flow that fetches a provider feed. A rate
states how many units of the foreign currency **one unit of the home currency** buys:
with home currency USD, an EUR row of `0.92` means 1 USD = 0.92 EUR. A rate keeps up to
twelve decimals.

### What a write stores

When a record is inserted, and whenever an update changes its amount, its code or its
document date, the engine computes the home amount:

- **The rate** is the row for the record's currency with the latest effective date **on
or before** the document date. A row dated after the document never applies, and
neither does "the most recent rate" when it is dated later. A `datetime` document date
is dated in the organization's timezone (the `localization.timezone` setting, UTC when
unset): an invoice written at 01:00 on April 1 in Shanghai takes the April 1 rate. A
`date` document date is its own day.
- **The home amount** is the amount **divided by** the rate, computed in exact decimal
arithmetic and rounded once, half away from zero, to the home currency's decimal
places as CLDR gives them (its fraction digits): 0 for JPY, 2 for USD and CNY, 3 for
KWD. For a few currencies CLDR's count differs from ISO 4217's minor units (IQD, HUF,
COP, IDR and PKR among them), and CLDR's applies. USD 10,000 at a rate of 0.1389 into
CNY stores ¥71,994.24.
- **The rate field** stores the rate row's own value, unrounded, so the stored home
amount always reproduces from the stored amount and rate.
- **An amount in the home currency itself** stores rate `1` and needs no rate row.
- **A blank amount** stores a blank home amount and a blank rate.

The home and rate fields are **the engine's alone**. A value a caller sends for either is
dropped like any `readonly` value (or refused under `strictReadonlyWrites`); a
`defaultValue` on either never stands; and a value a system write or a historical import
supplies is computed over. A batch insert and an import convert every row through the
same path as a single write.

A rate row corrected later does not rewrite the amounts already converted with it: an
update that does not touch the amount, its code or its document date leaves the stored
home amount and rate as they are.

An update by filter (`multi: true`) writes one patch to every matched row, so it is
refused, with `VALIDATION_FAILED`, when the patch would convert the rows to different home
amounts. Update those rows by id instead, and each is converted from its own row.

### What a write refuses

Each refusal is answered before anything is written, and the row is not stored:

| Refusal | When | Fix |
|:---|:---|:---|
| **`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. |
| **`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. |
| `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. |

The `validate()` preview, and an import's dry run, answer the same refusals for the same
rows.

### Totals in the home currency

The home field is an ordinary stored `currency` field, so every aggregate reads it as it
reads any amount: a `sum` over `amount_home` is the consolidated total, with no grouping,
and a `summary` roll-up, a dataset measure or a grid footer over it needs nothing special.

## See also

Expand Down
4 changes: 3 additions & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +353 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +355 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |

### Allowed Values: `FlowInnerRefusal.code`
Expand Down Expand Up @@ -483,8 +483,10 @@ const result = AutomationApiErrorCode.parse(data);
* `ERR_CURRENCY_CODE_REQUIRED`
* `ERR_DATASOURCE_UNAVAILABLE`
* `ERR_DRIVER_CONNECT`
* `ERR_EXCHANGE_RATE_MISSING`
* `ERR_FILE_CONSTRAINT`
* `ERR_FILE_REFERENCE_COPY`
* `ERR_HOME_CURRENCY_UNSET`
* `ERR_HOOK_TARGET_REBIND`
* `ERR_READONLY_FIELD_REJECTED`
* `ERR_SUMMARY_RECOMPUTE`
Expand Down
4 changes: 3 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +353 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +355 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -171,8 +171,10 @@ const result = ApiErrorSchema.parse(data);
* `ERR_CURRENCY_CODE_REQUIRED`
* `ERR_DATASOURCE_UNAVAILABLE`
* `ERR_DRIVER_CONNECT`
* `ERR_EXCHANGE_RATE_MISSING`
* `ERR_FILE_CONSTRAINT`
* `ERR_FILE_REFERENCE_COPY`
* `ERR_HOME_CURRENCY_UNSET`
* `ERR_HOOK_TARGET_REBIND`
* `ERR_READONLY_FIELD_REJECTED`
* `ERR_SUMMARY_RECOMPUTE`
Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,10 @@ const result = ErrorCode.parse(data);
* `ERR_CURRENCY_CODE_REQUIRED`
* `ERR_DATASOURCE_UNAVAILABLE`
* `ERR_DRIVER_CONNECT`
* `ERR_EXCHANGE_RATE_MISSING`
* `ERR_FILE_CONSTRAINT`
* `ERR_FILE_REFERENCE_COPY`
* `ERR_HOME_CURRENCY_UNSET`
* `ERR_HOOK_TARGET_REBIND`
* `ERR_READONLY_FIELD_REJECTED`
* `ERR_SUMMARY_RECOMPUTE`
Expand Down
Loading
Loading