Focus cash registers on activity and transaction details #55

Merged
eleith merged 1 commits from feat/focused-cash-register into main 2026-11-14 04:51:13 +00:00
30 changed files with 1681 additions and 950 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
'@hledger-reports/web': patch
---
Focus cash registers on recent activity and balance, with compact money columns and read-only transaction details for every entry. Preserve mixed-unit amounts instead of hiding the account.
+12 -9
View File
@@ -106,7 +106,10 @@ test('desktop account navigation preserves client routing and observes later sav
await accounts.getByRole('link', { name: /^Everyday checking\b/ }).click();
await expect(main.getByRole('heading', { name: 'Everyday checking', exact: true })).toBeVisible();
const registerStamp = main.locator('time');
// Investment registers still display the preparation timestamp used by this refresh check.
await accounts.getByRole('link', { name: /^Brokerage\b/ }).click();
await expect(main.getByRole('heading', { name: 'Brokerage', exact: true })).toBeVisible();
const registerStamp = main.getByText(/^Prepared at /).locator('time');
await expect(registerStamp).toHaveAttribute('datetime', savedAt);
// Explicit work happens in another authenticated page, not in the page left open.
@@ -127,7 +130,7 @@ test('desktop account navigation preserves client routing and observes later sav
Object.assign(window, { accountNavigationToken: value });
}, token);
await main.getByRole('link', { name: 'Newest first', exact: true }).click();
await expect(page).toHaveURL(/\/registers\/cash\/[a-z0-9]{8}\?order=desc$/);
await expect(page).toHaveURL(/\/registers\/investments\/[a-z0-9]{8}\?order=desc$/);
await expect(registerStamp).toHaveAttribute('datetime', laterAt);
expect(await page.evaluate(() => Reflect.get(window, 'accountNavigationToken'))).toBe(token);
@@ -239,11 +242,11 @@ test('responsive Accounts supports phone, overlay drawer and desktop transitions
// The account drawer must not leave the register's existing dialog unusable.
const activity = page.getByRole('region', { name: 'Cash register activity', exact: true });
const opener = activity.getByRole('button', { name: 'Breakdown', exact: true }).first();
const opener = activity.getByRole('button', { name: /^View transaction details:/ }).first();
await opener.click();
const breakdown = page.getByRole('dialog', { name: 'Transaction breakdown', exact: true });
await expect(breakdown).toBeVisible();
await breakdown.getByRole('button', { name: 'Close breakdown', exact: true }).click();
const details = page.getByRole('dialog', { name: 'Transaction details', exact: true });
await expect(details).toBeVisible();
await details.getByRole('button', { name: 'Close transaction details', exact: true }).click();
await expect(opener).toBeFocused();
await trigger.click();
@@ -252,9 +255,9 @@ test('responsive Accounts supports phone, overlay drawer and desktop transitions
await expect(drawer).not.toBeVisible();
await expect(page.getByRole('complementary', { name: 'Accounts', exact: true })).toBeVisible();
// Real focus and scrolling detect stale modal inertness/scroll locks, not CSS classes.
const newest = page.locator('main').getByRole('link', { name: 'Newest first', exact: true });
await newest.focus();
await expect(newest).toBeFocused();
const dateOrder = page.locator('main').getByRole('link', { name: /^Date:/ });
await dateOrder.focus();
await expect(dateOrder).toBeFocused();
await page.setViewportSize({ width: 1440, height: 500 });
await page.evaluate(() => window.scrollTo(0, 0));
await page.mouse.move(200, 400);
+85 -13
View File
@@ -1,31 +1,95 @@
import { expect, test } from '@playwright/test';
import {
expect,
test,
type Request as BrowserRequest,
type Response as BrowserResponse
} from '@playwright/test';
import { prepared } from './overview-fixture';
test('Cash register opens saved activity and a transaction breakdown', async ({ page }) => {
async function replayWithoutSession(response: BrowserResponse, origin: string) {
const request = response.request();
const headers = new Headers({ origin });
const contentType = request.headers()['content-type'];
if (contentType) headers.set('content-type', contentType);
const options: RequestInit = { method: request.method(), headers, redirect: 'error' };
if (request.method() !== 'GET' && request.method() !== 'HEAD') options.body = request.postData();
return fetch(response.url(), options);
}
test('Compact cash rows keep signed amounts and open details from the row', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await prepared(page);
await page.goto('/registers/cash');
await page
.locator('main')
.getByRole('link', { name: /^Everyday checking\b/ })
.click();
const activity = page.getByRole('region', { name: 'Cash register activity', exact: true });
const purchase = activity.locator('tbody tr').filter({ hasText: '2026-04-04' });
await expect(purchase.locator('time')).toHaveText('04/04/26', { useInnerText: true });
await expect(purchase.locator('time')).toHaveAttribute('datetime', '2026-04-04');
const amount = purchase.locator('[data-column="payment"]');
await expect(amount).toHaveText('-$1,400.00', { useInnerText: true });
await amount.click();
const dialog = page.getByRole('dialog', { name: 'Transaction details', exact: true });
await expect(dialog.getByText('-1400.00 USD', { exact: true }).first()).toBeVisible();
await expect(dialog.getByText('9500.00 USD', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Close transaction details', exact: true }).click();
await expect(purchase.getByRole('button', { name: /^View transaction details:/ })).toBeFocused();
});
test('Cash register opens recent saved activity and complete transaction details', async ({
page
}) => {
await prepared(page);
await page
.getByRole('complementary', { name: 'Accounts', exact: true })
.getByRole('link', { name: 'Cash', exact: true })
.click();
await expect(page).toHaveURL(/\/registers\/cash$/);
await page
const accountURL = await page
.locator('main')
.getByRole('link', { name: /^Everyday checking\b/ })
.click();
.getAttribute('href');
if (!accountURL) throw new Error('Missing saved cash account link.');
const reads: BrowserRequest[] = [];
page.on('request', (request) => {
if (request.resourceType() === 'fetch' || request.resourceType() === 'xhr') reads.push(request);
});
await page.goto(accountURL);
await expect(page.getByRole('heading', { name: /^Everyday checking\b/, level: 1 })).toBeVisible();
const activity = page.getByRole('region', { name: 'Cash register activity', exact: true });
await expect(activity.locator('tbody tr').last().locator('td[data-column="balance"]')).toHaveText(
'8520.00 USD'
await expect(page.getByLabel('Ending recorded balance', { exact: true })).toContainText(
'$8,520.00'
);
await expect(
activity.locator('tbody tr').first().locator('td[data-column="balance"]')
).toHaveText('$8,520.00');
const opener = activity.getByRole('button', { name: 'Breakdown', exact: true }).first();
await opener.click();
const dialog = page.getByRole('dialog', { name: 'Transaction breakdown', exact: true });
const purchase = activity.locator('tbody tr').filter({ hasText: '2026-04-04' });
await expect(purchase.locator('[data-column="payment"]')).toHaveText('-$1,400.00', {
useInnerText: true
});
const opener = purchase.getByRole('button', { name: /^View transaction details:/ });
await expect(opener).toBeEnabled();
expect(reads).toHaveLength(0);
const loaded = page.waitForResponse((response) => response.request().resourceType() === 'fetch');
await purchase.locator('td.date').click();
const response = await loaded;
expect(response.ok()).toBe(true);
expect(reads.map((request) => request.url())).toEqual([response.url()]);
const dialog = page.getByRole('dialog', { name: 'Transaction details', exact: true });
await expect(dialog).toBeVisible();
const splits = dialog.getByRole('table', { name: 'Split details', exact: true });
await expect(splits).toContainText('Brokerage');
await expect(splits.getByRole('cell', { name: '10.00 VTI', exact: true })).toBeVisible();
const close = dialog.getByRole('button', { name: 'Close breakdown', exact: true });
const postings = dialog.getByRole('region', { name: 'Posting lines', exact: true });
await expect(postings).toContainText('Brokerage');
await expect(postings.getByText('10.00 VTI', { exact: true })).toBeVisible();
await expect(dialog.getByText('-1400.00 USD', { exact: true }).first()).toBeVisible();
// Replay the actual framework-generated read without its session, not a guessed API URL.
const anonymous = await replayWithoutSession(response, new URL(page.url()).origin);
expect(anonymous.status).toBe(401);
expect(anonymous.headers.get('cache-control')).toBe('no-store');
const close = dialog.getByRole('button', { name: 'Close transaction details', exact: true });
await expect(close).toBeFocused();
await page.mouse.click(1, 1);
await expect(dialog).toBeVisible();
@@ -37,4 +101,12 @@ test('Cash register opens saved activity and a transaction breakdown', async ({
await close.click();
await expect(dialog).not.toBeVisible();
await expect(opener).toBeFocused();
const salary = activity.getByRole('button', { name: /^View transaction details: Employer on/ });
await salary.click();
await expect(dialog.getByRole('region', { name: 'Posting lines' })).toContainText(
'income:salary'
);
await close.click();
await expect(salary).toBeFocused();
});
@@ -1,186 +1,222 @@
<script lang="ts">
import { accountLabel, type AccountLabel } from '#lib/accounts.js';
import { formatAmount } from '#lib/amounts.js';
import { onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { resolve } from '$app/paths';
import type { ReportedAmount } from '@hledger-reports/hledger';
import type { CashBreakdown, CashRegisterData } from './data.js';
import TransactionBreakdown from './TransactionBreakdown.svelte';
import { isNegativeAmount, isPositiveAmount, isZeroAmount } from './amount-sign.js';
import type { CashRegisterData, CashTransactionRequest } from './data.js';
import { getCashTransaction } from './register.remote.js';
import TransactionDetails from './TransactionDetails.svelte';
let { data, labels = [] }: { data: CashRegisterData; labels?: readonly AccountLabel[] } =
$props();
let showUnits = $derived.by(() => {
const amounts = [
...(data.endingBalance ?? []),
...data.rows.flatMap((row) => [...row.change, ...row.balance])
];
const firstUnit = amounts[0]?.commodity;
return amounts.some((amount) => amount.commodity !== firstUnit);
});
let hydrated = $state(false);
let opener = $state<HTMLButtonElement | null>(null);
let selection = $state.raw<{ context: CashRegisterData; record: CashBreakdown } | null>(null);
// Invalidate synchronously with a replacement payload, before rendering its rows.
let selectedRecord = $derived(selection?.context === data ? selection.record : null);
let hasBreakdowns = $derived(data.rows.some((row) => row.breakdown !== null));
let request = $state.raw<{
context: CashRegisterData;
input: CashTransactionRequest;
} | null>(null);
// A replacement loader payload invalidates details before the new rows render.
let details = $derived(request?.context === data ? getCashTransaction(request.input) : null);
onMount(() => {
hydrated = true;
});
function openBreakdown(record: CashBreakdown, button: HTMLButtonElement) {
function openDetails(transactionIndex: number, button: HTMLButtonElement) {
opener = button;
selection = { context: data, record };
request = { context: data, input: { accountId: data.accountId, transactionIndex } };
}
function closeBreakdown() {
selection = null;
function closeDetails() {
request = null;
}
beforeNavigate(closeBreakdown);
function activateRow(event: MouseEvent, transactionIndex: number) {
if (!hydrated || event.defaultPrevented || event.button !== 0) return;
if (
event.target instanceof Element &&
event.target.closest('button, a, input, select, textarea, summary, [contenteditable]')
)
return;
if (window.getSelection()?.toString()) return;
const button = (event.currentTarget as HTMLTableRowElement).querySelector<HTMLButtonElement>(
'button.payee'
);
if (button) openDetails(transactionIndex, button);
}
beforeNavigate(closeDetails);
function pageParameters(page: number) {
return new URLSearchParams({ order: data.order, page: String(page) }).toString();
}
</script>
{#snippet amount(value: ReportedAmount | null)}
{#if value}
<span class="quantity">{value.quantity}</span>
<span>{value.commodity === '' ? 'no commodity' : value.commodity}</span>
{:else}Not reported{/if}
{#snippet amounts(values: ReportedAmount[])}
{#each values as value, index (index)}
{@const formatted = formatAmount(value)}
{@const unit = value.commodity || 'no commodity'}
<span class="quantity" class:negative={isNegativeAmount(value)}
>{formatted}{#if showUnits && !formatted.includes(unit)}<small class="unit">
{unit}</small
>{/if}</span
>
{/each}
{/snippet}
{#if data.unsupported}
<p>This account cannot be shown in the cash register.</p>
{#if data.rowCount === 0}
<p>No recorded activity.</p>
{:else}
<p><strong>Ending recorded balance:</strong> {@render amount(data.endingBalance)}</p>
<p>{data.rowCount} rows · Page {data.page} of {data.pageCount}</p>
<nav aria-label="Date order">
<span>Date:</span>
<a
href={resolve('/(protected)/registers/cash/[accountId]?order=asc', {
accountId: data.accountId
})}
aria-current={data.order === 'asc' ? 'true' : undefined}
onclick={closeBreakdown}>Oldest first</a
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the register horizontally.) -->
<div class="table-scroll" role="region" aria-label="Cash register activity" tabindex="0">
<table
class="report-table"
aria-label={`Transactions for ${accountLabel(data.accountName ?? 'Account', labels)}`}
>
<a
href={resolve('/(protected)/registers/cash/[accountId]?order=desc', {
accountId: data.accountId
})}
aria-current={data.order === 'desc' ? 'true' : undefined}
onclick={closeBreakdown}>Newest first</a
>
</nav>
{#if data.rowCount === 0}
<p>No recorded activity.</p>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the register horizontally.) -->
<div class="table-scroll" role="region" aria-label="Cash register activity" tabindex="0">
<table
class="report-table"
aria-label={`Transactions for ${accountLabel(data.accountName ?? 'Account', labels)}`}
>
<thead
><tr>
<th scope="col" aria-sort={data.order === 'asc' ? 'ascending' : 'descending'}>Date</th>
<th scope="col">Payee</th><th scope="col">Category</th><th scope="col">Notes</th>
<th scope="col" class="money">Payment</th><th scope="col" class="money">Deposit</th>
<th scope="col">Status</th><th scope="col" class="money">Balance</th><th scope="col"
>Details</th
<thead>
<tr>
<th
scope="col"
class="date"
aria-sort={data.order === 'asc' ? 'ascending' : 'descending'}
>
<a
href={resolve(
`/(protected)/registers/cash/[accountId]?order=${data.order === 'asc' ? 'desc' : 'asc'}`,
{ accountId: data.accountId }
)}
onclick={closeDetails}
aria-label={`Date: ${data.order === 'asc' ? 'oldest' : 'newest'} first; show ${data.order === 'asc' ? 'newest' : 'oldest'} first`}
>Date <span aria-hidden="true">{data.order === 'asc' ? '↑' : '↓'}</span></a
>
</tr></thead
>
<tbody>
{#each data.rows as row (row.index)}
<tr>
<td class="date">{row.date}</td>
<td><span class="single-line payee" title={row.payee}>{row.payee}</span></td>
<td data-column="category">
{#if row.categories.length === 0}—
{:else if row.categories.length === 1}
<span title={row.categories[0]}>{accountLabel(row.categories[0], labels)}</span>
{:else}Split ({row.categories.length} accounts){/if}
</td>
<td
><span class="single-line notes" title={row.notes.join('\n')}
>{row.notes.join(' · ')}</span
></td
</th>
<th scope="col" class="secondary">Status</th>
<th scope="col">Payee</th>
<th scope="col" class="secondary">Category/Transfer</th>
<th scope="col" class="money">
<span class="desktop-only">Payment</span><span class="mobile-only">Amount</span>
</th>
<th scope="col" class="money desktop-only">Deposit</th>
<th scope="col" class="money secondary">Balance</th>
</tr>
</thead>
<tbody>
{#each data.rows as row (row.index)}
{@const payments = row.change.filter(isNegativeAmount)}
{@const deposits = row.change.filter(isPositiveAmount)}
{@const zeroes = row.change.filter(isZeroAmount)}
<tr onclick={(event) => activateRow(event, row.index)}>
<td class="date">
<time datetime={row.date} title={row.date} aria-label={row.date}>
<span class="desktop-only">{row.date}</span><span class="mobile-only"
>{row.date.slice(5, 7)}/{row.date.slice(8, 10)}/{row.date.slice(2, 4)}</span
>
</time>
</td>
<td class="secondary status" data-column="status">
{#if row.statuses.length > 1}
<span
title={row.statuses.join(' / ')}
aria-label={`Mixed: ${row.statuses.join(' / ')}`}>Mixed</span
>
{:else}{row.statuses[0] ?? 'Not reported'}{/if}
</td>
<td class="payee-cell">
<button
class="payee"
type="button"
disabled={!hydrated}
aria-haspopup="dialog"
aria-label={`View transaction details: ${row.payee || 'Transaction'} on ${row.date}`}
onclick={(event) => {
if (hydrated && !event.defaultPrevented)
openDetails(row.index, event.currentTarget);
}}>{row.payee || 'Transaction'}</button
>
{#if row.direction === 'payment' || row.direction === 'deposit'}
<td class="money" data-column="payment"
>{#if row.direction === 'payment'}{@render amount(row.amount)}{/if}</td
>
<td class="money" data-column="deposit"
>{#if row.direction === 'deposit'}{@render amount(row.amount)}{/if}</td
>
{:else}
<td class="money" data-column="change" colspan="2"
>{#if row.direction === 'unchanged'}No change: {@render amount(
row.amount
)}{:else}Not reported{/if}</td
>
{/if}
<td data-column="status">{row.statuses.join(' / ')}</td>
<td class="money" data-column="balance">{@render amount(row.balance)}</td>
<td
>{#if row.breakdown}<button
class="ui-button ui-button--compact"
type="button"
disabled={!hydrated}
aria-haspopup="dialog"
onclick={(event) => {
if (row.breakdown) openBreakdown(row.breakdown, event.currentTarget);
}}>Breakdown</button
>{/if}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{#if hasBreakdowns}
<noscript><p>Breakdowns require JavaScript.</p></noscript>
{/if}
{/if}
<nav aria-label="Pagination">
{#if data.page > 1}
<a
href={resolve(`/(protected)/registers/cash/[accountId]?${pageParameters(1)}`, {
accountId: data.accountId
})}
onclick={closeBreakdown}>First</a
>
<a
href={resolve(`/(protected)/registers/cash/[accountId]?${pageParameters(data.page - 1)}`, {
accountId: data.accountId
})}
onclick={closeBreakdown}>Previous</a
>
{/if}
{#if data.pageCount !== null && data.page < data.pageCount}
<a
href={resolve(`/(protected)/registers/cash/[accountId]?${pageParameters(data.page + 1)}`, {
accountId: data.accountId
})}
onclick={closeBreakdown}>Next</a
>
<a
href={resolve(`/(protected)/registers/cash/[accountId]?${pageParameters(data.pageCount)}`, {
accountId: data.accountId
})}
onclick={closeBreakdown}>Last</a
>
{/if}
</nav>
</td>
<td class="secondary" data-column="category">
{#if row.categories.length === 0}Within account
{:else if row.categories.length === 1}
{accountLabel(row.categories[0], labels)}
{:else}Split{/if}
</td>
<td class="money movement" data-column="payment">
<span class="desktop-only">
{@render amounts(payments)}
{#if zeroes.length}<span class="neutral">No change: {@render amounts(zeroes)}</span>
{:else if !row.change.length}<span class="neutral">Not reported</span>{/if}
</span>
<span class="mobile-only">
{#if row.change.length}
{#if zeroes.length === row.change.length}<span class="neutral">No change</span
>{/if}
{@render amounts(row.change)}
{:else}<span class="neutral">Not reported</span>{/if}
</span>
</td>
<td class="money desktop-only" data-column="deposit">
{@render amounts(deposits)}
</td>
<td class="money secondary" data-column="balance">
{#if row.balance.length}{@render amounts(row.balance)}{:else}Not reported{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<noscript><p>Transaction details require JavaScript.</p></noscript>
{/if}
<TransactionBreakdown
{labels}
selected={selectedRecord}
onClose={closeBreakdown}
returnFocus={opener}
/>
{#if data.rowCount !== null && data.rowCount > 0}
<footer>
<span>{data.rowCount} transactions · Page {data.page} of {data.pageCount}</span>
<nav aria-label="Pagination">
{#if data.page > 1}
<a
href={resolve(`/(protected)/registers/cash/[accountId]?${pageParameters(1)}`, {
accountId: data.accountId
})}
onclick={closeDetails}>First</a
>
<a
href={resolve(
`/(protected)/registers/cash/[accountId]?${pageParameters(data.page - 1)}`,
{ accountId: data.accountId }
)}
onclick={closeDetails}>Previous</a
>
{/if}
{#if data.pageCount !== null && data.page < data.pageCount}
<a
href={resolve(
`/(protected)/registers/cash/[accountId]?${pageParameters(data.page + 1)}`,
{ accountId: data.accountId }
)}
onclick={closeDetails}>Next</a
>
<a
href={resolve(
`/(protected)/registers/cash/[accountId]?${pageParameters(data.pageCount)}`,
{ accountId: data.accountId }
)}
onclick={closeDetails}>Last</a
>
{/if}
</nav>
</footer>
{/if}
<TransactionDetails {labels} query={details} onClose={closeDetails} returnFocus={opener} />
<style>
nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-small);
}
[aria-current='true'] {
font-weight: var(--font-weight-strong);
}
.table-scroll {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-panel);
@@ -189,39 +225,132 @@
th,
td {
text-align: start;
vertical-align: middle;
padding: var(--space-compact) var(--space-tight);
white-space: nowrap;
vertical-align: top;
padding: var(--space-small) var(--space-tight);
border-bottom: var(--border-width) solid var(--color-border);
}
tbody tr {
/* Table row height is a minimum: larger text can still grow the row. */
height: 3rem;
}
th {
background: var(--color-background);
white-space: nowrap;
font-size: var(--font-small);
}
tbody tr {
cursor: pointer;
}
tbody tr:nth-child(even) {
background: var(--color-background);
}
.money {
text-align: end;
min-width: 10rem;
tbody tr:hover,
tbody tr:focus-within {
background: color-mix(in srgb, var(--color-accent) 7%, var(--color-background));
}
.single-line {
display: block;
tbody tr:last-child td {
border-bottom: 0;
}
.date {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.payee-cell {
min-width: 9rem;
max-width: 18rem;
}
.payee {
width: 14rem;
padding: 0;
border: 0;
background: none;
color: var(--color-text);
font: inherit;
text-align: start;
overflow-wrap: anywhere;
cursor: pointer;
text-decoration: none;
}
.notes {
width: 18rem;
.payee:disabled {
color: var(--color-text);
cursor: default;
}
.money {
text-align: end;
font-variant-numeric: tabular-nums;
}
.quantity {
font-variant-numeric: tabular-nums;
display: block;
white-space: nowrap;
}
.negative,
.negative .unit {
color: var(--color-negative);
}
.unit,
.neutral,
.status,
footer {
color: var(--color-muted);
font-size: var(--font-small);
}
.neutral > span {
display: block;
}
.mobile-only {
display: none;
}
footer,
nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-small);
}
footer {
justify-content: space-between;
padding-block: var(--space-small);
}
@media (max-width: 48rem) {
table,
thead,
tbody {
display: block;
}
tr {
display: grid;
grid-template-columns: max-content minmax(0, 1.4fr) minmax(0, 1fr);
column-gap: var(--space-tight);
padding: var(--space-small);
border-bottom: var(--border-width) solid var(--color-border);
}
tr:last-child {
border-bottom: 0;
}
th,
td {
padding: 0;
border: 0;
background: none;
}
.secondary,
.desktop-only {
display: none;
}
.mobile-only {
display: inline;
}
.payee-cell {
min-width: 0;
max-width: none;
}
.payee {
display: block;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.money {
min-width: 0;
}
.quantity {
white-space: normal;
overflow-wrap: anywhere;
}
}
</style>
@@ -1,42 +1,136 @@
import { render } from 'svelte/server';
import { expect, it, vi } from 'vitest';
import type { CashRegisterData } from './data.js';
import type { CashRegisterData, CashRow } from './data.js';
import { getCashTransaction } from './register.remote.js';
import CashRegister from './CashRegister.svelte';
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn() }));
vi.mock('./register.remote.js', () => ({ getCashTransaction: vi.fn() }));
it('preserves the native signed amount and supplied balances without rounding or recalculating', () => {
const data: CashRegisterData = {
function register(rows: CashRow[]): CashRegisterData {
return {
configured: true,
readFailed: false,
accountId: 'abcd1234',
accountName: 'assets:bank',
access: 'ready',
preparedAt: '2026-05-01T12:34:56Z',
rows: [
{
index: 1,
date: '2026-01-02',
payee: 'Shop',
categories: ['expenses:food'],
notes: [],
amount: { quantity: '-9007199254740993.000000000000000001', commodity: 'USD' },
balance: { quantity: '9007199254740995.000000000000000003', commodity: 'USD' },
statuses: ['Cleared'],
direction: 'payment',
breakdown: null
}
],
rowCount: 1,
rows,
rowCount: rows.length,
page: 1,
pageCount: 1,
order: 'asc',
endingBalance: { quantity: '42.123456789012345678', commodity: 'USD' },
unsupported: false
order: 'desc',
endingBalance: [{ quantity: '-42.123456789012345678', commodity: 'USD' }]
};
}
function text(html: string) {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function column(html: string, name: string, view: 'desktop' | 'mobile' = 'desktop') {
const cell =
html.match(new RegExp(`<td[^>]*data-column="${name}"[^>]*>([\\s\\S]*?)</td>`))?.[1] ?? '';
const mobile = cell.search(/<span[^>]*class="mobile-only(?:\s[^"]*)?"[^>]*>/);
if (mobile < 0) return text(cell);
return text(view === 'mobile' ? cell.slice(mobile) : cell.slice(0, mobile));
}
it('keeps native movement signs and exact historical balances without changing the data', () => {
const data = register([
{
index: 1,
date: '2026-01-02',
payee: 'Shop',
categories: ['expenses:food', 'expenses:other'],
statuses: ['Cleared', 'Pending'],
change: [
{ quantity: '-9007199254740993.000000000000000001', commodity: 'USD' },
{ quantity: '7.123456789012345678', commodity: 'EUR' },
{ quantity: '-2.25', commodity: 'hours' }
],
balance: [
{ quantity: '-9007199254740995.000000000000000003', commodity: 'USD' },
{ quantity: '3.75', commodity: 'hours' }
]
}
]);
const original = structuredClone(data);
const { body } = render(CashRegister, { props: { data } });
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
expect(text).toContain('-9007199254740993.000000000000000001 USD');
expect(text).toContain('9007199254740995.000000000000000003 USD');
expect(text).toContain('42.123456789012345678 USD');
const payment = column(body, 'payment');
const deposit = column(body, 'deposit');
expect(payment).toContain('-$9,007,199,254,740,993.000000000000000001');
expect(payment).toContain('-2.25 hours');
expect(deposit).toContain('€7.123456789012345678');
expect(deposit).not.toContain('hours');
const mobile = column(body, 'payment', 'mobile');
expect(mobile).toContain('-$9,007,199,254,740,993.000000000000000001');
expect(mobile).toContain('€7.123456789012345678');
expect(mobile).toContain('-2.25 hours');
expect(body).toContain('datetime="2026-01-02"');
expect(column(body, 'balance')).toContain('-$9,007,199,254,740,995.000000000000000003');
expect(column(body, 'balance')).toContain('3.75 hours');
expect(data).toEqual(original);
expect(getCashTransaction).not.toHaveBeenCalled();
});
it('distinguishes native USD from a literal dollar commodity despite their shared display symbol', () => {
const data = register([
{
index: 1,
date: '2026-01-02',
payee: 'Distinct units',
categories: [],
statuses: [],
change: [
{ quantity: '1.23', commodity: 'USD' },
{ quantity: '4.56', commodity: '$' }
],
balance: []
}
]);
const { body } = render(CashRegister, { props: { data } });
for (const rendered of [column(body, 'deposit'), column(body, 'payment', 'mobile')]) {
expect(rendered).toContain('$1.23 USD');
expect(rendered).toContain('$4.56');
expect(rendered).not.toContain('$4.56 USD');
}
});
it('keeps zero change neutral and an empty change bag unreported, without fabricating balances', () => {
const data = register([
{
index: 1,
date: '2026-01-02',
payee: 'Zero',
categories: [],
statuses: ['Unmarked'],
change: [{ quantity: '-0.000', commodity: 'hours' }],
balance: []
},
{
index: 2,
date: '2026-01-01',
payee: 'Unavailable',
categories: [],
statuses: [],
change: [],
balance: []
}
]);
const { body } = render(CashRegister, { props: { data } });
const rows =
body.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/)?.[1]?.match(/<tr[^>]*>[\s\S]*?<\/tr>/g) ?? [];
expect(rows).toHaveLength(2);
const [zero, unavailable] = rows;
if (!zero || !unavailable) throw new Error('Expected both saved transaction rows.');
expect(column(zero, 'payment')).toContain('No change');
expect(column(zero, 'payment')).toContain('-0.000 hours');
expect(column(unavailable, 'payment')).toBe('Not reported');
expect(column(zero, 'balance')).toBe('Not reported');
expect(column(unavailable, 'balance')).toBe('Not reported');
expect(getCashTransaction).not.toHaveBeenCalled();
});
@@ -1,139 +0,0 @@
<script lang="ts">
import { accountLabel, type AccountLabel } from '#lib/accounts.js';
import DetailDialog from '#lib/DetailDialog.svelte';
import type { CashBreakdown } from './data.js';
let {
selected,
onClose,
returnFocus = null,
labels = []
}: {
selected: CashBreakdown | null;
onClose: () => void;
returnFocus?: HTMLElement | null;
labels?: readonly AccountLabel[];
} = $props();
</script>
<DetailDialog
open={selected !== null}
title={selected?.payee || 'Transaction breakdown'}
label="Transaction breakdown"
closeLabel="Close breakdown"
{onClose}
{returnFocus}
>
{#if selected}
<p class="context">
{selected.date} ·
{#if selected.direction === 'unreported' || !selected.amount}
Not reported
{:else}
{#if selected.direction === 'payment'}Payment:
{:else if selected.direction === 'deposit'}Deposit:
{:else}No change:{/if}
{selected.amount.quantity}
{selected.amount.commodity === '' ? 'no commodity' : selected.amount.commodity}
{/if}
</p>
{#if selected.notes.some((note) => note.trim().length > 0)}
<section aria-label="Notes">
<h3>Notes</h3>
{#each selected.notes as note, index (index)}
<p class="text register-note">{note}</p>
{/each}
</section>
{/if}
{#if selected.internalTransfer}<p>Transfer within this account</p>{/if}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll split details horizontally.) -->
<div class="table-scroll" role="region" aria-label="Split details" tabindex="0">
<table class="report-table" aria-label="Split details">
<thead>
<tr>
<th scope="col">{selected.internalTransfer ? 'Account' : 'Category'}</th>
<th scope="col" class="money">Amount</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
{#each selected.lines as line, index (index)}
{@const label = accountLabel(line.account, labels)}
<tr class="split-line">
<td>
<span class="text">
{label}{#if label !== line.account}<small>{` (${line.account})`}</small>{/if}
</span>
{#if line.kind === 'VirtualPosting'}
<small class="badge">Virtual posting</small>
{:else if line.kind === 'BalancedVirtualPosting'}
<small class="badge">Balanced virtual posting</small>
{/if}
</td>
<td class="money">
{#if line.amounts.length}
<ul class="amounts">
{#each line.amounts as amount, amountIndex (amountIndex)}
<li>
{amount.quantity}
{amount.commodity === '' ? 'no commodity' : amount.commodity}
</li>
{/each}
</ul>
{:else}Not reported{/if}
</td>
<td class="text">{line.notes}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</DetailDialog>
<style>
h3 {
margin-block: var(--space-small);
font-size: inherit;
}
p {
margin-block: 1em;
}
.register-note {
margin-block: 0.25rem;
}
.context {
color: var(--color-muted);
}
.text {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
table {
font-size: var(--font-small);
}
th,
td {
padding: var(--space-small);
text-align: left;
vertical-align: top;
border-bottom: var(--border-width) solid var(--color-border);
}
thead th {
white-space: nowrap;
}
.money {
white-space: nowrap;
text-align: right;
font-variant-numeric: tabular-nums;
}
.amounts {
list-style: none;
padding: 0;
margin: 0;
}
.badge {
display: block;
color: var(--color-muted);
}
</style>
@@ -0,0 +1,205 @@
<script lang="ts">
import { accountLabel, type AccountLabel } from '#lib/accounts.js';
import DetailDialog from '#lib/DetailDialog.svelte';
import type { RemoteQuery } from '$app/server';
import type { ReportedAmount } from '@hledger-reports/hledger';
import { isNegativeAmount } from './amount-sign.js';
import type { CashTransactionDetails } from './data.js';
let {
query,
onClose,
returnFocus = null,
labels = []
}: {
query: RemoteQuery<CashTransactionDetails> | null;
onClose: () => void;
returnFocus?: HTMLElement | null;
labels?: readonly AccountLabel[];
} = $props();
let selected = $derived(query?.ready ? query.current : null);
async function retry() {
try {
await query?.refresh();
} catch {
// The query exposes the failure through its error field.
}
}
</script>
{#snippet amounts(values: ReportedAmount[], accountAmounts = false)}
{#if values.length}
<ul class="amounts">
{#each values as amount, index (index)}
<li class:negative={accountAmounts && isNegativeAmount(amount)}>
{amount.quantity}
{amount.commodity || 'no commodity'}
</li>
{/each}
</ul>
{:else}Not reported{/if}
{/snippet}
<DetailDialog
open={query !== null}
title={selected?.payee || 'Transaction details'}
label="Transaction details"
closeLabel="Close transaction details"
{onClose}
{returnFocus}
>
{#if query?.loading}
<p role="status">Loading transaction details…</p>
{:else if query?.error}
<p role="alert">Transaction details couldn't be read.</p>
<button class="ui-button ui-button--compact" type="button" onclick={retry}>Retry</button>
{:else if selected}
<dl class="metadata">
<div>
<dt>Account date</dt>
<dd>{selected.date}</dd>
</div>
{#if selected.transactionDate !== selected.date}
<div>
<dt>Transaction date</dt>
<dd>{selected.transactionDate}</dd>
</div>
{/if}
{#if selected.secondaryDate !== null && selected.secondaryDate !== selected.transactionDate}
<div>
<dt>Secondary date</dt>
<dd>{selected.secondaryDate}</dd>
</div>
{/if}
{#if selected.code}<div>
<dt>Reference</dt>
<dd class="text">{selected.code}</dd>
</div>{/if}
<div>
<dt>Status</dt>
<dd>{selected.statuses.join(' / ') || 'Not reported'}</dd>
</div>
<div>
<dt>Native change</dt>
<dd>{@render amounts(selected.change, true)}</dd>
</div>
<div>
<dt>Historical balance</dt>
<dd>{@render amounts(selected.balance, true)}</dd>
</div>
</dl>
{#if selected.description && selected.description !== selected.payee}
<p class="text">{selected.description}</p>
{/if}
{#if selected.notes.length}
<section aria-label="Transaction notes">
<h3>Notes</h3>
{#each selected.notes as note, index (index)}
<p class="text note">{note}</p>
{/each}
</section>
{/if}
<section aria-labelledby="cash-postings-heading">
<h3 id="cash-postings-heading">Posting lines</h3>
<ol class="postings">
{#each selected.lines as line, index (index)}
{@const label = accountLabel(line.account, labels)}
<li>
<div class="posting-header">
<div class="text">
<strong>{label}</strong>
{#if label !== line.account}<small class="account">{line.account}</small>{/if}
{#if line.selected}<small class="badge">Selected account</small>{/if}
</div>
<div class="money">{@render amounts(line.amounts)}</div>
</div>
<p class="posting-status">
Status: {line.status || 'Not reported'}
{#if line.kind === 'VirtualPosting'}
· Virtual posting
{:else if line.kind === 'BalancedVirtualPosting'}
· Balanced virtual posting{/if}
</p>
{#if line.notes}<p class="text note">{line.notes}</p>{/if}
</li>
{/each}
</ol>
</section>
{/if}
</DetailDialog>
<style>
h3 {
margin-block: var(--space-medium) var(--space-small);
font-size: inherit;
}
.metadata {
display: grid;
gap: var(--space-small);
margin-block: var(--space-medium);
}
.metadata > div {
display: grid;
grid-template-columns: minmax(7rem, 1fr) minmax(0, 2fr);
gap: var(--space-small);
}
dt,
.posting-status,
.account {
color: var(--color-muted);
}
dd {
margin: 0;
}
.text {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.note {
margin-block: var(--space-tight);
}
.amounts,
.postings {
list-style: none;
padding: 0;
margin: 0;
}
.amounts {
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.postings > li {
padding-block: var(--space-small);
border-top: var(--border-width) solid var(--color-border);
}
.posting-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-small);
}
.posting-header > .text {
flex: 1 1 12rem;
}
.money {
text-align: end;
margin-inline-start: auto;
max-width: 100%;
}
.posting-status {
font-size: var(--font-small);
margin-block: var(--space-tight) 0;
}
.negative {
color: var(--color-negative);
}
.badge,
.account {
display: block;
}
.badge {
color: var(--color-accent);
}
</style>
@@ -0,0 +1,13 @@
import type { ReportedAmount } from '@hledger-reports/hledger';
export function isZeroAmount({ quantity }: ReportedAmount): boolean {
return !/[1-9]/.test(quantity);
}
export function isNegativeAmount(amount: ReportedAmount): boolean {
return !isZeroAmount(amount) && amount.quantity.startsWith('-');
}
export function isPositiveAmount(amount: ReportedAmount): boolean {
return !isZeroAmount(amount) && !amount.quantity.startsWith('-');
}
+32 -26
View File
@@ -1,36 +1,43 @@
import type { ReportedAmount } from '@hledger-reports/hledger';
import type { RegisterAccount } from '#lib/accounts.js';
export type CashDirection = 'payment' | 'deposit' | 'unchanged' | 'unreported';
export interface CashSplitLine {
account: string;
amounts: ReportedAmount[];
notes: string;
kind: string;
}
export interface CashBreakdown {
date: string;
payee: string;
notes: string[];
amount: ReportedAmount | null;
direction: CashDirection;
internalTransfer: boolean;
lines: CashSplitLine[];
}
export interface CashRow {
index: number;
date: string;
payee: string;
categories: string[];
notes: string[];
amount: ReportedAmount | null;
balance: ReportedAmount | null;
statuses: string[];
direction: CashDirection;
breakdown: CashBreakdown | null;
change: ReportedAmount[];
balance: ReportedAmount[];
}
export interface CashTransactionRequest {
accountId: string;
transactionIndex: number;
}
export interface CashPostingLine {
account: string;
selected: boolean;
amounts: ReportedAmount[];
notes: string;
kind: string;
status: string;
}
export interface CashTransactionDetails {
index: number;
date: string;
transactionDate: string;
secondaryDate: string | null;
code: string;
payee: string;
description: string;
notes: string[];
statuses: string[];
change: ReportedAmount[];
balance: ReportedAmount[];
lines: CashPostingLine[];
}
export interface CashRegisterData {
@@ -45,8 +52,7 @@ export interface CashRegisterData {
page: number;
pageCount: number | null;
order: 'asc' | 'desc';
endingBalance: ReportedAmount | null;
unsupported: boolean;
endingBalance: ReportedAmount[] | null;
}
export interface CashAccountsData {
@@ -0,0 +1,10 @@
import { getRequestEvent, query } from '$app/server';
import type { CashTransactionRequest } from './data.js';
import { parseCashTransactionRequest } from './transaction-request.js';
import { readCashTransaction } from '#lib/server/registers/read-cash.js';
export const getCashTransaction = query('unchecked', async (input: CashTransactionRequest) => {
const request = parseCashTransactionRequest(input);
const { locals } = getRequestEvent();
return readCashTransaction(locals, request);
});
@@ -0,0 +1,27 @@
import { expect, it } from 'vitest';
import { parseCashTransactionRequest } from './transaction-request.js';
it('accepts an account-scoped transaction detail request', () => {
const request = { accountId: 'abcd1234', transactionIndex: 7 };
expect(parseCashTransactionRequest(request)).toEqual(request);
expect(parseCashTransactionRequest(Object.assign(Object.create(null), request))).toEqual(request);
});
it.each([
null,
{ accountId: '../PRIVATE', transactionIndex: 7 },
{ accountId: 'abcd1234\n', transactionIndex: 7 },
Object.assign(Object.create({ inherited: true }), { accountId: 'abcd1234', transactionIndex: 7 }),
{ accountId: 'abcd1234', transactionIndex: 0 },
{ accountId: 'abcd1234', transactionIndex: 1.5 },
{ accountId: 'abcd1234', transactionIndex: Number.MAX_SAFE_INTEGER + 1 },
{ accountId: 'abcd1234', transactionIndex: 7, source: 'PRIVATE' }
])('rejects malformed detail input without exposing its values: %j', (input) => {
try {
parseCashTransactionRequest(input);
expect.unreachable();
} catch (error) {
expect(error).toMatchObject({ status: 400 });
expect(JSON.stringify(error)).not.toContain('PRIVATE');
}
});
@@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import type { CashTransactionRequest } from './data.js';
export function parseCashTransactionRequest(input: unknown): CashTransactionRequest {
if (!hasCashTransactionRequestShape(input)) {
error(400, 'A valid transaction request is required.');
}
const { accountId, transactionIndex } = input;
if (!isAccountId(accountId)) {
error(400, 'A valid account ID is required.');
}
if (!isNativeTransactionIndex(transactionIndex)) {
error(400, 'A valid transaction index is required.');
}
return { accountId, transactionIndex };
}
function hasCashTransactionRequestShape(
input: unknown
): input is { accountId: unknown; transactionIndex: unknown } {
if (input === null || typeof input !== 'object' || Array.isArray(input)) return false;
const prototype = Object.getPrototypeOf(input);
if (prototype !== Object.prototype && prototype !== null) return false;
return (
Reflect.ownKeys(input).length === 2 &&
Object.hasOwn(input, 'accountId') &&
Object.hasOwn(input, 'transactionIndex')
);
}
function isAccountId(value: unknown): value is string {
return typeof value === 'string' && value.length === 8 && !/[^a-z0-9]/.test(value);
}
function isNativeTransactionIndex(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
+40
View File
@@ -105,6 +105,46 @@ it('fails closed without configured resources', async () => {
expect(input.event.locals.auth).not.toHaveBeenCalled();
});
it('rejects anonymous generated remote GETs without redirecting and adds private headers', async () => {
const input = setup(null);
Object.assign(input.event, { isRemoteRequest: true });
const response = await privateHeaders({ ...input, resolve: async () => requireSignIn(input) });
expect(response.status).toBe(401);
expect(response.headers.get('location')).toBeNull();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(input.event.locals.auth).toHaveBeenCalledOnce();
expect(input.resolve).not.toHaveBeenCalled();
});
it('checks the configured host before authentication for generated remote requests', async () => {
const input = setup(null);
const url = new URL('https://attacker.test/resource');
input.event = { ...input.event, isRemoteRequest: true, url, request: new Request(url) };
const response = await privateHeaders({ ...input, resolve: async () => protectOrigin(input) });
expect(response.status).toBe(403);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(input.event.locals.auth).not.toHaveBeenCalled();
expect(input.resolve).not.toHaveBeenCalled();
});
it('keeps authenticated generated remote responses private', async () => {
const input = setup(null);
Object.assign(input.event, { isRemoteRequest: true });
vi.mocked(input.event.locals.auth).mockResolvedValue({
user: { name: 'Reader' },
expires: '2026-11-07T00:00:00.000Z'
});
const response = await privateHeaders({ ...input, resolve: async () => requireSignIn(input) });
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(input.resolve).toHaveBeenCalledOnce();
});
it('passes a verified session without touching jobs or setting downstream headers twice', async () => {
const input = setup('/(protected)');
const session = { user: { name: 'Reader' }, expires: '2026-11-07T00:00:00.000Z' };
+8 -7
View File
@@ -6,16 +6,17 @@ function isPublicRoute(id: string): boolean {
return id === '/(public)' || id.startsWith('/(public)/');
}
function needsApplicationOrigin(id: string | null): boolean {
function needsApplicationOrigin(id: string | null, isRemoteRequest: boolean): boolean {
return (
id !== null &&
(!isPublicRoute(id) || id === '/(public)/(auth)' || id.startsWith('/(public)/(auth)/'))
isRemoteRequest ||
(id !== null &&
(!isPublicRoute(id) || id === '/(public)/(auth)' || id.startsWith('/(public)/(auth)/')))
);
}
export const privateHeaders: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
if (!needsApplicationOrigin(event.route.id)) return response;
if (!needsApplicationOrigin(event.route.id, event.isRemoteRequest)) return response;
const headers = new Headers(response.headers);
headers.set('cache-control', 'no-store');
if (!headers.has('referrer-policy'))
@@ -29,7 +30,7 @@ export const privateHeaders: Handle = async ({ event, resolve }) => {
};
export const protectOrigin: Handle = ({ event, resolve }) => {
if (!needsApplicationOrigin(event.route.id)) return resolve(event);
if (!needsApplicationOrigin(event.route.id, event.isRemoteRequest)) return resolve(event);
const application = event.locals.application;
if (!application)
return apiError(503, 'NOT_CONFIGURED', 'Application configuration is required.');
@@ -45,10 +46,10 @@ export const protectOrigin: Handle = ({ event, resolve }) => {
export const requireSignIn: Handle = async ({ event, resolve }) => {
const id = event.route.id;
if (id === null || isPublicRoute(id)) return resolve(event);
if (!event.isRemoteRequest && (id === null || isPublicRoute(id))) return resolve(event);
event.locals.session = await event.locals.auth();
if (event.locals.session) return resolve(event);
const endpoint = id.startsWith('/(protected)/(endpoints)/');
const endpoint = event.isRemoteRequest || id?.startsWith('/(protected)/(endpoints)/');
if (event.request.method !== 'GET' || endpoint) {
return apiError(401, 'AUTH_REQUIRED', 'Sign in to continue.');
}
+189 -407
View File
@@ -1,6 +1,6 @@
import { expect, it, vi } from 'vitest';
import type { DetailedAmount, PreparedPosting, PreparedAccountRow } from '@hledger-reports/hledger';
import { projectCashRegisterPage } from './cash';
import { projectCashRegisterPage, projectCashTransaction } from './cash';
import { accountPage } from './account-page.fixture';
vi.mock('@hledger-reports/hledger', () => {
@@ -61,10 +61,7 @@ function row(): PreparedAccountRow {
note: 'native note',
comment: ' transaction note\n',
precedingComment: 'preceding comment',
tags: [
['project', 'one'],
['project', 'two']
],
tags: [['project', 'one']],
postings: [
posting('arbitrary:selected', { comment: ' bank note\n' }),
posting('expenses:Full category', { comment: 'counterpart comment' })
@@ -74,143 +71,118 @@ function row(): PreparedAccountRow {
};
}
it('projects native payee, account-relative date, exact raw amounts, full category and selected notes', () => {
it('projects compact native summaries and complete details even for a simple transaction', () => {
const record = row();
const result = projectCashRegisterPage(accountPage([record], { endingBalance: record.balance }));
expect(result).toEqual({
unsupported: false,
endingBalance: { commodity: 'USD', quantity: '9007199254740993.000000000000000001' },
rows: [
{
index: 7,
date: '2026-05-03',
payee: 'Native payee',
categories: ['expenses:Full category'],
notes: ['native note', ' transaction note\n', ' bank note\n'],
amount: { commodity: 'USD', quantity: '-0.000000000000000001' },
balance: { commodity: 'USD', quantity: '9007199254740993.000000000000000001' },
statuses: ['Cleared'],
direction: 'payment',
breakdown: null
}
]
});
});
it.each([
{ description: 'Employer | January salary', note: 'January salary', notes: ['January salary'] },
{ description: 'Employer', note: 'Employer', notes: [] },
{ description: 'Employer |', note: '', notes: [] },
{ description: 'Employer | \t', note: ' \t', notes: [] }
])('projects prepared notes without comments for $description', ({ description, note, notes }) => {
const record = row();
Object.assign(record.transaction, { description, payee: 'Employer', note, comment: '' });
for (const posting of record.transaction.postings) posting.comment = '';
const before = structuredClone(record);
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.payee).toBe('Employer');
expect(result.notes).toEqual(notes);
expect(result.breakdown).toBeNull();
expect(record).toEqual(before);
});
it('keeps repeated comments and their whitespace alongside the prepared note in shared split notes', () => {
const record = row();
record.isSplit = true;
record.transaction.note = ' native note\n';
record.transaction.comment = 'Native payee';
record.transaction.postings[0].comment = ' native note\n';
const before = structuredClone(record);
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.notes).toEqual([' native note\n', 'Native payee', ' native note\n']);
expect(result.breakdown?.notes).toBe(result.notes);
expect(result.breakdown?.lines[0].notes).toBe('counterpart comment');
expect(record).toEqual(before);
});
it.each([
['-0.000000000000000001', 'payment'],
['9007199254740993.000000000000000001', 'deposit'],
['0', 'unchanged'],
['-0.000', 'unchanged'],
['+0.000', 'unchanged'],
['+1.000', 'deposit']
])('uses exact string sign for %s without altering the amount', (quantity, direction) => {
const record = row();
record.change = [amount(quantity)];
record.isSplit = true;
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.direction).toBe(direction);
expect(result.amount?.quantity).toBe(quantity);
expect(result.breakdown?.direction).toBe(direction);
expect(result.breakdown?.amount).toEqual(result.amount);
});
it('keeps empty bags unreported and blank commodity a literal supported unit', () => {
const record = row();
record.change = [];
record.balance = [];
expect(projectCashRegisterPage(accountPage([record]))).toMatchObject({
endingBalance: null,
unsupported: false,
rows: [{ amount: null, balance: null, direction: 'unreported' }]
});
record.change = [amount('1', '')];
expect(projectCashRegisterPage(accountPage([record]))).toMatchObject({
endingBalance: null,
unsupported: false,
rows: [{ amount: { commodity: '', quantity: '1' } }]
});
expect(projectCashRegisterPage(accountPage([]))).toEqual({
endingBalance: null,
unsupported: false,
rows: []
});
});
it('ignores counterpart units and preserves repeated-category and virtual postings in meaningful breakdowns', () => {
const record = row();
record.transaction.postings[1].amounts = [amount('75', 'SHARES'), amount('20', 'EUR')];
record.transaction.postings.push(
posting('expenses:Full category', {
kind: 'VirtualPosting',
amounts: [amount('25', '')],
tags: [['tag', 'value']]
})
const change = [{ commodity: 'USD', quantity: '-0.000000000000000001' }];
const balance = [{ commodity: 'USD', quantity: '9007199254740993.000000000000000001' }];
expect(projectCashRegisterPage(accountPage([record], { endingBalance: record.balance }))).toEqual(
{
endingBalance: balance,
rows: [
{
index: 7,
date: '2026-05-03',
payee: 'Native payee',
categories: ['expenses:Full category'],
statuses: ['Cleared'],
change,
balance
}
]
}
);
const result = projectCashRegisterPage(accountPage([record], { endingBalance: record.balance }));
expect(result.unsupported).toBe(false);
expect(result.rows[0].amount?.commodity).toBe('USD');
expect(result.endingBalance?.commodity).toBe('USD');
expect(result.rows[0].categories).toEqual(['expenses:Full category']);
expect(result.rows[0].breakdown).toEqual({
date: record.accountDate,
expect(projectCashTransaction(record)).toEqual({
index: 7,
date: '2026-05-03',
transactionDate: '2026-05-01',
secondaryDate: '2026-05-02',
code: 'REF',
payee: 'Native payee',
notes: result.rows[0].notes,
amount: result.rows[0].amount,
direction: 'payment',
internalTransfer: false,
description: 'Native payee | native note',
notes: ['native note', ' transaction note\n', ' bank note\n'],
statuses: ['Cleared'],
change,
balance,
lines: [
{
account: 'expenses:Full category',
notes: 'counterpart comment',
account: 'arbitrary:selected',
selected: true,
amounts: change,
notes: ' bank note\n',
kind: 'RealPosting',
amounts: [
{ commodity: 'SHARES', quantity: '75' },
{ commodity: 'EUR', quantity: '20' }
]
status: 'Cleared'
},
{
account: 'expenses:Full category',
notes: '',
kind: 'VirtualPosting',
amounts: [{ commodity: '', quantity: '25' }]
selected: false,
amounts: change,
notes: 'counterpart comment',
kind: 'RealPosting',
status: 'Cleared'
}
]
});
});
it('uses only native-selected indexes, keeping all actual statuses in native index order', () => {
it('retains all history and mixed-sign, mixed-unit native bags without netting or a whole-account gate', () => {
const records = [row(), row(), row()];
const bag = [
amount('-0.000000000000000001'),
amount('+2.00', 'EUR'),
amount('0', ''),
amount('-0.000'),
amount('+0.000'),
amount('+2.00', 'EUR')
];
for (const [index, record] of records.entries()) {
record.transaction.index = [90, 10, 70][index];
record.accountDate = ['2026-05-03', '2026-05-02', '2026-05-01'][index];
record.change = bag;
record.balance = bag;
}
const ending = [amount('9007199254740993.123456789012345678'), ...bag];
const page = accountPage(records, {
hasMultipleAmounts: true,
hasMultipleCommodities: true,
endingBalance: ending
});
const before = structuredClone(page);
const result = projectCashRegisterPage(page);
const publicBag = bag.map(({ commodity, quantity }) => ({ commodity, quantity }));
expect(result.rows.map((record) => record.index)).toEqual([90, 10, 70]);
for (const [index, summary] of result.rows.entries()) {
expect(summary.change).toEqual(publicBag);
expect(summary.balance).toEqual(publicBag);
const details = projectCashTransaction(records[index]);
expect(details.change).toEqual(publicBag);
expect(details.balance).toEqual(publicBag);
}
expect(result.endingBalance).toEqual([
{ commodity: 'USD', quantity: '9007199254740993.123456789012345678' },
...publicBag
]);
expect(page).toEqual(before);
});
it('keeps prepared source notes, including repeated comments and whitespace, but excludes a duplicate payee note', () => {
const record = row();
record.transaction.note = record.transaction.payee;
expect(projectCashTransaction(record).notes).toEqual([' transaction note\n', ' bank note\n']);
record.transaction.note = ' native note\n';
record.transaction.comment = 'Native payee';
record.transaction.postings[0].comment = ' native note\n';
expect(projectCashTransaction(record).notes).toEqual([
' native note\n',
'Native payee',
' native note\n'
]);
record.transaction.note = ' \t';
record.transaction.comment = '';
record.transaction.postings[0].comment = '\n';
expect(projectCashTransaction(record).notes).toEqual([]);
});
it('uses only native-selected indexes for categories, notes and effective statuses', () => {
const record = row();
record.transaction.comment = ' \n';
record.transaction.postings = [
@@ -221,23 +193,58 @@ it('uses only native-selected indexes, keeping all actual statuses in native ind
posting('assets:bank:child', { effectiveStatus: 'Cleared', comment: 'third' })
];
record.accountPostingIndexes = [2, 0, 3, 4];
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.statuses).toEqual(['Pending', 'Unmarked', 'Cleared']);
expect(result.notes).toEqual(['native note', 'second', 'first', 'third']);
expect(result.categories).toEqual(['arbitrary:selected']);
expect(result.breakdown?.notes).toEqual(result.notes);
expect(result.breakdown?.lines).toEqual([
{
account: 'arbitrary:selected',
amounts: [{ commodity: 'USD', quantity: '-0.000000000000000001' }],
notes: 'not selected',
kind: 'RealPosting'
}
const summary = projectCashRegisterPage(accountPage([record])).rows[0];
const details = projectCashTransaction(record);
expect(summary.categories).toEqual(['arbitrary:selected']);
expect(summary.statuses).toEqual(['Pending', 'Unmarked', 'Cleared']);
expect(details.statuses).toEqual(summary.statuses);
expect(details.notes).toEqual(['native note', 'second', 'first', 'third']);
expect(details.lines.map((line) => [line.selected, line.status])).toEqual([
[true, 'Unmarked'],
[false, 'Cleared'],
[true, 'Pending'],
[true, 'Unmarked'],
[true, 'Cleared']
]);
expect(result.breakdown?.internalTransfer).toBe(false);
record.accountPostingIndexes = [];
expect(projectCashTransaction(record)).toMatchObject({
statuses: [],
notes: ['native note'],
lines: record.transaction.postings.map(() => ({ selected: false }))
});
});
it('shows internal-transfer account movements without summing selected postings', () => {
it('deduplicates compact categories but retains every ordered split posting and signed amount', () => {
const record = row();
record.isSplit = true;
record.change = [amount('-100')];
record.transaction.postings[0].amounts = [amount('-100')];
record.transaction.postings[1].amounts = [amount('75')];
record.transaction.postings.push(
posting('expenses:Full category', { amounts: [amount('25')], comment: 'second allocation' }),
posting('memo:zero', { amounts: [amount('-0.000', '')], kind: 'VirtualPosting' }),
posting('memo:unreported', { amounts: [], kind: 'BalancedVirtualPosting' })
);
const summary = projectCashRegisterPage(accountPage([record])).rows[0];
expect(summary.categories).toEqual(['expenses:Full category', 'memo:zero', 'memo:unreported']);
const details = projectCashTransaction(record);
expect(details.lines.map((line) => [line.account, line.selected, line.amounts])).toEqual([
['arbitrary:selected', true, [{ commodity: 'USD', quantity: '-100' }]],
['expenses:Full category', false, [{ commodity: 'USD', quantity: '75' }]],
['expenses:Full category', false, [{ commodity: 'USD', quantity: '25' }]],
['memo:zero', false, [{ commodity: '', quantity: '-0.000' }]],
['memo:unreported', false, []]
]);
expect(details.lines.map((line) => line.kind)).toEqual([
'RealPosting',
'RealPosting',
'RealPosting',
'VirtualPosting',
'BalancedVirtualPosting'
]);
});
it('retains internal-transfer postings in source order rather than summing selected amounts', () => {
const record = row();
record.accountPostingIndexes = [1, 0];
record.change = [amount('0.000')];
@@ -245,88 +252,42 @@ it('shows internal-transfer account movements without summing selected postings'
posting('assets:bank:checking', { amounts: [amount('-100')], comment: 'from checking' }),
posting('assets:bank:savings', { amounts: [amount('100')], comment: 'to savings' })
];
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.categories).toEqual([]);
expect(result.breakdown).toEqual({
date: result.date,
payee: result.payee,
notes: ['native note', ' transaction note\n', 'to savings', 'from checking'],
amount: { commodity: 'USD', quantity: '0.000' },
direction: 'unchanged',
internalTransfer: true,
lines: [
{
account: 'assets:bank:checking',
amounts: [{ commodity: 'USD', quantity: '-100' }],
notes: 'from checking',
kind: 'RealPosting'
},
{
account: 'assets:bank:savings',
amounts: [{ commodity: 'USD', quantity: '100' }],
notes: 'to savings',
kind: 'RealPosting'
}
]
});
expect(result.breakdown?.notes).toEqual(result.notes);
});
it('uses native split even with one counterpart, and full ordered categories for distinct counterparts', () => {
const record = row();
record.isSplit = true;
expect(projectCashRegisterPage(accountPage([record])).rows[0].breakdown).not.toBeNull();
record.isSplit = false;
record.transaction.postings.push(
posting('another:category'),
posting('expenses:Full category'),
posting('another:category')
);
expect(projectCashRegisterPage(accountPage([record])).rows[0].categories).toEqual([
'expenses:Full category',
'another:category'
expect(projectCashRegisterPage(accountPage([record])).rows[0].categories).toEqual([]);
const details = projectCashTransaction(record);
expect(details.change).toEqual([{ commodity: 'USD', quantity: '0.000' }]);
expect(details.notes).toEqual([
'native note',
' transaction note\n',
'to savings',
'from checking'
]);
expect(details.lines.map((line) => [line.selected, line.amounts[0].quantity])).toEqual([
[true, '-100'],
[true, '100']
]);
});
it('preserves supplied descending-date row order', () => {
const records = ['2026-05-03', '2026-05-02', '2026-05-01'].map((date, index) => {
const record = row();
record.accountDate = date;
record.transaction.index = [90, 10, 70][index];
record.balance = [amount(String(index))];
return record;
it('preserves empty bags without inferring change, balance or an ending balance from other values', () => {
const record = row();
record.change = [];
record.balance = [];
record.transaction.secondaryDate = null;
record.transaction.postings[0].amounts = [];
expect(projectCashRegisterPage(accountPage([record], { endingBalance: [] }))).toMatchObject({
endingBalance: [],
rows: [{ change: [], balance: [] }]
});
const page = accountPage(records);
const before = structuredClone(page);
const result = projectCashRegisterPage(page);
expect(result.rows.map((record) => record.index)).toEqual([90, 10, 70]);
expect(result.rows.map((record) => record.balance?.quantity)).toEqual(['0', '1', '2']);
expect(page).toEqual(before);
});
it('uses the summary ending balance even when it is off-page', () => {
const result = projectCashRegisterPage(accountPage([row()], { endingBalance: [amount('7.00')] }));
expect(result).toMatchObject({
unsupported: false,
endingBalance: { commodity: 'USD', quantity: '7.00' },
rows: [{ balance: { commodity: 'USD', quantity: '9007199254740993.000000000000000001' } }]
expect(projectCashTransaction(record)).toMatchObject({
secondaryDate: null,
change: [],
balance: [],
lines: [{ amounts: [] }, { amounts: [expect.anything()] }]
});
expect(projectCashRegisterPage(accountPage([]))).toEqual({ endingBalance: [], rows: [] });
});
it.each([{ hasMultipleAmounts: true }, { hasMultipleCommodities: true }])(
'rejects unsupported whole-account activity despite scalar visible rows: %j',
(summary) => {
expect(projectCashRegisterPage(accountPage([row()], summary))).toEqual({
unsupported: true,
endingBalance: null,
rows: []
});
}
);
it('deep-whitelists every DTO field and nested amount without exposing tags or private metadata', () => {
it('deep-whitelists summaries, details and every amount, excluding native private metadata', () => {
const record = row();
record.isSplit = true;
const privateAmount = Object.assign(amount(), {
cost: { kind: 'UnitCost' as const, amount: amount('999', 'PRIVATE-cost') },
costBasis: { amount: amount('888', 'PRIVATE-basis'), date: null, label: 'PRIVATE-label' },
@@ -337,29 +298,13 @@ it('deep-whitelists every DTO field and nested amount without exposing tags or p
digitGroups: null,
precision: null,
rounding: 'PRIVATE-rounding'
},
extraNestedMetadata: { file: '/PRIVATE/amount' }
}
});
record.change = [privateAmount];
record.balance = [privateAmount];
record.investment.securities = [
{
commodity: 'PRIVATE-security',
action: 'unknown',
change: [amount('1', 'PRIVATE-security')],
postingIndexes: [0],
prices: [amount('999', 'PRIVATE-price')],
transactedAmounts: [amount('999', 'PRIVATE-transacted')]
}
];
record.investment.cash.unclassifiedChange = [privateAmount];
record.investment.cash.unclassifiedBalance = [privateAmount];
Object.assign(record, { extraNestedMetadata: { file: '/PRIVATE/row' } });
Object.assign(record.transaction, { extraNestedMetadata: { file: '/PRIVATE/transaction' } });
Object.assign(record.transaction.tags[0], { extraNestedMetadata: '/PRIVATE/tag' });
Object.assign(record.transaction.tags, { extraNestedMetadata: '/PRIVATE/tags' });
record.transaction.tags = [['PRIVATE-transaction-tag', 'PRIVATE-value']];
for (const p of record.transaction.postings) {
p.amounts = [privateAmount];
p.amounts = [privateAmount, privateAmount];
p.transactedAmounts = [amount('999', 'PRIVATE-transacted')];
p.balanceAssertion = {
amount: privateAmount,
@@ -367,13 +312,9 @@ it('deep-whitelists every DTO field and nested amount without exposing tags or p
inclusive: true,
sourcePosition: { file: '/PRIVATE/assertion', line: 1, column: 2 }
};
p.tags = [['PRIVATE-posting-tag', 'PRIVATE-value']];
p.transactionIndex = 'PRIVATE-index';
p.tags = [['posting-tag', 'value']];
Object.assign(p.tags[0], { extraNestedMetadata: '/PRIVATE/posting-tag' });
Object.assign(p.amounts, { extraNestedMetadata: '/PRIVATE/amount-bag' });
Object.assign(p, { extraNestedMetadata: { file: '/PRIVATE/posting' } });
}
// Inject unknown metadata at every source object and array depth, not just DTO roots.
function inject(value: unknown) {
if (!value || typeof value !== 'object') return;
for (const child of Object.values(value)) inject(child);
@@ -382,173 +323,14 @@ it('deep-whitelists every DTO field and nested amount without exposing tags or p
inject(record);
const before = structuredClone(record);
const result = projectCashRegisterPage(accountPage([record], { endingBalance: [privateAmount] }));
expect(JSON.stringify(result)).not.toMatch(
/PRIVATE|sourcePosition|balanceAssertion|cost|style|extraNestedMetadata|transactionIndex|investment|transactedAmounts/
);
expect(result.rows[0].amount).toEqual({ commodity: 'USD', quantity: '-0.000000000000000001' });
expect(result.rows[0].breakdown).not.toHaveProperty('index');
expect(result.rows[0].breakdown).not.toHaveProperty('note');
expect(result.endingBalance).toEqual(result.rows[0].amount);
for (const line of result.rows[0].breakdown!.lines) {
expect(line.amounts).toEqual([result.rows[0].amount]);
}
const keys = (value: object) => Object.keys(value).sort();
expect(keys(result)).toEqual(['endingBalance', 'rows', 'unsupported']);
expect(keys(result.rows[0])).toEqual([
'amount',
'balance',
'breakdown',
'categories',
'date',
'direction',
'index',
'notes',
'payee',
'statuses'
]);
const breakdown = result.rows[0].breakdown!;
expect(keys(breakdown)).toEqual([
'amount',
'date',
'direction',
'internalTransfer',
'lines',
'notes',
'payee'
]);
for (const line of breakdown.lines) {
expect(keys(line)).toEqual(['account', 'amounts', 'kind', 'notes']);
for (const value of line.amounts) expect(keys(value)).toEqual(['commodity', 'quantity']);
}
for (const value of [
result.endingBalance,
result.rows[0].amount,
result.rows[0].balance,
breakdown.amount
]) {
expect(keys(value!)).toEqual(['commodity', 'quantity']);
}
expect(JSON.stringify(result)).not.toMatch(
/tags|privateProbe|secondaryDate|precedingComment|description|effectiveStatus/
const details = projectCashTransaction(record);
expect(JSON.stringify({ result, details })).not.toMatch(
/PRIVATE|sourcePosition|balanceAssertion|cost|style|privateProbe|transactionIndex|investment|transactedAmounts|tags|precedingComment/
);
const publicAmount = { commodity: 'USD', quantity: '-0.000000000000000001' };
expect(result.endingBalance).toEqual([publicAmount]);
expect(result.rows[0].change).toEqual([publicAmount]);
expect(details.balance).toEqual([publicAmount]);
for (const line of details.lines) expect(line.amounts).toEqual([publicAmount, publicAmount]);
expect(record).toEqual(before);
});
it('uses the register date and notes, leaving original dates and posting metadata private', () => {
const record = row();
record.isSplit = true;
Object.assign(record.transaction.postings[0], {
date: '2026-05-03',
secondaryDate: '2026-05-04',
status: 'Pending',
effectiveStatus: 'Pending'
});
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.date).toBe('2026-05-03');
expect(result.statuses).toEqual(['Pending']);
expect(result.breakdown).toMatchObject({
date: result.date,
payee: result.payee,
notes: result.notes,
amount: result.amount,
direction: result.direction
});
expect(JSON.stringify(result.breakdown)).not.toMatch(
/2026-05-0[124]|status|effectiveStatus|secondaryDate|tags|code|description|precedingComment/
);
});
it('does not invent selection or a transaction-level row status when native selection is empty', () => {
const record = row();
record.accountPostingIndexes = [];
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.statuses).toEqual([]);
expect(result.notes).toEqual(['native note', ' transaction note\n']);
expect(result.categories).toEqual(['arbitrary:selected', 'expenses:Full category']);
expect(result.breakdown?.lines.map((line) => line.account)).toEqual(result.categories);
expect(result.breakdown?.internalTransfer).toBe(false);
});
it('keeps a missing summary ending balance rather than inferring it from visible balances', () => {
expect(projectCashRegisterPage(accountPage([row()], { endingBalance: [] }))).toMatchObject({
endingBalance: null,
unsupported: false,
rows: [{ balance: { quantity: '9007199254740993.000000000000000001' } }]
});
});
it('explains a 75/25 split with only two counterpart lines and full selected bank notes', () => {
const record = row();
record.change = [amount('-100')];
record.transaction.postings[0].amounts = [amount('-100')];
record.transaction.postings[1].amounts = [amount('75')];
record.transaction.postings.push(
posting('expenses:second', { amounts: [amount('25')], comment: 'second allocation' })
);
const before = structuredClone(record);
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.breakdown).toEqual({
date: record.accountDate,
payee: 'Native payee',
notes: ['native note', ' transaction note\n', ' bank note\n'],
amount: { quantity: '-100', commodity: 'USD' },
direction: 'payment',
internalTransfer: false,
lines: [
{
account: 'expenses:Full category',
amounts: [{ quantity: '75', commodity: 'USD' }],
notes: 'counterpart comment',
kind: 'RealPosting'
},
{
account: 'expenses:second',
amounts: [{ quantity: '25', commodity: 'USD' }],
notes: 'second allocation',
kind: 'RealPosting'
}
]
});
expect(result.breakdown?.notes).toEqual(result.notes);
expect(record).toEqual(before);
});
it('does not infer a missing account change or line amount from reported counterpart quantities', () => {
const record = row();
record.change = [];
record.transaction.postings.push(
posting('memo:zero', { amounts: [amount('-0.000', '')], kind: 'VirtualPosting' }),
posting('memo:unreported', { amounts: [], kind: 'BalancedVirtualPosting' })
);
record.transaction.postings[1].amounts = [
amount('9007199254740993.123456789012345678', 'SHARES'),
amount('7.00', 'EUR')
];
const result = projectCashRegisterPage(accountPage([record])).rows[0];
expect(result.breakdown).toMatchObject({
amount: null,
direction: 'unreported',
lines: [
{
amounts: [
{ quantity: '9007199254740993.123456789012345678', commodity: 'SHARES' },
{ quantity: '7.00', commodity: 'EUR' }
]
},
{ amounts: [{ quantity: '-0.000', commodity: '' }], kind: 'VirtualPosting' },
{ amounts: [], kind: 'BalancedVirtualPosting' }
]
});
});
it('exposes no transaction or posting tags for an internal transfer', () => {
const record = row();
record.isSplit = true;
record.accountPostingIndexes = [0, 1];
record.transaction.tags = [['PRIVATE-transaction-tag', 'PRIVATE-value']];
for (const posting of record.transaction.postings)
posting.tags = [['PRIVATE-posting-tag', 'PRIVATE-value']];
const result = projectCashRegisterPage(accountPage([record]));
expect(result.rows[0].breakdown?.internalTransfer).toBe(true);
expect(JSON.stringify(result)).not.toMatch(/tags|PRIVATE/);
});
+42 -47
View File
@@ -1,10 +1,10 @@
import type { PreparedAccountRow, PreparedPosting, ReportedAmount } from '@hledger-reports/hledger';
import type { AccountActivityPage } from '@hledger-reports/snapshots';
import type {
CashBreakdown,
CashSplitLine,
CashPostingLine,
CashRegisterData,
CashRow
CashRow,
CashTransactionDetails
} from '#lib/registers/cash/data.js';
export const cashRegisterPageSize = 50;
@@ -14,73 +14,68 @@ function publicAmount({ commodity, quantity }: ReportedAmount): ReportedAmount {
return { commodity, quantity };
}
function direction(amount: ReportedAmount | null): CashRow['direction'] {
if (!amount) return 'unreported';
if (!/[1-9]/.test(amount.quantity)) return 'unchanged';
return amount.quantity.startsWith('-') ? 'payment' : 'deposit';
function selectedPostings(row: PreparedAccountRow): PreparedPosting[] {
// Native qualification owns account membership, not names or otherAccounts.
return row.accountPostingIndexes.map((index) => row.transaction.postings[index]);
}
function publicSplitLine(posting: PreparedPosting): CashSplitLine {
return {
account: posting.account,
amounts: posting.amounts.map(publicAmount),
notes: posting.comment,
kind: posting.kind
};
function selectedStatuses(row: PreparedAccountRow): string[] {
return [...new Set(selectedPostings(row).map((posting) => posting.effectiveStatus))];
}
function projectCashRow(row: PreparedAccountRow): CashRow {
const transaction = row.transaction;
// Native qualification owns account membership, not names or otherAccounts.
const selectedIndexes = new Set(row.accountPostingIndexes);
const selected = row.accountPostingIndexes.map((index) => transaction.postings[index]);
const counterparts = transaction.postings.filter((_, index) => !selectedIndexes.has(index));
const categories = [...new Set(counterparts.map((posting) => posting.account))];
const counterparts = row.transaction.postings.filter((_, index) => !selectedIndexes.has(index));
return {
index: row.transaction.index,
date: row.accountDate,
payee: row.transaction.payee,
categories: [...new Set(counterparts.map((posting) => posting.account))],
statuses: selectedStatuses(row),
change: row.change.map(publicAmount),
balance: row.balance.map(publicAmount)
};
}
export function projectCashTransaction(row: PreparedAccountRow): CashTransactionDetails {
const transaction = row.transaction;
const selectedIndexes = new Set(row.accountPostingIndexes);
// Without a separator, the prepared note repeats the payee rather than adding detail.
const notes = [
transaction.note === transaction.payee ? '' : transaction.note,
transaction.comment,
...selected.map((posting) => posting.comment)
...selectedPostings(row).map((posting) => posting.comment)
].filter((comment) => comment.trim().length > 0);
const statuses = [...new Set(selected.map((posting) => posting.effectiveStatus))];
const amount = row.change[0] ? publicAmount(row.change[0]) : null;
let breakdown: CashBreakdown | null = null;
if (row.isSplit || counterparts.length > 1 || selected.length > 1) {
breakdown = {
date: row.accountDate,
payee: transaction.payee,
notes,
amount,
direction: direction(amount),
internalTransfer: counterparts.length === 0,
// Keep source posting order, including repeated categories and internal movements.
lines: (counterparts.length ? counterparts : transaction.postings).map(publicSplitLine)
};
}
const lines: CashPostingLine[] = transaction.postings.map((posting, index) => ({
account: posting.account,
selected: selectedIndexes.has(index),
amounts: posting.amounts.map(publicAmount),
notes: posting.comment,
kind: posting.kind,
status: posting.effectiveStatus
}));
return {
index: transaction.index,
date: row.accountDate,
transactionDate: transaction.date,
secondaryDate: transaction.secondaryDate,
code: transaction.code,
payee: transaction.payee,
categories,
description: transaction.description,
notes,
amount,
balance: row.balance[0] ? publicAmount(row.balance[0]) : null,
statuses,
direction: direction(amount),
breakdown
statuses: selectedStatuses(row),
change: row.change.map(publicAmount),
balance: row.balance.map(publicAmount),
lines
};
}
export function projectCashRegisterPage({
rows,
summary
}: AccountActivityPage): Pick<CashRegisterData, 'rows' | 'endingBalance' | 'unsupported'> {
if (summary.hasMultipleAmounts || summary.hasMultipleCommodities)
return { rows: [], endingBalance: null, unsupported: true };
const ending = summary.endingBalance[0];
}: AccountActivityPage): Pick<CashRegisterData, 'rows' | 'endingBalance'> {
return {
rows: rows.map(projectCashRow),
endingBalance: ending ? publicAmount(ending) : null,
unsupported: false
endingBalance: summary.endingBalance.map(publicAmount)
};
}
@@ -0,0 +1,146 @@
import type { AccountConfig } from '@hledger-reports/config';
import {
readAccountTransaction,
readReportPublication,
type ReportPublication
} from '@hledger-reports/snapshots';
import { beforeEach, expect, it, vi } from 'vitest';
import {
preparedAccountRows,
preparedOverview
} from '../../../../../../packages/db/src/reports/data.fixture.js';
import { testConfig } from '#lib/server/application.fixture.js';
import { readCashTransaction } from './read-cash.js';
vi.mock('@hledger-reports/snapshots', () => ({
readReportPublication: vi.fn(),
readAccountPage: vi.fn(),
readAccountTransaction: vi.fn()
}));
vi.mock('@hledger-reports/hledger', () => {
throw new Error('Browsing must not load the native runtime.');
});
const accountId = 'abcd1234';
const name = 'assets:cash';
const request = { accountId, transactionIndex: 7 };
const publication: ReportPublication = {
journal: testConfig.journal,
directory: '/PRIVATE/latest-set',
accounts: [{ id: accountId, name, access: 'ready', rowCount: 1, summary: { status: 'undated' } }],
overview: preparedOverview()
};
const row = preparedAccountRows(1, 'Fictional payee', request.transactionIndex)[0];
function locals(
accounts: AccountConfig[] = [{ account: name, register: 'cash' }]
): Pick<App.Locals, 'application' | 'auth' | 'savedReports'> {
return {
application: {
config: { ...testConfig, accounts },
paths: { snapshotDirectory: '/PRIVATE/snapshots', jobsDirectory: '/PRIVATE/jobs' },
get jobsClient(): never {
throw new Error('Browsing must not access jobs.');
},
get auth(): never {
throw new Error('Use request authentication.');
}
},
auth: vi.fn(async () => ({
user: { name: 'Reader' },
expires: '2026-11-07T00:00:00.000Z'
}))
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(readReportPublication).mockResolvedValue(publication);
vi.mocked(readAccountTransaction).mockResolvedValue(row);
});
it('reads the latest saved publication for each request and returns the public projection', async () => {
const result = await readCashTransaction(locals(), request);
expect(readReportPublication).toHaveBeenCalledExactlyOnceWith('/PRIVATE/snapshots');
expect(readAccountTransaction).toHaveBeenCalledExactlyOnceWith(publication, accountId, 7);
expect(result).toMatchObject({
index: 7,
date: row.accountDate,
change: [{ commodity: '$', quantity: '-100.00' }]
});
expect(result.balance).toEqual([{ commodity: '$', quantity: '9007199254740993.123456789' }]);
expect(JSON.stringify(result)).not.toMatch(/PRIVATE|sourcePosition|balanceAssertion|directory/);
const latest = { ...publication, directory: '/PRIVATE/new-latest-set' };
vi.mocked(readReportPublication).mockResolvedValue(latest);
await readCashTransaction(locals(), request);
expect(readAccountTransaction).toHaveBeenLastCalledWith(latest, accountId, 7);
expect(readReportPublication).toHaveBeenCalledTimes(2);
});
it('requires configuration and a session before accessing saved reports', async () => {
const unconfigured = locals();
unconfigured.application = null;
await expect(readCashTransaction(unconfigured, request)).rejects.toMatchObject({ status: 503 });
expect(unconfigured.auth).not.toHaveBeenCalled();
const anonymous = locals();
vi.mocked(anonymous.auth).mockResolvedValue(null);
await expect(readCashTransaction(anonymous, request)).rejects.toMatchObject({ status: 401 });
expect(readReportPublication).not.toHaveBeenCalled();
expect(readAccountTransaction).not.toHaveBeenCalled();
});
it('rejects accounts outside the configured cash register and native-ready catalog', async () => {
await expect(
readCashTransaction(locals(), { ...request, accountId: 'unknown1' })
).rejects.toMatchObject({ status: 404 });
await expect(
readCashTransaction(
locals([
{ account: name, register: 'investment' },
{ account: 'assets:other', register: 'cash' }
]),
request
)
).rejects.toMatchObject({ status: 404 });
vi.mocked(readReportPublication).mockResolvedValue({
...publication,
accounts: [
{ id: accountId, name, access: 'ambiguous', rowCount: null, summary: { status: 'ambiguous' } }
]
});
await expect(readCashTransaction(locals(), request)).rejects.toMatchObject({ status: 404 });
expect(readAccountTransaction).not.toHaveBeenCalled();
});
it('returns not found for an index without activity in the selected account', async () => {
vi.mocked(readAccountTransaction).mockResolvedValue(null);
await expect(
readCashTransaction(locals(), { ...request, transactionIndex: 999 })
).rejects.toMatchObject({ status: 404 });
expect(readAccountTransaction).toHaveBeenCalledExactlyOnceWith(publication, accountId, 999);
});
it('fails safely when the saved publication is missing or belongs to another journal', async () => {
vi.mocked(readReportPublication).mockResolvedValue(null);
await expect(readCashTransaction(locals(), request)).rejects.toMatchObject({ status: 503 });
vi.mocked(readReportPublication).mockResolvedValue({ ...publication, journal: '/PRIVATE/other' });
await expect(readCashTransaction(locals(), request)).rejects.toMatchObject({ status: 503 });
expect(readAccountTransaction).not.toHaveBeenCalled();
});
it('sanitizes saved-read failures without exposing private diagnostics', async () => {
const failure = new Error('PRIVATE /books/included.journal:12 database diagnostic');
vi.mocked(readReportPublication).mockRejectedValue(failure);
await expect(readCashTransaction(locals(), request)).rejects.toMatchObject({
status: 503,
body: { message: 'Prepared reports are unavailable.' }
});
vi.mocked(readReportPublication).mockResolvedValue(publication);
vi.mocked(readAccountTransaction).mockRejectedValue(failure);
await expect(readCashTransaction(locals(), request)).rejects.toMatchObject({
status: 503,
body: { message: 'Prepared transaction could not be read.' }
});
});
@@ -0,0 +1,104 @@
import type { PreparedAccountRow } from '@hledger-reports/hledger';
import {
readAccountPage,
readAccountTransaction,
type ReportPublication,
type StoredAccount
} from '@hledger-reports/snapshots';
import { error } from '@sveltejs/kit';
import type {
CashRegisterData,
CashTransactionDetails,
CashTransactionRequest
} from '#lib/registers/cash/data.js';
import { readSavedReports } from '#lib/server/reports.js';
import { registerMembers } from './accounts.js';
import { cashRegisterPageSize, projectCashRegisterPage, projectCashTransaction } from './cash.js';
type CashReadLocals = Pick<App.Locals, 'application' | 'auth' | 'savedReports'>;
type CashAccountState =
| { status: 'unavailable'; readFailed: boolean }
| { status: 'available'; publication: ReportPublication; account: StoredAccount };
type CashActivityData = Pick<
CashRegisterData,
'rowCount' | 'page' | 'pageCount' | 'rows' | 'endingBalance' | 'readFailed'
>;
async function requireCashRegisterAccess(locals: CashReadLocals): Promise<string[]> {
const { application } = locals;
if (!application) error(503, 'Application configuration is required.');
if (!(await locals.auth())) error(401, 'Sign in to continue.');
const members = registerMembers(application.config.accounts, 'cash');
if (members.length === 0) error(404, 'Register not configured.');
return members;
}
// Page and detail reads share account membership and the request's latest selection.
export async function readCashAccount(
locals: CashReadLocals,
members: readonly string[],
accountId: string
): Promise<CashAccountState> {
const { publication, readFailed } = await readSavedReports(locals);
if (readFailed || !publication) return { status: 'unavailable', readFailed };
const account = publication.accounts.find(
(entry) => entry.id === accountId && members.includes(entry.name)
);
if (!account) error(404, 'Account not found in the prepared reports.');
return { status: 'available', publication, account };
}
export async function readCashActivityPage(
publication: ReportPublication,
accountId: string,
{
rowCount,
order,
page
}: { rowCount: number; order: CashRegisterData['order']; page: number | null }
): Promise<CashActivityData> {
const pageCount = Math.max(1, Math.ceil(rowCount / cashRegisterPageSize));
const selectedPage = page ?? (order === 'asc' ? pageCount : 1);
if (selectedPage > pageCount) error(404, 'Page not found in the prepared report.');
const activity: CashActivityData = {
rowCount,
page: selectedPage,
pageCount,
rows: [],
endingBalance: null,
readFailed: false
};
// A declared empty account needs no invented amounts or body read.
if (rowCount === 0) return { ...activity, endingBalance: [] };
try {
const accountPage = await readAccountPage(publication, accountId, {
order,
offset: (selectedPage - 1) * cashRegisterPageSize,
limit: cashRegisterPageSize
});
if (accountPage === null) throw new Error('Prepared account rows unavailable.');
return { ...activity, ...projectCashRegisterPage(accountPage) };
} catch {
// Saved-read failures never trigger preparation or repair.
return { ...activity, readFailed: true };
}
}
export async function readCashTransaction(
locals: CashReadLocals,
{ accountId, transactionIndex }: CashTransactionRequest
): Promise<CashTransactionDetails> {
const members = await requireCashRegisterAccess(locals);
const state = await readCashAccount(locals, members, accountId);
if (state.status === 'unavailable') error(503, 'Prepared reports are unavailable.');
if (state.account.access !== 'ready') error(404, 'Account not found in the prepared reports.');
let row: PreparedAccountRow | null;
try {
row = await readAccountTransaction(state.publication, accountId, transactionIndex);
} catch {
error(503, 'Prepared transaction could not be read.');
}
if (!row) error(404, 'Transaction not found in the prepared reports.');
return projectCashTransaction(row);
}
@@ -193,7 +193,7 @@ it('pins selected pages and account summaries until the next request', async ()
params: { accountId: cashId },
parent: async () => layoutA,
setHeaders: () => {},
url: new URL(`http://localhost/registers/cash/${cashId}`)
url: new URL(`http://localhost/registers/cash/${cashId}?order=asc`)
} as unknown as Parameters<typeof loadCashAccount>[0]);
expect(cashPageA).toMatchObject({
preparedAt: overview.preparedAt,
@@ -205,11 +205,10 @@ it('pins selected pages and account summaries until the next request', async ()
{
index: 1,
payee: 'Market A',
balance: { commodity: '$', quantity: '9007199254740993.123456789' }
balance: [{ commodity: '$', quantity: '9007199254740993.123456789' }]
}
],
endingBalance: { commodity: '$', quantity: '8007199254740993.000000001' },
unsupported: false
endingBalance: [{ commodity: '$', quantity: '8007199254740993.000000001' }]
});
const investmentPageA = await loadInvestmentAccount({
locals,
@@ -258,14 +257,19 @@ it('pins selected pages and account summaries until the next request', async ()
params: { accountId: cashId },
parent: async () => layoutB,
setHeaders: () => {},
url: new URL(`http://localhost/registers/cash/${cashId}`)
url: new URL(`http://localhost/registers/cash/${cashId}?order=asc`)
} as unknown as Parameters<typeof loadCashAccount>[0]);
expect(cashPageB).toMatchObject({
preparedAt: preparedB,
readFailed: false,
rows: [],
endingBalance: null,
unsupported: true
rows: [
{
index: 1,
payee: 'Market B',
balance: [{ commodity: '$', quantity: '9007199254740994.123456789' }]
}
],
endingBalance: [{ commodity: '$', quantity: '8007199254740994.000000002' }]
});
const investmentPageB = await loadInvestmentAccount({
locals: freshLocals,
+2
View File
@@ -5,6 +5,7 @@
--color-text: #202d2a;
--color-muted: #50615b;
--color-accent: #225e4b;
--color-negative: #b42318;
--color-border: #cbd4ce;
--color-border-strong: var(--color-muted);
--color-on-accent: #ffffff;
@@ -49,6 +50,7 @@
--color-text: #edf5ee;
--color-muted: #acbfb1;
--color-accent: #8adbb4;
--color-negative: #fda29b;
--color-border: #3a5343;
--color-on-accent: #0b1310;
--color-chart-expense: #fdba74;
@@ -1,9 +1,7 @@
import { registerMembers } from '#lib/server/registers/accounts.js';
import { readAccountPage } from '@hledger-reports/snapshots';
import { error } from '@sveltejs/kit';
import type { CashRegisterData } from '#lib/registers/cash/data.js';
import { readSavedReports } from '#lib/server/reports.js';
import { cashRegisterPageSize, projectCashRegisterPage } from '#lib/server/registers/cash.js';
import { readCashAccount, readCashActivityPage } from '#lib/server/registers/read-cash.js';
import type { PageServerLoad } from './$types';
function parseRequest(
@@ -15,7 +13,7 @@ function parseRequest(
if (key !== 'order' && key !== 'page') error(400, 'Unsupported register parameter.');
}
const orders = parameters.getAll('order');
const order = orders[0] ?? 'asc';
const order = orders[0] ?? 'desc';
if (orders.length > 1 || (order !== 'asc' && order !== 'desc'))
error(400, 'A valid date order is required.');
const pages = parameters.getAll('page');
@@ -32,7 +30,7 @@ export const load = (async (event): Promise<CashRegisterData> => {
event.setHeaders({ 'cache-control': 'no-store' });
const { accountId, order, page } = parseRequest(event.params.accountId, event.url.searchParams);
const { application } = event.locals;
const data: CashRegisterData = {
const unavailable: CashRegisterData = {
configured: false,
readFailed: false,
accountId,
@@ -44,44 +42,30 @@ export const load = (async (event): Promise<CashRegisterData> => {
page: page ?? 1,
pageCount: null,
order,
endingBalance: null,
unsupported: false
endingBalance: null
};
if (!application) return data;
if (!application) return unavailable;
const members = registerMembers(application.config.accounts, 'cash');
if (members.length === 0) error(404, 'Register not configured.');
await event.parent();
const state = await readSavedReports(event.locals);
data.configured = true;
data.readFailed = state.readFailed;
// Pin one immutable publication; its journal and directory remain server-only.
const publication = state.publication;
if (!publication) return data;
const selected = publication.accounts.find((entry) => entry.id === accountId);
if (!selected || !members.includes(selected.name))
error(404, 'Account not found in the prepared reports.');
data.accountName = selected.name;
data.access = selected.access;
data.preparedAt = publication.overview.preparedAt;
if (selected.access === 'ambiguous') return data;
const rowCount = selected.rowCount!;
data.rowCount = rowCount;
data.pageCount = Math.max(1, Math.ceil(rowCount / cashRegisterPageSize));
data.page = page ?? (order === 'asc' ? data.pageCount : 1);
if (data.page > data.pageCount) error(404, 'Page not found in the prepared report.');
// A declared empty account needs no invented amounts or body read.
if (rowCount === 0) return data;
try {
const accountPage = await readAccountPage(publication, accountId, {
order,
offset: (data.page - 1) * cashRegisterPageSize,
limit: cashRegisterPageSize
});
if (accountPage === null) throw new Error('Prepared account rows unavailable.');
Object.assign(data, projectCashRegisterPage(accountPage));
} catch {
// Malformed/unavailable saved bodies are not a reason to run or repair reports.
data.readFailed = true;
}
return data;
const state = await readCashAccount(event.locals, members, accountId);
if (state.status === 'unavailable')
return { ...unavailable, configured: true, readFailed: state.readFailed };
const { publication, account } = state;
const available: CashRegisterData = {
...unavailable,
configured: true,
accountName: account.name,
access: account.access,
preparedAt: publication.overview.preparedAt
};
if (account.access === 'ambiguous') return available;
const activity = await readCashActivityPage(publication, accountId, {
rowCount: account.rowCount!,
order,
page
});
return { ...available, ...activity };
}) satisfies PageServerLoad;
@@ -2,12 +2,19 @@
import { resolve } from '$app/paths';
import CashRegister from '#lib/registers/cash/CashRegister.svelte';
import { accountLabel } from '#lib/accounts.js';
import { formatAmount } from '#lib/amounts.js';
import { isNegativeAmount } from '#lib/registers/cash/amount-sign.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let displayName = $derived(
data.accountName === null ? 'Account' : accountLabel(data.accountName, data.accountLabels)
let showBalanceUnits = $derived(
new Set(data.endingBalance?.map((amount) => amount.commodity)).size > 1
);
let displayName = $derived.by(() => {
if (data.accountName === null) return 'Account';
const label = accountLabel(data.accountName, data.accountLabels);
return label === data.accountName ? (label.split(':').at(-1) ?? label) : label;
});
</script>
<svelte:head>
@@ -15,11 +22,24 @@
<meta name="description" content="Prepared account transactions." />
</svelte:head>
<main class="page page--wide" id="main" tabindex="-1">
<main class="page page--wide cash-page" id="main" tabindex="-1">
<header>
<h1 class="page-title">{displayName}</h1>
{#if data.accountName !== null && displayName !== data.accountName}
<p>{data.accountName}</p>
{#if data.configured && !data.readFailed && data.access === 'ready'}
<div class="ending-balance" aria-label="Ending recorded balance">
<span class="balance-label">Ending recorded balance</span>
{#if data.endingBalance?.length}
{#each data.endingBalance as amount, index (index)}
{@const formatted = formatAmount(amount)}
{@const unit = amount.commodity || 'no commodity'}
<strong class:negative={isNegativeAmount(amount)}
>{formatted}{#if showBalanceUnits && !formatted.includes(unit)}<small>
{unit}</small
>{/if}</strong
>
{/each}
{:else}<span>Not reported</span>{/if}
</div>
{/if}
</header>
{#if !data.configured}
@@ -36,33 +56,47 @@
href={resolve('/(protected)/settings')}>Settings</a
>.
</p>
{:else if data.access === 'ambiguous'}
<p>
This account cannot be addressed uniquely by the native account selector. No account amounts
are shown.
</p>
{:else if data.access === 'ready'}
<CashRegister {data} labels={data.accountLabels} />
{:else}
{#if data.preparedAt}<p>
Prepared at <time datetime={data.preparedAt}>{data.preparedAt}</time>.
</p>{/if}
{#if data.access === 'ambiguous'}
<p>
This account cannot be addressed uniquely by the native account selector. No account amounts
are shown.
</p>
{:else if data.access === 'ready'}
<CashRegister {data} labels={data.accountLabels} />
{:else}
<p>
No prepared reports available. Prepare reports in <a href={resolve('/(protected)/settings')}
>Settings</a
>.
</p>
{/if}
<p>
No prepared reports available. Prepare reports in <a href={resolve('/(protected)/settings')}
>Settings</a
>.
</p>
{/if}
</main>
<style>
.cash-page {
padding-block: var(--space-medium);
gap: var(--space-small);
}
header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-small);
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-small) var(--space-medium);
overflow-wrap: anywhere;
}
.ending-balance {
display: flex;
flex-direction: column;
align-items: flex-end;
font-variant-numeric: tabular-nums;
margin-inline-start: auto;
}
.negative {
color: var(--color-negative);
}
.balance-label {
font-size: var(--font-small);
color: var(--color-muted);
}
</style>
@@ -14,7 +14,8 @@ import { load } from './+page.server.js';
vi.mock('@hledger-reports/snapshots', () => ({
readReportPublication: vi.fn(),
readAccountPage: vi.fn()
readAccountPage: vi.fn(),
readAccountTransaction: vi.fn()
}));
vi.mock('#lib/server/registers/cash.js', () => ({
cashRegisterPageSize: 50,
@@ -52,8 +53,7 @@ const accountPage: AccountActivityPage = {
};
const projection: ReturnType<typeof projectCashRegisterPage> = {
rows: [],
endingBalance: { commodity: 'USD', quantity: '1.0001' },
unsupported: false
endingBalance: [{ commodity: 'USD', quantity: '1.0001' }]
};
function event(query = '', accounts: AccountConfig[] = [{ account: name, register: 'cash' }]) {
const application: ApplicationContext = {
@@ -117,6 +117,41 @@ it('loads a member from one pinned publication and delegates its requested page'
expect(projectCashRegisterPage).toHaveBeenCalledExactlyOnceWith(accountPage);
expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
});
it('returns an unconfigured page without reading reports or parent data', async () => {
const request = event();
request.locals.application = null;
expect(await load(request)).toMatchObject({
configured: false,
readFailed: false,
accountName: null,
rows: [],
endingBalance: null
});
expect(request.parent).not.toHaveBeenCalled();
expect(readReportPublication).not.toHaveBeenCalled();
expect(readAccountPage).not.toHaveBeenCalled();
});
it('distinguishes missing reports from failed reads without accessing activity', async () => {
vi.mocked(readReportPublication).mockResolvedValue(null);
expect(await load(event())).toMatchObject({
configured: true,
readFailed: false,
accountName: null,
rows: [],
endingBalance: null
});
vi.mocked(readReportPublication).mockRejectedValue(new Error('PRIVATE storage diagnostic'));
const failed = await load(event());
expect(failed).toMatchObject({
configured: true,
readFailed: true,
rows: [],
endingBalance: null
});
expect(JSON.stringify(failed)).not.toContain('PRIVATE');
expect(readAccountPage).not.toHaveBeenCalled();
expect(projectCashRegisterPage).not.toHaveBeenCalled();
});
it('returns a safe read failure when the saved body cannot be read', async () => {
vi.mocked(readAccountPage).mockRejectedValue(
new Error('PRIVATE /books/included.journal:12 database diagnostic')
@@ -133,24 +168,23 @@ it('returns a safe read failure when the saved body cannot be read', async () =>
preparedAt: publication.overview.preparedAt,
rowCount: 51,
pageCount: 2,
page: 2,
order: 'asc',
page: 1,
order: 'desc',
rows: [],
endingBalance: null,
unsupported: false
endingBalance: null
});
expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, {
order: 'asc',
offset: 50,
order: 'desc',
offset: 0,
limit: 50
});
expect(projectCashRegisterPage).not.toHaveBeenCalled();
expect(queue).not.toHaveBeenCalled();
});
it('defaults descending navigation to the first page', async () => {
it('opens the newest recorded activity on the first descending page by default', async () => {
const firstPage = { ...accountPage, rows: preparedAccountRows(50) };
vi.mocked(readAccountPage).mockResolvedValue(firstPage);
await load(event('order=desc'));
await load(event());
expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, {
order: 'desc',
offset: 0,
@@ -158,8 +192,8 @@ it('defaults descending navigation to the first page', async () => {
});
expect(projectCashRegisterPage).toHaveBeenCalledExactlyOnceWith(firstPage);
});
it('defaults ascending navigation to the last page', async () => {
await load(event());
it('opens the last page when ascending order is requested', async () => {
await load(event('order=asc'));
expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, {
order: 'asc',
offset: 50,
@@ -195,7 +229,7 @@ it('does not read or project ambiguous or empty account activity', async () => {
rowCount: 0,
page: 1,
pageCount: 1,
endingBalance: null
endingBalance: []
});
expect(readAccountPage).not.toHaveBeenCalled();
expect(projectCashRegisterPage).not.toHaveBeenCalled();
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig(({ command }) => {
sveltekit({
adapter: adapter(),
preprocess: vitePreprocess(),
experimental: { remoteFunctions: true },
compilerOptions: {
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
+2 -1
View File
@@ -15,7 +15,8 @@ export {
writeAccountActivitySummaries,
writeReportData,
readReportData,
readAccountActivityPage
readAccountActivityPage,
readAccountActivityDetail
} from './queries.js';
export type { AccountActivityPage, AccountActivityPageOptions } from './queries.js';
export { AccountActivitySummaryBuilder } from './account-activity-summary.js';
+28
View File
@@ -12,6 +12,7 @@ import {
} from '../index.js';
import {
readAccountActivityPage,
readAccountActivityDetail,
readReportData,
writeAccountRows,
writeAccountActivitySummaries,
@@ -184,6 +185,25 @@ it('stores exact prepared payloads and native row order using the migrated schem
summary: summaries.get('assets:Cash')
});
expect(await readAccountActivityPage(reader, data.accounts[3], firstPage)).toBeNull();
expect(await readAccountActivityDetail(reader, data.accounts[1], rich.transaction.index)).toEqual(
rich
);
expect(
await readAccountActivityDetail(reader, data.accounts[1], nativeEnding.transaction.index)
).toEqual(nativeEnding);
expect(
await readAccountActivityDetail(reader, data.accounts[2], rich.transaction.index)
).toBeNull();
expect(
await readAccountActivityDetail(reader, data.accounts[1], otherRows[0].transaction.index)
).toBeNull();
expect(await readAccountActivityDetail(reader, data.accounts[1], 9999)).toBeNull();
expect(
await readAccountActivityDetail(reader, data.accounts[0], rich.transaction.index)
).toBeNull();
expect(
await readAccountActivityDetail(reader, data.accounts[3], rich.transaction.index)
).toBeNull();
await expect(
writeAccountRows(reader, 'assets:cash', rows.length, rows.slice(0, 1))
).rejects.toThrow();
@@ -244,6 +264,8 @@ it('shares one canonical transaction without embedding its body in account conte
const page = { order: 'asc', offset: 0, limit: 50 } as const;
expect((await readAccountActivityPage(database, account, page))?.rows).toEqual(rows);
expect((await readAccountActivityPage(database, other, page))?.rows).toEqual(otherRows);
expect(await readAccountActivityDetail(database, account, 1)).toEqual(rows[0]);
expect(await readAccountActivityDetail(database, other, 1)).toEqual(otherRows[0]);
// An account has one activity row per canonical transaction, not one per posting.
await expect(writeAccountRows(database, account.name, 1, rows)).rejects.toThrow();
@@ -315,6 +337,8 @@ it('reads date-ordered pages with native ties and exact account scope', async ()
await readAccountActivityPage(database, other, { order: 'asc', offset: 0, limit: 50 })
).toEqual({ rows: otherRows, summary: preparedActivitySummary(otherRows) });
const mismatchedAccount = { ...other, id: account.id };
await expect(readAccountActivityDetail(database, mismatchedAccount, 1)).rejects.toThrow();
await expect(readAccountActivityDetail(database, account, 0)).rejects.toThrow();
await expect(
readAccountActivityPage(database, mismatchedAccount, { order: 'asc', offset: 0, limit: 50 })
).rejects.toThrow();
@@ -330,6 +354,7 @@ it('reads date-ordered pages with native ties and exact account scope', async ()
.where('ordinal', '=', 0)
.execute();
await expect(readPage('desc', 50)).rejects.toThrow();
expect(await readAccountActivityDetail(database, account, rows[0].transaction.index)).toBeNull();
});
it('decodes only the requested page and rejects invalid posting references', async () => {
@@ -431,6 +456,9 @@ it('rejects selected missing or invalid canonical exports without decoding off-p
readAccountActivityPage(reader, account, { order: 'asc', offset, limit: 1 });
// The orphan still occupies its ordinal; a join must not shift later rows into the page.
expect(await readRow(3)).toEqual({ rows: rows.slice(3), summary: preparedActivitySummary(rows) });
expect(await readAccountActivityDetail(reader, account, 4)).toEqual(rows[3]);
await expect(readAccountActivityDetail(reader, account, 1)).rejects.toThrow();
await expect(readAccountActivityDetail(reader, account, 2)).rejects.toThrow();
await expect(readRow(0)).rejects.toThrow();
await expect(readRow(1)).rejects.toThrow();
await expect(readRow(2)).rejects.toThrow();
+63 -13
View File
@@ -219,6 +219,68 @@ function decodeActivitySummary(
};
}
async function assertStoredAccount(
database: Kysely<ReportDatabase>,
account: StoredAccount
): Promise<void> {
const stored = await database
.selectFrom('accounts')
.select(['id', 'name', 'access', 'row_count'])
.where('id', '=', account.id)
.executeTakeFirst();
if (
!stored ||
stored.id !== account.id ||
stored.name !== account.name ||
stored.access !== account.access ||
stored.row_count !== account.rowCount
)
throw new Error('Invalid account data.');
}
export async function readAccountActivityDetail(
database: Kysely<ReportDatabase>,
account: StoredAccount,
nativeIndex: number
): Promise<PreparedAccountRow | null> {
if (!Number.isSafeInteger(nativeIndex) || nativeIndex <= 0)
throw new Error('Invalid account transaction.');
await assertStoredAccount(database, account);
if (account.access === 'ambiguous') {
if (account.rowCount !== null) throw new Error('Invalid account data.');
return null;
}
const rowCount = account.rowCount;
if (
account.access !== 'ready' ||
rowCount === null ||
!Number.isSafeInteger(rowCount) ||
rowCount < 0
)
throw new Error('Invalid account data.');
const record = await database
.selectFrom('account_activity')
.leftJoin(
'transactions',
'transactions.native_index',
'account_activity.native_transaction_index'
)
.select([
'account_activity.ordinal',
'account_activity.account_date',
'account_activity.native_transaction_index',
'account_activity.context_json',
'transactions.native_index as canonical_native_index',
'transactions.original_json',
'transactions.transacted_json'
])
.where('account_activity.account_name', '=', account.name)
.where('account_activity.native_transaction_index', '=', nativeIndex)
.limit(1)
.executeTakeFirst();
return record ? decodeAccountRow(record, rowCount) : null;
}
export async function readAccountActivityPage(
database: Kysely<ReportDatabase>,
account: StoredAccount,
@@ -233,19 +295,7 @@ export async function readAccountActivityPage(
limit > maxPageRows
)
throw new Error('Invalid account page.');
const stored = await database
.selectFrom('accounts')
.select(['id', 'name', 'access', 'row_count'])
.where('id', '=', account.id)
.executeTakeFirst();
if (
!stored ||
stored.id !== account.id ||
stored.name !== account.name ||
stored.access !== account.access ||
stored.row_count !== account.rowCount
)
throw new Error('Invalid account data.');
await assertStoredAccount(database, account);
const storedSummary = await database
.selectFrom('account_activity_summaries')
.selectAll()
+2 -1
View File
@@ -2,7 +2,8 @@ export {
createReportStage,
readOverview,
readReportPublication,
readAccountPage
readAccountPage,
readAccountTransaction
} from './reports/publication.js';
export type { ReportPublication, ReportStage } from './reports/publication.js';
export type {
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import {
createReportStage,
readAccountPage,
readAccountTransaction,
readOverview,
readReportPublication
} from '../index.js';
@@ -187,6 +188,8 @@ it('commits and closes a complete private database before making its directory v
unclassifiedEndingCash: []
});
expect(secondPage?.summary).toEqual(firstPage?.summary);
expect(await readAccountTransaction(publication, account.id, 52)).toEqual(transactions[51]);
expect(await readAccountTransaction(publication, account.id, 9999)).toBeNull();
expect(await fs.readdir(publication.directory)).toEqual(['reports.sqlite']);
expect(await fs.readlink(join(snapshots, 'current'))).toBe(
`.sets/${basename(publication.directory)}`
@@ -311,6 +314,7 @@ it('distinguishes empty-ready accounts from ambiguous access without requiring e
}
: null
);
expect(await readAccountTransaction(publication, account.id, 1)).toBeNull();
}
});
@@ -676,6 +680,8 @@ it('closes read-only connections on success and on catalog or overview validatio
});
expect(page?.rows).toEqual(rows());
expect(connectionClosures.shift()).toHaveBeenCalledOnce();
expect(await readAccountTransaction(publication, account.id, 1)).toEqual(rows()[0]);
expect(connectionClosures.shift()).toHaveBeenCalledOnce();
const mismatched = {
...publication,
accounts: [{ ...account, name: 'not the persisted account' }]
@@ -684,6 +690,17 @@ it('closes read-only connections on success and on catalog or overview validatio
readAccountPage(mismatched, account.id, { order: 'asc', offset: 0, limit: 50 })
).rejects.toSatisfy(safeError('Unable to read account transactions'));
expect(connectionClosures.shift()).toHaveBeenCalledOnce();
await expect(readAccountTransaction(mismatched, account.id, 1)).rejects.toSatisfy(
safeError('Unable to read account transactions')
);
expect(connectionClosures.shift()).toHaveBeenCalledOnce();
vi.spyOn(reports, 'readAccountActivityDetail').mockRejectedValueOnce(
new Error(`${publication.directory}: private decoding failure`)
);
await expect(readAccountTransaction(publication, account.id, 1)).rejects.toSatisfy(
safeError('Unable to read account transactions')
);
expect(connectionClosures.shift()).toHaveBeenCalledOnce();
const overview = fixture();
const incomplete = {
...overview,
@@ -715,6 +732,18 @@ it('rejects unsafe and uncatalogued account IDs before opening financial data',
await expect(
readAccountPage(publication, '00000000', { order: 'asc', offset: 0, limit: 50 })
).rejects.toSatisfy(safeError('Unable to read account transactions'));
await expect(readAccountTransaction(publication, '../escape', 1)).rejects.toSatisfy(
safeError('Unable to read account transactions')
);
await expect(readAccountTransaction(publication, '00000000', 1)).rejects.toSatisfy(
safeError('Unable to read account transactions')
);
await expect(readAccountTransaction(publication, 'cash0001', 0)).rejects.toSatisfy(
safeError('Unable to read account transactions')
);
await expect(readAccountTransaction(publication, 'cash0001', 1)).rejects.toSatisfy(
safeError('Unable to read account transactions')
);
expect(reader).not.toHaveBeenCalled();
});
@@ -802,6 +831,9 @@ it('rejects a symlink in place of the selected completed directory', async () =>
limit: 50
})
).rejects.toSatisfy(safeError('Unable to read account transactions'));
await expect(
readAccountTransaction(publication, publication.accounts[0].id, 1)
).rejects.toSatisfy(safeError('Unable to read account transactions'));
});
it('requires a journal identity before replacing a publication', async () => {
+37 -7
View File
@@ -19,6 +19,7 @@ import {
isAccountId,
isAccountSummary,
readAccountActivityPage,
readAccountActivityDetail,
readReportData,
storedSummary,
summaryMatchesDate,
@@ -115,6 +116,20 @@ export async function readOverview(snapshotDirectory: string): Promise<OverviewD
return (await readReportPublication(snapshotDirectory))?.overview ?? null;
}
function isCompletedSetDirectory(directory: string): boolean {
return setPattern.test(basename(directory)) && basename(dirname(directory)) === '.sets';
}
async function openPublicationDatabase(publication: ReportPublication): Promise<ReportConnection> {
const directory = publication.directory;
if (!isCompletedSetDirectory(directory)) throw new Error('Invalid publication directory.');
await privateDirectory(dirname(directory));
await privateDirectory(directory);
const database = await openReadOnlyReportDatabase(join(directory, databaseName));
if (!database) throw new Error('Publication database is unavailable.');
return database;
}
export async function readAccountPage(
publication: ReportPublication,
id: string,
@@ -124,13 +139,7 @@ export async function readAccountPage(
if (!isAccountId(id)) throw new Error();
const account = publication.accounts.find((entry) => entry.id === id);
if (!account) throw new Error();
const directory = publication.directory;
if (!setPattern.test(basename(directory)) || basename(dirname(directory)) !== '.sets')
throw new Error();
await privateDirectory(dirname(directory));
await privateDirectory(directory);
const database = await openReadOnlyReportDatabase(join(directory, databaseName));
if (!database) throw new Error();
const database = await openPublicationDatabase(publication);
try {
return await readAccountActivityPage(database, account, options);
} finally {
@@ -141,6 +150,27 @@ export async function readAccountPage(
}
}
export async function readAccountTransaction(
publication: ReportPublication,
id: string,
nativeIndex: number
): Promise<PreparedAccountRow | null> {
try {
if (!isAccountId(id) || !Number.isSafeInteger(nativeIndex) || nativeIndex <= 0)
throw new Error();
const account = publication.accounts.find((entry) => entry.id === id);
if (!account) throw new Error();
const database = await openPublicationDatabase(publication);
try {
return await readAccountActivityDetail(database, account, nativeIndex);
} finally {
await database.destroy();
}
} catch {
throw new Error('Unable to read account transactions');
}
}
// Inspection alias only. Reselect after replacing it so a delayed older execution
// repairs its own regression if another set completed while it was updating.
async function updateCurrent(snapshotDirectory: string): Promise<void> {