diff --git a/public/js/app.js b/public/js/app.js
index b6d8ed1..250455f 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -25,6 +25,7 @@ import './modals/payment-modal.js';
import './modals/email-modal.js';
import { setDefaultDate } from './utils/helpers.js';
import { renderAccountingView } from './views/accounting-view.js';
+import { renderReportsView } from './views/reports-view.js';
// ============================================================
// Tab Management
@@ -51,7 +52,9 @@ function showTab(tabName) {
checkCurrentLogo();
} else if (tabName === 'accounting') {
renderAccountingView();
- }
+ } else if (tabName === 'reports') {
+ renderReportsView();
+ }
}
// ============================================================
@@ -76,7 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Hash-based navigation (e.g. after OAuth redirect /#settings)
if (window.location.hash) {
const hashTab = window.location.hash.replace('#', '');
- if (['quotes', 'invoices', 'customers', 'accounting', 'settings'].includes(hashTab)) {
+ if (['quotes', 'invoices', 'customers', 'accounting', 'reports', 'settings'].includes(hashTab)) {
showTab(hashTab);
}
}
diff --git a/public/js/utils/api.js b/public/js/utils/api.js
index 61e748a..71ba24b 100644
--- a/public/js/utils/api.js
+++ b/public/js/utils/api.js
@@ -223,6 +223,21 @@ const API = {
}
},
+ // Due-Diligence Reports API
+ reports: {
+ getArAging: (asOf, anonymize = false) => {
+ const params = new URLSearchParams({ asOf });
+ if (anonymize) params.set('anonymize', 'true');
+ return fetch('/api/reports/ar-aging?' + params.toString()).then(r => r.json());
+ },
+ getRevenue: (from, to, anonymize = false, groupByMonth = false) => {
+ const params = new URLSearchParams({ from, to });
+ if (anonymize) params.set('anonymize', 'true');
+ if (groupByMonth) params.set('groupByMonth', 'true');
+ return fetch('/api/reports/revenue?' + params.toString()).then(r => r.json());
+ }
+ },
+
// Settings API
settings: {
getLogo: () => fetch('/api/logo-info').then(r => r.json()),
diff --git a/public/js/views/reports-view.js b/public/js/views/reports-view.js
new file mode 100644
index 0000000..423e02f
--- /dev/null
+++ b/public/js/views/reports-view.js
@@ -0,0 +1,482 @@
+/**
+ * reports-view.js — Due-Diligence Reports
+ *
+ * 1. Accounts Receivable Aging (Stichtag frei wählbar)
+ * 2. Invoice-Level Revenue (Zeitraum frei wählbar, optional nach Monat gruppiert)
+ *
+ * Beide Reports können Kundennamen pseudonymisieren. Die Anonymisierung passiert
+ * ausschließlich serverseitig — dieses Modul bekommt bei aktiver Option gar keine
+ * Klarnamen zu sehen und kann sie folglich auch nicht in ein CSV schreiben.
+ *
+ * Beträge kommen als numeric-Strings vom Server und werden nur zur Anzeige
+ * formatiert. Ins CSV gehen die Rohwerte, damit Excel sauber rechnen kann.
+ */
+
+import { formatDate } from '../utils/helpers.js';
+
+// ── State ───────────────────────────────────────────────────────────
+const today = new Date().toISOString().split('T')[0];
+const startOfYear = `${new Date().getFullYear()}-01-01`;
+
+let arAsOf = today;
+let arAnonymize = false;
+let arData = null;
+
+let revFrom = startOfYear;
+let revTo = today;
+let revAnonymize = false;
+let revGroupByMonth = false;
+let revData = null;
+
+const AR_BUCKETS = [
+ { key: 'current', label: 'Current' },
+ { key: 'd1_30', label: '1–30 days' },
+ { key: 'd31_60', label: '31–60 days' },
+ { key: 'd61_90', label: '61–90 days' },
+ { key: 'd90_plus', label: '90+ days' }
+];
+const BUCKET_LABEL = Object.fromEntries(AR_BUCKETS.map(b => [b.key, b.label]));
+
+// ── Local helpers (Stil wie accounting-view.js) ─────────────────────
+
+/** numeric-String → "$1,234.56". Kein Float-Zwischenschritt außer zur Anzeige. */
+function fmtMoney(v) {
+ if (v == null || v === '') return '—';
+ const n = parseFloat(v);
+ if (isNaN(n)) return '—';
+ const fixed = n.toFixed(2);
+ const [intPart, decPart] = fixed.replace('-', '').split('.');
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+ return `${n < 0 ? '-' : ''}$${grouped}.${decPart}`;
+}
+
+function escapeHtml(s) {
+ if (s == null) return '';
+ return String(s)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+function showError(slotId, message) {
+ const el = document.getElementById(slotId);
+ if (!el) return;
+ el.innerHTML = `
+
+
Report Error
+
${escapeHtml(message)}
+
`;
+}
+
+function showLoading(slotId, message = 'Loading…') {
+ const el = document.getElementById(slotId);
+ if (!el) return;
+ el.innerHTML = `
+
+
+
${escapeHtml(message)}
+
`;
+}
+
+function methodologyBox(text, anonymized) {
+ return `
+
+ Methodology: ${escapeHtml(text)}
+ ${anonymized ? 'anonymized' : ''}
+
`;
+}
+
+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' });
+}
+
+// ── CSV ─────────────────────────────────────────────────────────────
+
+function csvCell(v) {
+ if (v == null) return '';
+ const s = String(v);
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+}
+
+function csvRow(cells) {
+ return cells.map(csvCell).join(',');
+}
+
+function downloadCsv(filename, lines) {
+ // BOM, damit Excel UTF-8 korrekt erkennt
+ const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+// ── Shell ───────────────────────────────────────────────────────────
+
+export function renderReportsView() {
+ const el = document.getElementById('reports-content');
+ if (!el) return;
+
+ el.innerHTML = `
+
+
+
+
+
+
Accounts Receivable Aging
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Invoice-Level Revenue
+
+
+
+
`;
+}
+
+// ── Report 1: AR Aging ──────────────────────────────────────────────
+
+export async function loadArAging() {
+ arAsOf = document.getElementById('ar-asof').value || today;
+ arAnonymize = document.getElementById('ar-anonymize')?.checked || false;
+ showLoading('ar-result', 'Loading receivables…');
+ try {
+ const data = await window.API.reports.getArAging(arAsOf, arAnonymize);
+ if (data.error) return showError('ar-result', data.error);
+ arData = data;
+ renderArAging(data);
+ } catch (err) {
+ showError('ar-result', err.message || 'Failed to load AR aging');
+ }
+}
+
+function renderArAging(data) {
+ if (!data.rows.length) {
+ document.getElementById('ar-result').innerHTML =
+ methodologyBox(data.methodology, data.anonymized) +
+ '
No open receivables as of this date.
';
+ return;
+ }
+
+ let rowsHtml = '';
+ for (const r of data.rows) {
+ const overdue = r.bucket !== 'current';
+ rowsHtml += `
+ | ${escapeHtml(r.customer_name || '—')} |
+ ${escapeHtml(r.invoice_number || '—')} |
+ ${formatDate(r.invoice_date)} |
+ ${formatDate(r.due_date)} |
+ ${fmtMoney(r.original_amount)} |
+ ${fmtMoney(r.open_amount)} |
+
+ ${BUCKET_LABEL[r.bucket] || r.bucket}
+ |
+
`;
+ }
+
+ const t = data.totals;
+ rowsHtml += `
+ | TOTAL (${t.invoice_count} invoices) |
+ ${fmtMoney(t.original_amount)} |
+ ${fmtMoney(t.open_amount)} |
+ |
+
`;
+
+ let bucketCells = '';
+ for (const b of AR_BUCKETS) {
+ const entry = data.buckets[b.key];
+ bucketCells += `
+ | ${b.label} |
+ ${entry.invoice_count} |
+ ${fmtMoney(entry.open_amount)} |
+
`;
+ }
+
+ document.getElementById('ar-result').innerHTML =
+ methodologyBox(data.methodology, data.anonymized) + `
+
+
+
+
+
+ | Bucket |
+ Invoices |
+ Open |
+
+
+
+ ${bucketCells}
+
+ | Total |
+ ${t.invoice_count} |
+ ${fmtMoney(t.open_amount)} |
+
+
+
+
+
+
+
+
+
+ | Customer |
+ Invoice # |
+ Date |
+ Due |
+ Original |
+ Open |
+ Bucket |
+
+
+ ${rowsHtml}
+
+
`;
+}
+
+export function exportArAgingCsv() {
+ if (!arData || !arData.rows.length) {
+ return showError('ar-result', 'Run the report first — there is nothing to export.');
+ }
+ const lines = [];
+ lines.push(csvRow([`Accounts Receivable Aging as of ${arData.asOf}`]));
+ lines.push(csvRow([`Methodology: ${arData.methodology}`]));
+ lines.push('');
+ lines.push(csvRow(['Customer', 'Invoice #', 'Invoice Date', 'Due Date', 'Original Amount', 'Open Amount', 'Bucket']));
+
+ for (const r of arData.rows) {
+ lines.push(csvRow([
+ r.customer_name || '',
+ r.invoice_number || '',
+ String(r.invoice_date).split('T')[0],
+ String(r.due_date).split('T')[0],
+ r.original_amount,
+ r.open_amount,
+ BUCKET_LABEL[r.bucket] || r.bucket
+ ]));
+ }
+
+ const t = arData.totals;
+ lines.push(csvRow(['TOTAL', '', '', '', t.original_amount, t.open_amount, `${t.invoice_count} invoices`]));
+ lines.push('');
+ lines.push(csvRow(['Bucket Summary', 'Invoices', 'Open Amount']));
+ for (const b of AR_BUCKETS) {
+ const entry = arData.buckets[b.key];
+ lines.push(csvRow([b.label, entry.invoice_count, entry.open_amount]));
+ }
+ lines.push(csvRow(['Total', t.invoice_count, t.open_amount]));
+
+ downloadCsv(`ar-aging-${arData.asOf}${arData.anonymized ? '-anonymized' : ''}.csv`, lines);
+}
+
+// ── Report 2: Invoice-Level Revenue ─────────────────────────────────
+
+export async function loadRevenue() {
+ revFrom = document.getElementById('rev-from').value;
+ revTo = document.getElementById('rev-to').value;
+ revAnonymize = document.getElementById('rev-anonymize')?.checked || false;
+ revGroupByMonth = document.getElementById('rev-group-month')?.checked || false;
+
+ if (!revFrom || !revTo) return showError('rev-result', 'Please select both a from and a to date.');
+ if (revFrom > revTo) return showError('rev-result', 'The from date must not be after the to date.');
+
+ showLoading('rev-result', 'Loading revenue…');
+ try {
+ const data = await window.API.reports.getRevenue(revFrom, revTo, revAnonymize, revGroupByMonth);
+ if (data.error) return showError('rev-result', data.error);
+ revData = data;
+ renderRevenue(data);
+ } catch (err) {
+ showError('rev-result', err.message || 'Failed to load revenue');
+ }
+}
+
+function revenueRowHtml(r) {
+ return `
+ | ${escapeHtml(r.customer_name || '—')} |
+ ${escapeHtml(r.invoice_number || '—')} |
+ ${formatDate(r.invoice_date)} |
+ ${fmtMoney(r.subtotal)} |
+ ${fmtMoney(r.tax_amount)} |
+ ${fmtMoney(r.total)} |
+ ${fmtMoney(r.paid_amount)} |
+ ${fmtMoney(r.open_amount)} |
+ ${escapeHtml(r.payment_status || '—')} |
+
`;
+}
+
+function revenueSummaryRow(label, s, extraClass) {
+ return ``;
+}
+
+function renderRevenue(data) {
+ if (!data.rows.length) {
+ document.getElementById('rev-result').innerHTML =
+ methodologyBox(data.methodology, data.anonymized) +
+ '
No invoices found in this period.
';
+ return;
+ }
+
+ let rowsHtml = '';
+ if (data.groupByMonth) {
+ // Monats-Zwischensummen kommen aus SQL, nicht aus JS-Addition
+ const sumsByMonth = new Map(data.months.map(m => [m.month_key, m]));
+ const groupClass = 'bg-gray-50 border-t-2 border-gray-300 font-semibold text-gray-700';
+ let currentMonth = null;
+
+ for (const r of data.rows) {
+ if (r.month_key !== currentMonth) {
+ if (currentMonth !== null) {
+ rowsHtml += revenueSummaryRow('Group Total', sumsByMonth.get(currentMonth), groupClass);
+ }
+ currentMonth = r.month_key;
+ rowsHtml += `
| 📅 ${escapeHtml(monthLabel(r.month_key))} |
`;
+ }
+ rowsHtml += revenueRowHtml(r);
+ }
+ if (currentMonth !== null) {
+ rowsHtml += revenueSummaryRow('Group Total', sumsByMonth.get(currentMonth), groupClass);
+ }
+ } else {
+ for (const r of data.rows) rowsHtml += revenueRowHtml(r);
+ }
+
+ rowsHtml += revenueSummaryRow('TOTAL', data.totals, 'border-t-2 border-gray-400 bg-gray-100 font-bold');
+
+ document.getElementById('rev-result').innerHTML =
+ methodologyBox(data.methodology, data.anonymized) + `
+
+
+
+
+ | Customer |
+ Invoice # |
+ Date |
+ Subtotal |
+ Tax |
+ Total |
+ Paid |
+ Open |
+ Status |
+
+
+ ${rowsHtml}
+
+
`;
+}
+
+export function exportRevenueCsv() {
+ if (!revData || !revData.rows.length) {
+ return showError('rev-result', 'Run the report first — there is nothing to export.');
+ }
+ const lines = [];
+ lines.push(csvRow([`Invoice-Level Revenue ${revData.from} to ${revData.to}`]));
+ lines.push(csvRow([`Methodology: ${revData.methodology}`]));
+ lines.push('');
+
+ const header = ['Customer', 'Invoice #', 'Invoice Date', 'Subtotal', 'Tax', 'Total', 'Paid', 'Open', 'Payment Status'];
+ if (revData.groupByMonth) header.unshift('Month');
+ lines.push(csvRow(header));
+
+ for (const r of revData.rows) {
+ const cells = [
+ r.customer_name || '',
+ r.invoice_number || '',
+ String(r.invoice_date).split('T')[0],
+ r.subtotal, r.tax_amount, r.total, r.paid_amount, r.open_amount,
+ r.payment_status || ''
+ ];
+ if (revData.groupByMonth) cells.unshift(r.month_key);
+ lines.push(csvRow(cells));
+ }
+
+ const t = revData.totals;
+ const totalCells = [`TOTAL (${t.invoice_count} invoices)`, '', '',
+ t.subtotal, t.tax_amount, t.total, t.paid_amount, t.open_amount, ''];
+ if (revData.groupByMonth) totalCells.unshift('');
+ lines.push(csvRow(totalCells));
+
+ if (revData.groupByMonth && revData.months.length) {
+ lines.push('');
+ lines.push(csvRow(['Month', 'Invoices', 'Subtotal', 'Tax', 'Total', 'Paid', 'Open']));
+ for (const m of revData.months) {
+ lines.push(csvRow([m.month_key, m.invoice_count, m.subtotal, m.tax_amount, m.total, m.paid_amount, m.open_amount]));
+ }
+ }
+
+ downloadCsv(`revenue-${revData.from}_to_${revData.to}${revData.anonymized ? '-anonymized' : ''}.csv`, lines);
+}
+
+// ── Expose for onclick handlers ─────────────────────────────────────
+
+window.reportsView = {
+ loadArAging,
+ exportArAgingCsv,
+ loadRevenue,
+ exportRevenueCsv
+};
diff --git a/schema.sql b/schema.sql
index 28a6d95..c8350a4 100644
--- a/schema.sql
+++ b/schema.sql
@@ -2,7 +2,7 @@
-- PostgreSQL database dump
--
-\restrict dcppwhgnHJoNOBlNPc2moWihaP892wdvcafOsrY89xMPWDOJABsPkfufznphBjh
+\restrict TsnkGh5w4Bqm8Rc90OFENmnWJzydLxev1gibhMKf2y6LGG5aXtdaN2AqdspAKnp
-- Dumped from database version 17.7
-- Dumped by pg_dump version 17.7
@@ -23,6 +23,47 @@ SET default_tablespace = '';
SET default_table_access_method = heap;
+--
+-- Name: accounting_sync_log; Type: TABLE; Schema: public; Owner: quoteuser
+--
+
+CREATE TABLE public.accounting_sync_log (
+ id integer NOT NULL,
+ created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
+ action character varying(50) NOT NULL,
+ entity_type character varying(50),
+ entity_qbo_id character varying(50),
+ status character varying(20) NOT NULL,
+ request_excerpt text,
+ response_excerpt text,
+ user_id character varying(100)
+);
+
+
+ALTER TABLE public.accounting_sync_log OWNER TO quoteuser;
+
+--
+-- Name: accounting_sync_log_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
+--
+
+CREATE SEQUENCE public.accounting_sync_log_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.accounting_sync_log_id_seq OWNER TO quoteuser;
+
+--
+-- Name: accounting_sync_log_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
+--
+
+ALTER SEQUENCE public.accounting_sync_log_id_seq OWNED BY public.accounting_sync_log.id;
+
+
--
-- Name: customers; Type: TABLE; Schema: public; Owner: quoteuser
--
@@ -47,7 +88,8 @@ CREATE TABLE public.customers (
qbo_id character varying(50),
qbo_sync_token character varying(50),
contact character varying(255),
- remarks text
+ remarks text,
+ secondary_email character varying(255)
);
@@ -92,6 +134,7 @@ CREATE TABLE public.invoice_items (
unit_cost character varying(50)
);
+
ALTER TABLE public.invoice_items OWNER TO quoteuser;
--
@@ -125,7 +168,7 @@ CREATE TABLE public.invoices (
invoice_number character varying(50) DEFAULT NULL::character varying,
customer_id integer,
invoice_date date NOT NULL,
- terms character varying(100) DEFAULT 'Net 14'::character varying,
+ terms character varying(100) DEFAULT 'Net 30'::character varying,
auth_code character varying(255),
tax_exempt boolean DEFAULT false,
tax_rate numeric(5,2) DEFAULT 8.25,
@@ -151,7 +194,9 @@ CREATE TABLE public.invoices (
stripe_payment_link_url text,
stripe_payment_status character varying(50) DEFAULT 'pending'::character varying,
sent_dates date[] DEFAULT '{}'::date[],
- source character varying(20) DEFAULT 'native'
+ worker character varying(100),
+ source character varying(20) DEFAULT 'native'::character varying,
+ qbo_payment_error text
);
@@ -257,6 +302,59 @@ ALTER SEQUENCE public.payments_id_seq OWNER TO quoteuser;
ALTER SEQUENCE public.payments_id_seq OWNED BY public.payments.id;
+--
+-- Name: qbo_account_cache; Type: TABLE; Schema: public; Owner: quoteuser
+--
+
+CREATE TABLE public.qbo_account_cache (
+ qbo_id character varying(50) NOT NULL,
+ name character varying(255) NOT NULL,
+ fully_qualified_name character varying(500),
+ account_type character varying(50),
+ account_sub_type character varying(100),
+ classification character varying(50),
+ current_balance numeric(14,2),
+ currency character varying(10),
+ active boolean DEFAULT true,
+ sync_token character varying(50),
+ cached_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
+);
+
+
+ALTER TABLE public.qbo_account_cache OWNER TO quoteuser;
+
+--
+-- Name: qbo_cache_status; Type: TABLE; Schema: public; Owner: quoteuser
+--
+
+CREATE TABLE public.qbo_cache_status (
+ cache_name character varying(50) NOT NULL,
+ last_synced_at timestamp without time zone,
+ last_sync_count integer,
+ last_sync_error text
+);
+
+
+ALTER TABLE public.qbo_cache_status OWNER TO quoteuser;
+
+--
+-- Name: qbo_vendor_cache; Type: TABLE; Schema: public; Owner: quoteuser
+--
+
+CREATE TABLE public.qbo_vendor_cache (
+ qbo_id character varying(50) NOT NULL,
+ display_name character varying(255) NOT NULL,
+ company_name character varying(255),
+ primary_email character varying(255),
+ primary_phone character varying(50),
+ active boolean DEFAULT true,
+ sync_token character varying(50),
+ cached_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
+);
+
+
+ALTER TABLE public.qbo_vendor_cache OWNER TO quoteuser;
+
--
-- Name: quote_items; Type: TABLE; Schema: public; Owner: quoteuser
--
@@ -344,25 +442,12 @@ ALTER SEQUENCE public.quotes_id_seq OWNER TO quoteuser;
ALTER SEQUENCE public.quotes_id_seq OWNED BY public.quotes.id;
---
--- Name: settings; Type: TABLE; Schema: public; Owner: quoteuser
---
-
-CREATE TABLE public.settings (
- key character varying(100) NOT NULL,
- value text,
- updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
-);
-
-
-ALTER TABLE public.settings OWNER TO quoteuser;
-
--
-- Name: sales_tax_periods; Type: TABLE; Schema: public; Owner: quoteuser
--
CREATE TABLE public.sales_tax_periods (
- id serial NOT NULL,
+ id integer NOT NULL,
period_start date NOT NULL,
period_end date NOT NULL,
total_sales numeric(10,2),
@@ -379,15 +464,57 @@ CREATE TABLE public.sales_tax_periods (
sales_tax_payable_id character varying(50),
sales_tax_payable_name character varying(200),
qbo_journal_entry_id character varying(50),
- status character varying(20) DEFAULT 'open',
booked_at timestamp without time zone,
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT sales_tax_periods_period_start_period_end_key UNIQUE (period_start, period_end)
+ status character varying(20) DEFAULT 'open'::character varying
);
+
ALTER TABLE public.sales_tax_periods OWNER TO quoteuser;
+--
+-- Name: sales_tax_periods_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
+--
+
+CREATE SEQUENCE public.sales_tax_periods_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.sales_tax_periods_id_seq OWNER TO quoteuser;
+
+--
+-- Name: sales_tax_periods_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
+--
+
+ALTER SEQUENCE public.sales_tax_periods_id_seq OWNED BY public.sales_tax_periods.id;
+
+
+--
+-- Name: settings; Type: TABLE; Schema: public; Owner: quoteuser
+--
+
+CREATE TABLE public.settings (
+ key character varying(100) NOT NULL,
+ value text,
+ updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
+);
+
+
+ALTER TABLE public.settings OWNER TO quoteuser;
+
+--
+-- Name: accounting_sync_log id; Type: DEFAULT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.accounting_sync_log ALTER COLUMN id SET DEFAULT nextval('public.accounting_sync_log_id_seq'::regclass);
+
+
--
-- Name: customers id; Type: DEFAULT; Schema: public; Owner: quoteuser
--
@@ -437,6 +564,21 @@ ALTER TABLE ONLY public.quote_items ALTER COLUMN id SET DEFAULT nextval('public.
ALTER TABLE ONLY public.quotes ALTER COLUMN id SET DEFAULT nextval('public.quotes_id_seq'::regclass);
+--
+-- Name: sales_tax_periods id; Type: DEFAULT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.sales_tax_periods ALTER COLUMN id SET DEFAULT nextval('public.sales_tax_periods_id_seq'::regclass);
+
+
+--
+-- Name: accounting_sync_log accounting_sync_log_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.accounting_sync_log
+ ADD CONSTRAINT accounting_sync_log_pkey PRIMARY KEY (id);
+
+
--
-- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
--
@@ -501,6 +643,30 @@ ALTER TABLE ONLY public.payments
ADD CONSTRAINT payments_pkey PRIMARY KEY (id);
+--
+-- Name: qbo_account_cache qbo_account_cache_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.qbo_account_cache
+ ADD CONSTRAINT qbo_account_cache_pkey PRIMARY KEY (qbo_id);
+
+
+--
+-- Name: qbo_cache_status qbo_cache_status_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.qbo_cache_status
+ ADD CONSTRAINT qbo_cache_status_pkey PRIMARY KEY (cache_name);
+
+
+--
+-- Name: qbo_vendor_cache qbo_vendor_cache_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.qbo_vendor_cache
+ ADD CONSTRAINT qbo_vendor_cache_pkey PRIMARY KEY (qbo_id);
+
+
--
-- Name: quote_items quote_items_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
--
@@ -525,6 +691,22 @@ ALTER TABLE ONLY public.quotes
ADD CONSTRAINT quotes_quote_number_key UNIQUE (quote_number);
+--
+-- Name: sales_tax_periods sales_tax_periods_period_start_period_end_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.sales_tax_periods
+ ADD CONSTRAINT sales_tax_periods_period_start_period_end_key UNIQUE (period_start, period_end);
+
+
+--
+-- Name: sales_tax_periods sales_tax_periods_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.sales_tax_periods
+ ADD CONSTRAINT sales_tax_periods_pkey PRIMARY KEY (id);
+
+
--
-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
--
@@ -533,6 +715,27 @@ ALTER TABLE ONLY public.settings
ADD CONSTRAINT settings_pkey PRIMARY KEY (key);
+--
+-- Name: idx_accounting_sync_log_action; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_accounting_sync_log_action ON public.accounting_sync_log USING btree (action);
+
+
+--
+-- Name: idx_accounting_sync_log_created; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_accounting_sync_log_created ON public.accounting_sync_log USING btree (created_at DESC);
+
+
+--
+-- Name: idx_accounting_sync_log_qbo_id; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_accounting_sync_log_qbo_id ON public.accounting_sync_log USING btree (entity_qbo_id);
+
+
--
-- Name: idx_customers_qbo_id; Type: INDEX; Schema: public; Owner: quoteuser
--
@@ -624,6 +827,41 @@ CREATE INDEX idx_payments_customer ON public.payments USING btree (customer_id);
CREATE INDEX idx_payments_date ON public.payments USING btree (payment_date);
+--
+-- Name: idx_qbo_account_cache_active; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_qbo_account_cache_active ON public.qbo_account_cache USING btree (active);
+
+
+--
+-- Name: idx_qbo_account_cache_classification; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_qbo_account_cache_classification ON public.qbo_account_cache USING btree (classification);
+
+
+--
+-- Name: idx_qbo_account_cache_type; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_qbo_account_cache_type ON public.qbo_account_cache USING btree (account_type);
+
+
+--
+-- Name: idx_qbo_vendor_cache_active; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_qbo_vendor_cache_active ON public.qbo_vendor_cache USING btree (active);
+
+
+--
+-- Name: idx_qbo_vendor_cache_name_lower; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_qbo_vendor_cache_name_lower ON public.qbo_vendor_cache USING btree (lower((display_name)::text));
+
+
--
-- Name: idx_quote_items_quote_id; Type: INDEX; Schema: public; Owner: quoteuser
--
@@ -728,5 +966,5 @@ ALTER TABLE ONLY public.quotes
-- PostgreSQL database dump complete
--
-\unrestrict dcppwhgnHJoNOBlNPc2moWihaP892wdvcafOsrY89xMPWDOJABsPkfufznphBjh
+\unrestrict TsnkGh5w4Bqm8Rc90OFENmnWJzydLxev1gibhMKf2y6LGG5aXtdaN2AqdspAKnp
diff --git a/src/index.js b/src/index.js
index 7ca65b4..735b305 100644
--- a/src/index.js
+++ b/src/index.js
@@ -26,6 +26,7 @@ const paymentRoutes = require('./routes/payments');
const qboRoutes = require('./routes/qbo');
const settingsRoutes = require('./routes/settings');
const accountingRoutes = require('./routes/accounting');
+const reportRoutes = require('./routes/reports');
// Import PDF service for browser initialization
const { setBrowser } = require('./services/pdf-service');
@@ -123,6 +124,7 @@ app.use('/api/payments', paymentRoutes);
app.use('/api/qbo', qboRoutes);
app.use('/api/accounting', accountingRoutes);
app.use('/api/settings', settingsRoutes);
+app.use('/api/reports', reportRoutes);
// Start server
async function startServer() {
diff --git a/src/routes/reports.js b/src/routes/reports.js
new file mode 100644
index 0000000..fae234a
--- /dev/null
+++ b/src/routes/reports.js
@@ -0,0 +1,327 @@
+/**
+ * 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;