new reports
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
<button onclick="showTab('invoices')" id="tab-invoices" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Invoices</button>
|
||||
<button onclick="showTab('customers')" id="tab-customers" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Customers</button>
|
||||
<button onclick="showTab('accounting')" id="tab-accounting" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Accounting</button>
|
||||
<button onclick="showTab('reports')" id="tab-reports" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Reports</button>
|
||||
<button onclick="showTab('settings')" id="tab-settings" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,6 +149,15 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Reports Tab -->
|
||||
<div id="reports-tab" class="tab-content hidden">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-3xl font-bold text-gray-800">Reports</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">Due-diligence reporting. Enable anonymization before sharing exports externally.</p>
|
||||
</div>
|
||||
<div id="reports-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="settings-tab" class="tab-content hidden">
|
||||
<div class="mb-6">
|
||||
|
||||
@@ -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,6 +52,8 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
482
public/js/views/reports-view.js
Normal file
482
public/js/views/reports-view.js
Normal file
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showError(slotId, message) {
|
||||
const el = document.getElementById(slotId);
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="font-semibold text-red-800">Report Error</p>
|
||||
<p class="text-sm text-red-600 mt-1">${escapeHtml(message)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function showLoading(slotId, message = 'Loading…') {
|
||||
const el = document.getElementById(slotId);
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="flex items-center gap-3 p-4 text-gray-500">
|
||||
<svg class="animate-spin h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span>${escapeHtml(message)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function methodologyBox(text, anonymized) {
|
||||
return `
|
||||
<div class="mb-3 px-3 py-2 bg-gray-50 border border-gray-200 rounded text-xs text-gray-600">
|
||||
<span class="font-semibold text-gray-700">Methodology:</span> ${escapeHtml(text)}
|
||||
${anonymized ? '<span class="ml-2 inline-block px-2 py-0.5 bg-amber-100 text-amber-800 rounded font-medium">anonymized</span>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Report 1: AR Aging -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-4 py-3 border-b bg-gray-50">
|
||||
<h3 class="font-semibold text-gray-800">Accounts Receivable Aging</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
|
||||
<input type="date" id="ar-asof" value="${arAsOf}"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<button onclick="window.reportsView.loadArAging()"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
||||
<button onclick="window.reportsView.exportArAgingCsv()"
|
||||
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">⬇ Export CSV</button>
|
||||
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" id="ar-anonymize" ${arAnonymize ? 'checked' : ''}
|
||||
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
|
||||
Anonymize customers
|
||||
</label>
|
||||
</div>
|
||||
<div id="ar-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report 2: Invoice-Level Revenue -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-4 py-3 border-b bg-gray-50">
|
||||
<h3 class="font-semibold text-gray-800">Invoice-Level Revenue</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">From</label>
|
||||
<input type="date" id="rev-from" value="${revFrom}"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">To</label>
|
||||
<input type="date" id="rev-to" value="${revTo}"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<button onclick="window.reportsView.loadRevenue()"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
||||
<button onclick="window.reportsView.exportRevenueCsv()"
|
||||
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">⬇ Export CSV</button>
|
||||
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" id="rev-group-month" ${revGroupByMonth ? 'checked' : ''}
|
||||
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
|
||||
Group by month
|
||||
</label>
|
||||
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" id="rev-anonymize" ${revAnonymize ? 'checked' : ''}
|
||||
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
|
||||
Anonymize customers
|
||||
</label>
|
||||
</div>
|
||||
<div id="rev-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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) +
|
||||
'<p class="text-sm text-gray-500">No open receivables as of this date.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let rowsHtml = '';
|
||||
for (const r of data.rows) {
|
||||
const overdue = r.bucket !== 'current';
|
||||
rowsHtml += `<tr class="border-t hover:bg-gray-50">
|
||||
<td class="px-3 py-2 text-sm font-medium">${escapeHtml(r.customer_name || '—')}</td>
|
||||
<td class="px-3 py-2 text-sm">${escapeHtml(r.invoice_number || '—')}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-600">${formatDate(r.invoice_date)}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-600">${formatDate(r.due_date)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.original_amount)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium">${fmtMoney(r.open_amount)}</td>
|
||||
<td class="px-3 py-2 text-sm text-center ${overdue ? 'text-red-700 font-medium' : 'text-gray-500'}">
|
||||
${BUCKET_LABEL[r.bucket] || r.bucket}
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
const t = data.totals;
|
||||
rowsHtml += `<tr class="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td class="px-3 py-2 text-sm" colspan="4">TOTAL (${t.invoice_count} invoices)</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(t.original_amount)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(t.open_amount)}</td>
|
||||
<td></td>
|
||||
</tr>`;
|
||||
|
||||
let bucketCells = '';
|
||||
for (const b of AR_BUCKETS) {
|
||||
const entry = data.buckets[b.key];
|
||||
bucketCells += `<tr class="border-t">
|
||||
<td class="px-3 py-2 text-sm">${b.label}</td>
|
||||
<td class="px-3 py-2 text-sm text-center text-gray-600">${entry.invoice_count}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(entry.open_amount)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
document.getElementById('ar-result').innerHTML =
|
||||
methodologyBox(data.methodology, data.anonymized) + `
|
||||
<div class="mb-4 max-w-md">
|
||||
<div class="overflow-x-auto border border-gray-200 rounded">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Bucket</th>
|
||||
<th class="px-3 py-2 text-center font-medium text-gray-700">Invoices</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${bucketCells}
|
||||
<tr class="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td class="px-3 py-2 text-sm">Total</td>
|
||||
<td class="px-3 py-2 text-sm text-center">${t.invoice_count}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(t.open_amount)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto border border-gray-200 rounded">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Invoice #</th>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Date</th>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Due</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Original</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Open</th>
|
||||
<th class="px-3 py-2 text-center font-medium text-gray-700">Bucket</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rowsHtml}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `<tr class="border-t hover:bg-gray-50">
|
||||
<td class="px-3 py-2 text-sm font-medium">${escapeHtml(r.customer_name || '—')}</td>
|
||||
<td class="px-3 py-2 text-sm">${escapeHtml(r.invoice_number || '—')}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-600">${formatDate(r.invoice_date)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.subtotal)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.tax_amount)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium">${fmtMoney(r.total)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.paid_amount)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.open_amount)}</td>
|
||||
<td class="px-3 py-2 text-sm text-center text-gray-500">${escapeHtml(r.payment_status || '—')}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function revenueSummaryRow(label, s, extraClass) {
|
||||
return `<tr class="${extraClass}">
|
||||
<td class="px-3 py-2 text-sm" colspan="3">${escapeHtml(label)} (${s.invoice_count} invoices)</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.subtotal)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.tax_amount)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.total)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.paid_amount)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.open_amount)}</td>
|
||||
<td></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderRevenue(data) {
|
||||
if (!data.rows.length) {
|
||||
document.getElementById('rev-result').innerHTML =
|
||||
methodologyBox(data.methodology, data.anonymized) +
|
||||
'<p class="text-sm text-gray-500">No invoices found in this period.</p>';
|
||||
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 += `<tr class="bg-blue-50"><td colspan="9" class="px-3 py-2 text-sm font-bold text-blue-800">📅 ${escapeHtml(monthLabel(r.month_key))}</td></tr>`;
|
||||
}
|
||||
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) + `
|
||||
<div class="overflow-x-auto border border-gray-200 rounded">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Invoice #</th>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Date</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Subtotal</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Tax</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Total</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Paid</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Open</th>
|
||||
<th class="px-3 py-2 text-center font-medium text-gray-700">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rowsHtml}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
280
schema.sql
280
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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
327
src/routes/reports.js
Normal file
327
src/routes/reports.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user