pdf export

This commit is contained in:
2026-07-26 17:05:48 -05:00
parent 363a14d49b
commit e086fe8931
4 changed files with 473 additions and 27 deletions

View File

@@ -16,6 +16,7 @@
const express = require('express');
const router = express.Router();
const { pool } = require('../config/database');
const { renderArAgingPdf, renderRevenuePdf } = require('../services/report-pdf-service');
// ────────────────────────────────────────────────────────────────────
// Helpers
@@ -140,14 +141,13 @@ aged AS (
WHERE b.open_amount > 0
)`;
router.get('/ar-aging', async (req, res) => {
try {
const asOf = req.query.asOf || todayISO();
if (!DATE_RE.test(asOf)) {
return res.status(400).json({ error: 'asOf must be a valid date (YYYY-MM-DD)' });
}
const doAnonymize = isTrue(req.query.anonymize);
/**
* Beschafft die AR-Aging-Daten. Bewusst als eigene Funktion, damit der
* JSON-Endpoint und der PDF-Endpoint exakt dieselbe Query, dieselben
* SQL-Summen und dieselbe Anonymisierung verwenden — Bildschirm und PDF
* können so gar nicht auseinanderlaufen.
*/
async function fetchArAging(asOf, doAnonymize) {
const rowsResult = await pool.query(
`${AR_BASE_CTE}
SELECT id, invoice_number, invoice_date, terms, customer_id, customer_name,
@@ -190,7 +190,7 @@ router.get('/ar-aging', async (req, res) => {
? anonymizeRows(rowsResult.rows, await buildPseudonymMap())
: rowsResult.rows;
res.json({
return {
asOf,
anonymized: doAnonymize,
methodology:
@@ -203,7 +203,16 @@ router.get('/ar-aging', async (req, res) => {
buckets,
totals,
rows
});
};
}
router.get('/ar-aging', async (req, res) => {
try {
const asOf = req.query.asOf || todayISO();
if (!DATE_RE.test(asOf)) {
return res.status(400).json({ error: 'asOf must be a valid date (YYYY-MM-DD)' });
}
res.json(await fetchArAging(asOf, isTrue(req.query.anonymize)));
} catch (err) {
console.error('ar-aging error:', err.message);
res.status(500).json({ error: err.message });
@@ -229,21 +238,8 @@ const REVENUE_FROM_SQL = `
) pa ON pa.invoice_id = i.id
WHERE i.invoice_date >= $1::date AND i.invoice_date <= $2::date`;
router.get('/revenue', async (req, res) => {
try {
const { from, to } = req.query;
if (!from || !to) {
return res.status(400).json({ error: 'from and to are required' });
}
if (!DATE_RE.test(from) || !DATE_RE.test(to)) {
return res.status(400).json({ error: 'from and to must be valid dates (YYYY-MM-DD)' });
}
if (from > to) {
return res.status(400).json({ error: 'from must not be after to' });
}
const doAnonymize = isTrue(req.query.anonymize);
const groupByMonth = isTrue(req.query.groupByMonth);
/** Siehe fetchArAging — dieselbe Begründung für die gemeinsame Nutzung. */
async function fetchRevenue(from, to, doAnonymize, groupByMonth) {
const rowsResult = await pool.query(
`SELECT
i.id,
@@ -302,7 +298,7 @@ router.get('/revenue', async (req, res) => {
? anonymizeRows(rowsResult.rows, await buildPseudonymMap())
: rowsResult.rows;
res.json({
return {
from,
to,
anonymized: doAnonymize,
@@ -317,11 +313,70 @@ router.get('/revenue', async (req, res) => {
months,
totals,
rows
});
};
}
/** Gemeinsame Validierung für /revenue und /revenue/pdf. */
function validateRevenueParams(query) {
const { from, to } = query;
if (!from || !to) return { error: 'from and to are required' };
if (!DATE_RE.test(from) || !DATE_RE.test(to)) {
return { error: 'from and to must be valid dates (YYYY-MM-DD)' };
}
if (from > to) return { error: 'from must not be after to' };
return { from, to };
}
router.get('/revenue', async (req, res) => {
try {
const v = validateRevenueParams(req.query);
if (v.error) return res.status(400).json({ error: v.error });
res.json(await fetchRevenue(
v.from, v.to, isTrue(req.query.anonymize), isTrue(req.query.groupByMonth)
));
} catch (err) {
console.error('revenue error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ────────────────────────────────────────────────────────────────────
// PDF-Exporte
//
// Die Daten werden hier serverseitig NEU geholt — die fertigen Zeilen des
// Clients werden bewusst nicht entgegengenommen. Damit lässt sich die
// Anonymisierung nicht clientseitig umgehen; sie hängt allein am
// anonymize-Query-Parameter, den dieselbe fetch-Funktion auswertet wie
// die JSON-Route.
// ────────────────────────────────────────────────────────────────────
router.get('/ar-aging/pdf', async (req, res) => {
try {
const asOf = req.query.asOf || todayISO();
if (!DATE_RE.test(asOf)) {
return res.status(400).json({ error: 'asOf must be a valid date (YYYY-MM-DD)' });
}
const doAnonymize = isTrue(req.query.anonymize);
const data = await fetchArAging(asOf, doAnonymize);
await renderArAgingPdf(res, data);
} catch (err) {
console.error('ar-aging pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
router.get('/revenue/pdf', async (req, res) => {
try {
const v = validateRevenueParams(req.query);
if (v.error) return res.status(400).json({ error: v.error });
const data = await fetchRevenue(
v.from, v.to, isTrue(req.query.anonymize), isTrue(req.query.groupByMonth)
);
await renderRevenuePdf(res, data);
} catch (err) {
console.error('revenue pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,281 @@
/**
* report-pdf-service.js — PDF-Erzeugung für die Due-Diligence-Reports
*
* Nutzt bewusst dieselbe Puppeteer-Instanz wie alle übrigen PDFs: die Engine
* ist generatePdfFromHtml() aus pdf-service.js, das auf dem beim App-Start
* gestarteten Browser arbeitet (src/index.js initBrowser → setBrowser).
* Hier wird keine zweite Engine eingeführt.
*
* Die Methodik-Zeile ist fester Bestandteil des Kopfes — buildReportHtml()
* verlangt sie, damit ein Export sie nicht versehentlich weglassen kann.
*/
const path = require('path');
const fs = require('fs').promises;
const { generatePdfFromHtml, getLogoHtml } = require('./pdf-service');
const { formatMoney } = require('../utils/helpers');
const TEMPLATE_PATH = path.join(__dirname, '..', '..', 'templates', 'dd-report-template.html');
const COMPANY_NAME = 'Bay Area Affiliates, Inc.';
const COMPANY_ADDRESS = '1001 Blucher Street<br>Corpus Christi, Texas 78401';
const SLOGAN = 'Providing IT Services and Support in South Texas Since 1996';
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/** numeric-String/Zahl → "$1,234.56" (formatMoney liefert ohne Dollarzeichen). */
function money(v) {
return `$${formatMoney(v ?? 0)}`;
}
/**
* → MM/DD/YYYY, identisch zu formatDate() in public/js/utils/helpers.js.
*
* Wichtig: Der pg-Treiber gibt date-Spalten als Date-Objekte zurück, nicht als
* Strings. Im JSON-Weg serialisiert res.json() sie vorher nach ISO, hier kommen
* sie roh an — ohne den Date-Zweig landete man bei "Thu Jul 02 2026 00:00:00
* GMT-0500 (…)". Wie im Frontend wird nach UTC gelesen, damit beide Wege für
* denselben Datensatz dasselbe Datum zeigen.
*/
function fmtDate(v) {
if (!v) return '—';
if (v instanceof Date) {
const m = String(v.getUTCMonth() + 1).padStart(2, '0');
const d = String(v.getUTCDate()).padStart(2, '0');
return `${m}/${d}/${v.getUTCFullYear()}`;
}
const parts = String(v).split('T')[0].split('-');
if (parts.length !== 3) return String(v);
return `${parts[1]}/${parts[2]}/${parts[0]}`;
}
/**
* Baut das Report-HTML aus dem gemeinsamen Template.
* @param {object} o
* @param {string} o.title Titel in Großbuchstaben, z.B. "ACCOUNTS RECEIVABLE AGING"
* @param {string} o.meta Zeitraum-/Stichtagszeile
* @param {string} o.methodology Pflichtfeld — landet fest im Kopf
* @param {boolean} o.anonymized hängt " (anonymized)" an den Titel
* @param {string} o.detailTitle Überschrift über der Positionstabelle
* @param {string} o.tableHead <tr>…</tr> für <thead>
* @param {string} o.tableBody <tr>…</tr>-Folge für <tbody>
* @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 `<tr>
<td class="text">${label}</td>
<td class="center">${b.invoice_count}</td>
<td>${money(b.original_amount)}</td>
<td>${money(b.open_amount)}</td>
</tr>`;
}).join('');
const t = data.totals;
return `
<div class="section-title">Aging Summary</div>
<table class="items-table summary-table">
<thead>
<tr><th>Bucket</th><th>Invoices</th><th>Original</th><th>Open</th></tr>
</thead>
<tbody>
${rows}
<tr class="grand-total">
<td class="text">Total</td>
<td class="center">${t.invoice_count}</td>
<td>${money(t.original_amount)}</td>
<td>${money(t.open_amount)}</td>
</tr>
</tbody>
</table>`;
}
async function renderArAgingPdf(res, data) {
const labelOf = Object.fromEntries(AR_BUCKET_LABELS);
let body = data.rows.map(r => `
<tr>
<td class="text">${escapeHtml(r.customer_name || '—')}</td>
<td class="text">${escapeHtml(r.invoice_number || '—')}</td>
<td class="center">${fmtDate(r.invoice_date)}</td>
<td class="center">${fmtDate(r.due_date)}</td>
<td>${money(r.original_amount)}</td>
<td>${money(r.open_amount)}</td>
<td class="center">${labelOf[r.bucket] || r.bucket}</td>
</tr>`).join('');
if (!data.rows.length) {
body = `<tr><td class="text" colspan="7">No open receivables as of ${escapeHtml(data.asOf)}.</td></tr>`;
} else {
const t = data.totals;
body += `
<tr class="grand-total">
<td class="text" colspan="4">TOTAL (${t.invoice_count} invoices)</td>
<td>${money(t.original_amount)}</td>
<td>${money(t.open_amount)}</td>
<td></td>
</tr>`;
}
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: `<tr>
<th>Customer</th><th>Invoice #</th><th>Date</th><th>Due</th>
<th>Original</th><th>Open</th><th>Bucket</th>
</tr>`,
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 `
<tr>
<td class="text">${escapeHtml(r.customer_name || '—')}</td>
<td class="text">${escapeHtml(r.invoice_number || '—')}</td>
<td class="center">${fmtDate(r.invoice_date)}</td>
<td>${money(r.subtotal)}</td>
<td>${money(r.tax_amount)}</td>
<td>${money(r.total)}</td>
<td>${money(r.paid_amount)}</td>
<td>${money(r.open_amount)}</td>
<td class="center">${escapeHtml(r.payment_status || '—')}</td>
</tr>`;
}
function revenueSummaryRow(label, s, cls) {
return `
<tr class="${cls}">
<td class="text" colspan="3">${escapeHtml(label)} (${s.invoice_count} invoices)</td>
<td>${money(s.subtotal)}</td>
<td>${money(s.tax_amount)}</td>
<td>${money(s.total)}</td>
<td>${money(s.paid_amount)}</td>
<td>${money(s.open_amount)}</td>
<td></td>
</tr>`;
}
async function renderRevenuePdf(res, data) {
let body = '';
if (!data.rows.length) {
body = `<tr><td class="text" colspan="9">No invoices in this period.</td></tr>`;
} 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 += `<tr class="group-header"><td colspan="9">${escapeHtml(monthLabel(r.month_key))}</td></tr>`;
}
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: `<tr>
<th>Customer</th><th>Invoice #</th><th>Date</th>
<th>Subtotal</th><th>Tax</th><th>Total</th>
<th>Paid</th><th>Open</th><th>Status</th>
</tr>`,
tableBody: body
});
await sendReportPdf(res, html,
`Invoice-Revenue-${data.from}-to-${data.to}${data.anonymized ? '-anonymized' : ''}`);
}
module.exports = {
buildReportHtml,
sendReportPdf,
renderArAgingPdf,
renderRevenuePdf
};