|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [ADR-0144 D5, D7 parameters 3 and 4, Open items 2, 3 and 6] The write-time |
| 4 | +// home projection's rounding pins on the real SQL drivers, with the platform's |
| 5 | +// own `sys_exchange_rate` object: the rate row is chosen by each dialect's |
| 6 | +// `effective_date <= ? ORDER BY effective_date DESC LIMIT 1`, read back |
| 7 | +// through each dialect's exact-decimal column, and the home amount is stored |
| 8 | +// and read back through each dialect's currency column. |
| 9 | +// |
| 10 | +// Cells: better-sqlite3 and sqlite-wasm always run; PostgreSQL runs where |
| 11 | +// `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. The Postgres |
| 12 | +// cell owns one schema, named from this file, created before and dropped |
| 13 | +// after. The in-memory face of the same pins (and the strips, defaults, |
| 14 | +// preview and predicate-update cells) is packages/objectql's |
| 15 | +// `engine-home-amount-projection.test.ts`. |
| 16 | + |
| 17 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 18 | +import { basename } from 'node:path'; |
| 19 | +import { fileURLToPath } from 'node:url'; |
| 20 | +import { ObjectQL } from '@objectstack/objectql'; |
| 21 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 22 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 23 | +import { SysExchangeRate } from '@objectstack/platform-objects/system'; |
| 24 | +import { installDefaultOrganization, DEFAULT_ORGANIZATION_ID } from './default-organization.testkit.js'; |
| 25 | + |
| 26 | +const LIVE_NAMESPACE = `os_lv_${basename(fileURLToPath(import.meta.url)).replace(/\.test\.ts$/, '').replace(/[^a-z0-9]+/gi, '_')}`; |
| 27 | +const PG_URL = process.env.OS_TEST_POSTGRES_URL; |
| 28 | + |
| 29 | +async function adminRaw(config: Record<string, unknown>, statements: string[]): Promise<void> { |
| 30 | + const admin = new SqlDriver(config as never); |
| 31 | + try { |
| 32 | + for (const statement of statements) await admin.execute(statement); |
| 33 | + } finally { |
| 34 | + await admin.disconnect(); |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +interface Cell { |
| 39 | + readonly id: string; |
| 40 | + readonly env: string | null; |
| 41 | + readonly url: string | undefined; |
| 42 | + readonly make: () => Promise<SqlDriver>; |
| 43 | + readonly teardown: () => Promise<void>; |
| 44 | +} |
| 45 | + |
| 46 | +const CELLS: readonly Cell[] = [ |
| 47 | + { |
| 48 | + id: 'driver-sql (better-sqlite3 :memory:)', |
| 49 | + env: null, |
| 50 | + url: 'sqlite', |
| 51 | + make: async () => new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), |
| 52 | + teardown: async () => {}, |
| 53 | + }, |
| 54 | + { |
| 55 | + id: 'driver-sqlite-wasm (:memory:)', |
| 56 | + env: null, |
| 57 | + url: 'sqlite-wasm', |
| 58 | + make: async () => new SqliteWasmDriver({ filename: ':memory:' }), |
| 59 | + teardown: async () => {}, |
| 60 | + }, |
| 61 | + { |
| 62 | + id: 'driver-sql (pg)', |
| 63 | + env: 'OS_TEST_POSTGRES_URL', |
| 64 | + url: PG_URL, |
| 65 | + make: async () => { |
| 66 | + await adminRaw({ client: 'pg', connection: PG_URL }, [ |
| 67 | + `drop schema if exists ${LIVE_NAMESPACE} cascade`, |
| 68 | + `create schema ${LIVE_NAMESPACE}`, |
| 69 | + ]); |
| 70 | + return new SqlDriver({ client: 'pg', connection: PG_URL, searchPath: [LIVE_NAMESPACE] } as never); |
| 71 | + }, |
| 72 | + teardown: async () => { |
| 73 | + await adminRaw({ client: 'pg', connection: PG_URL }, [`drop schema if exists ${LIVE_NAMESPACE} cascade`]); |
| 74 | + }, |
| 75 | + }, |
| 76 | +]; |
| 77 | + |
| 78 | +const SYS = { context: { isSystem: true } }; |
| 79 | + |
| 80 | +const FX_INVOICE = { |
| 81 | + name: 'fx_invoice', |
| 82 | + label: 'FX invoice', |
| 83 | + fields: { |
| 84 | + name: { name: 'name', type: 'text' }, |
| 85 | + currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }, |
| 86 | + invoice_date: { name: 'invoice_date', type: 'date' }, |
| 87 | + amount: { |
| 88 | + name: 'amount', |
| 89 | + type: 'currency', |
| 90 | + currencyConfig: { |
| 91 | + currencyMode: 'record', |
| 92 | + currencyField: 'currency_code', |
| 93 | + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, |
| 94 | + }, |
| 95 | + }, |
| 96 | + amount_home: { name: 'amount_home', type: 'currency', readonly: true }, |
| 97 | + amount_rate: { name: 'amount_rate', type: 'number', readonly: true }, |
| 98 | + }, |
| 99 | +}; |
| 100 | + |
| 101 | +const appWith = (objects: unknown[]) => ({ |
| 102 | + id: 'com.objectstack.dogfood.home-amount-projection', |
| 103 | + name: 'Home amount projection', |
| 104 | + version: '1.0.0', |
| 105 | + type: 'plugin', |
| 106 | + scope: 'system', |
| 107 | + objects, |
| 108 | +}); |
| 109 | + |
| 110 | +/** Each rate as `[currency, effective_date, rate]`; every currency's rate against the home currency of the test. */ |
| 111 | +const RATES: ReadonlyArray<[string, string, number]> = [ |
| 112 | + ['USD', '2026-01-01', 0.1389], |
| 113 | + ['USD', '2026-04-01', 0.15], |
| 114 | + ['USD', '2026-05-01', 7.123456789012], // twelve decimals, the column's scale |
| 115 | + ['EUR', '2026-01-01', 0.0065], // read as JPY-home in the minor-unit cell |
| 116 | + ['GBP', '2026-01-01', 1.0857], |
| 117 | + ['CHF', '2026-01-01', 3.2468], |
| 118 | +]; |
| 119 | + |
| 120 | +for (const cell of CELLS) { |
| 121 | + describe.skipIf(!cell.url)( |
| 122 | + `the write-time home projection — ${cell.id}${cell.url ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, |
| 123 | + () => { |
| 124 | + let driver: SqlDriver; |
| 125 | + let engine: ObjectQL; |
| 126 | + let home = 'CNY'; |
| 127 | + |
| 128 | + beforeAll(async () => { |
| 129 | + driver = await cell.make(); |
| 130 | + engine = new ObjectQL(); |
| 131 | + engine.registerDriver(driver, true); |
| 132 | + await engine.init(); |
| 133 | + engine.registerApp(appWith([SysExchangeRate, FX_INVOICE])); |
| 134 | + await engine.syncSchemas(); |
| 135 | + await installDefaultOrganization(engine); |
| 136 | + engine.setHomeCurrencyResolver(async () => home); |
| 137 | + for (const [currency, effective_date, rate] of RATES) { |
| 138 | + await engine.insert('sys_exchange_rate', { currency, effective_date, rate, source: 'manual' }, SYS); |
| 139 | + } |
| 140 | + }); |
| 141 | + |
| 142 | + afterAll(async () => { |
| 143 | + await engine?.destroy(); |
| 144 | + await cell.teardown(); |
| 145 | + }); |
| 146 | + |
| 147 | + /** Insert one invoice and read it back from the store. */ |
| 148 | + async function written(row: Record<string, unknown>) { |
| 149 | + const created = await engine.insert('fx_invoice', { name: 'inv', ...row }, SYS); |
| 150 | + return read(created.id); |
| 151 | + } |
| 152 | + async function read(id: unknown) { |
| 153 | + const back = await engine.findOne('fx_invoice', { where: { id }, context: SYS.context } as never) as Record<string, unknown>; |
| 154 | + return { home: Number(back.amount_home), rate: Number(back.amount_rate), org: back.organization_id }; |
| 155 | + } |
| 156 | + |
| 157 | + it('the card\'s check: USD 10,000 at 0.1389 into CNY stores 71994.24 with the rate 0.1389', async () => { |
| 158 | + home = 'CNY'; |
| 159 | + expect(await written({ currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 })) |
| 160 | + .toEqual({ home: 71994.24, rate: 0.1389, org: DEFAULT_ORGANIZATION_ID }); |
| 161 | + }); |
| 162 | + |
| 163 | + it('three minor-unit classes round half away from zero: JPY 0, USD 2, KWD 3', async () => { |
| 164 | + home = 'JPY'; |
| 165 | + expect(await written({ currency_code: 'EUR', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 15385, rate: 0.0065 }); // 15384.615… |
| 166 | + home = 'USD'; |
| 167 | + expect(await written({ currency_code: 'GBP', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 92.11, rate: 1.0857 }); // 92.1064… |
| 168 | + home = 'KWD'; |
| 169 | + expect(await written({ currency_code: 'CHF', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 30.8, rate: 3.2468 }); // 30.79955… |
| 170 | + home = 'CNY'; |
| 171 | + }); |
| 172 | + |
| 173 | + it('a twelve-decimal rate survives the column and divides exactly', async () => { |
| 174 | + // 1000 ÷ 7.123456789012 = 140.381282… |
| 175 | + expect(await written({ currency_code: 'USD', invoice_date: '2026-05-02', amount: 1000 })).toMatchObject({ home: 140.38, rate: 7.123456789012 }); |
| 176 | + }); |
| 177 | + |
| 178 | + it('the same currency as home is rate 1, rounded to the home minor units', async () => { |
| 179 | + home = 'CNY'; |
| 180 | + expect(await written({ currency_code: 'CNY', amount: 12.345 })).toMatchObject({ home: 12.35, rate: 1 }); |
| 181 | + }); |
| 182 | + |
| 183 | + it('the rate row is the latest on or before the document date; none ⇒ refused, nothing stored', async () => { |
| 184 | + home = 'CNY'; |
| 185 | + expect(await written({ currency_code: 'USD', invoice_date: '2026-03-31', amount: 150 })).toMatchObject({ home: 1079.91, rate: 0.1389 }); |
| 186 | + expect(await written({ currency_code: 'USD', invoice_date: '2026-04-01', amount: 150 })).toMatchObject({ home: 1000, rate: 0.15 }); |
| 187 | + const before = (await engine.find('fx_invoice', { context: SYS.context } as never)).length; |
| 188 | + const err = await engine.insert('fx_invoice', { name: 'early', currency_code: 'USD', invoice_date: '2025-12-31', amount: 1 }, SYS) |
| 189 | + .then(() => null, (e: { code?: string; status?: number; currency?: string; date?: string }) => e); |
| 190 | + expect({ code: err?.code, status: err?.status, currency: err?.currency, date: err?.date }) |
| 191 | + .toEqual({ code: 'ERR_EXCHANGE_RATE_MISSING', status: 400, currency: 'USD', date: '2025-12-31' }); |
| 192 | + expect((await engine.find('fx_invoice', { context: SYS.context } as never)).length).toBe(before); |
| 193 | + }); |
| 194 | + |
| 195 | + it('an update of the amount, the code or the date converts again; another edit does not', async () => { |
| 196 | + home = 'CNY'; |
| 197 | + const { id } = await engine.insert('fx_invoice', { name: 'u', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, SYS); |
| 198 | + await engine.update('fx_invoice', { id, amount: 1389 }, SYS); |
| 199 | + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); |
| 200 | + await engine.update('fx_invoice', { id, invoice_date: '2026-04-15' }, SYS); |
| 201 | + expect(await read(id)).toMatchObject({ home: 9260, rate: 0.15 }); |
| 202 | + await engine.update('fx_invoice', { id, currency_code: 'CNY' }, SYS); |
| 203 | + expect(await read(id)).toMatchObject({ home: 1389, rate: 1 }); |
| 204 | + await engine.update('fx_invoice', { id, currency_code: 'USD', invoice_date: '2026-03-15' }, SYS); |
| 205 | + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); |
| 206 | + await engine.update('fx_invoice', { id, name: 'renamed' }, SYS); |
| 207 | + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); |
| 208 | + }); |
| 209 | + }, |
| 210 | + ); |
| 211 | +} |
0 commit comments