diff --git a/.changeset/27192-home-amount-error-codes.md b/.changeset/27192-home-amount-error-codes.md new file mode 100644 index 00000000000..f2e82deec99 --- /dev/null +++ b/.changeset/27192-home-amount-error-codes.md @@ -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. diff --git a/.changeset/27192-home-amount-projection.md b/.changeset/27192-home-amount-projection.md new file mode 100644 index 00000000000..79e8fa1bfc4 --- /dev/null +++ b/.changeset/27192-home-amount-projection.md @@ -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`. diff --git a/content/docs/data-modeling/currency.mdx b/content/docs/data-modeling/currency.mdx index c09ce58a7a4..e489f45c61e 100644 --- a/content/docs/data-modeling/currency.mdx +++ b/content/docs/data-modeling/currency.mdx @@ -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 diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 2dfd5c4947a..47b869c1952 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -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` @@ -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` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 2362a57bb8d..6a0aad1ec09 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -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`. | @@ -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` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 4101142258b..6c95dd2813a 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -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` diff --git a/packages/objectql/src/engine-home-amount-projection.test.ts b/packages/objectql/src/engine-home-amount-projection.test.ts new file mode 100644 index 00000000000..1e5c5bd3744 --- /dev/null +++ b/packages/objectql/src/engine-home-amount-projection.test.ts @@ -0,0 +1,424 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D5] The write-time home projection through the engine's write +// seams, on the in-memory face: a driver double whose `find` is the engine's +// own in-memory reading of a driver query (`applyDriverQueryInMemory`: where, +// orderBy, limit, fields), so the rate row the projection picks is chosen by +// the same `effective_date <= date ORDER BY effective_date DESC LIMIT 1` a +// SQL driver runs. ⛔ No driver-memory arm: its test consumers are a closed +// census (`check:driver-memory-census`). The double ignores `tenantId`, so +// organization isolation of the rate read is not pinned here: the same pins, +// and the two-organization one, on SQLite and Postgres are packages/qa/dogfood's +// `test/home-amount-projection.test.ts`. +// +// Pinned here, per seam (insert, insertMany, by-id update, predicate update, +// the validate() preview): +// +// - the stored home amount is amount ÷ rate, rounded to the home currency's +// CLDR fraction digits, +// stored with the rate used; the same currency as home is rate 1, read-free; +// - a missing rate refuses ERR_EXCHANGE_RATE_MISSING and an unset home +// currency ERR_HOME_CURRENCY_UNSET, and nothing is written; +// - the two projected fields: a caller's value is stripped (`readonly`) and +// recomputed over, a system or `preserveAudit` writer's value is recomputed +// over, a `defaultValue` never stands; a rate field declaring a `scale` +// below the rate's decimals stores the full rate, never rounded and never +// refused (a `readonly` value is checked for shape only, so the declared +// scale reaches display only); +// - a `datetime` document date is dated in the organization's +// `localization.timezone` (UTC when unset); a `date` is its own day; +// - an update projects when it moves the amount, its code or its document +// date, and never otherwise (a corrected rate reaches stored rows only +// through a recompute). + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { EngineUpdateOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; +import { applyDriverQueryInMemory } from './driver-query-in-memory.js'; + +function makeDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const finds: Record = {}; + let n = 0; + const select = (object: string, ast: any) => + applyDriverQueryInMemory(Array.from(storeFor(object).values()), { + where: ast?.where, orderBy: ast?.orderBy, limit: ast?.limit, offset: ast?.offset, + }); + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + finds[object] = (finds[object] ?? 0) + 1; + return select(object, ast).map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + const [hit] = select(object, { ...ast, limit: 1 }); + return hit ? { ...hit } : null; + }, + async create(object: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return { ...row }; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return { ...row }; + }, + async updateMany(object: string, ast: any, data: Record) { + const s = storeFor(object); + const hits = select(object, ast); + for (const r of hits) s.set(r.id as string, { ...s.get(r.id as string), ...data }); + return hits.length; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async deleteMany() { return 0; }, + async count(object: string, ast: any) { return select(object, ast).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor, finds }; +} + +/** The rate table's shape (`sys_exchange_rate`, owned by platform-objects; a stand-in here). */ +const RATE_OBJECT = { + name: 'sys_exchange_rate', + fields: { + currency: { type: 'text', valueDomain: 'iso_4217_currency' }, + rate: { type: 'number', scale: 12, precision: 24 }, + effective_date: { type: 'date' }, + }, +}; + +const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }; +const invoice = (over: { home?: Record; rate?: Record; dateType?: 'date' | 'datetime' } = {}) => ({ + name: 'fx_invoice', + fields: { + title: { type: 'text' }, + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + invoice_date: { type: over.dateType ?? 'date' }, + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } }, + amount_home: { type: 'currency', readonly: true, ...over.home }, + amount_rate: { type: 'number', readonly: true, ...over.rate }, + }, +}); + +type Thrown = (Error & { code?: string; status?: number; field?: string; fields?: Array<{ field: string; code: string }> }) | null; +const refusalOf = async (p: Promise): Promise => p.then(() => null, (e) => e as Thrown); + +const SYS = { context: { isSystem: true } } as const; +const BATCH: EngineUpdateOptions = { where: { title: 'batch' }, multi: true }; + +/** `home: null` composes no home-currency resolver at all (no settings service). */ +async function boot(object = invoice(), home: string | null = 'CNY') { + const engine = new ObjectQL(); + const d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject(RATE_OBJECT as any); + engine.registry.registerObject(object as any); + if (home !== null) engine.setHomeLocalizationResolver(async () => ({ currency: home })); + const rate = (currency: string, effective_date: string, value: number) => + d.storeFor('sys_exchange_rate').set(`${currency}_${effective_date}`, { id: `${currency}_${effective_date}`, currency, effective_date, rate: value }); + return { engine, ...d, rate }; +} + +describe('insert: the home amount is the amount divided by the rate in force on the document date', () => { + let t: Awaited>; + beforeEach(async () => { + t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + t.rate('USD', '2026-04-01', 0.15); + t.rate('EUR', '2026-01-01', 0.128); + }); + + it('USD 10,000 at 0.1389 into CNY stores ¥71,994.24 and the rate 0.1389', async () => { + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(row).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('a batch converts each row at its own currency and date, reading each (currency, date) rate once', async () => { + const before = t.finds.sys_exchange_rate ?? 0; + await t.engine.insert('fx_invoice', [ + { title: 'a', currency_code: 'USD', invoice_date: '2026-03-15', amount: 100 }, + { title: 'b', currency_code: 'USD', invoice_date: '2026-03-15', amount: 200 }, + { title: 'c', currency_code: 'USD', invoice_date: '2026-04-02', amount: 150 }, + { title: 'd', currency_code: 'EUR', invoice_date: '2026-03-15', amount: 64 }, + ]); + const byTitle = Object.fromEntries([...t.storeFor('fx_invoice').values()].map((r) => [r.title, [r.amount_home, r.amount_rate]])); + expect(byTitle).toEqual({ a: [719.94, 0.1389], b: [1439.88, 0.1389], c: [1000, 0.15], d: [500, 0.128] }); + expect((t.finds.sys_exchange_rate ?? 0) - before).toBe(3); + }); + + it('the same currency as home is rate 1 and reads no rate', async () => { + const before = t.finds.sys_exchange_rate ?? 0; + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'CNY', amount: 12.345 }); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 12.35, amount_rate: 1 }); + // 1.005 is 1.00499999… as a double: a float rounding stores 1.00, the decimal one 1.01. + const half = await t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'CNY', amount: 1.005 }); + expect(t.storeFor('fx_invoice').get(half.id)).toMatchObject({ amount_home: 1.01, amount_rate: 1 }); + expect(t.finds.sys_exchange_rate ?? 0).toBe(before); + }); + + it('a blank amount stores a blank home amount and rate', async () => { + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15' }); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: null, amount_rate: null }); + }); + + it('no rate on or before the document date refuses ERR_EXCHANGE_RATE_MISSING, and nothing is written', async () => { + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2025-12-31', amount: 1 })); + expect({ code: err?.code, status: err?.status, field: err?.field }).toEqual({ code: 'ERR_EXCHANGE_RATE_MISSING', status: 400, field: 'amount' }); + expect(err?.message).toContain('no exchange rate for USD has an effective date on or before 2025-12-31'); + const noRow = await refusalOf(t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'JPY', invoice_date: '2026-03-15', amount: 1 })); + expect(noRow?.code).toBe('ERR_EXCHANGE_RATE_MISSING'); + expect(t.storeFor('fx_invoice').size).toBe(0); + }); + + it('a foreign amount with a blank document date is refused on the date', async () => { + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', amount: 1 })); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect(err?.fields?.map((f) => [f.field, f.code])).toEqual([['invoice_date', 'required']]); + expect(t.storeFor('fx_invoice').size).toBe(0); + }); + + it('a blank code is the currency-code refusal, not a rate lookup', async () => { + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', invoice_date: '2026-03-15', amount: 1 })); + expect(err?.code).toBe('ERR_CURRENCY_CODE_REQUIRED'); + }); + + it('insertMany culls a refused row per row and stores the rest', async () => { + const outcomes = await t.engine.insertMany('fx_invoice', [ + { title: 'ok', currency_code: 'USD', invoice_date: '2026-03-15', amount: 100 }, + { title: 'no-rate', currency_code: 'JPY', invoice_date: '2026-03-15', amount: 100 }, + ]); + expect(outcomes.map((o: any) => [o.ok, o.error?.code])).toEqual([[true, undefined], [false, 'ERR_EXCHANGE_RATE_MISSING']]); + expect([...t.storeFor('fx_invoice').values()].map((r) => r.title)).toEqual(['ok']); + }); +}); + +describe('the home currency is the organization\'s setting, never a default', () => { + it('no resolver (no settings service) refuses ERR_HOME_CURRENCY_UNSET', async () => { + const t = await boot(invoice(), null); + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 })); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'ERR_HOME_CURRENCY_UNSET', status: 400 }); + expect(err?.message).toContain('localization.currency'); + expect(t.storeFor('fx_invoice').size).toBe(0); + }); + + it('the resolver is asked for the row\'s organization', async () => { + const t = await boot(invoice(), null); + const asked: Array = []; + t.engine.setHomeLocalizationResolver(async (org) => { asked.push(org); return { currency: 'CNY' }; }); + await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'CNY', amount: 1 }, { context: { isSystem: true, tenantId: 'org_a' } } as any); + expect(asked).toEqual(['org_a']); + }); +}); + +describe('a datetime document date is dated in the organization\'s localization.timezone', () => { + // 2026-03-15T17:00Z is 01:00 on 2026-03-16 in Shanghai, and still the 15th in UTC. + const AT_0100_SHANGHAI = '2026-03-15T17:00:00Z'; + async function bootZoned(zones: Record) { + const t = await boot(invoice({ dateType: 'datetime' }), null); + const asked: Array = []; + t.engine.setHomeLocalizationResolver(async (org) => { asked.push(org); return { currency: 'CNY', timezone: zones[org ?? ''] }; }); + t.rate('USD', '2026-03-15', 0.1389); + t.rate('USD', '2026-03-16', 0.15); + return { ...t, asked }; + } + const as = (org: string) => ({ context: { isSystem: true, tenantId: org } }) as any; + + it('an Asia/Shanghai organization writing at 01:00 local time gets the rate of that local day', async () => { + const t = await bootZoned({ org_sh: 'Asia/Shanghai' }); + const row = await t.engine.insert('fx_invoice', { title: 'sh', currency_code: 'USD', invoice_date: AT_0100_SHANGHAI, amount: 1500 }, as('org_sh')); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 10000, amount_rate: 0.15 }); + // One localization read per organization per write answers the currency and the timezone. + expect(t.asked).toEqual(['org_sh']); + }); + + it('a UTC organization, or one with no timezone set, keeps the UTC day', async () => { + const t = await bootZoned({ org_utc: 'UTC', org_unset: undefined }); + for (const org of ['org_utc', 'org_unset']) { + const row = await t.engine.insert('fx_invoice', { title: org, currency_code: 'USD', invoice_date: AT_0100_SHANGHAI, amount: 1389 }, as(org)); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + } + }); + + it('an update of the datetime re-dates it in the same timezone', async () => { + const t = await bootZoned({ org_sh: 'Asia/Shanghai' }); + const row = await t.engine.insert('fx_invoice', { title: 'sh', currency_code: 'USD', invoice_date: '2026-03-15T03:00:00Z', amount: 1389 }, as('org_sh')); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + await t.engine.update('fx_invoice', { id: row.id, invoice_date: AT_0100_SHANGHAI }, as('org_sh')); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 9260, amount_rate: 0.15 }); + }); + + it('a date document date is never moved by the timezone', async () => { + const t = await boot(invoice(), null); + t.engine.setHomeLocalizationResolver(async () => ({ currency: 'CNY', timezone: 'Asia/Shanghai' })); + t.rate('USD', '2026-03-15', 0.1389); + t.rate('USD', '2026-03-16', 0.15); + const row = await t.engine.insert('fx_invoice', { title: 'd', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1389 }, as('org_sh')); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + }); +}); + +describe('only the engine writes the home and rate fields', () => { + it('a caller\'s value is stripped as readonly and the projection is stored', async () => { + const t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + const events: any[] = []; + const row = await t.engine.insert( + 'fx_invoice', + { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000, amount_home: 1, amount_rate: 2 }, + { onFieldsDropped: (e: any) => events.push(e) } as any, + ); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(events.map((e) => [e.reason, e.fields])).toEqual([['readonly', ['amount_home', 'amount_rate']]]); + const strict = await refusalOf(t.engine.insert( + 'fx_invoice', { title: 'i2', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1, amount_home: 1 }, { strictReadonlyWrites: true } as any, + )); + expect(strict?.code).toBe('ERR_READONLY_FIELD_REJECTED'); + }); + + it('a system writer\'s value is recomputed over, on insert and on update', async () => { + const t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000, amount_home: 1, amount_rate: 2 }, SYS); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + await t.engine.update('fx_invoice', { id: row.id, amount_home: 5 }, SYS); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('a preserveAudit (historical import) update of the home field is recomputed over, not kept', async () => { + const t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }); + await t.engine.update('fx_invoice', { id: row.id, amount_home: 99999, amount_rate: 0.5 }, { context: { preserveAudit: true } } as any); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('a defaultValue on the home or rate field never stands', async () => { + const t = await boot(invoice({ home: { defaultValue: 0 }, rate: { defaultValue: 1 } })); + t.rate('USD', '2026-01-01', 0.1389); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + const blank = await t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'USD', invoice_date: '2026-03-15' }); + expect(t.storeFor('fx_invoice').get(blank.id)).toMatchObject({ amount_home: null, amount_rate: null }); + }); + + it('a rate field declaring a scale below the rate\'s decimals stores the rate unrounded, and converts with all of it', async () => { + // `readonly` values are judged for SHAPE only (`ReadonlyValueScope`), so a + // declared `scale` is no write constraint on the rate field, and every + // numeric column is the same exact decimal: the stored rate is the rate + // row's own value, and the stored home amount reproduces from it. + const t = await boot(invoice({ rate: { scale: 4 } })); + t.rate('USD', '2026-02-01', 0.138912345678); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-02-15', amount: 10000 }); + // 10000 ÷ 0.138912345678 = 71987.84…; at the rounded 0.1389 it would be 71994.24. + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71987.84, amount_rate: 0.138912345678 }); + }); +}); + +describe('update: the projection follows the amount, its code and its document date', () => { + let t: Awaited>; + let id: string; + beforeEach(async () => { + t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + t.rate('USD', '2026-04-01', 0.15); + t.rate('EUR', '2026-01-01', 0.128); + id = (await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 })).id; + }); + const stored = () => t.storeFor('fx_invoice').get(id); + + it('an amount change converts again at the same rate', async () => { + await t.engine.update('fx_invoice', { id, amount: 1389 }); + expect(stored()).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + }); + + it('a code change converts at the new currency\'s rate', async () => { + await t.engine.update('fx_invoice', { id, currency_code: 'EUR' }); + expect(stored()).toMatchObject({ amount_home: 78125, amount_rate: 0.128 }); + await t.engine.update('fx_invoice', { id, currency_code: 'CNY' }); + expect(stored()).toMatchObject({ amount_home: 10000, amount_rate: 1 }); + }); + + it('a date change picks the rate in force on the new date', async () => { + await t.engine.update('fx_invoice', { id, invoice_date: '2026-04-01' }); + expect(stored()).toMatchObject({ amount_home: 66666.67, amount_rate: 0.15 }); + }); + + it('clearing the amount clears the home amount and rate', async () => { + await t.engine.update('fx_invoice', { id, amount: null }); + expect(stored()).toMatchObject({ amount_home: null, amount_rate: null }); + }); + + it('an unrelated edit after a rate correction leaves the stored conversion alone', async () => { + t.rate('USD', '2026-01-01', 0.2); + const before = t.finds.sys_exchange_rate ?? 0; + await t.engine.update('fx_invoice', { id, title: 'renamed' }); + expect(stored()).toMatchObject({ title: 'renamed', amount_home: 71994.24, amount_rate: 0.1389 }); + expect(t.finds.sys_exchange_rate ?? 0).toBe(before); + }); + + it('a date change with no rate in force refuses, and the stored row is untouched', async () => { + const err = await refusalOf(t.engine.update('fx_invoice', { id, invoice_date: '2025-06-30' })); + expect(err?.code).toBe('ERR_EXCHANGE_RATE_MISSING'); + expect(err?.message).toContain(`(record ${id})`); + expect(stored()).toMatchObject({ invoice_date: '2026-03-15', amount_home: 71994.24 }); + }); +}); + +describe('predicate update: one patch for every matched row', () => { + let t: Awaited>; + beforeEach(async () => { + t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + t.rate('EUR', '2026-01-01', 0.128); + await t.engine.insert('fx_invoice', { title: 'batch', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }); + await t.engine.insert('fx_invoice', { title: 'batch', currency_code: 'USD', invoice_date: '2026-03-15', amount: 2000 }); + }); + + it('a patch that converts every row to one value stores it', async () => { + await t.engine.update('fx_invoice', { amount: 128, currency_code: 'EUR' }, BATCH); + expect([...t.storeFor('fx_invoice').values()].map((r) => [r.amount_home, r.amount_rate])).toEqual([[1000, 0.128], [1000, 0.128]]); + }); + + it('a patch that converts rows to different values is refused before anything is written', async () => { + const err = await refusalOf(t.engine.update('fx_invoice', { currency_code: 'EUR' }, BATCH)); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect(err?.message).toContain('update those rows by id'); + expect([...t.storeFor('fx_invoice').values()].map((r) => r.currency_code)).toEqual(['USD', 'USD']); + }); +}); + +describe('validate(): the preview refuses what the write refuses', () => { + it('insert and by-id update previews answer the projection\'s refusal as the row\'s finding', async () => { + const t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + const preview = await t.engine.validate('fx_invoice', [ + { title: 'p1', currency_code: 'JPY', invoice_date: '2026-03-15', amount: 1 }, + { title: 'p2', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }, + ]); + expect(preview.results?.map((r) => r.valid)).toEqual([false, true]); + expect(preview.results?.[0]?.errors).toEqual([ + { field: 'amount', code: 'ERR_EXCHANGE_RATE_MISSING', message: expect.stringContaining('no exchange rate for JPY') }, + ]); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }); + const update = await t.engine.validate('fx_invoice', { id: row.id, invoice_date: '2025-01-01' }, { mode: 'update' }); + expect(update.results?.[0]?.errors?.map((e) => e.code)).toEqual(['ERR_EXCHANGE_RATE_MISSING']); + expect((await t.engine.validate('fx_invoice', { id: row.id, title: 'x' }, { mode: 'update' })).valid).toBe(true); + }); +}); diff --git a/packages/objectql/src/engine-validate.ts b/packages/objectql/src/engine-validate.ts index 7659524e34f..ccdef5f4bc5 100644 --- a/packages/objectql/src/engine-validate.ts +++ b/packages/objectql/src/engine-validate.ts @@ -51,6 +51,7 @@ import { validateRecordInScope, normalizeBlankTypedValues, normalizeNumericStrin import { evaluateValidationRules, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js'; import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js'; import { assertRecordCurrencyCodes, RecordCurrencyCodeRequiredError } from './validation/record-currency-code.js'; +import { isHomeAmountRefusal } from './home-amount-projection.js'; import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from './engine-update-dispatch.js'; import { droppedFieldEvents, @@ -93,11 +94,12 @@ export interface ValidateHost { resolveOptionPermissions: ObjectQL['resolveOptionPermissions']; getSummaryDescriptors: ObjectQL['getSummaryDescriptors']; assertReferencesResolve: ObjectQL['assertReferencesResolve']; + previewHomeAmounts: ObjectQL['previewHomeAmounts']; } /** The body of `ObjectQL#validate`; the method's docblock there is its contract. */ export async function validate( - this: Pick, + this: Pick, object: string, data: Record | Record[], options?: { @@ -563,6 +565,14 @@ export async function validate( object, (schemaForValidation as { fields?: unknown } | undefined)?.fields, mode === 'insert' ? row : judgedViews[i], mode, mode === 'insert' ? undefined : row, storedIds[i], ); + // [ADR-0144 D5] The write's home projection, on the same image and + // under the same condition: a row the write refuses for no home + // currency or no rate on its document date (or a blank document date) + // is refused here with the same code, so a dry run does not pass it. + // It reads the rate table and the home currency; it writes nothing. + await this.previewHomeAmounts( + object, schemaForValidation, mode, row, mode === 'insert' ? row : judgedViews[i], options?.context, storedIds[i], + ); } warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, { // [#22445] The stored row, when this row named one: the by-id @@ -593,7 +603,7 @@ export async function validate( return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings }; } // The write throws this refusal whole; the preview answers it as the row's finding. - if (e instanceof RecordCurrencyCodeRequiredError) { + if (e instanceof RecordCurrencyCodeRequiredError || isHomeAmountRefusal(e)) { return { valid: false, errors: [{ field: e.field, code: e.code, message: e.message }], warnings }; } throw e; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 34f177e5fa9..a50936956b9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -315,6 +315,18 @@ import { assertProjectionNamesAField, refuseProjectionOfUnknownColumns } from '. import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js'; import { assertRecordCurrencyAggregatesGrouped } from './record-currency-aggregate-door.js'; import { assertRecordCurrencyCodes, touchesRecordCurrency } from './validation/record-currency-code.js'; +import { + EXCHANGE_RATE_OBJECT, + homeAmountsTouched, + memoizeHomeAmountServing, + planHomeAmounts, + projectHomeAmounts, + settleBulkHomeAmounts, + type HomeAmountPlanEntry, + type HomeAmountServing, + type HomeLocalization, + type HomeLocalizationResolver, +} from './home-amount-projection.js'; import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js'; import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; import { SchemaRegistry, type ArtifactInstallScope } from './registry.js'; @@ -9654,6 +9666,149 @@ export class ObjectQL implements IObjectQLEngine { this.logger.info('I18nService configured for validation messages'); } + /** + * [ADR-0144 D4, Open item 4] Where the write-time home projection reads an + * organization's localization: its `localization.currency` setting (the + * home currency) and its `localization.timezone` setting (the zone a + * `datetime` document date is dated in; ruling 6539345871, letter B), each + * `undefined` when unset. Bridged by `ObjectQLPlugin`, which resolves the + * settings service when a write asks, not when it starts. With no resolver + * every organization's home currency is unset, so a write of an amount + * declaring `homeAmount` is refused `ERR_HOME_CURRENCY_UNSET` — the engine + * never assumes one — and an unset timezone reads UTC. + */ + setHomeLocalizationResolver(resolver: HomeLocalizationResolver | undefined): void { + this.homeLocalizationResolver = resolver; + } + + private homeLocalizationResolver?: HomeLocalizationResolver; + + /** + * [ADR-0144 D5, Open item 2] What one write's home projection reads, memoized + * for that write: each organization's home currency once, and each + * (organization, currency, document date) rate once, however many rows ask. + * + * The rate is the `sys_exchange_rate` row of that currency with the latest + * `effective_date` on or before the document date, in the organization the + * row lands in. Read `sudo()`-shaped (`{ ...context, isSystem: true }`): a + * rank-and-file writer cannot read the rate table (parameter 2 makes it an + * administrator's object), yet every write of theirs is converted through + * it; the caller's transaction rides along, and `tenantId` is the row's own + * organization, so one organization's rate never converts another's + * amount. An install without the rate object registered has no rate for any + * currency. + */ + private homeAmountServing(context: ExecutionContext | undefined): HomeAmountServing { + // One settings read per organization per write answers both the home + // currency and the timezone. + const localizations = new Map>(); + const localizationOf = (organizationId: string | undefined) => { + const key = organizationId ?? ''; + let hit = localizations.get(key); + if (!hit) { + hit = Promise.resolve(this.homeLocalizationResolver?.(organizationId)); + localizations.set(key, hit); + } + return hit; + }; + return memoizeHomeAmountServing({ + homeCurrency: async (organizationId) => (await localizationOf(organizationId))?.currency, + timezone: async (organizationId) => (await localizationOf(organizationId))?.timezone, + rateOn: async (organizationId, currency, onOrBefore) => { + if (!this._registry.getObject(EXCHANGE_RATE_OBJECT)) return undefined; + const rows = await this.find(EXCHANGE_RATE_OBJECT, { + where: { currency, effective_date: { $lte: onOrBefore } }, + orderBy: [{ field: 'effective_date', order: 'desc' }], + limit: 1, + fields: ['rate', 'effective_date'], + context: { + ...(context ?? {}), + isSystem: true, + ...(organizationId !== undefined ? { tenantId: organizationId } : {}), + } as ExecutionContext, + }); + const row = rows[0] as { rate?: unknown; effective_date?: unknown } | undefined; + if (!row) return undefined; + const effective = row.effective_date; + return { + rate: row.rate, + effectiveDate: effective instanceof Date ? effective.toISOString().slice(0, 10) : (typeof effective === 'string' ? effective : undefined), + }; + }, + }); + } + + /** + * [ADR-0144 D5] The organization a written row belongs to, for its home + * projection: the row's own `organization_id`, else the writer's active + * organization, else the organization a system insert resolved — the + * assignment seam's reading, so both seams convert and route one row under + * one organization. + */ + private static homeAmountOrganizationOf( + row: Record | null | undefined, + context: ExecutionContext | undefined, + derived?: string | undefined, + ): string | undefined { + const own = row?.organization_id; + if (own !== undefined && own !== null && own !== '') return String(own); + if (context?.tenantId) return String(context.tenantId); + return derived ?? undefined; + } + + /** + * [ADR-0144 D5] The `validate()` preview's run of the home projection: the + * write's own computation over the preview's image (the insert row, or the + * stored row with the patch over it), answering nothing and writing + * nothing — it throws exactly the refusal the write would. + */ + private async previewHomeAmounts( + object: string, + schema: unknown, + mode: 'insert' | 'update', + row: Record, + image: Record, + context: ExecutionContext | undefined, + recordId: unknown, + ): Promise { + const plan = planHomeAmounts(schema); + const entries = mode === 'insert' ? plan : homeAmountsTouched(plan, row); + if (entries.length === 0) return; + await projectHomeAmounts(entries, image, this.homeAmountServing(context), { + object, + operation: mode, + organizationId: ObjectQL.homeAmountOrganizationOf(image, context), + recordId, + }); + } + + /** + * [ADR-0144 D5] Project a by-id update's home amounts onto its FINAL patch: + * only the declarations the patch touches ({@link homeAmountsTouched}), + * computed over the stored row with the patch laid over it. Shared by the + * write and its `validate()` preview, so a dry run refuses what the write + * refuses. + */ + private async projectHomeAmountsOntoPatch( + object: string, + plan: readonly HomeAmountPlanEntry[], + prior: Record | null | undefined, + patch: Record, + context: ExecutionContext | undefined, + recordId: unknown, + serving: HomeAmountServing = this.homeAmountServing(context), + ): Promise { + const touched = homeAmountsTouched(plan, patch); + if (touched.length === 0) return; + const image = { ...(prior ?? {}), ...patch }; + Object.assign(patch, await projectHomeAmounts(touched, image, serving, { + object, + operation: 'update', + organizationId: ObjectQL.homeAmountOrganizationOf(image, context), + recordId, + })); + } + /** * Locale + translation hooks handed to the validators so a rejected write is * reported in the caller's language (#3957). @@ -14530,6 +14685,7 @@ export class ObjectQL implements IObjectQLEngine { resolveOptionPermissions: (...a) => this.resolveOptionPermissions(...a), getSummaryDescriptors: (...a) => this.getSummaryDescriptors(...a), assertReferencesResolve: (...a) => this.assertReferencesResolve(...a), + previewHomeAmounts: (...a) => this.previewHomeAmounts(...a), }; return validate.call(host, object, data, options); } @@ -15585,6 +15741,35 @@ export class ObjectQL implements IObjectQLEngine { } } } + // [ADR-0144 D5] The write-time home projection — after every hook, + // strip and re-default, so the values it writes are the ones stored + // (a `readonly` strip never takes them, and a `defaultValue` on the + // home or rate field never stands), and before validation, which then + // judges them like any other stored value (the rate field's `scale` + // included). In every context: a system writer's value for either + // field is recomputed over too. A row it refuses (no home currency, no + // rate in force on the document date) is culled per row under partial + // mode, and nothing is written for it. Free for an object declaring no + // `homeAmount` (`planHomeAmounts` is one loop over the field names). + const homeAmountPlan = planHomeAmounts(schemaForValidation); + if (homeAmountPlan.length > 0) { + const homeServing = this.homeAmountServing(opCtx.context); + for (let i = 0; i < rows.length; i++) { + if (rowErrors[i] !== undefined) continue; + const row = rows[i] as Record | null | undefined; + if (!row || typeof row !== 'object') continue; + try { + Object.assign(row, await projectHomeAmounts(homeAmountPlan, row, homeServing, { + object, + operation: 'insert', + organizationId: ObjectQL.homeAmountOrganizationOf(row, opCtx.context, derivedOrganizationId), + })); + } catch (e) { + if (!partialMode) throw e; + rowErrors[i] = e; + } + } + } // [#4977] A `parent`-scoped `requiredWhen` ("once the header is Sent, // every line must carry a description") is a SERVER guarantee, and the // insert is where a line is first written empty — so unlike the @@ -17361,6 +17546,25 @@ export class ObjectQL implements IObjectQLEngine { applyStoredExpressionsToPatch(patch, computed!, priorRecord); } assertNoStrictDrops(); + // [ADR-0144 D5] The write-time home projection on the by-id + // path, over the stored row with the FINAL payload laid over it + // — after the hooks, both strips and the strict refusal, so its + // values are never stripped and a strict caller is told about + // its own payload first — and before the second validation pass, + // which judges what it wrote. Only when the payload touches an + // amount declaring `homeAmount`, its code, its document date or + // one of the two projected fields: an unrelated edit never + // re-reads a rate. A value a system or `preserveAudit` writer + // sent for the home or rate field is recomputed over. + { + const homeAmountPlan = planHomeAmounts(updateSchema); + if (homeAmountPlan.length > 0) { + await this.projectHomeAmountsOntoPatch( + object, homeAmountPlan, priorRecord, hookContext.input.data as Record, + opCtx.context, hookContext.input.id, + ); + } + } // [#21663] The payload is FINAL here (see the seam below), so // every readonly value on it is one the driver will store: a // system writer's (the strip above never ran for it), a hook's, @@ -17700,6 +17904,35 @@ export class ObjectQL implements IObjectQLEngine { } } assertNoStrictDrops(); + // [ADR-0144 D5] The write-time home projection on the predicate + // path, at the by-id branch's point and for its reasons. One + // patch reaches every matched row, so each matched row (the D7 + // read — `readPriorRows` is a memo) is projected with the patch + // laid over it, and {@link settleBulkHomeAmounts} keeps a + // projected value only when it is the same on every row; a patch + // that would convert to different values is refused before the + // statement. A refusal of any matched row (no home currency, no + // rate on its document date) refuses the whole update. + { + const homeAmountPlan = planHomeAmounts(updateSchema); + const homePatch = hookContext.input.data as Record; + const touched = homeAmountsTouched(homeAmountPlan, homePatch); + if (touched.length > 0) { + const matched = ((await readPriorRows()) ?? []).filter((row) => row != null && typeof row === 'object'); + const homeServing = this.homeAmountServing(opCtx.context); + const projected: Array> = []; + for (const row of matched) { + const image = { ...row, ...homePatch }; + projected.push(await projectHomeAmounts(touched, image, homeServing, { + object, + operation: 'update', + organizationId: ObjectQL.homeAmountOrganizationOf(image, opCtx.context), + recordId: row.id, + })); + } + hookContext.input.data = settleBulkHomeAmounts(touched, homePatch, projected, object) as any; + } + } // [#21663] The predicate-path twin of the by-id second pass, at the // same point and for the same reason: the readonly values left on // the final payload are stored, so their SHAPE is judged — before diff --git a/packages/objectql/src/home-amount-projection.test.ts b/packages/objectql/src/home-amount-projection.test.ts new file mode 100644 index 00000000000..ad91cd426a1 --- /dev/null +++ b/packages/objectql/src/home-amount-projection.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D5, D7 parameters 3 and 4, Open items 2, 3, 4 and 6] The home +// projection's own arithmetic and verdicts, with no engine: the exact decimal +// division, the one half-away-from-zero rounding to the home currency's minor +// units, the document-date reading, and what one projection writes or +// refuses over a served home currency and rate. The engine's three write +// seams are `engine-home-amount-projection.test.ts`; the same pins on SQLite +// and Postgres are packages/qa/dogfood's `test/home-amount-projection.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { + documentDateOf, + divideToPlaces, + formatScaledDecimal, + homeAmountsTouched, + memoizeHomeAmountServing, + planHomeAmounts, + projectHomeAmounts, + settleBulkHomeAmounts, + toScaledDecimal, + ExchangeRateMissingError, + HomeCurrencyUnsetError, + type HomeAmountServing, +} from './home-amount-projection.js'; +import { ValidationError } from './validation/record-validator.js'; + +const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }; +const SCHEMA = { + fields: { + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + invoice_date: { type: 'date' }, + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } }, + amount_home: { type: 'currency', readonly: true }, + amount_rate: { type: 'number', readonly: true }, + plain: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, + }, +}; +const PLAN = planHomeAmounts(SCHEMA); + +/** A served rate table: `rates[currency]` is a list of `[effective_date, rate]`; reads are counted. */ +function serving(home: string | undefined, rates: Record> = {}, timezone?: string) { + const reads = { home: 0, rate: 0, timezone: 0 }; + const s: HomeAmountServing = { + async homeCurrency() { + reads.home += 1; + return home; + }, + async timezone() { + reads.timezone += 1; + return timezone; + }, + async rateOn(_org, currency, onOrBefore) { + reads.rate += 1; + const hit = (rates[currency] ?? []) + .filter(([d]) => d <= onOrBefore) + .sort(([a], [b]) => (a < b ? 1 : -1))[0]; + return hit ? { rate: hit[1], effectiveDate: hit[0] } : undefined; + }, + }; + return { s, reads }; +} + +const write = { object: 'fx_invoice', operation: 'insert' as const, organizationId: 'org_a' }; + +describe('the exact decimal arithmetic', () => { + it('reads a number by its shortest spelling and a string as written, exponents included', () => { + expect(toScaledDecimal(0.1389)).toEqual({ digits: 1389n, scale: 4 }); + expect(toScaledDecimal('7.123456789012')).toEqual({ digits: 7123456789012n, scale: 12 }); + expect(toScaledDecimal(-12.5)).toEqual({ digits: -125n, scale: 1 }); + expect(toScaledDecimal(1e21)).toEqual({ digits: 10n ** 21n, scale: 0 }); + expect(toScaledDecimal(1e-7)).toEqual({ digits: 1n, scale: 7 }); + expect(toScaledDecimal('.5')).toEqual({ digits: 5n, scale: 1 }); + for (const bad of ['', '.', 'abc', '1,000', NaN, Infinity, null, undefined, {}]) { + expect(toScaledDecimal(bad), String(bad)).toBeUndefined(); + } + }); + + it('divides by the rate (parameter 3) and rounds once, half away from zero', () => { + const div = (a: number | string, r: number | string, places: number) => + formatScaledDecimal(divideToPlaces(toScaledDecimal(a)!, toScaledDecimal(r)!, places)); + // The card's two-minute check: USD 10,000 at 0.1389 is ¥71,994.24 (71994.2404…). + expect(div(10000, 0.1389, 2)).toBe('71994.24'); + // Exactly half rounds away from zero, on both signs. + expect(div('0.125', 1, 2)).toBe('0.13'); + expect(div('-0.125', 1, 2)).toBe('-0.13'); + expect(div('0.124999', 1, 2)).toBe('0.12'); + // 1.005 has no exact binary double (1.00499999…), so a float `toFixed(2)` answers 1.00; the decimal is 1.01. + expect(div(1.005, 1, 2)).toBe('1.01'); + // A float division would answer 0.30000000000000004 for 0.1 + 0.2 style inputs; exact here. + expect(div('0.3', '0.1', 2)).toBe('3.00'); + // Three minor-unit classes: JPY 0, USD 2, KWD 3. + expect(div(100, 0.0065, 0)).toBe('15385'); // 15384.615… + expect(div(100, 1.0857, 2)).toBe('92.11'); // 92.106… + expect(div(100, 3.2468, 3)).toBe('30.800'); // 30.79955… + }); +}); + +describe('the document date', () => { + it('a date is its own day; an instant with no timezone is its UTC day', () => { + expect(documentDateOf('2026-03-15')).toBe('2026-03-15'); + expect(documentDateOf('2026-03-31T23:30:00-05:00')).toBe('2026-04-01'); + expect(documentDateOf('2026-03-31T23:30:00Z')).toBe('2026-03-31'); + expect(documentDateOf(new Date('2026-03-15T08:00:00Z'))).toBe('2026-03-15'); + expect(documentDateOf('not a date')).toBeUndefined(); + expect(documentDateOf(20260315)).toBeUndefined(); + }); + + it('an instant is dated in the organization\'s timezone; a calendar date is not moved by it', () => { + // 2026-03-15T17:00Z is 01:00 on the 16th in Shanghai and 13:00 on the 15th in New York. + expect(documentDateOf('2026-03-15T17:00:00Z', 'Asia/Shanghai')).toBe('2026-03-16'); + expect(documentDateOf(new Date('2026-03-15T17:00:00Z'), 'Asia/Shanghai')).toBe('2026-03-16'); + expect(documentDateOf('2026-03-16T02:00:00Z', 'America/New_York')).toBe('2026-03-15'); + expect(documentDateOf('2026-03-15T17:00:00Z', 'UTC')).toBe('2026-03-15'); + expect(documentDateOf('2026-03-15', 'Asia/Shanghai')).toBe('2026-03-15'); + // A zone `Intl` does not know reads UTC. + expect(documentDateOf('2026-03-15T17:00:00Z', 'Mars/Olympus')).toBe('2026-03-15'); + }); + + it('a datetime rateDate asks the timezone and picks the rate of the local day; a date rateDate never asks it', async () => { + const datetimePlan = planHomeAmounts({ fields: { ...SCHEMA.fields, invoice_date: { type: 'datetime' } } }); + expect(datetimePlan[0]!.rateDateIsInstant).toBe(true); + const rates = { USD: [['2026-03-15', 0.1389], ['2026-03-16', 0.15]] as Array<[string, unknown]> }; + const at0100Shanghai = { currency_code: 'USD', invoice_date: '2026-03-15T17:00:00Z', amount: 15 }; + const sh = serving('CNY', rates, 'Asia/Shanghai'); + expect(await projectHomeAmounts(datetimePlan, at0100Shanghai, sh.s, write)).toEqual({ amount_home: 100, amount_rate: 0.15 }); + expect(sh.reads.timezone).toBe(1); + const utc = serving('CNY', rates, undefined); + expect(await projectHomeAmounts(datetimePlan, at0100Shanghai, utc.s, write)).toEqual({ amount_home: 107.99, amount_rate: 0.1389 }); + const dated = serving('CNY', rates, 'Asia/Shanghai'); + expect(await projectHomeAmounts(PLAN, { ...at0100Shanghai, invoice_date: '2026-03-15' }, dated.s, write)).toEqual({ amount_home: 107.99, amount_rate: 0.1389 }); + expect(dated.reads.timezone).toBe(0); + }); +}); + +describe('the plan', () => { + it('reads only a record-mode amount declaring homeAmount', () => { + expect(PLAN).toEqual([{ + amountField: 'amount', codeField: 'currency_code', homeField: 'amount_home', rateDateField: 'invoice_date', rateDateIsInstant: false, rateField: 'amount_rate', + }]); + expect(planHomeAmounts({ fields: { a: { type: 'currency' } } })).toEqual([]); + expect(planHomeAmounts(undefined)).toEqual([]); + }); + + it('an update projects only when it carries an input or a projected field', () => { + for (const key of ['amount', 'currency_code', 'invoice_date', 'amount_home', 'amount_rate']) { + expect(homeAmountsTouched(PLAN, { [key]: 1 }), key).toEqual(PLAN); + } + expect(homeAmountsTouched(PLAN, { plain: 1, name: 'x' })).toEqual([]); + }); +}); + +describe('one projection', () => { + it('a foreign amount is divided by the rate in force on the document date, and the rate is stored as read', async () => { + const { s } = serving('CNY', { USD: [['2026-01-01', 0.1389]] }); + const out = await projectHomeAmounts(PLAN, { amount: 10000, currency_code: 'USD', invoice_date: '2026-03-15' }, s, write); + expect(out).toEqual({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('the rate row is the latest one on or before the document date — never a later one', async () => { + const { s } = serving('USD', { EUR: [['2026-01-01', 0.8], ['2026-03-15', 0.9], ['2026-03-16', 0.5]] }); + const at = (invoice_date: string) => projectHomeAmounts(PLAN, { amount: 90, currency_code: 'EUR', invoice_date }, s, write); + expect(await at('2026-03-15')).toEqual({ amount_home: 100, amount_rate: 0.9 }); // on the date + expect(await at('2026-03-14')).toEqual({ amount_home: 112.5, amount_rate: 0.8 }); // the earlier row + expect(await at('2026-03-16')).toEqual({ amount_home: 180, amount_rate: 0.5 }); + }); + + it('a date before every rate row refuses ERR_EXCHANGE_RATE_MISSING, naming the currency and the date', async () => { + const { s } = serving('USD', { EUR: [['2026-03-16', 0.9]] }); + const err = await projectHomeAmounts(PLAN, { amount: 90, currency_code: 'EUR', invoice_date: '2026-03-15' }, s, write) + .then(() => null, (e) => e); + expect(err).toBeInstanceOf(ExchangeRateMissingError); + expect({ code: err.code, status: err.status, field: err.field, currency: err.currency, date: err.date, homeCurrency: err.homeCurrency }) + .toEqual({ code: 'ERR_EXCHANGE_RATE_MISSING', status: 400, field: 'amount', currency: 'EUR', date: '2026-03-15', homeCurrency: 'USD' }); + expect(err.message).toContain('no exchange rate for EUR has an effective date on or before 2026-03-15'); + expect(err.message).not.toMatch(/#\d/); + }); + + it('a stored rate that is not a positive number counts as missing', async () => { + for (const rate of [0, -1, null, 'abc']) { + const { s } = serving('USD', { EUR: [['2026-01-01', rate]] }); + await expect(projectHomeAmounts(PLAN, { amount: 1, currency_code: 'EUR', invoice_date: '2026-03-15' }, s, write), String(rate)) + .rejects.toBeInstanceOf(ExchangeRateMissingError); + } + }); + + it('the same currency as home converts at 1, with no rate read, rounded to the home minor units', async () => { + const { s, reads } = serving('USD'); + expect(await projectHomeAmounts(PLAN, { amount: 10.005, currency_code: 'USD' }, s, write)) + .toEqual({ amount_home: 10.01, amount_rate: 1 }); + expect(reads.rate).toBe(0); + }); + + it('three minor-unit classes: JPY 0, CNY 2, KWD 3', async () => { + const rates = { EUR: [['2026-01-01', 0.0065], ['2026-01-02', 0.1289], ['2026-01-03', 3.0103]] as Array<[string, unknown]> }; + const at = async (home: string, date: string) => + projectHomeAmounts(PLAN, { amount: 100, currency_code: 'EUR', invoice_date: date }, serving(home, rates).s, write); + expect(await at('JPY', '2026-01-01')).toEqual({ amount_home: 15385, amount_rate: 0.0065 }); // 15384.615… + expect(await at('CNY', '2026-01-02')).toEqual({ amount_home: 775.8, amount_rate: 0.1289 }); // 775.79519… + expect(await at('KWD', '2026-01-03')).toEqual({ amount_home: 33.219, amount_rate: 3.0103 }); // 33.2192… + }); + + it('a blank amount stores a blank home amount and rate, and reads nothing', async () => { + const { s, reads } = serving(undefined); + expect(await projectHomeAmounts(PLAN, { amount: null, currency_code: 'EUR' }, s, write)) + .toEqual({ amount_home: null, amount_rate: null }); + expect(reads).toEqual({ home: 0, rate: 0, timezone: 0 }); + }); + + it('no home currency refuses ERR_HOME_CURRENCY_UNSET, naming the setting — never a default', async () => { + for (const home of [undefined, '', 'XX']) { + const err = await projectHomeAmounts(PLAN, { amount: 1, currency_code: 'EUR', invoice_date: '2026-01-01' }, serving(home).s, write) + .then(() => null, (e) => e); + expect(err, String(home)).toBeInstanceOf(HomeCurrencyUnsetError); + expect({ code: err.code, status: err.status, field: err.field, setting: err.setting }) + .toEqual({ code: 'ERR_HOME_CURRENCY_UNSET', status: 400, field: 'amount', setting: 'localization.currency' }); + expect(err.message).toContain('localization.currency'); + } + }); + + it('a foreign amount with a blank document date is a required-field refusal on the date', async () => { + const err = await projectHomeAmounts(PLAN, { amount: 1, currency_code: 'EUR' }, serving('USD').s, write).then(() => null, (e) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.fields.map((f: { field: string; code: string }) => ({ field: f.field, code: f.code }))) + .toEqual([{ field: 'invoice_date', code: 'required' }]); + }); + + it('leaves the entry to the check that refuses its input: blank or non-ISO code, unparsable amount or date', async () => { + const { s, reads } = serving('USD'); + for (const image of [ + { amount: 1, currency_code: '' }, + { amount: 1, currency_code: 'usd' }, + { amount: 1, currency_code: 'ZZZ' }, + { amount: 'lots', currency_code: 'EUR', invoice_date: '2026-01-01' }, + { amount: 1, currency_code: 'EUR', invoice_date: 'someday' }, + ]) { + expect(await projectHomeAmounts(PLAN, image, s, write), JSON.stringify(image)).toEqual({}); + } + expect(reads.rate).toBe(0); + }); + + it('a memoized serving reads each home currency, timezone and (currency, date) rate once', async () => { + const { s, reads } = serving('USD', { EUR: [['2026-01-01', 0.9]] }, 'Asia/Shanghai'); + const m = memoizeHomeAmountServing(s); + const datetimePlan = planHomeAmounts({ fields: { ...SCHEMA.fields, invoice_date: { type: 'datetime' } } }); + for (let i = 0; i < 5; i++) { + await projectHomeAmounts(datetimePlan, { amount: i + 1, currency_code: 'EUR', invoice_date: '2026-02-01T10:00:00Z' }, m, write); + } + expect(reads).toEqual({ home: 1, rate: 1, timezone: 1 }); + }); +}); + +describe('the predicate update settlement', () => { + it('keeps a projected value that is the same on every matched row, and drops a caller value either way', () => { + expect(settleBulkHomeAmounts(PLAN, { currency_code: 'USD', amount_home: 5 }, [ + { amount_home: 10, amount_rate: 1 }, { amount_home: 10, amount_rate: 1 }, + ], 'fx_invoice')).toEqual({ currency_code: 'USD', amount_home: 10, amount_rate: 1 }); + expect(settleBulkHomeAmounts(PLAN, { amount_home: 5 }, [], 'fx_invoice')).toEqual({}); + }); + + it('refuses a patch that converts to different values on different rows', () => { + const err = (() => { + try { + settleBulkHomeAmounts(PLAN, { currency_code: 'USD' }, [ + { amount_home: 10, amount_rate: 1 }, { amount_home: 20, amount_rate: 1 }, + ], 'fx_invoice'); + } catch (e) { + return e as ValidationError; + } + return null; + })(); + expect(err).toBeInstanceOf(ValidationError); + expect(err!.fields.map((f) => ({ field: f.field, code: f.code }))).toEqual([{ field: 'amount_home', code: 'invalid_value' }]); + expect(err!.message).toContain('update those rows by id'); + }); +}); diff --git a/packages/objectql/src/home-amount-projection.ts b/packages/objectql/src/home-amount-projection.ts new file mode 100644 index 00000000000..61216f0c62f --- /dev/null +++ b/packages/objectql/src/home-amount-projection.ts @@ -0,0 +1,553 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D5, D7 parameters 3 and 4] The write-time home projection: a + * `record`-mode amount declaring `currencyConfig.homeAmount` is converted into + * the organization's home currency when it is written, and the result is + * stored in the declared home field together with the rate it used. + * + * ## What one projection computes + * + * For each declaration, over the row IMAGE the write stores (the insert row + * after every hook and strip, or the stored row with the final update payload + * laid over it): + * + * - **A blank amount** stores a blank home amount and a blank rate. There is + * nothing to convert, so nothing is looked up and nothing is refused. + * - **The home currency** is the organization's `localization.currency` + * setting, read through the host. Unset, the write is refused + * `ERR_HOME_CURRENCY_UNSET` / 400 (ADR-0144 Open item 4): the sources rule + * out a silent default, so the engine never picks one. + * - **The same currency as home** stores rate `1` and the amount rounded to + * the home currency's fraction digits, with no rate lookup and no rate row + * needed (Open item 6). + * - **A foreign currency** reads the rate row of that currency with the latest + * `effective_date` on or before the record's document date (Open item 2): + * never a row dated after the document, and never "the most recent row". + * None ⇒ the write is refused `ERR_EXCHANGE_RATE_MISSING` / 400, naming the + * currency and the date (D5: "a missing rate refuses the write"; no 1:1 + * rate, no nearest row, no null home amount is stored in its place). + * - **The arithmetic** is parameter 3: "1 home unit = `rate` units of the + * foreign currency", so the home amount is the record amount DIVIDED by the + * rate. It is exact decimal arithmetic on the decimal spellings of the two + * numbers (scaled integers, never a floating-point division), rounded once, + * half away from zero, to the home currency's CLDR fraction digits read from + * `CURRENCY_FRACTION_DIGITS` (Open item 3): JPY 0, USD 2, KWD 3. CLDR's + * count differs from ISO 4217's minor units for some codes (IQD, HUF, COP, + * IDR, PKR among them); the table is CLDR's, and so is the rounding. + * - **The rate stored** is the rate row's own value, never rounded (D1: the + * amount is stored "with the rate used"; a rounded rate would not reproduce + * the stored home amount). The rate field is `readonly`, and a readonly + * value is judged for its shape only (`ReadonlyValueScope`), so a `scale` + * it declares below the rate's twelve decimals is no write constraint: the + * column (every numeric column is the same exact decimal) keeps every digit, + * and the declared scale reaches only the display. + * + * ## Who writes the two fields + * + * Only this projection. The spec makes the home and rate fields declare + * `readonly: true`, so a caller's value for either is stripped before the + * projection runs; the projection writes AFTER every strip, onto the payload + * the driver stores, so its own values are never stripped. It also runs after + * the field defaults, so a `defaultValue` on either field never stands: the + * projection overwrites it. A writer the strips do not judge (a system write, + * an update under `preserveAudit`) that sends the home or rate field triggers + * the projection, which recomputes over it: the stored home amount is always + * the declared conversion of the stored amount, never a copied-in number. A + * corrected rate row reaches stored amounts only through an explicit + * recompute, because an update that touches none of the amount, its code, its + * document date or the two projected fields does not project at all (D5). + */ + +import { isValueDomainMember } from '@objectstack/spec/shared'; +import { CURRENCY_FRACTION_DIGITS } from '@objectstack/spec/data'; +import { isRecordCurrencyBlank, recordCurrencyFieldOf } from './validation/record-currency-code.js'; +import { ValidationError } from './validation/record-validator.js'; + +/** The object the rates are read from (ADR-0144 D3). */ +export const EXCHANGE_RATE_OBJECT = 'sys_exchange_rate'; + +/** The ADR-0112 `code` {@link ExchangeRateMissingError} carries. */ +export const EXCHANGE_RATE_MISSING_CODE = 'ERR_EXCHANGE_RATE_MISSING' as const; + +/** The ADR-0112 `code` {@link HomeCurrencyUnsetError} carries. */ +export const HOME_CURRENCY_UNSET_CODE = 'ERR_HOME_CURRENCY_UNSET' as const; + +/** One `homeAmount` declaration, with the code field its amount is in. */ +export interface HomeAmountPlanEntry { + /** The `record`-mode `currency` field declaring `homeAmount`. */ + readonly amountField: string; + /** Its `currencyConfig.currencyField`: the ISO 4217 code of the amount. */ + readonly codeField: string; + /** `homeAmount.field`: where the home amount is stored. */ + readonly homeField: string; + /** `homeAmount.rateDate`: the document date that picks the rate. */ + readonly rateDateField: string; + /** + * The document date is a `datetime` field: an instant, whose calendar day is + * taken in the organization's `localization.timezone`. A `date` field's value + * is its own calendar day. + */ + readonly rateDateIsInstant: boolean; + /** `homeAmount.rateField`: where the rate used is stored. */ + readonly rateField: string; +} + +/** + * Every `homeAmount` declaration of an object's field map, in declaration + * order. Read off the registered declaration: the spec already refused a + * pointer at a missing or wrong-typed field, so a declaration here names + * fields that exist. Empty for an object declaring none (the common case), so + * an ordinary write pays one loop over its field names. + */ +export function planHomeAmounts(schema: unknown): HomeAmountPlanEntry[] { + const fields = (schema as { fields?: unknown } | null | undefined)?.fields; + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) return []; + const plan: HomeAmountPlanEntry[] = []; + for (const [amountField, def] of Object.entries(fields as Record)) { + const codeField = recordCurrencyFieldOf(def); + if (codeField === undefined) continue; + const home = (def as { currencyConfig?: { homeAmount?: unknown } }).currencyConfig?.homeAmount; + if (home === null || typeof home !== 'object') continue; + const { field, rateDate, rateField } = home as { field?: unknown; rateDate?: unknown; rateField?: unknown }; + if (typeof field !== 'string' || typeof rateDate !== 'string' || typeof rateField !== 'string') continue; + const rateDateType = ((fields as Record)[rateDate] as { type?: unknown } | undefined)?.type; + plan.push({ amountField, codeField, homeField: field, rateDateField: rateDate, rateDateIsInstant: rateDateType === 'datetime', rateField }); + } + return plan; +} + +/** The keys whose presence in an update payload makes the projection run for `entry`. */ +function projectionInputs(entry: HomeAmountPlanEntry): readonly string[] { + return [entry.amountField, entry.codeField, entry.rateDateField, entry.homeField, entry.rateField]; +} + +/** + * The entries an update payload moves: it carries the amount, its code, its + * document date, or one of the two projected fields. Only these are projected + * on an update, so an edit of any other field never re-reads a rate (a + * corrected rate reaches stored rows only through an explicit recompute). + */ +export function homeAmountsTouched( + plan: readonly HomeAmountPlanEntry[], + payload: Record | null | undefined, +): HomeAmountPlanEntry[] { + if (!payload || plan.length === 0) return []; + return plan.filter((entry) => projectionInputs(entry).some((key) => Object.prototype.hasOwnProperty.call(payload, key))); +} + +// ── Exact decimal arithmetic ────────────────────────────────────────────── + +/** A decimal as a scaled integer: the value is `digits / 10^scale`. */ +export interface ScaledDecimal { + readonly digits: bigint; + readonly scale: number; +} + +const DECIMAL_SPELLING = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/; + +/** + * The exact decimal a stored or written number spells, or `undefined` when it + * spells none. A JavaScript number is read through its shortest round-trip + * spelling (`String(0.1389)` is `'0.1389'`), which is the decimal the writer + * wrote; a string (a driver answering an exact-decimal column as text) is read + * as written. Exponent spellings are accepted. + */ +export function toScaledDecimal(value: unknown): ScaledDecimal | undefined { + let text: string; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return undefined; + text = String(value); + } else if (typeof value === 'bigint') { + text = value.toString(); + } else if (typeof value === 'string') { + text = value.trim(); + } else { + return undefined; + } + const m = DECIMAL_SPELLING.exec(text); + if (!m) return undefined; + const [, sign, int = '', frac = '', exp] = m; + if (int === '' && frac === '') return undefined; + let digits = BigInt(`${int}${frac}` || '0'); + let scale = frac.length - (exp === undefined ? 0 : Number(exp)); + if (scale < 0) { + digits *= 10n ** BigInt(-scale); + scale = 0; + } + return { digits: sign === '-' ? -digits : digits, scale }; +} + +const abs = (n: bigint): bigint => (n < 0n ? -n : n); + +/** + * `numerator / denominator`, rounded to an integer half away from zero — + * the one rounding step of the projection. + */ +function divideRoundHalfAwayFromZero(numerator: bigint, denominator: bigint): bigint { + const negative = (numerator < 0n) !== (denominator < 0n); + const n = abs(numerator); + const d = abs(denominator); + let q = n / d; + if ((n % d) * 2n >= d) q += 1n; + return negative ? -q : q; +} + +/** + * `amount ÷ rate`, rounded once, half away from zero, to `places` decimals — + * parameter 3 as arithmetic. Both operands are exact; so is every + * intermediate. + */ +export function divideToPlaces(amount: ScaledDecimal, rate: ScaledDecimal, places: number): ScaledDecimal { + // (a / 10^as) / (r / 10^rs) × 10^places = a × 10^(rs + places) / (r × 10^as) + const numerator = amount.digits * 10n ** BigInt(rate.scale + places); + const denominator = rate.digits * 10n ** BigInt(amount.scale); + return { digits: divideRoundHalfAwayFromZero(numerator, denominator), scale: places }; +} + +/** The canonical decimal spelling of a scaled decimal (`-1234.50` for `{ -123450n, 2 }`). */ +export function formatScaledDecimal(value: ScaledDecimal): string { + const text = abs(value.digits).toString().padStart(value.scale + 1, '0'); + const body = value.scale === 0 ? text : `${text.slice(0, -value.scale)}.${text.slice(-value.scale)}`; + return value.digits < 0n ? `-${body}` : body; +} + +/** + * The number the home field stores: the rounded decimal's own spelling read + * back as a number. A currency value is a bare number (ADR-0104 D1); the + * arithmetic that produced it never touched a float. + */ +function toStoredNumber(value: ScaledDecimal): number { + const n = Number(formatScaledDecimal(value)); + return Object.is(n, -0) ? 0 : n; +} + +// ── The document date ───────────────────────────────────────────────────── + +const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * The calendar date a document-date value names, as `YYYY-MM-DD`, or + * `undefined` when it names none. A `date` value is its own calendar date, and + * so is a bare `YYYY-MM-DD` spelling of any value. An instant (a `datetime` + * value) is given `timezone`, the organization's `localization.timezone` + * (IANA), and its calendar date is taken there (ruling 6539345871, letter B): + * an invoice written at 01:00 in Shanghai is dated that Shanghai day. It is + * the organization's setting and never the writer's request zone, so every + * write path (a REST write, an import, a flow) picks the same rate for the + * same stored instant. No timezone, or one `Intl` does not know, reads UTC, + * the `localization.timezone` default — the `autonumber-format` reading. + */ +export function documentDateOf(value: unknown, timezone?: string): string | undefined { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : calendarDayIn(value, timezone); + if (typeof value !== 'string') return undefined; + const text = value.trim(); + if (CALENDAR_DATE.test(text)) return text; + const ms = Date.parse(text); + return Number.isNaN(ms) ? undefined : calendarDayIn(new Date(ms), timezone); +} + +/** The `YYYY-MM-DD` calendar day of `instant` in `timezone` (IANA), UTC when absent or unknown. */ +function calendarDayIn(instant: Date, timezone: string | undefined): string { + if (timezone && timezone !== 'UTC') { + try { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(instant); + const y = parts.find((p) => p.type === 'year')?.value; + const m = parts.find((p) => p.type === 'month')?.value; + const d = parts.find((p) => p.type === 'day')?.value; + if (y && m && d) return `${y}-${m}-${d}`; + } catch { + // An unknown zone reads UTC, below. + } + } + return instant.toISOString().slice(0, 10); +} + +// ── What the projection reads ───────────────────────────────────────────── + +/** The rate row the projection picked. */ +export interface ExchangeRateRow { + readonly rate: unknown; + readonly effectiveDate: string | undefined; +} + +/** + * What a projection reads from outside the row, for one organization. The + * engine supplies it per write, memoized, so a batch of N rows in one + * currency and date asks the rate table once. + */ +export interface HomeAmountServing { + /** The organization's `localization.currency`, or `undefined` when unset. */ + homeCurrency(organizationId: string | undefined): Promise; + /** The organization's `localization.timezone` (IANA), or `undefined` when unset (read as UTC). */ + timezone(organizationId: string | undefined): Promise; + /** The `currency` row with the latest `effective_date` on or before `onOrBefore`, or `undefined`. */ + rateOn(organizationId: string | undefined, currency: string, onOrBefore: string): Promise; +} + +/** An organization's two localization settings the projection reads; each `undefined` when unset. */ +export interface HomeLocalization { + /** `localization.currency`: the home currency. */ + readonly currency?: string; + /** `localization.timezone` (IANA): the zone a `datetime` document date is dated in. */ + readonly timezone?: string; +} + +/** Reads an organization's {@link HomeLocalization}; the engine's host bridge to the settings service. */ +export type HomeLocalizationResolver = (organizationId: string | undefined) => Promise; + +/** Wrap a serving so each (organization, currency, date) and each organization's home currency and timezone are read once. */ +export function memoizeHomeAmountServing(serving: HomeAmountServing): HomeAmountServing { + const homes = new Map>(); + const zones = new Map>(); + const rates = new Map>(); + return { + homeCurrency(organizationId) { + const key = organizationId ?? ''; + let hit = homes.get(key); + if (!hit) { + hit = serving.homeCurrency(organizationId); + homes.set(key, hit); + } + return hit; + }, + timezone(organizationId) { + const key = organizationId ?? ''; + let hit = zones.get(key); + if (!hit) { + hit = serving.timezone(organizationId); + zones.set(key, hit); + } + return hit; + }, + rateOn(organizationId, currency, onOrBefore) { + const key = `${organizationId ?? ''}\u0000${currency}\u0000${onOrBefore}`; + let hit = rates.get(key); + if (!hit) { + hit = serving.rateOn(organizationId, currency, onOrBefore); + rates.set(key, hit); + } + return hit; + }, + }; +} + +// ── The refusals ────────────────────────────────────────────────────────── + +const verb = (operation: 'insert' | 'update') => (operation === 'insert' ? 'Insert' : 'Update'); +const rowPhrase = (recordId: unknown) => (recordId === undefined || recordId === null ? '' : ` (record ${String(recordId)})`); + +/** + * `ERR_EXCHANGE_RATE_MISSING` / 400 — no rate row for the record's currency + * is in force on its document date (ADR-0144 D5, Open item 9). Identified by + * `code`, which survives crossing package boundaries. + */ +export class ExchangeRateMissingError extends Error { + readonly code = EXCHANGE_RATE_MISSING_CODE; + readonly status = 400; + /** The same number under ADR-0112 D5's spelling. */ + readonly httpStatus = 400; + /** The object written. */ + readonly object: string; + /** The amount that could not be converted. */ + readonly field: string; + /** Its currency: the code of the rate row that is missing. */ + readonly currency: string; + /** The document date, `YYYY-MM-DD`: no row is in force on or before it. */ + readonly date: string; + /** The organization's home currency the amount was to be converted into. */ + readonly homeCurrency: string; + + constructor( + object: string, + operation: 'insert' | 'update', + entry: HomeAmountPlanEntry, + currency: string, + date: string, + homeCurrency: string, + recordId?: unknown, + ) { + super( + `${verb(operation)} on '${object}'${rowPhrase(recordId)} was REFUSED: '${entry.amountField}' is in ${currency} ` + + `and is converted into the home currency ${homeCurrency} at the rate in force on its document date ` + + `('${entry.rateDateField}' = ${date}), and no exchange rate for ${currency} has an effective date on or ` + + `before ${date}. The row was not written. Add an exchange rate for ${currency} effective on or before ` + + `${date} (Setup → Exchange Rates; a rate states how many ${currency} one ${homeCurrency} buys), then write ` + + `again. A missing rate is never replaced by 1, by a later rate, or by an empty home amount.`, + ); + this.name = 'ExchangeRateMissingError'; + this.object = object; + this.field = entry.amountField; + this.currency = currency; + this.date = date; + this.homeCurrency = homeCurrency; + } +} + +/** + * `ERR_HOME_CURRENCY_UNSET` / 400 — the organization has no home currency, so + * an amount declaring a home amount cannot be converted (ADR-0144 Open item + * 4). Identified by `code`. + */ +export class HomeCurrencyUnsetError extends Error { + readonly code = HOME_CURRENCY_UNSET_CODE; + readonly status = 400; + /** The same number under ADR-0112 D5's spelling. */ + readonly httpStatus = 400; + /** The object written. */ + readonly object: string; + /** The amount whose home amount could not be computed. */ + readonly field: string; + /** The setting to fill, `namespace.key`. */ + readonly setting = 'localization.currency'; + + constructor(object: string, operation: 'insert' | 'update', entry: HomeAmountPlanEntry, held: string | undefined, recordId?: unknown) { + const state = held === undefined + ? 'the organization has no home currency: the setting Localization → Default currency (localization.currency) is not set' + : `the organization's home currency setting Localization → Default currency (localization.currency) holds '${held}', which is not an ISO 4217 currency code`; + super( + `${verb(operation)} on '${object}'${rowPhrase(recordId)} was REFUSED: '${entry.amountField}' stores its amount ` + + `converted into the home currency in '${entry.homeField}', and ${state}. The row was not written. ` + + `Set Localization → Default currency to the organization's home currency (for example USD), then write ` + + `again. The platform never assumes a home currency.`, + ); + this.name = 'HomeCurrencyUnsetError'; + this.object = object; + this.field = entry.amountField; + } +} + +/** Is this one of the projection's own refusals? */ +export function isHomeAmountRefusal(e: unknown): e is ExchangeRateMissingError | HomeCurrencyUnsetError { + return e instanceof ExchangeRateMissingError || e instanceof HomeCurrencyUnsetError; +} + +// ── The projection ──────────────────────────────────────────────────────── + +const isIsoCurrency = (code: string): boolean => isValueDomainMember('iso_4217_currency', code); + +/** Where a projection runs: whose organization, which operation, which record. */ +export interface HomeAmountWrite { + readonly object: string; + readonly operation: 'insert' | 'update'; + readonly organizationId: string | undefined; + readonly recordId?: unknown; +} + +/** + * Project every entry of `plan` over one row `image` and answer the values to + * write: for each entry, its home field and its rate field. Reads through + * `serving`; never mutates `image`. + * + * An entry whose inputs another check refuses is left out, so that check + * answers instead of a rate lookup over a value that will never be stored: a + * blank code (`ERR_CURRENCY_CODE_REQUIRED`), a code that is not ISO 4217 or + * an amount or document date that does not parse (the record validator). + * + * @throws HomeCurrencyUnsetError no usable `localization.currency` + * @throws ExchangeRateMissingError no rate row in force on the document date + * @throws ValidationError a foreign amount with a blank document date + */ +export async function projectHomeAmounts( + plan: readonly HomeAmountPlanEntry[], + image: Record, + serving: HomeAmountServing, + write: HomeAmountWrite, +): Promise> { + const out: Record = {}; + for (const entry of plan) { + const amountValue = image[entry.amountField]; + if (isRecordCurrencyBlank(amountValue)) { + out[entry.homeField] = null; + out[entry.rateField] = null; + continue; + } + const amount = toScaledDecimal(amountValue); + const codeValue = image[entry.codeField]; + if (amount === undefined || isRecordCurrencyBlank(codeValue) || typeof codeValue !== 'string') continue; + const code = codeValue.trim(); + if (!isIsoCurrency(code)) continue; + + const homeValue = await serving.homeCurrency(write.organizationId); + const home = typeof homeValue === 'string' && homeValue.trim() !== '' ? homeValue.trim() : undefined; + if (home === undefined || !isIsoCurrency(home)) { + throw new HomeCurrencyUnsetError(write.object, write.operation, entry, home, write.recordId); + } + const places = CURRENCY_FRACTION_DIGITS[home]!; + + if (code === home) { + // Open item 6: the same currency as home converts at 1, with no lookup. + out[entry.homeField] = toStoredNumber(divideToPlaces(amount, { digits: 1n, scale: 0 }, places)); + out[entry.rateField] = 1; + continue; + } + + const dateValue = image[entry.rateDateField]; + if (isRecordCurrencyBlank(dateValue)) { + throw new ValidationError([{ + field: entry.rateDateField, + code: 'required', + message: `'${entry.rateDateField}' is required to convert '${entry.amountField}' (${code}) into the home ` + + `currency ${home}: the exchange rate is the one in force on this date`, + }]); + } + const date = documentDateOf(dateValue, entry.rateDateIsInstant ? await serving.timezone(write.organizationId) : undefined); + if (date === undefined) continue; + + const row = await serving.rateOn(write.organizationId, code, date); + const rate = row ? toScaledDecimal(row.rate) : undefined; + if (!row || rate === undefined || rate.digits <= 0n) { + throw new ExchangeRateMissingError(write.object, write.operation, entry, code, date, home, write.recordId); + } + out[entry.homeField] = toStoredNumber(divideToPlaces(amount, rate, places)); + out[entry.rateField] = typeof row.rate === 'number' ? row.rate : Number(formatScaledDecimal(rate)); + } + return out; +} + +/** + * The one patch a PREDICATE update can store for the projected fields of the + * entries it touches, judged over every matched row's projection. `updateMany` + * writes ONE patch to all of them, so a projected field is set only when it + * comes out the same on every matched row (the payload moved every row to one + * currency, amount and date, say). A patch that would project different + * values on different rows cannot be one statement, and is refused before + * anything is written: update those rows by id, where each is projected from + * its own stored row. A caller's value for a projected field never stands + * either way — the stored-expression rule for a predicate update, applied to + * the home projection. + */ +export function settleBulkHomeAmounts( + touched: readonly HomeAmountPlanEntry[], + patch: Record, + projected: ReadonlyArray>, + object: string, +): Record { + const out: Record = { ...patch }; + const refused: Array<{ field: string; code: 'invalid_value'; message: string }> = []; + for (const entry of touched) { + for (const name of [entry.homeField, entry.rateField]) { + delete out[name]; + if (projected.length === 0) continue; + const first = projected[0]![name]; + if (projected.every((values) => Object.is(values[name], first))) { + if (first !== undefined) out[name] = first; + continue; + } + refused.push({ + field: name, + code: 'invalid_value', + message: `'${name}' on '${object}' is the engine's conversion of '${entry.amountField}' into the home currency, ` + + 'and this update moves its inputs on rows where it then converts to different values, which one update ' + + 'of all the matched rows cannot store; update those rows by id, so each is converted from its own row', + }); + } + } + if (refused.length > 0) throw new ValidationError(refused); + return out; +} diff --git a/packages/objectql/src/home-amount-write-paths.test.ts b/packages/objectql/src/home-amount-write-paths.test.ts new file mode 100644 index 00000000000..db0b5b1165c --- /dev/null +++ b/packages/objectql/src/home-amount-write-paths.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D5] Import and REST writes reach the home projection through the +// one engine path, on a booted kernel: `ObjectQLPlugin` with its settings +// bridge (the home currency is the `settings` service's +// `localization.currency`, asked for the row's organization), the real +// protocol, and the shared import runner. +// +// The reading this pins: the REST data routes write through the protocol's +// `createData` / `updateData` (`packages/rest/src/crud-routes.ts`), which call +// `engine.insert` / `engine.update`; an import (`POST …/import` and the +// import-job worker, both `runImport`) writes through the protocol's +// `insertManyData` (batched, `engine.insertMany` → `engine.insert`) or +// `createData` / `updateData` (per row). Every one of them lands in the same +// insert or update seam, so every one of them converts, and refuses, alike. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectKernel, runImport, type ImportProtocolLike } from '@objectstack/core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQLPlugin } from './plugin.js'; +import { ObjectQL } from './engine.js'; +import { applyDriverQueryInMemory } from './driver-query-in-memory.js'; + +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const select = (o: string, ast: any) => + applyDriverQueryInMemory(Array.from(storeFor(o).values()), { where: ast?.where, orderBy: ast?.orderBy, limit: ast?.limit, offset: ast?.offset }); + let n = 0; + const driver: any = { + name: 'fx-store', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async syncSchema() {}, + async find(o: string, ast: any) { return select(o, ast).map((r) => ({ ...r })); }, + async findOne(o: string, ast: any) { const [r] = select(o, { ...ast, limit: 1 }); return r ? { ...r } : null; }, + async create(o: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `rec_${n}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return { ...row }; + }, + async update(o: string, id: string, data: Record) { + const row = { ...storeFor(o).get(id), ...data, id }; + storeFor(o).set(id, row); + return { ...row }; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { return select(o, ast).length; }, + }; + return { driver, storeFor }; +} + +const OBJECT = 'fx_invoice'; +const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }; +const FIELDS = { + ref: { name: 'ref', type: 'text' }, + currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }, + invoice_date: { name: 'invoice_date', type: 'date' }, + amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } }, + amount_home: { name: 'amount_home', type: 'currency', readonly: true }, + amount_rate: { name: 'amount_rate', type: 'number', readonly: true }, +}; + +describe('import and REST writes convert through the same engine path', () => { + let kernel: ObjectKernel; + let objectql: ObjectQL; + let stub: ReturnType; + /** What the settings service was asked, and what it answers. */ + let settingsAsked: Array<{ ns: string; key: string | string[]; tenantId?: unknown }>; + let home: string | undefined; + let timezone: string | undefined; + + /** `grouped`: the settings service offers `getMany` (the real one does); otherwise `get` alone. */ + async function bootKernel(withSettings: boolean, grouped = true) { + settingsAsked = []; + home = 'CNY'; + timezone = undefined; + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + stub = makeStubDriver(); + await kernel.use({ + name: 'fx-store-plugin', type: 'driver', version: '1.0.0', + init: async (ctx: any) => { ctx.registerService('driver.fx-store', stub.driver); }, + } as any); + if (withSettings) { + await kernel.use({ + name: 'fake-settings', type: 'standard', version: '1.0.0', + init: async (ctx: any) => { + const value = (key: string) => (key === 'currency' ? home : key === 'timezone' ? timezone : undefined); + ctx.registerService('settings', { + async get(ns: string, key: string, c?: { tenantId?: unknown }) { + settingsAsked.push({ ns, key, tenantId: c?.tenantId }); + return { value: value(key) }; + }, + ...(grouped ? { + async getMany(ns: string, keys: string[], c?: { tenantId?: unknown }) { + settingsAsked.push({ ns, key: keys, tenantId: c?.tenantId }); + return Object.fromEntries(keys.map((k) => [k, { value: value(k) }])); + }, + } : {}), + }); + }, + } as any); + } + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + objectql = kernel.getService('objectql'); + objectql.registry.registerObject({ + name: 'sys_exchange_rate', datasource: 'fx-store', + fields: { + currency: { name: 'currency', type: 'text', valueDomain: 'iso_4217_currency' }, + rate: { name: 'rate', type: 'number', scale: 12, precision: 24 }, + effective_date: { name: 'effective_date', type: 'date' }, + }, + } as any, 'test', 'test'); + objectql.registry.registerObject({ name: OBJECT, label: 'FX invoice', datasource: 'fx-store', fields: FIELDS } as any, 'test', 'test'); + stub.storeFor('sys_exchange_rate').set('usd', { id: 'usd', currency: 'USD', effective_date: '2026-01-01', rate: 0.1389 }); + } + + afterEach(async () => { + if (kernel?.getState() === 'running') await kernel.shutdown(); + }); + + const storedByRef = (ref: string) => { + const hits = Array.from(stub.storeFor(OBJECT).values()).filter((r) => r.ref === ref); + expect(hits.length, `one stored row for ${ref}`).toBe(1); + return hits[0]!; + }; + const protocol = () => new ObjectStackProtocolImplementation(objectql as never) as unknown as ImportProtocolLike & { + createData(a: unknown): Promise; updateData(a: unknown): Promise; + }; + const perRow = (): ImportProtocolLike => { + const impl = protocol(); + return { findData: (a) => impl.findData(a), createData: (a) => impl.createData(a), updateData: (a) => impl.updateData(a) }; + }; + const metaMap = new Map(Object.entries(FIELDS)); + const importRows = (p: ImportProtocolLike, rows: Array>, over: Record = {}) => runImport({ + p, objectName: OBJECT, metaMap, writeMode: 'insert', matchFields: ['ref'], dryRun: false, runAutomations: false, + trimWhitespace: true, skipBlankMatchKey: false, context: { userId: 'usr_importer', tenantId: 'org_a' }, rows, ...over, + } as any); + + describe('with the settings service composed', () => { + beforeEach(async () => { await bootKernel(true); }); + + it('the REST door (protocol createData / updateData) converts on create and on an amount change', async () => { + const p = protocol(); + await p.createData({ object: OBJECT, data: { ref: 'rest', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, context: { userId: 'u1', tenantId: 'org_a' } }); + expect(storedByRef('rest')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + await p.updateData({ object: OBJECT, id: storedByRef('rest').id, data: { amount: 1389 }, context: { userId: 'u1', tenantId: 'org_a' } }); + expect(storedByRef('rest')).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + // The home currency and timezone were asked of the settings service in + // one grouped read, for the row's organization. + expect(settingsAsked).toContainEqual({ ns: 'localization', key: ['currency', 'timezone'], tenantId: 'org_a' }); + expect(settingsAsked.every((a) => Array.isArray(a.key))).toBe(true); + }); + + for (const [label, p] of [['batched (insertManyData)', protocol], ['per-row (createData)', perRow]] as const) { + it(`an import, ${label}, converts each row and refuses a row with no rate in force`, async () => { + const summary = await importRows(p(), [ + { ref: 'a', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }, + { ref: 'b', currency_code: 'CNY', amount: '12.345' }, + { ref: 'c', currency_code: 'USD', invoice_date: '2025-06-30', amount: '1' }, + ]); + expect(summary.results.map((r: any) => r.ok)).toEqual([true, true, false]); + expect(JSON.stringify(summary.results[2])).toContain('ERR_EXCHANGE_RATE_MISSING'); + expect(storedByRef('a')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(storedByRef('b')).toMatchObject({ amount_home: 12.35, amount_rate: 1 }); + expect(Array.from(stub.storeFor(OBJECT).values()).some((r) => r.ref === 'c')).toBe(false); + }); + } + + it('a historical (preserveAudit) update-mode import of the home column is recomputed over, not kept', async () => { + await importRows(protocol(), [{ ref: 'h', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }]); + const summary = await importRows(perRow(), [{ ref: 'h', amount_home: '1', amount_rate: '9' }], { + writeMode: 'update', treatAsHistorical: true, + }); + expect(summary.results.map((r: any) => r.ok)).toEqual([true]); + expect(storedByRef('h')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('a datetime document date is dated in the organization\'s localization.timezone through the REST door', async () => { + timezone = 'Asia/Shanghai'; + objectql.registry.registerObject({ + name: 'fx_order', label: 'FX order', datasource: 'fx-store', + fields: { ...FIELDS, invoice_date: { name: 'invoice_date', type: 'datetime' } }, + } as any, 'test', 'test'); + stub.storeFor('sys_exchange_rate').set('usd_0316', { id: 'usd_0316', currency: 'USD', effective_date: '2026-03-16', rate: 0.15 }); + // 2026-03-15T17:00Z is 01:00 on the 16th in Shanghai. + const created = await protocol().createData({ object: 'fx_order', data: { ref: 'o', currency_code: 'USD', invoice_date: '2026-03-15T17:00:00Z', amount: 1500 }, context: { userId: 'u1', tenantId: 'org_a' } }); + const stored = Array.from(stub.storeFor('fx_order').values()).find((r) => r.ref === 'o'); + expect(stored, JSON.stringify(created)).toMatchObject({ amount_home: 10000, amount_rate: 0.15 }); + }); + + it('an unset localization.currency refuses the REST write ERR_HOME_CURRENCY_UNSET', async () => { + home = undefined; + const err = await protocol().createData({ object: OBJECT, data: { ref: 'x', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }, context: { userId: 'u1' } }) + .then(() => null, (e: { code?: string }) => e); + expect(err?.code).toBe('ERR_HOME_CURRENCY_UNSET'); + expect(stub.storeFor(OBJECT).size).toBe(0); + }); + }); + + it('a settings service without getMany is asked key by key, and converts alike', async () => { + await bootKernel(true, false); + await protocol().createData({ object: OBJECT, data: { ref: 'k', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, context: { userId: 'u1', tenantId: 'org_a' } }); + expect(storedByRef('k')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(settingsAsked).toEqual([ + { ns: 'localization', key: 'currency', tenantId: 'org_a' }, + { ns: 'localization', key: 'timezone', tenantId: 'org_a' }, + ]); + }); + + it('with no settings service composed, the home currency is unset — refused, never defaulted', async () => { + await bootKernel(false); + const err = await protocol().createData({ object: OBJECT, data: { ref: 'x', currency_code: 'CNY', amount: 1 }, context: { userId: 'u1' } }) + .then(() => null, (e: { code?: string }) => e); + expect(err?.code).toBe('ERR_HOME_CURRENCY_UNSET'); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 1e264be2d2d..18fc75b5c3d 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -1340,6 +1340,51 @@ export class ObjectQLPlugin implements Plugin { error: e.message, }); } + + // [ADR-0144 D4, Open item 4] The localization the write-time home + // projection reads: the organization's `localization.currency` (the + // home currency it converts into) and `localization.timezone` (the + // zone a `datetime` document date is dated in; ruling 6539345871, + // letter B), in one grouped `getMany` read. The settings service is + // resolved when a write asks, not here, so plugin order cannot leave + // the projection without it. No settings service, or a `localization` + // namespace or key not registered, is an unset setting — an unset home + // currency is refused, never defaulted, and an unset timezone reads + // UTC; a read that FAILS is the write's failure, not an unset setting. + const isUnregisteredSetting = (e: any) => e?.code === 'SETTINGS_UNKNOWN_NAMESPACE' || e?.code === 'SETTINGS_UNKNOWN_KEY'; + const settingText = (resolved: { value?: unknown } | undefined) => (typeof resolved?.value === 'string' ? resolved.value : undefined); + this.ql.setHomeLocalizationResolver(async (organizationId) => { + let settings: { + get?: (ns: string, key: string, c?: Record) => Promise<{ value?: unknown } | undefined>; + getMany?: (ns: string, keys: string[], c?: Record) => Promise>; + } | undefined; + try { + settings = ctx.getService('settings') as typeof settings; + } catch { + return undefined; + } + if (!settings || typeof settings.get !== 'function') return undefined; + const scope = organizationId !== undefined ? { tenantId: organizationId } : {}; + if (typeof settings.getMany === 'function') { + try { + const many = await settings.getMany('localization', ['currency', 'timezone'], scope); + return { currency: settingText(many?.currency), timezone: settingText(many?.timezone) }; + } catch (e: any) { + // `getMany` is all-or-nothing: one unregistered key refuses + // both, so each is asked on its own below. + if (!isUnregisteredSetting(e)) throw e; + } + } + const one = async (key: string) => { + try { + return settingText(await settings!.get!('localization', key, scope)); + } catch (e: any) { + if (isUnregisteredSetting(e)) return undefined; + throw e; + } + }; + return { currency: await one('currency'), timezone: await one('timezone') }; + }); } // Initialize drivers (calls driver.connect() which sets up persistence) diff --git a/packages/qa/dogfood/test/home-amount-projection.test.ts b/packages/qa/dogfood/test/home-amount-projection.test.ts new file mode 100644 index 00000000000..666bd1d3233 --- /dev/null +++ b/packages/qa/dogfood/test/home-amount-projection.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D5, D7 parameters 3 and 4, Open items 2, 3 and 6] The write-time +// home projection's rounding pins on the real SQL drivers, with the platform's +// own `sys_exchange_rate` object: the rate row is chosen by each dialect's +// `effective_date <= ? ORDER BY effective_date DESC LIMIT 1`, read back +// through each dialect's exact-decimal column, and the home amount is stored +// and read back through each dialect's currency column. +// +// Cells: better-sqlite3 and sqlite-wasm always run; PostgreSQL runs where +// `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. The Postgres +// cell owns one schema, named from this file, created before and dropped +// after. One pin runs under the `isolated` posture with two organizations: a +// rate row is read in the written row's organization only. The in-memory face of the same pins (and the strips, defaults, +// preview and predicate-update cells) is packages/objectql's +// `engine-home-amount-projection.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { SysExchangeRate } from '@objectstack/platform-objects/system'; +import { installDefaultOrganization, DEFAULT_ORGANIZATION_ID } from './default-organization.testkit.js'; + +const LIVE_NAMESPACE = `os_lv_${basename(fileURLToPath(import.meta.url)).replace(/\.test\.ts$/, '').replace(/[^a-z0-9]+/gi, '_')}`; +const PG_URL = process.env.OS_TEST_POSTGRES_URL; + +async function adminRaw(config: Record, statements: string[]): Promise { + const admin = new SqlDriver(config as never); + try { + for (const statement of statements) await admin.execute(statement); + } finally { + await admin.disconnect(); + } +} + +interface Cell { + readonly id: string; + readonly env: string | null; + readonly url: string | undefined; + readonly make: () => Promise; + readonly teardown: () => Promise; +} + +const CELLS: readonly Cell[] = [ + { + id: 'driver-sql (better-sqlite3 :memory:)', + env: null, + url: 'sqlite', + make: async () => new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + teardown: async () => {}, + }, + { + id: 'driver-sqlite-wasm (:memory:)', + env: null, + url: 'sqlite-wasm', + make: async () => new SqliteWasmDriver({ filename: ':memory:' }), + teardown: async () => {}, + }, + { + id: 'driver-sql (pg)', + env: 'OS_TEST_POSTGRES_URL', + url: PG_URL, + make: async () => { + await adminRaw({ client: 'pg', connection: PG_URL }, [ + `drop schema if exists ${LIVE_NAMESPACE} cascade`, + `create schema ${LIVE_NAMESPACE}`, + ]); + return new SqlDriver({ client: 'pg', connection: PG_URL, searchPath: [LIVE_NAMESPACE] } as never); + }, + teardown: async () => { + await adminRaw({ client: 'pg', connection: PG_URL }, [`drop schema if exists ${LIVE_NAMESPACE} cascade`]); + }, + }, +]; + +const SYS = { context: { isSystem: true } }; + +const FX_INVOICE = { + name: 'fx_invoice', + label: 'FX invoice', + fields: { + name: { name: 'name', type: 'text' }, + currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }, + invoice_date: { name: 'invoice_date', type: 'date' }, + amount: { + name: 'amount', + type: 'currency', + currencyConfig: { + currencyMode: 'record', + currencyField: 'currency_code', + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, + }, + }, + amount_home: { name: 'amount_home', type: 'currency', readonly: true }, + amount_rate: { name: 'amount_rate', type: 'number', readonly: true }, + }, +}; + +/** The same amount, dated by a `datetime`: its calendar day is taken in the organization's timezone. */ +const FX_ORDER = { + ...FX_INVOICE, + name: 'fx_order', + label: 'FX order', + fields: { ...FX_INVOICE.fields, invoice_date: { name: 'invoice_date', type: 'datetime' } }, +}; + +const appWith = (objects: unknown[]) => ({ + id: 'com.objectstack.dogfood.home-amount-projection', + name: 'Home amount projection', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects, +}); + +/** Each rate as `[currency, effective_date, rate]`; every currency's rate against the home currency of the test. */ +const RATES: ReadonlyArray<[string, string, number]> = [ + ['USD', '2026-01-01', 0.1389], + ['USD', '2026-04-01', 0.15], + ['USD', '2026-05-01', 7.123456789012], // twelve decimals, the column's scale + ['EUR', '2026-01-01', 0.0065], // read as JPY-home in the minor-unit cell + ['GBP', '2026-01-01', 1.0857], + ['CHF', '2026-01-01', 3.2468], +]; + +for (const cell of CELLS) { + describe.skipIf(!cell.url)( + `the write-time home projection — ${cell.id}${cell.url ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: SqlDriver; + let engine: ObjectQL; + let home = 'CNY'; + let timezone: string | undefined; + + beforeAll(async () => { + driver = await cell.make(); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registerApp(appWith([SysExchangeRate, FX_INVOICE, FX_ORDER])); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + engine.setHomeLocalizationResolver(async () => ({ currency: home, timezone })); + for (const [currency, effective_date, rate] of RATES) { + await engine.insert('sys_exchange_rate', { currency, effective_date, rate, source: 'manual' }, SYS); + } + }); + + afterAll(async () => { + await engine?.destroy(); + await cell.teardown(); + }); + + /** Insert one invoice and read it back from the store. */ + async function written(row: Record) { + const created = await engine.insert('fx_invoice', { name: 'inv', ...row }, SYS); + return read(created.id); + } + async function read(id: unknown) { + const back = await engine.findOne('fx_invoice', { where: { id }, context: SYS.context } as never) as Record; + return { home: Number(back.amount_home), rate: Number(back.amount_rate), org: back.organization_id }; + } + + it('the card\'s check: USD 10,000 at 0.1389 into CNY stores 71994.24 with the rate 0.1389', async () => { + home = 'CNY'; + expect(await written({ currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 })) + .toEqual({ home: 71994.24, rate: 0.1389, org: DEFAULT_ORGANIZATION_ID }); + }); + + it('three minor-unit classes round half away from zero: JPY 0, USD 2, KWD 3', async () => { + home = 'JPY'; + expect(await written({ currency_code: 'EUR', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 15385, rate: 0.0065 }); // 15384.615… + home = 'USD'; + expect(await written({ currency_code: 'GBP', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 92.11, rate: 1.0857 }); // 92.1064… + home = 'KWD'; + expect(await written({ currency_code: 'CHF', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 30.8, rate: 3.2468 }); // 30.79955… + home = 'CNY'; + }); + + it('a twelve-decimal rate survives the column and divides exactly', async () => { + // 1000 ÷ 7.123456789012 = 140.381282… + expect(await written({ currency_code: 'USD', invoice_date: '2026-05-02', amount: 1000 })).toMatchObject({ home: 140.38, rate: 7.123456789012 }); + }); + + it('the same currency as home is rate 1, rounded to the home minor units', async () => { + home = 'CNY'; + expect(await written({ currency_code: 'CNY', amount: 12.345 })).toMatchObject({ home: 12.35, rate: 1 }); + // 1.005 is 1.00499999… as a double: a float rounding stores 1.00, the decimal one 1.01. + expect(await written({ currency_code: 'CNY', amount: 1.005 })).toMatchObject({ home: 1.01, rate: 1 }); + }); + + it('the rate row is the latest on or before the document date; none ⇒ refused, nothing stored', async () => { + home = 'CNY'; + expect(await written({ currency_code: 'USD', invoice_date: '2026-03-31', amount: 150 })).toMatchObject({ home: 1079.91, rate: 0.1389 }); + expect(await written({ currency_code: 'USD', invoice_date: '2026-04-01', amount: 150 })).toMatchObject({ home: 1000, rate: 0.15 }); + const before = (await engine.find('fx_invoice', { context: SYS.context } as never)).length; + const err = await engine.insert('fx_invoice', { name: 'early', currency_code: 'USD', invoice_date: '2025-12-31', amount: 1 }, SYS) + .then(() => null, (e: { code?: string; status?: number; currency?: string; date?: string }) => e); + expect({ code: err?.code, status: err?.status, currency: err?.currency, date: err?.date }) + .toEqual({ code: 'ERR_EXCHANGE_RATE_MISSING', status: 400, currency: 'USD', date: '2025-12-31' }); + expect((await engine.find('fx_invoice', { context: SYS.context } as never)).length).toBe(before); + }); + + it('rates are per organization: one organization\'s rate row never converts another\'s write', async () => { + home = 'CNY'; + // Organizations are walled from each other under the `isolated` posture; + // `single` is one organization by definition and reads install-wide. + engine.setTenancyPostureProvider(() => 'isolated'); + try { + const driverOf = engine.getDriverForObject('sys_organization')!; + for (const id of ['org_fx_a', 'org_fx_b']) { + if (!(await driverOf.findOne('sys_organization', { where: { id } } as never))) await driverOf.create('sys_organization', { id, name: id }); + } + const as = (org: string) => ({ context: { isSystem: true, tenantId: org } }); + // SEK has a rate in organization A only; NOK in both, at different rates. + await engine.insert('sys_exchange_rate', { currency: 'SEK', effective_date: '2026-01-01', rate: 1.5, source: 'manual' }, as('org_fx_a')); + await engine.insert('sys_exchange_rate', { currency: 'NOK', effective_date: '2026-01-01', rate: 1.6, source: 'manual' }, as('org_fx_a')); + await engine.insert('sys_exchange_rate', { currency: 'NOK', effective_date: '2026-01-01', rate: 2, source: 'manual' }, as('org_fx_b')); + + const inA = await engine.insert('fx_invoice', { name: 'a', currency_code: 'SEK', invoice_date: '2026-03-15', amount: 300 }, as('org_fx_a')); + expect(await read(inA.id)).toEqual({ home: 200, rate: 1.5, org: 'org_fx_a' }); + const nokA = await engine.insert('fx_invoice', { name: 'a2', currency_code: 'NOK', invoice_date: '2026-03-15', amount: 320 }, as('org_fx_a')); + expect(await read(nokA.id)).toEqual({ home: 200, rate: 1.6, org: 'org_fx_a' }); + const nokB = await engine.insert('fx_invoice', { name: 'b', currency_code: 'NOK', invoice_date: '2026-03-15', amount: 320 }, as('org_fx_b')); + expect(await read(nokB.id)).toEqual({ home: 160, rate: 2, org: 'org_fx_b' }); + + // Organization B has no SEK rate: A's row is not read for it, so the write is refused. + const err = await engine.insert('fx_invoice', { name: 'b2', currency_code: 'SEK', invoice_date: '2026-03-15', amount: 300 }, as('org_fx_b')) + .then(() => null, (e: { code?: string }) => e); + expect(err?.code).toBe('ERR_EXCHANGE_RATE_MISSING'); + } finally { + engine.setTenancyPostureProvider(() => undefined); + } + }); + + it('a datetime document date is dated in localization.timezone, through the column and back', async () => { + home = 'CNY'; + // 2026-03-31T17:00Z is 01:00 on 2026-04-01 in Shanghai; USD has a new rate from April 1. + const at0100Shanghai = '2026-03-31T17:00:00Z'; + const readOrder = async (id: unknown) => { + const back = await engine.findOne('fx_order', { where: { id }, context: SYS.context } as never) as Record; + return { home: Number(back.amount_home), rate: Number(back.amount_rate) }; + }; + try { + timezone = 'Asia/Shanghai'; + const sh = await engine.insert('fx_order', { name: 'sh', currency_code: 'USD', invoice_date: at0100Shanghai, amount: 1500 }, SYS); + expect(await readOrder(sh.id)).toEqual({ home: 10000, rate: 0.15 }); + // An amount edit reads the stored datetime back from the column and dates it alike. + await engine.update('fx_order', { id: sh.id, amount: 3000 }, SYS); + expect(await readOrder(sh.id)).toEqual({ home: 20000, rate: 0.15 }); + timezone = undefined; + const utc = await engine.insert('fx_order', { name: 'utc', currency_code: 'USD', invoice_date: at0100Shanghai, amount: 1389 }, SYS); + expect(await readOrder(utc.id)).toEqual({ home: 10000, rate: 0.1389 }); + } finally { + timezone = undefined; + } + }); + + it('an update of the amount, the code or the date converts again; another edit does not', async () => { + home = 'CNY'; + const { id } = await engine.insert('fx_invoice', { name: 'u', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, SYS); + await engine.update('fx_invoice', { id, amount: 1389 }, SYS); + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); + await engine.update('fx_invoice', { id, invoice_date: '2026-04-15' }, SYS); + expect(await read(id)).toMatchObject({ home: 9260, rate: 0.15 }); + await engine.update('fx_invoice', { id, currency_code: 'CNY' }, SYS); + expect(await read(id)).toMatchObject({ home: 1389, rate: 1 }); + await engine.update('fx_invoice', { id, currency_code: 'USD', invoice_date: '2026-03-15' }, SYS); + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); + await engine.update('fx_invoice', { id, name: 'renamed' }, SYS); + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); + }); + }, + ); +} diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index d1858db817b..520b7adf3a5 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -99,6 +99,7 @@ "CROSS_FIELD_COMPARISON_CLASSES (const)", "CROSS_FIELD_COMPARISON_TYPE_CLASSES (const)", "CROSS_FIELD_NO_CLASS_REASONS (const)", + "CURRENCY_FRACTION_DIGITS (const)", "CalendarDateValue (type)", "CalendarDateValueSchema (const)", "ClockTimeValue (type)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 7437a2b6fad..4ec83f2a079 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -96,6 +96,7 @@ "CROSS_FIELD_COMPARISON_CLASSES": "src/data/filter-cross-field-comparison-class.ts#CROSS_FIELD_COMPARISON_CLASSES (const)", "CROSS_FIELD_COMPARISON_TYPE_CLASSES": "src/data/filter-cross-field-comparison-class.ts#CROSS_FIELD_COMPARISON_TYPE_CLASSES (const)", "CROSS_FIELD_NO_CLASS_REASONS": "src/data/filter-cross-field-comparison-class.ts#CROSS_FIELD_NO_CLASS_REASONS (const)", + "CURRENCY_FRACTION_DIGITS": "src/data/currency-fraction-digits.ts#CURRENCY_FRACTION_DIGITS (const)", "CalendarDateValue": "src/data/field-value.zod.ts#CalendarDateValue (type)", "CalendarDateValueSchema": "src/data/field-value.zod.ts#CalendarDateValueSchema (const)", "ClockTimeValue": "src/data/field-value.zod.ts#ClockTimeValue (type)", diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 3754c1e4b15..a3c4600130b 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -801,12 +801,28 @@ export const ERROR_CODE_LEDGER = { 'ERR_CURRENCY_CODE_REQUIRED', 'ERR_DATASOURCE_UNAVAILABLE', 'ERR_DRIVER_CONNECT', + // [ADR-0144 D5, Open items 2 and 9] A write would store a `record`-mode + // amount declaring `homeAmount` in a foreign currency, and no exchange rate + // for that currency is in force on the record's document date (the latest + // `effective_date` on or before it). 400; the driver is never asked to write + // the row (`ExchangeRateMissingError`, `home-amount-projection.ts`); the + // message names the currency and the date. No 1:1 rate, later rate or empty + // home amount is stored in its place. Fixed by adding a rate row. + 'ERR_EXCHANGE_RATE_MISSING', // [#8846] a `before*` hook moved or cleared the id the engine resolved a // by-id (or per-row predicate) write against — refused rather than // silently writing a different record (`HOOK_TARGET_REBIND_ERROR_CODE`, // `hook-target-rebind-errors.ts`), during a write the dispatcher is // serving. Same #8087-gate family. 'ERR_HOOK_TARGET_REBIND', + // [ADR-0144 Open item 4] A write would store a `record`-mode amount + // declaring `homeAmount`, and the organization has no usable home currency: + // `localization.currency` is unset (or holds no ISO 4217 code). 400; the + // driver is never asked to write the row (`HomeCurrencyUnsetError`, + // `home-amount-projection.ts`). The platform never assumes a home currency. + // Fixed by setting `localization.currency` — a different remedy from + // `ERR_EXCHANGE_RATE_MISSING`, hence its own code. + 'ERR_HOME_CURRENCY_UNSET', // [#5320] Third EMITTER of the code (metadata-protocol and plugin-security // already register it) — the registration loop's `views:` tighten refuses a // non-container entry, and the `viewItems:` channel refuses an entry the diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index fc388d94a58..281e4c9288b 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -180,6 +180,14 @@ export * from './api-derivation'; // so the two can never drift apart (#7521). export * from './managed-api-affordance'; export * from './field.zod'; +// [ADR-0144 Open item 3] The CLDR fraction digits per currency — the checked-in +// CLDR snapshot whose key set is the `iso_4217_currency` value domain. Its +// digit counts are CLDR's, which differ from ISO 4217's minor units for some +// codes (IQD 0 vs 3; HUF, COP, IDR, PKR 0 vs 2). Exported for its first digit +// reader: objectql's write-time home projection rounds a home amount to the +// home currency's CLDR fraction digits with it, from this one table rather +// than a second copy or a runtime `Intl` probe. +export * from './currency-fraction-digits'; // The credential read mask (ADR-0100) — the ONE string a masked read serves, in // place of the two byte-identical literals objectql and service-settings each // declared until #7572. Both now import this one, so the mask a console sees on