Skip to content

Commit fe88ecd

Browse files
feat(formula): publish the home-amount conversion arithmetic (#31631)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi91WUDPBNqRzVkEppXR4HpYx)_ Fixes #31609 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:把引擎换算本位币金额的四个纯函数(`toScaledDecimal`、`divideToPlaces`、`formatScaledDecimal`、`documentDateOf`)和 `ScaledDecimal` 类型原样从 `objectql` 内部文件搬到已发布包 `@objectstack/formula` 并导出;`objectql` 改为导入回来,行为不变。 - 为什么改:#27196 要在发票表单上实时预览「≈ 本位币金额」,数字必须与保存时引擎算出的一致;前端已依赖 `formula`、不依赖 `objectql`,不允许复制第二份。 - 风险与回滚:纯搬移,两段函数体逐字节相同(下方 Verification 有比对);三个既有换算测试文件一行未改且全绿。因 ADR-0144 有两处路径锚点指向被搬的文件,本 PR 同时改了这两处括注(受管面,Tier H)。回滚即 revert。 - 先看:`packages/formula/src/home-amount-arithmetic.ts`(搬来的函数),`packages/objectql/src/home-amount-projection.ts` 顶部的导入/再导出,ADR-0144 第 387、402 行附近的锚点改动。 - 两分钟验证:`pnpm --filter @objectstack/formula exec vitest run src/home-amount-arithmetic.test.ts`,预期 5 个用例全绿(JPY 0 位、USD 2 位、KWD 3 位、平分远离零、时区取日)。 - 需要你做的:批准本 PR(改动触及 `docs/adr/**`,需 GOVERNED_APPROVERS 批准后由席位落地)。 ## What changes - **New module `packages/formula/src/home-amount-arithmetic.ts`.** `toScaledDecimal`, `divideToPlaces` (with its private `divideRoundHalfAwayFromZero` and `abs`), `formatScaledDecimal`, `documentDateOf` (with its private `calendarDayIn`, `CALENDAR_DATE`) and the `ScaledDecimal` type, moved byte-identical out of `packages/objectql/src/home-amount-projection.ts`, under a new module header. The module imports nothing. - **`packages/formula/src/index.ts`** exports the four functions and the `ScaledDecimal` type under their engine names. No clash in formula's index (`git grep` for each name on `origin/main` hits only objectql and ADR-0144). - **`packages/objectql/src/home-amount-projection.ts`** imports them from `@objectstack/formula` and re-exports them, so `home-amount-recompute.ts` and the projection test keep their import site. `toStoredNumber` (a projection-only step) stays. `CURRENCY_FRACTION_DIGITS` stays in `@objectstack/spec`. No new dependency edge: objectql, `ui/packages/core` and `ui/packages/app-shell` already depend on `@objectstack/formula`, which depends only on `@objectstack/spec`. - **Forced edit, governed: `docs/adr/0144-…-write-time-home-projection.md`** (+4/−2, seat claim amendment on #31609). Two anchor parentheticals pointed at `packages/objectql/src/home-amount-projection.ts#documentDateOf` and `#divideToPlaces`, `#divideRoundHalfAwayFromZero`, `#toScaledDecimal`. `divideRoundHalfAwayFromZero` is private and moves, so `pnpm check:adr-symbol-anchors` (required, `Lint & Repo Gates`) reds on any move-only diff. Both now point at `packages/formula/src/home-amount-arithmetic.ts`, and the 402 parenthetical says the arithmetic moved there unchanged on 2026-11-08 in #31609. No decision text changed. - Changeset `.changeset/31609-home-amount-arithmetic-formula.md`: `@objectstack/formula` patch, `@objectstack/objectql` patch (see Acceptance notes on the level). ## Tests - **New:** `packages/formula/src/home-amount-arithmetic.test.ts` (5 cases), imported through formula's index: exact-decimal reading (number, string, bigint; non-numbers spell none); the CLDR digits read from `CURRENCY_FRACTION_DIGITS` — JPY `100 ÷ 0.0065 → 15385`, USD `100 ÷ 1.0857 → 92.11`, KWD `100 ÷ 3.2468 → 30.800`, CNY `10000 ÷ 0.1389 → 71994.24` (the #27196 sample); the half-away-from-zero tie on both signs (`0.125 → 0.13`, `-0.125 → -0.13`, JPY `0.5 → 1`, KWD `-0.0005 → -0.001`, `1.005 → 1.01`); a `datetime` taken as its calendar day in Asia/Shanghai and America/New_York; UTC when the zone is unset, `UTC` or unknown. - **Unchanged and green:** `home-amount-projection.test.ts`, `engine-home-amount-projection.test.ts`, `home-amount-write-paths.test.ts` (0 lines changed). No other test file is edited. - No ablation: the diff changes no tested behaviour (move-only); the new pin's red leg is main itself (below). ## Acceptance notes - **Changeset level.** The delta asked for `minor` on formula. `scripts/check-changeset-no-major.mjs` (the level axis, maintainer ruling A) requires a declared `Metadata-protocol: none` to grade every entry it introduces `patch`, so both entries are `patch`. Raised with the seat in the report. - **New export names** (`toScaledDecimal`, `divideToPlaces`, `formatScaledDecimal`, `documentDateOf`, `ScaledDecimal`) keep the engine's names; listed for the seat's ruling at ACCEPT. ## Verification Head `cc6ea750a`, worktree merged with `origin/main` `133f75ae`. <details><summary>Red first: the new test on main's source (before the move)</summary> ``` FAIL src/home-amount-arithmetic.test.ts > … > reads an exact decimal from a number, a string and a bigint; spells none for a non-number TypeError: toScaledDecimal is not a function … (4 more, same: toScaledDecimal / documentDateOf is not a function) Test Files 1 failed (1) ``` </details> <details><summary>Move-only: both blocks byte-identical</summary> A script compared `git show 95df345:packages/objectql/src/home-amount-projection.ts` against the new module: ``` blockA (old L141-213, 73 lines) at new line 21 # ScaledDecimal … formatScaledDecimal blockB (old L225-269, 45 lines) at new line 95 # "The document date" heading … calendarDayIn ``` `git diff --color-moved=zebra 95df345 f3715fe` over the two files colours every changed line as moved except: in objectql, the 8 added import/re-export lines and one removed blank line; in formula, the 20-line module header (copyright, docblock, the `Exact decimal arithmetic` section heading, blank lines). No line of a function body is outside the moved set. </details> <details><summary>Package runs (os-verify-lock, VERDICT 0)</summary> - `@objectstack/formula` build + test + typecheck: Test Files 66 passed (66), Tests 1708 passed (1708); `check:test-typecheck` OK. - `@objectstack/objectql` build; the three projection suites: Test Files 3 passed (3), Tests 76 passed (76); full test: Test Files 570 passed (570), Tests 11571 passed (11571); typecheck (`tsc --noEmit` + `check:test-typecheck` OK). - After the merge: `turbo run build --filter='./packages/**'`: 81 successful, 81 total. - Lint: `eslint packages/formula packages/objectql --no-inline-config` exit 0. </details> <details><summary>ADR anchor gate, red then green</summary> Move commit `f3715fe6`, before the ADR edit: ``` ❌ check-adr-symbol-anchors: 1 finding(s) across 151 records. [unresolved-symbol] docs/adr/0144-…-write-time-home-projection.md:402 `#divideRoundHalfAwayFromZero` `divideRoundHalfAwayFromZero` has no declaration site or string-literal token in `packages/objectql/src/home-amount-projection.ts` ``` After `cc6ea750a`: ``` ✅ check-adr-symbol-anchors: 3274 anchors across 151 records resolve — 856 symbol (829 declaration, 27 literal), 2367 file-level, 51 cross-repo, 6 exempt, 48 continuation. ``` </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `cc6ea750a`: 73 commands (the ADR hunk adds `check-adr-links` ×2, `check:adr-anchors`, `check:adr-index`, `check:pm-governed-merges`, `check:pm-prior-rulings` and lint's `check:doc-formula-expressions` to the 66 the move derived). - 71 exit 0 on the first pass. - `pnpm check:dts-closure` and `pnpm check:dual-build-cjs-loads` exit 1 on that pass: six packages this diff does not touch (`cloud-connection`, `connector-mcp`, `connector-openapi`, `connector-rest`, `trigger-record-change`, `trigger-schedule`) had no `.d.ts` in this worktree's `dist/` while a second gate sweep ran in the same tree. After `turbo run build --force` of those six (44/44), both exit 0: `256/256 declared declaration file(s) present across 80 package(s)`; `122/72/2525/1` entries over floors `90/58/520/1`. - `node scripts/check-plugin-teardown-shape.mjs --self-test` needed its pinned fixture commit fetched (exit 3 → `git fetch origin 621a487…` → exit 0, 48 cases). - `--ran` reconciliation: `73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED`. - Not in the derived list, run anyway: `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0 (no anchored file in the change set). - No dogfood battery: no `!` in the title and no `(narrowing)` arm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQeo8gpywkDhZJu9nP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3806a5a commit fe88ecd

6 files changed

Lines changed: 232 additions & 122 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
feat(formula): `@objectstack/formula` publishes the home-amount conversion arithmetic, `toScaledDecimal`, `divideToPlaces`, `formatScaledDecimal`, `documentDateOf` and the `ScaledDecimal` type
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
The four functions the write-time home projection (ADR-0144 D5) converts a record amount with: exact decimal reading, the one division by the rate rounded half away from zero to the decimals you pass (the home currency's `CURRENCY_FRACTION_DIGITS`, still in `@objectstack/spec`), the canonical decimal spelling, and the calendar day a document date names in the organization's timezone (UTC when unset). They move unchanged out of `@objectstack/objectql`, which now imports them back and still re-exports them from its projection module, so a form can preview "≈ home amount" with the engine's own arithmetic instead of a second copy. Stored home amounts, rates and refusals do not change.

‎docs/adr/0144-multi-currency-record-currency-dated-rates-write-time-home-projection.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ Where a source leans one way, the lean is quoted here, not adopted. This record
384384
calendar day in the organization's `localization.timezone`, UTC when the setting is unset or unknown to the host,
385385
never the writer's request zone, so a REST write, an import and a flow pick the same rate for the same stored
386386
instant — ruling [`6539345871`](https://github.com/objectstack-ai/objectstack/issues/8345#issuecomment-6539345871) on #8345, letter B
387-
(`packages/objectql/src/home-amount-projection.ts#documentDateOf`; the zone rides the same settings bridge as the
387+
(`packages/formula/src/home-amount-arithmetic.ts#documentDateOf`; the zone rides the same settings bridge as the
388388
home currency, item 4). Not chosen: A, the UTC day now and the organization's zone as a later card, a semantic
389389
change over stored rows once released. A foreign-currency amount with a blank document date is refused
390390
`VALIDATION_FAILED` with `required` on the date field (item 9). Review: contract review
@@ -399,7 +399,9 @@ Where a source leans one way, the lean is quoted here, not adopted. This record
399399
exact scaled-decimal arithmetic over `BigInt` with no float step, rounded **once, half away from zero** (accounting
400400
`HALF_UP`; `1.005` stores `1.01`, `-0.125` stores `-0.13`) to the home currency's fraction digits, and stored as the
401401
rounded decimal's own spelling, the bare number ADR-0104 D1 requires
402-
(`packages/objectql/src/home-amount-projection.ts#divideToPlaces`, `#divideRoundHalfAwayFromZero`, `#toScaledDecimal`).
402+
(`packages/formula/src/home-amount-arithmetic.ts#divideToPlaces`, `#divideRoundHalfAwayFromZero`, `#toScaledDecimal`;
403+
moved there unchanged from `packages/objectql/src/home-amount-projection.ts`, which imports them back, on 2026-11-08
404+
in #31609, so the form's live home-amount preview reads the engine's one copy).
403405
The rate is stored in `homeAmount.rateField` **unrounded**, as the rate row holds it, so the
404406
stored home amount reproduces from the stored amount and rate
405407
(`packages/objectql/src/home-amount-projection.ts#projectHomeAmounts`); a `scale` declared on that field reaches
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0144 D5, D7 parameter 3, Open items 2 and 3] The home-amount
4+
// conversion arithmetic, pinned at its published home on the engine's own
5+
// samples. The write-time projection (`@objectstack/objectql`) and the form's
6+
// live "≈ home amount" preview both read these four exports, so the number a
7+
// form shows before save is the number the engine stores. The projection's
8+
// verdicts over a served rate stay pinned in objectql's
9+
// `home-amount-projection.test.ts`.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { CURRENCY_FRACTION_DIGITS } from '@objectstack/spec/data';
13+
import { documentDateOf, divideToPlaces, formatScaledDecimal, toScaledDecimal } from './index';
14+
15+
/** `amount ÷ rate` to the home currency's CLDR fraction digits, as the projection spells it. */
16+
const homeAmount = (amount: number | string, rate: number | string, home: string): string =>
17+
formatScaledDecimal(divideToPlaces(toScaledDecimal(amount)!, toScaledDecimal(rate)!, CURRENCY_FRACTION_DIGITS[home]!));
18+
19+
describe('the home-amount arithmetic exported from @objectstack/formula', () => {
20+
it('reads an exact decimal from a number, a string and a bigint; spells none for a non-number', () => {
21+
expect(toScaledDecimal(0.1389)).toEqual({ digits: 1389n, scale: 4 });
22+
expect(toScaledDecimal('7.123456789012')).toEqual({ digits: 7123456789012n, scale: 12 });
23+
expect(toScaledDecimal(12n)).toEqual({ digits: 12n, scale: 0 });
24+
expect(toScaledDecimal(1e21)).toEqual({ digits: 10n ** 21n, scale: 0 });
25+
for (const bad of ['', 'abc', '1,000', NaN, Infinity, null, undefined, {}]) {
26+
expect(toScaledDecimal(bad), String(bad)).toBeUndefined();
27+
}
28+
});
29+
30+
it('rounds to each home currency\'s CLDR digits: JPY 0, USD 2, KWD 3', () => {
31+
expect(homeAmount(100, 0.0065, 'JPY')).toBe('15385'); // 15384.615…
32+
expect(homeAmount(100, 1.0857, 'USD')).toBe('92.11'); // 92.106…
33+
expect(homeAmount(100, 3.2468, 'KWD')).toBe('30.800'); // 30.79955…
34+
// The #27196 preview sample: USD 10,000 at 0.1389 is ¥71,994.24 in a CNY home.
35+
expect(homeAmount(10000, 0.1389, 'CNY')).toBe('71994.24');
36+
});
37+
38+
it('rounds a tie half away from zero, on both signs, with no float in the way', () => {
39+
expect(homeAmount('0.125', 1, 'USD')).toBe('0.13');
40+
expect(homeAmount('-0.125', 1, 'USD')).toBe('-0.13');
41+
expect(homeAmount('0.5', 1, 'JPY')).toBe('1');
42+
expect(homeAmount('-0.0005', 1, 'KWD')).toBe('-0.001');
43+
// 1.005 has no exact binary double, so a float `toFixed(2)` answers 1.00; the decimal is 1.01.
44+
expect(homeAmount(1.005, 1, 'USD')).toBe('1.01');
45+
});
46+
47+
it('takes a datetime as its calendar day in a non-UTC timezone', () => {
48+
// 2026-03-15T17:00Z is 01:00 on the 16th in Shanghai and 13:00 on the 15th in New York.
49+
expect(documentDateOf('2026-03-15T17:00:00Z', 'Asia/Shanghai')).toBe('2026-03-16');
50+
expect(documentDateOf(new Date('2026-03-15T17:00:00Z'), 'Asia/Shanghai')).toBe('2026-03-16');
51+
expect(documentDateOf('2026-03-16T02:00:00Z', 'America/New_York')).toBe('2026-03-15');
52+
// A calendar date is its own day whatever the zone.
53+
expect(documentDateOf('2026-03-15', 'Asia/Shanghai')).toBe('2026-03-15');
54+
});
55+
56+
it('takes the UTC day when the timezone is unset, UTC or unknown', () => {
57+
expect(documentDateOf('2026-03-15T17:00:00Z')).toBe('2026-03-15');
58+
expect(documentDateOf('2026-03-31T23:30:00-05:00')).toBe('2026-04-01');
59+
expect(documentDateOf('2026-03-15T17:00:00Z', 'UTC')).toBe('2026-03-15');
60+
expect(documentDateOf('2026-03-15T17:00:00Z', 'Mars/Olympus')).toBe('2026-03-15');
61+
expect(documentDateOf('not a date')).toBeUndefined();
62+
expect(documentDateOf(20260315)).toBeUndefined();
63+
});
64+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0144 D5, D7 parameter 3, Open items 2 and 3] The home-amount
5+
* conversion arithmetic: exact decimal reading, the one division by the rate
6+
* rounded half away from zero, the canonical spelling, and the document date
7+
* a rate is picked for.
8+
*
9+
* Published here, not kept inside `@objectstack/objectql`, because two readers
10+
* must give the same answer: the engine's write-time home projection
11+
* (`@objectstack/objectql`'s `home-amount-projection.ts`, which imports these
12+
* back) and the form's live "≈ home amount" preview under `ui/`. A second
13+
* copy in the browser is exactly the drift a JPY (0 digits) or KWD (3 digits)
14+
* amount would hide. The fraction digits themselves stay in
15+
* `@objectstack/spec` (`CURRENCY_FRACTION_DIGITS`); this module is the
16+
* arithmetic only, and has no dependency of its own.
17+
*/
18+
19+
// ── Exact decimal arithmetic ──────────────────────────────────────────────
20+
21+
/** A decimal as a scaled integer: the value is `digits / 10^scale`. */
22+
export interface ScaledDecimal {
23+
readonly digits: bigint;
24+
readonly scale: number;
25+
}
26+
27+
const DECIMAL_SPELLING = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
28+
29+
/**
30+
* The exact decimal a stored or written number spells, or `undefined` when it
31+
* spells none. A JavaScript number is read through its shortest round-trip
32+
* spelling (`String(0.1389)` is `'0.1389'`), which is the decimal the writer
33+
* wrote; a string (a driver answering an exact-decimal column as text) is read
34+
* as written. Exponent spellings are accepted.
35+
*/
36+
export function toScaledDecimal(value: unknown): ScaledDecimal | undefined {
37+
let text: string;
38+
if (typeof value === 'number') {
39+
if (!Number.isFinite(value)) return undefined;
40+
text = String(value);
41+
} else if (typeof value === 'bigint') {
42+
text = value.toString();
43+
} else if (typeof value === 'string') {
44+
text = value.trim();
45+
} else {
46+
return undefined;
47+
}
48+
const m = DECIMAL_SPELLING.exec(text);
49+
if (!m) return undefined;
50+
const [, sign, int = '', frac = '', exp] = m;
51+
if (int === '' && frac === '') return undefined;
52+
let digits = BigInt(`${int}${frac}` || '0');
53+
let scale = frac.length - (exp === undefined ? 0 : Number(exp));
54+
if (scale < 0) {
55+
digits *= 10n ** BigInt(-scale);
56+
scale = 0;
57+
}
58+
return { digits: sign === '-' ? -digits : digits, scale };
59+
}
60+
61+
const abs = (n: bigint): bigint => (n < 0n ? -n : n);
62+
63+
/**
64+
* `numerator / denominator`, rounded to an integer half away from zero —
65+
* the one rounding step of the projection.
66+
*/
67+
function divideRoundHalfAwayFromZero(numerator: bigint, denominator: bigint): bigint {
68+
const negative = (numerator < 0n) !== (denominator < 0n);
69+
const n = abs(numerator);
70+
const d = abs(denominator);
71+
let q = n / d;
72+
if ((n % d) * 2n >= d) q += 1n;
73+
return negative ? -q : q;
74+
}
75+
76+
/**
77+
* `amount ÷ rate`, rounded once, half away from zero, to `places` decimals —
78+
* parameter 3 as arithmetic. Both operands are exact; so is every
79+
* intermediate.
80+
*/
81+
export function divideToPlaces(amount: ScaledDecimal, rate: ScaledDecimal, places: number): ScaledDecimal {
82+
// (a / 10^as) / (r / 10^rs) × 10^places = a × 10^(rs + places) / (r × 10^as)
83+
const numerator = amount.digits * 10n ** BigInt(rate.scale + places);
84+
const denominator = rate.digits * 10n ** BigInt(amount.scale);
85+
return { digits: divideRoundHalfAwayFromZero(numerator, denominator), scale: places };
86+
}
87+
88+
/** The canonical decimal spelling of a scaled decimal (`-1234.50` for `{ -123450n, 2 }`). */
89+
export function formatScaledDecimal(value: ScaledDecimal): string {
90+
const text = abs(value.digits).toString().padStart(value.scale + 1, '0');
91+
const body = value.scale === 0 ? text : `${text.slice(0, -value.scale)}.${text.slice(-value.scale)}`;
92+
return value.digits < 0n ? `-${body}` : body;
93+
}
94+
95+
// ── The document date ─────────────────────────────────────────────────────
96+
97+
const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
98+
99+
/**
100+
* The calendar date a document-date value names, as `YYYY-MM-DD`, or
101+
* `undefined` when it names none. A `date` value is its own calendar date, and
102+
* so is a bare `YYYY-MM-DD` spelling of any value. An instant (a `datetime`
103+
* value) is given `timezone`, the organization's `localization.timezone`
104+
* (IANA), and its calendar date is taken there (ruling 6539345871, letter B):
105+
* an invoice written at 01:00 in Shanghai is dated that Shanghai day. It is
106+
* the organization's setting and never the writer's request zone, so every
107+
* write path (a REST write, an import, a flow) picks the same rate for the
108+
* same stored instant. No timezone, or one `Intl` does not know, reads UTC,
109+
* the `localization.timezone` default — the `autonumber-format` reading.
110+
*/
111+
export function documentDateOf(value: unknown, timezone?: string): string | undefined {
112+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : calendarDayIn(value, timezone);
113+
if (typeof value !== 'string') return undefined;
114+
const text = value.trim();
115+
if (CALENDAR_DATE.test(text)) return text;
116+
const ms = Date.parse(text);
117+
return Number.isNaN(ms) ? undefined : calendarDayIn(new Date(ms), timezone);
118+
}
119+
120+
/** The `YYYY-MM-DD` calendar day of `instant` in `timezone` (IANA), UTC when absent or unknown. */
121+
function calendarDayIn(instant: Date, timezone: string | undefined): string {
122+
if (timezone && timezone !== 'UTC') {
123+
try {
124+
const parts = new Intl.DateTimeFormat('en-CA', {
125+
timeZone: timezone,
126+
year: 'numeric',
127+
month: '2-digit',
128+
day: '2-digit',
129+
}).formatToParts(instant);
130+
const y = parts.find((p) => p.type === 'year')?.value;
131+
const m = parts.find((p) => p.type === 'month')?.value;
132+
const d = parts.find((p) => p.type === 'day')?.value;
133+
if (y && m && d) return `${y}-${m}-${d}`;
134+
} catch {
135+
// An unknown zone reads UTC, below.
136+
}
137+
}
138+
return instant.toISOString().slice(0, 10);
139+
}

‎packages/formula/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ export type { FieldRole, ExprInput, ExprSchemaHint, ExprFaultOutcome, ExprValida
185185
// source field's own `currencyConfig`. The producer behind `returnType: 'currency'`.
186186
export { inferFormulaReturn } from './formula-return';
187187
export type { FormulaReturnDeclaration, FormulaSourceField } from './formula-return';
188+
// ADR-0144 D5 — the home-amount conversion arithmetic: the engine's write-time
189+
// home projection (`@objectstack/objectql`) and the form's live "≈ home amount"
190+
// preview read this one copy, so the number a form shows before save is the
191+
// number the engine stores.
192+
export { toScaledDecimal, divideToPlaces, formatScaledDecimal, documentDateOf } from './home-amount-arithmetic';
193+
export type { ScaledDecimal } from './home-amount-arithmetic';
188194
// The closed set of refusal codes `validateExpression` and
189195
// `collectCelRootIdentifiers` carry beside their English message, with the
190196
// typed params each message interpolates — so a localized author surface keys

0 commit comments

Comments
 (0)