From 802b49587819e07def1c23ecfe5252c0373dfdd3 Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 4 Nov 2026 19:18:56 -0800 Subject: [PATCH] feat: prepare period totals and account snapshot values --- ...are-homepage-periods-and-account-values.md | 10 + README.md | 10 +- apps/web/README.md | 29 +- apps/web/e2e/manual-preparation.e2e.ts | 4 + .../src/lib/charts/expense-categories.test.ts | 4 +- apps/web/src/lib/charts/expense-categories.ts | 4 +- .../src/lib/charts/income-expenses.test.ts | 4 +- apps/web/src/lib/charts/income-expenses.ts | 4 +- apps/web/src/lib/charts/net-worth.test.ts | 4 +- apps/web/src/lib/charts/net-worth.ts | 4 +- .../src/lib/overview/ExpenseCategories.svelte | 4 +- .../src/lib/overview/IncomeExpenses.svelte | 4 +- .../src/lib/overview/JournalCoverage.svelte | 4 +- apps/web/src/lib/overview/NetWorth.svelte | 4 +- apps/web/src/lib/overview/data.ts | 8 + apps/web/src/lib/server/accounts.test.ts | 7 +- apps/web/src/lib/server/accounts.ts | 2 +- .../server/preparation.integration.test.ts | 35 +- apps/web/src/lib/server/reports.test.ts | 3 +- .../src/routes/(protected)/+page.server.ts | 40 +- .../(protected)/holdings/page.server.test.ts | 72 +- .../routes/(protected)/page.server.test.ts | 183 ++++- .../cash/[accountId]/page.server.test.ts | 5 +- .../registers/cash/page.server.test.ts | 14 +- .../[accountId]/page.server.test.ts | 5 +- .../registers/investments/page.server.test.ts | 14 +- .../(protected)/settings/page.server.test.ts | 3 +- apps/worker/README.md | 13 +- apps/worker/src/workflows/index.test.ts | 46 +- .../accounts.integration.test.ts | 98 --- .../prepare-reports/cash.integration.test.ts | 133 ---- .../prepare-reports/edits.integration.test.ts | 10 +- .../prepare-reports/run.integration.test.ts | 132 +--- .../src/workflows/prepare-reports/run.test.ts | 14 +- docs/architecture.md | 51 +- docs/development.md | 24 +- packages/hledger/README.md | 105 ++- .../src/accounts/balances.integration.test.ts | 170 ++++ .../hledger/src/accounts/balances.test.ts | 109 +++ packages/hledger/src/accounts/balances.ts | 129 +++ packages/hledger/src/index.ts | 8 +- packages/hledger/src/overview/batch.test.ts | 321 ++------ packages/hledger/src/overview/batch.ts | 166 ++-- .../src/overview/income-statement.test.ts | 25 +- .../hledger/src/overview/income-statement.ts | 6 +- .../hledger/src/overview/net-worth.test.ts | 142 ++-- packages/hledger/src/overview/net-worth.ts | 37 +- packages/hledger/src/overview/ranges.test.ts | 205 +++++ packages/hledger/src/overview/ranges.ts | 117 +++ packages/hledger/src/overview/report.test.ts | 737 +++--------------- packages/hledger/src/overview/report.ts | 127 +-- .../src/prepare-reports.integration.test.ts | 10 +- packages/hledger/src/prepare-reports.test.ts | 20 +- packages/hledger/src/prepare-reports.ts | 28 +- packages/hledger/test-fixtures.ts | 29 +- packages/snapshots/README.md | 49 +- packages/snapshots/src/reports.test.ts | 246 ++++-- packages/snapshots/src/reports.ts | 125 ++- 58 files changed, 2062 insertions(+), 1854 deletions(-) create mode 100644 .changeset/prepare-homepage-periods-and-account-values.md create mode 100644 apps/web/src/lib/overview/data.ts delete mode 100644 apps/worker/src/workflows/prepare-reports/accounts.integration.test.ts delete mode 100644 apps/worker/src/workflows/prepare-reports/cash.integration.test.ts create mode 100644 packages/hledger/src/accounts/balances.integration.test.ts create mode 100644 packages/hledger/src/accounts/balances.test.ts create mode 100644 packages/hledger/src/accounts/balances.ts create mode 100644 packages/hledger/src/overview/ranges.test.ts create mode 100644 packages/hledger/src/overview/ranges.ts diff --git a/.changeset/prepare-homepage-periods-and-account-values.md b/.changeset/prepare-homepage-periods-and-account-values.md new file mode 100644 index 0000000..b2da2cc --- /dev/null +++ b/.changeset/prepare-homepage-periods-and-account-values.md @@ -0,0 +1,10 @@ +--- +'@hledger-reports/web': patch +'@hledger-reports/worker': patch +--- + +Prepare native 1/3/6/12-month income, expenses, NET and category totals, twelve-month histories, latest assets/liabilities and raw/valued account summaries including investment cash. Require financial format 6; transaction history and the v1 void-input preparation workflow are unchanged. + +Stop old preparation execution, deploy compatible web and worker revisions together, then explicitly Prepare reports again in Settings. Format-5 files remain untouched but unusable, with no adapter, fallback, migration, deletion or automatic work; account IDs may reset across the transition. + +Keep the current six-month UI by projecting only its rendered fields, range-6 categories and last six history points. Period controls, a three-month default and account sidebar remain future work. diff --git a/README.md b/README.md index 58dbf0b..6400634 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The window is journal-anchored, not clock-anchored or a freshness guarantee. Exa A compact journal-coverage panel shows the recorded transaction count and inclusive native date range for the whole journal. Secondary and posting dates can extend that range; the financial window remains anchored to the latest primary transaction date. This is context, not proof of completeness or freshness. -The hledger library reads the journal and organizes neutral, table/chart-ready `OverviewData`. The worker publishes that data; web renders it directly, with type-only hledger imports. Chart.js renders separate income/category commodity scales and one net-worth line in hledger's inferred base currency; the tables remain exact and server-rendered. Income/category totals and dated historical balance sheets share one native loaded journal and complete prepared-report set. Only complete current-format reports are supported; every overview includes all four panels. Unconverted holdings mark net-worth points incomplete rather than silently becoming zero or a partial consolidated total. +The hledger library reads the journal and organizes neutral, table/chart-ready `OverviewData`. The worker publishes native 1/3/6/12-month income, expenses, NET and category totals, shared twelve-month income/expense and net-worth histories, latest assets/liabilities and raw/valued account summaries. Web uses type-only hledger imports and an explicit current-page projection: range-6 categories and the last six history points, with only rendered context. Other ranges, latest sides, account values/catalog and private provenance are not serialized. The current six-month UI is deliberate; period controls, a three-month default and an account sidebar are future work. Chart.js renders separate income/category commodity scales and one net-worth line in hledger's inferred base currency; the tables remain exact and server-rendered. Income/category totals and dated historical balance sheets share one native loaded journal and complete prepared-report set. Only complete current-format reports are supported; every overview includes all four panels. Unconverted holdings mark net-worth points incomplete rather than silently becoming zero or a partial consolidated total. **Cash and Investment registers list their explicitly configured accounts.** Select one for prepared activity with ordinary pagination: 50 rows for Cash, or 50 whole transactions for Investments. Dates, exact changes and running balances come from hledger and do not reset on page boundaries. The web reads the native account directory in `overview.json`, opens the selected account's complete JSON file, and projects only the requested page. Friendly labels do not change native identity or report scope. Browsing never executes native work; Overview and all native account activity still publish together as one complete set. @@ -73,18 +73,18 @@ accounts: label: Living expenses ``` -Account identities and labels are literal strings, with exact matching and no environment expansion or prefix aliases. Labels apply throughout presentation while canonical names remain available. Label-only entries do not enable a register. Missing saved accounts are shown as unavailable. Holdings uses the same investment membership for its selector and displayed native account groups; without investment members it is also unavailable. IDs, financial values and preparation scope remain unchanged. See the [account configuration contract](packages/config/README.md#account-presentation). +Account identities and labels are literal strings, with exact matching and no environment expansion or prefix aliases. Labels apply throughout presentation while canonical names remain available. Label-only entries do not enable a register. Missing saved accounts are shown as unavailable. Holdings uses the same investment membership for its selector and displayed native account groups; without investment members it is also unavailable. Labels and membership do not alter native identity, financial values or preparation scope. See the [account configuration contract](packages/config/README.md#account-presentation). Configuration and `data/` remain siblings. Renaming the config in place does not move data or change the selected journal. Both processes must select the same file: - `data/jobs/`: private SQLite workflow queue, query parameters/results and preparation history. - `data/snapshots/.validation/`: immutable completed-check records, separate from financial sets. Records are retained without automatic cleanup or a history UI; private journal/request metadata is never sent to the browser. -- `data/snapshots/current/overview.json`: the selected journal identity, `OverviewData` and the account directory. Each native account has a stable eight-character lowercase-letter/digit ID, full name, access state and row count. +- `data/snapshots/current/overview.json`: the selected journal identity, `OverviewData` and the account directory. Each native account has an eight-character lowercase-letter/digit ID, full name, access state, row count and required raw/valued summary. IDs are reused by exact name within supported same-journal publications, but may reset across the format transition. - `data/snapshots/current/transactions/.json`: one complete account register per file, with its identity, exact native amounts and exported transaction/posting metadata. Source and assertion positions remain private. This is processed report output, not original journal text or a complete investment cost-basis export. Account names remain JSON data, never paths. Pagination is a server-side array slice, not persisted page files. -Financial files are prepared together in a hidden staging set and published as a complete directory; `current` is the inspectable entry point. Readers accept only complete format-5 publications for the selected journal; this change does not bump the financial format or require re-preparing supported sets. Unsupported older files are ignored and left untouched; explicitly prepare reports in Settings to create a current set. Readers do not migrate, delete or automatically regenerate files. Former source-observation files are also left untouched and are not read or written. A failed preparation can still leave a new complete report available; reload saved reports to observe the result. See [publication and recovery](packages/snapshots/README.md#publication-and-reads) for commit behavior. +Financial files are prepared together in a hidden staging set and published as a complete directory; `current` is the inspectable entry point. Readers require complete **format-6** publications for the selected journal, replacing format 5. To activate, stop old preparation execution, deploy compatible web and worker revisions together, then explicitly **Prepare reports again** in Settings. Format-5 files remain untouched but unusable; old account URLs may change because IDs may reset at this transition. Readers do not migrate, delete or automatically regenerate files. Former source-observation files are also left untouched and are not read or written. One directory rename commits the entire financial set. Before that commit, failure retains the previous complete set; a later `current` alias failure can fail the job while the new complete set remains selectable, without rollback or deletion. Reload saved reports to observe the result. The v1 void-input workflow and independent recorded-validation lifecycle are unchanged. See [publication and recovery](packages/snapshots/README.md#publication-and-reads) for commit behavior. -In Compose, these paths are visible under **`apps/web/data/`** in the checkout. The previous named volume is left untouched; [copy it safely before the first bind-mounted start](docs/development.md#inspecting-data-and-preserving-the-previous-volume) to retain its queue and publications. Protect and back up the entire data directory; do not log financial data or native diagnostics. [Prepared-data lifetime](packages/snapshots/README.md#prepared-reports) covers growth, last-good recovery and untouched unsupported files. +In Compose, these paths are visible under **`apps/web/data/`** in the checkout. The previous named volume is left untouched; [copy it safely before the first bind-mounted start](docs/development.md#inspecting-data-and-preserving-the-previous-volume) to retain its queue and publications. Protect and back up the entire data directory; do not log financial data or native diagnostics. [Prepared-data lifetime](packages/snapshots/README.md#current-format-and-preservation) covers growth, last-good recovery and untouched unsupported files. Config loading neither opens journals nor creates directories. Each process loads its explicit selection at startup; restart after YAML edits. Blank/invalid selections fail startup. Web initializes one jobs client and reuses it for requests. A shared hook checks both framework request URL origins against normalized YAML `url.app`; POSTs additionally require an exact same-origin request and a 2-KiB streamed body limit: form encoding for preparation, JSON for live queries. These transport guards remain in addition to the central authentication gate. Responses are no-store. Vite denies raw journals, SQLite/sidecars and data/input/result folders. diff --git a/apps/web/README.md b/apps/web/README.md index 8a546ba..f2c1029 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -59,6 +59,14 @@ See [account configuration](../../packages/config/README.md#account-presentation The [native library](../../packages/hledger/README.md#overview-data) owns financial meaning, periods and exact quantities. Web renders its report data, with type-only hledger imports; it does not interpret native tuples or reconstruct balances. +Preparation now saves native 1/3/6/12-month range totals/categories, shared twelve-month +income/expense and net-worth histories, latest assets/liabilities and account summaries. +The homepage server explicitly projects only rendered context, range-6 categories +and the last six monthly/net-worth points into `OverviewPageData`. It omits all +range headline totals, other ranges, latest sides, account values/catalog, private +publication metadata and provenance. This is the current page model, not a legacy +producer adapter. Period controls, a three-month default and sidebar values remain +future work; routes, navigation, Settings, live queries and themes are unchanged. Overview uses the latest primary transaction's month and preceding five months, not the computer's month. Income/expenses are not available cash, cash flow or net @@ -76,7 +84,8 @@ exact table instead. There is no web financial summation or currency conversion. Transactions show full recorded account activity, including subaccounts for real parents. Implied ancestors are not extra reports. Each native account has a stable -eight-character lowercase-letter/digit ID. Names remain exact data, never paths or +eight-character lowercase-letter/digit ID within supported same-journal publications; +IDs may reset across the format transition. Names remain exact data, never paths or slugs. Multiple securities can belong to one account. Native dates, related-account labels, split flags, changes and attached running balances survive pagination. Empty accounts have no invented transaction; ambiguous native selectors show no @@ -106,23 +115,27 @@ data/snapshots/current/ transactions/.json ``` -Overview contains the selected journal identity and account directory. One whole +The saved `overview.json` envelope contains the selected journal identity, financial +overview and account directory, including required raw/valued summaries. Register +directories explicitly project only identity/access/count fields, not summaries. One whole account file contains all native rows. The worker publishes a complete immutable set; a request pins that set before reading. Private journal identities and set -paths never enter page data. Financial format **5** retains full exported +paths never enter page data. Financial format **6** retains unchanged full exported transaction/posting metadata and private source/assertion positions. Register loaders project only the selected page's public table/detail fields; stored records are never passed directly into PageData. -Financial format remains **5**, with no re-preparation requirement for supported sets. -Unsupported saved files are left untouched; use Prepare reports in Settings to create -a current complete set. -There is no older-format fallback, partial-card compatibility or automatic migration. +Financial format **6** replaces 5. Stop old preparation execution, deploy compatible +web and worker revisions together, then explicitly **Prepare reports again** in +Settings. Format-5 files remain untouched but unusable; account IDs may reset and +old account URLs may no longer resolve. There is no automatic work, older-format +fallback, partial-data adapter, migration or deletion. The v1 void-input preparation +workflow and independent validation-evidence lifecycle are unchanged. Reports for a different configured journal are withheld. Immutable validation records live separately in `data/snapshots/.validation/`, retained without cleanup or a history UI. Homepage browsing reads these files, never the queue or native journal. -See [prepared storage](../../packages/snapshots/README.md#prepared-reports) and the +See [prepared storage](../../packages/snapshots/README.md#current-format-and-preservation) and the [old-volume copy guide](../../docs/development.md#inspecting-data-and-preserving-the-previous-volume). Do not copy into populated data folders or delete volumes to simulate an empty app. diff --git a/apps/web/e2e/manual-preparation.e2e.ts b/apps/web/e2e/manual-preparation.e2e.ts index 91fbf66..6a1bf91 100644 --- a/apps/web/e2e/manual-preparation.e2e.ts +++ b/apps/web/e2e/manual-preparation.e2e.ts @@ -14,6 +14,10 @@ test('explicitly prepares reports and browses saved Overview without JavaScript' const preparedAt = await prepareReports(page); const income = page.getByRole('region', { name: 'Income vs expenses', exact: true }); await expect(income.getByRole('table')).toContainText('1000.00'); + await expect(income.locator('tbody tr')).toHaveCount(12); // Two series for six months. + const netWorth = page.getByRole('table', { name: 'Net worth by valuation date', exact: true }); + await expect(netWorth.locator('tbody tr')).toHaveCount(6); + await expect(netWorth.locator('tbody tr').last()).toContainText('2026-04-16'); expect(await readPreparationTimestamp(page)).toBe(preparedAt); await page.reload(); diff --git a/apps/web/src/lib/charts/expense-categories.test.ts b/apps/web/src/lib/charts/expense-categories.test.ts index f34a75a..fa3259d 100644 --- a/apps/web/src/lib/charts/expense-categories.test.ts +++ b/apps/web/src/lib/charts/expense-categories.test.ts @@ -1,10 +1,10 @@ -import type { OverviewData } from '@hledger-reports/hledger'; +import type { OverviewPageData } from '#lib/overview/data.js'; import type { TooltipItem, TooltipModel } from 'chart.js'; import { expect, it } from 'vitest'; import { theme } from './theme.fixture.js'; import { expenseCategoriesChart } from './expense-categories'; -type Rows = OverviewData['expenseCategories']['rows']; +type Rows = OverviewPageData['expenseCategories']['rows']; function rows(): Rows { return [ diff --git a/apps/web/src/lib/charts/expense-categories.ts b/apps/web/src/lib/charts/expense-categories.ts index 69bd596..7275be6 100644 --- a/apps/web/src/lib/charts/expense-categories.ts +++ b/apps/web/src/lib/charts/expense-categories.ts @@ -1,10 +1,10 @@ -import type { OverviewData } from '@hledger-reports/hledger'; +import type { OverviewPageData } from '#lib/overview/data.js'; import type { ChartConfiguration } from 'chart.js'; import type { ChartTheme } from './theme.js'; import { accountLabel, type AccountLabel } from '../accounts.js'; export function expenseCategoriesChart( - rows: OverviewData['expenseCategories']['rows'], + rows: OverviewPageData['expenseCategories']['rows'], commodity: string, theme: ChartTheme, labels: readonly AccountLabel[] = [] diff --git a/apps/web/src/lib/charts/income-expenses.test.ts b/apps/web/src/lib/charts/income-expenses.test.ts index dc3350d..1469bb0 100644 --- a/apps/web/src/lib/charts/income-expenses.test.ts +++ b/apps/web/src/lib/charts/income-expenses.test.ts @@ -1,10 +1,10 @@ -import type { OverviewData } from '@hledger-reports/hledger'; +import type { OverviewPageData } from '#lib/overview/data.js'; import type { TooltipItem, TooltipModel } from 'chart.js'; import { expect, it } from 'vitest'; import { theme } from './theme.fixture.js'; import { incomeExpensesChart } from './income-expenses'; -function months(): OverviewData['incomeExpenses']['months'] { +function months(): OverviewPageData['incomeExpenses']['months'] { return ['2025-11', '2025-12', '2026-01', '2026-02', '2026-03', '2026-04'].map( (begin, index, periods) => ({ begin: `${begin}-01`, diff --git a/apps/web/src/lib/charts/income-expenses.ts b/apps/web/src/lib/charts/income-expenses.ts index 556b14a..b8c3d7e 100644 --- a/apps/web/src/lib/charts/income-expenses.ts +++ b/apps/web/src/lib/charts/income-expenses.ts @@ -1,9 +1,9 @@ -import type { OverviewData } from '@hledger-reports/hledger'; +import type { OverviewPageData } from '#lib/overview/data.js'; import type { ChartConfiguration } from 'chart.js'; import type { ChartTheme } from './theme.js'; export function incomeExpensesChart( - months: OverviewData['incomeExpenses']['months'], + months: OverviewPageData['incomeExpenses']['months'], commodity: string, theme: ChartTheme ): ChartConfiguration<'bar', (number | null)[], string> | null { diff --git a/apps/web/src/lib/charts/net-worth.test.ts b/apps/web/src/lib/charts/net-worth.test.ts index c6a919f..9321d8a 100644 --- a/apps/web/src/lib/charts/net-worth.test.ts +++ b/apps/web/src/lib/charts/net-worth.test.ts @@ -1,10 +1,10 @@ -import type { OverviewData } from '@hledger-reports/hledger'; +import type { OverviewPageData } from '#lib/overview/data.js'; import type { TooltipItem, TooltipModel } from 'chart.js'; import { expect, it } from 'vitest'; import { theme } from './theme.fixture.js'; import { netWorthChart } from './net-worth'; -function overview(): OverviewData { +function overview(): OverviewPageData { return { journalCoverage: { transactionCount: 1, firstDate: '2026-04-16', lastDate: '2026-04-16' }, expenseCategories: { depth: 2, rows: [] }, diff --git a/apps/web/src/lib/charts/net-worth.ts b/apps/web/src/lib/charts/net-worth.ts index c8beada..8487eb8 100644 --- a/apps/web/src/lib/charts/net-worth.ts +++ b/apps/web/src/lib/charts/net-worth.ts @@ -1,9 +1,9 @@ -import type { OverviewData } from '@hledger-reports/hledger'; +import type { OverviewPageData } from '#lib/overview/data.js'; import type { ChartConfiguration } from 'chart.js'; import type { ChartTheme } from './theme.js'; export function netWorthChart( - netWorth: OverviewData['netWorth'], + netWorth: OverviewPageData['netWorth'], theme: ChartTheme ): ChartConfiguration<'line', (number | null)[], string> | null { const { currency, points } = netWorth; diff --git a/apps/web/src/lib/overview/ExpenseCategories.svelte b/apps/web/src/lib/overview/ExpenseCategories.svelte index 64ab923..81c7651 100644 --- a/apps/web/src/lib/overview/ExpenseCategories.svelte +++ b/apps/web/src/lib/overview/ExpenseCategories.svelte @@ -1,10 +1,10 @@ diff --git a/apps/web/src/lib/overview/NetWorth.svelte b/apps/web/src/lib/overview/NetWorth.svelte index 92ae4fe..0b65417 100644 --- a/apps/web/src/lib/overview/NetWorth.svelte +++ b/apps/web/src/lib/overview/NetWorth.svelte @@ -1,9 +1,9 @@ diff --git a/apps/web/src/lib/overview/data.ts b/apps/web/src/lib/overview/data.ts new file mode 100644 index 0000000..cc02ab0 --- /dev/null +++ b/apps/web/src/lib/overview/data.ts @@ -0,0 +1,8 @@ +import type { OverviewData, OverviewRange } from '@hledger-reports/hledger'; + +/** Only the saved financial fields rendered by the current six-month homepage. */ +export type OverviewPageData = Pick & { + expenseCategories: OverviewRange['expenseCategories']; + incomeExpenses: OverviewData['incomeExpenses']; + netWorth: OverviewData['netWorth']; +}; diff --git a/apps/web/src/lib/server/accounts.test.ts b/apps/web/src/lib/server/accounts.test.ts index e081199..417b4b4 100644 --- a/apps/web/src/lib/server/accounts.test.ts +++ b/apps/web/src/lib/server/accounts.test.ts @@ -1,5 +1,4 @@ import type { AccountConfig } from '@hledger-reports/config'; -import type { StoredAccount } from '@hledger-reports/snapshots'; import { expect, it } from 'vitest'; import { registerDirectory, registerMembers } from './accounts.js'; @@ -20,7 +19,7 @@ it('joins canonical names without inventing IDs or counts and ignores label chan { account: 'assets:bank', label: 'Checking', register: 'cash' }, { account: 'ASSETS', register: 'cash' } ]; - const catalog: StoredAccount[] = [ + const catalog: Parameters[1] = [ { id: 'abcd1234', name: 'assets:bank', access: 'ready', rowCount: 0 }, { id: 'ambig000', name: 'ASSETS', access: 'ambiguous', rowCount: null }, { id: 'other000', name: 'missing:child', access: 'ready', rowCount: 3 } @@ -28,8 +27,8 @@ it('joins canonical names without inventing IDs or counts and ignores label chan const directory = registerDirectory(registerMembers(accounts, 'cash'), catalog); expect(directory).toEqual([ { id: null, name: 'missing', access: 'missing', rowCount: null }, - catalog[0], - catalog[1] + { id: 'abcd1234', name: 'assets:bank', access: 'ready', rowCount: 0 }, + { id: 'ambig000', name: 'ASSETS', access: 'ambiguous', rowCount: null } ]); const relabeled = accounts.map((entry) => ({ ...entry, label: 'New label' })); expect(registerDirectory(registerMembers(relabeled, 'cash'), catalog)).toEqual(directory); diff --git a/apps/web/src/lib/server/accounts.ts b/apps/web/src/lib/server/accounts.ts index d9710d6..f951c2c 100644 --- a/apps/web/src/lib/server/accounts.ts +++ b/apps/web/src/lib/server/accounts.ts @@ -11,7 +11,7 @@ export function registerMembers( export function registerDirectory( members: readonly string[], - catalog: readonly StoredAccount[] + catalog: readonly Pick[] ): RegisterAccount[] { return members.map((name) => { const saved = catalog.find((entry) => entry.name === name); diff --git a/apps/web/src/lib/server/preparation.integration.test.ts b/apps/web/src/lib/server/preparation.integration.test.ts index 318d81f..6e38240 100644 --- a/apps/web/src/lib/server/preparation.integration.test.ts +++ b/apps/web/src/lib/server/preparation.integration.test.ts @@ -21,31 +21,13 @@ const directories: string[] = []; const safeMessage = 'Reports preparation is unavailable.'; const privateMessage = 'PRIVATE /journal financial output'; const ready = { - expenseCategories: { depth: 2, rows: [] }, - netWorth: { - currency: null, - points: [ - '2025-11-30', - '2025-12-31', - '2026-01-31', - '2026-02-28', - '2026-03-31', - '2026-04-30' - ].map((date) => ({ date, amounts: [], unconverted: [] })) - }, - asOf: '2026-04-30', - preparedAt: '2026-05-01T00:00:00.000Z', - journalCoverage: { transactionCount: 10, firstDate: '2026-01-01', lastDate: '2026-04-30' }, - incomeExpenses: { - months: [ - { - begin: '2026-04-01', - endExclusive: '2026-05-01', - income: [{ commodity: 'USD', quantity: '9007199254740993.000000000000000001' }], - expenses: [] - } - ] - } + asOf: null, + preparedAt: '2026-05-01T12:34:56Z', + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + ranges: null, + balanceSheet: null, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] } } satisfies OverviewData; afterEach(async () => { @@ -149,7 +131,7 @@ it('joins 16 racing explicit requests through real SDK atomic keys on two backen }); }); -it('joins a running worker, returns acceptance immediately, and preserves its full ready SQLite result', async () => { +it('joins a running worker, returns acceptance immediately, and observes completion', async () => { const { first, second } = await setup(); const started = deferred(); const finish = deferred(); @@ -176,7 +158,6 @@ it('joins a running worker, returns acceptance immediately, and preserves its fu await expect.poll(() => readReportsPreparation(second)).toBe('idle'); const saved = await second.backend.getWorkflowRun({ workflowRunId: id }); expect(saved?.status).toBe('completed'); - expect(saved?.output).toEqual(ready); expect(saved?.attempts).toBe(1); expect(executions).toBe(1); }); diff --git a/apps/web/src/lib/server/reports.test.ts b/apps/web/src/lib/server/reports.test.ts index 4b7fef5..002002e 100644 --- a/apps/web/src/lib/server/reports.test.ts +++ b/apps/web/src/lib/server/reports.test.ts @@ -35,7 +35,8 @@ const publication: ReportPublication = { preparedAt: '2026-04-01T12:00:00Z', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, incomeExpenses: { months: [] }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, + balanceSheet: null, netWorth: { currency: null, points: [] } } }; diff --git a/apps/web/src/routes/(protected)/+page.server.ts b/apps/web/src/routes/(protected)/+page.server.ts index 057347a..ce00c02 100644 --- a/apps/web/src/routes/(protected)/+page.server.ts +++ b/apps/web/src/routes/(protected)/+page.server.ts @@ -1,4 +1,5 @@ import { readSavedReports, readSavedValidation } from '#lib/server/reports.js'; +import type { OverviewPageData } from '#lib/overview/data.js'; import type { PageServerLoad } from './$types'; export const load = (async (event) => { @@ -18,9 +19,46 @@ export const load = (async (event) => { readSavedReports(application.paths.snapshotDirectory, application.config.journal), readSavedValidation(application.paths.snapshotDirectory, application.config.journal) ]); + const saved = publication?.overview; + const overview: OverviewPageData | null = saved + ? { + asOf: saved.asOf, + preparedAt: saved.preparedAt, + journalCoverage: { + transactionCount: saved.journalCoverage.transactionCount, + firstDate: saved.journalCoverage.firstDate, + lastDate: saved.journalCoverage.lastDate + }, + expenseCategories: { + depth: saved.ranges === null ? 2 : saved.ranges[6].expenseCategories.depth, + rows: (saved.ranges === null ? [] : saved.ranges[6].expenseCategories.rows).map( + (row) => ({ + account: row.account, + amounts: row.amounts.map(({ commodity, quantity }) => ({ commodity, quantity })) + }) + ) + }, + incomeExpenses: { + months: saved.incomeExpenses.months.slice(-6).map((month) => ({ + begin: month.begin, + endExclusive: month.endExclusive, + income: month.income.map(({ commodity, quantity }) => ({ commodity, quantity })), + expenses: month.expenses.map(({ commodity, quantity }) => ({ commodity, quantity })) + })) + }, + netWorth: { + currency: saved.netWorth.currency, + points: saved.netWorth.points.slice(-6).map((point) => ({ + date: point.date, + amounts: point.amounts.map(({ commodity, quantity }) => ({ commodity, quantity })), + unconverted: point.unconverted + })) + } + } + : null; return { configured: true, - overview: publication?.overview ?? null, + overview, readFailed, validation, validationReadFailed diff --git a/apps/web/src/routes/(protected)/holdings/page.server.test.ts b/apps/web/src/routes/(protected)/holdings/page.server.test.ts index 0e83067..384df9e 100644 --- a/apps/web/src/routes/(protected)/holdings/page.server.test.ts +++ b/apps/web/src/routes/(protected)/holdings/page.server.test.ts @@ -1,6 +1,7 @@ import { testConfig } from '#lib/server/application.fixture.js'; import { beforeEach, expect, it, vi } from 'vitest'; import { type JobsClient } from '@hledger-reports/jobs'; +import type { OverviewRange } from '@hledger-reports/hledger'; import { readReportPublication, type ReportPublication } from '@hledger-reports/snapshots'; import { runHoldingsReport } from '#lib/server/holdings.js'; import type { ApplicationContext } from '#lib/server/application.js'; @@ -19,30 +20,83 @@ const client = { const readLocals = vi.fn<() => App.Locals>(); const journal = '/PRIVATE/current.journal'; const investmentAccounts: ReportPublication['accounts'] = [ - { id: 'broker01', name: 'assets:Broker (A.*)', access: 'ready', rowCount: 3 }, - { id: 'ambig001', name: 'ASSETS', access: 'ambiguous', rowCount: null }, - { id: 'ambig002', name: 'assets', access: 'ambiguous', rowCount: null } + { + id: 'broker01', + name: 'assets:Broker (A.*)', + access: 'ready', + rowCount: 3, + summary: { status: 'ready', balances: [], valuation: { amounts: [], unconverted: [] } } + }, + { + id: 'ambig001', + name: 'ASSETS', + access: 'ambiguous', + rowCount: null, + summary: { status: 'ambiguous' } + }, + { + id: 'ambig002', + name: 'assets', + access: 'ambiguous', + rowCount: null, + summary: { status: 'ambiguous' } + } ]; const nonInvestmentSettings: ApplicationContext['config']['accounts'] = [ { account: 'expenses:fees', register: 'cash' }, { account: 'assets:labeled', label: 'Investments' } ]; +const anchorRange: OverviewRange = { + begin: '2026-04-01', + endExclusive: '2026-04-17', + income: [], + expenses: [], + net: [], + expenseCategories: { depth: 2, rows: [] } +}; const publication: ReportPublication = { journal, directory: '/PRIVATE/completed-set', accounts: [ ...investmentAccounts, - { id: 'expense1', name: 'expenses:fees', access: 'ready', rowCount: 0 }, - { id: 'label001', name: 'assets:labeled', access: 'ready', rowCount: 1 }, - { id: 'child001', name: 'assets:Broker (A.*):child', access: 'ready', rowCount: 1 } + { + id: 'expense1', + name: 'expenses:fees', + access: 'ready', + rowCount: 0, + summary: { status: 'ready', balances: [], valuation: { amounts: [], unconverted: [] } } + }, + { + id: 'label001', + name: 'assets:labeled', + access: 'ready', + rowCount: 1, + summary: { status: 'ready', balances: [], valuation: { amounts: [], unconverted: [] } } + }, + { + id: 'child001', + name: 'assets:Broker (A.*):child', + access: 'ready', + rowCount: 1, + summary: { status: 'ready', balances: [], valuation: { amounts: [], unconverted: [] } } + } ], overview: { asOf: '2026-04-16', preparedAt: '2026-05-01T12:34:56Z', journalCoverage: { transactionCount: 3, firstDate: '2026-04-01', lastDate: '2026-04-16' }, - expenseCategories: { depth: 2, rows: [] }, - netWorth: { currency: null, points: [] }, - incomeExpenses: { months: [] } + ranges: { + 1: anchorRange, + 3: { ...anchorRange, begin: '2026-02-01' }, + 6: { ...anchorRange, begin: '2025-11-01' }, + 12: { ...anchorRange, begin: '2025-05-01' } + }, + balanceSheet: { + assets: { amounts: [], unconverted: [] }, + liabilities: { amounts: [], unconverted: [] } + }, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] } } }; const report = { diff --git a/apps/web/src/routes/(protected)/page.server.test.ts b/apps/web/src/routes/(protected)/page.server.test.ts index 0ea07ad..f097ee3 100644 --- a/apps/web/src/routes/(protected)/page.server.test.ts +++ b/apps/web/src/routes/(protected)/page.server.test.ts @@ -1,7 +1,7 @@ import { testConfig } from '#lib/server/application.fixture.js'; import { beforeEach, expect, it, vi } from 'vitest'; import { readReportPublication, readValidation } from '@hledger-reports/snapshots'; -import type { OverviewData } from '@hledger-reports/hledger'; +import type { OverviewData, OverviewRange } from '@hledger-reports/hledger'; import type { ApplicationContext } from '#lib/server/application.js'; import { load } from './+page.server.js'; @@ -20,27 +20,88 @@ vi.mock('yaml', () => { }); const journal = '/PRIVATE/main.journal'; +const nonSixMonthRange: OverviewRange = { + begin: '2025-05-01', + endExclusive: '2026-04-17', + income: [{ commodity: 'PRIVATE income', quantity: '1' }], + expenses: [{ commodity: 'PRIVATE expenses', quantity: '2' }], + net: [{ commodity: 'PRIVATE net', quantity: '3' }], + expenseCategories: { + depth: 2, + rows: [{ account: 'PRIVATE non-six category', amounts: [] }] + } +}; +const historyDates = [ + ['2025-05-01', '2025-06-01', '2025-05-31'], + ['2025-06-01', '2025-07-01', '2025-06-30'], + ['2025-07-01', '2025-08-01', '2025-07-31'], + ['2025-08-01', '2025-09-01', '2025-08-31'], + ['2025-09-01', '2025-10-01', '2025-09-30'], + ['2025-10-01', '2025-11-01', '2025-10-31'], + ['2025-11-01', '2025-12-01', '2025-11-30'], + ['2025-12-01', '2026-01-01', '2025-12-31'], + ['2026-01-01', '2026-02-01', '2026-01-31'], + ['2026-02-01', '2026-03-01', '2026-02-28'], + ['2026-03-01', '2026-04-01', '2026-03-31'], + ['2026-04-01', '2026-05-01', '2026-04-16'] +]; +const months: OverviewData['incomeExpenses']['months'] = historyDates.map( + ([begin, endExclusive]) => ({ begin, endExclusive, income: [], expenses: [] }) +); +const points: OverviewData['netWorth']['points'] = historyDates.map(([, , date]) => ({ + date, + amounts: [], + unconverted: [] +})); +const lastDroppedMonth = months[5]; +lastDroppedMonth.income = [{ commodity: 'DROPPED income', quantity: '1' }]; +const lastDroppedPoint = points[5]; +lastDroppedPoint.unconverted = ['DROPPED stock']; +const firstRetainedMonth = months[6]; +firstRetainedMonth.income = [{ commodity: 'USD', quantity: '9007199254740993.000000000000000001' }]; +const latestPoint = points[11]; +latestPoint.amounts = [{ commodity: 'USD', quantity: '0.000000000000000001' }]; +latestPoint.unconverted = ['STOCK']; const overview: OverviewData = { - expenseCategories: { depth: 2, rows: [] }, - netWorth: { currency: null, points: [] }, + ranges: { + 1: nonSixMonthRange, + 3: nonSixMonthRange, + 6: { + ...nonSixMonthRange, + begin: '2025-11-01', + expenseCategories: { + depth: 2, + rows: [{ account: 'expenses:six-month', amounts: [{ commodity: 'USD', quantity: '6' }] }] + } + }, + 12: nonSixMonthRange + }, + balanceSheet: { + assets: { amounts: [{ commodity: 'PRIVATE assets', quantity: '7' }], unconverted: [] }, + liabilities: { amounts: [], unconverted: ['PRIVATE liabilities'] } + }, + netWorth: { currency: 'USD', points }, asOf: '2026-04-16', preparedAt: '2026-10-21T12:00:00Z', journalCoverage: { transactionCount: 9, firstDate: '2026-04-01', lastDate: '2026-04-16' }, - incomeExpenses: { - months: [ - { - begin: '2026-04-01', - endExclusive: '2026-05-01', - income: [{ commodity: 'USD', quantity: '9007199254740993.000000000000000001' }], - expenses: [{ commodity: 'EUR', quantity: '-5.00' }] - } - ] - } + incomeExpenses: { months } }; const publication = { overview, journal, - accounts: [{ id: 'abcd1234', name: 'PRIVATE account', access: 'ready' as const, rowCount: 101 }], + accounts: [ + { + id: 'abcd1234', + name: 'PRIVATE account', + access: 'ready' as const, + rowCount: 101, + summary: { + status: 'ready' as const, + balances: [{ commodity: 'PRIVATE units', quantity: '123' }], + valuation: { amounts: [], unconverted: ['PRIVATE units'] } + } + } + ], directory: '/PRIVATE/completed-set' }; const readLocals = vi.fn<(application: ApplicationContext | null) => App.Locals>(); @@ -101,26 +162,90 @@ it('returns an unconfigured shell without reads when application is unavailable' expect(readReportPublication).not.toHaveBeenCalled(); expect(readValidation).not.toHaveBeenCalled(); }); -it('passively returns the configured saved overview without private publication context', async () => { +it('passively returns only rendered six-month fields from the complete saved publication', async () => { + const privateSource = { + ...publication, + overview: { + ...overview, + provenance: '/PRIVATE/source', + journalCoverage: { ...overview.journalCoverage, fingerprint: 'PRIVATE identity' } + } + }; + vi.mocked(readReportPublication).mockResolvedValue(privateSource); const request = event(); const data = await load(request); expect(data).toEqual({ configured: true, - overview, + overview: { + asOf: '2026-04-16', + preparedAt: '2026-10-21T12:00:00Z', + journalCoverage: { transactionCount: 9, firstDate: '2026-04-01', lastDate: '2026-04-16' }, + expenseCategories: { + depth: 2, + rows: [{ account: 'expenses:six-month', amounts: [{ commodity: 'USD', quantity: '6' }] }] + }, + incomeExpenses: { months: expect.any(Array) }, + netWorth: { currency: 'USD', points: expect.any(Array) } + }, readFailed: false, validation: null, validationReadFailed: false }); - expect(data.overview).toBe(overview); + const renderedMonths = data.overview!.incomeExpenses.months; + const renderedPoints = data.overview!.netWorth.points; + expect(renderedMonths).toHaveLength(6); + expect(renderedPoints).toHaveLength(6); + expect(renderedMonths[0]).toEqual({ + begin: '2025-11-01', + endExclusive: '2025-12-01', + income: [{ commodity: 'USD', quantity: '9007199254740993.000000000000000001' }], + expenses: [] + }); + expect(renderedMonths[5]).toEqual({ + begin: '2026-04-01', + endExclusive: '2026-05-01', + income: [], + expenses: [] + }); + expect(renderedPoints[0]).toEqual({ date: '2025-11-30', amounts: [], unconverted: [] }); + expect(renderedPoints[5]).toEqual({ + date: '2026-04-16', + amounts: [{ commodity: 'USD', quantity: '0.000000000000000001' }], + unconverted: ['STOCK'] + }); expect(readReportPublication).toHaveBeenCalledExactlyOnceWith('/PRIVATE/context/snapshots'); expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); - expect(JSON.stringify(data)).not.toMatch(/PRIVATE|directory|accounts|transactions|fingerprint/); + expect(JSON.stringify(data)).not.toMatch( + /PRIVATE|DROPPED|directory|accounts|transactions|fingerprint/ + ); }); -it.each(['missing', 'other-journal'])( +it('keeps an undated publication empty without inventing categories or history', async () => { + vi.mocked(readReportPublication).mockResolvedValue({ + ...publication, + overview: { + asOf: null, + preparedAt: overview.preparedAt, + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + ranges: null, + balanceSheet: null, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] } + } + }); + expect((await load(event())).overview).toEqual({ + asOf: null, + preparedAt: overview.preparedAt, + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + expenseCategories: { depth: 2, rows: [] }, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] } + }); +}); +it.each(['missing-or-unsupported', 'other-journal'])( 'withholds unavailable data for %s without inventing a report', async (condition) => { vi.mocked(readReportPublication).mockResolvedValue( - condition === 'missing' ? null : { ...publication, journal: '/PRIVATE/other' } + condition === 'missing-or-unsupported' ? null : { ...publication, journal: '/PRIVATE/other' } ); expect(await load(event())).toEqual({ configured: true, @@ -150,20 +275,18 @@ it('loads saved validation independently without private identity or financial p request: { id: 'PRIVATE request', createdAt: '2026-10-22T11:00:00Z' }, result: validation }); - expect(await load(event())).toEqual({ - configured: true, - overview, - readFailed: false, - validation, - validationReadFailed: false - }); + const passed = await load(event()); + expect(passed).toMatchObject({ readFailed: false, validation, validationReadFailed: false }); + expect(passed.overview?.asOf).toBe('2026-04-16'); + expect(passed.validation).toEqual(validation); + expect(JSON.stringify(passed)).not.toMatch(/PRIVATE|request|format/); expect(readValidation).toHaveBeenCalledExactlyOnceWith('/PRIVATE/context/snapshots'); vi.mocked(readValidation).mockRejectedValue(new Error('PRIVATE corrupt validation')); - expect(await load(event())).toEqual({ - configured: true, - overview, + const failed = await load(event()); + expect(failed).toMatchObject({ readFailed: false, validation: null, validationReadFailed: true }); + expect(failed.overview?.asOf).toBe('2026-04-16'); }); diff --git a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts index 6568f30..1c246b2 100644 --- a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts @@ -28,12 +28,13 @@ const name = 'custom:銀行'; const publication: ReportPublication = { journal: testConfig.journal, directory: '/PRIVATE/pinned-set', - accounts: [{ id, name, access: 'ready', rowCount: 51 }], + accounts: [{ id, name, access: 'ready', rowCount: 51, summary: { status: 'undated' } }], overview: { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, + balanceSheet: null, netWorth: { currency: null, points: [] }, incomeExpenses: { months: [] } } diff --git a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts index fe2c1c0..fef6e4d 100644 --- a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts @@ -20,12 +20,22 @@ const member = { id: 'abcd1234', name: 'custom:銀行', access: 'ready' as const const publication: ReportPublication = { journal: testConfig.journal, directory: '/PRIVATE/set', - accounts: [member, { id: 'other001', name: 'custom:other', access: 'ready', rowCount: 1 }], + accounts: [ + { ...member, summary: { status: 'undated' } }, + { + id: 'other001', + name: 'custom:other', + access: 'ready', + rowCount: 1, + summary: { status: 'undated' } + } + ], overview: { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, + balanceSheet: null, netWorth: { currency: null, points: [] }, incomeExpenses: { months: [] } } diff --git a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts index 60aef1d..0aa6311 100644 --- a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts @@ -28,12 +28,13 @@ const name = 'custom:銀行'; const publication: ReportPublication = { journal: testConfig.journal, directory: '/PRIVATE/pinned-set', - accounts: [{ id, name, access: 'ready', rowCount: 51 }], + accounts: [{ id, name, access: 'ready', rowCount: 51, summary: { status: 'undated' } }], overview: { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, + balanceSheet: null, netWorth: { currency: null, points: [] }, incomeExpenses: { months: [] } } diff --git a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts index 266390b..33fe264 100644 --- a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts @@ -20,12 +20,22 @@ const member = { id: 'abcd1234', name: 'custom:銀行', access: 'ready' as const const publication: ReportPublication = { journal: testConfig.journal, directory: '/PRIVATE/set', - accounts: [member, { id: 'other001', name: 'custom:other', access: 'ready', rowCount: 1 }], + accounts: [ + { ...member, summary: { status: 'undated' } }, + { + id: 'other001', + name: 'custom:other', + access: 'ready', + rowCount: 1, + summary: { status: 'undated' } + } + ], overview: { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, + balanceSheet: null, netWorth: { currency: null, points: [] }, incomeExpenses: { months: [] } } diff --git a/apps/web/src/routes/(protected)/settings/page.server.test.ts b/apps/web/src/routes/(protected)/settings/page.server.test.ts index 2f2a15b..a276a16 100644 --- a/apps/web/src/routes/(protected)/settings/page.server.test.ts +++ b/apps/web/src/routes/(protected)/settings/page.server.test.ts @@ -90,7 +90,8 @@ it('serializes only saved time, never financial data, journal or publication dir accounts: [], overview: { journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, + balanceSheet: null, netWorth: { currency: null, points: [] }, preparedAt, asOf: null, diff --git a/apps/worker/README.md b/apps/worker/README.md index 45c5729..7b509e5 100644 --- a/apps/worker/README.md +++ b/apps/worker/README.md @@ -34,8 +34,6 @@ src/workflows/ run.ts run.test.ts run.integration.test.ts - accounts.integration.test.ts - cash.integration.test.ts drain.integration.test.ts edits.integration.test.ts test-fixtures.ts @@ -69,19 +67,20 @@ not a completed financial set or proof of unchanged inputs. Snapshots owns immutable evidence and its selection rules; the web exposes only the saved outcome/time. See [validation ownership](../../docs/architecture.md#last-recorded-validation) for failure semantics and [recorded evidence](../../packages/snapshots/README.md#recorded-validation) -for storage/privacy/retention. Financial format **5**, six-month views and workflow -identity/version/output/retry policy are unchanged. +for storage/privacy/retention. Financial format **6** replaces 5; the web deliberately +keeps its six-month presentation. The workflow identity/version, void input, +OverviewData output contract and retry policy are unchanged. -After recording a pass, the handler reads the previous saved publication, allowing missing, corrupt or unsupported data to be replaced by an explicit job. Only a usable current-format publication with a matching journal path allows reuse of prior account IDs. It creates a stage and calls `prepareReports(journal, { writeRows: stage.writeRows })`. The library uses one cached hledger journal view for Overview and every full-history account register, delivering exact rows in awaited, ordered batches of at most 50. These are not persisted pages: the store writes **one whole JSON file per account** without pooling every register in Node. The library owns account selection, dates, quantities and native balances. Edits during a build do not trigger another job: the publication represents the view loaded for that execution. +After recording a pass, the handler reads the previous saved publication, allowing missing, corrupt or unsupported data to be replaced by an explicit job. Only a usable current-format publication with a matching journal path allows reuse of prior account IDs. It creates a stage and calls `prepareReports(journal, { writeRows: stage.writeRows })`. The library uses one cached hledger journal view for native 1/3/6/12-month income/expenses/NET/category totals, twelve shared monthly columns, twelve net-worth points, latest assets/liabilities, two bulk raw/valued account reports and every full-history account register, delivering exact rows in awaited, ordered batches of at most 50. For dated journals this is 4 income statements + 12 balance sheets + 2 bulk balances = **18 financial report commands**, still two financial processes including discovery, plus the separate check = **three processes for the whole job**. Hledger owns all financial arithmetic and the latest-primary-date cutoff, including partial months; no JS sums, per-configured-member reports or WorkerConfig changes are added. These are not persisted pages: the store writes **one whole JSON file per account** without pooling every register in Node. The library owns account selection, dates, quantities and native balances. Edits during a build do not trigger another job: the publication represents the view loaded for that execution. After successful native production and awaited staged I/O, `stage.publish(reports.overview, reports.accounts, journal)` commits the complete Overview, catalog and account files. The handler returns **its own calculated OverviewData**, not a readback of whichever publication is currently selected, and always awaits stage discard. Failure before the financial directory commit retains the previous complete set. An inspection-only `current` alias failure after commit may fail the job while the new valid complete set remains selectable. No completed set is rolled back or deleted; Settings tells users to reload saved reports, not that failure guarantees unchanged data. There are no SDK financial checkpoints; old queued runs with legacy steps can finish under this handler without changing those steps. -Only publication format **5** is accepted; this change does not bump it. Whole-account files retain the library-owned original transaction and every posting, comments, ordered tags, exact nested amounts, account context and private source/assertion positions. These records reflect hledger's account-register export, not a verbatim source archive or complete investment cost/basis record; the worker does not recover annotations stripped by hledger. Source paths remain private stored metadata, never browser payloads or job output. There is no application-imposed saved-file size cap. +Only publication format **6** is accepted. Required raw inclusive balances and native end-valued amount bags with `unconverted` evidence are stored in the account catalog; investment values include uninvested cash. Existing native canonical qualification, exact full names, declared/used membership and register rows are unchanged. Whole-account files retain the library-owned original transaction and every posting, comments, ordered tags, exact nested amounts, account context and private source/assertion positions. These records reflect hledger's account-register export, not a verbatim source archive or complete investment cost/basis record; the worker does not recover annotations stripped by hledger. Source paths remain private stored metadata, never browser payloads or job output. There is no application-imposed saved-file size cap. -The store pins an immutable private `.sets/` directory for each read; `current/` is an atomically updated human-inspection alias. Unsupported formats (including format 4, format 3's table-only rows, formats 2/1 and raw Overview files) provide no current report data or reusable account IDs. Their files remain untouched: the user must explicitly **Prepare reports again**, with no automatic upgrade, snapshot migration, compatibility reader or garbage collection. This report-file policy is separate from the queued SDK job policies described here. OpenWorkflow persists only the handler's own **OverviewData** financial result in its private SQLite database, not duplicate account transaction arrays or source positions. +The store pins an immutable private `.sets/` directory for each read; `current/` is an atomically updated human-inspection alias. Unsupported formats (including format 5 and all older shapes) provide no current report data or reusable account IDs. To activate format 6, stop old preparation execution and deploy compatible web and worker revisions together before explicitly **Prepare reports again** in Settings. Their format-5 files remain untouched but unusable, and account IDs may reset across the transition. There is no automatic work, fallback, migration, compatibility reader or garbage collection. This report-file policy is separate from the queued SDK job policies described here. OpenWorkflow persists only the handler's own **OverviewData** financial result in its private SQLite database, not duplicate account transaction arrays or source positions. See the [snapshot store](../../packages/snapshots/README.md) for last-good recovery and storage growth, and [web requester](../web/README.md) for request coordination and [shared contracts](../../packages/jobs/README.md) for persisted identities. diff --git a/apps/worker/src/workflows/index.test.ts b/apps/worker/src/workflows/index.test.ts index 2ea17dc..cad9d59 100644 --- a/apps/worker/src/workflows/index.test.ts +++ b/apps/worker/src/workflows/index.test.ts @@ -124,47 +124,13 @@ it('registers prepare directly and passes its result through unchanged', async ( run: { id: 'example', createdAt: new Date('2026-10-21T12:00:00Z') } }; const data: OverviewData = { - asOf: '2026-04-16', + asOf: null, preparedAt: '2026-10-21T12:00:01Z', - journalCoverage: { - transactionCount: 2, - firstDate: '2026-04-01', - lastDate: '2026-04-16' - }, - expenseCategories: { - depth: 2, - rows: [{ account: 'expenses:food', amounts: [{ commodity: 'USD', quantity: '100.00' }] }] - }, - netWorth: { - currency: 'USD', - points: [ - '2025-11-30', - '2025-12-31', - '2026-01-31', - '2026-02-28', - '2026-03-31', - '2026-04-16' - ].map((date, index) => ({ - date, - amounts: index === 5 ? [{ commodity: 'USD', quantity: '900.00' }] : [], - unconverted: [] - })) - }, - incomeExpenses: { - months: [ - ['2025-11-01', '2025-12-01'], - ['2025-12-01', '2026-01-01'], - ['2026-01-01', '2026-02-01'], - ['2026-02-01', '2026-03-01'], - ['2026-03-01', '2026-04-01'], - ['2026-04-01', '2026-05-01'] - ].map(([begin, endExclusive], index) => ({ - begin, - endExclusive, - income: index === 5 ? [{ commodity: 'USD', quantity: '1000.00' }] : [], - expenses: index === 5 ? [{ commodity: 'USD', quantity: '100.00' }] : [] - })) - } + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + ranges: null, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] }, + balanceSheet: null }; prepare.mockResolvedValue(data); expect(await registration![1](context)).toBe(data); diff --git a/apps/worker/src/workflows/prepare-reports/accounts.integration.test.ts b/apps/worker/src/workflows/prepare-reports/accounts.integration.test.ts deleted file mode 100644 index fce9adb..0000000 --- a/apps/worker/src/workflows/prepare-reports/accounts.integration.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { enqueuePreparation, financialValues } from './test-fixtures.js'; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { expect, it } from 'vitest'; -import { openJobsClient, prepareReportsSpec } from '@hledger-reports/jobs'; -import { readAccountTransactions, readReportPublication } from '@hledger-reports/snapshots'; -import { startWorker } from '../../index.js'; - -it('publishes whole-account files with exact native balances across a browser page boundary', async () => { - const directory = await mkdtemp(join(tmpdir(), 'prepared-accounts-')); - const journal = join(directory, 'main.journal'); - const snapshots = join(directory, 'data', 'snapshots'); - const config = join(directory, 'config', 'hledger-reports.yaml'); - const quantity = '9007199254740993.01'; - const source = [ - 'account assets:precise\naccount equity:opening\naccount expenses:unused\ncommodity UNIT\ncommodity EUR\n', - ...Array.from( - { length: 57 }, - (_, index) => - `2026-04-16 Entry ${index + 1}\n assets:precise ${index ? '0.0049 UNIT' : `${quantity} EUR`}\n equity:opening\n` - ) - ].join('\n'); - await mkdir(join(directory, 'config')); - await writeFile( - config, - JSON.stringify({ - journal, - url: { app: 'http://127.0.0.1:3000' }, - auth: { credentials: { username: 'fixture', password: '${AUTH_PASSWORD}' } } - }) - ); - await writeFile(journal, source); - const client = await openJobsClient(join(directory, 'data', 'jobs')); - let service: Awaited> | undefined; - try { - service = await startWorker(config, { port: 0 }); - await enqueuePreparation(client); - await expect - .poll( - async () => - (await client.backend.listWorkflowRuns({ workflowName: prepareReportsSpec.name })).data[0] - ?.status, - { timeout: 12_000 } - ) - .toMatch(/^(completed|failed)$/); - expect( - (await client.backend.listWorkflowRuns({ workflowName: prepareReportsSpec.name })).data[0] - ).toMatchObject({ status: 'completed', error: null }); - const publication = (await readReportPublication(snapshots))!; - const account = publication.accounts!.find((entry) => entry.name === 'assets:precise')!; - expect(account).toMatchObject({ access: 'ready', rowCount: 57 }); - expect(account.id).toMatch(/^[a-z0-9]{8}$/); - const { transactions } = await readAccountTransactions(publication, account.id); - expect(transactions).toHaveLength(57); - expect(financialValues(transactions![0].change)).toEqual([{ commodity: 'EUR', quantity }]); - // Pagination is only a view over a whole-account read, never stored pages. - const secondPage = transactions!.slice(50, 100); - expect(secondPage).toHaveLength(7); - expect(secondPage[0].transaction.description).toBe('Entry 51'); - expect(financialValues(secondPage[0].change)).toEqual([ - { commodity: 'UNIT', quantity: '0.0049' } - ]); - expect(financialValues(secondPage[0].balance)).toEqual([ - { commodity: 'EUR', quantity }, - { commodity: 'UNIT', quantity: '0.2450' } - ]); - expect(financialValues(secondPage[6].balance)).toEqual([ - { commodity: 'EUR', quantity }, - { commodity: 'UNIT', quantity: '0.2744' } - ]); - const unused = publication.accounts!.find((entry) => entry.name === 'expenses:unused')!; - expect(unused).toMatchObject({ access: 'ready', rowCount: 0 }); - expect((await readAccountTransactions(publication, unused.id)).transactions).toEqual([]); - expect((await readdir(publication.directory!)).sort()).toEqual([ - 'overview.json', - 'transactions' - ]); - expect((await readdir(join(publication.directory!, 'transactions'))).sort()).toEqual( - publication.accounts!.map((entry) => `${entry.id}.json`).sort() - ); - expect( - JSON.parse( - await readFile(join(snapshots, 'current', 'transactions', `${account.id}.json`), 'utf8') - ) - ).toEqual({ id: account.id, name: account.name, transactions }); - const output = ( - await client.backend.listWorkflowRuns({ workflowName: prepareReportsSpec.name }) - ).data[0]!.output; - expect(output).toEqual(publication.overview); - expect(JSON.stringify(output)).not.toContain(publication.directory!); - expect(await readFile(journal, 'utf8')).toBe(source); - } finally { - await service?.stop(); - await client.close(); - await rm(directory, { recursive: true, force: true }); - } -}, 20_000); diff --git a/apps/worker/src/workflows/prepare-reports/cash.integration.test.ts b/apps/worker/src/workflows/prepare-reports/cash.integration.test.ts deleted file mode 100644 index d515d25..0000000 --- a/apps/worker/src/workflows/prepare-reports/cash.integration.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { expect, it } from 'vitest'; -import { openJobsClient, prepareReportsSpec } from '@hledger-reports/jobs'; -import { readAccountTransactions, readReportPublication } from '@hledger-reports/snapshots'; -import { createPrepareReports } from './run.js'; -import { financialValues } from './test-fixtures.js'; - -// Worker-owned fictional input: no runtime dependency on another package's fixtures. -const source = `commodity USD -account assets:bank -account equity:opening -account expenses:food -account expenses:household - -2026-04-01 Opening - assets:bank 1000.00 USD - equity:opening - -; receipt retained privately -2026-04-02 * (receipt-42) Market | supplies - ; shopping note - ; project: home - assets:bank -100.00 USD = 900.00 USD ; bank note - expenses:food 75.00 USD ; food note - expenses:household 25.00 USD ; household note -`; - -it('stores full cash detail and private source paths while SQLite retains only its Overview result', async () => { - const directory = await mkdtemp(join(tmpdir(), 'prepared-cash-')); - const journal = join(directory, 'main.journal'); - const included = join(directory, 'private-cash.journal'); - const snapshots = join(directory, 'snapshots'); - const rootSource = 'include private-cash.journal\n'; - await writeFile(journal, rootSource); - await writeFile(included, source); - const client = await openJobsClient(join(directory, 'jobs')); - // Exercise the real SDK and handler without a health server or application startup. - client.workflow.implementWorkflow(prepareReportsSpec, createPrepareReports(journal, snapshots)); - const worker = client.workflow.newWorker({ concurrency: 1 }); - try { - await worker.start(); - const handle = await client.workflow.runWorkflow(prepareReportsSpec, undefined); - const output = await handle.result({ timeoutMs: 12_000 }); - const publication = (await readReportPublication(snapshots))!; - const account = publication.accounts.find((entry) => entry.name === 'assets:bank')!; - const { transactions } = await readAccountTransactions(publication, account.id); - expect(transactions).toHaveLength(2); - const split = transactions![1]; - expect(split.accountPostingIndexes).toEqual([0]); - expect(financialValues(split.change)).toEqual([{ commodity: 'USD', quantity: '-100.00' }]); - expect(financialValues(split.balance)).toEqual([{ commodity: 'USD', quantity: '900.00' }]); - expect(split.transaction).toMatchObject({ - index: 2, - date: '2026-04-02', - status: 'Cleared', - code: 'receipt-42', - description: 'Market | supplies', - payee: 'Market', - note: 'supplies', - tags: [['project', 'home']] - }); - expect(split.transaction.comment).toContain('shopping note'); - expect(split.transaction.precedingComment).toContain('receipt retained privately'); - expect( - split.transaction.postings.map((posting) => ({ - account: posting.account, - amounts: financialValues(posting.amounts), - comment: posting.comment.trim() - })) - ).toEqual([ - { - account: 'assets:bank', - amounts: [{ commodity: 'USD', quantity: '-100.00' }], - comment: 'bank note' - }, - { - account: 'expenses:food', - amounts: [{ commodity: 'USD', quantity: '75.00' }], - comment: 'food note' - }, - { - account: 'expenses:household', - amounts: [{ commodity: 'USD', quantity: '25.00' }], - comment: 'household note' - } - ]); - expect(split.transaction.postings.map((posting) => posting.transactedAmounts)).toEqual([ - [{ commodity: 'USD', quantity: '-100.00' }], - [{ commodity: 'USD', quantity: '75.00' }], - [{ commodity: 'USD', quantity: '25.00' }] - ]); - expect(split.investment.securities).toEqual([]); - expect(split.transaction.sourcePositions.length).toBeGreaterThan(0); - for (const position of split.transaction.sourcePositions) { - expect(position.file).toBe(included); - expect(position.line).toBeGreaterThan(0); - } - const assertion = split.transaction.postings[0].balanceAssertion!; - expect(financialValues([assertion.amount])).toEqual([{ commodity: 'USD', quantity: '900.00' }]); - expect(assertion.sourcePosition?.file).toBe(included); - const raw = await readFile( - join(publication.directory, 'transactions', `${account.id}.json`), - 'utf8' - ); - expect(JSON.parse(raw)).toEqual({ id: account.id, name: account.name, transactions }); - expect(raw).toContain(included); - expect((await readdir(publication.directory)).sort()).toEqual([ - 'overview.json', - 'transactions' - ]); - expect( - JSON.parse(await readFile(join(publication.directory, 'overview.json'), 'utf8')).format - ).toBe(5); - const run = await client.backend.getWorkflowRun({ workflowRunId: handle.workflowRun.id }); - expect(run).toMatchObject({ status: 'completed', output }); - expect(output).toEqual(publication.overview); - expect(JSON.stringify(run!.output)).not.toMatch( - /postings|investment|transactedAmounts|costBasis|sourcePositions/ - ); - expect(JSON.stringify(run!.output)).not.toContain(directory); - expect( - (await client.backend.listStepAttempts({ workflowRunId: handle.workflowRun.id })).data - ).toEqual([]); - expect(await readFile(journal, 'utf8')).toBe(rootSource); - expect(await readFile(included, 'utf8')).toBe(source); - } finally { - await worker.stop(); - await client.close(); - await rm(directory, { recursive: true, force: true }); - } -}, 20_000); diff --git a/apps/worker/src/workflows/prepare-reports/edits.integration.test.ts b/apps/worker/src/workflows/prepare-reports/edits.integration.test.ts index 03d1f17..c933491 100644 --- a/apps/worker/src/workflows/prepare-reports/edits.integration.test.ts +++ b/apps/worker/src/workflows/prepare-reports/edits.integration.test.ts @@ -108,7 +108,7 @@ it('explicitly prepares glob price additions/removals and atomic include/root sa const priced = (await readReportPublication(f.snapshots))!; expect(priced.overview.journalCoverage).toEqual(first.overview.journalCoverage); expect(priced.overview.asOf).toBe(first.overview.asOf); - expect(priced.overview.netWorth?.points[5].amounts).toEqual([ + expect(priced.overview.netWorth?.points[11].amounts).toEqual([ { commodity: 'USD', quantity: '12725.00' } ]); await rm(price); @@ -124,10 +124,10 @@ it('explicitly prepares glob price additions/removals and atomic include/root sa const edited = (await readReportPublication(f.snapshots))!; expect(edited.overview.asOf).toBe(first.overview.asOf); expect(edited.overview.journalCoverage).toEqual(first.overview.journalCoverage); - expect(edited.overview.incomeExpenses.months[5].income).toEqual([ + expect(edited.overview.incomeExpenses.months[11].income).toEqual([ { commodity: 'USD', quantity: '1500.00' } ]); - expect(edited.overview.netWorth?.points[5].amounts).toEqual([ + expect(edited.overview.netWorth?.points[11].amounts).toEqual([ { commodity: 'USD', quantity: '12325.00' } ]); await writeFile( @@ -198,7 +198,7 @@ it('publishes its loaded native view despite later edits, with no automatic foll expect(await runs(client)).toHaveLength(2); expect(nextRead.released).toBe(false); const intervening = (await readReportPublication(f.snapshots))!; - expect(intervening.overview.incomeExpenses.months[5].income).toEqual([ + expect(intervening.overview.incomeExpenses.months[11].income).toEqual([ { commodity: 'USD', quantity: '1100.00' } ]); expect(intervening.directory).not.toBe(initial.directory); @@ -209,7 +209,7 @@ it('publishes its loaded native view despite later edits, with no automatic foll nextRelease.release(); await settled(client, 3); const latest = (await readReportPublication(f.snapshots))!; - expect(latest.overview.incomeExpenses.months[5].income).toEqual([ + expect(latest.overview.incomeExpenses.months[11].income).toEqual([ { commodity: 'USD', quantity: '1300.00' } ]); expect(latest.directory).not.toBe(intervening.directory); diff --git a/apps/worker/src/workflows/prepare-reports/run.integration.test.ts b/apps/worker/src/workflows/prepare-reports/run.integration.test.ts index 5920376..7adadba 100644 --- a/apps/worker/src/workflows/prepare-reports/run.integration.test.ts +++ b/apps/worker/src/workflows/prepare-reports/run.integration.test.ts @@ -1,8 +1,4 @@ -import { - enqueuePreparation, - financialValues, - readLatestPreparationState -} from './test-fixtures.js'; +import { enqueuePreparation, readLatestPreparationState } from './test-fixtures.js'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { setTimeout as delay } from 'node:timers/promises'; @@ -97,118 +93,33 @@ it('explicitly prepares a saved overview through the real SDK without a browser expect(Date.parse(validation.result.completedAt)).toBeLessThanOrEqual( Date.parse(data.preparedAt) ); - expect(data.asOf).toBe('2026-04-16'); - expect(data.journalCoverage).toEqual({ - transactionCount: 9, - firstDate: '2026-04-01', - lastDate: '2026-04-16' - }); - expect(data.incomeExpenses.months).toHaveLength(6); - expect(data.incomeExpenses.months[5]).toMatchObject({ - begin: '2026-04-01', - endExclusive: '2026-05-01', - income: [{ commodity: 'USD', quantity: '1000.00' }], - expenses: [{ commodity: 'USD', quantity: '100.00' }] - }); - expect(data.expenseCategories).toEqual({ - depth: 2, - rows: [{ account: 'expenses:living', amounts: [{ commodity: 'USD', quantity: '100.00' }] }] - }); - expect(data.netWorth).toEqual({ - currency: 'USD', - points: [ - '2025-11-30', - '2025-12-31', - '2026-01-31', - '2026-02-28', - '2026-03-31', - '2026-04-16' - ].map((date, index) => ({ - date, - amounts: index === 5 ? [{ commodity: 'USD', quantity: '11825.00' }] : [], - unconverted: [] - })) - }); - expect(await readOverview(f.snapshots)).toEqual(data); + expect(data.incomeExpenses.months.at(-1)?.income).toEqual([ + { commodity: 'USD', quantity: '1000.00' } + ]); expect(run.output).toEqual(data); const publication = (await readReportPublication(f.snapshots))!; - expect(publication.journal).toBe(f.journal); - const brokerage = publication.accounts.find( - (account) => account.name === 'assets:investments:brokerage' - )!; - const { transactions } = await readAccountTransactions(publication, brokerage.id); - const buy = transactions!.find((row) => row.transaction.note === 'Buy VTI and BND')!; - const sale = transactions!.find((row) => row.transaction.note === 'Sell five VTI')!; - // Native decimal scales are retained; these assertions require the exact values, - // without rounding or converting financial strings to JavaScript numbers. - for (const [commodity, quantity, price, transacted] of [ - ['VTI', /^10(?:\.0+)?$/, /^100(?:\.0+)?$/, /^1000(?:\.0+)?$/], - ['BND', /^5(?:\.0+)?$/, /^80(?:\.0+)?$/, /^400(?:\.0+)?$/] - ] as const) { - const posting = buy.transaction.postings.find( - (entry) => entry.account === brokerage.name && entry.amounts[0].commodity === commodity - )!; - expect(financialValues(posting.amounts)).toEqual([ - { commodity, quantity: expect.stringMatching(quantity) } - ]); - expect(posting.amounts[0].cost).toMatchObject({ - kind: 'UnitCost', - amount: { commodity: 'USD', quantity: expect.stringMatching(price) } - }); - expect(posting.transactedAmounts).toEqual([ - { commodity: 'USD', quantity: expect.stringMatching(transacted) } - ]); - expect(buy.investment.securities).toContainEqual( - expect.objectContaining({ - commodity, - action: 'buy', - change: [{ commodity, quantity: expect.stringMatching(quantity) }], - prices: [{ commodity: 'USD', quantity: expect.stringMatching(price) }], - transactedAmounts: posting.transactedAmounts - }) - ); - } - const disposal = sale.transaction.postings.find( - (posting) => posting.account === brokerage.name && posting.amounts[0].commodity === 'VTI' - )!; - expect(financialValues(disposal.amounts)).toEqual([ - { commodity: 'VTI', quantity: expect.stringMatching(/^-5(?:\.0+)?$/) } - ]); - expect(disposal.amounts[0]).toMatchObject({ - cost: { - kind: 'UnitCost', - amount: { commodity: 'USD', quantity: expect.stringMatching(/^150(?:\.0+)?$/) } - }, - costBasis: { - amount: { commodity: 'USD', quantity: expect.stringMatching(/^100(?:\.0+)?$/) } - } + const account = publication.accounts.find((entry) => entry.name === 'assets:bank:checking')!; + expect(account.summary).toMatchObject({ + status: 'ready', + balances: expect.arrayContaining([{ commodity: 'USD', quantity: '8520.00' }]) }); - expect(disposal.transactedAmounts).toEqual([ - { commodity: 'USD', quantity: expect.stringMatching(/^-750(?:\.0+)?$/) } - ]); - expect(sale.investment.securities).toContainEqual( - expect.objectContaining({ - commodity: 'VTI', - action: 'sell', - transactedAmounts: disposal.transactedAmounts - }) + const { transactions } = await readAccountTransactions(publication, account.id); + const salary = transactions!.find((row) => row.transaction.note === 'Salary')!; + expect(salary.transaction.sourcePositions).toContainEqual( + expect.objectContaining({ file: f.included, line: expect.any(Number) }) ); - expect(buy.transaction.sourcePositions).toEqual( - expect.arrayContaining([expect.objectContaining({ file: f.included })]) - ); - const raw = await readFile( - join(publication.directory, 'transactions', `${brokerage.id}.json`), - 'utf8' - ); - expect(JSON.parse(raw)).toEqual({ id: brokerage.id, name: brokerage.name, transactions }); expect(JSON.stringify(run.output)).not.toMatch( - /postings|investment|transactedAmounts|costBasis|sourcePositions/ + /transactions|postings|investment|transactedAmounts|costBasis|sourcePositions|fingerprint/ ); expect(JSON.stringify(run.output)).not.toContain(f.directory); await service.stop(); service = undefined; expect(await readOverview(f.snapshots)).toEqual(data); expect(await readValidation(f.snapshots)).toEqual(validation); + const savedPublication = (await readReportPublication(f.snapshots))!; + expect((await readAccountTransactions(savedPublication, account.id)).transactions).toEqual( + transactions + ); expect(await readFile(f.journal, 'utf8')).toBe('include included.journal\n'); expect(await readFile(f.included, 'utf8')).toBe(source); expect(await readFile(f.legacy, 'utf8')).toBe('legacy preview unchanged'); @@ -282,7 +193,7 @@ it('retains last-good data without retry flooding, permits an explicit SDK retry await enqueuePreparation(client); const retried = await settled(client, 4); const updated = (await readOverview(f.snapshots))!; - expect(updated.incomeExpenses.months[5].income).toEqual([ + expect(updated.incomeExpenses.months[11].income).toEqual([ { commodity: 'USD', quantity: '1500.00' } ]); expect(retried.output).toEqual(updated); @@ -297,7 +208,8 @@ it('retains last-good data without retry flooding, permits an explicit SDK retry expect(empty).toMatchObject({ asOf: null, incomeExpenses: { months: [] }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, + balanceSheet: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, netWorth: { currency: null, points: [] } }); @@ -366,10 +278,10 @@ it('prepares on request after a stopped-worker include edit even with saved data expect(refreshedRun.idempotencyKey).toBeNull(); expect(refreshed.asOf).toBe(original.asOf); expect(refreshed.journalCoverage).toEqual(original.journalCoverage); - expect(refreshed.incomeExpenses.months[5].income).toEqual([ + expect(refreshed.incomeExpenses.months[11].income).toEqual([ { commodity: 'USD', quantity: '1500.00' } ]); - expect(refreshed.netWorth?.points[5].amounts).toEqual([ + expect(refreshed.netWorth?.points[11].amounts).toEqual([ { commodity: 'USD', quantity: '12325.00' } ]); expect(refreshedRun.output).toEqual(refreshed); diff --git a/apps/worker/src/workflows/prepare-reports/run.test.ts b/apps/worker/src/workflows/prepare-reports/run.test.ts index fd1240c..ce7302e 100644 --- a/apps/worker/src/workflows/prepare-reports/run.test.ts +++ b/apps/worker/src/workflows/prepare-reports/run.test.ts @@ -29,11 +29,19 @@ const overview: OverviewData = { asOf: null, preparedAt: '2026-04-16T00:00:00Z', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, netWorth: { currency: null, points: [] }, - incomeExpenses: { months: [] } + incomeExpenses: { months: [] }, + balanceSheet: null }; -const accounts = [{ name: 'assets:cash', access: 'ready' as const, rowCount: 0 }]; +const accounts = [ + { + name: 'assets:cash', + access: 'ready' as const, + rowCount: 0, + summary: { status: 'undated' as const } + } +]; const previous: ReportPublication = { overview, journal, diff --git a/docs/architecture.md b/docs/architecture.md index 1e0282b..f87ecdc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,29 @@ data, never paths. The server reads one account's complete JSON and slices 50 ro before returning page data. Account changes and balances are already calculated by hledger; pagination does not reconstruct them. No separate indexes or persisted page files. -Financial format remains **5**. It retains original exported transaction/posting +Financial format **6** replaces 5. The overview requires native 1/3/6/12-month +income, expenses, NET and category range totals; twelve shared monthly columns; +twelve exact-date net-worth points; and latest native assets/liabilities sides. +Range totals come from native `prTotals.prrTotal` and `cbrTotals.prrTotal`, never JS sums or +subtraction. Side totals retain native signs. The cutoff is the latest primary +transaction date, including partial months and recorded future dates, not today. +`NativeValue` contains exact `amounts` and explicit `unconverted` commodities. + +Two bulk raw/valued native tree balance reports add required account-catalog +summaries: raw inclusive balances and end-valued bags, including investment cash. +Exact full native names and existing canonical `--find` qualification remain +unchanged; there is no fuzzy folding, per-configured-account work or WorkerConfig +change. Unconverted evidence includes exact descendants even if parent amounts +cancel; known unused accounts retain native `[]`, not fake zeros or missing data. +Undated overviews have null ranges/balanceSheet, empty histories and null currency; +accounts are undated unless ambiguous. No financial arithmetic moves into JS. + +For dated journals, the cached batch now runs four income statements, twelve balance sheets and two +bulk balances: **18 financial report commands**, still two financial processes +including discovery, plus the separate preflight = **three whole-job processes**. +Account membership, history and the SDK v1 void-input workflow contract are unchanged. + +Format 6 retains original exported transaction/posting metadata, recursively exact amounts and account-relative facts in `PreparedAccountRow`: `accountDate` is distinct from the original `transaction.date`. The hledger library owns payee parsing, status inheritance and canonical account/subtree posting membership. The current unfiltered @@ -63,19 +85,29 @@ register changes only the contextual date; the mapper verifies that contract rat than silently dropping other differences. Transaction indexes are scoped to the prepared journal view, not durable cross-preparation IDs. -The existing six-month Overview and Cash/Investment registers remain unchanged. -Register loaders project only the selected page's public fields; source/assertion -positions and unused details stay private. This change adds no financial-format -transition, period controls or account sidebar. +The current six-month Overview and Cash/Investment register presentation remain +unchanged. The homepage server builds `OverviewPageData` explicitly from range-6 +categories, the last six monthly/net-worth points and rendered context only. It +serializes no unrendered ranges/headline totals, latest sides, account values/catalog, +private publication metadata or provenance. This is a current page model, not a +legacy producer adapter. Register directories project identity/access/count fields; +register details project only their selected page's public fields. Period controls, +a three-month default and account sidebar remain future work, not existing UI. +Routes, navigation, Settings, live queries and theme behavior are unchanged. Failure before the financial directory commit retains the previous complete set. After commit, an inspection-only `current` alias update can fail the job while the new valid complete set remains selectable. Completed sets are never rolled back or deleted, and a job failure must not be presented as proof that reports are unchanged. -Readers support only the current complete report shape: Overview contains all four sections, and the journal -identity, account directory and pinned set location are always present. If the latest +Readers support only the complete format-6 report shape with required ranges, +histories, latest sides and account summaries; the private journal identity, account +directory and pinned set location are always present. If the latest saved set uses an unsupported format, no report is returned; the user explicitly -prepares again. Older files are neither interpreted nor used as a fallback. +prepares again. Older files are neither interpreted nor used as a fallback. Activation requires +stopping old preparation execution, deploying compatible web and worker revisions +together, then explicitly **Prepare reports again** in Settings. Format-5 files stay +untouched but unusable. Account IDs may reset across this format transition; +exact-name reuse continues within supported same-journal publications. Reports for another configured journal are withheld. Journal identity is private operational context, not an input fingerprint or freshness assertion. Files are left @@ -171,7 +203,8 @@ retaining their existing native-value and geometry rules. Server-rendered exact tables do not depend on a canvas or browser styling APIs. The hledger package's feature folders reflect the product: `overview/` groups its -batch, financial organization and report formats; `transactions/account-rows.ts` +batch, range/history organization and report formats; `accounts/balances.ts` owns +bulk inclusive account summaries; `transactions/account-rows.ts` interprets account-relative activity; `transactions/transaction.ts` retains exported transaction/posting metadata and exact nested amounts; `queries/cost-balance.ts` implements the live recorded-cost query. Their tests live alongside them. A feature does not need several implementation diff --git a/docs/development.md b/docs/development.md index eea0ab5..dd3475b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,7 +34,7 @@ This uses the checked-in Compose files and your ignored live configuration. A mise/fnox Docker task is an owner-local convenience, not part of the checked-in project. The remaining commands below run from the repository root. -Open **http://127.0.0.1:5173/** and sign in. Overview and Transactions read saved reports; if none exist, they say so. Use the inline **Prepare reports** form in **Settings** (`/settings`) to request a complete calculation, then reload Settings to check status and navigate to saved results. `/prepare` is removed without a redirect or POST alias; Settings links to the existing `/queries` tool. Startup, journal edits and browsing never request preparation automatically. Saved reports remain available while a requested job runs or when the worker is unavailable. The separate automated-test `lots.journal` fixture anchors at **2026-04-16**, giving **November 2025–April 2026** regardless of today's date; no fixture edits are needed. Overview's bars, categories, net-worth trend and coverage now share a complete preparation with all account registers. Use a configured **Cash register** or **Investment register** in the primary navigation, then select an account; ordinary pagination reads saved account activity with native running balances. Account history is not restricted to the Overview window. Only complete financial format-5 reports are displayed; this change keeps format 5 and the existing six-month Overview/register behavior. Unsupported saved formats show “No prepared reports available.”; explicitly prepare again to create the current set. Existing saved files are not rewritten or deleted automatically. That fixture's net-worth point is 11825.00 USD at April 16, not the 12010.00 USD from later April 30 prices. Native whole-journal coverage includes 9 transactions dated April 1–16 in that fixture; its date span can include secondary/posting dates, while the financial window is anchored to the latest primary transaction date. See the [web contract](../apps/web/README.md) for interaction behavior and the [native library](../packages/hledger/README.md#overview-data) for dataset semantics. +Open **http://127.0.0.1:5173/** and sign in. Overview and Transactions read saved reports; if none exist, they say so. Use the inline **Prepare reports** form in **Settings** (`/settings`) to request a complete calculation, then reload Settings to check status and navigate to saved results. `/prepare` is removed without a redirect or POST alias; Settings links to the existing `/queries` tool. Startup, journal edits and browsing never request preparation automatically. Saved reports remain available while a requested job runs or when the worker is unavailable. The separate automated-test `lots.journal` fixture anchors at **2026-04-16**, giving **November 2025–April 2026** regardless of today's date; no fixture edits are needed. Overview's bars, categories, net-worth trend and coverage now share a complete preparation with all account registers. Use a configured **Cash register** or **Investment register** in the primary navigation, then select an account; ordinary pagination reads saved account activity with native running balances. Account history is not restricted to the Overview window. Only complete financial format-6 reports are displayed; format 5 is no longer usable. Preparation saves native 1/3/6/12-month ranges, twelve monthly/net-worth history points, latest assets/liabilities and raw/valued account summaries. The current UI deliberately keeps six months: the server projects only range-6 categories, the last six history points and rendered context, not unrendered ranges, sides, account values/catalog or private provenance. Period controls, a three-month default and sidebar values are future work. Unsupported saved formats show “No prepared reports available.”; follow the activation steps below and explicitly prepare again. Existing saved files are not rewritten or deleted automatically. That fixture's net-worth point is 11825.00 USD at April 16, not the 12010.00 USD from later April 30 prices. Native whole-journal coverage includes 9 transactions dated April 1–16 in that fixture; its date span can include secondary/posting dates, while the financial window is anchored to the latest primary transaction date. See the [web contract](../apps/web/README.md) for interaction behavior and the [native library](../packages/hledger/README.md#overview-data) for dataset semantics. Follow **Live queries** from Settings to the existing `/queries` screen; submit `type:X` as of `2026-04-30` for the fixture expense total **100.00 USD**. Run waits for the worker through the same queue. Query reload still @@ -73,14 +73,30 @@ old inode after a host editor replacement; a calculation cannot read a replaceme that the container cannot see. Treat the entire host data folder as sensitive: SQLite contains query parameters, full financial results and preparation history, while the inspectable report set lives in -`data/snapshots/current/`. See [prepared reports](../packages/snapshots/README.md#prepared-reports) +`data/snapshots/current/`. See [prepared reports](../packages/snapshots/README.md#current-format-and-preservation) for publication, recovery, growth and untouched unsupported files. -The current set has one `overview.json` with journal/report details and the account directory, plus one `transactions/.json` for every account's complete activity. IDs are eight lowercase letters/digits, stable across preparations by exact native name; names never become paths. The server parses that one account file and returns a 50-row slice. It does not read every account or execute hledger. Whole sets are prepared privately and published together; `current` is the inspection link into hidden immutable sets. Each request pins its selected set. Larger preparations can still take substantial native time/memory. There are no application-imposed native execution deadlines, output/row/tuple limits or saved-file size caps, and no universal journal-capacity guarantee. Unsupported outputs and older complete sets are left untouched, but are not a fallback for the current format. Re-preparing unsupported data creates a new set and new account IDs; IDs are reused only from a supported set for the same journal. Do not remove active account files individually. +The current set has one `overview.json` with journal/report details and the account directory including required raw inclusive balances and native end-valued summaries with unconverted evidence, plus one `transactions/.json` for every account's complete activity. IDs are eight lowercase letters/digits, reused within supported same-journal publications by exact native name; names never become paths. The server parses that one account file and returns a 50-row slice. It does not read every account or execute hledger. Whole sets are prepared privately and published together; `current` is the inspection link into hidden immutable sets. Each request pins its selected set. Larger preparations can still take substantial native time/memory. There are no application-imposed native execution deadlines, output/row/tuple limits or saved-file size caps, and no universal journal-capacity guarantee. Unsupported outputs and older complete sets are left untouched, but are not a fallback for the current format. Re-preparing unsupported data creates a new set and may reset account IDs; IDs are reused only from a supported set for the same journal. Do not remove active account files individually. **SQLite query retention is enabled**. See [retention scope, scheduling and backup precautions](../apps/worker/README.md#query-job-retention); it is not prepared-file cleanup. +## Financial format-6 activation + +Stop old preparation execution before upgrading. Deploy compatible web and worker +revisions together, then explicitly **Prepare reports again** in Settings. Independent +image publication is not a coordinated activation. Format-5 files remain untouched +but unusable; account IDs may reset and old account URLs may no longer resolve. +There is no automatic preparation, adapter, fallback, migration or deletion. The +v1 void-input workflow identity and independent validation-evidence lifecycle do +not change, nor do configuration, native account membership or transaction history. + +For dated journals, the cached financial path now uses 4 income statements + 12 balance sheets + 2 +bulk balances = **18 financial report commands**, still two financial processes +including discovery, plus the separate strict check = **three whole-job processes**. +This command count is not a benchmark or capacity guarantee. Hledger still owns all +financial arithmetic and the latest-primary-date cutoff, including partial months. + ## Register configuration Add exact account entries to the existing private YAML to enable the corresponding @@ -119,7 +135,7 @@ apps/web/data/ snapshots/current/transactions/2b8n5r6p.json ``` -The account directory in `overview.json` maps short IDs to full native names, access states and transaction counts. Each account file contains its identity and complete transaction array with exact quantity strings. There are no persisted page files or separate account indexes. Inspect published files read-only. Do not edit queue files or +The account directory in `overview.json` maps short IDs to full native names, access states, transaction counts and raw/valued summaries. Each account file contains its identity and complete transaction array with exact quantity strings. There are no persisted page files or separate account indexes. Inspect published files read-only. Do not edit queue files or completed datasets while the services are running. Runtime contents are ignored by Git, formatting/lint tools and Docker builds, and Vite denies raw data serving. The entire folder is Git-ignored, with no tracked placeholder. Create it on the host diff --git a/packages/hledger/README.md b/packages/hledger/README.md index 7478765..f44b7cc 100644 --- a/packages/hledger/README.md +++ b/packages/hledger/README.md @@ -26,9 +26,12 @@ src/ ├── overview/ │ ├── batch.ts # Grouped Overview commands and output │ ├── report.ts # OverviewData and report organization +│ ├── ranges.ts # Native 1/3/6/12-month totals and shared history │ ├── income-statement.ts # Hledger income-statement JSON decoding │ ├── balance-sheet.ts # Hledger balance-sheet JSON decoding │ └── net-worth.ts # Interprets hledger net-worth results +├── accounts/ +│ └── balances.ts # Bulk raw/valued inclusive account summaries ├── transactions/ │ ├── account-rows.ts # Register context and selected-posting indexes │ └── transaction.ts # Full exported transaction/posting/amount metadata @@ -40,32 +43,43 @@ src/ └── report-date.ts # Shared ISO report-date formatting ``` -The three feature folders reflect the product views, even where a feature currently +The feature folders reflect the product views, even where a feature currently needs only one module and its tests. Shared execution and preparation stay at the root because they serve more than one feature. There is no client resource or separate Overview executor. Storage and publication remain caller-owned. ## Overview data -`prepareReports` returns this Overview alongside the account directory. All fields are required, including `journalCoverage`, `expenseCategories` and `netWorth`. Report interpretation produces financial fields without timestamps; the preparation operation adds `preparedAt` once, after all account writes and successful hledger completion: +`prepareReports` returns this Overview alongside the account directory. All fields are required; undated results use explicit nulls and empty histories. Report interpretation produces financial fields without timestamps; the preparation operation adds `preparedAt` once, after all account writes and successful hledger completion: ```ts +type OverviewMonths = 1 | 3 | 6 | 12; +type NativeValue = { amounts: ReportedAmount[]; unconverted: string[] }; +interface OverviewRange { + begin: string; + endExclusive: string; + income: ReportedAmount[]; + expenses: ReportedAmount[]; + net: ReportedAmount[]; + expenseCategories: { + depth: 2; + rows: { account: string; amounts: ReportedAmount[] }[]; + }; +} interface OverviewData { asOf: string | null; preparedAt: string; + ranges: Record | null; + balanceSheet: { assets: NativeValue; liabilities: NativeValue } | null; netWorth: { currency: string | null; - points: { date: string; amounts: ReportedAmount[]; unconverted: string[] }[]; + points: ({ date: string } & NativeValue)[]; }; journalCoverage: { transactionCount: number; firstDate: string | null; lastDate: string | null; }; - expenseCategories: { - depth: 2; - rows: { account: string; amounts: ReportedAmount[] }[]; - }; incomeExpenses: { months: { begin: string; @@ -78,39 +92,41 @@ interface OverviewData { // ReportedAmount = { commodity: string; quantity: string } ``` -`journalCoverage` is the whole-journal transaction count and inclusive native date span. It comes from the existing **cached batch's unfiltered stats**, alongside the financial report—not the earlier discovery process. No additional native command is needed. The native span includes secondary and posting dates, so it can extend beyond the latest primary transaction (`asOf`) or the six-month reporting window. The library converts the native exclusive span end to an inclusive `lastDate`; web does not interpret native statistics or perform that conversion. +`journalCoverage` is the whole-journal transaction count and inclusive native date span. It comes from the existing **cached batch's unfiltered stats**, alongside the financial report—not the earlier discovery process. No additional native command is needed. The native span includes secondary and posting dates, so it can extend beyond the latest primary transaction (`asOf`) or any prepared reporting range. The library converts the native exclusive span end to an inclusive `lastDate`; web does not interpret native statistics or perform that conversion. Empty journals have count `0` and null dates. Price directives and unexpanded periodic rules do not create recorded transactions. Rates, recent-days counts, runtime statistics and source paths are not exposed. Coverage does not prove completeness or freshness. -`expenseCategories` contains native expense-account totals over that same six-month window, at maximum absolute account depth 2. For example, `expenses:food:groceries` and `expenses:food:restaurants` roll into `expenses:food`; depth is counted from the beginning of the full account name. Native flat reporting keeps shallower direct postings separate rather than displaying inclusive parent totals alongside their children. Full native account names and order are retained, including expense roots not literally named `expenses`. +Each range's `expenseCategories` contains native expense-account totals over that selected range, at maximum absolute account depth 2. For example, `expenses:food:groceries` and `expenses:food:restaurants` roll into `expenses:food`; depth is counted from the beginning of the full account name. Native flat reporting keeps shallower direct postings separate rather than displaying inclusive parent totals alongside their children. Full native account names and order are retained, including expense roots not literally named `expenses`. -Category amounts come directly from native `prrTotal` bags in the existing income statement—not TypeScript sums of monthly columns or children. Empty journals return `{ depth: 2, rows: [] }`. +Range income and expenses come from native subreport `prTotals.prrTotal`, NET from `cbrTotals.prrTotal`, and categories from native row `prrTotal` bags—not TypeScript sums of monthly columns, subtraction or child aggregation. Native signs and refunds are retained. -`netWorth` contains six native historical balance-sheet NET amount bags: the previous five month ends and a final point at `asOf`. Hledger performs assets-minus-liabilities and dated market conversion; the library does not sum balances or calculate FX. Every point uses only prices available on or before its valuation date, without falling back to transaction cost or fetching quotes. For the original fixture the April 16 point is **11825.00 USD**, not the **12010.00 USD** obtained using later April 30 quotes. +`netWorth` contains twelve native historical balance-sheet NET amount bags: the previous eleven month ends and a final point at `asOf`. Hledger performs assets-minus-liabilities and dated market conversion; the library does not sum balances or calculate FX. Every point uses only prices available on or before its valuation date, without falling back to transaction cost or fetching quotes. For the original fixture the April 16 point is **11825.00 USD**, not the **12010.00 USD** obtained using later April 30 quotes. The target is hledger's **inferred base currency** from the same loaded journal, not a hardcoded USD or a new application setting. This is an inference, not a reporting-currency directive: `commodity` declares a commodity and `D` supplies input defaults. The inferred identifier is preserved exactly, including case, spaces, commas and an unnamed commodity. One target applies to the entire prepared trend. A different second-pass inference fails preparation instead of mixing currencies. The chosen target remains visible in the UI. Each point's `amounts` is the native NET bag. `unconverted` lists non-target commodities remaining in either the native asset or liability totals, even if those units cancel in NET. Such a point is incomplete and is not plotted as a consolidated total; the exact table retains its native mixed/partial amounts. Missing native amounts are not invented target-currency zeros; a native explicit zero is retained. Empty journals return `{ currency: null, points: [] }`. -Native amount bags are not guaranteed to have one entry per commodity: distinct cost annotations can leave multiple entries. Consumers must not silently select the first or combine them as a presentation shortcut. +`balanceSheet` copies the latest native assets and liabilities side totals, with their native signs and separate `unconverted` lists; it does not recompute them from NET or account summaries. `NativeValue` always contains an amount bag and explicit conversion evidence, not a guaranteed target-currency scalar. -`begin` and `endExclusive` are native journal report periods. The library organizes native totals into six calendar months, including empty months, and preserves exact reported quantity strings, native signs, refunds and commodities. `preparedAt` is the library's current preparation timestamp, not a snapshot-writer or SDK timestamp. Consumers receive ready JSON-serializable data; native tuples, raw income JSON and BigInt decoding stay inside the library. Future overview cards extend this dataset and their renderer, not separate per-card APIs or stores. +Native amount bags are not guaranteed to have one entry per commodity: distinct cost annotations can leave multiple entries. Consumers must not silently select the first or combine them as a presentation shortcut. Empty commodity IDs, exact quantity strings, duplicate entries and explicit zeros are preserved; empty native bags stay `[]`. -`asOf` is the latest recorded **primary transaction date**, not today or SDK request time. Assets, transfers, gains and recorded future-dated transactions can anchor the window. Price directives and unexpanded periodic rules do not count. Gain accounts (`type:G`) are excluded from income/expense values, not date discovery. Net worth uses native asset/liability classification; it does not inherit the income report's gain filter. The window covers the as-of month plus five preceding calendar months. Its trailing month runs through the latest transaction date without claiming completeness. For example, `2026-04-16` anchors November 2025–April 2026 regardless of today's date. +Each range begins on the first day of its earliest month and ends exclusively at `asOf` plus one day, including partial trailing months. The shared `incomeExpenses.months` history comes only from the twelve-month statement and retains twelve native calendar-month columns, including empty months. Those columns' `endExclusive` boundaries remain calendar boundaries, even for the clipped trailing month. `preparedAt` is the library's current preparation timestamp, not a snapshot-writer or SDK timestamp. Consumers receive ready JSON-serializable data; native tuples, raw income JSON and BigInt decoding stay inside the library. The current web page explicitly projects range-6 categories and the last six history points; period controls, a three-month default and account-sidebar presentation are future work, not existing UI. -No dated transactions returns the standard dataset with `asOf: null`, a current `preparedAt`, `journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }`, `incomeExpenses: { months: [] }`, `expenseCategories: { depth: 2, rows: [] }` and `netWorth: { currency: null, points: [] }`, not six fabricated zero months. The income/category report requests no cost, market valuation or FX conversion. Its amounts are not available cash, cash flow or net worth: transfers, loans and asset-sale proceeds differ. This neutral dataset supports the exact table and grouped bars without chart-library configuration in this package. +`asOf` is the latest recorded **primary transaction date**, not today or SDK request time. Assets, transfers, gains and recorded future-dated transactions can anchor the window. Price directives and unexpanded periodic rules do not count. Gain accounts (`type:G`) are excluded from income/expense values, not date discovery. Net worth uses native asset/liability classification; it does not inherit the income report's gain filter. Each 1/3/6/12-month range covers the as-of month and its preceding calendar months. Its trailing month runs through the latest primary transaction date without claiming completeness. For example, `2026-04-16` anchors the shared history at May 2025–April 2026 and the current six-month view at November 2025–April 2026, regardless of today's date. + +No dated transactions returns the standard dataset with `asOf: null`, a current `preparedAt`, `journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }`, `ranges: null`, `balanceSheet: null`, `incomeExpenses: { months: [] }` and `netWorth: { currency: null, points: [] }`, not fabricated zero months. The income/category report requests no cost, market valuation or FX conversion. Its amounts are not available cash, cash flow or net worth: transfers, loans and asset-sale proceeds differ. This neutral dataset supports the exact table and grouped bars without chart-library configuration in this package. ### Overview planning and interpretation -`overview/batch.ts` plans commands and decodes the framed Overview batch; it does not execute hledger. `journal-stats.ts` decodes metadata; `overview/income-statement.ts` and `overview/balance-sheet.ts` describe/decode their hledger wire formats. `overview/report.ts` owns `OverviewData` and organizes financial fields, delegating net-worth interpretation to `overview/net-worth.ts`. The combined preparation operation below owns when these steps happen; there is no separate Overview executor. +`overview/batch.ts` plans commands and decodes the framed Overview batch; it does not execute hledger. `journal-stats.ts` decodes metadata; `overview/income-statement.ts` and `overview/balance-sheet.ts` describe/decode their hledger wire formats. `overview/report.ts` owns `OverviewData` and organizes financial fields, delegating range/history organization to `overview/ranges.ts` and net-worth/side interpretation to `overview/net-worth.ts`. The combined preparation operation below owns when these steps happen; there is no separate Overview executor. -Unfiltered strict native statistics discover the primary anchor and inferred target. The actual prepared batch reads statistics, one monthly income statement, six date-specific historical balance sheets and all account registers from the same loaded journal. Known static markers delimit the reports; decoding from the end avoids mistaking metadata-path text or escaped JSON strings for a report boundary. +Unfiltered strict native statistics discover the primary anchor and inferred target. The actual prepared batch reads statistics, four income statements, twelve date-specific historical balance sheets, two bulk account balance reports and all account registers from the same loaded journal. For dated journals that is **18 financial report commands**, up from 7, still within two financial processes (discovery plus cached production). The worker's separate strict preflight makes **three processes for the whole job**, not three financial batches. Known static markers delimit the reports; decoding from the end avoids mistaking metadata-path text or escaped JSON strings for a report boundary. -The income command retains `--flat --depth=2 --change --monthly --empty`, the six-month bounds, `not:type:G` and `not:date:AS_OF_PLUS_1..`. Each balance sheet uses `--historical --flat --empty --depth=1`, its month start, the day after its valuation date, `--value=EXACT_DATE,INFERRED_TARGET`, JSON output and the matching negative date cutoff. It does **not** use monthly interval expansion or the income gain filter. Native historical accumulation retains opening balances from before the displayed window. +Each income command retains `--flat --depth=2 --change --monthly --empty`, its 1/3/6/12-month bounds, `not:type:G` and `not:date:AS_OF_PLUS_1..`. Each balance sheet uses `--historical --flat --empty --depth=1`, its month start, the day after its valuation date, `--value=EXACT_DATE,INFERRED_TARGET`, JSON output and the matching negative date cutoff. It does **not** use monthly interval expansion or the income gain filter. Native historical accumulation retains opening balances from before the displayed window. -Both passes disable ambient config and read the source journal and native includes read-only. The second `stats` primary date and inferred currency must match discovery; a changed anchor or target fails preparation and leaves the last good published dataset intact. Counts and span may change at the same anchor; the second parsed view supplies both coverage and financial data. For an empty journal, the actual batch still confirms statistics and account identity, without inventing six financial periods. This does not guarantee atomic multi-file reads during initial parsing. +Both passes disable ambient config and read the source journal and native includes read-only. The second `stats` primary date and inferred currency must match discovery; a changed anchor or target fails preparation and leaves the last good published dataset intact. Counts and span may change at the same anchor; the second parsed view supplies both coverage and financial data. For an empty journal, the actual batch still confirms statistics and account identity, without inventing financial periods. This does not guarantee atomic multi-file reads during initial parsing. -The income report starts on the first day five months before the journal as-of month and ends at the first day of the following month. Native `--empty` retains six monthly columns, including holes. The absolute **negative date query** at as-of plus one day keeps the native cutoff consistent within the monthly period; positive date filtering or a mid-month `-e` alone is unsafe under native interval expansion. This does not discard recorded future transactions based on the clock. `not:type:G` filters only income-statement values. +Each income report starts on the first day of its range and uses a command end at the first day of the following month. Native `--empty` retains every monthly column, including holes; only the twelve-month statement supplies published monthly history. The absolute **negative date query** at as-of plus one day keeps the native cutoff consistent within the monthly period; positive date filtering or a mid-month `-e` alone is unsafe under native interval expansion. This does not discard recorded future transactions based on the clock. `not:type:G` filters only income-statement values. All reports must finish and validate before the combined producer succeeds. A failed later balance sheet or account register cannot publish only earlier results. Exact JSON decoding and interpretation stay in the library, checking the consumed structure and expected periods without a second comprehensive financial schema. Unsupported scientific-notation mantissa tokens fail closed before publication. @@ -143,29 +159,39 @@ interface PreparedAccountSummary { name: string; access: 'ready' | 'ambiguous'; rowCount: number | null; + summary: PreparedBalance; } +type PreparedBalance = + | { status: 'ambiguous' } + | { status: 'undated' } + | { status: 'ready'; balances: ReportedAmount[]; valuation: NativeValue }; ``` Native strict discovery runs `stats` and `accounts --flat --directives` in one cached `run`. A second, single cached `run` supplies the unfiltered stats, the -**unchanged** income/six-balance-sheet financial plan, the actual account list, +four-income/twelve-balance-sheet/two-bulk-balance financial plan, the actual account list, all native `accounts --find --directives` identity qualifications, and then all full-history `aregister --historical --empty -Ojson` reports. Every published financial value comes from this second loaded journal. Its anchor, inferred currency and actual account list must match discovery. Both processes disable ambient config. The financial batch remains independently `--strict`. This is not an atomic filesystem snapshot. The worker now calls standalone `checkJournal` before -this unchanged two-process financial path: **three native processes/parses** for an +this two-process financial path: **three native processes/parses** for an explicit preparation. The check can observe different edits from financial production; a passed check is not proof of identical inputs, freshness or a completed financial set. The worker/storage, not this library, record completed outcomes; incomplete checks preserve prior evidence. Read-only `worker:inspect` also uses `checkJournal` but never writes evidence. The caller owns final publication; browsing saved reports does not -invoke preparation. Financial format remains **5**, with the existing six-month -Overview and full-history registers unchanged. +invoke preparation. Storage requires financial format **6**; the current six-month +Overview presentation and full-history register rows remain unchanged. Activation +requires stopping old preparation execution, deploying compatible web and worker +revisions together, then explicitly **Prepare reports again** in Settings. Format-5 +files remain untouched but unusable, and account IDs may reset; there is no adapter, +fallback, migration, deletion or automatic work. See the +[storage contract](../snapshots/README.md#current-format-and-preservation). The account summaries contain only native used/declared accounts, sorted by literal name; -it does not expand implicit parents. An explicitly declared parent has native +the directory does not expand implicit parents. An explicitly declared parent has native **subtree** history. UI-only ancestor headings must not request extra registers. Unused declared leaves remain ready with count zero and an empty `writeRows(name, [])` call. Names retain native whitespace, punctuation and Unicode. They are identities, @@ -177,7 +203,24 @@ Qualification uses native matching, not JavaScript case folding. If a requested name differs from its native match, or multiple requested names share one canonical match, **all involved accounts** are ambiguous, with null counts and no row writes. Their entire native register results are ignored rather than -financially filtered in JavaScript. +financially filtered in JavaScript. Their summary is `{ status: 'ambiguous' }`, even +without a date; other undated accounts have `{ status: 'undated' }`. + +For dated ready accounts, two bulk `balance --tree --no-elide --declared --historical +--empty -e AS_OF_PLUS_1 -Ojson not:date:AS_OF_PLUS_1..` commands provide raw inclusive +`balances` and end-valued `valuation`. Only the valued command adds +`--value=AS_OF,INFERRED_TARGET` as one literal argument. No per-configured-account +reports, role filters, cost conversion or JS financial sums are added. Investment +totals include uninvested cash and other non-lot assets. + +Rows match exact full native names, never shortened labels or fuzzy/case-folded +names; existing native `--find` qualification remains authoritative. A missing +expected row fails preparation. Known declared-unused accounts retain native `[]`, +not fake zeros or missing-data states. Valuation's `unconverted` list also collects +residual commodities from exact `name + ':'` descendants, even when opposing child +exposures cancel in the inclusive parent. This collects evidence, not amounts; +incomplete valuation does not change register access. Summaries cover only the +existing declared/used directory, not extra implicit parents. Rows retain native order and cumulative historical balances across callback boundaries. `PreparedAccountRow` combines the native **account-relative** report @@ -204,8 +247,8 @@ have one authoritative location in `transaction`, not duplicate legacy table fie `PreparedTransaction`, `PreparedPosting`, `DetailedAmount`, `AmountStyle`, and `SourcePosition` are public types alongside `PreparedAccountRow`. All fields in the prepared contract are required; nullable fields contain explicit nulls. -This richer record requires newly prepared saved reports, not an adapter that -pretends old table-only rows contain transaction detail. OverviewData is unchanged. +Format 6 leaves these transaction records unchanged; the required new financial +fields are overview ranges/history/sides and catalog summaries, not a transaction adapter. ### Mapping and privacy @@ -238,7 +281,7 @@ transaction; the mapper retains annotations when exported but cannot recover precision or annotations lost upstream. This report is **not a verbatim source archive or complete investment record**. No extra print command is issued. -History is not restricted to Overview's six-month window or primary `asOf`; no +History is not restricted to Overview's ranges or primary `asOf`; no register date bounds, clock context, cost or valuation are requested. Exact native JSON numeric tokens feed the existing BigInt codec; quantities such as `0.0049` are not rounded through CSV or JavaScript numbers. Every ordered mixed, @@ -299,7 +342,7 @@ transaction/posting metadata and recursive exact amount projection. Tests cover the pure financial plan/decoding/organization and the actual combined producer. Independent native fixture oracles remain test-only; removed standalone Overview/register execution APIs are not kept alive just to support tests. Process -safety tests live next to `process.ts`. The richer record does not change the -financial stream protocol, commands or callback backpressure. +safety tests live next to `process.ts`. The finite range/side/balance outputs extend +the framed financial prefix; register exports and callback backpressure remain unchanged. Development uses source exports and the root Node loader; production uses compiled `dist/src`. `pnpm build`, `check`, `lint`, and `test` operate on this package. diff --git a/packages/hledger/src/accounts/balances.integration.test.ts b/packages/hledger/src/accounts/balances.integration.test.ts new file mode 100644 index 0000000..4503065 --- /dev/null +++ b/packages/hledger/src/accounts/balances.integration.test.ts @@ -0,0 +1,170 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { expect, it } from 'vitest'; +import { formatQuantity, parseExactJson, type Amount } from '../amounts.js'; +import { prepareReports } from '../prepare-reports.js'; + +const exec = promisify(execFile); +const lots = fileURLToPath( + new URL('../../../../apps/worker/fixtures/lots.journal', import.meta.url) +); + +type NativeBalance = [[string, string, number, Amount[]][], Amount[]]; +const amounts = (bag: Amount[]) => + bag.map(({ acommodity, aquantity }) => ({ + commodity: acommodity, + quantity: formatQuantity(aquantity) + })); + +async function compareNativeSubtree(journal: string, name: string) { + const prepared = await prepareReports(journal, { async writeRows() {} }); + const { asOf } = prepared.overview; + const base = prepared.overview.netWorth.currency!; + expect(asOf).not.toBeNull(); + const next = new Date(`${asOf}T00:00:00Z`); + next.setUTCDate(next.getUTCDate() + 1); + const end = next.toISOString().slice(0, 10); + const account = prepared.accounts.find((account) => account.name === name)!; + const escaped = account.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Independently execute scoped CLI reports, not the new planner/decoder/organizer. + // Native queries are case-insensitive; select exact full-name tree rows below, + // never the report total (which can include the case-separated neighbour). + const native = async (valued: boolean) => { + const { stdout } = await exec('hledger', [ + '--no-conf', + '--lang=en', + '--strict', + '-f', + journal, + 'balance', + '--tree', + '--no-elide', + '--declared', + '--historical', + '--empty', + '-e', + end, + '-Ojson', + `not:date:${end}..`, + `acct:^${escaped}(:|$)`, + ...(valued ? [`--value=${asOf},${base}`] : []) + ]); + return (parseExactJson(stdout) as NativeBalance)[0]; + }; + const raw = await native(false); + const valued = await native(true); + const rawRow = raw.find(([name]) => name === account.name)!; + const valuedRow = valued.find(([name]) => name === account.name)!; + expect(rawRow).toBeDefined(); + expect(valuedRow).toBeDefined(); + expect(account.summary).toMatchObject({ + status: 'ready', + balances: amounts(rawRow[3]), + valuation: { amounts: amounts(valuedRow[3]) } + }); + return prepared; +} + +it('prepares native cash-inclusive account valuations on the existing owned lot fixture', async () => { + const original = await readFile(lots, 'utf8'); + const prepared = await compareNativeSubtree(lots, 'assets:investments:brokerage'); + const brokerage = prepared.accounts.find(({ name }) => name === 'assets:investments:brokerage')!; + expect(brokerage.summary).toMatchObject({ + status: 'ready', + valuation: { + amounts: [{ commodity: 'USD', quantity: expect.stringMatching(/^2555(?:\.0+)?$/) }], + unconverted: [] + } + }); + expect(await readFile(lots, 'utf8')).toBe(original); +}); + +it('matches native exact subtrees across direct holdings, case, posting cutoffs and cancelled residuals', async () => { + const directory = await mkdtemp(join(tmpdir(), 'prepared-balances-')); + const journal = join(directory, 'main.journal'); + const source = `commodity USD 1.00 +commodity ABC 1. +commodity MISSING 1. +commodity XYZ 1. +account Assets:Parent ; type: A +account Assets:Parent:Child +account Assets:Parentage ; type: A +account Assets:Unused ; type: A +account assets:parent:other ; type: A +account Assets:Case ; type: A +account Assets:case ; type: A +account Assets:Cancel ; type: A +account Assets:Cancel:Long +account Assets:Cancel:Short +account Equity:Opening ; type: E +account Expenses:Office ; type: X +account Memo + +P 2026-04-01 ABC 10 USD + +2026-04-01 Parent lots, cash, non-assets and case-separated neighbours + Assets:Parent 2 ABC @ 10 USD + Assets:Parent 3 ABC @ 12 USD + Assets:Parent 100 USD + Assets:Parent:Child 20 USD + Assets:Parentage 5 USD + assets:parent:other 40 USD + Assets:Case 1 USD + Assets:case 2 USD + Expenses:Office 9 USD + Equity:Opening -233 USD + (Memo) 7 XYZ + +2026-04-02 Opposing unpriced child exposures + Assets:Cancel:Long 2 MISSING + Assets:Cancel:Short -2 MISSING + +2026-04-02 Later posting dates do not extend the primary cutoff + Assets:Parent 999 USD ; date: 2026-05-01 + Equity:Opening -999 USD ; date: 2026-05-01 +`; + try { + await writeFile(journal, source); + const prepared = await compareNativeSubtree(journal, 'Assets:Parent'); + expect(prepared.overview.asOf).toBe('2026-04-02'); + const summary = (name: string) => + prepared.accounts.find((account) => account.name === name)!.summary; + expect(summary('Assets:Parent')).toEqual({ + status: 'ready', + balances: [ + { commodity: 'ABC', quantity: '2' }, + { commodity: 'ABC', quantity: '3' }, + { commodity: 'USD', quantity: '120' } + ], + valuation: { amounts: [{ commodity: 'USD', quantity: '170' }], unconverted: [] } + }); + expect(summary('Assets:Unused')).toEqual({ + status: 'ready', + balances: [], + valuation: { amounts: [], unconverted: [] } + }); + expect(summary('Assets:Cancel')).toMatchObject({ + status: 'ready', + valuation: { unconverted: ['MISSING'] } + }); + expect(summary('Memo')).toEqual({ + status: 'ready', + balances: [{ commodity: 'XYZ', quantity: '7' }], + valuation: { amounts: [{ commodity: 'XYZ', quantity: '7' }], unconverted: ['XYZ'] } + }); + expect(summary('Expenses:Office')).toMatchObject({ status: 'ready' }); + expect(summary('Assets:Case')).toEqual({ status: 'ambiguous' }); + expect(summary('Assets:case')).toEqual({ status: 'ambiguous' }); + expect(summary('assets:parent:other')).toMatchObject({ + status: 'ready', + balances: [{ commodity: 'USD', quantity: '40' }] + }); + expect(await readFile(journal, 'utf8')).toBe(source); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/hledger/src/accounts/balances.test.ts b/packages/hledger/src/accounts/balances.test.ts new file mode 100644 index 0000000..26e9641 --- /dev/null +++ b/packages/hledger/src/accounts/balances.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { decodeAccountBalances, organizeAccountBalance, planAccountBalances } from './balances.js'; + +const asOf = '2026-04-02'; +const frame = (raw: string, valued = raw, prefix = 'overview') => + `${prefix}\n__HLEDGER_ACCOUNT_BALANCES__\n${raw}\n__HLEDGER_ACCOUNT_VALUATIONS__\n${valued}`; +const empty = '[[],[]]'; +const amount = (commodity: string, quantity: string) => ({ commodity, quantity }); + +describe('finite account balance reports', () => { + it('appends only two all-account native tree commands with the exact cutoff and literal target', () => { + const commands = planAccountBalances({ asOf, baseCurrency: 'US D,quoted' }); + const balance = [ + '--', + 'balance', + '--tree', + '--no-elide', + '--declared', + '--historical', + '--empty', + '-e', + '2026-04-03', + '-Ojson', + 'not:date:2026-04-03..' + ]; + expect(commands).toEqual([ + '--', + 'echo', + '__HLEDGER_ACCOUNT_BALANCES__', + ...balance, + '--', + 'echo', + '__HLEDGER_ACCOUNT_VALUATIONS__', + ...balance, + '--value=2026-04-02,US D,quoted' + ]); + expect(planAccountBalances({ asOf: null, baseCurrency: '' })).toEqual([]); + }); + + it('decodes from the end losslessly, preserving cost-distinguished entries and exact full names', () => { + const raw = `[[["Assets:Parent","Parent",1,[ + {"acommodity":"ABC","aquantity":{"decimalMantissa":900719925474099301,"decimalPlaces":2},"acost":{"tag":"UnitCost","contents":{"acommodity":"USD","aquantity":{"decimalMantissa":10,"decimalPlaces":0}}}}, + {"acommodity":"ABC","aquantity":{"decimalMantissa":3,"decimalPlaces":0},"acost":{"tag":"UnitCost","contents":{"acommodity":"USD","aquantity":{"decimalMantissa":12,"decimalPlaces":0}}}} + ]],["assets:parent","parent",1,[]],["Assets:Unused","Unused",1,[]]],[]]`; + const prefix = + 'metadata path\n__HLEDGER_ACCOUNT_BALANCES__\nnot a report\n__HLEDGER_ACCOUNT_VALUATIONS__\noverview'; + const text = frame(raw, empty, prefix); + const decoded = decodeAccountBalances(text, asOf); + expect(decoded.overviewBatch).toBe(prefix); + expect(decoded.balances.get('Assets:Parent')).toEqual([ + amount('ABC', '9007199254740993.01'), + amount('ABC', '3') + ]); + expect(decoded.balances.get('assets:parent')).toEqual([]); + expect(decoded.balances.get('Assets:Unused')).toEqual([]); + expect(decoded.balances.has('Parent')).toBe(false); + // Retrying has no effect on input or earlier results. + expect(decodeAccountBalances(text, asOf)).toEqual(decoded); + }); + + it('requires complete framing and native rows rather than accepting partial or duplicate output', () => { + expect(() => decodeAccountBalances(frame(empty).slice(0, -1), asOf)).toThrow(); + expect(() => decodeAccountBalances('overview', asOf)).toThrow(); + expect(() => + decodeAccountBalances(frame('[[["a","a",0,[]],["a","a",0,[]]],[]]'), asOf) + ).toThrow(); + expect(() => decodeAccountBalances(frame('[[["a","a",0,null]],[]]'), asOf)).toThrow(); + }); + + it('retains zero and unnamed residual evidence only within the exact-name subtree', () => { + const balances = new Map([['Assets:Parent', []]]); + const valuations = new Map([ + ['Assets:Parent', [amount('USD', '170')]], + ['Assets:Parent:Zero', [amount('ZERO', '0'), amount('', '0')]], + ['Assets:Parentage', [amount('SIBLING', '1')]], + ['assets:parent:other', [amount('OTHERCASE', '1')]] + ]); + expect( + organizeAccountBalance('Assets:Parent', 'ready', asOf, 'USD', { balances, valuations }) + ).toEqual({ + status: 'ready', + balances: [], + valuation: { amounts: [amount('USD', '170')], unconverted: ['ZERO', ''] } + }); + }); + + it('keeps qualification and dates authoritative, but rejects missing dated ready rows', () => { + const reports = decodeAccountBalances('unchanged', null); + expect(reports).toEqual({ + overviewBatch: 'unchanged', + balances: new Map(), + valuations: new Map() + }); + expect(organizeAccountBalance('a', 'ambiguous', null, 'USD', reports)).toEqual({ + status: 'ambiguous' + }); + expect(organizeAccountBalance('a', 'ambiguous', asOf, 'USD', reports)).toEqual({ + status: 'ambiguous' + }); + expect(organizeAccountBalance('a', 'ready', null, 'USD', reports)).toEqual({ + status: 'undated' + }); + for (const present of ['balances', 'valuations'] as const) { + const incomplete = { balances: new Map(), valuations: new Map() }; + incomplete[present].set('a', []); + expect(() => organizeAccountBalance('a', 'ready', asOf, 'USD', incomplete)).toThrow(); + } + }); +}); diff --git a/packages/hledger/src/accounts/balances.ts b/packages/hledger/src/accounts/balances.ts new file mode 100644 index 0000000..f5252d5 --- /dev/null +++ b/packages/hledger/src/accounts/balances.ts @@ -0,0 +1,129 @@ +import { parseExactJson, reportedAmounts, type Amount, type ReportedAmount } from '../amounts.js'; +import type { NativeValue } from '../overview/report.js'; +import { isoDate } from '../report-date.js'; + +export type PreparedBalance = + | { status: 'ambiguous' } + | { status: 'undated' } + | { status: 'ready'; balances: ReportedAmount[]; valuation: NativeValue }; + +interface AccountBalanceReports { + balances: Map; + valuations: Map; +} + +const balanceMarker = '\n__HLEDGER_ACCOUNT_BALANCES__\n'; +const valuationMarker = '\n__HLEDGER_ACCOUNT_VALUATIONS__\n'; +const failureMessage = 'Invalid hledger account balance report.'; + +/** Append two finite reports to the existing cached-journal financial plan. */ +export function planAccountBalances({ + asOf, + baseCurrency +}: { + asOf: string | null; + baseCurrency: string; +}): string[] { + if (asOf === null) return []; + const end = new Date(`${asOf}T00:00:00Z`); + end.setUTCDate(end.getUTCDate() + 1); + const endExclusive = isoDate(end); + const balance = [ + '--', + 'balance', + '--tree', + '--no-elide', + '--declared', + '--historical', + '--empty', + '-e', + endExclusive, + '-Ojson', + `not:date:${endExclusive}..` + ]; + return [ + '--', + 'echo', + balanceMarker.trim(), + ...balance, + '--', + 'echo', + valuationMarker.trim(), + ...balance, + // The inferred commodity ID is literal, even when empty or containing spaces/commas. + `--value=${asOf},${baseCurrency}` + ]; +} + +function decodeBalanceReport(text: string): Map { + const report = parseExactJson(text); + if (!Array.isArray(report) || report.length !== 2 || !Array.isArray(report[0])) { + throw new Error(failureMessage); + } + // Check the native total bag, but never use it to construct account balances. + reportedAmounts(report[1] as Amount[]); + const rows = new Map(); + for (const row of report[0]) { + if ( + !Array.isArray(row) || + row.length !== 4 || + typeof row[0] !== 'string' || + !row[0] || + typeof row[1] !== 'string' || + !Number.isInteger(row[2]) || + row[2] < 0 || + rows.has(row[0]) + ) { + throw new Error(failureMessage); + } + // Index the exact full name, not the shortened tree display label. + rows.set(row[0], reportedAmounts(row[3] as Amount[])); + } + return rows; +} + +/** Pure prefix decoding: safe to retry while the existing report stream grows. */ +export function decodeAccountBalances( + batch: string, + asOf: string | null +): AccountBalanceReports & { overviewBatch: string } { + if (asOf === null) return { overviewBatch: batch, balances: new Map(), valuations: new Map() }; + // Metadata can contain literal framing text; native JSON strings escape newlines. + const valuationSplit = batch.lastIndexOf(valuationMarker); + if (valuationSplit < 0) throw new Error(failureMessage); + const valuations = decodeBalanceReport(batch.slice(valuationSplit + valuationMarker.length)); + batch = batch.slice(0, valuationSplit); + const balanceSplit = batch.lastIndexOf(balanceMarker); + if (balanceSplit < 0) throw new Error(failureMessage); + const balances = decodeBalanceReport(batch.slice(balanceSplit + balanceMarker.length)); + return { overviewBatch: batch.slice(0, balanceSplit), balances, valuations }; +} + +/** Qualification belongs to the existing native account directory, not row presence. */ +export function organizeAccountBalance( + name: string, + access: 'ready' | 'ambiguous', + asOf: string | null, + baseCurrency: string, + reports: AccountBalanceReports +): PreparedBalance { + if (access === 'ambiguous') return { status: 'ambiguous' }; + if (asOf === null) return { status: 'undated' }; + const balances = reports.balances.get(name); + const amounts = reports.valuations.get(name); + if (!balances || !amounts) throw new Error(failureMessage); + const unconverted = new Set(); + for (const [account, bag] of reports.valuations) { + if (account !== name && !account.startsWith(`${name}:`)) continue; + // Child residuals can cancel in the inclusive parent. Retain their evidence, + // including explicit zeros; this is set collection, never financial arithmetic. + for (const { commodity } of bag) { + if (commodity !== baseCurrency) unconverted.add(commodity); + } + } + return { + status: 'ready', + balances, + valuation: { amounts, unconverted: [...unconverted] } + }; +} diff --git a/packages/hledger/src/index.ts b/packages/hledger/src/index.ts index d229603..e9a7220 100644 --- a/packages/hledger/src/index.ts +++ b/packages/hledger/src/index.ts @@ -4,7 +4,12 @@ export { JournalCheckError, supportedHledgerVersion } from './checks.js'; -export type { OverviewData } from './overview/report.js'; +export type { + OverviewData, + OverviewMonths, + OverviewRange, + NativeValue +} from './overview/report.js'; export { runCostBalance } from './queries/cost-balance.js'; export type { CostBalanceReport } from './queries/cost-balance.js'; export type { ReportedAmount } from './amounts.js'; @@ -12,6 +17,7 @@ export { runHoldings } from './holdings/report.js'; export type { HoldingPosition, HoldingsAccount, HoldingsReport } from './holdings/report.js'; export { prepareReports } from './prepare-reports.js'; export type { PreparedAccountSummary } from './prepare-reports.js'; +export type { PreparedBalance } from './accounts/balances.js'; export type { PreparedAccountRow } from './transactions/account-rows.js'; export type { PreparedInvestmentData, diff --git a/packages/hledger/src/overview/batch.test.ts b/packages/hledger/src/overview/batch.test.ts index bcbb40c..9d7b63e 100644 --- a/packages/hledger/src/overview/batch.test.ts +++ b/packages/hledger/src/overview/batch.test.ts @@ -1,262 +1,111 @@ -import { afterEach, expect, it, vi } from 'vitest'; -import { formatQuantity, type Amount } from '../amounts.js'; -import { parseIncomeStatement, type HledgerIncomeStatement } from './income-statement.js'; -import { decodeOverviewReports, planOverviewReports, type OverviewReports } from './batch.js'; +import { expect, it } from 'vitest'; +import { decodeOverviewReports, planOverviewReports } from './batch.js'; import { journalMetadata } from '../journal-stats.js'; -import { balanceFrames } from '../../test-fixtures.js'; -const minimal = '{ "cbrDates": [], "cbrSubreports": [], "nativeExtra": true }\n'; -const marker = '\n__HLEDGER_INCOME_REPORT__\n'; -const coverage = { transactionCount: 9, firstDate: '2026-04-01', lastDate: '2026-04-16' }; -const stats = (date = '2026-04-16') => { - if (date === 'none') { - return 'Base currency : none\nMain file : /private/journal\nTxns span : to (0 days)\nLast txn : none\nTxns : 0 (0.0 per day)\n'; - } - let firstDate = date; - let days = 1; - let rate = '9.0'; - if (date === '2026-04-16') { - firstDate = '2026-04-01'; - days = 16; - rate = '0.6'; - } - const end = new Date(`${date}T00:00:00Z`); - end.setUTCDate(end.getUTCDate() + 1); - // Malformed metadata must reach the parser, not throw while building a fixture. - const endExclusive = Number.isNaN(end.getTime()) ? 'invalid' : end.toISOString().slice(0, 10); - return `Base currency : USD\nMain file : /private/journal\nTxns span : ${firstDate} to ${endExclusive} (${days} days)\nLast txn : ${date} (188 days ago)\nTxns : 9 (${rate} per day)\n`; -}; -const batch = (date = '2026-04-16', json = minimal) => - `${stats(date)}${marker.slice(1)}${json}${balanceFrames(date === 'none' ? '2026-04-16' : date)}`; -function decodeFixture(first = stats(), second = batch()) { - const metadata = journalMetadata(first); - return decodeOverviewReports(second, metadata, planOverviewReports(metadata)); -} -const quantities = (amounts: Amount[]) => - amounts.map((amount) => [amount.acommodity, formatQuantity(amount.aquantity)]); -const totals = (report: HledgerIncomeStatement, section: number, month = 5) => - report.cbrSubreports[section][1].prTotals.prrAmounts[month]; +import { balanceFrames, incomeFrames } from '../../test-fixtures.js'; -function reportOf(result: OverviewReports): HledgerIncomeStatement { - expect(result.report).not.toBeNull(); - return result.report!; +const stats = (asOf = '2026-04-16', currency = 'USD') => + asOf === 'none' + ? `Base currency : ${currency}\nLast txn : none\nTxns : 0\nTxns span : to \n` + : `Base currency : ${currency}\nLast txn : ${asOf}\nTxns : 1\nTxns span : ${asOf} to ${nextDay(asOf)}\n`; +function nextDay(asOf: string) { + const date = new Date(`${asOf}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + 1); + return date.toISOString().slice(0, 10); +} +const batch = (asOf = '2026-04-16') => stats(asOf) + incomeFrames(asOf) + balanceFrames(asOf); +function decode(text = batch(), discovery = stats()) { + const metadata = journalMetadata(discovery); + return decodeOverviewReports(text, metadata, planOverviewReports(metadata)); } -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); -}); - -it('plans metadata, income and six exact-date native valuations in one finite batch', () => { - const result: OverviewReports = decodeFixture(); - const args = planOverviewReports(journalMetadata(stats())).commands; - expect(args.slice(0, 18)).toEqual([ - 'stats', - '--', - 'echo', - '__HLEDGER_INCOME_REPORT__', - '--', - 'incomestatement', - '--flat', - '--change', - '--monthly', - '--empty', - '--depth=2', - '-b', - '2025-11-01', - '-e', - '2026-05-01', - '-Ojson', - 'not:type:G', - 'not:date:2026-04-17..' +it('plans four statements and twelve exact-date sheets in one finite cached financial batch', () => { + const plan = planOverviewReports(journalMetadata(stats())); + expect( + plan.incomeRanges.map(({ months, begin, endExclusive }) => ({ months, begin, endExclusive })) + ).toEqual([ + { months: 1, begin: '2026-04-01', endExclusive: '2026-04-17' }, + { months: 3, begin: '2026-02-01', endExclusive: '2026-04-17' }, + { months: 6, begin: '2025-11-01', endExclusive: '2026-04-17' }, + { months: 12, begin: '2025-05-01', endExclusive: '2026-04-17' } ]); - const dates = [ + const commands = plan.commands.join(' ').split(' -- echo ').slice(1); + expect(commands).toHaveLength(16); + for (const [index, range] of plan.incomeRanges.entries()) { + expect(commands[index]).toBe( + `__HLEDGER_INCOME_${range.months}__ -- incomestatement --flat --change --monthly --empty --depth=2 -b ${range.begin} -e 2026-05-01 -Ojson not:type:G not:date:2026-04-17..` + ); + } + expect(plan.snapshotPeriods.map(({ date }) => date)).toEqual([ + '2025-05-31', + '2025-06-30', + '2025-07-31', + '2025-08-31', + '2025-09-30', + '2025-10-31', '2025-11-30', '2025-12-31', '2026-01-31', '2026-02-28', '2026-03-31', '2026-04-16' - ]; - let offset = 18; - for (const [index, date] of dates.entries()) { - const begin = date.slice(0, 7) + '-01'; - const next = new Date(`${date}T00:00:00Z`); - next.setUTCDate(next.getUTCDate() + 1); - const end = next.toISOString().slice(0, 10); - const command = [ - '--', - 'echo', - `__HLEDGER_NET_WORTH_${index + 1}__`, - '--', - 'balancesheet', - '--historical', - '--flat', - '--empty', - '--depth=1', - '-b', - begin, - '-e', - end, - `--value=${date},USD`, - '-Ojson', - `not:date:${end}..` - ]; - expect(args.slice(offset, offset + command.length)).toEqual(command); - offset += command.length; + ]); + for (const [index, point] of plan.snapshotPeriods.entries()) { + expect(commands[index + 4]).toBe( + `__HLEDGER_NET_WORTH_${index + 1}__ -- balancesheet --historical --flat --empty --depth=1 -b ${point.begin} -e ${point.endExclusive} --value=${point.date},USD -Ojson not:date:${point.endExclusive}..` + ); } - expect(args).toHaveLength(offset); - expect(result).toMatchObject({ - asOf: '2026-04-16', - journalCoverage: coverage, - report: parseIncomeStatement(minimal) - }); + const decoded = decode(); + expect(decoded.incomeStatements.map(({ months }) => months)).toEqual([1, 3, 6, 12]); + expect(decoded.snapshots).toHaveLength(12); }); it.each([ - ['2024-02-29', '2023-09-01', '2024-03-01', '2024-03-01'], - ['2026-03-31', '2025-10-01', '2026-04-01', '2026-04-01'], - ['2026-01-01', '2025-08-01', '2026-02-01', '2026-01-02'], - ['2026-12-31', '2026-07-01', '2027-01-01', '2027-01-01'], - ['0001-06-30', '0001-01-01', '0001-07-01', '0001-07-01'] -])('derives calendar boundaries from native Last txn %s', (asOf, begin, end, tomorrow) => { - expect(decodeFixture(stats(asOf), batch(asOf)).asOf).toBe(asOf); - const args = planOverviewReports(journalMetadata(stats(asOf))).commands; - expect(args[args.indexOf('-b') + 1]).toBe(begin); - expect(args[args.indexOf('-e') + 1]).toBe(end); - expect(args.at(-1)).toBe(`not:date:${tomorrow}..`); - expect(args).toContain(`--value=${asOf},USD`); -}); - -it.each([ - [ - '2024-03-01', - ['2023-10-31', '2023-11-30', '2023-12-31', '2024-01-31', '2024-02-29', '2024-03-01'] - ], - [ - '2026-01-01', - ['2025-08-31', '2025-09-30', '2025-10-31', '2025-11-30', '2025-12-31', '2026-01-01'] - ] + ['2024-03-01', '2023-04-01', '2024-02-29', '2024-03-02'], + ['2026-01-01', '2025-02-01', '2025-12-31', '2026-01-02'], + ['0001-12-31', '0001-01-01', '0001-11-30', '0002-01-01'] ])( - 'derives the prior five month-end snapshots in UTC and the final exact primary date %s', - (asOf, expected) => { - const result = decodeFixture(stats(asOf), batch(asOf)); - expect(result.snapshots.map((point) => point.date)).toEqual(expected); - expect( - planOverviewReports(journalMetadata(stats(asOf))).commands.filter((arg) => - arg.startsWith('--value=') - ) - ).toEqual(expected.map((date) => `--value=${date},USD`)); + 'derives twelve calendar months and the actual cutoff from %s', + (asOf, begin, previous, cutoff) => { + const plan = planOverviewReports(journalMetadata(stats(asOf))); + expect(plan.incomeRanges[3].begin).toBe(begin); + expect(plan.incomeRanges[3].endExclusive).toBe(cutoff); + expect(plan.snapshotPeriods[10].date).toBe(previous); + expect(plan.snapshotPeriods[11].date).toBe(asOf); + expect(decode(batch(asOf), stats(asOf)).asOf).toBe(asOf); } ); -it.each(['1900-01-01', '2099-12-31'])('does not use the computer date %s', (today) => { - vi.useFakeTimers({ toFake: ['Date'] }); - vi.setSystemTime(new Date(`${today}T00:00:00Z`)); - expect(decodeFixture()).toMatchObject({ - asOf: '2026-04-16', - journalCoverage: coverage, - report: parseIncomeStatement(minimal) - }); +it('rejects a twelve-month window outside supported dates', () => { + expect(() => planOverviewReports(journalMetadata(stats('0001-06-30')))).toThrow(); }); -it('plans stats and decodes empty data for exact none, without a clock fallback', () => { - expect(planOverviewReports(journalMetadata(stats('none')))).toEqual({ - commands: ['stats'], - incomePeriods: [], - snapshotPeriods: [] - }); - expect(decodeFixture(stats('none'), stats('none'))).toEqual({ - asOf: null, - baseCurrency: 'none', - incomePeriods: [], - snapshots: [], - journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - report: null +it('omits all financial commands and dates for undated input', () => { + const metadata = journalMetadata(stats('none')); + const plan = planOverviewReports(metadata); + expect(plan).toEqual({ commands: ['stats'], incomeRanges: [], snapshotPeriods: [] }); + expect(decodeOverviewReports(stats('none'), metadata, plan)).toEqual({ + ...metadata, + incomeStatements: [], + snapshots: [] }); + expect(() => decodeOverviewReports(stats(), metadata, plan)).toThrow(); +}); + +it.each(['INCOME_3', 'NET_WORTH_6'])('requires financial framing for %s', (name) => { + expect(() => decode(batch().replace(`__HLEDGER_${name}__`, 'missing'))).toThrow(); +}); + +it('splits backwards despite literal markers in paths and escaped JSON strings', () => { + const markers = '\n__HLEDGER_INCOME_12__\n\n__HLEDGER_NET_WORTH_12__\n'; + const text = batch().replace('"cbrTotals":', `"ignored":${JSON.stringify(markers)},"cbrTotals":`); + expect(decode(`Main file : /private/${markers}filename\n${text}`).snapshots).toHaveLength(12); }); it.each([ - '', - '2026-02-30', - '2025-02-29', - '2026-4-15', - '2026-04-15T00:00:00Z', - '0000-01-01', - '0001-05-31', - '9999-12-15', - '9999-12-31', - '10000-01-01', - 'None', - 'none (0 days ago)' -])('rejects invalid metadata or boundaries before the batch (case %#)', (date) => { - expect(() => planOverviewReports(journalMetadata(stats(date)))).toThrow(); -}); - -it.each([ - '', - 'Last transaction : 2026-04-16\n', - 'Last txn :\n2026-04-16\n', - stats() + stats(), - stats() + 'Last txn : none\n' -])('fails closed on missing or ambiguous stats (case %#)', (text) => { - expect(() => journalMetadata(text)).toThrow(); -}); - -it.each(['none', '2026-04-15', '2026-05-01'])( - 'rejects a second parsed journal with a changed Last txn %s', - (second) => { - expect(() => decodeFixture(stats(), batch(second))).toThrow(); - } -); - -it('accepts changed values at the same anchor from the second native loaded view', () => { - const changed = `{"cbrDates":[],"cbrSubreports":[["Revenues",{"prTotals":{"prrAmounts":[[{"acommodity":"USD","aquantity":{"decimalMantissa":12500,"decimalPlaces":2}}]]}},true]]}\n`; - const updatedStats = stats().replace('2026-04-01 to', '2025-01-01 to').replace(': 9 (', ': 10 ('); - const result = decodeFixture(stats(), `${updatedStats}${marker}${changed}${balanceFrames()}`); - expect(result).toMatchObject({ - asOf: '2026-04-16', - journalCoverage: { ...coverage, transactionCount: 10, firstDate: '2025-01-01' }, - report: parseIncomeStatement(changed) - }); - expect(quantities(totals(reportOf(result), 0, 0))).toEqual([['USD', '125.00']]); -}); - -it('splits the last marker and parses escaped markers', () => { - const json = ` {"cbrDates":[],"cbrSubreports":[],"exact":900719925474099301,"text":${JSON.stringify(marker)}}\n\n`; - const second = `Main file : /private/path${marker}part\n${stats()}${marker}${json}${balanceFrames()}`; - expect(decodeFixture(stats(), second)).toMatchObject({ - asOf: '2026-04-16', - journalCoverage: coverage, - report: parseIncomeStatement(json) - }); -}); - -it.each([ - stats() + minimal, - `${stats()}__HLEDGER_INCOME_REPORT__ ${minimal}`, - `${marker}${minimal}`, - `${stats()}${marker}SECRET invalid JSON`, - `${stats()}${marker}null`, - `${stats()}${marker}[]`, - `${stats()}${marker}{}`, - `${stats()}${marker}{"cbrDates":[],"cbrSubreports":null}`, - `${stats()}${marker}{"cbrDates":[],"cbrSubreports":[],"decimalMantissa":1.5}`, - `${stats()}${marker}${minimal}${marker}private tail`, - `${stats('2026-02-30')}${marker}${minimal}` -])('rejects malformed batch framing, metadata or report decoding (case %#)', (text) => { - expect(() => decodeFixture(stats(), text + balanceFrames())).toThrow(); -}); - -it('rejects changed empty-journal metadata or base currency', () => { - const discovery = journalMetadata(stats('none')); - const plan = planOverviewReports(discovery); - expect(() => decodeOverviewReports(stats(), discovery, plan)).toThrow(); - for (const currency of ['USD', 'usd', '']) - expect(() => - decodeOverviewReports( - stats('none').replace('Base currency : none', `Base currency : ${currency}`), - discovery, - plan - ) - ).toThrow(); + batch().replace('__HLEDGER_INCOME_3__\n{', '__HLEDGER_INCOME_3__\nSECRET {'), + batch() + 'private tail', + batch().replace('Last txn : 2026-04-16', 'Last txn : 2026-04-15'), + batch().replace('Base currency : USD', 'Base currency : usd') +])('rejects malformed financial framing or changed discovery metadata (case %#)', (text) => { + expect(() => decode(text)).toThrow(); }); diff --git a/packages/hledger/src/overview/batch.ts b/packages/hledger/src/overview/batch.ts index 110e9bf..87ee1f8 100644 --- a/packages/hledger/src/overview/batch.ts +++ b/packages/hledger/src/overview/batch.ts @@ -2,86 +2,69 @@ import { parseBalanceSheet, type HledgerBalanceSheet } from './balance-sheet.js' import { parseIncomeStatement, type HledgerIncomeStatement } from './income-statement.js'; import { journalMetadata, type JournalCoverage } from '../journal-stats.js'; import { isoDate } from '../report-date.js'; - -interface Period { - begin: string; - endExclusive: string; -} +import { planIncomeRanges, type IncomeRangePlan, type Period } from './ranges.js'; export interface OverviewReports { asOf: string | null; baseCurrency: string; journalCoverage: JournalCoverage; - report: HledgerIncomeStatement | null; - incomePeriods: Period[]; + incomeStatements: (IncomeRangePlan & { report: HledgerIncomeStatement })[]; snapshots: (Period & { date: string; report: HledgerBalanceSheet })[]; } -const incomeMarker = '\n__HLEDGER_INCOME_REPORT__\n'; -// A finite batch: these are static framing tokens, not a report registry. -const balanceMarkers = [ - '\n__HLEDGER_NET_WORTH_1__\n', - '\n__HLEDGER_NET_WORTH_2__\n', - '\n__HLEDGER_NET_WORTH_3__\n', - '\n__HLEDGER_NET_WORTH_4__\n', - '\n__HLEDGER_NET_WORTH_5__\n', - '\n__HLEDGER_NET_WORTH_6__\n' +// Static framing for four ranges and twelve valuations, not a report registry. +const incomeMarkers = [ + '\n__HLEDGER_INCOME_1__\n', + '\n__HLEDGER_INCOME_3__\n', + '\n__HLEDGER_INCOME_6__\n', + '\n__HLEDGER_INCOME_12__\n' ]; +const balanceMarkers = Array.from( + { length: 12 }, + (_, index) => `\n__HLEDGER_NET_WORTH_${index + 1}__\n` +); const failureMessage = 'Invalid hledger Overview batch.'; -// Finite financial plan used by the combined prepared reports job. export function planOverviewReports({ asOf, baseCurrency }: Pick) { - if (asOf === null) return { commands: ['stats'], incomePeriods: [], snapshotPeriods: [] }; - const date = new Date(`${asOf}T00:00:00Z`); - const nextDay = new Date(date); - nextDay.setUTCDate(nextDay.getUTCDate() + 1); - const dayAfterLastTransaction = isoDate(nextDay); - date.setUTCDate(1); - date.setUTCMonth(date.getUTCMonth() - 5); - const incomePeriods: Period[] = []; + const incomeRanges = asOf === null ? [] : planIncomeRanges(asOf); const snapshotPeriods: (Period & { date: string })[] = []; - for (let index = 0; index < 6; index++) { - const begin = isoDate(date); - date.setUTCMonth(date.getUTCMonth() + 1); - const endExclusive = isoDate(date); - incomePeriods.push({ begin, endExclusive }); - const monthEnd = new Date(date); - monthEnd.setUTCDate(monthEnd.getUTCDate() - 1); - let valuationDate = isoDate(monthEnd); - let snapshotEnd = endExclusive; - if (index === 5) { - valuationDate = asOf; - snapshotEnd = dayAfterLastTransaction; - } - snapshotPeriods.push({ begin, endExclusive: snapshotEnd, date: valuationDate }); + const commands = ['stats']; + if (asOf === null) return { commands, incomeRanges, snapshotPeriods }; + const history = incomeRanges[3]; + for (const [index, range] of incomeRanges.entries()) { + commands.push( + '--', + 'echo', + incomeMarkers[index].trim(), + '--', + 'incomestatement', + '--flat', + '--change', + '--monthly', + '--empty', + '--depth=2', + '-b', + range.begin, + '-e', + range.periods.at(-1)!.endExclusive, + '-Ojson', + 'not:type:G', + // Monthly expansion defeats positive date queries and a mid-month -e cutoff. + `not:date:${range.endExclusive}..` + ); } - - // One parsed journal supplies metadata, income and all six exact-date valuations. - const commands = [ - 'stats', - '--', - 'echo', - incomeMarker.trim(), - '--', - 'incomestatement', - '--flat', - '--change', - '--monthly', - '--empty', - '--depth=2', - '-b', - incomePeriods[0].begin, - '-e', - incomePeriods[5].endExclusive, - '-Ojson', - 'not:type:G', - // Monthly expansion defeats positive date queries and a mid-month -e cutoff. - `not:date:${dayAfterLastTransaction}..` - ]; - for (const [index, point] of snapshotPeriods.entries()) { + for (const [index, period] of history.periods.entries()) { + const monthEnd = new Date(`${period.endExclusive}T00:00:00Z`); + monthEnd.setUTCDate(monthEnd.getUTCDate() - 1); + const point = { + begin: period.begin, + endExclusive: index === 11 ? history.endExclusive : period.endExclusive, + date: index === 11 ? asOf : isoDate(monthEnd) + }; + snapshotPeriods.push(point); commands.push( '--', 'echo', @@ -96,46 +79,47 @@ export function planOverviewReports({ point.begin, '-e', point.endExclusive, - // One native argument, including literal spaces/commas or the empty ID. + // Preserve the literal base ID as one argument, including empty/spaces/commas. `--value=${point.date},${baseCurrency}`, '-Ojson', `not:date:${point.endExclusive}..` ); } - return { commands, incomePeriods, snapshotPeriods }; + return { commands, incomeRanges, snapshotPeriods }; } export function decodeOverviewReports( batch: string, { asOf, baseCurrency }: Pick, - { incomePeriods, snapshotPeriods }: ReturnType + { incomeRanges, snapshotPeriods }: ReturnType ): OverviewReports { - if (asOf === null) { - const metadata = journalMetadata(batch); - if (metadata.asOf !== null || metadata.baseCurrency !== baseCurrency) - throw new Error(failureMessage); - return { ...metadata, report: null, incomePeriods: [], snapshots: [] }; - } - const snapshots: OverviewReports['snapshots'] = []; - // Decode from the end: metadata paths may contain earlier literal markers, - // while markers in JSON strings are escaped and cannot match this framing. - for (let index = 5; index >= 0; index--) { - const marker = balanceMarkers[index]; - const split = batch.lastIndexOf(marker); - if (split < 0) throw new Error(failureMessage); - snapshots.unshift({ - ...snapshotPeriods[index], - report: parseBalanceSheet(batch.slice(split + marker.length)) - }); - batch = batch.slice(0, split); + const incomeStatements: OverviewReports['incomeStatements'] = []; + if (asOf !== null) { + // Decode backwards: paths may contain earlier literal markers; JSON strings escape them. + for (let index = 11; index >= 0; index--) { + const marker = balanceMarkers[index]; + const split = batch.lastIndexOf(marker); + if (split < 0) throw new Error(failureMessage); + snapshots.unshift({ + ...snapshotPeriods[index], + report: parseBalanceSheet(batch.slice(split + marker.length)) + }); + batch = batch.slice(0, split); + } + for (let index = 3; index >= 0; index--) { + const marker = incomeMarkers[index]; + const split = batch.lastIndexOf(marker); + if (split < 0) throw new Error(failureMessage); + incomeStatements.unshift({ + ...incomeRanges[index], + report: parseIncomeStatement(batch.slice(split + marker.length)) + }); + batch = batch.slice(0, split); + } } - const split = batch.lastIndexOf(incomeMarker); - if (split < 0) throw new Error(failureMessage); - const metadata = journalMetadata(batch.slice(0, split)); - if (metadata.asOf !== asOf || metadata.baseCurrency !== baseCurrency) { + const metadata = journalMetadata(batch); + if (metadata.asOf !== asOf || metadata.baseCurrency !== baseCurrency) throw new Error(failureMessage); - } - const report = parseIncomeStatement(batch.slice(split + incomeMarker.length)); - return { ...metadata, report, incomePeriods, snapshots }; + return { ...metadata, incomeStatements, snapshots }; } diff --git a/packages/hledger/src/overview/income-statement.test.ts b/packages/hledger/src/overview/income-statement.test.ts index cb3ec37..079b910 100644 --- a/packages/hledger/src/overview/income-statement.test.ts +++ b/packages/hledger/src/overview/income-statement.test.ts @@ -1,14 +1,16 @@ import { expect, it } from 'vitest'; -import { formatQuantity, parseExactJson } from '../amounts.js'; +import { formatQuantity } from '../amounts.js'; import { parseIncomeStatement } from './income-statement.js'; -it('parses exact mantissas and retains all unconsumed native fields separately', () => { - const json = `{"cbrDates":[],"cbrSubreports":[["Revenues",{"prTotals":{"prrAmounts":[[{"acommodity":"USD","acost":null,"aquantity":{"decimalMantissa":900719925474099301,"decimalPlaces":2}}]],"extraTotal":42},"prRows":["untouched"]},true]],"cbrTitle":"native title","extra":{"key":"value"}}`; +it('parses exact mantissas in monthly and range totals', () => { + const json = `{"cbrDates":[],"cbrTotals":{"prrTotal":[]},"cbrSubreports":[["Revenues",{"prTotals":{"prrAmounts":[[{"acommodity":"USD","acost":null,"aquantity":{"decimalMantissa":900719925474099301,"decimalPlaces":2}}]],"prrTotal":[{"acommodity":"USD","aquantity":{"decimalMantissa":900719925474099301,"decimalPlaces":2}}]},"prRows":[]},true]]}`; const report = parseIncomeStatement(json); - expect(report).toEqual(parseExactJson(json)); expect(report.cbrSubreports[0][1].prTotals.prrAmounts[0][0].aquantity.decimalMantissa).toBe( 900719925474099301n ); + expect(report.cbrSubreports[0][1].prTotals.prrTotal[0].aquantity.decimalMantissa).toBe( + 900719925474099301n + ); expect( report.cbrSubreports[0][1].prTotals.prrAmounts[0].map((a) => [ a.acommodity, @@ -17,14 +19,9 @@ it('parses exact mantissas and retains all unconsumed native fields separately', ).toEqual([['USD', '9007199254740993.01']]); }); -it.each(['SECRET invalid JSON', 'null', '[]', '{}', '{"cbrDates":[],"cbrSubreports":null}'])( - 'rejects malformed report shapes (case %#)', - (text) => { - expect(() => parseIncomeStatement(text)).toThrow(); - } -); - -it.each(['1.5', '"123"', 'null'])('requires the exact integer mantissa codec for %s', (token) => { - const json = `{"cbrDates":[],"cbrSubreports":[],"decimalMantissa":${token}}`; - expect(() => parseIncomeStatement(json)).toThrow(); +it.each([ + 'SECRET invalid JSON', + '{"cbrDates":[],"cbrTotals":{"prrTotal":[]},"cbrSubreports":null}' +])('rejects malformed report shapes (case %#)', (text) => { + expect(() => parseIncomeStatement(text)).toThrow(); }); diff --git a/packages/hledger/src/overview/income-statement.ts b/packages/hledger/src/overview/income-statement.ts index 1498a03..5627961 100644 --- a/packages/hledger/src/overview/income-statement.ts +++ b/packages/hledger/src/overview/income-statement.ts @@ -2,10 +2,11 @@ import { parseExactJson, type Amount } from '../amounts.js'; export interface HledgerIncomeStatement { cbrDates: [{ tag: 'Exact'; contents: string }, { tag: 'Exact'; contents: string }][]; + cbrTotals: { prrTotal: Amount[] }; cbrSubreports: [ title: string, report: { - prTotals: { prrAmounts: Amount[][] }; + prTotals: { prrAmounts: Amount[][]; prrTotal: Amount[] }; prRows: { prrName: string; prrTotal: Amount[] }[]; }, contribution: boolean @@ -22,7 +23,8 @@ export function parseIncomeStatement(json: string): HledgerIncomeStatement { !('cbrDates' in report) || !Array.isArray(report.cbrDates) || !('cbrSubreports' in report) || - !Array.isArray(report.cbrSubreports) + !Array.isArray(report.cbrSubreports) || + !('cbrTotals' in report) ) { throw new Error(failureMessage); } diff --git a/packages/hledger/src/overview/net-worth.test.ts b/packages/hledger/src/overview/net-worth.test.ts index 51a43f5..fd90577 100644 --- a/packages/hledger/src/overview/net-worth.test.ts +++ b/packages/hledger/src/overview/net-worth.test.ts @@ -3,11 +3,24 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { prepareReports, getHledgerVersion } from '../index.js'; -import { balanceFrames, decodeOverviewFixture } from '../../test-fixtures.js'; +import { balanceFrames, decodeOverviewFixture, incomeFrames } from '../../test-fixtures.js'; import { planOverviewReports } from './batch.js'; import { journalMetadata } from '../journal-stats.js'; -const dates = ['2025-11-30', '2025-12-31', '2026-01-31', '2026-02-28', '2026-03-31', '2026-04-16']; +const dates = [ + '2025-05-31', + '2025-06-30', + '2025-07-31', + '2025-08-31', + '2025-09-30', + '2025-10-31', + '2025-11-30', + '2025-12-31', + '2026-01-31', + '2026-02-28', + '2026-03-31', + '2026-04-16' +]; const accounts = `account Wallet ; type: A account Shares ; type: A account Debt ; type: L @@ -26,7 +39,7 @@ const stats = (currency = 'USD') => `Base currency : ${currency}\nLast txn : 2026-04-16\nTxns : 1\nTxns span : 2026-04-16 to 2026-04-17\n`; const frames = () => balanceFrames() - .split(/\n__HLEDGER_NET_WORTH_\d__\n/) + .split(/\n__HLEDGER_NET_WORTH_\d+__\n/) .slice(1) .map( (text) => @@ -41,21 +54,12 @@ const frames = () => } ); function batch(reports = frames(), currency = 'USD') { - const income = { - cbrDates: frames().map((report, index) => [ - report.cbrDates[0][0], - { tag: 'Exact', contents: index === 5 ? '2026-05-01' : report.cbrDates[0][1].contents } - ]), - cbrSubreports: [ - ['Revenues', { prTotals: { prrAmounts: [[], [], [], [], [], []] }, prRows: [] }, true], - ['Expenses', { prTotals: { prrAmounts: [[], [], [], [], [], []] }, prRows: [] }, false] - ] - }; - return `${stats(currency)}\n__HLEDGER_INCOME_REPORT__\n${JSON.stringify(income)}${reports.map((report, index) => `\n__HLEDGER_NET_WORTH_${index + 1}__\n${JSON.stringify(report)}`).join('')}`; + return `${stats(currency)}${incomeFrames()}${reports.map((report, index) => `\n__HLEDGER_NET_WORTH_${index + 1}__\n${JSON.stringify(report)}`).join('')}`; } + it('uses the exact native net bag, retaining mixed amounts, order and explicit zero entries', () => { const reports = frames(); - const report = reports[5]; + const report = reports[11]; // Deliberately unrelated numbers: no JS accounting or net reconstruction. report.cbrTotals.prrAmounts = [ [nativeAmount('z', -300), nativeAmount('USD', 0), nativeAmount('a', 700)] @@ -71,10 +75,20 @@ it('uses the exact native net bag, retaining mixed amounts, order and explicit z currency: 'USD', points: dates.map((date, index) => ({ date, - amounts: index === 5 ? [q('-3.00', 'z'), q('0.00'), q('7.00', 'a')] : [], - unconverted: index === 5 ? ['unpriced', 'a', 'only-debt'] : [] + amounts: index === 11 ? [q('-3.00', 'z'), q('0.00'), q('7.00', 'a')] : [], + unconverted: index === 11 ? ['unpriced', 'a', 'only-debt'] : [] })) }); + expect(data.balanceSheet).toEqual({ + assets: { + amounts: [q('0.00', 'unpriced'), q('4.00'), q('2.00', 'a')], + unconverted: ['unpriced', 'a'] + }, + liabilities: { + amounts: [q('0.00', 'unpriced'), q('1.00', 'only-debt')], + unconverted: ['unpriced', 'only-debt'] + } + }); expect(JSON.parse(JSON.stringify(data))).toEqual(data); }); @@ -92,13 +106,6 @@ it.each(['', 'none', 'usd', 'US Dollar', 'US,Dollar', ' USD '])( } ); -it.each(['EUR', '', 'usd', ' USD ', 'none'])( - 'rejects a changed second-pass inferred base %j without publishing income', - (currency) => { - expect(() => decodeOverviewFixture(stats(), batch(frames(), currency))).toThrow(); - } -); - it.each([ ['missing dates', (r: ReturnType[number]) => r.cbrDates.pop()], ['extra dates', (r: ReturnType[number]) => r.cbrDates.push(r.cbrDates[0])], @@ -183,26 +190,6 @@ describe.each(['net', 'assets', 'liabilities'] as const)('malformed consumed %s }); }); -it.each([1, 2, 3, 4, 5, 6])('fails closed on missing or malformed balance marker %i', (index) => { - for (const replacement of ['', `__HLEDGER_NET_WORTH_${index}__ `]) { - expect(() => - decodeOverviewFixture(stats(), batch().replace(`__HLEDGER_NET_WORTH_${index}__`, replacement)) - ).toThrow(); - } -}); - -it('frames backwards despite filenames containing every earlier literal delimiter and escaped JSON markers', () => { - const text = batch(); - const markers = ['INCOME_REPORT', ...Array.from({ length: 6 }, (_, i) => `NET_WORTH_${i + 1}`)] - .map((name) => `\n__HLEDGER_${name}__\n`) - .join(''); - const escaped = text.replace('"cbrTotals":', `"ignored":${JSON.stringify(markers)},"cbrTotals":`); - expect( - decodeOverviewFixture(stats(), `Main file : /private/${markers}filename\n${escaped}`).netWorth - ?.points - ).toHaveLength(6); -}); - describe('native market assets minus liabilities snapshots', () => { let directory: string; let journal: string; @@ -232,9 +219,19 @@ describe('native market assets minus liabilities snapshots', () => { const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.netWorth).toEqual({ currency: 'USD', - points: ['1000.00', '1000.00', '700.00', '750.00', '750.00', '-250.00'].map( - (quantity, index) => ({ date: dates[index], amounts: [q(quantity)], unconverted: [] }) - ) + points: [ + ...Array(6).fill('1000.00'), + '1000.00', + '1000.00', + '700.00', + '750.00', + '750.00', + '-250.00' + ].map((quantity, index) => ({ date: dates[index], amounts: [q(quantity)], unconverted: [] })) + }); + expect(data.balanceSheet).toEqual({ + assets: { amounts: [q('-150.00')], unconverted: [] }, + liabilities: { amounts: [q('100.00')], unconverted: [] } }); }); @@ -244,11 +241,11 @@ describe('native market assets minus liabilities snapshots', () => { 'commodity USD 1.00\n' + accounts + txn('2026-04-16', 'Wallet', 'Debt', '100.00 USD') ); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(data.netWorth?.points.slice(0, 5)).toEqual( - dates.slice(0, 5).map((date) => ({ date, amounts: [], unconverted: [] })) + expect(data.netWorth?.points.slice(0, 11)).toEqual( + dates.slice(0, 11).map((date) => ({ date, amounts: [], unconverted: [] })) ); - expect(data.netWorth?.points[5]).toEqual({ - date: dates[5], + expect(data.netWorth?.points[11]).toEqual({ + date: dates[11], amounts: [q('0.00')], unconverted: [] }); @@ -266,11 +263,13 @@ describe('native market assets minus liabilities snapshots', () => { const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.netWorth).toEqual({ currency: 'USD', - points: ['40', '40', '80', '80', '80', '121.00'].map((quantity, index) => ({ - date: dates[index], - amounts: [q(quantity)], - unconverted: [] - })) + points: [...Array(6).fill('40'), '40', '40', '80', '80', '80', '121.00'].map( + (quantity, index) => ({ + date: dates[index], + amounts: [q(quantity)], + unconverted: [] + }) + ) }); }); @@ -283,8 +282,8 @@ describe('native market assets minus liabilities snapshots', () => { ); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.netWorth?.currency).toBe('USD'); - expect(data.netWorth?.points[5]).toEqual({ - date: dates[5], + expect(data.netWorth?.points[11]).toEqual({ + date: dates[11], amounts: [q('2', 'STOCK'), q('-100.00')], unconverted: ['STOCK'] }); @@ -300,8 +299,8 @@ describe('native market assets minus liabilities snapshots', () => { ); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.netWorth?.currency).toBe('USD'); - expect(data.netWorth?.points[5]).toEqual({ - date: dates[5], + expect(data.netWorth?.points[11]).toEqual({ + date: dates[11], amounts: [q('2', 'STOCK')], unconverted: ['STOCK'] }); @@ -317,8 +316,12 @@ describe('native market assets minus liabilities snapshots', () => { ); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.netWorth?.currency).toBe('USD'); - expect(data.netWorth?.points[5]?.unconverted).toEqual(['STOCK']); - expect(data.netWorth?.points[5]?.amounts).toEqual([q('0', 'STOCK')]); + expect(data.netWorth?.points[11]?.unconverted).toEqual(['STOCK']); + expect(data.netWorth?.points[11]?.amounts).toEqual([q('0', 'STOCK')]); + expect(data.balanceSheet).toEqual({ + assets: { amounts: [q('2', 'STOCK')], unconverted: ['STOCK'] }, + liabilities: { amounts: [q('2', 'STOCK')], unconverted: ['STOCK'] } + }); }); it.each(['USD', 'usd', 'US Dollar', 'US,Dollar', ' USD ', 'none', 'BTC', ''])( @@ -333,22 +336,11 @@ describe('native market assets minus liabilities snapshots', () => { ); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.netWorth?.currency).toBe(currency); - expect(data.netWorth?.points[5]).toEqual({ - date: dates[5], + expect(data.netWorth?.points[11]).toEqual({ + date: dates[11], amounts: [q('42.00', currency)], unconverted: [] }); } ); - - it.each([ - '', - 'commodity USD 1.00\n', - 'commodity USD 1.00\ncommodity EUR 1.00\nP 2099-01-01 EUR 2.00 USD\n' - ])('uses no currency/window for nonfinancial input (case %#)', async (text) => { - await writeFile(journal, accounts + text); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(data.asOf).toBeNull(); - expect(data.netWorth).toEqual({ currency: null, points: [] }); - }); }); diff --git a/packages/hledger/src/overview/net-worth.ts b/packages/hledger/src/overview/net-worth.ts index e5aa712..40374e9 100644 --- a/packages/hledger/src/overview/net-worth.ts +++ b/packages/hledger/src/overview/net-worth.ts @@ -1,6 +1,6 @@ -import { reportedAmounts } from '../amounts.js'; +import { reportedAmounts, type Amount } from '../amounts.js'; import type { OverviewReports } from './batch.js'; -import type { OverviewData } from './report.js'; +import type { NativeValue, OverviewData } from './report.js'; const failureMessage = 'Invalid hledger net-worth report.'; @@ -47,13 +47,25 @@ function organizeNetWorthPoint( // Completeness comes from BOTH hledger side totals, even when the net // cancels a commodity. Keep explicit zero entries; never invent money. - const sides = [...reportedAmounts(assetTotals[0]), ...reportedAmounts(liabilityTotals[0])]; + const assetsValue = nativeValue(assetTotals[0], baseCurrency); + const liabilitiesValue = nativeValue(liabilityTotals[0], baseCurrency); return { - date, - amounts: reportedAmounts(net[0]), + point: { + date, + amounts: reportedAmounts(net[0]), + unconverted: [...new Set([...assetsValue.unconverted, ...liabilitiesValue.unconverted])] + }, + balanceSheet: { assets: assetsValue, liabilities: liabilitiesValue } + }; +} + +function nativeValue(bag: Amount[], baseCurrency: string): NativeValue { + const amounts = reportedAmounts(bag); + return { + amounts, unconverted: [ ...new Set( - sides.map((amount) => amount.commodity).filter((commodity) => commodity !== baseCurrency) + amounts.map((amount) => amount.commodity).filter((commodity) => commodity !== baseCurrency) ) ] }; @@ -63,9 +75,12 @@ export function organizeNetWorth({ asOf, baseCurrency, snapshots -}: OverviewReports): OverviewData['netWorth'] { - if (asOf === null) return { currency: null, points: [] }; - if (snapshots.length !== 6) throw new Error(failureMessage); - const points = snapshots.map((snapshot) => organizeNetWorthPoint(snapshot, baseCurrency)); - return { currency: baseCurrency, points }; +}: OverviewReports): Pick { + if (asOf === null) return { netWorth: { currency: null, points: [] }, balanceSheet: null }; + if (snapshots.length !== 12) throw new Error(failureMessage); + const decoded = snapshots.map((snapshot) => organizeNetWorthPoint(snapshot, baseCurrency)); + return { + netWorth: { currency: baseCurrency, points: decoded.map(({ point }) => point) }, + balanceSheet: decoded[11].balanceSheet + }; } diff --git a/packages/hledger/src/overview/ranges.test.ts b/packages/hledger/src/overview/ranges.test.ts new file mode 100644 index 0000000..905f274 --- /dev/null +++ b/packages/hledger/src/overview/ranges.test.ts @@ -0,0 +1,205 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { planIncomeRanges, organizeIncomeRange } from './ranges.js'; +import type { HledgerIncomeStatement } from './income-statement.js'; +import type { Amount } from '../amounts.js'; +import { prepareReports, getHledgerVersion } from '../index.js'; + +const plan = planIncomeRanges('2026-04-16')[3]; +function statement(range = plan): HledgerIncomeStatement { + const totals = () => ({ prrAmounts: range.periods.map(() => []), prrTotal: [] }); + return { + cbrDates: range.periods.map(({ begin, endExclusive }) => [ + { tag: 'Exact', contents: begin }, + { tag: 'Exact', contents: endExclusive } + ]), + cbrTotals: { prrTotal: [] }, + cbrSubreports: [ + ['Revenues', { prTotals: totals(), prRows: [] }, true], + ['Expenses', { prTotals: totals(), prRows: [] }, false] + ] + }; +} +const amount = (commodity: string, mantissa: bigint): Amount => ({ + acommodity: commodity, + aquantity: { decimalMantissa: mantissa, decimalPlaces: 2 }, + acost: null +}); + +it('copies native range totals, NET, signs and cost-distinguished bags, never monthly financial sums', () => { + const parsed = statement(); + parsed.cbrSubreports[0][1].prTotals.prrTotal = [ + amount('USD', 900719925474099306n), + amount('EUR', -1230n) + ]; + parsed.cbrSubreports[1][1].prTotals.prrTotal = [amount('USD', -500n)]; + parsed.cbrTotals.prrTotal = [amount('USD', 42n)]; + const costed = (mantissa: bigint, cost: bigint): Amount => ({ + ...amount('STOCK', mantissa), + acost: { tag: 'UnitCost', contents: amount('USD', cost) } + }); + parsed.cbrSubreports[1][1].prRows = [ + { + prrName: 'z: Full NAME ', + prrTotal: [costed(200n, 5000n), costed(300n, 6000n), amount('EUR', -900719925474099306n)] + }, + { prrName: 'Fees', prrTotal: [] } + ]; + const before = structuredClone(parsed); + const data = organizeIncomeRange(parsed, plan); + expect(data.range).toEqual({ + begin: '2025-05-01', + endExclusive: '2026-04-17', + income: [ + { commodity: 'USD', quantity: '9007199254740993.06' }, + { commodity: 'EUR', quantity: '-12.30' } + ], + expenses: [{ commodity: 'USD', quantity: '-5.00' }], + net: [{ commodity: 'USD', quantity: '0.42' }], + expenseCategories: { + depth: 2, + rows: [ + { + account: 'z: Full NAME ', + amounts: [ + { commodity: 'STOCK', quantity: '2.00' }, + { commodity: 'STOCK', quantity: '3.00' }, + { commodity: 'EUR', quantity: '-9007199254740993.06' } + ] + }, + { account: 'Fees', amounts: [] } + ] + } + }); + expect( + data.months.every(({ income, expenses }) => income.length === 0 && expenses.length === 0) + ).toBe(true); + expect(parsed).toEqual(before); +}); + +it.each<[string, (report: HledgerIncomeStatement) => void]>([ + ['malformed range total', (r) => Reflect.set(r.cbrTotals, 'prrTotal', null)], + [ + 'wrong period', + (r) => { + r.cbrDates[0][0].contents = '2025-05-02'; + } + ], + [ + 'wrong contribution', + (r) => { + r.cbrSubreports[0][2] = false; + } + ], + [ + 'malformed category', + (r) => { + r.cbrSubreports[1][1].prRows = [{ prrName: 'Fees', prrTotal: [] }]; + Reflect.set(r.cbrSubreports[1][1].prRows[0], 'prrTotal', null); + } + ] +])('rejects %s before publishing a range', (_name, mutate) => { + const report = statement(); + mutate(report); + expect(() => organizeIncomeRange(report, plan)).toThrow(); +}); + +it('validates shorter statements even though their monthly columns are not published', () => { + const shorter = planIncomeRanges('2026-04-16')[0]; + const report = statement(shorter); + report.cbrSubreports[0][1].prTotals.prrAmounts.pop(); + expect(() => organizeIncomeRange(report, shorter)).toThrow(); +}); + +const declarations = `commodity USD 1.00 +account Wallet ; type: A +account Reserve ; type: A +account Paycheck ; type: R +account Gains ; type: G +account Costs ; type: X +account Costs:Food +account Costs:Food:Groceries +account Costs:Refund +account expenses:not-an-expense ; type: A +`; +const posting = (date: string, debit: string, credit: string, amount: string) => + `\n${date} Example\n ${debit} ${amount} USD\n ${credit}\n`; +const usd = (quantity: string) => [{ commodity: 'USD', quantity }]; + +describe('native hledger 1.99.5 finite overview ranges', () => { + let directory: string; + let journal: string; + beforeAll(async () => { + await getHledgerVersion(); + }); + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'hledger-ranges-')); + journal = join(directory, 'owned.journal'); + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('retains native 1/3/6/12 income, expenses, NET and disjoint categories across refunds and posting cutoff', async () => { + const text = + declarations + + posting('2025-04-30', 'Wallet', 'Paycheck', '999.00') + + posting('2025-05-01', 'Wallet', 'Paycheck', '100.00') + + posting('2025-05-01', 'Costs:Food:Groceries', 'Wallet', '10.00') + + posting('2025-11-01', 'Wallet', 'Paycheck', '200.00') + + posting('2025-11-01', 'Costs:Food', 'Wallet', '20.00') + + posting('2026-02-01', 'Wallet', 'Paycheck', '300.00') + + posting('2026-02-01', 'Costs', 'Wallet', '30.00') + + posting('2026-04-01', 'Wallet', 'Paycheck', '400.00') + + posting('2026-04-01', 'Costs:Food:Groceries', 'Wallet', '40.00') + + posting('2026-04-10', 'Costs:Refund', 'Wallet', '-5.00') + + posting('2026-04-13', 'Wallet', 'Gains', '500.00') + + posting('2026-04-14', 'expenses:not-an-expense', 'Wallet', '999.00') + + posting('2026-04-16', 'Wallet', 'Reserve', '1.00') + + '\n2026-04-12 Later posting\n Costs:Food 700.00 USD ; date:2026-04-17\n Wallet\n' + + '\n~ monthly from 2025-05-01\n Costs:Food 900.00 USD\n Wallet\n'; + await writeFile(journal, text); + const { overview } = await prepareReports(journal, { writeRows: async () => {} }); + expect(overview.asOf).toBe('2026-04-16'); + for (const [months, begin, income, expenses, net, food] of [ + [1, '2026-04-01', '400.00', '35.00', '365.00', '40.00'], + [3, '2026-02-01', '700.00', '65.00', '635.00', '40.00'], + [6, '2025-11-01', '900.00', '85.00', '815.00', '60.00'], + [12, '2025-05-01', '1000.00', '95.00', '905.00', '70.00'] + ] as const) { + const range = overview.ranges![months]; + expect(range).toMatchObject({ + begin, + endExclusive: '2026-04-17', + income: usd(income), + expenses: usd(expenses), + net: usd(net) + }); + expect(range.expenseCategories).toEqual({ + depth: 2, + rows: [ + ...(months === 1 ? [] : [{ account: 'Costs', amounts: usd('30.00') }]), + { account: 'Costs:Food', amounts: usd(food) }, + { account: 'Costs:Refund', amounts: usd('-5.00') } + ] + }); + } + expect(overview.incomeExpenses.months).toHaveLength(12); + expect(overview.incomeExpenses.months[0]).toEqual({ + begin: '2025-05-01', + endExclusive: '2025-06-01', + income: usd('100.00'), + expenses: usd('10.00') + }); + expect(overview.incomeExpenses.months[11]).toEqual({ + begin: '2026-04-01', + endExclusive: '2026-05-01', + income: usd('400.00'), + expenses: usd('35.00') + }); + expect(overview.incomeExpenses.months[1]).toMatchObject({ income: [], expenses: [] }); + expect(await readFile(journal, 'utf8')).toBe(text); + }); +}); diff --git a/packages/hledger/src/overview/ranges.ts b/packages/hledger/src/overview/ranges.ts new file mode 100644 index 0000000..6b8b975 --- /dev/null +++ b/packages/hledger/src/overview/ranges.ts @@ -0,0 +1,117 @@ +import { reportedAmounts, type ReportedAmount } from '../amounts.js'; +import { isoDate } from '../report-date.js'; +import type { HledgerIncomeStatement } from './income-statement.js'; + +export type OverviewMonths = 1 | 3 | 6 | 12; +export interface OverviewRange { + begin: string; + endExclusive: string; + income: ReportedAmount[]; + expenses: ReportedAmount[]; + net: ReportedAmount[]; + expenseCategories: { + depth: 2; + rows: { account: string; amounts: ReportedAmount[] }[]; + }; +} + +export interface Period { + begin: string; + endExclusive: string; +} + +export interface IncomeRangePlan { + months: OverviewMonths; + begin: string; + endExclusive: string; + periods: Period[]; +} + +const failureMessage = 'Invalid hledger Overview range.'; + +export function planIncomeRanges(asOf: string): IncomeRangePlan[] { + const cutoff = new Date(`${asOf}T00:00:00Z`); + cutoff.setUTCDate(cutoff.getUTCDate() + 1); + const endExclusive = isoDate(cutoff); + const date = new Date(`${asOf}T00:00:00Z`); + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() - 11); + const history: Period[] = Array.from({ length: 12 }, () => { + const begin = isoDate(date); + date.setUTCMonth(date.getUTCMonth() + 1); + return { begin, endExclusive: isoDate(date) }; + }); + return ([1, 3, 6, 12] as const).map((months) => { + const periods = history.slice(-months); + return { months, begin: periods[0].begin, endExclusive, periods }; + }); +} + +function incomeSections(report: HledgerIncomeStatement) { + const [income, expenses] = report.cbrSubreports; + if ( + report.cbrSubreports.length !== 2 || + !Array.isArray(income) || + !Array.isArray(expenses) || + income[0] !== 'Revenues' || + expenses[0] !== 'Expenses' || + income[2] !== true || + expenses[2] !== false + ) + throw new Error(failureMessage); + return { income: income[1], expenses: expenses[1] }; +} + +function organizeMonths(report: HledgerIncomeStatement, plan: IncomeRangePlan) { + const { income, expenses } = incomeSections(report); + const incomeByMonth = income?.prTotals?.prrAmounts; + const expensesByMonth = expenses?.prTotals?.prrAmounts; + if ( + report.cbrDates.length !== plan.months || + !Array.isArray(incomeByMonth) || + incomeByMonth.length !== plan.months || + !Array.isArray(expensesByMonth) || + expensesByMonth.length !== plan.months + ) + throw new Error(failureMessage); + return report.cbrDates.map((period, index) => { + const expected = plan.periods[index]; + if ( + !Array.isArray(period) || + period.length !== 2 || + period[0]?.tag !== 'Exact' || + period[1]?.tag !== 'Exact' || + period[0].contents !== expected.begin || + period[1].contents !== expected.endExclusive + ) + throw new Error(failureMessage); + return { + ...expected, + income: reportedAmounts(incomeByMonth[index]), + expenses: reportedAmounts(expensesByMonth[index]) + }; + }); +} + +export function organizeIncomeRange(report: HledgerIncomeStatement, plan: IncomeRangePlan) { + // Validate every native column, even though only the twelve-month history is published. + const months = organizeMonths(report, plan); + const { income, expenses } = incomeSections(report); + const rows = expenses?.prRows; + if (!Array.isArray(rows)) throw new Error(failureMessage); + const range: OverviewRange = { + begin: plan.begin, + endExclusive: plan.endExclusive, + income: reportedAmounts(income?.prTotals?.prrTotal), + expenses: reportedAmounts(expenses?.prTotals?.prrTotal), + net: reportedAmounts(report.cbrTotals?.prrTotal), + expenseCategories: { + depth: 2, + rows: rows.map((row) => { + if (!row || typeof row.prrName !== 'string') throw new Error(failureMessage); + return { account: row.prrName, amounts: reportedAmounts(row.prrTotal) }; + }) + } + }; + return { range, months }; +} diff --git a/packages/hledger/src/overview/report.test.ts b/packages/hledger/src/overview/report.test.ts index eb4b1e3..fd80ee3 100644 --- a/packages/hledger/src/overview/report.test.ts +++ b/packages/hledger/src/overview/report.test.ts @@ -1,393 +1,92 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as api from '../index.js'; -import { decodeOverviewReports, planOverviewReports } from './batch.js'; -import { journalMetadata } from '../journal-stats.js'; -import { organizeOverviewData } from './report.js'; -import { - prepareReports, - getHledgerVersion, - type OverviewData, - type ReportedAmount -} from '../index.js'; - -import { balanceFrames, decodeOverviewFixture } from '../../test-fixtures.js'; - -const failure = 'Prepared transactions could not be read. Check the journal.'; -const coverage = { transactionCount: 9, firstDate: '2026-04-01', lastDate: '2026-04-16' }; -const emptyCoverage = { transactionCount: 0, firstDate: null, lastDate: null }; -const stats = (date = '2026-04-16') => { - if (date === 'none') { - return 'Base currency : none\nTxns span : to (0 days)\nLast txn : none\nTxns : 0 (0.0 per day)\n'; - } - return `Base currency : USD\nTxns span : 2026-04-01 to 2026-04-17 (16 days)\nLast txn : ${date}\nTxns : 9 (0.6 per day)\n`; -}; - -const marker = '\n__HLEDGER_INCOME_REPORT__\n'; -const periods = [ - ['2025-11-01', '2025-12-01'], - ['2025-12-01', '2026-01-01'], - ['2026-01-01', '2026-02-01'], - ['2026-02-01', '2026-03-01'], - ['2026-03-01', '2026-04-01'], - ['2026-04-01', '2026-05-01'] -]; -const amount = (commodity: string, mantissa: string, places = 2) => - `{"acommodity":${JSON.stringify(commodity)},"aquantity":{"decimalMantissa":${mantissa},"decimalPlaces":${places},"floatingPoint":0},"acost":null,"sourceExtra":"ignored"}`; -function nativeReport(income = '[]', expenses = '[]') { - return { - cbrDates: periods.map((period) => period.map((contents) => ({ tag: 'Exact', contents }))), - cbrSubreports: [ - ['Revenues', { prTotals: { prrAmounts: [[], [], [], [], [], income] }, prRows: [] }, true], - ['Expenses', { prTotals: { prrAmounts: [[], [], [], [], [], expenses] }, prRows: [] }, false] - ], - sourceAdditionalField: { ignored: true } - }; -} -// Insert the original numeric tokens instead of rounding them through JS numbers. -function reportJson(income = '[]', expenses = '[]', rows = '[]') { - return JSON.stringify(nativeReport()) - .replace('"[]"', income) - .replace('"[]"', expenses) - .replace('"prRows":[]},false', `"prRows":${rows}},false`); -} -const batch = (json = reportJson(), metadata = stats()) => - `${metadata}${marker}${json}${balanceFrames()}`; -const quantities = (amounts: ReportedAmount[]) => - amounts.map((amount) => [amount.commodity, amount.quantity]); -const totals = ( - data: Omit, - section: 'income' | 'expenses', - month = 5 -) => data.incomeExpenses.months[month][section]; - -function expectSerializable(data: Omit) { - expect(data).toHaveProperty('journalCoverage'); - expect(data).toHaveProperty('netWorth'); - expect(data.expenseCategories).toEqual({ depth: 2, rows: expect.any(Array) }); - expect(JSON.parse(JSON.stringify(data))).toEqual(data); -} - -function expectWindow(data: Omit, begin: string, end: string) { - expect(data.incomeExpenses.months).toHaveLength(6); - expect(data.incomeExpenses.months[0].begin).toBe(begin); - expect(data.incomeExpenses.months[5].endExclusive).toBe(end); - expectSerializable(data); -} +import { prepareReports, getHledgerVersion } from '../index.js'; +import { balanceFrames, decodeOverviewFixture, incomeFrames } from '../../test-fixtures.js'; +const stats = + 'Base currency : USD\nLast txn : 2026-04-16\nTxns : 9\nTxns span : 2026-04-01 to 2026-04-17\n'; +const emptyStats = 'Base currency : none\nLast txn : none\nTxns : 0\nTxns span : to \n'; +const batch = (metadata = stats) => metadata + incomeFrames() + balanceFrames(); afterEach(() => { - vi.restoreAllMocks(); vi.useRealTimers(); }); -it('exposes the combined producer, not standalone Overview execution or interpretation', () => { - expect(api).toHaveProperty('prepareReports'); - expect(api).not.toHaveProperty('getOverviewData'); - expect(api).not.toHaveProperty('parseIncomeStatement'); - expect(api).not.toHaveProperty('runMonthlyIncomeStatement'); - expect(api).not.toHaveProperty('readOverviewReports'); - expect(api).not.toHaveProperty('parseBalanceSheet'); - expect(api).not.toHaveProperty('journalMetadata'); -}); - -it('organizes only render-ready fields with exact quantities and unchanged signs/commodities', () => { - const data = decodeOverviewFixture( - stats(), - batch( - reportJson( - `[${amount('USD', '900719925474099306')},${amount('EUR', '-1230')}]`, - `[${amount('USD', '-500')},${amount('STOCK', '2', 0)}]` - ) - ) - ); - expect(data).toEqual({ +it('publishes all four ranges, a shared twelve-month history and latest native sides', () => { + const data = decodeOverviewFixture(stats, batch()); + expect(data).toMatchObject({ asOf: '2026-04-16', - journalCoverage: coverage, - incomeExpenses: { - months: periods.map(([begin, endExclusive], index) => ({ - begin, - endExclusive, - income: - index === 5 - ? [ - { commodity: 'USD', quantity: '9007199254740993.06' }, - { commodity: 'EUR', quantity: '-12.30' } - ] - : [], - expenses: - index === 5 - ? [ - { commodity: 'USD', quantity: '-5.00' }, - { commodity: 'STOCK', quantity: '2' } - ] - : [] - })) + journalCoverage: { transactionCount: 9, firstDate: '2026-04-01', lastDate: '2026-04-16' }, + ranges: { + 1: { begin: '2026-04-01', endExclusive: '2026-04-17' }, + 3: { begin: '2026-02-01', endExclusive: '2026-04-17' }, + 6: { begin: '2025-11-01', endExclusive: '2026-04-17' }, + 12: { begin: '2025-05-01', endExclusive: '2026-04-17' } }, - expenseCategories: { depth: 2, rows: [] }, - netWorth: { - currency: 'USD', - points: [ - '2025-11-30', - '2025-12-31', - '2026-01-31', - '2026-02-28', - '2026-03-31', - '2026-04-16' - ].map((date) => ({ date, amounts: [], unconverted: [] })) + balanceSheet: { + assets: { amounts: [], unconverted: [] }, + liabilities: { amounts: [], unconverted: [] } } }); - expectSerializable(data); + expect(data.incomeExpenses.months).toHaveLength(12); + expect(data.incomeExpenses.months[11].endExclusive).toBe('2026-05-01'); + expect(data.netWorth.points).toHaveLength(12); + expect(data.netWorth.points[11].date).toBe('2026-04-16'); + expect(data).not.toHaveProperty('expenseCategories'); + expect(JSON.parse(JSON.stringify(data))).toEqual(data); }); -it('uses native row totals, names, bags and ordering without mutating the report', () => { - const costed = (quantity: string, cost: string) => - amount('STOCK', quantity, 0).replace( - '"acost":null', - `"acost":{"tag":"UnitCost","contents":${amount('USD', cost)}}` - ); - const rows = `[${[ - `{"prrName":"z: Full NAME ","prrTotal":[${costed('2', '5000')},${costed('3', '6000')},${amount('EUR', '-900719925474099306')}],"prrAmounts":[[${amount('USD', '999')}]]}`, - '{"prrName":"Fees","prrTotal":[]}', - `{"prrName":"a:small","prrTotal":[${amount('USD', '1')}]}` - ].join(',')}]`; - const json = reportJson('[]', '[]', rows); - const metadata = journalMetadata(stats()); - const native = decodeOverviewReports(batch(json), metadata, planOverviewReports(metadata)); - const before = structuredClone(native); - const data = organizeOverviewData(native); - expect(data.expenseCategories).toEqual({ - depth: 2, - rows: [ - { - account: 'z: Full NAME ', - amounts: [ - { commodity: 'STOCK', quantity: '2' }, - { commodity: 'STOCK', quantity: '3' }, - { commodity: 'EUR', quantity: '-9007199254740993.06' } - ] - }, - { account: 'Fees', amounts: [] }, - { account: 'a:small', amounts: [{ commodity: 'USD', quantity: '0.01' }] } - ] +it('publishes monthly history only from the twelve-month statement', () => { + const amount = (mantissa: number) => + JSON.stringify({ + acommodity: 'USD', + aquantity: { decimalMantissa: mantissa, decimalPlaces: 2 } + }); + const [shorter, history] = incomeFrames().split('\n__HLEDGER_INCOME_12__\n'); + const statements = + shorter.replace('"prrAmounts":[[]]', `"prrAmounts":[[${amount(111)}]]`) + + '\n__HLEDGER_INCOME_12__\n' + + history.replace('"prrAmounts":[[]', `"prrAmounts":[[${amount(222)}]`); + const data = decodeOverviewFixture(stats, stats + statements + balanceFrames()); + expect(data.incomeExpenses.months[0].income).toEqual([{ commodity: 'USD', quantity: '2.22' }]); + expect(data.incomeExpenses.months[11].income).toEqual([]); + expect(data.ranges![12].income).toEqual([]); +}); + +it('projects undated input without invented ranges, sides, currency, dates or a timestamp', () => { + expect(decodeOverviewFixture(emptyStats, emptyStats)).toEqual({ + asOf: null, + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + ranges: null, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] }, + balanceSheet: null }); - expect(native).toEqual(before); - expect(totals(data, 'expenses')).toEqual([]); - expectSerializable(data); }); -it('does not publish private metadata or unused native report fields', () => { - const metadata = `Main file : /private/SECRET.journal\n${stats()}`; - const json = reportJson().replace( - '"sourceAdditionalField":', - '"sourcePath":"/private/SECRET.journal","sourceAdditionalField":' - ); - const data = decodeOverviewFixture(metadata, batch(json, metadata)); +it('uses second cached metadata and publishes no private paths or unused native fields', () => { + const metadata = `Main file : /private/SECRET.journal\n${stats.replace('Txns : 9', 'Txns : 10').replace('2026-04-01 to', '2025-01-01 to')}`; + const text = batch(metadata).replace('"cbrTotals":', '"sourcePath":"SECRET","cbrTotals":'); + const data = decodeOverviewFixture(stats, text); + expect(data.journalCoverage).toEqual({ + transactionCount: 10, + firstDate: '2025-01-01', + lastDate: '2026-04-16' + }); expect(JSON.stringify(data)).not.toContain('SECRET'); - expect(data).not.toHaveProperty('sourcePath'); - expect(data).not.toHaveProperty('sourceAdditionalField'); - expectSerializable(data); }); -it.each([ - 'null', - '{}', - '[null]', - '[{}]', - '[{"prrName":42,"prrTotal":[]}]', - '[{"prrName":["expenses:food","food",1],"prrTotal":[]}]', - '[{"prrName":"SECRET /private/journal"}]', - '[{"prrName":"Fees","prrTotal":null}]', - '[{"prrName":"Fees","prrTotal":{}}]', - '[{"prrName":"Fees","prrTotal":[null]}]', - '[{"prrName":"Fees","prrTotal":[{}]}]', - '[{"prrName":"Fees","prrTotal":[{"acommodity":42}]}]', - '[{"prrName":"Fees","prrTotal":[{"acommodity":"USD","aquantity":{"decimalMantissa":1,"decimalPlaces":-1}}]}]' -])('rejects malformed native category rows (case %#)', (rows) => { - expect(() => decodeOverviewFixture(stats(), batch(reportJson('[]', '[]', rows)))).toThrow(); -}); - -it('rejects missing expense prRows instead of publishing a silently missing card', () => { - const json = reportJson().replace(',"prRows":[]},false', '},false'); - expect(() => decodeOverviewFixture(stats(), batch(json))).toThrow(); -}); - -it('organizes without reading the clock or assigning a preparation timestamp', () => { +it('does not consult the clock or assign the producer-owned completion timestamp', () => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2099-12-31T12:34:56.789Z')); - const data = decodeOverviewFixture(stats(), batch()); + const data = decodeOverviewFixture(stats, batch()); expect(data.asOf).toBe('2026-04-16'); expect(data).not.toHaveProperty('preparedAt'); - expectSerializable(data); }); -it('projects empty native metadata without inventing a window or timestamp', () => { - const data = decodeOverviewFixture(stats('none'), stats('none')); - expect(data).toEqual({ - asOf: null, - journalCoverage: emptyCoverage, - incomeExpenses: { months: [] }, - expenseCategories: { depth: 2, rows: [] }, - netWorth: { currency: null, points: [] } - }); - expectSerializable(data); -}); - -it.each([ - ['missing section', (report: ReturnType) => report.cbrSubreports.pop()], - [ - 'wrong contribution', - (report: ReturnType) => (report.cbrSubreports[0][2] = false) - ], - [ - 'wrong expense contribution', - (report: ReturnType) => (report.cbrSubreports[1][2] = true) - ], - ['missing month', (report: ReturnType) => report.cbrDates.pop()], - [ - 'non-exact date', - (report: ReturnType) => (report.cbrDates[0][0].tag = 'Unbounded') - ], - [ - 'misaligned month', - (report: ReturnType) => (report.cbrDates[0][0].contents = '2025-11-02') - ], - [ - 'wrong exclusive end', - (report: ReturnType) => (report.cbrDates[5][1].contents = '2026-04-30') - ] -])('fails closed on %s', (_name, mutate) => { - const report = JSON.parse(reportJson()) as ReturnType; - expect(() => decodeOverviewFixture(stats(), batch(JSON.stringify(report)))).not.toThrow(); - mutate(report); - expect(() => decodeOverviewFixture(stats(), batch(JSON.stringify(report)))).toThrow(); -}); - -it.each([ - ['missing income columns', '"prrAmounts":[[],[],[],[],[],[]]', '"prrAmounts":[]'], - ['null totals', '"prTotals":{"prrAmounts":[[],[],[],[],[],[]]}', '"prTotals":null'], - [ - 'missing expense month', - '"Expenses",{"prTotals":{"prrAmounts":[[],[],[],[],[],[]]},"prRows":[]}', - '"Expenses",{"prTotals":{"prrAmounts":[[],[],[],[],[]]},"prRows":[]}' - ], - [ - 'non-array section', - '"Revenues",{"prTotals":{"prrAmounts":[[],[],[],[],[],[]]},"prRows":[]},true', - '"Revenues",null,true' - ] -])('rejects %s instead of misaligning monthly values', (_name, source, replacement) => { - const json = reportJson(); - expect(json).toContain(source); - expect(() => decodeOverviewFixture(stats(), batch(json.replace(source, replacement)))).toThrow(); -}); - -it.each([ - 'null', - '{}', - '[null]', - '[{}]', - `[{"acommodity":42,"aquantity":{"decimalMantissa":1,"decimalPlaces":2}}]`, - `[{"acommodity":"USD","aquantity":{"decimalMantissa":1,"decimalPlaces":-1}}]`, - `[{"acommodity":"USD","aquantity":{"decimalMantissa":1,"decimalPlaces":256}}]`, - `[{"acommodity":"USD","aquantity":{"decimalMantissa":1,"decimalPlaces":1.5}}]`, - `[{"acommodity":"USD","aquantity":{"decimalMantissa":"123","decimalPlaces":2}}]`, - `[{"acommodity":"USD","aquantity":{"decimalMantissa":1e-20,"decimalPlaces":2}}]` -])('rejects malformed native amounts or unsupported mantissa tokens (case %#)', (bag) => { - expect(() => decodeOverviewFixture(stats(), batch(reportJson(bag)))).toThrow(); -}); - -it('keeps failing closed for fractional scientific mantissas in otherwise unused native averages', () => { - const json = reportJson().replace( - '"sourceAdditionalField":', - '"prrAverage":{"decimalMantissa":9.007199254740993e-20,"decimalPlaces":2},"sourceAdditionalField":' - ); - expect(() => decodeOverviewFixture(stats(), batch(json))).toThrow(); -}); - -it('preserves scientific integer mantissas in financial values and safely ignores unused averages', () => { - const json = reportJson(`[${amount('USD', '9.00719925474099301e17')}]`).replace( - '"sourceAdditionalField":', - '"prrAverage":{"decimalMantissa":9.007199254740993e20,"decimalPlaces":2},"sourceAdditionalField":' - ); - const data = decodeOverviewFixture(stats(), batch(json)); - expect(totals(data, 'income')).toEqual([{ commodity: 'USD', quantity: '9007199254740993.01' }]); - expectSerializable(data); -}); - -it('publishes coverage and both financial cards from the second cached journal view', () => { - const secondStats = stats() - .replace('2026-04-01 to', '2025-01-01 to') - .replace('Txns : 9', 'Txns : 10'); - const data = decodeOverviewFixture( - stats(), - batch( - reportJson( - `[${amount('USD', '12500')}]`, - `[${amount('USD', '2500')}]`, - `[{"prrName":"Spending","prrTotal":[${amount('USD', '2500')}]}]` - ), - secondStats - ) - ); - expect(data.journalCoverage).toEqual({ - ...coverage, - transactionCount: 10, - firstDate: '2025-01-01' - }); - expect(data.asOf).toBe('2026-04-16'); - expectWindow(data, '2025-11-01', '2026-05-01'); - expect(quantities(totals(data, 'income'))).toEqual([['USD', '125.00']]); - expect(quantities(totals(data, 'expenses'))).toEqual([['USD', '25.00']]); - expect(data.expenseCategories).toEqual({ - depth: 2, - rows: [{ account: 'Spending', amounts: [{ commodity: 'USD', quantity: '25.00' }] }] - }); -}); - -it('uses only exact native fields and retains a safe integer count as an ordinary number', () => { - const text = `${stats().replace('Txns : 9', 'Txns : 9007199254740991')}Txns last 7 days : 123\nTxns last 30 days : 456\nClock rate : SECRET\n`; - const data = decodeOverviewFixture(text, batch(reportJson(), text)); - expect(data.journalCoverage).toEqual({ ...coverage, transactionCount: Number.MAX_SAFE_INTEGER }); - expectSerializable(data); -}); - -const malformedStats = [ - ...['Txns span', 'Last txn', 'Txns', 'Base currency'].flatMap((field) => [ - [`missing ${field}`, stats().replace(new RegExp(`^${field} :.*\\n`, 'm'), '')], - [ - `duplicate ${field}`, - stats() + - stats() - .split('\n') - .find((line) => line.startsWith(`${field} :`)) + - '\n' - ] - ]), - ...['00', '-1', '+1', '1.5', '1e3', '9007199254740992'].map((count) => [ - `invalid count ${count}`, - stats().replace('Txns : 9', `Txns : ${count}`) - ]), - ['invalid first date', stats().replace('2026-04-01 to', '2026-02-30 to')], - ['invalid end date', stats().replace('to 2026-04-17', 'to 2026-04-31')], - ['unsupported first year', stats().replace('2026-04-01 to', '0000-01-01 to')], - ['unsupported end year', stats().replace('to 2026-04-17', 'to 10000-01-01')], - ['non-ISO span', stats().replace('2026-04-01 to', '2026-4-01 to')], - ['reversed span', stats().replace('2026-04-01 to', '2026-04-18 to')], - ['zero-length span', stats().replace('2026-04-01 to', '2026-04-17 to')], - ['primary before span', stats().replace('Last txn : 2026-04-16', 'Last txn : 2026-03-31')], - ['primary at exclusive end', stats().replace('Last txn : 2026-04-16', 'Last txn : 2026-04-17')], - ['positive count without primary', stats().replace('Last txn : 2026-04-16', 'Last txn : none')], - ['positive count without span', stats().replace('2026-04-01 to 2026-04-17', ' to ')], - ['zero count with primary', stats('none').replace('Last txn : none', 'Last txn : 2026-04-16')], - ['zero count with span', stats('none').replace(' to ', '2026-04-01 to 2026-04-17')] -]; -it.each(malformedStats)('rejects %s in either native metadata view', (_name, text) => { - expect(() => decodeOverviewFixture(text, batch())).toThrow(); - expect(() => decodeOverviewFixture(stats(), batch(reportJson(), text))).toThrow(); -}); +const failure = 'Prepared transactions could not be read. Check the journal.'; +const emptyCoverage = { transactionCount: 0, firstDate: null, lastDate: null }; const declarations = `commodity USD 1.00 commodity EUR 1.00 @@ -417,67 +116,23 @@ describe('native hledger 1.99.5 journal-anchored income statement', () => { await rm(directory, { recursive: true, force: true }); }); - it('uses unchanged lots.journal: April 16, Nov–Apr, 1000 revenue / 100 expenses', async () => { - const fixture = fileURLToPath( - new URL('../../../../apps/worker/fixtures/lots.journal', import.meta.url) - ); - const before = await readFile(fixture); - const { overview: data } = await prepareReports(fixture, { writeRows: async () => {} }); - expect(data.asOf).toBe('2026-04-16'); - expect(data.journalCoverage).toEqual(coverage); - expect(data.netWorth).toEqual({ - currency: 'USD', - points: [ - '2025-11-30', - '2025-12-31', - '2026-01-31', - '2026-02-28', - '2026-03-31', - '2026-04-16' - ].map((date, index) => ({ - date, - amounts: index === 5 ? [{ commodity: 'USD', quantity: '11825.00' }] : [], - unconverted: [] - })) - }); - expect(data.netWorth?.points[5].amounts).not.toContainEqual({ - commodity: 'USD', - quantity: '12010.00' - }); - - expect(data.incomeExpenses.months[5].income).not.toContainEqual({ - commodity: 'USD', - quantity: '1250.00' - }); - expectWindow(data, '2025-11-01', '2026-05-01'); - expect(quantities(totals(data, 'income'))).toEqual([['USD', '1000.00']]); - expect(quantities(totals(data, 'expenses'))).toEqual([['USD', '100.00']]); - expect(data.expenseCategories?.rows).toEqual([ - { account: 'expenses:living', amounts: [{ commodity: 'USD', quantity: '100.00' }] } - ]); - expect(data.incomeExpenses.months.slice(0, 5)).toEqual( - periods - .slice(0, 5) - .map(([begin, endExclusive]) => ({ begin, endExclusive, income: [], expenses: [] })) - ); - expect(await readFile(fixture)).toEqual(before); - }); - - it.each([ - ['2026-04-15', '2025-11-01', '2026-05-01'], - ['2026-01-31', '2025-08-01', '2026-02-01'], - ['2024-02-29', '2023-09-01', '2024-03-01'] - ])('keeps six empty income columns with asset-only data ending %s', async (asOf, begin, end) => { - await writeFile(journal, declarations + posting(asOf, 'Wallet', 'Reserve', '1.00 USD')); + it('keeps twelve empty income columns and four empty ranges with a leap-day asset anchor', async () => { + await writeFile(journal, declarations + posting('2024-02-29', 'Wallet', 'Reserve', '1.00 USD')); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(data.asOf).toBe(asOf); - expect(data.journalCoverage).toEqual({ transactionCount: 1, firstDate: asOf, lastDate: asOf }); - expectWindow(data, begin, end); - expect(data.expenseCategories).toEqual({ depth: 2, rows: [] }); + expect(data.asOf).toBe('2024-02-29'); + expect(data.incomeExpenses.months).toHaveLength(12); for (const month of data.incomeExpenses.months) { expect(month.income).toEqual([]); expect(month.expenses).toEqual([]); } + for (const range of Object.values(data.ranges!)) { + expect(range).toMatchObject({ + income: [], + expenses: [], + net: [], + expenseCategories: { depth: 2, rows: [] } + }); + } }); it('returns nulls for no transactions despite future price and periodic dates', async () => { @@ -492,13 +147,13 @@ describe('native hledger 1.99.5 journal-anchored income statement', () => { journalCoverage: emptyCoverage, preparedAt: expect.any(String), incomeExpenses: { months: [] }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, + balanceSheet: null, netWorth: { currency: null, points: [] } }); - expectSerializable(data); }); - it('uses maximum primary asset date, not order, secondary, posting, price or rules', async () => { + it('uses the maximum primary date as anchor while coverage includes posting and secondary dates', async () => { await writeFile( journal, declarations + @@ -507,40 +162,20 @@ describe('native hledger 1.99.5 journal-anchored income statement', () => { posting('2040-07-01', 'Wallet', 'Paycheck', '1000.00 USD') + posting('2040-07-02', 'Spending', 'Wallet', '100.00 USD') + '\n2040-07-03 Later posting date\n Spending 700.00 USD ; date:2040-07-13\n Wallet\n' + + '\n2040-06-01 Earlier posting date\n Wallet 2.00 USD\n Paycheck -2.00 USD ; date:2039-01-01\n' + '\n~ monthly from 2099-01-01\n Spending 1.00 USD\n Wallet\n' ); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.asOf).toBe('2040-07-12'); expect(data.journalCoverage).toEqual({ - transactionCount: 4, - firstDate: '2040-07-01', + transactionCount: 5, + firstDate: '2039-01-01', lastDate: '2099-12-31' }); - expectWindow(data, '2040-02-01', '2040-08-01'); - expect(quantities(totals(data, 'income'))).toEqual([['USD', '1000.00']]); - expect(quantities(totals(data, 'expenses'))).toEqual([['USD', '100.00']]); - }); - - it('includes earlier posting and later secondary dates in coverage, not the report anchor', async () => { - await writeFile( - journal, - declarations + - '\n2026-04-10=2099-12-31 Later secondary date\n Spending 700.00 USD ; date:2026-05-01\n Wallet\n' + - '\n2026-04-01 Earlier posting date\n Wallet 1000.00 USD\n Paycheck -1000.00 USD ; date:2025-01-01\n' - ); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(data.journalCoverage).toEqual({ - transactionCount: 2, - firstDate: '2025-01-01', - lastDate: '2099-12-31' + expect(data.incomeExpenses.months.find(({ begin }) => begin === '2040-07-01')).toMatchObject({ + income: [{ commodity: 'USD', quantity: '1000.00' }], + expenses: [{ commodity: 'USD', quantity: '100.00' }] }); - expect(data.asOf).toBe('2026-04-10'); - expectWindow(data, '2025-11-01', '2026-05-01'); - expect(data.expenseCategories).toEqual({ depth: 2, rows: [] }); - for (const month of data.incomeExpenses.months) { - expect(month.income).toEqual([]); - expect(month.expenses).toEqual([]); - } }); it('anchors on gain transactions but excludes gains from income values', async () => { @@ -553,145 +188,13 @@ describe('native hledger 1.99.5 journal-anchored income statement', () => { ); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.asOf).toBe('2026-05-03'); - expectWindow(data, '2025-12-01', '2026-06-01'); - expect(quantities(totals(data, 'income', 4))).toEqual([['USD', '1000.00']]); - expect(quantities(totals(data, 'expenses', 4))).toEqual([['USD', '100.00']]); - expect(totals(data, 'income')).toEqual([]); - }); - - it('uses the final six calendar months, including recorded future transactions', async () => { - await writeFile( - journal, - declarations + - posting('2090-10-31', 'Wallet', 'Paycheck', '99.00 USD') + - posting('2090-11-01', 'Wallet', 'Paycheck', '200.00 USD') + - posting('2091-04-15', 'Wallet', 'Paycheck', '1000.00 USD') - ); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(data.asOf).toBe('2091-04-15'); - expect(data.journalCoverage).toEqual({ - transactionCount: 3, - firstDate: '2090-10-31', - lastDate: '2091-04-15' + expect(data.incomeExpenses.months.find(({ begin }) => begin === '2026-04-01')).toMatchObject({ + income: [{ commodity: 'USD', quantity: '1000.00' }], + expenses: [{ commodity: 'USD', quantity: '100.00' }] }); - expectWindow(data, '2090-11-01', '2091-05-01'); - expect(quantities(totals(data, 'income', 0))).toEqual([['USD', '200.00']]); - expect(quantities(totals(data, 'income'))).toEqual([['USD', '1000.00']]); - }); - - it.each([ - ['expenses', 'Spending', 'Wallet'], - ['income', 'Wallet', 'Paycheck'] - ] as const)('preserves native negative %s', async (section, debit, credit) => { - await writeFile(journal, declarations + posting('2026-04-15', debit, credit, '-5.00 USD')); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(quantities(totals(data, section))).toEqual([['USD', '-5.00']]); - expectSerializable(data); - }); - - it('keeps commodities separate despite prices, without cost or market revaluation', async () => { - await writeFile( - journal, - declarations + - 'P 2026-04-01 EUR 2.00 USD\nP 2026-04-01 STOCK 50.00 USD\nP 2099-04-15 STOCK 900.00 USD\n' + - posting('2026-04-01', 'Wallet', 'Paycheck', '10.00 EUR') + - posting('2026-04-01', 'Wallet', 'Paycheck', '20.00 USD') + - '\n2026-04-01 Stock expense\n Spending 2 STOCK @ 50.00 USD\n Wallet -100.00 USD\n' + expect(data.incomeExpenses.months.find(({ begin }) => begin === '2026-05-01')?.income).toEqual( + [] ); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(data.asOf).toBe('2026-04-01'); - expect(data.journalCoverage).toEqual({ - transactionCount: 3, - firstDate: '2026-04-01', - lastDate: '2026-04-01' - }); - expect(quantities(totals(data, 'income'))).toEqual([ - ['EUR', '10.00'], - ['USD', '20.00'] - ]); - expect(quantities(totals(data, 'expenses'))).toEqual([['STOCK', '2']]); - }); - - it('keeps actual native mantissas beyond the safe integer range lossless', async () => { - await writeFile( - journal, - declarations + posting('2026-04-15', 'Wallet', 'Paycheck', '9007199254740993.06 USD') - ); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expectSerializable(data); - expect(quantities(totals(data, 'income'))).toEqual([['USD', '9007199254740993.06']]); - }); - - it('uses absolute depth two disjoint totals including parent/root postings across months', async () => { - await writeFile( - journal, - declarations + - ` -account expenses ; type: X -account expenses:food -account expenses:food:groceries -account expenses:food:restaurants -account expenses:housing:rent -account Fees ; type: X -` + - posting('2025-10-31', 'expenses:food:groceries', 'Wallet', '999.00 USD') + - posting('2025-11-01', 'expenses:food:groceries', 'Wallet', '10.00 USD') + - posting('2026-04-01', 'expenses:food:restaurants', 'Wallet', '20.00 USD') + - posting('2026-04-02', 'expenses:food', 'Wallet', '7.00 USD') + - posting('2026-04-03', 'expenses:housing:rent', 'Wallet', '100.00 USD') + - posting('2026-04-04', 'expenses', 'Wallet', '4.00 USD') + - posting('2026-04-16', 'Fees', 'Wallet', '3.00 USD') + - '\n2026-04-05 Later posting date\n expenses:food 800.00 USD ; date:2026-04-17\n Wallet\n' - ); - const before = await readFile(journal); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expectWindow(data, '2025-11-01', '2026-05-01'); - expect(data.asOf).toBe('2026-04-16'); - expect(data.expenseCategories).toEqual({ - depth: 2, - rows: [ - { account: 'expenses', amounts: [{ commodity: 'USD', quantity: '4.00' }] }, - { account: 'expenses:food', amounts: [{ commodity: 'USD', quantity: '37.00' }] }, - { account: 'expenses:housing', amounts: [{ commodity: 'USD', quantity: '100.00' }] }, - { account: 'Fees', amounts: [{ commodity: 'USD', quantity: '3.00' }] } - ] - }); - expect(quantities(totals(data, 'expenses', 0))).toEqual([['USD', '10.00']]); - expect(quantities(totals(data, 'expenses'))).toEqual([['USD', '134.00']]); - expect(await readFile(journal)).toEqual(before); - }); - - it('lets native expense types classify non-literal roots at mixed depths, preserving refunds', async () => { - await writeFile( - journal, - declarations + - ` -account Costs ; type: X -account Costs:Travel -account Costs:Travel:Rail -account Costs:Refunds -account Fees ; type: X -account Fees:filing -account expenses:not-an-expense ; type: A -` + - posting('2026-04-01', 'Costs', 'Wallet', '4.00 USD') + - posting('2026-04-01', 'Costs:Travel', 'Wallet', '7.00 USD') + - posting('2026-04-02', 'Costs:Travel:Rail', 'Wallet', '20.00 USD') + - posting('2026-04-03', 'Costs:Refunds', 'Wallet', '-5.00 USD') + - posting('2026-04-03', 'Fees', 'Wallet', '3.00 USD') + - posting('2026-04-03', 'Fees:filing', 'Wallet', '2.00 USD') + - posting('2026-04-16', 'expenses:not-an-expense', 'Wallet', '999.00 USD') - ); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(data.expenseCategories?.rows).toEqual([ - { account: 'Costs', amounts: [{ commodity: 'USD', quantity: '4.00' }] }, - { account: 'Costs:Travel', amounts: [{ commodity: 'USD', quantity: '27.00' }] }, - { account: 'Costs:Refunds', amounts: [{ commodity: 'USD', quantity: '-5.00' }] }, - { account: 'Fees', amounts: [{ commodity: 'USD', quantity: '3.00' }] }, - { account: 'Fees:filing', amounts: [{ commodity: 'USD', quantity: '2.00' }] } - ]); - expect(quantities(totals(data, 'expenses'))).toEqual([['USD', '31.00']]); - expectSerializable(data); }); it('retains exact category currencies and cost-annotated bags without FX or price expansion', async () => { @@ -706,43 +209,18 @@ account expenses:not-an-expense ; type: A ); const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); expect(data.asOf).toBe('2026-04-03'); - expect(data.expenseCategories?.rows).toEqual([ - { - account: 'Spending', - amounts: [ - { commodity: 'EUR', quantity: '9007199254740993.06' }, - { commodity: 'STOCK', quantity: '2' }, - { commodity: 'STOCK', quantity: '3' }, - { commodity: 'USD', quantity: '-5.00' } - ] - } - ]); - expectSerializable(data); - }); - - it('includes both six-month boundary dates without expanding prices or periodic entries', async () => { - await writeFile( - journal, - declarations + - 'P 2099-12-31 EUR 2.00 USD\n' + - posting('2025-10-31', 'Spending', 'Wallet', '999.00 USD') + - posting('2025-11-01', 'Spending', 'Wallet', '10.00 USD') + - posting('2026-04-30', 'Spending', 'Wallet', '20.00 USD') + - '\n~ monthly from 2025-11-01\n Spending 900.00 USD\n Wallet\n' - ); - const { overview: data } = await prepareReports(journal, { writeRows: async () => {} }); - expect(data.asOf).toBe('2026-04-30'); - expect(data.journalCoverage).toEqual({ - transactionCount: 3, - firstDate: '2025-10-31', - lastDate: '2026-04-30' - }); - expectWindow(data, '2025-11-01', '2026-05-01'); - expect(data.expenseCategories?.rows).toEqual([ - { account: 'Spending', amounts: [{ commodity: 'USD', quantity: '30.00' }] } - ]); - expect(quantities(totals(data, 'expenses', 0))).toEqual([['USD', '10.00']]); - expect(quantities(totals(data, 'expenses'))).toEqual([['USD', '20.00']]); + const bag = [ + { commodity: 'EUR', quantity: '9007199254740993.06' }, + { commodity: 'STOCK', quantity: '2' }, + { commodity: 'STOCK', quantity: '3' }, + { commodity: 'USD', quantity: '-5.00' } + ]; + expect(data.ranges![1].expenses).toEqual(bag); + expect(data.ranges![1].expenseCategories.rows).toEqual([{ account: 'Spending', amounts: bag }]); + expect( + data.incomeExpenses.months.find(({ begin }) => begin === '2026-04-01')?.expenses + ).toEqual(bag); + expect(JSON.parse(JSON.stringify(data))).toEqual(data); }); it('redacts real strict validation diagnostics at the combined public producer', async () => { @@ -757,18 +235,5 @@ account expenses:not-an-expense ; type: A expect((error as Error).message).toBe(failure); expect((error as Error).cause).toBeUndefined(); expect(Object.keys(error as Error)).toEqual([]); - expect(String(error)).not.toContain('SECRET'); - expect(String(error)).not.toContain(journal); - expect(String(error)).not.toContain('123.45'); - }); - - it('cancels an actual native invocation safely', async () => { - const controller = new AbortController(); - const result = prepareReports(journal, { - signal: controller.signal, - writeRows: async () => {} - }); - controller.abort(); - await expect(result).rejects.toThrow(failure); }); }); diff --git a/packages/hledger/src/overview/report.ts b/packages/hledger/src/overview/report.ts index 380e871..8a25946 100644 --- a/packages/hledger/src/overview/report.ts +++ b/packages/hledger/src/overview/report.ts @@ -1,21 +1,21 @@ -import { reportedAmounts, type ReportedAmount } from '../amounts.js'; +import type { ReportedAmount } from '../amounts.js'; import type { OverviewReports } from './batch.js'; -import type { HledgerIncomeStatement } from './income-statement.js'; import type { JournalCoverage } from '../journal-stats.js'; import { organizeNetWorth } from './net-worth.js'; +import { organizeIncomeRange, type OverviewMonths, type OverviewRange } from './ranges.js'; + +export type { OverviewMonths, OverviewRange } from './ranges.js'; + +export type NativeValue = { + amounts: ReportedAmount[]; + unconverted: string[]; +}; export interface OverviewData { asOf: string | null; preparedAt: string; journalCoverage: JournalCoverage; - expenseCategories: { - depth: 2; - rows: { account: string; amounts: ReportedAmount[] }[]; - }; - netWorth: { - currency: string | null; - points: { date: string; amounts: ReportedAmount[]; unconverted: string[] }[]; - }; + ranges: Record | null; incomeExpenses: { months: { begin: string; @@ -24,88 +24,43 @@ export interface OverviewData { expenses: ReportedAmount[]; }[]; }; -} - -const failureMessage = 'Invalid hledger Overview report.'; - -function organizeMonths( - report: HledgerIncomeStatement, - periods: OverviewReports['incomePeriods'] -): OverviewData['incomeExpenses']['months'] { - const [income, expenses] = report.cbrSubreports; - if ( - report.cbrDates.length !== 6 || - report.cbrSubreports.length !== 2 || - !Array.isArray(income) || - !Array.isArray(expenses) || - income[2] !== true || - expenses[2] !== false - ) { - throw new Error(failureMessage); - } - const incomeByMonth = income[1]?.prTotals?.prrAmounts; - const expensesByMonth = expenses[1]?.prTotals?.prrAmounts; - if ( - !Array.isArray(incomeByMonth) || - !Array.isArray(expensesByMonth) || - incomeByMonth.length !== 6 || - expensesByMonth.length !== 6 - ) { - throw new Error(failureMessage); - } - - return report.cbrDates.map((period, index) => { - const { begin: expectedBegin, endExclusive: expectedEnd } = periods[index]; - if ( - !Array.isArray(period) || - period.length !== 2 || - period[0]?.tag !== 'Exact' || - period[1]?.tag !== 'Exact' || - period[0].contents !== expectedBegin || - period[1].contents !== expectedEnd - ) { - throw new Error(failureMessage); - } - return { - begin: period[0].contents, - endExclusive: period[1].contents, - income: reportedAmounts(incomeByMonth[index]), - expenses: reportedAmounts(expensesByMonth[index]) - }; - }); -} - -function organizeExpenseCategories( - report: HledgerIncomeStatement -): OverviewData['expenseCategories'] { - const [, expenseSubreport] = report.cbrSubreports; - const [, expenseReport] = expenseSubreport; - const rows = expenseReport?.prRows; - if (!Array.isArray(rows)) throw new Error(failureMessage); - return { - depth: 2, - rows: rows.map((row) => { - if (!row || typeof row.prrName !== 'string') throw new Error(failureMessage); - // Hledger's flat depth-limited rows are disjoint; prrTotal already spans the window. - return { account: row.prrName, amounts: reportedAmounts(row.prrTotal) }; - }) + netWorth: { + currency: string | null; + points: ({ date: string } & NativeValue)[]; }; + balanceSheet: { assets: NativeValue; liabilities: NativeValue } | null; } -// Shared internal organizer, not a second financial model. export function organizeOverviewData(reports: OverviewReports): Omit { - const { asOf, report, journalCoverage, incomePeriods } = reports; - const months = asOf === null || report === null ? [] : organizeMonths(report, incomePeriods); - const expenseCategories = - asOf === null || report === null - ? { depth: 2 as const, rows: [] } - : organizeExpenseCategories(report); - const netWorth = organizeNetWorth(reports); + const { asOf, journalCoverage, incomeStatements } = reports; + const { netWorth, balanceSheet } = organizeNetWorth(reports); + if (asOf === null) { + return { + asOf, + journalCoverage, + ranges: null, + incomeExpenses: { months: [] }, + netWorth, + balanceSheet + }; + } + if (incomeStatements.length !== 4) throw new Error('Invalid hledger Overview ranges.'); + const organized = incomeStatements.map((statement, index) => { + if (statement.months !== ([1, 3, 6, 12] as const)[index]) + throw new Error('Invalid hledger Overview ranges.'); + return organizeIncomeRange(statement.report, statement); + }); return { asOf, journalCoverage, - incomeExpenses: { months }, - expenseCategories, - netWorth + ranges: { + 1: organized[0].range, + 3: organized[1].range, + 6: organized[2].range, + 12: organized[3].range + }, + incomeExpenses: { months: organized[3].months }, + netWorth, + balanceSheet }; } diff --git a/packages/hledger/src/prepare-reports.integration.test.ts b/packages/hledger/src/prepare-reports.integration.test.ts index 23433e9..8131b9d 100644 --- a/packages/hledger/src/prepare-reports.integration.test.ts +++ b/packages/hledger/src/prepare-reports.integration.test.ts @@ -187,13 +187,19 @@ describe('prepared account reports against independent native registers', () => expect(reports.accounts.find(({ name }) => name === 'assets:unused')).toEqual({ name: 'assets:unused', access: 'ready', - rowCount: 0 + rowCount: 0, + summary: { + status: 'ready', + balances: [], + valuation: { amounts: [], unconverted: [] } + } }); for (const name of ['assets:Case', 'assets:case']) { expect(reports.accounts.find((account) => account.name === name)).toEqual({ name, access: 'ambiguous', - rowCount: null + rowCount: null, + summary: { status: 'ambiguous' } }); expect(accounts.has(name)).toBe(false); } diff --git a/packages/hledger/src/prepare-reports.test.ts b/packages/hledger/src/prepare-reports.test.ts index 01395da..afc08ce 100644 --- a/packages/hledger/src/prepare-reports.test.ts +++ b/packages/hledger/src/prepare-reports.test.ts @@ -129,8 +129,9 @@ it('uses two processes for the combined financial view and native JSON rows for '--', 'stats' ]); - expect(args.filter((a) => a === 'balancesheet')).toHaveLength(6); - expect(args.filter((a) => a === 'incomestatement')).toHaveLength(1); + expect(args.filter((a) => a === 'balancesheet')).toHaveLength(12); + expect(args.filter((a) => a === 'incomestatement')).toHaveLength(4); + expect(args.filter((a) => a === 'balance')).toHaveLength(2); expect(args.filter((a) => a === 'aregister')).toHaveLength(7); expect(args.filter((a) => a === 'print')).toHaveLength(14); for (const type of ['A', 'C', 'L', 'R', 'X', 'G']) expect(args).toContain(`type:${type}`); @@ -160,8 +161,8 @@ it('uses two processes for the combined financial view and native JSON rows for firstDate: '2026-04-01', lastDate: '2026-04-16' }); - expect(prepared.overview.incomeExpenses.months).toHaveLength(6); - expect(prepared.overview.incomeExpenses.months[5]).toEqual({ + expect(prepared.overview.incomeExpenses.months).toHaveLength(12); + expect(prepared.overview.incomeExpenses.months.at(-1)).toEqual({ begin: '2026-04-01', endExclusive: '2026-05-01', income: [{ commodity: 'USD', quantity: '1000.00' }], @@ -179,7 +180,12 @@ it('uses two processes for the combined financial view and native JSON rows for expect(account.access).toBe('ready'); const rows = collected.rowsByName.get(account.name)!; expect(rows.map(financialRow)).toEqual(await oracle(checking, account.name)); - expect(account).toEqual({ name: account.name, access: 'ready', rowCount: rows.length }); + expect(account).toMatchObject({ + name: account.name, + access: 'ready', + rowCount: rows.length, + summary: { status: 'ready' } + }); } expect(JSON.parse(JSON.stringify(prepared))).toEqual(prepared); }); @@ -664,7 +670,9 @@ describe('native protocol and lifecycle', () => { ); const collected = collector(); const result = await prepareReports('/private', collected); - expect(result.accounts).toEqual([{ name, access: 'ready', rowCount: 0 }]); + expect(result.accounts).toEqual([ + { name, access: 'ready', rowCount: 0, summary: { status: 'undated' } } + ]); expect(collected.rowsByName.get(name)).toEqual([]); }); diff --git a/packages/hledger/src/prepare-reports.ts b/packages/hledger/src/prepare-reports.ts index d3e18f3..b0187ed 100644 --- a/packages/hledger/src/prepare-reports.ts +++ b/packages/hledger/src/prepare-reports.ts @@ -1,5 +1,11 @@ import { decodeOverviewReports, planOverviewReports } from './overview/batch.js'; import { organizeOverviewData, type OverviewData } from './overview/report.js'; +import { + planAccountBalances, + decodeAccountBalances, + organizeAccountBalance, + type PreparedBalance +} from './accounts/balances.js'; import { journalMetadata } from './journal-stats.js'; import { ReportStreamReader } from './report-stream.js'; import { @@ -15,6 +21,7 @@ export interface PreparedAccountSummary { name: string; access: 'ready' | 'ambiguous'; rowCount: number | null; + summary: PreparedBalance; } type AccountRowWriter = (accountName: string, rows: readonly PreparedAccountRow[]) => Promise; @@ -108,18 +115,28 @@ function decodePreparedPrefix( if (actual.length !== names.length || actual.some((name, index) => name !== names[index])) { throw new Error(failureMessage); } + const accountReports = decodeAccountBalances(financial, discovery.asOf); + const overview = organizeOverviewData( + decodeOverviewReports(accountReports.overviewBatch, discovery, plan) + ); // Native --find defines equivalence; JS case folding does not. const counts = new Map(); for (const name of canonical) counts.set(name, (counts.get(name) ?? 0) + 1); const accounts: PreparedAccountSummary[] = names.map((name, index) => { const nativeName = canonical[index]; - if (nativeName !== name || counts.get(nativeName) !== 1) { - return { name, access: 'ambiguous', rowCount: null }; - } - return { name, access: 'ready', rowCount: 0 }; + const access = nativeName !== name || counts.get(nativeName) !== 1 ? 'ambiguous' : 'ready'; + const rowCount = access === 'ambiguous' ? null : 0; + const summary = organizeAccountBalance( + name, + access, + discovery.asOf, + discovery.baseCurrency, + accountReports + ); + return { name, access, rowCount, summary }; }); return { - overview: organizeOverviewData(decodeOverviewReports(financial, discovery, plan)), + overview, accounts, metadata }; @@ -213,6 +230,7 @@ export async function prepareReports( 'run', '--', ...plan.commands, + ...planAccountBalances(discovery), ...echo(accountMarker), '--', 'accounts', diff --git a/packages/hledger/test-fixtures.ts b/packages/hledger/test-fixtures.ts index 7c2c3b9..c468e92 100644 --- a/packages/hledger/test-fixtures.ts +++ b/packages/hledger/test-fixtures.ts @@ -1,6 +1,7 @@ import { journalMetadata } from './src/journal-stats.js'; import { decodeOverviewReports, planOverviewReports } from './src/overview/batch.js'; import { organizeOverviewData } from './src/overview/report.js'; +import { planIncomeRanges } from './src/overview/ranges.js'; // Compose only pure production parsers/planners/organizers; never execute native jobs. export function decodeOverviewFixture(discovery: string, batch: string) { @@ -14,12 +15,12 @@ export function decodeOverviewFixture(discovery: string, batch: string) { export function balanceFrames(asOf = '2026-04-16'): string { const date = new Date(`${asOf}T00:00:00Z`); date.setUTCDate(1); - date.setUTCMonth(date.getUTCMonth() - 5); - return Array.from({ length: 6 }, (_, index) => { + date.setUTCMonth(date.getUTCMonth() - 11); + return Array.from({ length: 12 }, (_, index) => { const begin = date.toISOString().slice(0, 10); date.setUTCMonth(date.getUTCMonth() + 1); - const end = index === 5 ? new Date(`${asOf}T00:00:00Z`) : new Date(date); - if (index === 5) end.setUTCDate(end.getUTCDate() + 1); + const end = index === 11 ? new Date(`${asOf}T00:00:00Z`) : new Date(date); + if (index === 11) end.setUTCDate(end.getUTCDate() + 1); const report = { cbrDates: [ [begin, end.toISOString().slice(0, 10)].map((contents) => ({ tag: 'Exact', contents })) @@ -33,3 +34,23 @@ export function balanceFrames(asOf = '2026-04-16'): string { return `\n__HLEDGER_NET_WORTH_${index + 1}__\n${JSON.stringify(report)}\n`; }).join(''); } + +// Empty finite statements; callers can replace a consumed field without rebuilding framing. +export function incomeFrames(asOf = '2026-04-16'): string { + return planIncomeRanges(asOf) + .map((range) => { + const totals = () => ({ prrAmounts: range.periods.map(() => []), prrTotal: [] }); + const report = { + cbrDates: range.periods.map(({ begin, endExclusive }) => + [begin, endExclusive].map((contents) => ({ tag: 'Exact', contents })) + ), + cbrTotals: { prrTotal: [] }, + cbrSubreports: [ + ['Revenues', { prTotals: totals(), prRows: [] }, true], + ['Expenses', { prTotals: totals(), prRows: [] }, false] + ] + }; + return `\n__HLEDGER_INCOME_${range.months}__\n${JSON.stringify(report)}\n`; + }) + .join(''); +} diff --git a/packages/snapshots/README.md b/packages/snapshots/README.md index b444d61..3354bfa 100644 --- a/packages/snapshots/README.md +++ b/packages/snapshots/README.md @@ -35,7 +35,7 @@ Each execution owns a unique hidden stage. One directory rename commits the **entire** immutable completed set under `.sets/`. Each set contains: ```text -overview.json # { format: 5, journal, overview, accounts } +overview.json # { format: 6, journal, overview, accounts } transactions/<8-character-id>.json # { id, name, transactions } ``` @@ -54,11 +54,21 @@ that directory pinned for subsequent account reads in the request; the account reader does not reselect a newer set. `readOverview` returns only the financial overview. -`StoredAccount` contains `{ id, name, access: 'ready' | 'ambiguous', rowCount }`. +`StoredAccount` contains `{ id, name, access: 'ready' | 'ambiguous', rowCount, summary }`. +The required `summary` is the native `PreparedBalance`: ambiguous access has +`{ status: 'ambiguous' }`; other undated accounts have `{ status: 'undated' }`; +dated ready accounts have `{ status: 'ready', balances, valuation }`. `balances` +is the raw inclusive native bag; `valuation` contains native end-valued `amounts` +and explicit `unconverted` evidence, including descendant residuals. A dated ready unused +account retains native empty bags, not invented zeros. Storage neither sums nor +values accounts. Summaries live in this catalog, not separate value files; +`transactions/.json` and its full history are unchanged. + Account IDs are random eight-character lowercase alphanumeric identifiers, reused -by **exact full native name** from the previous catalog. Pass only -`previousPublication.accounts`, and only for the **same journal**. Collisions are -checked against all previous and newly assigned IDs. Names remain unchanged data, +by **exact full native name** from the previous supported-format catalog. Pass only +`previousPublication.accounts`, and only for the **same journal**. IDs may reset +across the format-5-to-6 transition; there is no legacy catalog reader to preserve them. +Collisions are checked against all previous and newly assigned IDs. Names remain unchanged data, never filesystem paths. There is no separate registry or account-page index. Every account file contains **all** its rows, with no application-imposed file-size cap. @@ -82,8 +92,9 @@ no capacity guarantee is implied. ## Recorded validation -Validation evidence is operational, independent of financial format **5**. The worker -writes after standalone strict checking completes, before financial staging. The +Validation evidence is operational, independent of financial format **6**; its +own format and workflow are unchanged. The worker writes after standalone strict +checking completes, before financial staging. The store does not run checks or infer outcomes. `JournalValidationResult` contains `{ outcome: 'passed' | 'could-not-validate', completedAt: string }`; `ValidationRecord` contains `{ format: 1, journal, request: { id, createdAt }, result }`. @@ -119,21 +130,27 @@ The separate read-only native inspection command never writes these records. ## Current format and preservation -Only format-5 envelopes `{ format: 5, journal, overview, accounts }` are read. -Financial format remains **5**; validation evidence does not change it or require -re-preparing supported sets. Format 4 and older shapes, including format 3's table-only -rows, are unsupported; explicitly **Prepare reports again** in Settings to create -a supported set. There is no legacy reader or record adapter. +Only format-6 envelopes `{ format: 6, journal, overview, accounts }` are read. +Format 5 and older shapes are unsupported. To activate, stop old preparation +execution and deploy compatible web and worker revisions together before explicitly +**Prepare reports again** in Settings. Independent image publication does not +coordinate that upgrade. Old files remain untouched and unusable by the new reader; +no automatic work, adapter, fallback, migration or deletion is provided. Account IDs +may reset at this transition; exact-name reuse continues within the supported format. No completed set, or an unsupported format in the latest completed set, returns `null`: there is no fallback to an older supported set. Callers can show “No prepared reports available.” with a Settings link for manual preparation. Store reads never prepare automatically. Malformed JSON or invalid current publications fail with a generic read error. -The store checks required overview header primitives and card containers -(`incomeExpenses`, `journalCoverage`, `expenseCategories`, `netWorth`), not deep -financial tuples or quantities. It never upgrades partial cards, supplies zero -values, or recomputes producer-owned data. +The store checks required overview header primitives and containers: all four +`ranges` (1/3/6/12, including native totals and categories), `journalCoverage`, +twelve `incomeExpenses.months`, twelve `netWorth.points`, latest `balanceSheet` +sides, and required catalog summary states consistent with access/date. Undated +results require `ranges: null`, `balanceSheet: null`, empty histories and +`netWorth.currency: null`; ambiguous summaries remain ambiguous. It does not parse +deep financial tuples or quantities, upgrade partial data, supply zeros or +recompute producer-owned values. Old `dashboard/*.json` files are neither scanned nor read. Format-2 source metadata is not adapted. Old `source.json`, `dashboard/inputs.json`, and diff --git a/packages/snapshots/src/reports.test.ts b/packages/snapshots/src/reports.test.ts index 1a90761..c97215d 100644 --- a/packages/snapshots/src/reports.test.ts +++ b/packages/snapshots/src/reports.test.ts @@ -28,35 +28,48 @@ let root: string; let snapshots: string; const older = { id: 'older', createdAt: new Date('2026-04-30T23:59:59.999Z') }; const newer = { id: 'newer', createdAt: new Date('2026-05-01T00:00:00.000Z') }; +const expenseAmounts = [ + { commodity: 'EUR', quantity: '0.0000000000000000000000012300' }, + { commodity: '', quantity: '-0.000' } +]; +const range = (months: 1 | 3 | 6 | 12) => ({ + begin: { 1: '2026-04-01', 3: '2026-02-01', 6: '2025-11-01', 12: '2025-05-01' }[months], + endExclusive: '2026-04-17', + income: [{ commodity: '$', quantity: '9007199254740993.123456789' }], + expenses: expenseAmounts, + net: [{ commodity: '$', quantity: '9007199254740992.123456789' }], + expenseCategories: { + depth: 2 as const, + rows: [{ account: 'expenses:food', amounts: expenseAmounts }] + } +}); const fixture = (): OverviewData => ({ asOf: '2026-04-16', preparedAt: '2026-05-02T12:34:56.789Z', journalCoverage: { transactionCount: 1, firstDate: '2026-04-16', lastDate: '2026-04-16' }, - expenseCategories: { - depth: 2, - rows: [ - { - account: 'expenses:food', - amounts: [ - { commodity: 'EUR', quantity: '0.0000000000000000000000012300' }, - { commodity: '', quantity: '-0.000' } - ] - } - ] + ranges: { 1: range(1), 3: range(3), 6: range(6), 12: range(12) }, + netWorth: { + currency: '$', + points: Array.from({ length: 12 }, (_, i) => ({ + date: i === 11 ? '2026-04-16' : new Date(Date.UTC(2025, i + 5, 0)).toISOString().slice(0, 10), + amounts: [{ commodity: '$', quantity: '9007199254740993.123456789' }], + unconverted: [] + })) }, - netWorth: { currency: null, points: [] }, incomeExpenses: { - months: [ - { - begin: '2026-04-01', - endExclusive: '2026-05-01', - income: [{ commodity: '$', quantity: '9007199254740993.123456789' }], - expenses: [ - { commodity: 'EUR', quantity: '0.0000000000000000000000012300' }, - { commodity: '', quantity: '-0.000' } - ] - } - ] + months: Array.from({ length: 12 }, (_, i) => ({ + begin: new Date(Date.UTC(2025, i + 4, 1)).toISOString().slice(0, 10), + endExclusive: new Date(Date.UTC(2025, i + 5, 1)).toISOString().slice(0, 10), + income: [{ commodity: '$', quantity: '9007199254740993.123456789' }], + expenses: expenseAmounts + })) + }, + balanceSheet: { + assets: { + amounts: [{ commodity: '$', quantity: '9007199254740994.123456789' }], + unconverted: [] + }, + liabilities: { amounts: [{ commodity: '$', quantity: '-1' }], unconverted: [] } } }); const journal = '/books/main.journal'; @@ -145,7 +158,25 @@ const rows = (count = 1, description = 'exact producer strings'): PreparedAccoun const ready = (name = 'assets:cash', rowCount = 1): PreparedAccountSummary => ({ name, access: 'ready', - rowCount + rowCount, + summary: { + status: 'ready', + balances: rowCount === 0 ? [] : [{ commodity: 'TEST', quantity: '2.000' }], + valuation: { + amounts: rowCount === 0 ? [] : [{ commodity: 'TEST', quantity: '2.000' }], + unconverted: rowCount === 0 ? [] : ['TEST'] + } + } +}); +const ambiguous = (name = 'ambiguous'): PreparedAccountSummary => ({ + name, + access: 'ambiguous', + rowCount: null, + summary: { status: 'ambiguous' } +}); +const undated = (name: string): PreparedAccountSummary => ({ + ...ready(name, 0), + summary: { status: 'undated' } }); const filename = (run = older) => `${String(run.createdAt.getTime()).padStart(16, '0')}-${run.id}.json`; @@ -199,7 +230,7 @@ it('publishes one readable overview and one whole-account JSON, with private mod expect( JSON.parse(await fs.readFile(join(publication.directory, 'overview.json'), 'utf8')) ).toEqual({ - format: 5, + format: 6, overview: fixture(), journal, accounts: [account] @@ -321,16 +352,18 @@ it('represents empty-ready and ambiguous accounts distinctly, including a missin const stage = await createReportStage(snapshots, older); await stage.writeRows('empty callback', []); const accounts: PreparedAccountSummary[] = [ - ready('empty', 0), - ready('empty callback', 0), - { name: 'ambiguous', access: 'ambiguous', rowCount: null } + undated('empty'), + undated('empty callback'), + ambiguous() ]; const overview: OverviewData = { ...fixture(), asOf: null, incomeExpenses: { months: [] }, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - expenseCategories: { depth: 2, rows: [] } + ranges: null, + balanceSheet: null, + netWorth: { currency: null, points: [] } }; await stage.publish(overview, accounts, journal); expect(await readOverview(snapshots)).toEqual(overview); @@ -515,7 +548,7 @@ it.each( [ready('missing', 1)], [ready('assets:cash', 2)], [], - [{ name: 'assets:cash', access: 'ambiguous', rowCount: null }], + [ambiguous('assets:cash')], [ready(), ready()], [ready('assets:cash', -1)] ] as PreparedAccountSummary[][] @@ -886,7 +919,7 @@ it.each( } ); -it('rejects a format-5 row missing accountDate without falling back to date or rewriting it', async () => { +it('rejects a current row missing accountDate without falling back to date or rewriting it', async () => { await publish(); const publication = await selected(); const account = publication.accounts[0]; @@ -949,37 +982,29 @@ it('ignores old dashboard files without scanning or reading them, preserving the expect(await fs.readFile(path, 'utf8')).toBe(raw); }); -it.each([2, 3, 4, 99])( - 'ignores unsupported latest format %i without falling back or changing bytes', - async (format) => { - await publish(older); - const previous = await selected(); - await publish(newer); - const latest = await selected(); - const path = join(latest.directory, 'overview.json'); - const accountPath = join(latest.directory, 'transactions', `${latest.accounts[0].id}.json`); - const accountBytes = await fs.readFile(accountPath, 'utf8'); - // Unsupported envelopes need no valid current financial fields or source identity. - const raw = JSON.stringify({ - format, - source: { journal: null }, - overview: 'old payload', - accounts: null - }); - await fs.writeFile(path, raw); - expect(await readReportPublication(snapshots)).toBeNull(); - expect(await readOverview(snapshots)).toBeNull(); - expect(await fs.readFile(path, 'utf8')).toBe(raw); - expect((await readAccountTransactions(previous, previous.accounts[0].id)).transactions).toEqual( - rows() - ); - await publish({ id: 'explicit', createdAt: new Date('2026-05-03T00:00:00Z') }); - expect(await readOverview(snapshots)).toEqual(fixture()); - expect(await fs.readFile(path, 'utf8')).toBe(raw); - expect(await fs.readFile(accountPath, 'utf8')).toBe(accountBytes); - expect(await fs.readdir(join(snapshots, '.sets'))).toHaveLength(3); - } -); +it('leaves opaque format 5 data untouched without fallback until explicit preparation', async () => { + await publish(older); + const previous = await selected(); + await publish(newer); + const latest = await selected(); + const path = join(latest.directory, 'overview.json'); + const accountPath = join(latest.directory, 'transactions', `${latest.accounts[0].id}.json`); + const accountBytes = await fs.readFile(accountPath, 'utf8'); + const raw = JSON.stringify({ format: 5, overview: 'opaque old payload', accounts: null }); + await fs.writeFile(path, raw); + expect(await readReportPublication(snapshots)).toBeNull(); + expect(await readOverview(snapshots)).toBeNull(); + expect(await fs.readFile(path, 'utf8')).toBe(raw); + expect(await fs.readFile(accountPath, 'utf8')).toBe(accountBytes); + expect((await readAccountTransactions(previous, previous.accounts[0].id)).transactions).toEqual( + rows() + ); + await publish({ id: 'explicit', createdAt: new Date('2026-05-03T00:00:00Z') }); + expect(await readOverview(snapshots)).toEqual(fixture()); + expect(await fs.readFile(path, 'utf8')).toBe(raw); + expect(await fs.readFile(accountPath, 'utf8')).toBe(accountBytes); + expect(await fs.readdir(join(snapshots, '.sets'))).toHaveLength(3); +}); it('ignores unsafe/foreign/temporary names and leaves old namespaces untouched', async () => { const foreign = [ @@ -1016,13 +1041,13 @@ it('rejects current overview file symlinks and non-files', async () => { }); it.each([ - { format: 5, overview: fixture(), accounts: [] }, - { format: 5, journal: null, overview: fixture(), accounts: [] }, - { format: 5, journal: ' ', overview: fixture(), accounts: [] }, - { format: 5, journal, overview: fixture(), accounts: null }, - { format: 5, journal, overview: fixture(), accounts: [{ ...ready(), id: '../bad' }] }, + { format: 6, overview: fixture(), accounts: [] }, + { format: 6, journal: null, overview: fixture(), accounts: [] }, + { format: 6, journal: ' ', overview: fixture(), accounts: [] }, + { format: 6, journal, overview: fixture(), accounts: null }, + { format: 6, journal, overview: fixture(), accounts: [{ ...ready(), id: '../bad' }] }, { - format: 5, + format: 6, journal, overview: fixture(), accounts: [ @@ -1037,33 +1062,82 @@ it.each([ await expect(readOverview(snapshots)).rejects.toSatisfy(safeError('Unable to read overview')); }); -it.each([ - { preparedAt: 42 }, - { asOf: undefined }, - { incomeExpenses: { months: null } }, - { journalCoverage: undefined }, - { journalCoverage: { transactionCount: '1', firstDate: null, lastDate: null } }, - { journalCoverage: { transactionCount: 1, firstDate: [], lastDate: null } }, - { expenseCategories: undefined }, - { expenseCategories: { depth: 1, rows: [] } }, - { expenseCategories: { depth: 2, rows: null } }, - { netWorth: undefined }, - { netWorth: { currency: 42, points: [] } }, - { netWorth: { currency: null, points: null } } -])('rejects incomplete current card containers without fabricating data (%j)', async (patch) => { +it('rejects a current publication missing its required account summary', async () => { await publish(); const publication = await selected(); - const overview = { ...fixture(), ...patch }; const path = join(publication.directory, 'overview.json'); - const raw = JSON.stringify({ format: 5, journal, overview, accounts: publication.accounts }); + const raw = JSON.stringify({ + format: 6, + journal, + overview: fixture(), + accounts: [{ ...publication.accounts[0], summary: undefined }] + }); + await fs.writeFile(path, raw); + await expect(readReportPublication(snapshots)).rejects.toSatisfy( + safeError('Unable to read overview') + ); + expect(await fs.readFile(path, 'utf8')).toBe(raw); +}); + +it('rejects an undated ready summary against a dated overview, preserving the old set', async () => { + await publish(); + const pinned = await selected(); + const stage = await createReportStage(snapshots, newer); + // Same complete row setup as the happy publication; only the summary differs. + await stage.writeRows('assets:cash', rows()); + const account: PreparedAccountSummary = { ...ready(), summary: { status: 'undated' } }; + await expect(stage.publish(fixture(), [account], journal)).rejects.toSatisfy( + safeError('Unable to prepare reports') + ); + expect(await selected()).toEqual(pinned); + expect((await readAccountTransactions(pinned, pinned.accounts[0].id)).transactions).toEqual( + rows() + ); + expect(await fs.readdir(join(snapshots, '.sets'))).toEqual([basename(pinned.directory)]); +}); + +it('rejects ambiguous access with a ready summary, preserving the old set', async () => { + await publish(); + const pinned = await selected(); + const stage = await createReportStage(snapshots, newer); + const account: PreparedAccountSummary = { ...ambiguous(), summary: ready().summary }; + await expect(stage.publish(fixture(), [account], journal)).rejects.toSatisfy( + safeError('Unable to prepare reports') + ); + expect(await selected()).toEqual(pinned); + expect(await fs.readdir(join(snapshots, '.sets'))).toEqual([basename(pinned.directory)]); +}); + +it('rejects a selected current overview missing range 12 without fallback or changing bytes', async () => { + await publish(older); + const previous = await selected(); + await publish(newer); + const publication = await selected(); + const overview = { ...fixture(), ranges: { 1: range(1), 3: range(3), 6: range(6) } }; + const path = join(publication.directory, 'overview.json'); + const raw = JSON.stringify({ format: 6, journal, overview, accounts: publication.accounts }); await fs.writeFile(path, raw); await expect(readOverview(snapshots)).rejects.toSatisfy(safeError('Unable to read overview')); expect(await fs.readFile(path, 'utf8')).toBe(raw); + expect((await readAccountTransactions(previous, previous.accounts[0].id)).transactions).toEqual( + rows() + ); +}); + +it('rejects publication without range 12, preserving the previous readable pinned set', async () => { + await publish(); + const pinned = await selected(); const stage = await createReportStage(snapshots, newer); - await expect(stage.publish(overview as OverviewData, [], journal)).rejects.toSatisfy( + await stage.writeRows('assets:cash', rows()); + const overview = { ...fixture(), ranges: { 1: range(1), 3: range(3), 6: range(6) } }; + await expect(stage.publish(overview as OverviewData, [ready()], journal)).rejects.toSatisfy( safeError('Unable to prepare reports') ); - expect(await fs.readdir(join(snapshots, '.sets'))).toEqual([basename(publication.directory)]); + expect(await selected()).toEqual(pinned); + expect((await readAccountTransactions(pinned, pinned.accounts[0].id)).transactions).toEqual( + rows() + ); + expect(await fs.readdir(join(snapshots, '.sets'))).toEqual([basename(pinned.directory)]); }); it.each(['not JSON: private text', 'null', '[]'])( diff --git a/packages/snapshots/src/reports.ts b/packages/snapshots/src/reports.ts index 4be1554..e03ba30 100644 --- a/packages/snapshots/src/reports.ts +++ b/packages/snapshots/src/reports.ts @@ -1,7 +1,8 @@ import type { OverviewData, PreparedAccountSummary, - PreparedAccountRow + PreparedAccountRow, + PreparedBalance } from '@hledger-reports/hledger'; import { randomInt, randomUUID } from 'node:crypto'; import { constants } from 'node:fs'; @@ -14,13 +15,14 @@ const runIdPattern = /^[A-Za-z0-9_-]{1,128}(?![\s\S])/; const setPattern = /^(\d{16}-[A-Za-z0-9_-]{1,128})-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?![\s\S])/; const idPattern = /^[a-z0-9]{8}(?![\s\S])/; -const currentFormat = 5; +const currentFormat = 6; export interface StoredAccount { id: string; name: string; access: 'ready' | 'ambiguous'; rowCount: number | null; + summary: PreparedBalance; } export interface ReportPublication { @@ -51,25 +53,59 @@ function isNullableString(value: unknown): boolean { } // Check the current header and card containers, not producer-owned financial rows. -function hasCurrentOverviewContainers(value: unknown): boolean { +function hasNativeValueContainers(value: unknown): boolean { + return isObject(value) && Array.isArray(value.amounts) && Array.isArray(value.unconverted); +} + +function hasRangeContainers(value: unknown): boolean { + if (!isObject(value)) return false; return ( - isObject(value) && - typeof value.preparedAt === 'string' && - isNullableString(value.asOf) && - isObject(value.incomeExpenses) && - Array.isArray(value.incomeExpenses.months) && - isObject(value.journalCoverage) && - typeof value.journalCoverage.transactionCount === 'number' && - Number.isSafeInteger(value.journalCoverage.transactionCount) && - value.journalCoverage.transactionCount >= 0 && - isNullableString(value.journalCoverage.firstDate) && - isNullableString(value.journalCoverage.lastDate) && + typeof value.begin === 'string' && + typeof value.endExclusive === 'string' && + Array.isArray(value.income) && + Array.isArray(value.expenses) && + Array.isArray(value.net) && isObject(value.expenseCategories) && value.expenseCategories.depth === 2 && - Array.isArray(value.expenseCategories.rows) && - isObject(value.netWorth) && - isNullableString(value.netWorth.currency) && - Array.isArray(value.netWorth.points) + Array.isArray(value.expenseCategories.rows) + ); +} + +function hasCurrentOverviewContainers(value: unknown): value is OverviewData { + if ( + !isObject(value) || + typeof value.preparedAt !== 'string' || + !isNullableString(value.asOf) || + !isObject(value.journalCoverage) || + typeof value.journalCoverage.transactionCount !== 'number' || + !Number.isSafeInteger(value.journalCoverage.transactionCount) || + value.journalCoverage.transactionCount < 0 || + !isNullableString(value.journalCoverage.firstDate) || + !isNullableString(value.journalCoverage.lastDate) || + !isObject(value.incomeExpenses) || + !Array.isArray(value.incomeExpenses.months) || + !isObject(value.netWorth) || + !isNullableString(value.netWorth.currency) || + !Array.isArray(value.netWorth.points) + ) + return false; + if (value.asOf === null) { + return ( + value.ranges === null && + value.balanceSheet === null && + value.incomeExpenses.months.length === 0 && + value.netWorth.currency === null && + value.netWorth.points.length === 0 + ); + } + const ranges = value.ranges; + if (!isObject(ranges) || !isObject(value.balanceSheet)) return false; + return ( + [1, 3, 6, 12].every((months) => hasRangeContainers(ranges[months])) && + value.incomeExpenses.months.length === 12 && + value.netWorth.points.length === 12 && + hasNativeValueContainers(value.balanceSheet.assets) && + hasNativeValueContainers(value.balanceSheet.liabilities) ); } @@ -108,17 +144,41 @@ function hasCurrentTransactionContainers(value: unknown): boolean { } function isSummary(value: unknown): value is PreparedAccountSummary { + if (!isObject(value) || typeof value.name !== 'string' || !isObject(value.summary)) return false; + if (value.access === 'ambiguous') + return value.rowCount === null && value.summary.status === 'ambiguous'; + if ( + value.access !== 'ready' || + typeof value.rowCount !== 'number' || + !Number.isSafeInteger(value.rowCount) || + value.rowCount < 0 + ) + return false; + if (value.summary.status === 'undated') return true; return ( - isObject(value) && - typeof value.name === 'string' && - ((value.access === 'ambiguous' && value.rowCount === null) || - (value.access === 'ready' && - typeof value.rowCount === 'number' && - Number.isSafeInteger(value.rowCount) && - value.rowCount >= 0)) + value.summary.status === 'ready' && + Array.isArray(value.summary.balances) && + hasNativeValueContainers(value.summary.valuation) ); } +function summaryMatchesDate(account: PreparedAccountSummary, asOf: string | null): boolean { + if (account.access === 'ambiguous') return account.summary.status === 'ambiguous'; + return account.summary.status === (asOf === null ? 'undated' : 'ready'); +} + +function storedSummary(summary: PreparedBalance): PreparedBalance { + if (summary.status !== 'ready') return { status: summary.status }; + return { + status: 'ready', + balances: summary.balances, + valuation: { + amounts: summary.valuation.amounts, + unconverted: summary.valuation.unconverted + } + }; +} + function isCatalog(value: unknown): value is StoredAccount[] { if (!Array.isArray(value)) return false; const names = new Set(); @@ -166,9 +226,12 @@ export async function readReportPublication( !record.journal.trim() ) throw new Error(); + const overview = record.overview; + if (!record.accounts.every((account) => summaryMatchesDate(account, overview.asOf))) + throw new Error(); return { // Financial rows remain producer-owned after checking current header/containers. - overview: record.overview as OverviewData, + overview: record.overview, journal: record.journal, accounts: record.accounts, directory @@ -345,7 +408,12 @@ export async function createReportStage( const names = new Set(); const catalog: StoredAccount[] = []; for (const account of accounts) { - if (!isSummary(account) || names.has(account.name)) throw new Error(); + if ( + !isSummary(account) || + !summaryMatchesDate(account, overview.asOf) || + names.has(account.name) + ) + throw new Error(); names.add(account.name); const metadata = written.get(account.name); remaining.delete(account.name); @@ -367,7 +435,8 @@ export async function createReportStage( id, name: account.name, access: account.access, - rowCount: account.rowCount + rowCount: account.rowCount, + summary: storedSummary(account.summary) }); } if (remaining.size) throw new Error(); -- 2.54.0