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
13 changes: 11 additions & 2 deletions backend/src/finance/accountantReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,18 @@ function values(entry: AccountantReportEntry) {
];
}

/** Cash-date order, deterministic parent ties, and saved allocation order. */
function chronologicalReportRows(entries: AccountantReportEntry[]) {
return entries.map((entry, index) => ({ entry, index }))
.sort((left, right) => accountantCashDate(left.entry).localeCompare(accountantCashDate(right.entry))
|| String(left.entry.id || "").localeCompare(String(right.entry.id || ""))
|| left.index - right.index)
.map(({ entry }) => entry);
}

export function formatAccountantCsv(entries: AccountantReportEntry[]) {
const lines = [HEADERS.map(csvCell).join(",")];
for (const entry of entries) {
for (const entry of chronologicalReportRows(entries)) {
lines.push(values(entry)
.map((value, index) => csvCell(index === 3 ? value : spreadsheetText(value)))
.join(","));
Expand All @@ -104,7 +113,7 @@ export function formatAccountantCsv(entries: AccountantReportEntry[]) {

export function formatAccountantTsv(entries: AccountantReportEntry[]) {
const lines = [HEADERS.join("\t")];
for (const entry of entries) {
for (const entry of chronologicalReportRows(entries)) {
lines.push(values(entry)
.map((value, index) => {
const text = String(value ?? "").replace(/[\t\r\n]/g, " ");
Expand Down
2 changes: 1 addition & 1 deletion backend/src/reconcile/monthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1211,7 +1211,7 @@ export async function monthPackage(client: DynamoDBDocumentClient, period: strin
if (!registeredProof || registeredProof.status !== "active") continue;
seenDocuments.add(documentId);
entries.push({
path: naming.packagePath(period, "proofs", naming.proofName({ date: accountantCashDate(reportRow), counterparty: reportRow.counterparty as string, description: reportRow.description as string, amount: reportRow.amount as string, currency: String(reportRow.currency || row.ledger!.currency), filename: proof.filename })),
path: naming.packagePath(period, "statements", naming.proofName({ date: accountantCashDate(reportRow), counterparty: reportRow.counterparty as string, description: reportRow.description as string, amount: reportRow.amount as string, currency: String(reportRow.currency || row.ledger!.currency), filename: proof.filename })),
kind: "proof", documentId, rowId: row.id,
...(proof.filename ? { originalFilename: proof.filename } : {}),
});
Expand Down
7 changes: 4 additions & 3 deletions backend/src/reconcile/naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
* statements/YYYY-MM_<bank>-<account>_statement.<ext>
* income/YYYY-MM-DD_<company>_<what>_<amount><CUR>.pdf
* expenses/YYYY-MM-DD_<company>_<what>_<amount><CUR>.pdf
* proofs/YYYY-MM-DD_<company>_<what>_<amount><CUR>.pdf
* statements/YYYY-MM-DD_<company>_<payment-proof-what>_<amount><CUR>.pdf
* taxes/YYYY-MM-DD_<kind>_<period>.pdf
*
* The folder carries the type, so names inside it do not repeat it. Names
* sort by date, never collide (-2, -3 …) and are derived from data only, so
* Statements and payment proofs share one folder; payment-proof names keep
* their distinguishing prefix. Names sort by date, never collide (-2, -3 …)
* and are derived from data only, so
* the same month always yields the same package. Originals keep their own
* filename in document metadata.
*/
Expand Down
46 changes: 44 additions & 2 deletions backend/tests/bookkeeping-accountant-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it } from "node:test";
import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { handleBookkeepingRoutes } from "../src/routes/bookkeeping";
import { formatAccountantCsv, selectAccountantReportRows } from "../src/finance/accountantReport";
import { monthPackage } from "../src/reconcile/monthService";

const entries = [
{
Expand Down Expand Up @@ -101,6 +102,47 @@ function verifiedClient(rows: Array<Record<string, any>>): DynamoDBDocumentClien
const client = verifiedClient(entries);

describe("native accountant report API", () => {
it("orders native CSV, copied TSV and package CSV by cash date with deterministic parents and saved allocation order", async () => {
const period = "2033-10";
const payment = (id: string, paidDate: string, counterparty: string) => ({
id, transactionDate: "2033-09-30", paidDate, counterparty, description: "Invented service",
amount: "23.00", currency: "EUR", entryType: "expense",
});
const rows = [
payment("invented-late", "2033-10-31", "Invented Late"),
payment("invented-tie-z", "2033-10-16", "Invented Tie Z"),
{ ...payment("invented-tie-m", "2033-10-16", "Invented Parent"), allocations: [
{ documentId: "invented-doc-z", counterparty: "Invented Allocation First", description: "Invented first", amount: "5.00", currency: "EUR" },
{ documentId: "invented-doc-a", counterparty: "Invented Allocation Second", description: "Invented second", amount: "7.00", currency: "EUR" },
{ documentId: "invented-doc-m", counterparty: "Invented Allocation Third", description: "Invented third", amount: "11.00", currency: "EUR" },
] },
payment("invented-early", "2033-10-01", "Invented Early"),
payment("invented-tie-a", "2033-10-16", "Invented Tie A"),
];
const before = JSON.stringify(rows), source = verifiedClient(rows);
const expectedCompanies = ["Invented Early", "Invented Tie A", "Invented Allocation First", "Invented Allocation Second",
"Invented Allocation Third", "Invented Tie Z", "Invented Late"];
let csv = "";
for (const format of ["csv", "tsv"] as const) {
const response = await handleBookkeepingRoutes("/api/bookkeeping/reports/accountant", "GET", {
httpMethod: "GET", path: "/api/bookkeeping/reports/accountant", headers: {},
queryStringParameters: { period, format },
}, source, true);
assert.equal(response.statusCode, 200, response.body);
const report = JSON.parse(response.body);
assert.equal(report.count, 7);
const cells = report.content.trim().split(/\r?\n/).slice(1).map((line: string) =>
format === "csv" ? line.split(",").map((value) => value.slice(1, -1)) : line.split("\t"));
assert.deepEqual(cells.map((row: string[]) => row[1]), expectedCompanies);
assert.deepEqual(cells.map((row: string[]) => row[0]), ["2033-10-01", ...Array(5).fill("2033-10-16"), "2033-10-31"]);
assert.deepEqual(cells.slice(2, 5).map((row: string[]) => row[3]), ["-5.00", "-7.00", "-11.00"]);
if (format === "csv") csv = report.content;
}
const pack = await monthPackage(source, period, { draft: true });
assert.equal(pack.csv, csv, "native and package CSV use the same ordering and formatter");
assert.equal(JSON.stringify(rows), before, "formatting preserves source dates, amounts and allocation order");
});

it("selects the cash period before applying allocation projection and report exclusions", () => {
const rows = selectAccountantReportRows([
{ id: "moved", transactionDate: "2026-10-31", paidDate: "2026-11-02", amount: "30.00", currency: "EUR", entryType: "income", counterparty: "Synthetic Company", description: "Combined payment", allocations: [
Expand Down Expand Up @@ -206,9 +248,9 @@ describe("native accountant report API", () => {
assert.equal(report.count, 3);
assert.equal(report.content,
'"Date","Company","What","Amount, EUR"\r\n'
+ '"2026-10-10","Synthetic Padded Currency Vendor","Padded currency service","-30.00"\r\n'
+ '"2026-10-08","Synthetic Missing Currency Vendor","Missing currency service","-10.00"\r\n'
+ '"2026-10-09","Synthetic Lowercase Currency Vendor","Lowercase currency service","-20.00"\r\n'
+ '"2026-10-08","Synthetic Missing Currency Vendor","Missing currency service","-10.00"\r\n');
+ '"2026-10-10","Synthetic Padded Currency Vendor","Padded currency service","-30.00"\r\n');
});

it("uses the same accountant inclusion rules for copied TSV", async () => {
Expand Down
7 changes: 4 additions & 3 deletions backend/tests/reconcile-month-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,12 +476,13 @@ describe('reconciliation ledger projection', () => {
&& entry.path === '2027-01/income/2027-01-03_synthetic-client-c_workshop-delivery_15.00EUR.pdf'));
assert.equal(nextMonth.entries.filter((entry) => entry.documentId === linkedProof.id).length, 1);
assert.ok(nextMonth.entries.some((entry) => entry.kind === 'proof' && entry.documentId === linkedProof.id
&& entry.path === '2027-01/proofs/2027-01-06_synthetic-proof-client_payment-proof-proof-linked-income_12.00EUR.pdf'));
&& entry.path === '2027-01/statements/2027-01-06_synthetic-proof-client_payment-proof-proof-linked-income_12.00EUR.pdf'));
assert.ok(nextMonth.entries.some((entry) => entry.kind === 'proof' && entry.documentId === secondProof.id
&& entry.path === '2027-01/proofs/2027-01-08_synthetic-other-client_payment-proof-proof-only-income_8.00EUR.pdf'));
&& entry.path === '2027-01/statements/2027-01-08_synthetic-other-client_payment-proof-proof-only-income_8.00EUR.pdf'));
assert.ok(nextMonth.entries.some((entry) => entry.kind === 'proof' && entry.documentId === allocatedProof.id
&& entry.path === '2027-01/proofs/2027-01-03_synthetic-combined-client_payment-proof-combined-settlement_120.00EUR.pdf'));
&& entry.path === '2027-01/statements/2027-01-03_synthetic-combined-client_payment-proof-combined-settlement_120.00EUR.pdf'));
assert.equal(nextMonth.entries.filter((entry) => entry.kind === 'proof').length, 3);
assert.ok(nextMonth.entries.every((entry) => !entry.path.includes('/proofs/')));

const priorMonth = await monthPackage(client, '2026-12', { draft: true });
assert.doesNotMatch(priorMonth.csv, /Synthetic Client A|Synthetic Client B|Synthetic Proof Client/);
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ describe('reconcile API', () => {
assert.ok(paths.includes('2026-12/2026-12_reconciliation_datatalksclub.csv'), paths.join('\n'));
assert.ok(paths.some((path: string) => /^2026-12\/statements\/2026-12_finom-main_statement\./.test(path)), paths.join('\n'));
assert.ok(paths.includes('2026-12/expenses/2026-12-14_google_google-one_1.99EUR.pdf'), paths.join('\n'));
assert.ok(paths.includes('2026-12/proofs/2026-12-14_google_payment-proof-google-one_1.99EUR.pdf'), paths.join('\n'));
assert.ok(paths.includes('2026-12/statements/2026-12-14_google_payment-proof-google-one_1.99EUR.pdf'), paths.join('\n'));
// Taxes stay out of the accountant's package, as in every report sent so far.
assert.ok(!paths.some((path: string) => /finanzamt/.test(path)));

Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/reconcile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Usage
YYYY-MM/YYYY-MM_reconciliation_datatalksclub.csv
YYYY-MM/statements/YYYY-MM_<bank>-<account>_statement.<ext>
YYYY-MM/income|expenses/YYYY-MM-DD_<company>_<what>_<amount><CUR>.pdf
YYYY-MM/proofs/YYYY-MM-DD_<company>_<payment-proof-what>_<amount><CUR>.pdf
YYYY-MM/statements/YYYY-MM-DD_<company>_<payment-proof-what>_<amount><CUR>.pdf
plus datatalksclub-YYYY-MM.zip, created natively without an external zip
command. Date is the cash date; company/what use the saved report values,
and amount uses the invoice/allocation currency. Payment proofs name
Expand Down
8 changes: 4 additions & 4 deletions cli/test/reconcile.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ test("package writes the month folder layout from the manifest and never sends a
{ path: "2026-10/statements/2026-10_finom-main_statement.csv", kind: "statement", documentId: "doc-stmt" },
{ path: "2026-10/expenses/2026-10-14_example-vendor_hosting_8.00EUR.pdf", kind: "invoice", documentId: "doc-google" },
{ path: "2026-10/expenses/2026-10-15_synthetic-vendor_example-service_9.00EUR.pdf", kind: "invoice", invoiceId: "synthetic-invoice" },
{ path: "2026-10/proofs/2026-10-14_example-vendor_payment-proof-hosting_8.00EUR.pdf", kind: "proof", documentId: "doc-proof" },
{ path: "2026-10/statements/2026-10-14_example-vendor_payment-proof-hosting_8.00EUR.pdf", kind: "proof", documentId: "doc-proof" },
] }],
["/documents/doc-stmt/download", { downloadUrl: "https://private.example.test/stmt" }],
["/documents/doc-google/download", { downloadUrl: "https://private.example.test/google" }],
Expand All @@ -609,15 +609,15 @@ test("package writes the month folder layout from the manifest and never sends a
"2026-10/statements/2026-10_finom-main_statement.csv",
"2026-10/expenses/2026-10-14_example-vendor_hosting_8.00EUR.pdf",
"2026-10/expenses/2026-10-15_synthetic-vendor_example-service_9.00EUR.pdf",
"2026-10/proofs/2026-10-14_example-vendor_payment-proof-hosting_8.00EUR.pdf",
"2026-10/statements/2026-10-14_example-vendor_payment-proof-hosting_8.00EUR.pdf",
]);
assert.equal(await (await import("node:fs/promises")).readFile(join(directory, "2026-10/proofs/2026-10-14_example-vendor_payment-proof-hosting_8.00EUR.pdf"), "utf8"), "synthetic-bytes");
assert.equal(await (await import("node:fs/promises")).readFile(join(directory, "2026-10/statements/2026-10-14_example-vendor_payment-proof-hosting_8.00EUR.pdf"), "utf8"), "synthetic-bytes");
assert.equal(await (await import("node:fs/promises")).readFile(join(directory, "2026-10/expenses/2026-10-15_synthetic-vendor_example-service_9.00EUR.pdf"), "utf8"), "synthetic-bytes");
const zipped = await archiveEntries(result.zip);
assert.deepEqual(zipped.map(([path]) => path).sort(), [...result.written].sort());
assert.ok(!zipped.some(([path]) => path.endsWith("2030-01_stale-unpaid-invoice.pdf")));
assert.equal(await readFile(stalePath, "utf8"), "pre-existing synthetic file");
assert.equal(zipped.find(([path]) => path.endsWith("proofs/2026-10-14_example-vendor_payment-proof-hosting_8.00EUR.pdf"))?.[1].toString(), "synthetic-bytes");
assert.equal(zipped.find(([path]) => path.endsWith("statements/2026-10-14_example-vendor_payment-proof-hosting_8.00EUR.pdf"))?.[1].toString(), "synthetic-bytes");
assert.match(zipped.find(([path]) => path.endsWith("reconciliation_datatalksclub.csv"))?.[1].toString() || "", /date,status/);
assert.ok(!calls.some((call) => call.method === "POST"), "building a package never writes");
});
Expand Down
8 changes: 6 additions & 2 deletions docs/reconcile-package-naming.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ from the saved report row, including invoice allocations. Amount is the absolute
invoice or allocation amount in its original currency. Invoice numbers remain
in the source document and do not add a file-name component.

Payment proofs use the same four components inside `proofs/`, with
Payment proofs use the same four components inside `statements/`, with
`payment-proof-` before their saved What. Statements use
`YYYY-MM_bank-account_statement.pdf` inside `statements/`; tabular sources keep
their source extension. Statements do not invent transaction amounts or What.

Text components are ASCII slugs of at most 40 characters. Duplicate paths use
`-2`, `-3` before the extension, assigned in source-identity order. The report
content and the original document bytes and metadata are preserved.
values and the original document bytes and metadata are preserved.

Native accountant CSV, copied TSV, and the package CSV sort by cash date from
earliest to latest. Equal dates sort by payment identity while retaining each
payment's saved invoice allocation order.