Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/27193-recompute-home-amounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/objectql': patch
'@objectstack/cli': patch
---

feat(objectql,cli): `os migrate recompute-home-amounts` — recompute stored home-currency amounts at the rates in force now, report first, rows without a rate listed and skipped

Clause-②: yes (widening)
Metadata-protocol: none

- **`@objectstack/objectql`: `recomputeHomeAmounts(engine, logger, { object, field?, since?, apply?, maxRecords?, maxListed?, source? })`**, with `assertHomeAmountRecomputeSince` (the pure `since` check a door can run before it opens a database), `formatHomeAmountRecomputeReport`, `homeAmountRecomputeComplete` and `HOME_AMOUNT_SKIP_REASONS`. It walks every stored row of an object whose `record`-mode amounts declare `currencyConfig.homeAmount` and projects each through the write-time projection itself (the same rate row, division, single rounding, same-currency rule and organization-timezone day), in the row's own organization, so each organization converts into its own home currency at its own rates and is counted apart. A dry run (the default) reports what would change and writes nothing; `apply` writes only the rows that differ, through `engine.update` with the home field in the payload, so the value stored is the one the write itself computes, and only the home and rate fields move (`updated_at` is kept, so a consumer syncing by `updated_at` does not see the recompute). Being a write, it moves what reads the home field as a fresh write would: stored formulas, parent summaries, and the per-row audit rows where audit capture is installed. Rows that cannot be converted are listed by reason and never written: `rate_missing` (with the currency and date), `code_blank` (an amount with no currency code; the remedy is `os migrate record-currency-codes`), `document_date_blank`, `home_currency_unset` and `unconvertible`. `since` filters on the document date (`homeAmount.rateDate`). One run-level `sys_audit_log` entry records each applied run (counts per organization and reason, and the skipped rows), also when an unclassified error stops the run partway (marked `aborted`, then the error is thrown on). A walk cut short by `maxRecords` is `truncated` and not complete. Refused before any row is read: an unknown object (`OBJECT_NOT_FOUND`), an object or `field` declaring no home amount (`INVALID_FIELD`), a `since` that is not `YYYY-MM-DD` (`VALIDATION_FAILED`).
- **`@objectstack/objectql`: `ObjectQL.homeAmountReadsFor(context?)`**, the reads the write-time projection makes (its serving, and the organization a write converts a row under), for a caller that projects a stored row as a write would.
- **`@objectstack/cli`: `os migrate recompute-home-amounts --object <o> [--field <amount>] [--since YYYY-MM-DD] [--apply] [--yes] [--json] [--force] [--max-records] [--database-url]`**, the shell of `os migrate record-currency-codes`, booting the settings service (each organization's `localization.currency` and `.timezone`) and the audit ledger. It exits 1 while any row is listed and skipped, on a dry run as on an apply, because a total over the home field silently leaves such a row out, and when `--max-records` cut the walk short.
33 changes: 32 additions & 1 deletion content/docs/data-modeling/currency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,8 @@ 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.
home amount and rate as they are. To bring stored rows up to the rates in force now, run
the [recompute](#recomputing-stored-home-amounts).

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
Expand All @@ -336,6 +337,36 @@ Each refusal is answered before anything is written, and the row is not stored:
The `validate()` preview, and an import's dry run, answer the same refusals for the same
rows.

### Recomputing stored home amounts

`os migrate recompute-home-amounts` brings the stored home amounts of an object up to the
rates in force now: after a rate row is corrected or added, or after a home amount is
declared on an object that already holds rows.

```bash
os migrate recompute-home-amounts --object invoice # Report: what would change, what cannot be converted
os migrate recompute-home-amounts --object invoice --since 2026-01-01 # Only documents dated on or after January 1
os migrate recompute-home-amounts --object invoice --apply # Write the changes (prompts)
```

Each row is converted exactly as a write converts it, in its own organization's home
currency at that organization's rates, and an applied row holds what a fresh write of it
would store, so what reads the home field (formulas, roll-ups, the audit trail) follows as
it does for a fresh write. The row's `updated_at` is kept: an integration that syncs by
`updated_at` does not see the recomputed value until it resyncs the object. `--since` compares the **document date** (the field `homeAmount.rateDate`
names), read as the day the write reads it. The report counts each organization apart and
lists every row that changes, with its stored and new values.

**A row that cannot be converted is listed and left as stored**, never filled with a rate
of 1, a later rate or a zero. The report names each one and why: no rate in force on its
document date (add the rate), an amount with no currency code (run
[`os migrate record-currency-codes`](#switching-a-field-that-already-holds-rows) first: no
rate can help a row that names no currency; it writes its one code into every
organization's codeless rows, so check the per-organization count first), no document date, or no home currency. Until
a re-run converts them, a total over the home field leaves those rows out, so the command
keeps exiting 1 while any is listed, and the applied run's audit entry records them. The
full reference is [`os migrate recompute-home-amounts`](/docs/deployment/cli#os-migrate-recompute-home-amounts).

### Totals in the home currency

The home field is an ordinary stored `currency` field, so every aggregate reads it as it
Expand Down
63 changes: 63 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,7 @@ where the data lives.
| `os migrate option-codes` | Rewrite option values still stored as a number, a boolean or SQLite `"N.0"` text (rows written before option fields stored the code string) to that code string, so filters, reads and unique keys see one value. Values it cannot decide are listed, never rewritten. No flag; idempotent. See [below](#os-migrate-option-codes) |
| `os migrate text-scalars` | Rewrite numbers and booleans still stored raw on text fields (rows written before text fields stored the string) to that string, so filters, reads and unique keys see one value. SQLite `"N.0"` text it cannot decide is listed, never rewritten. No flag; idempotent. See [below](#os-migrate-text-scalars) |
| `os migrate record-currency-codes` | Write the currency code you give into the rows of a record-mode currency field that hold an amount and no code, the rows stored before the field was switched to `currencyMode: 'record'`. The code is never derived. No flag; idempotent. See [below](#os-migrate-record-currency-codes) |
| `os migrate recompute-home-amounts` | Recompute the home-currency amounts a record-mode currency field stores, at the exchange rates in force now, after a rate is corrected or added. Reports the rows that change and lists, without writing, the rows it cannot convert. No flag; idempotent. See [below](#os-migrate-recompute-home-amounts) |
| `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column |
| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. It upgrades a database 17.x or an 18.0 pre-release (`18.0.0-next.*`) created; a v18 server refuses to start on one until it has run. See [below](#os-migrate-organization-ownership) |
| `os migrate activity-changed-fields` | Stamp the two read-rule columns (`changed_fields`, `milestone_key`) on the `sys_activity` rows written before they existed. Until it runs, those rows are withheld from every reader but the system, so run it at once after upgrading. No flag; idempotent |
Expand Down Expand Up @@ -1621,6 +1622,68 @@ every write turns a blank code into a code, so a second run finds nothing to wri
Re-running until the report says zero *is* the verification, which is why this
command records no flag.

#### `os migrate recompute-home-amounts`

A record-mode amount that declares a home amount is converted into the organization's home
currency when it is written, and stored with the rate it used
([Currency fields](/docs/data-modeling/currency#converting-into-the-home-currency)). A rate
row corrected or added later does not rewrite what was stored. This command does, after a
report.

```bash
os migrate recompute-home-amounts --object invoice # Dry run: reports, writes none
os migrate recompute-home-amounts --object invoice --since 2026-01-01 # Documents dated on or after Jan 1 only
os migrate recompute-home-amounts --object invoice --field amount --apply # Write (prompts)
os migrate recompute-home-amounts --object invoice --apply --yes --json # CI / scripts
```

**The write's own numbers.** Each stored row is projected exactly as a write projects it:
the rate row with the latest effective date on or before the document date, in the row's
own organization; the amount divided by the rate in exact decimal arithmetic, rounded once
to the home currency's decimal places; rate `1` for an amount in the home currency; and a
`datetime` document date read as its day in the organization's timezone. An applied row is
written through the engine, which converts it again, so it holds what a fresh write of the
row would store. Each organization converts into its own home currency at its own rates,
and the report counts each organization apart.

**What is written.** Only rows whose stored home amount or rate differs, and in them only
the home and rate fields: `updated_at` keeps its stored value. Because each one is an
ordinary write of the home field, whatever reads that field moves with it, as it would for
a fresh write: stored formulas, parent summaries, and, where audit capture is installed
(it is in the command's own boot), one per-row `sys_audit_log` `update` row for each row
rewritten. ⚠️ Since `updated_at` does not move, a consumer that syncs incrementally by
`updated_at` does not see a recomputed row; resync the object, or the rows the report
lists. (`os migrate record-currency-codes` does move `updated_at`.) `--field` limits the run to
one amount; without it, every amount of the object that declares a home amount is
recomputed. `--since YYYY-MM-DD` keeps the rows whose **document date** (the field
`homeAmount.rateDate` names, the date that picks the rate) is on or after that day, a
`datetime` read as its day in the organization's timezone; a row with no document date is
outside every `--since` window. A malformed `--since` is refused with `VALIDATION_FAILED` /
400 before the database is opened; an object the deployment does not declare
(`OBJECT_NOT_FOUND`), or one or a `--field` that declares no home amount (`INVALID_FIELD`),
is refused after the boot and before any row is read.

**Rows it cannot convert are listed and skipped**, each with its reason, and never written:

| Reason | The row | Fix, then re-run |
|:---|:---|:---|
| `rate_missing` | No rate for its currency is in force on its document date (the report names both). | Add the rate. |
| `code_blank` | An amount with no currency code: stored before the field switched to record mode. | [`os migrate record-currency-codes`](#os-migrate-record-currency-codes). It writes its one code into the codeless rows of **every** organization, so check the report's `code_blank` count per organization first. |
| `document_date_blank` | A foreign-currency amount with no document date. | Fill the document date. |
| `home_currency_unset` | Its organization has no `localization.currency`. | Set **Localization → Default currency**. |
| `unconvertible` | A code, amount or document date the write itself refuses. | Correct the value. |

A skipped row keeps whatever it stored, so a total over the home field leaves it out (a
`sum` skips an empty home amount silently). That gap stays visible: the command exits 1,
on a dry run and on an apply, for as long as any row is listed, and an applied run's
run-level `sys_audit_log` entry (one per run beside the per-row rows, no record,
`action: update`, `metadata.event: home_amount.recompute`) records the counts per
organization and reason and every skipped row. A run that an unexpected error stops
partway still writes that entry, marked `aborted` with the counts it reached, before the
command fails. A walk cut short by `--max-records` also exits 1: rows past the bound were
not looked at. Idempotent: re-running until it lists nothing and changes nothing *is* the
verification, which is why it records no flag.

#### `os migrate comment-reactions`

v18 retired `sys_comment.reactions`, the JSON column that held every emoji
Expand Down
Loading
Loading