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
29 changes: 26 additions & 3 deletions backend/e2e/canonical-capability-behavior.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -737,9 +737,11 @@ test.describe('canonical frontend capability behavior', () => {
await expect(row).toBeVisible();
await expect(page.locator('.bk-month__head')).toContainText('out −€18.50');

await uploadBookkeepingEvidence(page, context, transaction.id, {
const paymentEvidence = await uploadBookkeepingEvidence(page, context, transaction.id, {
month: '2026-08', filename: 'synthetic-evidence.pdf',
});
const proof = await context.request.post('/api/reconcile/months/2026-08/proof', { data: { transactionId: transaction.id, documentId: paymentEvidence.id } });
expect(proof.ok()).toBe(true);
await page.goto('/bookkeeping?month=2026-08');
await row.locator('.bk-col-date').click();
await expect(page).toHaveURL(new RegExp(`tx=${transaction.id}`));
Expand Down Expand Up @@ -791,6 +793,27 @@ test.describe('canonical frontend capability behavior', () => {
await expect(page.locator('[data-bookkeeping-status]')).toContainText('PDF uploaded and verified');
await expect(page.locator('.bookkeeping-documents article')).toHaveCount(index + 2);
}
// Book both actual payments parsed from the synthetic statement PDFs.
// Resolve evidence through the same operator APIs as the portal.
const monthResponse = await context.request.get('/api/reconcile/months/2026-08');
expect(monthResponse.status()).toBe(200);
const month = await json(monthResponse);
const hosting = month.rows.find((item) => item.bank?.counterparty === 'Example Hosting');
const tools = month.rows.find((item) => item.bank?.counterparty === 'Example Tools');
expect(hosting?.bank).toMatchObject({ date: '2026-08-12', amount: '-18.50', currency: 'EUR' });
expect(tools?.bank).toMatchObject({ date: '2026-08-15', amount: '-27.00', currency: 'EUR' });
const paired = await context.request.post('/api/reconcile/months/2026-08/pair', { data: {
bankKey: hosting.bank.key, transactionId: transaction.id,
} });
expect(paired.status()).toBe(200);
const booked = await context.request.post('/api/reconcile/months/2026-08/ledger', { data: {
bankKey: tools.bank.key, category: 'testing', description: 'Synthetic tools service',
} });
expect(booked.status()).toBe(201);
const toolsTransaction = (await json(booked)).transaction;
await uploadBookkeepingEvidence(page, context, toolsTransaction.id, {
month: '2026-08', filename: 'synthetic-tools-evidence.pdf',
});
const retired = await context.request.post('/api/bookkeeping/documents/upload', { data: {
filename: 'synthetic-evidence.pdf', contentType: 'application/pdf', byteSize: 34, documentType: 'receipt',
} });
Expand All @@ -804,7 +827,7 @@ test.describe('canonical frontend capability behavior', () => {
const packageReview = page.getByRole('region', { name: 'Accountant package', exact: true });
await expect(packageReview.getByRole('heading', { name: 'August 2026 package', exact: true })).toBeVisible();
await expect(packageReview).toContainText('Package ready.');
await expect(packageReview).toContainText('1 transactions and 3 documents inside.');
await expect(packageReview).toContainText('2 transactions and 4 documents inside.');
// The archive is generated by the explicit download action; that is also
// what files the ZIP as a private document.
await page.getByRole('button', { name: 'Download package' }).click();
Expand All @@ -814,7 +837,7 @@ test.describe('canonical frontend capability behavior', () => {
await page.goto('/bookkeeping?month=2026-08');
await expect(row).toBeVisible();
await page.goto('/bookkeeping?view=files&month=2026-08');
await expect(page.locator('.bookkeeping-documents article')).toHaveCount(4);
await expect(page.locator('.bookkeeping-documents article')).toHaveCount(5);
await expect(page.locator('.bookkeeping-documents')).toContainText('datatalksclub-2026-08.zip');
await context.close();
recordCapabilityEvidence(testInfo, [{
Expand Down
30 changes: 24 additions & 6 deletions backend/src/routes/bookkeepingReports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,21 @@ import {
type BookkeepingItem,
} from "../db/bookkeeping";
import { preflightOrganizations } from '../vies/service';
import { assertAccountantPaymentReady, MonthGateError } from "../reconcile/monthService";
import { accountantCashDate } from "../finance/accountantReport";
import { businessAccountRequiresStatement } from "../finance/businessAccounts";
import {
headDocumentObject,
json,
MONEY,
MONTH,
parse,
s3,
signUrl,
uploadStream,
} from "./bookkeepingShared";

const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/;

const csvCell = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;

const activeLedgerRows = async (client: DynamoDBDocumentClient) =>
Expand Down Expand Up @@ -66,12 +69,19 @@ export async function handleBookkeepingReportRoutes(
const body = parse(event.body || null),
month = String(body?.month || "");
if (!MONTH.test(month)) return json(400, { error: "Invalid month" });
const ledger = await activeLedgerRows(client);
let omitted: Set<string>;
try { omitted = await assertAccountantPaymentReady(client, month, ledger); }
catch (error) {
if (error instanceof MonthGateError) return json(409, { reportPeriod: month, error: error.message, blockers: error.blockers });
throw error;
}
const reports = await listBookkeepingItems(client, "report");
const existing = reports.find(
(r) =>
r.month === month && ["ready", "generated"].includes(String(r.status)),
);
if (existing) return json(200, { report: existing, duplicate: true, vatPreflight: await vatPreflightSummary(client) });
if (existing) return json(200, { reportPeriod: month, report: existing, duplicate: true, vatPreflight: await vatPreflightSummary(client) });
const accounts = (await listBookkeepingItems(client, "account")).filter(
(a) => a.kind === "business" && a.active !== false && businessAccountRequiresStatement(String(a.id), month),
);
Expand All @@ -92,8 +102,8 @@ export async function handleBookkeepingReportRoutes(
error: "Missing required business statements",
missingAccountIds: missing.map((a) => a.id),
});
const monthTransactions = (await activeLedgerRows(client))
.filter((t) => String(t.transactionDate).startsWith(month));
const monthTransactions = ledger
.filter((t) => accountantCashDate(t).startsWith(month) && !omitted.has(String(t.id)));
const excludedTransactions = monthTransactions.filter(
isReportExemptTransaction,
);
Expand Down Expand Up @@ -175,6 +185,7 @@ export async function handleBookkeepingReportRoutes(
throw error;
}
return json(201, {
reportPeriod: month,
report,
vatPreflight: await vatPreflightSummary(client),
warnings: {
Expand Down Expand Up @@ -236,6 +247,13 @@ export async function handleBookkeepingReportRoutes(
const report = await getBookkeepingItem(client, "report", archive[1]);
if (!report || !["ready", "generated"].includes(String(report.status)))
return json(404, { error: "Ready report not found" });
const month = String(report.month || "");
if (!MONTH.test(month)) return json(400, { error: "Invalid month" });
try { await assertAccountantPaymentReady(client, month); }
catch (error) {
if (error instanceof MonthGateError) return json(409, { reportPeriod: month, error: error.message, blockers: error.blockers });
throw error;
}
if (report.archiveDocumentId) {
const doc = await getBookkeepingItem(
client,
Expand All @@ -251,7 +269,7 @@ export async function handleBookkeepingReportRoutes(
}),
{ expiresIn: 300 },
);
return json(200, { downloadUrl: url, expiresIn: 300, duplicate: true, vatPreflight: await vatPreflightSummary(client) });
return json(200, { reportPeriod: month, downloadUrl: url, expiresIn: 300, duplicate: true, vatPreflight: await vatPreflightSummary(client) });
}
}
const transactionIds = Array.isArray(report.transactionIds)
Expand Down Expand Up @@ -402,7 +420,7 @@ export async function handleBookkeepingReportRoutes(
}),
{ expiresIn: 300 },
);
return json(201, { downloadUrl: url, expiresIn: 300, vatPreflight: await vatPreflightSummary(client) });
return json(201, { reportPeriod: month, downloadUrl: url, expiresIn: 300, vatPreflight: await vatPreflightSummary(client) });
}
return null;
}
7 changes: 7 additions & 0 deletions backend/tests/api-bookkeeping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createSession } from '../src/db/sessions';
import { createHash } from 'crypto';
import { Readable } from 'stream';
import { handleBookkeepingTransactionRoutes } from '../src/routes/bookkeepingTransactions';
import { attachProof } from '../src/reconcile/monthService';
import { TABLE_BOOKKEEPING } from '../src/db/tableNames';

const invoke=(method:string,path:string,body?:unknown,headers?:Record<string,string>,query?:Record<string,string>)=>handler({httpMethod:method,path,headers:headers||{},body:body===undefined?null:JSON.stringify(body),queryStringParameters:query||null},{});
Expand Down Expand Up @@ -89,6 +90,7 @@ describe('bookkeeping API',()=>{
const receipt=(await putBookkeepingItem(client,'document',{documentType:'receipt',status:'active',originalFilename:'receipt.pdf',contentType:'application/pdf',byteSize:9,sha256:createHash('sha256').update('synthetic-receipt').digest('hex'),s3Key:'documents/synthetic-receipt'})).item;
const link=await invoke('POST','/api/bookkeeping/links',{documentId:receipt.id,transactionId:transaction.id,coverageType:'evidence'});assert.equal(link.statusCode,201);
const duplicate=await invoke('POST','/api/bookkeeping/links',{documentId:receipt.id,transactionId:transaction.id,coverageType:'evidence'});assert.equal(duplicate.statusCode,200);assert.equal(JSON.parse(duplicate.body).id,JSON.parse(link.body).id);
await attachProof(client, '2026-08', 'synthetic-operator', transaction.id, receipt.id);
const snapshot=await invoke('POST','/api/bookkeeping/reports/snapshot',{month:'2026-08'});assert.equal(snapshot.statusCode,201);const report=JSON.parse(snapshot.body).report;assert.deepEqual(report.transactionIds,[transaction.id]);assert.equal(report.documentIds.length,3);
const retry=await invoke('POST','/api/bookkeeping/reports/snapshot',{month:'2026-08'});assert.equal(retry.statusCode,200);assert.equal(JSON.parse(retry.body).report.id,report.id);
});
Expand Down Expand Up @@ -125,6 +127,11 @@ describe('bookkeeping API',()=>{
const activeBusinessAccounts=JSON.parse((await invoke('GET','/api/bookkeeping/accounts')).body).items.filter((account:any)=>account.kind==='business'&&account.active!==false);
assert.ok(activeBusinessAccounts.length>=2);
for(const account of activeBusinessAccounts) await putBookkeepingItem(client,'document',{documentType:'bank-statement',status:'active',accountId:account.id,statementMonth:'2026-09',originalFilename:'synthetic.pdf',contentType:'application/pdf',byteSize:9,sha256:createHash('sha256').update(`synthetic-vat-${account.id}`).digest('hex'),s3Key:`documents/${account.id}`});
for (const transaction of (await listTransactions(client)).filter((row) => String(row.transactionDate).startsWith('2026-09') && !['Taxes', 'Health Insurance', 'Owner pay'].includes(String(row.category)) && !['Finanzamt', 'Techniker Krankenkasse'].includes(String(row.counterparty)))) {
const evidence = (await putBookkeepingItem(client, 'document', { documentType: 'receipt', status: 'active', originalFilename: `synthetic-vat-${transaction.id}.pdf`, contentType: 'application/pdf', byteSize: 9, sha256: createHash('sha256').update(`synthetic-vat-proof-${transaction.id}`).digest('hex'), s3Key: `documents/synthetic-vat-${transaction.id}` })).item;
await invoke('POST', '/api/bookkeeping/links', { documentId: evidence.id, transactionId: transaction.id, coverageType: 'evidence' });
await attachProof(client, '2026-09', 'synthetic-operator', String(transaction.id), evidence.id);
}
const snapshot=await invoke('POST','/api/bookkeeping/reports/snapshot',{month:'2026-09',privateDocumentIds:[ownerEvidence.id]});assert.equal(snapshot.statusCode,201,snapshot.body);
const report=JSON.parse(snapshot.body).report;
assert.equal(report.reconciliation.excludedTransactionCount,5);
Expand Down
111 changes: 111 additions & 0 deletions backend/tests/bookkeeping-report-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { getClient } from "../src/db/client";
import { createDocumentLink, getBookkeepingItem, listBookkeepingItems, putBookkeepingItem } from "../src/db/bookkeeping";
import { createTables, startLocal, stopLocal } from "../scripts/local-dynamodb";
import { attachProof } from "../src/reconcile/monthService";
import { handleBookkeepingReportRoutes } from "../src/routes/bookkeepingReports";
import * as storage from "../src/routes/bookkeepingShared";

describe("portal package payment readiness", () => {
let client: DynamoDBDocumentClient;
let writes = 0, signs = 0, objectOperations = 0, uploads = 0;
const originalStorage = { s3: storage.s3, signUrl: storage.signUrl, uploadStream: storage.uploadStream };
before(async () => {
const underlying = await getClient(await startLocal());
await createTables(underlying);
client = { send: async (command: any) => {
if (["PutCommand", "UpdateCommand", "DeleteCommand", "TransactWriteCommand"].includes(command.constructor.name)) writes++;
return underlying.send(command);
} } as unknown as DynamoDBDocumentClient;
storage.setBookkeepingStorageForTests({ send: async () => { objectOperations++; return {}; } } as never,
async () => { signs++; return "https://invented.example.test/archive"; });
storage.setBookkeepingArchiveUploaderForTests(async () => { uploads++; return { VersionId: "invented-version" }; });
});
after(async () => {
storage.setBookkeepingStorageForTests(originalStorage.s3, originalStorage.signUrl);
storage.setBookkeepingArchiveUploaderForTests(originalStorage.uploadStream);
await stopLocal();
});
const invoke = (path: string, body: unknown = {}) => handleBookkeepingReportRoutes(path, "POST", {
httpMethod: "POST", path, body: JSON.stringify(body), headers: {},
}, client);
const payment = async (month: string, suffix: string) => (await putBookkeepingItem(client, "bookkeeping", {
id: `invented-payment-${suffix}`, transactionDate: `${month}-06`, paidDate: `${month}-06`,
counterparty: `Invented Vendor ${suffix}`, description: "Invented service", amount: "31.00", currency: "EUR", entryType: "expense",
})).item;
async function confirmPayment(transactionId: string, month: string) {
const document = (await putBookkeepingItem(client, "document", {
id: `invented-evidence-${transactionId}`, status: "active", documentType: "receipt", originalFilename: "invented-receipt.pdf",
})).item;
await createDocumentLink(client, { documentId: document.id, transactionId, coverageType: "evidence" });
await attachProof(client, month, "invented-operator", transactionId, document.id);
}
async function statements(month: string) {
for (const id of ["invented-business-one", "invented-business-two"]) {
if (!await getBookkeepingItem(client, "account", id)) await putBookkeepingItem(client, "account", { id, kind: "business", active: true });
await putBookkeepingItem(client, "document", { documentType: "bank-statement", status: "active", accountId: id, statementMonth: month });
}
}
async function assertBlocked(path: string, month: string, transactionId: string, body: unknown = {}) {
writes = signs = objectOperations = uploads = 0;
const response = await invoke(path, body);
assert.equal(response?.statusCode, 409);
const result = JSON.parse(response!.body);
assert.equal(result.reportPeriod, month);
assert.ok(result.blockers.some((row: any) => row.rowId === `ledger:${transactionId}` && row.reason));
assert.equal(result.report, undefined);
assert.equal(result.downloadUrl, undefined);
assert.deepEqual({ writes, signs, objectOperations, uploads }, { writes: 0, signs: 0, objectOperations: 0, uploads: 0 });
}
it("blocks new snapshots, ready duplicates, and fresh/cached archives before writes or S3 signing", async () => {
const month = "2036-01", transaction = await payment(month, "unconfirmed");
await assertBlocked("/api/bookkeeping/reports/snapshot", month, transaction.id, { month });
assert.deepEqual(await listBookkeepingItems(client, "report"), []);
const archive = (await putBookkeepingItem(client, "document", { id: "invented-cached-archive", status: "active", s3Key: "invented/archive.zip" })).item;
const cached = (await putBookkeepingItem(client, "report", { id: "invented-cached-report", month, status: "generated", archiveDocumentId: archive.id, transactionIds: [transaction.id], documentIds: [] })).item;
const ready = (await putBookkeepingItem(client, "report", { id: "invented-ready-report", month, status: "ready", transactionIds: [transaction.id], documentIds: [] })).item;
await assertBlocked("/api/bookkeeping/reports/snapshot", month, transaction.id, { month });
await assertBlocked(`/api/bookkeeping/reports/${cached.id}/archive`, month, transaction.id);
await assertBlocked(`/api/bookkeeping/reports/${ready.id}/archive`, month, transaction.id);
assert.equal((await getBookkeepingItem(client, "report", cached.id))?.archiveDocumentId, archive.id);
await confirmPayment(transaction.id, month);
const duplicate = await invoke("/api/bookkeeping/reports/snapshot", { month });
assert.equal(duplicate?.statusCode, 200);
assert.equal(JSON.parse(duplicate!.body).duplicate, true);
const cachedResponse = await invoke(`/api/bookkeeping/reports/${cached.id}/archive`);
assert.equal(cachedResponse?.statusCode, 200);
assert.equal(JSON.parse(cachedResponse!.body).reportPeriod, month);
assert.equal(signs, 1, "a resolved month may sign its cached archive");
assert.equal(uploads, 0);
});
it("creates a snapshot for a verified paid month and retains the selected cash-month context", async () => {
const month = "2036-04", transaction = await payment(month, "confirmed");
await confirmPayment(transaction.id, month);
await statements(month);
const response = await invoke("/api/bookkeeping/reports/snapshot", { month });
assert.equal(response?.statusCode, 201, response?.body);
const result = JSON.parse(response!.body);
assert.equal(result.reportPeriod, month);
assert.deepEqual(result.report.transactionIds, [transaction.id]);
});
it("does not include an unconfirmed next-month cash payment merely because its invoice date is in the selected month", async () => {
const month = "2036-05";
await putBookkeepingItem(client, "bookkeeping", { id: "invented-future-cash", transactionDate: `${month}-31`, paidDate: "2036-06-02", counterparty: "Invented Future Cash Vendor", amount: "44.00", currency: "EUR", entryType: "expense" });
await statements(month);
const response = await invoke("/api/bookkeeping/reports/snapshot", { month });
assert.equal(response?.statusCode, 201, response?.body);
assert.deepEqual(JSON.parse(response!.body).report.transactionIds, []);
});
it("keeps invalid input and invalid stored report months at 400 without signing or writing", async () => {
writes = signs = objectOperations = uploads = 0;
for (const month of ["2036", "2036-00", "2036-13"]) {
assert.equal((await invoke("/api/bookkeeping/reports/snapshot", { month }))?.statusCode, 400);
}
const invalid = (await putBookkeepingItem(client, "report", { id: "invented-invalid-month", month: "all", status: "generated", archiveDocumentId: "invented-cached-archive" })).item;
writes = 0;
assert.equal((await invoke(`/api/bookkeeping/reports/${invalid.id}/archive`))?.statusCode, 400);
assert.deepEqual({ writes, signs, objectOperations, uploads }, { writes: 0, signs: 0, objectOperations: 0, uploads: 0 });
});
});
Loading