+ * @param {string} [o.summaryBlock] optionaler Block oberhalb der Positionen
+ */
+async function buildReportHtml(o) {
+ if (!o.methodology) {
+ throw new Error('buildReportHtml: methodology is required β it must appear in the PDF header');
+ }
+ const template = await fs.readFile(TEMPLATE_PATH, 'utf-8');
+ const logoHTML = await getLogoHtml();
+ const generated = new Date().toISOString().split('T')[0];
+
+ return template
+ .replace('{{LOGO_HTML}}', logoHTML)
+ .replace('{{COMPANY_NAME}}', COMPANY_NAME)
+ .replace('{{COMPANY_ADDRESS}}', COMPANY_ADDRESS)
+ .replace('{{SLOGAN}}', SLOGAN)
+ .replace('{{REPORT_TITLE}}', escapeHtml(o.title))
+ .replace('{{ANONYMIZED_NOTE}}', o.anonymized ? ' (anonymized)' : '')
+ .replace('{{REPORT_META}}', escapeHtml(o.meta))
+ .replace('{{METHODOLOGY}}', escapeHtml(o.methodology))
+ .replace('{{SUMMARY_BLOCK}}', o.summaryBlock || '')
+ .replace('{{DETAIL_TITLE}}', escapeHtml(o.detailTitle || 'Detail'))
+ .replace('{{TABLE_HEAD}}', o.tableHead)
+ .replace('{{REPORT_BODY}}', o.tableBody)
+ .replace('{{GENERATED_DATE}}', generated);
+}
+
+/** Erzeugt das PDF und schickt es als Download β wie renderReportPdf in accounting.js. */
+async function sendReportPdf(res, html, filename) {
+ const pdf = await generatePdfFromHtml(html);
+ const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '-');
+ res.set({
+ 'Content-Type': 'application/pdf',
+ 'Content-Length': pdf.length,
+ 'Content-Disposition': `attachment; filename="${sanitized}.pdf"`
+ });
+ res.end(pdf, 'binary');
+}
+
+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Report 1 β Accounts Receivable Aging
+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const AR_BUCKET_LABELS = [
+ ['current', 'Current'],
+ ['d1_30', '1-30 days'],
+ ['d31_60', '31-60 days'],
+ ['d61_90', '61-90 days'],
+ ['d90_plus', '90+ days']
+];
+
+function arSummaryBlock(data) {
+ const rows = AR_BUCKET_LABELS.map(([key, label]) => {
+ const b = data.buckets[key];
+ return `
+ | ${label} |
+ ${b.invoice_count} |
+ ${money(b.original_amount)} |
+ ${money(b.open_amount)} |
+
`;
+ }).join('');
+
+ const t = data.totals;
+ return `
+ Aging Summary
+
+
+ | Bucket | Invoices | Original | Open |
+
+
+ ${rows}
+
+ | Total |
+ ${t.invoice_count} |
+ ${money(t.original_amount)} |
+ ${money(t.open_amount)} |
+
+
+
`;
+}
+
+async function renderArAgingPdf(res, data) {
+ const labelOf = Object.fromEntries(AR_BUCKET_LABELS);
+
+ let body = data.rows.map(r => `
+
+ | ${escapeHtml(r.customer_name || 'β')} |
+ ${escapeHtml(r.invoice_number || 'β')} |
+ ${fmtDate(r.invoice_date)} |
+ ${fmtDate(r.due_date)} |
+ ${money(r.original_amount)} |
+ ${money(r.open_amount)} |
+ ${labelOf[r.bucket] || r.bucket} |
+
`).join('');
+
+ if (!data.rows.length) {
+ body = `| No open receivables as of ${escapeHtml(data.asOf)}. |
`;
+ } else {
+ const t = data.totals;
+ body += `
+
+ | TOTAL (${t.invoice_count} invoices) |
+ ${money(t.original_amount)} |
+ ${money(t.open_amount)} |
+ |
+
`;
+ }
+
+ const html = await buildReportHtml({
+ title: 'ACCOUNTS RECEIVABLE AGING',
+ anonymized: data.anonymized,
+ meta: `As of: ${data.asOf}`,
+ methodology: data.methodology,
+ summaryBlock: data.rows.length ? arSummaryBlock(data) : '',
+ detailTitle: 'Open Invoices',
+ tableHead: `
+ | Customer | Invoice # | Date | Due |
+ Original | Open | Bucket |
+
`,
+ tableBody: body
+ });
+
+ await sendReportPdf(res, html,
+ `AR-Aging-${data.asOf}${data.anonymized ? '-anonymized' : ''}`);
+}
+
+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Report 2 β Invoice-Level Revenue
+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function monthLabel(monthKey) {
+ const [y, m] = monthKey.split('-').map(Number);
+ return new Date(Date.UTC(y, m - 1, 1))
+ .toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
+}
+
+function revenueRow(r) {
+ return `
+
+ | ${escapeHtml(r.customer_name || 'β')} |
+ ${escapeHtml(r.invoice_number || 'β')} |
+ ${fmtDate(r.invoice_date)} |
+ ${money(r.subtotal)} |
+ ${money(r.tax_amount)} |
+ ${money(r.total)} |
+ ${money(r.paid_amount)} |
+ ${money(r.open_amount)} |
+ ${escapeHtml(r.payment_status || 'β')} |
+
`;
+}
+
+function revenueSummaryRow(label, s, cls) {
+ return `
+
+ | ${escapeHtml(label)} (${s.invoice_count} invoices) |
+ ${money(s.subtotal)} |
+ ${money(s.tax_amount)} |
+ ${money(s.total)} |
+ ${money(s.paid_amount)} |
+ ${money(s.open_amount)} |
+ |
+
`;
+}
+
+async function renderRevenuePdf(res, data) {
+ let body = '';
+
+ if (!data.rows.length) {
+ body = `| No invoices in this period. |
`;
+ } else if (data.groupByMonth) {
+ // Monats-Zwischensummen stammen aus SQL, nicht aus JS-Addition
+ const sumsByMonth = new Map(data.months.map(m => [m.month_key, m]));
+ let currentMonth = null;
+ for (const r of data.rows) {
+ if (r.month_key !== currentMonth) {
+ if (currentMonth !== null) {
+ body += revenueSummaryRow('Group Total', sumsByMonth.get(currentMonth), 'group-total');
+ }
+ currentMonth = r.month_key;
+ body += ``;
+ }
+ body += revenueRow(r);
+ }
+ if (currentMonth !== null) {
+ body += revenueSummaryRow('Group Total', sumsByMonth.get(currentMonth), 'group-total');
+ }
+ body += revenueSummaryRow('TOTAL', data.totals, 'grand-total');
+ } else {
+ body = data.rows.map(revenueRow).join('');
+ body += revenueSummaryRow('TOTAL', data.totals, 'grand-total');
+ }
+
+ const html = await buildReportHtml({
+ title: 'INVOICE-LEVEL REVENUE',
+ anonymized: data.anonymized,
+ meta: `Period: ${data.from} to ${data.to}`
+ + (data.groupByMonth ? ' Β· grouped by month' : ''),
+ methodology: data.methodology,
+ detailTitle: data.groupByMonth ? 'Invoices by Month' : 'Invoices',
+ tableHead: `
+ | Customer | Invoice # | Date |
+ Subtotal | Tax | Total |
+ Paid | Open | Status |
+
`,
+ tableBody: body
+ });
+
+ await sendReportPdf(res, html,
+ `Invoice-Revenue-${data.from}-to-${data.to}${data.anonymized ? '-anonymized' : ''}`);
+}
+
+module.exports = {
+ buildReportHtml,
+ sendReportPdf,
+ renderArAgingPdf,
+ renderRevenuePdf
+};
diff --git a/templates/dd-report-template.html b/templates/dd-report-template.html
new file mode 100644
index 0000000..f1dc8cb
--- /dev/null
+++ b/templates/dd-report-template.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
{{REPORT_TITLE}}{{ANONYMIZED_NOTE}}
+
+
+
+
Methodology: {{METHODOLOGY}}
+
+ {{SUMMARY_BLOCK}}
+
+
{{DETAIL_TITLE}}
+
+
+ {{TABLE_HEAD}}
+
+
+ {{REPORT_BODY}}
+
+
+
+
+ Generated: {{GENERATED_DATE}}
+
+
+
+