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
11 changes: 11 additions & 0 deletions backend/src/finance/accountantReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,14 @@ export function formatAccountantTsv(entries: AccountantReportEntry[]) {
}
return lines.join("\n");
}

/** Email-ready semantic table using the same cells and order as CSV/TSV. */
export function formatAccountantHtml(entries: AccountantReportEntry[]) {
const escape = (value: unknown) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[character]!);
const headers = HEADERS.map((header) => `<th scope="col" style="padding:2px 3px;vertical-align:middle;border:1px solid rgb(204,204,204);text-align:left;font-weight:bold">${escape(header)}</th>`).join("");
const rows = chronologicalReportRows(entries).map((entry) => `<tr>${values(entry).map((value, index) =>
`<td style="padding:2px 3px;vertical-align:middle;border:1px solid rgb(204,204,204);white-space:pre-wrap${index === 3 ? ";text-align:right" : ""}">${escape(value)}</td>`).join("")}</tr>`);
return `<table cellspacing="0" cellpadding="0" dir="ltr" border="1" style="font-size:10pt;font-family:Arial;border-collapse:collapse"><thead><tr>${headers}</tr></thead><tbody>${rows.join("")}</tbody></table>`;
}
12 changes: 7 additions & 5 deletions backend/src/routes/bookkeepingAccountantReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
accountantReportRows,
accountantReportRowsForPeriod,
formatAccountantCsv,
formatAccountantHtml,
formatAccountantTsv,
isAccountantReportRowIncluded,
unconvertedAccountantReportRows,
Expand All @@ -13,11 +14,11 @@ import { assertAccountantPaymentReady, MonthGateError } from "../reconcile/month
import type { LambdaEvent, LambdaResponse } from "../types";
import { json } from "./bookkeepingShared";

export { accountantReportRows, formatAccountantCsv, formatAccountantTsv };
export { accountantReportRows, formatAccountantCsv, formatAccountantTsv, formatAccountantHtml };

/**
* The native accountant Copy/CSV formatter, also available to the generic
* `dataops api` CLI. This is separate from the reconciliation package CSV.
* The native accountant CSV/TSV/HTML formatter, also available to the generic
* `dataops api` CLI. CSV cells and ordering are shared with the reconciliation package.
*/
export async function handleBookkeepingAccountantReportRoutes(
path: string,
Expand All @@ -30,7 +31,7 @@ export async function handleBookkeepingAccountantReportRoutes(
const format = String(event.queryStringParameters?.format || "csv");
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(period))
return json(400, { error: "Select a report month: period must be YYYY-MM" });
if (format !== "csv" && format !== "tsv")
if (format !== "csv" && format !== "tsv" && format !== "html")
return json(400, { error: "Invalid format" });

const transactions = await listTransactions(client);
Expand All @@ -55,6 +56,7 @@ export async function handleBookkeepingAccountantReportRoutes(
format,
count: included.length,
excludedCount: reportRows.length - included.length,
content: format === "csv" ? formatAccountantCsv(included) : formatAccountantTsv(included),
content: format === "html" ? formatAccountantHtml(included)
: format === "csv" ? formatAccountantCsv(included) : formatAccountantTsv(included),
});
}
65 changes: 61 additions & 4 deletions backend/tests/bookkeeping-accountant-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
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 { formatAccountantCsv, formatAccountantHtml, selectAccountantReportRows } from "../src/finance/accountantReport";
import { monthPackage } from "../src/reconcile/monthService";

const entries = [
Expand Down Expand Up @@ -123,16 +123,19 @@ describe("native accountant report API", () => {
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) {
for (const format of ["csv", "tsv", "html"] 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"));
const cells = format === "html"
? [...report.content.matchAll(/<tbody>([\s\S]*)<\/tbody>/g)][0][1].split("</tr>").filter(Boolean)
.map((line: string) => [...line.matchAll(/<td[^>]*>(.*?)<\/td>/g)].map((match) => match[1]))
: 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"]);
Expand All @@ -143,6 +146,60 @@ describe("native accountant report API", () => {
assert.equal(JSON.stringify(rows), before, "formatting preserves source dates, amounts and allocation order");
});

it("serves a semantic HTML table with the same cells and cash order as TSV", async () => {
const rows = [...entries, { id: "html-special", paidDate: "2026-10-31", amount: "1.25", currency: "EUR", entryType: "expense",
counterparty: `Invented & <Vendor> "Quote" 'A'`, description: "<script>alert(1)</script>\nSecond line" }];
const source = verifiedClient(rows);
const response = await handleBookkeepingRoutes("/api/bookkeeping/reports/accountant", "GET", {
httpMethod: "GET", path: "/api/bookkeeping/reports/accountant", headers: {},
queryStringParameters: { period: "2026-10", format: "html" },
}, source, true);
assert.equal(response.statusCode, 200, response.body);
const report = JSON.parse(response.body);
assert.equal(report.format, "html");
assert.equal(report.count, 3);
assert.equal(report.excludedCount, 2);
assert.match(report.content, /^<table[^>]*><thead><tr>/);
assert.equal((report.content.match(/<th scope="col"/g) || []).length, 4);
assert.equal((report.content.match(/<td /g) || []).length, 12);
assert.match(report.content, /Invented &amp; &lt;Vendor&gt; &quot;Quote&quot; &#39;A&#39;/);
assert.match(report.content, /&lt;script&gt;alert\(1\)&lt;\/script&gt;\nSecond line/);
assert.doesNotMatch(report.content, /<script|Synthetic Owner|Synthetic Tax Office|Stale Alias/);
const decode = (value: string) => value.replace(/&(amp|lt|gt|quot|#39);/g, (_, entity: string) =>
({ amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" })[entity]!);
const htmlCells = [...report.content.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((match) => decode(match[1]));
const tsvResponse = await handleBookkeepingRoutes("/api/bookkeeping/reports/accountant", "GET", {
httpMethod: "GET", path: "/api/bookkeeping/reports/accountant", headers: {},
queryStringParameters: { period: "2026-10", format: "tsv" },
}, source, true);
const tsvCells = JSON.parse(tsvResponse.body).content.split("\n").slice(1).flatMap((line: string) => line.split("\t"));
assert.deepEqual(htmlCells.map((cell) => cell.replace(/[\t\r\n]/g, " ")), tsvCells);
assert.match(formatAccountantHtml([]), /<tbody><\/tbody>/);
assert.equal(formatAccountantHtml([{ counterparty: "=text", description: "+text" }]).includes(">=text</td>"), true,
"HTML uses original text, without spreadsheet formula guards");
});

it("applies the same payment readiness and currency gates to HTML", async () => {
const row = { id: "invented-pending", transactionDate: "2033-10-01", amount: "5.00", currency: "EUR", entryType: "expense" };
const unverified = { send: async (command: any) => ({ Items: command.input.ExpressionAttributeValues?.[":transaction"] ? [row] : [] }) } as unknown as DynamoDBDocumentClient;
for (const format of ["csv", "tsv", "html"]) {
const response = await handleBookkeepingRoutes("/api/bookkeeping/reports/accountant", "GET", {
httpMethod: "GET", path: "/api/bookkeeping/reports/accountant", headers: {},
queryStringParameters: { period: "2033-10", format },
}, unverified, true);
assert.equal(response.statusCode, 409, response.body);
const body = JSON.parse(response.body);
assert.ok(body.blockers.length);
assert.equal(body.content, undefined);
}
const response = await handleBookkeepingRoutes("/api/bookkeeping/reports/accountant", "GET", {
httpMethod: "GET", path: "/api/bookkeeping/reports/accountant", headers: {},
queryStringParameters: { period: "2033-10", format: "html" },
}, verifiedClient([{ ...row, currency: "USD" }]), true);
assert.equal(response.statusCode, 409, response.body);
assert.deepEqual(JSON.parse(response.body).unconvertedTransactionIds, [row.id]);
});

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
12 changes: 12 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,15 @@ exit code 5 and HTTP status 409. `error.details` retains only the documented
`reportPeriod` and `blockers` fields (including row identities and reasons).
Native draft packages and direct accountant CSV/TSV API calls use the same error
contract and write no report content or package files when blocked.

The accountant report also accepts `format=html` through the same read-only API:

```sh
dataops api GET '/api/bookkeeping/reports/accountant?period=YYYY-MM&format=html' --json
```

The JSON response keeps `reportPeriod`, `format`, `count`, `excludedCount` and
`content`; `content` is an escaped semantic four-column HTML table suitable for
an email body. It uses the same report rows, cash-date ordering, EUR values and
readiness gates as CSV/TSV. HTML retains the original cell text and line breaks;
spreadsheet-only formula protection is unnecessary in escaped HTML text.