Focus cash registers on activity and transaction details #55
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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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('-');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' };
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user