Prepare period totals and account snapshot values #39

Merged
eleith merged 1 commits from feat/prepared-homepage-data into main 2026-11-05 03:24:15 +00:00
58 changed files with 2062 additions and 1854 deletions

No files matched your search

@@ -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.
+5 -5
View File
@@ -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/<account-id>.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.
+21 -8
View File
@@ -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/<account-id>.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.
+4
View File
@@ -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();
@@ -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 [
@@ -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[] = []
@@ -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`,
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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: [] },
+2 -2
View File
@@ -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;
@@ -1,10 +1,10 @@
<script lang="ts">
import type { OverviewData } from '@hledger-reports/hledger';
import type { OverviewPageData } from '#lib/overview/data.js';
import { accountLabel, type AccountLabel } from '#lib/accounts.js';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import { expenseCategoriesChart } from '#lib/charts/expense-categories.js';
let { overview, labels = [] }: { overview: OverviewData; labels?: readonly AccountLabel[] } =
let { overview, labels = [] }: { overview: OverviewPageData; labels?: readonly AccountLabel[] } =
$props();
const categories = $derived(overview.expenseCategories);
const commodities = $derived([
@@ -1,8 +1,8 @@
<script lang="ts">
import type { OverviewData } from '@hledger-reports/hledger';
import type { OverviewPageData } from '#lib/overview/data.js';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import { incomeExpensesChart } from '#lib/charts/income-expenses.js';
let { overview }: { overview: OverviewData } = $props();
let { overview }: { overview: OverviewPageData } = $props();
const series = ['income', 'expenses'] as const;
const labels = { income: 'Income', expenses: 'Expenses' };
const commodities = $derived([
@@ -1,7 +1,7 @@
<script lang="ts">
import type { OverviewData } from '@hledger-reports/hledger';
import type { OverviewPageData } from '#lib/overview/data.js';
let { overview }: { overview: OverviewData } = $props();
let { overview }: { overview: OverviewPageData } = $props();
const coverage = $derived(overview.journalCoverage);
</script>
+2 -2
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import type { OverviewData } from '@hledger-reports/hledger';
import type { OverviewPageData } from '#lib/overview/data.js';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import { netWorthChart } from '#lib/charts/net-worth.js';
let { overview }: { overview: OverviewData } = $props();
let { overview }: { overview: OverviewPageData } = $props();
const netWorth = $derived(overview.netWorth);
const incomplete = $derived(netWorth.points.some((point) => point.unconverted.length > 0));
</script>
+8
View File
@@ -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<OverviewData, 'asOf' | 'preparedAt' | 'journalCoverage'> & {
expenseCategories: OverviewRange['expenseCategories'];
incomeExpenses: OverviewData['incomeExpenses'];
netWorth: OverviewData['netWorth'];
};
+3 -4
View File
@@ -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<typeof registerDirectory>[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);
+1 -1
View File
@@ -11,7 +11,7 @@ export function registerMembers(
export function registerDirectory(
members: readonly string[],
catalog: readonly StoredAccount[]
catalog: readonly Pick<StoredAccount, 'id' | 'name' | 'access' | 'rowCount'>[]
): RegisterAccount[] {
return members.map((name) => {
const saved = catalog.find((entry) => entry.name === name);
@@ -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);
});
+2 -1
View File
@@ -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: [] }
}
};
@@ -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
@@ -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 = {
@@ -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');
});
@@ -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: [] }
}
@@ -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: [] }
}
@@ -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: [] }
}
@@ -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: [] }
}
@@ -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,
+6 -7
View File
@@ -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.
+6 -40
View File
@@ -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);
@@ -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<ReturnType<typeof startWorker>> | 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);
@@ -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);
@@ -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);
@@ -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);
@@ -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,
+42 -9
View File
@@ -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
+20 -4
View File
@@ -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/<account-id>.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/<account-id>.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
+74 -31
View File
@@ -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<OverviewMonths, OverviewRange> | 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.
@@ -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 });
}
});
@@ -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();
}
});
});
+129
View File
@@ -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<string, ReportedAmount[]>;
valuations: Map<string, ReportedAmount[]>;
}
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<string, ReportedAmount[]> {
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<string, ReportedAmount[]>();
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<string>();
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] }
};
}
+7 -1
View File
@@ -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,
+85 -236
View File
@@ -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();
});
+75 -91
View File
@@ -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<OverviewReports, 'asOf' | 'baseCurrency'>) {
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<OverviewReports, 'asOf' | 'baseCurrency'>,
{ incomePeriods, snapshotPeriods }: ReturnType<typeof planOverviewReports>
{ incomeRanges, snapshotPeriods }: ReturnType<typeof planOverviewReports>
): 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 };
}
@@ -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();
});
@@ -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);
}
+67 -75
View File
@@ -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<typeof frames>[number]) => r.cbrDates.pop()],
['extra dates', (r: ReturnType<typeof frames>[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<string>(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<string>(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: [] });
});
});
+26 -11
View File
@@ -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<OverviewData, 'netWorth' | 'balanceSheet'> {
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
};
}
@@ -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);
});
});
+117
View File
@@ -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 };
}
+101 -636
View File
@@ -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<OverviewData, 'preparedAt'>,
section: 'income' | 'expenses',
month = 5
) => data.incomeExpenses.months[month][section];
function expectSerializable(data: Omit<OverviewData, 'preparedAt'>) {
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<OverviewData, 'preparedAt'>, 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<typeof nativeReport>) => report.cbrSubreports.pop()],
[
'wrong contribution',
(report: ReturnType<typeof nativeReport>) => (report.cbrSubreports[0][2] = false)
],
[
'wrong expense contribution',
(report: ReturnType<typeof nativeReport>) => (report.cbrSubreports[1][2] = true)
],
['missing month', (report: ReturnType<typeof nativeReport>) => report.cbrDates.pop()],
[
'non-exact date',
(report: ReturnType<typeof nativeReport>) => (report.cbrDates[0][0].tag = 'Unbounded')
],
[
'misaligned month',
(report: ReturnType<typeof nativeReport>) => (report.cbrDates[0][0].contents = '2025-11-02')
],
[
'wrong exclusive end',
(report: ReturnType<typeof nativeReport>) => (report.cbrDates[5][1].contents = '2026-04-30')
]
])('fails closed on %s', (_name, mutate) => {
const report = JSON.parse(reportJson()) as ReturnType<typeof nativeReport>;
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);
});
});
+41 -86
View File
@@ -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<OverviewMonths, OverviewRange> | 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<OverviewData, 'preparedAt'> {
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
};
}
@@ -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);
}
+14 -6
View File
@@ -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([]);
});
+23 -5
View File
@@ -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<void>;
@@ -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<string, number>();
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',
+25 -4
View File
@@ -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('');
}
+33 -16
View File
@@ -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/<id>.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
+160 -86
View File
@@ -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', '[]'])(
+97 -28
View File
@@ -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<string>();
@@ -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<string>();
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();