/** * Reports Routes — /api/reports/* * * Due-Diligence-Reports: * GET /api/reports/ar-aging?asOf=YYYY-MM-DD&anonymize=true|false * GET /api/reports/revenue?from=YYYY-MM-DD&to=YYYY-MM-DD&anonymize=true|false&groupByMonth=true|false * * Anonymisierung läuft ausschließlich serverseitig: bei anonymize=true verlassen * weder Kundenname noch customer_id oder bill_to_name den Server, damit auch ein * CSV-Export beim Käufer keine Klarnamen enthält. * * Beträge bleiben durchgängig numeric (exakte Dezimalarithmetik in Postgres) und * werden als Strings an den Client gereicht — kein Float-Zwischenschritt, keine * Rundungsfehler. Auch alle Summen werden in SQL gebildet, nicht in JS. */ const express = require('express'); const router = express.Router(); const { pool } = require('../config/database'); // ──────────────────────────────────────────────────────────────────── // Helpers // ──────────────────────────────────────────────────────────────────── const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; function todayISO() { return new Date().toISOString().split('T')[0]; } /** * Zahlungsziel aus dem Freitext-Feld invoices.terms. * Identische Logik wie im Frontend (invoice-view.js getTermDays). * Im Produktivbestand kommen nur 'Net 30' / 'Net 14' / 'Net 15' vor — der * ELSE-Zweig greift dort nie, bleibt aber als Absicherung für neue Freitexte. */ const TERM_DAYS_SQL = ` CASE WHEN lower(COALESCE(i.terms, '')) LIKE '%receipt%' THEN 0 WHEN lower(COALESCE(i.terms, '')) ~ 'net[[:space:]]*[0-9]+' THEN (substring(lower(i.terms) from 'net[[:space:]]*([0-9]+)'))::int ELSE 14 END`; const UNASSIGNED_PSEUDONYM = 'Customer (unassigned)'; /** * Deterministisches Pseudonym-Mapping über den GESAMTEN Kundenstamm, nicht nur * über die Zeilen des jeweiligen Reports. * * Das ist bewusst so: würde pro Report dicht von 001 an durchnummeriert, wäre * "Customer 001" im AR-Aging ein anderer Kunde als "Customer 001" im Revenue- * Report — der Käufer könnte Forderungsrisiko und Umsatz desselben Kunden nicht * zusammenführen und würde beim Versuch falsche Schlüsse ziehen. Über den * Kundenstamm nummeriert bleibt ein Pseudonym stabil über beide Reports und über * beliebige Zeiträume hinweg. Preis dafür sind Lücken in der Nummernfolge, die * keine zusätzliche Information preisgeben. * * Rechnungen ohne customer_id (bill_to_name-Fallback) lassen sich nicht stabil * nummerieren und laufen alle unter einem Sammelpseudonym. */ async function buildPseudonymMap() { const result = await pool.query('SELECT id FROM customers ORDER BY id ASC'); const map = new Map(); result.rows.forEach((r, idx) => { map.set(Number(r.id), `Customer ${String(idx + 1).padStart(3, '0')}`); }); return map; } /** * Ersetzt Klarnamen durch Pseudonyme und entfernt alle verbleibenden * Identifikatoren aus der Payload — customer_id wäre sonst ein Rückkanal, * über den sich die Pseudonymisierung trivial rückgängig machen ließe. */ function anonymizeRows(rows, map) { return rows.map(r => { const { customer_id, ...rest } = r; const label = customer_id != null ? (map.get(Number(customer_id)) || UNASSIGNED_PSEUDONYM) : UNASSIGNED_PSEUDONYM; return { ...rest, customer_name: label }; }); } function isTrue(v) { return v === 'true' || v === true; } // ──────────────────────────────────────────────────────────────────── // Report 1 — Accounts Receivable Aging // ──────────────────────────────────────────────────────────────────── const AR_BUCKETS = ['current', 'd1_30', 'd31_60', 'd61_90', 'd90_plus']; /** * $1 = Stichtag (asOf). * * Der Stichtagsfilter sitzt bewusst INNERHALB der CTE paid_as_of. Stünde * "WHERE p.payment_date <= $1" im Hauptquery, würde der LEFT JOIN zum INNER * JOIN degradieren und alle nie bezahlten Rechnungen verschlucken. So zählen * nur Zahlungen bis zum Stichtag; alles danach existiert für den Report nicht. */ const AR_BASE_CTE = ` WITH paid_as_of AS ( SELECT pi.invoice_id, SUM(pi.amount) AS paid_amount FROM payment_invoices pi JOIN payments p ON p.id = pi.payment_id WHERE p.payment_date <= $1::date GROUP BY pi.invoice_id ), base AS ( SELECT i.id, i.invoice_number, i.invoice_date, i.terms, i.customer_id, COALESCE(c.name, i.bill_to_name) AS customer_name, i.total::numeric(12,2) AS original_amount, COALESCE(pa.paid_amount, 0)::numeric(12,2) AS paid_amount, (i.total - COALESCE(pa.paid_amount, 0))::numeric(12,2) AS open_amount, (i.invoice_date + (${TERM_DAYS_SQL})) AS due_date FROM invoices i LEFT JOIN customers c ON c.id = i.customer_id LEFT JOIN paid_as_of pa ON pa.invoice_id = i.id WHERE i.invoice_date <= $1::date ), aged AS ( SELECT b.*, ($1::date - b.due_date) AS days_past_due, CASE WHEN $1::date - b.due_date <= 0 THEN 'current' WHEN $1::date - b.due_date <= 30 THEN 'd1_30' WHEN $1::date - b.due_date <= 60 THEN 'd31_60' WHEN $1::date - b.due_date <= 90 THEN 'd61_90' ELSE 'd90_plus' END AS bucket FROM base b 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); const rowsResult = await pool.query( `${AR_BASE_CTE} SELECT id, invoice_number, invoice_date, terms, customer_id, customer_name, original_amount, paid_amount, open_amount, due_date, days_past_due, bucket FROM aged ORDER BY customer_name NULLS LAST, invoice_date, id`, [asOf] ); // Summen in SQL: GROUPING SETS liefert Bucket-Zeilen + Gesamtzeile (bucket IS NULL) const sumsResult = await pool.query( `${AR_BASE_CTE} SELECT bucket, COUNT(*)::integer AS invoice_count, SUM(original_amount)::numeric(12,2) AS original_amount, SUM(open_amount)::numeric(12,2) AS open_amount FROM aged GROUP BY GROUPING SETS ((bucket), ())`, [asOf] ); const buckets = {}; for (const key of AR_BUCKETS) { buckets[key] = { invoice_count: 0, original_amount: '0.00', open_amount: '0.00' }; } let totals = { invoice_count: 0, original_amount: '0.00', open_amount: '0.00' }; for (const r of sumsResult.rows) { const entry = { invoice_count: r.invoice_count, original_amount: r.original_amount, open_amount: r.open_amount }; if (r.bucket === null) totals = entry; else if (buckets[r.bucket]) buckets[r.bucket] = entry; } const rows = doAnonymize ? anonymizeRows(rowsResult.rows, await buildPseudonymMap()) : rowsResult.rows; res.json({ asOf, anonymized: doAnonymize, methodology: `Aging basis: invoice_date + payment terms (no separate due-date field exists). ` + `Open amounts are net of payments with payment_date on or before ${asOf}; ` + `later payments are excluded. Invoices dated after ${asOf} are not included.` + (doAnonymize ? ' Customer names are replaced by pseudonyms; the same pseudonym denotes the same customer across both reports and all periods.' : ''), buckets, totals, rows }); } catch (err) { console.error('ar-aging error:', err.message); res.status(500).json({ error: err.message }); } }); // ──────────────────────────────────────────────────────────────────── // Report 2 — Invoice-Level Revenue // ──────────────────────────────────────────────────────────────────── /** * $1 = from, $2 = to (beide auf invoice_date). * Bezahlt/Offen spiegeln hier den AKTUELLEN Zahlungsstand (keine * Stichtagsbegrenzung) — anders als im AR-Aging. Steht so in der Methodik-Zeile. */ const REVENUE_FROM_SQL = ` FROM invoices i LEFT JOIN customers c ON c.id = i.customer_id LEFT JOIN ( SELECT invoice_id, SUM(amount) AS paid_amount FROM payment_invoices GROUP BY invoice_id ) 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); const rowsResult = await pool.query( `SELECT i.id, i.invoice_number, i.invoice_date, i.customer_id, COALESCE(c.name, i.bill_to_name) AS customer_name, i.subtotal::numeric(12,2) AS subtotal, i.tax_amount::numeric(12,2) AS tax_amount, i.total::numeric(12,2) AS total, COALESCE(pa.paid_amount, 0)::numeric(12,2) AS paid_amount, (i.total - COALESCE(pa.paid_amount, 0))::numeric(12,2) AS open_amount, i.payment_status, to_char(i.invoice_date, 'YYYY-MM') AS month_key ${REVENUE_FROM_SQL} ORDER BY i.invoice_date, i.id`, [from, to] ); const sumsResult = await pool.query( `SELECT to_char(i.invoice_date, 'YYYY-MM') AS month_key, COUNT(*)::integer AS invoice_count, SUM(i.subtotal)::numeric(12,2) AS subtotal, SUM(i.tax_amount)::numeric(12,2) AS tax_amount, SUM(i.total)::numeric(12,2) AS total, SUM(COALESCE(pa.paid_amount, 0))::numeric(12,2) AS paid_amount, SUM(i.total - COALESCE(pa.paid_amount, 0))::numeric(12,2) AS open_amount ${REVENUE_FROM_SQL} GROUP BY GROUPING SETS ((to_char(i.invoice_date, 'YYYY-MM')), ()) ORDER BY month_key NULLS LAST`, [from, to] ); const emptyTotals = { invoice_count: 0, subtotal: '0.00', tax_amount: '0.00', total: '0.00', paid_amount: '0.00', open_amount: '0.00' }; let totals = emptyTotals; const months = []; for (const r of sumsResult.rows) { const entry = { invoice_count: r.invoice_count, subtotal: r.subtotal, tax_amount: r.tax_amount, total: r.total, paid_amount: r.paid_amount, open_amount: r.open_amount }; if (r.month_key === null) totals = entry; else months.push({ month_key: r.month_key, ...entry }); } const rows = doAnonymize ? anonymizeRows(rowsResult.rows, await buildPseudonymMap()) : rowsResult.rows; res.json({ from, to, anonymized: doAnonymize, groupByMonth, methodology: `Period filtered on invoice_date from ${from} to ${to}. ` + `Paid and open amounts reflect the current payment status as of report generation, ` + `not the end of the period.` + (doAnonymize ? ' Customer names are replaced by pseudonyms; the same pseudonym denotes the same customer across both reports and all periods.' : ''), months, totals, rows }); } catch (err) { console.error('revenue error:', err.message); res.status(500).json({ error: err.message }); } }); module.exports = router;