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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion backend/e2e/canonical-capability-behavior.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { uploadBookkeepingEvidence } = require("./helpers/bookkeeping-evidence");
const { syntheticStatementPdf } = require('./helpers/bookkeeping-statement');
const { test, expect } = require('@playwright/test');
const { recordCapabilityEvidence } = require('./helpers/capability-evidence');
const {
Expand Down Expand Up @@ -755,6 +756,7 @@ test.describe('canonical frontend capability behavior', () => {
await page.getByRole('button', { name: 'Upload PDF' }).click();
await expect(page.locator('[data-bookkeeping-status]')).toContainText('Choose a PDF first');
for (const [index] of accounts.entries()) {
const bank = ['finom', 'revolut'][index];
await page.getByLabel('Document type').selectOption('bank-statement');
const accountSelect = page.locator('[data-account]');
const renderedAccountId = await accountSelect.locator('option').nth(index + 1).getAttribute('value');
Expand All @@ -763,9 +765,29 @@ test.describe('canonical frontend capability behavior', () => {
await page.getByLabel('Statement month').fill('2026-08');
await page.locator('[data-pdf]').setInputFiles({
name: `synthetic-statement-${index + 1}.pdf`, mimeType: 'application/pdf',
buffer: Buffer.from(`%PDF-1.4\nsynthetic statement ${index + 1}\n%%EOF`),
buffer: await syntheticStatementPdf(bank),
});
// The configured business-* account is document metadata, not a bank
// enum. The import must recognize the issuer from the actual PDF bytes.
const previewResponse = page.waitForResponse((response) =>
response.url().endsWith('/api/reconcile/statements/import') && response.request().postDataJSON().preview === true);
const committedResponse = page.waitForResponse((response) =>
response.url().endsWith('/api/reconcile/statements/import') && response.status() === 201);
await page.getByRole('button', { name: 'Upload PDF' }).click();
const preview = await previewResponse;
expect(preview.status()).toBe(200);
expect(preview.request().postDataJSON().account).toBeUndefined();
expect(preview.request().postDataJSON().period).toBe('2026-08');
expect(await json(preview)).toMatchObject({
account: bank, committed: false, rowCount: 1, issueDate: '2026-09-04',
preview: { requiresConfirmation: false, candidateIssueDate: '2026-09-04' },
});
const committed = await committedResponse;
expect(committed.request().postDataJSON().period).toBe('2026-08');
expect(await json(committed)).toMatchObject({
account: bank, committed: true,
statements: [{ period: '2026-08', from: '2026-08-01', to: '2026-08-31', rowCount: 1 }],
});
await expect(page.locator('[data-bookkeeping-status]')).toContainText('PDF uploaded and verified');
await expect(page.locator('.bookkeeping-documents article')).toHaveCount(index + 2);
}
Expand Down
44 changes: 44 additions & 0 deletions backend/e2e/helpers/bookkeeping-statement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const { PDFDocument, StandardFonts } = require('pdf-lib');

// Invented statements with real text layers: exercise byte recognition rather
// than bypassing the supported bank layouts with a PDF-looking placeholder.
async function syntheticStatementPdf(bank) {
const lines = bank === 'finom' ? [
'Example Widgets GmbH Statement of account',
'Issue date: 04.09.2026',
'From: 01.08.2026',
'To: 31.08.2026',
'Opening balance: 100,00 €',
'Closing balance: 81,50 €',
'IBAN: EX00 0000 0000 0000 0000 01',
'Completed Description Pay in/out',
'12.08.2026 Example Hosting',
'Synthetic subscription',
'- 18,50 €',
'FINOM, a limited liability company registered in the Netherlands.',
] : bank === 'revolut' ? [
'Account statement',
'Generated on the September 4, 2026',
'Account name Euro',
'Currency EUR',
'IBAN EX00 0000 0000 0000 0000 02',
'Balance summary',
'Opening balance €200.00',
'Money in €0.00',
'Money out - €27.00',
'Closing balance €173.00',
'Transactions from August 1, 2026 to August 31, 2026',
'Date (UTC) Description Money out Money in Balance',
'15 Aug 2026 CAR Example Tools €27.00 €173.00',
'Transaction types',
'© 2026 Revolut Bank UAB',
] : null;
if (!lines) throw new Error(`Unsupported synthetic statement bank: ${bank}`);
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Courier);
const page = pdf.addPage([760, 800]);
lines.forEach((line, index) => page.drawText(line, { x: 30, y: 760 - index * 24, size: 10, font }));
return Buffer.from(await pdf.save());
}

module.exports = { syntheticStatementPdf };
2 changes: 1 addition & 1 deletion backend/scripts/test-sponsor-finance-transaction.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,4 @@ NODE_ENV=test \
SKIP_AUTH=true \
SPONSOR_FINANCE_ENABLED=true \
node --import tsx --test --test-concurrency=1 --test-force-exit \
tests/bookkeeping-allocation-link-guard.ddb.ts tests/vies-invoice-gate.ddb.ts tests/sponsor-finance-transaction.ddb.ts tests/sponsor-finance-payment-follow-up.ddb.ts tests/sponsor-finance-finom-task.ddb.ts tests/z-discovery-boundary.ddb.ts
tests/reconcile-statement-transaction.ddb.ts tests/bookkeeping-allocation-link-guard.ddb.ts tests/vies-invoice-gate.ddb.ts tests/sponsor-finance-transaction.ddb.ts tests/sponsor-finance-payment-follow-up.ddb.ts tests/sponsor-finance-finom-task.ddb.ts tests/z-discovery-boundary.ddb.ts
5 changes: 5 additions & 0 deletions backend/src/db/bookkeeping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export type BookkeepingTransactItem = NonNullable<
ConstructorParameters<typeof TransactWriteCommand>[0]["TransactItems"]
>[number];

/** Shared atomic bookkeeping write, including the repository's explicit local test emulator. */
export async function transactBookkeeping(client: DynamoDBDocumentClient, input: ConstructorParameters<typeof TransactWriteCommand>[0]) {
return transact(client, input, false, true);
}

export function bookkeepingItemId(unique?: string) {
return unique ? createHash("sha256").update(unique).digest("hex") : randomUUID();
}
Expand Down
1 change: 1 addition & 0 deletions backend/src/docs/frontend-assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"src/surfaces/finance/bookkeeping-markup.js",
"src/surfaces/finance/bookkeeping-navigation.js",
"src/surfaces/finance/bookkeeping-reporting.js",
"src/surfaces/finance/bookkeeping-statement-import.js",
"src/surfaces/finance/bookkeeping-statement.js",
"src/surfaces/finance/bookkeeping-transaction-dialog.js",
"src/surfaces/finance/bookkeeping-transaction-modal.js",
Expand Down
11 changes: 7 additions & 4 deletions backend/src/reconcile/monthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const LOG_LIMIT = 2000;

export type MonthAction =
| "pair" | "unpair" | "exception" | "clear-exception" | "void-ledger" | "restore-ledger" | "attach-proof" | "attach-document"
| "invoice-disposition" | "auto-match" | "create-ledger" | "confirm-exceptions" | "close" | "reopen" | "summary";
| "invoice-disposition" | "import-statement" | "auto-match" | "create-ledger" | "confirm-exceptions" | "close" | "reopen" | "summary";

export type MonthLogEntry = { at: string; by: string; action: MonthAction; detail: Record<string, unknown> };

Expand All @@ -81,6 +81,7 @@ export type MonthRecord = {
exceptionConfirmation?: { digest: string; by: string; at: string; count: number };
closed?: { by: string; at: string };
log: MonthLogEntry[];
statementSelection?: import("./statementSelection").StatementSelection;
version: number;
createdAt: string;
updatedAt: string;
Expand Down Expand Up @@ -1117,7 +1118,7 @@ async function confirmedInvoiceSource(client: DynamoDBDocumentClient, document:
* reportable row's invoice/receipt and payment proof. Taxes, health insurance,
* and confirmed owner withdrawals stay out. Building it never sends anything.
*/
export async function monthPackage(client: DynamoDBDocumentClient, period: string, options: { draft?: boolean } = {}): Promise<{ reportPeriod: string; period: string; state: MonthRecord["state"]; draft: boolean; entries: PackageEntry[]; blockers: PackageBlocker[]; csv: string }> {
export async function monthPackage(client: DynamoDBDocumentClient, period: string, options: { draft?: boolean } = {}): Promise<{ reportPeriod: string; period: string; state: MonthRecord["state"]; draft: boolean; entries: PackageEntry[]; blockers: PackageBlocker[]; statementWarnings: PackageBlocker[]; csv: string }> {
const transactions = await listTransactions(client);
const omitted = await assertAccountantPaymentReady(client, period, transactions);
const view = await buildMonth(client, period);
Expand All @@ -1140,7 +1141,9 @@ export async function monthPackage(client: DynamoDBDocumentClient, period: strin
const row = monthRowsByLedger.get(String(reportRow.id));
if (row?.ledger && row.paymentProof) proofByDocument.set(row.paymentProof.documentId, { row, reportRow: transactionsById.get(String(reportRow.id)) || reportRow, proof: row.paymentProof });
}
const statements = (await listStatements(client)).filter((record) => record.period === period);
const { statementPackageSources } = await import("./statementImport");
const { statements, problems, warnings: statementWarnings } = await statementPackageSources(client, period);
blockers.push(...problems);
for (const record of statements) {
const wallets = [...new Set(record.rows.map((row) => row.wallet || "main"))];
entries.push({
Expand Down Expand Up @@ -1218,5 +1221,5 @@ export async function monthPackage(client: DynamoDBDocumentClient, period: strin
|| String(left.documentId || left.invoiceId || left.artifactId || "").localeCompare(String(right.documentId || right.invoiceId || right.artifactId || "")));
const unique = naming.uniquePaths(entries.map((entry) => entry.path));
entries.forEach((entry, index) => { entry.path = unique[index]; });
return { reportPeriod: period, period, state: view.state, draft: view.state !== "closed", entries, blockers, csv: formatAccountantCsv(reportRows) };
return { reportPeriod: period, period, state: view.state, draft: view.state !== "closed", entries, blockers, statementWarnings, csv: formatAccountantCsv(reportRows) };
}
2 changes: 1 addition & 1 deletion backend/src/reconcile/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type StatementBalance = {
};

export type StatementPeriod = { from: string; to: string };
export type ParsedStatement = { rows: StatementRow[]; warnings: string[]; balances: StatementBalance[]; period?: StatementPeriod };
export type ParsedStatement = { rows: StatementRow[]; warnings: string[]; balances: StatementBalance[]; period?: StatementPeriod; issueDate?: string; bankAccounts?: Record<string, string>; openingBalances?: StatementBalance[] };
export type ParseOptions = { currency?: string };

const CURRENCY = /^[A-Z]{3}$/;
Expand Down
29 changes: 24 additions & 5 deletions backend/src/reconcile/pdfLayouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* that does not reconcile throws, because a silently dropped or mis-signed
* row would otherwise flow into reconciliation unnoticed.
*/
import { createHash } from "crypto";
import { ParseError, parseAmount, parseStatementDate, sumAmounts } from "./parsers";
import type { ParsedStatement, StatementBalance, StatementPeriod, StatementRow } from "./parsers";

Expand All @@ -24,6 +25,11 @@ export type PdfParsedStatement = ParsedStatement & {
openingBalances: StatementBalance[];
};

function accountFingerprint(value: string | undefined): string | undefined {
const normalized = String(value || "").replace(/\s+/g, "").toUpperCase();
return normalized ? createHash("sha256").update(normalized).digest("hex").slice(0, 24) : undefined;
}

const CURRENCY_SYMBOLS: Record<string, string> = { "€": "EUR", "$": "USD", "£": "GBP" };

function cents(value: string | number): number {
Expand Down Expand Up @@ -103,9 +109,13 @@ export function parseFinomPdfText(text: string): PdfParsedStatement {
if (!layout) {
throw new ParseError(`unrecognized finom PDF statement layout: column header ${[...headers].map((h) => `"${h}"`).join(", ") || "missing"}; expected one of ${FINOM_LAYOUTS.map((l) => `"${l.header}"`).join(", ")}`);
}
const period = { from: parseStatementDate(finomLabel(text, "From")), to: parseStatementDate(finomLabel(text, "To")) };
const opening = parseAmount(finomLabel(text, "Opening balance"));
const closing = parseAmount(finomLabel(text, "Closing balance"));
const headerText = text.split(/^[ \t]*Completed\s/m)[0];
const period = { from: parseStatementDate(finomLabel(headerText, "From")), to: parseStatementDate(finomLabel(headerText, "To")) };
const issueDate = parseStatementDate(finomLabel(headerText, "Issue date"));
const accountId = accountFingerprint(headerText.match(/^IBAN:\s*([^\r\n]+)/m)?.[1]);
if (period.from > period.to) throw new ParseError("finom PDF statement header From is after To");
const opening = parseAmount(finomLabel(headerText, "Opening balance"));
const closing = parseAmount(finomLabel(headerText, "Closing balance"));

type Block = { date: string; counterparty: string; details: string[]; amount?: string; balance?: string; orig?: { amount: string; currency: string } };
const blocks: Block[] = [];
Expand Down Expand Up @@ -167,6 +177,8 @@ export function parseFinomPdfText(text: string): PdfParsedStatement {
};
});

if (rows.some((row) => row.date < period.from || row.date > period.to)) throw new ParseError("finom PDF statement row lies outside header From/To");

// Rows are printed newest first; walk them oldest first against the balances.
const net = cents(sumAmounts(rows));
if (cents(opening) + net !== cents(closing)) {
Expand All @@ -183,6 +195,8 @@ export function parseFinomPdfText(text: string): PdfParsedStatement {
}
return {
layout: layout.name,
issueDate,
bankAccounts: accountId ? { "main|EUR": accountId } : {},
rows,
warnings: [],
period,
Expand Down Expand Up @@ -222,6 +236,7 @@ function revolutMoney(piece: string): Money | null {
}

type RevolutSection = {
accountId?: string;
wallet?: string;
currency?: string;
opening?: Money;
Expand All @@ -244,6 +259,8 @@ export function parseRevolutPdfText(text: string): PdfParsedStatement {
if (!REVOLUT_ENTITY.test(text) || !REVOLUT_SUMMARY.test(text)) {
throw new ParseError("unrecognized revolut PDF statement layout: Revolut Bank UAB issuer and \"Balance summary\" not found");
}
const generated = text.match(/Generated on the ([^\n\r]+)/)?.[1]?.trim();
const issueDate = generated ? parseStatementDate(generated) : undefined;
const sections: RevolutSection[] = [];
let section: RevolutSection | null = null;
let inTable = false;
Expand Down Expand Up @@ -302,7 +319,8 @@ export function parseRevolutPdfText(text: string): PdfParsedStatement {
}
if (parts.length === 2) {
const [label, value] = parts;
if (label === "Currency" && /^[A-Z]{3}$/.test(value)) section.currency = value;
if (["IBAN", "Account number"].includes(label)) section.accountId = accountFingerprint(value);
else if (label === "Currency" && /^[A-Z]{3}$/.test(value)) section.currency = value;
else if (label === "Opening balance") section.opening = revolutSummary(section, label, value);
else if (label === "Money in") section.moneyIn = revolutSummary(section, label, value);
else if (label === "Money out") section.moneyOut = revolutSummary(section, label, value);
Expand Down Expand Up @@ -404,5 +422,6 @@ export function parseRevolutPdfText(text: string): PdfParsedStatement {
from: periods.map((p) => p.from).sort()[0],
to: periods.map((p) => p.to).sort().at(-1)!,
};
return { layout: REVOLUT_LAYOUT, rows, warnings: [], period, openingBalances, balances };
if (!issueDate) throw new ParseError("unrecognized revolut PDF statement layout: no generated issue date");
return { layout: REVOLUT_LAYOUT, issueDate, bankAccounts: Object.fromEntries(sections.filter((section) => section.accountId).map((section) => [`${section.currency}|${section.currency}`, section.accountId!])), rows, warnings: [], period, openingBalances, balances };
}
25 changes: 23 additions & 2 deletions backend/src/reconcile/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* row when the report is built.
*/
import { createHash } from "crypto";
import { PutCommand } from "@aws-sdk/lib-dynamodb";
import { GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { TABLE_BOOKKEEPING } from "../db/tableNames";
import {
Expand Down Expand Up @@ -54,6 +54,9 @@ export type StatementRecord = {
from?: string;
to?: string;
warnings?: string[];
bankAccounts?: Record<string, string>;
issueDate?: string;
openingBalances?: StatementBalance[];
createdAt: string;
updatedAt: string;
};
Expand All @@ -70,6 +73,9 @@ export type StatementInput = {
from?: string;
to?: string;
warnings?: string[];
bankAccounts?: Record<string, string>;
issueDate?: string;
openingBalances?: StatementBalance[];
};

export type AccountBalance = StatementBalance & { accountId: StatementAccount; statementId: string };
Expand Down Expand Up @@ -144,6 +150,9 @@ export async function saveStatement(client: DynamoDBDocumentClient, input: State
...(input.filename ? { filename: input.filename } : {}),
...(input.contentType ? { contentType: input.contentType } : {}),
rows: input.rows,
...(input.bankAccounts ? { bankAccounts: input.bankAccounts } : {}),
...(input.issueDate ? { issueDate: input.issueDate } : {}),
...(input.openingBalances ? { openingBalances: input.openingBalances } : {}),
...(input.balances?.length ? { balances: input.balances } : {}),
from: input.from || sorted[0].date,
to: input.to || sorted[sorted.length - 1].date,
Expand All @@ -163,10 +172,22 @@ export async function getStatement(client: DynamoDBDocumentClient, id: string):
return (await getBookkeepingItem(client, "statement", id)) as unknown as StatementRecord | null;
}

export async function listStatements(client: DynamoDBDocumentClient): Promise<StatementRecord[]> {
export async function listStatementHistory(client: DynamoDBDocumentClient): Promise<StatementRecord[]> {
return (await listBookkeepingItems(client, "statement")) as unknown as StatementRecord[];
}

export async function listStatements(client: DynamoDBDocumentClient): Promise<StatementRecord[]> {
const records = await listStatementHistory(client);
const { defaultStatementSelection, selectedStatementRecords } = await import("./statementSelection");
const active: StatementRecord[] = [];
for (const period of new Set(records.map((record) => record.period))) {
const own = records.filter((record) => record.period === period);
const result = await client.send(new GetCommand({ TableName: TABLE_BOOKKEEPING, Key: { PK: `RECONCILE-MONTH#${period}`, SK: `RECONCILE-MONTH#${period}` }, ConsistentRead: true }));
active.push(...selectedStatementRecords(own, result.Item?.statementSelection || defaultStatementSelection(own)));
}
return active;
}

/**
* The latest known balance per account wallet and currency, across every
* stored statement: the one with the latest as-of date wins, a later upload
Expand Down
Loading