Skip to content

Commit 62cbf9a

Browse files
feat(showcase): make the invoice the live multi-currency example (#31757)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbQp4GJGprgLNVJkzvtKwfLe)_ Fixes #27197 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 示例应用的发票成为可运行的多币种示例:新增币种、单据日期、含税总额(按记录币种)、本位币金额和所用汇率五个字段;种子写入 EUR/JPY 两组带日期的汇率,12 张发票分别为 USD/EUR/JPY;`.env` 把本位币固定为 USD;Revenue Pulse 改为汇总本位币金额;附一个默认关闭的定时流程模板,从汇率接口拉取汇率写入 `sys_exchange_rate`;平台检查清单新增一项。 - **为什么改:** 卡片要求示例应用里有一个从字段、汇率到报表都能实际跑通的多币种例子,让客户照着开启自己的汇率同步。 - **风险与回滚:** 只改示例应用、一条测试启动助手和检查清单,不动平台代码。一个既读不到 `.env` 又没有其他本位币来源的宿主,会因本位币未设置而拒绝所有带币种的种子发票,发票列表为空(详见下文)。回滚即 revert 本 PR。 - **先看:** `examples/app-showcase/src/data/objects/invoice.object.ts` 的多币种字段段落,然后看 `src/automation/flows/exchange-rate-sync.flow.ts`。 - **两分钟验证:** `cd examples/app-showcase && pnpm exec vitest run`,预期 46 个文件全绿;或者 `pnpm dev --fresh` 后打开发票 INV-1004,应显示 €450.00 和本位币 $500.00,下方注明「Converted at 0.9 on 当天日期」。 - **需要你做的:** 无,由席位落地。 ## What changes All edits are in `examples/app-showcase/**`, one test helper and the checklist. No platform code changes. - **`invoice.object.ts`.** Five new fields on `showcase_invoice`, all from the merged ADR-0144 spec: - `currency_code` is text with `valueDomain: 'iso_4217_currency'`. It is optional. - `invoice_date` is the document date: a required date that defaults to today. It is separate from `issued_on`, which stays blank on drafts. - `amount` ("Total incl. Tax") has `currencyMode: 'record'` and `currencyField: 'currency_code'`, plus `homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }`. Its value is a stored expression over the `total` roll-up and `tax_rate`, rounded to two decimals. - `amount_home` (a currency field) and `amount_rate` (a number) are read-only. The engine writes them. - **A2's rule: a record-mode amount with no currency stays blank.** `amount` evaluates to null while `currency_code` is blank, so `amount_home` and `amount_rate` stay blank too. An invoice created without a currency, which is what the existing tests and harnesses do, is still accepted. - **Seeds (`seed/index.ts`).** New `exchangeRates` dataset with four manual rows: EUR 0.92 from 2025-01-01 and 0.9 from 30 days ago; JPY 151.5 from 2025-01-01 and 149.25 from 30 days ago. Every invoice gets a `currency_code`. INV-1004, 1009 and 1010 are EUR, INV-1007 and 1008 are JPY, and the rest are USD. Each invoice's `invoice_date` is its `issued_on` where one exists. INV-1008 gets two yen-priced lines, so one JPY invoice converts a non-zero amount. - **`.env`.** Adds `OS_LOCALIZATION_CURRENCY=USD`, which is read by `os dev`, `os serve` and `os start`. The seeded rates are quoted from USD, so the home currency must be USD. No app or seed door sets `localization.currency` today: the dev seed derives it from the host's POSIX locale, which is unset in containers. - **A host with no .env reader and no currency gets zero invoices.** On such a host the home currency is unset. Every seed invoice carries a code and a non-blank amount, so each is refused `ERR_HOME_CURRENCY_UNSET`. The seeder drops refused rows with a warning, so the invoice list is empty and the boot still succeeds. In-process harnesses do not read `.env`. `packages/qa/dogfood/test/showcase-boot.ts` therefore sets `OS_LOCALIZATION_CURRENCY ??= 'USD'` before `bootStack`; this file was added by claim amendment 6730137663. Showcase test files that boot their own stack create invoices with no code, so A2 keeps them green. - **Flow template `showcase_exchange_rate_sync`.** It runs on a daily cron (`0 6 * * *`): - an `http` GET whose url slot reads `rate_feed_url`, a flow variable the customer edits in Studio; the default is a USD-based public feed; - a CEL `assignment` turns the `rates` map into rows; - a `loop` writes each row with `create_record` on `sys_exchange_rate` (`source: 'app'`), inside a `try_catch`, so a day that is already loaded is skipped row by row. The flow ships with `status: 'obsolete'` (loaded, not armed) and `runAs: 'system'`, so no boot and no CI run calls a third-party service. - **Revenue Pulse.** The invoice measure is now `total_home_sum`, the sum of `amount_home`, because a sum of amounts in mixed currencies means nothing. The KPI reads "Invoiced (Home Currency)". - **Record page (`invoice-document.page.ts`).** The page lists its sections explicitly, so it now names the new fields: Currency and Invoice Date join the Invoice section, and the amount, home amount and rate join Amounts. - **Translations.** The five field labels are added in `en` and `zh-CN`. `amount` is labelled "Total incl. Tax" so it does not share the bundle's "Total" with the Subtotal. - **Checklist.** Adds the item `records-forms.multi-currency-invoice` in `docs/qa/platform-checklist/areas/records-forms.json`. ## Tests - No new test file. The projection, refusal and preview are pinned in the platform by #27192 and #27196. This PR adds the example, and the live run below verifies it. - The existing suites ran against the new seed and the env-pinning helper: every showcase test, and every dogfood file that boots the showcase (counts under Verification). ## Acceptance notes The checklist item was verified live in a run caused by this PR's checklist edit, so there is no `qa-run` record (the `checklist-test` skill §5). The setup was a `pnpm dev --fresh -p 4791` showcase with the built Console, signed in as the seeded admin. `GET /api/settings/localization` returns `currency` USD, source env, locked: "In use by 1 object (Invoice) and 4 exchange rates". | Clause | Verdict | Oracle evidence | |---|---|---| | A foreign invoice stores the rate in force on its date, and amount ÷ rate | pass | INV-1008 JPY: 2026-11-04, 237864 → `amount_rate` 149.25 (the row from 30 days ago), `amount_home` 1593.73. INV-1007 JPY: 2026-09-10 → 151.5, the 2025-01-01 row, because 149.25 starts later. INV-1004 EUR: today, 450 → 0.9 / 500. INV-1009 EUR: 2026-08-01 → 0.92. | | A home-currency invoice stores rate 1 and amount_home = amount | pass | INV-1001 1749.56 → 1749.56 @ 1. INV-1003 323.94 → 323.94 @ 1. | | No currency_code: accepted, all three blank | pass | POST without a code → 201; `amount`, `amount_home` and `amount_rate` are all null. | | A currency change re-projects | pass | INV-1002 PATCH `currency_code` USD→EUR → 200: 198 @ 1 / 198 became 198 @ 0.9 / 220. | | A date before any rate is refused, naming the currency and date, and writes nothing | pass | POST JPY dated 2024-06-01 → 400 `ERR_EXCHANGE_RATE_MISSING`; the message names JPY and 2024-06-01. A list by that name returns 0 rows. | | Console: the home field is read-only with its hint, and the form preview follows edits | pass | The INV-1004 page shows "€450.00" and "$500.00 · Converted at 0.9 on Nov 9, 2026". The edit form shows "≈ $500.00 at 0.9 on Nov 9, 2026", then "≈ $3.02 at 149.25 …" after switching to JPY, then "No JPY rate on or before Jun 1, 2024: saving is refused until one is added" after setting that date. Save is refused inline with the engine's message. | **Screenshots** (deviceScaleFactor 2, 1280×900, kept with the run's scripts and API readings in the project files, folder `issue-27197/shots`): - `detail-inv1004-eur.png`: the EUR record page. - `detail-inv1008-jpy.png`: the JPY record page, showing ¥237,864, then $1,593.73 converted at 149.25 on Nov 4, 2026. - `edit-form-eur.png`, `edit-form-jpy-today.png` and `edit-form-jpy-2024.png`: the edit form's preview line at each edit. - `save-refused-missing-rate.png`: the inline refusal. - `revenue-pulse.png`: the KPI showing $3,667 "Invoiced (Home Currency)" over the last 90 days. - `exchange-rates.png`: Setup → Exchange Rates with the four seeded rows. **What the example cannot show (reported to the seat, not fixed here):** invoice lines and the Subtotal stay in dynamic mode. A child amount cannot take its parent's record currency, and a summary over a record-mode amount is refused (`RecordCurrencySummaryRefusedError`). So on a EUR or JPY invoice the line prices and Subtotal display with the home "$" while `amount` shows € or ¥. Both screenshots of the JPY invoice show this. ## Verification Head `c180833e`, which merges `origin/main` (5 commits, none touching a file on this PR). The dogfood battery and the gate sweep ran on `6d2c5b3a`, the pre-merge head with the same PR files. 11 files, +441 / −17. - **Showcase.** `typecheck` exits 0. `validate` exits 0, with no warning that names a new field. `vitest run`: 46 files, 544 tests passed, re-run after the merge of main. - **Dogfood.** All 248 files that boot the showcase (`bootShowcase` users and every app-showcase importer): 246 passed and 2 skipped (env-gated); 1832 tests passed, 11 skipped; exit 0. Then the 17 invoice-writing files, re-run after the record-page and rounding commit: 17 passed, 125 tests. - **Checklist validator.** `node scripts/check-platform-checklist.mjs`: OK, 361 items. - **i18n ratchet.** `check-i18n-coverage`: OK, none new. - **Gates.** `dispatch-gates --ran`: 56 derived, 55 run, 1 NOT-MEASURED, 0 UNRUN. Every recorded exit is 0 except `check:dual-build-cjs-loads`, which exits 3: a prerequisite is not met (unbuilt packages that this PR does not touch), left to CI. `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone; after fetching its pinned fixture commit it exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jS3Cr7JjyVM85apubn --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1cd0e01 commit 62cbf9a

11 files changed

Lines changed: 441 additions & 17 deletions

File tree

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6272,6 +6272,98 @@
62726272
"ref": "#27438"
62736273
}
62746274
]
6275+
},
6276+
{
6277+
"id": "records-forms.multi-currency-invoice",
6278+
"title": "A multi-currency invoice stores its home-currency amount at the rate in force on its document date, refuses a date no rate covers, and keeps a currency-less amount blank",
6279+
"since": "v18",
6280+
"status": "active",
6281+
"revision": 1,
6282+
"priority": "P2",
6283+
"surface": "mixed",
6284+
"personas": [
6285+
"seeded admin (admin@objectos.ai / admin123) — the Default Organization owner; the only persona that may read Setup → Exchange Rates"
6286+
],
6287+
"fixtures": {
6288+
"app": "showcase",
6289+
"requires": [
6290+
"a showcase boot whose home currency is USD. `os dev` / `os serve` / `os start` read examples/app-showcase/.env, which pins OS_LOCALIZATION_CURRENCY=USD; an in-process harness must set that variable itself (packages/qa/dogfood/test/showcase-boot.ts#bootShowcase does). Read the value back first with GET /api/settings/localization and confirm currency is USD before any clause",
6291+
"the stock seed's exchange rates (examples/app-showcase/src/data/seed/index.ts#exchangeRates): EUR 0.92 from 2025-01-01 and 0.9 from 30 days before the boot, JPY 151.5 from 2025-01-01 and 149.25 from 30 days before the boot, all source manual. No JPY or EUR rate exists before 2025-01-01, which is what the steps[3] refusal probe relies on",
6292+
"the stock seed's invoices carry a currency_code: INV-1004 / INV-1009 / INV-1010 are EUR, INV-1007 / INV-1008 are JPY (INV-1008 has two yen-priced lines), the rest USD",
6293+
"the console bundle for the browser legs (pnpm console:build)"
6294+
],
6295+
"knownGaps": [
6296+
"invoice lines and the Subtotal (`total`) roll-up stay dynamic-mode currency: a child amount cannot take its parent's record currency, and a summary over a record-mode child amount is refused (packages/objectql/src/record-currency-aggregate-door.ts#RecordCurrencySummaryRefusedError). On a EUR or JPY invoice those two show the home currency's symbol while `amount` shows the record's own. That is the platform's current shape, not a failure of this item",
6297+
"a host with no .env reader and no other currency source has no home currency, so every coded seed invoice is refused ERR_HOME_CURRENCY_UNSET and dropped by the seeder with a warning: the invoice list is empty. That is a fixture failure (requires[0]), scored blocked(fixture), never a FAIL of these clauses"
6298+
]
6299+
},
6300+
"steps": [
6301+
"GET /api/v1/data/showcase_invoice?filter currency_code in EUR, JPY and read amount, currency_code, invoice_date, amount_home and amount_rate on each; GET /api/v1/data/sys_exchange_rate for the rows those dates select",
6302+
"POST a showcase_invoice with no currency_code (name, account, status draft) and read it back",
6303+
"PATCH a USD draft invoice's currency_code to EUR and read amount_home / amount_rate again",
6304+
"POST a showcase_invoice with currency_code JPY and invoice_date 2024-06-01 (before any JPY rate)",
6305+
"in the console, open a EUR invoice's detail page, then its edit form, and change currency_code and invoice_date on the form without saving"
6306+
],
6307+
"acceptance": [
6308+
{
6309+
"clause": "a foreign-currency invoice stores `amount_rate` = the sys_exchange_rate row for its currency with the greatest effective_date on or before its invoice_date, and `amount_home` = amount ÷ that rate rounded to the home currency's 2 decimals",
6310+
"oracle": "api",
6311+
"verify": "for each EUR and JPY seed invoice, pick the governing rate row by hand from the steps[0] rate read and recompute amount ÷ rate; both stored fields must match. A rate taken from a later row, or a home amount computed as amount × rate, is a FAIL",
6312+
"evidence": "the invoice and rate reads, and the hand computation per invoice"
6313+
},
6314+
{
6315+
"clause": "a home-currency invoice stores rate 1 and amount_home = amount",
6316+
"oracle": "api",
6317+
"verify": "the USD seed invoices with a non-blank amount read amount_rate 1 and amount_home equal to amount",
6318+
"evidence": "the reads"
6319+
},
6320+
{
6321+
"clause": "an invoice with no currency_code is accepted and keeps amount, amount_home and amount_rate blank (a record-mode amount with no currency is not projected)",
6322+
"oracle": "api",
6323+
"verify": "the steps[1] POST answers 201 and its read-back has all three null",
6324+
"evidence": "the response and the read"
6325+
},
6326+
{
6327+
"clause": "changing currency_code re-projects: after the steps[2] PATCH the invoice carries the EUR rate in force on its invoice_date and the matching amount_home",
6328+
"oracle": "api",
6329+
"verify": "before and after reads of the same id; the after values match the clause-0 computation for EUR",
6330+
"evidence": "the two reads"
6331+
},
6332+
{
6333+
"clause": "a foreign invoice dated before any rate for its currency is refused ERR_EXCHANGE_RATE_MISSING, naming the currency and the date, and no row is written",
6334+
"oracle": "api",
6335+
"verify": "the steps[3] POST answers 400 with code ERR_EXCHANGE_RATE_MISSING (packages/objectql/src/home-amount-projection.ts#EXCHANGE_RATE_MISSING_CODE) and a message naming JPY and 2024-06-01; a follow-up list by that name returns nothing",
6336+
"evidence": "the response body and the empty list"
6337+
},
6338+
{
6339+
"clause": "the console shows the home amount read-only with its 'Converted at RATE on DATE' hint on a EUR invoice, and the edit form shows the live '≈ AMOUNT at RATE on DATE' preview, which follows a changed currency or date before save",
6340+
"oracle": "screenshot",
6341+
"verify": "screenshots at deviceScaleFactor 2 of the detail page and of the form before and after the steps[4] edits (ui/packages/fields/src/widgets/homeAmountPreview.tsx#HomeAmountPreview draws the preview)",
6342+
"evidence": "the screenshots"
6343+
}
6344+
],
6345+
"negative": [
6346+
"a foreign invoice stored at rate 1, at a rate dated after its invoice_date, or with amount_home computed by multiplication is a FAIL; so is a currency-less invoice refused, or one stored with a home amount; so is a missing-rate write that stores a row"
6347+
],
6348+
"traps": [
6349+
"seed-data-thin",
6350+
"clock-skew"
6351+
],
6352+
"source": [
6353+
"docs/adr/0144-multi-currency-record-currency-dated-rates-write-time-home-projection.md (record currency, home amount, rate direction)",
6354+
"packages/objectql/src/home-amount-projection.ts#HOME_CURRENCY_UNSET_CODE",
6355+
"examples/app-showcase/src/data/objects/invoice.object.ts (currency_code, invoice_date, amount, amount_home, amount_rate)",
6356+
"examples/app-showcase/src/automation/flows/exchange-rate-sync.flow.ts (the rates-feed template, shipped switched off)",
6357+
"#27197"
6358+
],
6359+
"history": [
6360+
{
6361+
"revision": 1,
6362+
"date": "2026-11-09",
6363+
"change": "initial: the showcase invoice became the multi-currency example (#27197); verified live in the same PR, clause table in its body",
6364+
"ref": "#31757"
6365+
}
6366+
]
62756367
}
62766368
]
62776369
}

‎examples/app-showcase/.env‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,13 @@
33
# deployment turns it on, so this example opts in. `os dev`, `os serve` and
44
# `os start` read this file from the project root.
55
OS_PAGE_REACT_ENABLED=true
6+
7+
# The organization's home currency (Setup → Localization → Default currency).
8+
# The invoice is the multi-currency example: it converts each invoice into the
9+
# home currency at write, from the exchange rates the seed loads, and those
10+
# rates are stated against USD. So the home currency is pinned here rather than
11+
# derived from this host's locale: a host with no locale would otherwise leave
12+
# it unset, and every seeded invoice would be refused (ERR_HOME_CURRENCY_UNSET).
13+
# Pinned by environment, the Localization page shows it locked; edit this line
14+
# (and the seeded rates) to run the example in another home currency.
15+
OS_LOCALIZATION_CURRENCY=USD
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineFlow } from '@objectstack/spec';
4+
5+
/**
6+
* Exchange Rate Sync — a TEMPLATE for keeping `sys_exchange_rate` current from
7+
* a rates feed (multi-currency, ADR-0144).
8+
*
9+
* The platform ships no rates feed and never calls one: rates are typed in
10+
* Setup → Exchange Rates, imported from a file, or written by an app. This flow
11+
* is the "written by an app" kind, as an example a customer copies and turns
12+
* on. Once a day it reads a JSON feed and writes one rate row per currency,
13+
* marked `source: 'app'`.
14+
*
15+
* ## The feed, and what it must say
16+
*
17+
* The rate direction is the platform's one direction: 1 unit of the home
18+
* currency = `rate` units of the foreign currency. So the feed must be quoted
19+
* FROM the organization's home currency. The default below reads the
20+
* Frankfurter API (European Central Bank reference rates) from USD, the home
21+
* currency this example pins in `.env`; its answer is
22+
* `{ "base": "USD", "date": "YYYY-MM-DD", "rates": { "EUR": 0.92, ... } }`.
23+
* A feed quoted the other way round would store every rate inverted.
24+
*
25+
* ## Where the customer changes the URL
26+
*
27+
* `rate_feed_url` is the setting: the `http` node's `url` is the text slot
28+
* `{{ rate_feed_url }}`, and the variable's `defaultValue` is the URL. Change
29+
* it in Studio (Automation → this flow → Variables) to point at the bank or
30+
* provider the company reconciles against, and to quote from its own home
31+
* currency. A URL whose path or query carries a secret does not belong here:
32+
* the flow definition is readable by everyone who can read flows, so a
33+
* credentialed feed goes through a connector, whose credential lives outside
34+
* the definition (`src/system/connectors/index.ts`).
35+
*
36+
* ## What one run does
37+
*
38+
* 1. `fetch_rates` GETs the feed.
39+
* 2. `to_rows` turns the feed's `rates` map into a list of
40+
* `{ currency, rate }` entries, one per currency the feed quotes.
41+
* 3. `each_rate` writes one `sys_exchange_rate` row per entry, dated with
42+
* the feed's own `date` (the day the rates are for, not the day the flow
43+
* ran). The table holds one row per (currency, effective date), so a
44+
* second run on the same feed day is refused row by row; each write sits
45+
* in its own `try_catch`, so a day already loaded is skipped and a run
46+
* never stops halfway through the list.
47+
*
48+
* Converted amounts do not move when a new rate arrives: an invoice keeps the
49+
* rate in force on its own document date (`invoice_date`). A corrected rate
50+
* reaches amounts already stored only through an explicit recompute
51+
* (`os migrate recompute-home-amounts`).
52+
*
53+
* ## Why it ships switched off
54+
*
55+
* `status: 'obsolete'` is the platform's "loaded, not armed": Studio lists
56+
* and opens the flow and its on/off switch reads Off, and no trigger fires it.
57+
* A shipped example must not call a third-party service on every developer's
58+
* boot and in CI. To use it, switch it on in Studio. Scheduled work shipped
59+
* in a package also needs the deployment switch
60+
* `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` (see `ScheduledDigestFlow` in
61+
* `./index.ts` for that rule and the tenancy posture it depends on).
62+
*
63+
* The flow writes an administrators' object: only an organization
64+
* administrator may write exchange rates, so the run is `runAs: 'system'`, the
65+
* principal a schedule runs as when no user started it.
66+
*/
67+
export const ExchangeRateSyncFlow = defineFlow({
68+
name: 'showcase_exchange_rate_sync',
69+
label: 'Exchange Rate Sync (template)',
70+
description:
71+
'Once a day, reads exchange rates from a feed quoted from the home currency and writes them to Setup → Exchange Rates (source: app). A template: switch it on and point rate_feed_url at your provider.',
72+
type: 'schedule',
73+
status: 'obsolete',
74+
runAs: 'system',
75+
variables: [
76+
{
77+
name: 'rate_feed_url',
78+
type: 'text',
79+
isInput: false,
80+
isOutput: false,
81+
defaultValue: 'https://api.frankfurter.app/latest?from=USD',
82+
},
83+
],
84+
nodes: [
85+
{
86+
id: 'start',
87+
type: 'start',
88+
label: 'Daily at 06:00',
89+
config: {
90+
schedule: { type: 'cron', expression: '0 6 * * *' },
91+
},
92+
},
93+
{
94+
id: 'fetch_rates',
95+
type: 'http',
96+
label: 'Fetch Rates',
97+
config: {
98+
url: '{{ rate_feed_url }}',
99+
method: 'GET',
100+
},
101+
},
102+
{
103+
id: 'to_rows',
104+
type: 'assignment',
105+
label: 'One Entry per Currency',
106+
config: {
107+
assignments: {
108+
// A CEL comprehension over the map's keys. Each entry keeps the
109+
// feed's number as is: the rate table refuses a zero or negative
110+
// rate, and one with more than twelve decimals, so a bad quote is
111+
// refused at the write and never stored rounded.
112+
rate_rows: {
113+
dialect: 'cel',
114+
source: "fetch_rates.response.rates.map(c, {'currency': c, 'rate': dyn(fetch_rates.response.rates[c])})",
115+
},
116+
rate_date: { dialect: 'cel', source: 'fetch_rates.response.date' },
117+
},
118+
},
119+
},
120+
{
121+
id: 'each_rate',
122+
type: 'loop',
123+
label: 'For Each Currency',
124+
config: {
125+
collection: { dialect: 'cel', source: 'rate_rows' },
126+
iteratorVariable: 'row',
127+
maxIterations: 200,
128+
body: {
129+
nodes: [
130+
{
131+
id: 'guard_write',
132+
type: 'try_catch',
133+
label: 'Skip a Day Already Loaded',
134+
config: {
135+
try: {
136+
nodes: [
137+
{
138+
id: 'write_rate',
139+
type: 'create_record',
140+
label: 'Write Exchange Rate',
141+
config: {
142+
objectName: 'sys_exchange_rate',
143+
fields: {
144+
currency: { dialect: 'cel', source: 'row.currency' },
145+
rate: { dialect: 'cel', source: 'row.rate' },
146+
effective_date: { dialect: 'cel', source: 'rate_date' },
147+
source: 'app',
148+
},
149+
},
150+
},
151+
],
152+
edges: [],
153+
},
154+
catch: {
155+
nodes: [{ id: 'rate_skipped', type: 'assignment', label: 'Rate Skipped' }],
156+
},
157+
},
158+
},
159+
],
160+
edges: [],
161+
},
162+
},
163+
},
164+
{ id: 'end', type: 'end', label: 'End' },
165+
],
166+
edges: [
167+
{ id: 'e1', source: 'start', target: 'fetch_rates' },
168+
{ id: 'e2', source: 'fetch_rates', target: 'to_rows' },
169+
{ id: 'e3', source: 'to_rows', target: 'each_rate' },
170+
{ id: 'e4', source: 'each_rate', target: 'end' },
171+
],
172+
});

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { defineFlow } from '@objectstack/spec';
44
import { DynamicApprovalFlow } from './dynamic-approval.flow';
55
import { ApproverBindingsFlow } from './approver-bindings.flow';
66
import { RecalcEstimatesFlow, RecalcTaskEstimateFlow } from './recalc-estimate.flow';
7+
import { ExchangeRateSyncFlow } from './exchange-rate-sync.flow';
78

89
/**
910
* Task Completed → Notify — an autolaunched, record-triggered flow that fires
@@ -2167,4 +2168,6 @@ export const allFlows = [
21672168
// `showcase_recalc_estimates_api` endpoint (src/system/apis/).
21682169
RecalcEstimatesFlow,
21692170
RecalcTaskEstimateFlow,
2171+
// Multi-currency: the rates-feed template (loaded, switched off).
2172+
ExchangeRateSyncFlow,
21702173
];

‎examples/app-showcase/src/data/objects/invoice.object.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,80 @@ export const Invoice = ObjectSchema.create({
164164
label: 'Subtotal',
165165
summaryOperations: { object: 'showcase_invoice_line', field: 'amount', function: 'sum' },
166166
}),
167+
168+
// ── Multi-currency (ADR-0144) ─────────────────────────────────────────
169+
//
170+
// An invoice is billed in the customer's currency, and the company reports
171+
// in its own: the organization's home currency (Setup → Localization →
172+
// Default currency). Five fields make that work, and every one of them is
173+
// declared here and referenced by name. The platform injects none of them
174+
// and fixes none of their names.
175+
//
176+
// currency_code the currency this invoice is billed in (ISO 4217).
177+
// invoice_date the document date; the exchange rate in force on this
178+
// day converts the invoice.
179+
// amount the tax-inclusive total, in `currency_code`
180+
// (`currencyMode: 'record'`).
181+
// amount_home the same total converted into the home currency. The
182+
// engine writes it at every write that moves the amount,
183+
// its currency or its date, and stores the rate it used in
184+
// amount_rate (1 home unit = rate units of `currency_code`).
185+
//
186+
// Rates come from Setup → Exchange Rates (`sys_exchange_rate`). A foreign
187+
// invoice dated before any rate of its currency is REFUSED, naming the
188+
// currency and the date; nothing converts at 1:1 or at the nearest rate.
189+
// Consolidated reporting sums `amount_home`, never `amount`: the Revenue
190+
// Pulse dataset does.
191+
//
192+
// What does NOT follow the invoice's currency: the line items and the
193+
// `total` Subtotal above. A line cannot name its invoice's code field as its
194+
// own currency, and a roll-up of amounts in different currencies is refused,
195+
// so the lines stay plain amounts and display in the home currency's format.
196+
197+
// The record currency. A `text` field declaring the ISO 4217 value domain,
198+
// so a write can store only a real code. Optional: an invoice without a
199+
// currency has no `amount` yet (see below).
200+
currency_code: Field.text({
201+
label: 'Currency',
202+
maxLength: 3,
203+
valueDomain: 'iso_4217_currency',
204+
}),
205+
// The document date, distinct from `issued_on` (blank on a draft): a
206+
// foreign amount needs a date to pick its rate, so a draft dated today is
207+
// convertible from its first save.
208+
invoice_date: Field.date({
209+
label: 'Invoice Date',
210+
required: true,
211+
defaultValue: 'NOW()',
212+
}),
213+
// The tax-inclusive total in the invoice's own currency, recomputed on every
214+
// write from the Subtotal roll-up (the roll-up's recompute is itself a
215+
// write, so adding a line moves it). It stays blank until a currency is
216+
// chosen: an amount of money with no currency is not an amount the engine
217+
// will store or convert. Rounded to two decimals, so the stored total is
218+
// the one the invoice states (the seeded yen lines are whole yen, so a yen
219+
// total stays whole).
220+
amount: Field.currency({
221+
label: 'Total incl. Tax',
222+
expression: cel`record.currency_code == null || record.currency_code == '' ? null : double(round((record.total == null ? 0 : record.total) * (1 + (record.tax_rate == null ? 0 : record.tax_rate) / 100) * 100)) / 100`,
223+
currencyConfig: {
224+
currencyMode: 'record',
225+
currencyField: 'currency_code',
226+
homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' },
227+
},
228+
}),
229+
// Written by the engine only (`readonly`), in `dynamic` mode: it displays
230+
// in the home currency, so this metadata never names that currency.
231+
amount_home: Field.currency({
232+
label: 'Total incl. Tax (Home Currency)',
233+
readonly: true,
234+
}),
235+
// The rate the conversion used, kept with every digit it was stored with
236+
// (no `scale`, so nothing rounds the rate a reader is shown).
237+
amount_rate: Field.number({
238+
label: 'Exchange Rate',
239+
readonly: true,
240+
}),
167241
},
168242

169243
validations: [

0 commit comments

Comments
 (0)