Files
invoice-system/src/routes/accounting.js
2026-06-10 17:21:29 -05:00

602 lines
26 KiB
JavaScript

/**
* Accounting Routes — /api/accounting/*
*
* Phase 1 + Phase 2 Lieferung 1 (Sync, Cache-Reads, Sync-Status)
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs').promises;
const accountingService = require('../services/accounting-service');
const { pool } = require('../config/database');
const { generatePdfFromHtml, getLogoHtml } = require('../services/pdf-service');
const { formatDate, formatMoney } = require('../utils/helpers');
const multer = require('multer');
// Limit aus ENV, default 5 MB. Akzeptierte Werte z.B. "5", "10", "20"
const ATTACHMENT_MAX_MB = parseInt(process.env.EXPENSE_ATTACHMENT_MAX_MB || '5', 10);
const ATTACHMENT_MAX_BYTES = ATTACHMENT_MAX_MB * 1024 * 1024;
const ALLOWED_MIME = new Set([
'image/png',
'image/jpeg',
'image/jpg',
'image/heic',
'image/heif',
'application/pdf'
]);
const attachUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: ATTACHMENT_MAX_BYTES },
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIME.has(file.mimetype)) {
return cb(new Error(`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPG, HEIC, PDF`));
}
cb(null, true);
}
});
// ────────────────────────────────────────────────────────────────────
function handleQboError(err, res, context) {
const statusCode = err.statusCode || 500;
if (statusCode >= 500) {
console.error(`❌ Accounting/${context} error:`, err.message);
if (err.qboFault) console.error(' QBO Fault:', JSON.stringify(err.qboFault));
if (err.stack) console.error(err.stack);
} else {
console.warn(`⚠️ Accounting/${context} ${statusCode}:`, err.message);
}
res.status(statusCode).json({ error: err.message || 'Request failed', context });
}
// ════════════════════════════════════════════════════════════════════
// Phase 1 — read-only
// ════════════════════════════════════════════════════════════════════
router.get('/accounts', async (req, res) => {
try {
const accounts = await accountingService.listAccounts({
type: req.query.type || null,
activeOnly: req.query.activeOnly !== 'false'
});
res.json(accounts);
} catch (err) { handleQboError(err, res, 'accounts'); }
});
router.get('/register', async (req, res) => {
const { accountId, startDate, endDate } = req.query;
if (!accountId) return res.status(400).json({ error: 'accountId is required' });
try {
res.json(await accountingService.getRegister({ accountId, startDate, endDate }));
} catch (err) { handleQboError(err, res, 'register'); }
});
router.get('/reports/profit-loss', async (req, res) => {
try {
res.json(await accountingService.getProfitAndLoss({
startDate: req.query.startDate,
endDate: req.query.endDate,
accountingMethod: req.query.accountingMethod || 'Accrual'
}));
} catch (err) { handleQboError(err, res, 'profit-loss'); }
});
router.get('/reports/balance-sheet', async (req, res) => {
try {
res.json(await accountingService.getBalanceSheet({
asOfDate: req.query.asOfDate,
accountingMethod: req.query.accountingMethod || 'Accrual'
}));
} catch (err) { handleQboError(err, res, 'balance-sheet'); }
});
router.get('/reports/tax-summary', async (req, res) => {
try {
res.json(await accountingService.getTaxSummary({
startDate: req.query.startDate,
endDate: req.query.endDate,
accountingMethod: req.query.accountingMethod || 'Accrual'
}));
} catch (err) { handleQboError(err, res, 'tax-summary'); }
});
// ─── Sales Tax Periods ────────────────────────────────────────────
router.get('/sales-tax/periods', async (req, res) => {
try {
res.json(await accountingService.getTaxPeriods());
} catch (err) { handleQboError(err, res, 'sales-tax-periods'); }
});
router.post('/sales-tax/periods', async (req, res) => {
try {
res.json(await accountingService.upsertTaxPeriod(req.body));
} catch (err) { handleQboError(err, res, 'sales-tax-periods'); }
});
router.post('/sales-tax/periods/:id/record', async (req, res) => {
try {
res.json(await accountingService.createTaxPaymentJE({
periodId: req.params.id,
...req.body
}));
} catch (err) { handleQboError(err, res, 'sales-tax-record'); }
});
router.post('/sales-tax/periods/:id/mark-external', async (req, res) => {
try {
res.json(await accountingService.markTaxPaidExternal(req.params.id));
} catch (err) { handleQboError(err, res, 'sales-tax-mark-external'); }
});
// ─── Customer Revenue Report ──────────────────────────────────────
router.get('/reports/customer-revenue', async (req, res) => {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ error: 'startDate and endDate are required' });
}
const result = await pool.query(
`SELECT
c.id,
c.name AS customer_name,
COUNT(i.id)::integer AS invoice_count,
COALESCE(SUM(i.subtotal), 0)::numeric(10,2) AS total_revenue,
(SELECT COALESCE(SUM(i2.subtotal), 0)::numeric(10,2)
FROM invoices i2
WHERE i2.invoice_date >= $1 AND i2.invoice_date <= $2
) AS grand_total
FROM customers c
JOIN invoices i ON i.customer_id = c.id
WHERE i.invoice_date >= $1 AND i.invoice_date <= $2
GROUP BY c.id, c.name
HAVING COUNT(i.id) > 0
ORDER BY total_revenue DESC`,
[startDate, endDate]
);
res.json(result.rows);
} catch (err) {
console.error('customer-revenue error:', err.message);
res.status(500).json({ error: err.message });
}
});
router.get('/reports/customer-revenue/pdf', async (req, res) => {
try {
const { startDate, endDate, anonymize } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ error: 'startDate and endDate are required' });
}
const result = await pool.query(
`SELECT
c.name AS customer_name,
COUNT(i.id)::integer AS invoice_count,
COALESCE(SUM(i.subtotal), 0)::numeric(10,2) AS total_revenue,
(SELECT COALESCE(SUM(i2.subtotal), 0)::numeric(10,2)
FROM invoices i2
WHERE i2.invoice_date >= $1 AND i2.invoice_date <= $2
) AS grand_total
FROM customers c
JOIN invoices i ON i.customer_id = c.id
WHERE i.invoice_date >= $1 AND i.invoice_date <= $2
GROUP BY c.id, c.name
HAVING COUNT(i.id) > 0
ORDER BY total_revenue DESC`,
[startDate, endDate]
);
const rows = result.rows;
const grandTotal = rows.length > 0 ? parseFloat(rows[0].grand_total) || 0 : 0;
const totalInvoices = rows.reduce((s, r) => s + parseInt(r.invoice_count), 0);
const doAnonymize = anonymize === 'true';
const maskName = (name) => doAnonymize ? name.charAt(0) : name;
let rowsHtml = '';
let rank = 0;
for (const r of rows) {
rank++;
const rev = parseFloat(r.total_revenue) || 0;
const pct = grandTotal > 0 ? ((rev / grandTotal) * 100).toFixed(1) : '0.0';
rowsHtml += `<tr>
<td class="name">${rank}. ${maskName(r.customer_name)}</td>
<td class="number">${r.invoice_count}</td>
<td class="number">$${formatMoney(rev)}</td>
<td class="number">${pct}%</td>
</tr>`;
}
rowsHtml += `<tr class="footer-row">
<td class="total-label">TOTAL (${rows.length} customers)</td>
<td class="total-amount">${totalInvoices}</td>
<td class="total-amount">$${formatMoney(grandTotal)}</td>
<td class="total-amount">100.0%</td>
</tr>`;
const templatePath = path.join(__dirname, '..', '..', 'templates', 'customer-revenue-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
const logoHTML = await getLogoHtml();
const dateRange = `${startDate} to ${endDate}`;
const generated = new Date().toISOString().split('T')[0];
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{COMPANY_NAME}}', 'Bay Area Affiliates, Inc.')
.replace('{{COMPANY_ADDRESS}}', '1001 Blucher Street<br>Corpus Christi, Texas 78401')
.replace('{{SLOGAN}}', 'Providing IT Services and Support in South Texas Since 1996')
.replace('{{DATE_RANGE}}', dateRange)
.replace('{{GENERATED_DATE}}', generated)
.replace('{{ANONYMIZED_NOTE}}', doAnonymize ? ' (anonymized)' : '')
.replace('{{ROWS}}', rowsHtml);
const pdf = await generatePdfFromHtml(html);
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdf.length,
'Content-Disposition': `attachment; filename="Customer-Revenue-${startDate}-to-${endDate}.pdf"`
});
res.end(pdf, 'binary');
} catch (err) {
console.error('customer-revenue pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── Shared Report PDF helper ──────────────────────────────────────
function reportToHtml(report) {
const cols = (report.Columns && report.Columns.Column) || [];
const headHtml = cols.map(c =>
`<th>${escapeHtmlReport(c.ColTitle || '')}</th>`
).join('');
function buildRows(rows, depth) {
if (!rows) return '';
const arr = Array.isArray(rows) ? rows : [rows];
let html = '';
for (const row of arr) {
const isSection = row.type === 'Section' || row.Rows || row.Summary;
const indent = depth * 16;
if (row.Header && row.Header.ColData) {
const cells = row.Header.ColData.map((c, i) =>
`<td style="padding-left:${i === 0 ? 12 + indent : 0}px;${i === 0 ? ' font-weight:bold;' : ''}">${i === 0 ? escapeHtmlReport(c.value || '') : ''}</td>`
).join('');
html += `<tr class="section-header">${cells}</tr>`;
}
if (isSection && row.Rows && row.Rows.Row)
html += buildRows(row.Rows.Row, depth + 1);
if (row.Summary && row.Summary.ColData) {
const cells = row.Summary.ColData.map((c, i) =>
`<td style="padding-left:${i === 0 ? 12 + indent : 0}px;" class="cell-bold">${escapeHtmlReport(c.value || '')}</td>`
).join('');
html += `<tr class="summary-row">${cells}</tr>`;
}
if (!isSection && row.ColData) {
const cells = row.ColData.map((c, i) =>
`<td style="padding-left:${i === 0 ? 12 + indent : 0}px;">${escapeHtmlReport(c.value || '')}</td>`
).join('');
html += `<tr>${cells}</tr>`;
}
}
return html;
}
const bodyHtml = buildRows(report.Rows && report.Rows.Row ? report.Rows.Row : null, 0);
return { headHtml, bodyHtml };
}
function escapeHtmlReport(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
async function renderReportPdf(res, title, meta, filename, reportObj) {
const templatePath = path.join(__dirname, '..', '..', 'templates', 'report-shell-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
const logoHTML = await getLogoHtml();
const { headHtml, bodyHtml } = reportToHtml(reportObj);
const generated = new Date().toISOString().split('T')[0];
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{COMPANY_NAME}}', 'Bay Area Affiliates, Inc.')
.replace('{{COMPANY_ADDRESS}}', '1001 Blucher Street<br>Corpus Christi, Texas 78401')
.replace('{{SLOGAN}}', 'Providing IT Services and Support in South Texas Since 1996')
.replace('{{REPORT_TITLE}}', title)
.replace('{{REPORT_META}}', meta)
.replace('{{TABLE_HEAD}}', `<tr>${headHtml}</tr>`)
.replace('{{REPORT_BODY}}', bodyHtml)
.replace('{{GENERATED_DATE}}', generated);
const pdf = await generatePdfFromHtml(html);
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '-');
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdf.length,
'Content-Disposition': `attachment; filename="${sanitized}.pdf"`
});
res.end(pdf, 'binary');
}
// ─── P&L PDF ───────────────────────────────────────────────────────
router.get('/reports/profit-loss/pdf', async (req, res) => {
try {
const startDate = req.query.startDate;
const endDate = req.query.endDate;
const method = req.query.accountingMethod || 'Accrual';
if (!startDate || !endDate) return res.status(400).json({ error: 'startDate and endDate required' });
const report = await accountingService.getProfitAndLoss({ startDate, endDate, accountingMethod: method });
await renderReportPdf(res,
'PROFIT & LOSS REPORT',
`Period: ${startDate} to ${endDate} · ${method}`,
`Profit-Loss-${startDate}-to-${endDate}`,
report
);
} catch (err) {
console.error('p&l pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── Balance Sheet PDF ─────────────────────────────────────────────
router.get('/reports/balance-sheet/pdf', async (req, res) => {
try {
const asOfDate = req.query.asOfDate;
const method = req.query.accountingMethod || 'Accrual';
if (!asOfDate) return res.status(400).json({ error: 'asOfDate required' });
const report = await accountingService.getBalanceSheet({ asOfDate, accountingMethod: method });
await renderReportPdf(res,
'BALANCE SHEET',
`As of: ${asOfDate} · ${method}`,
`Balance-Sheet-${asOfDate}`,
report
);
} catch (err) {
console.error('balance-sheet pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── Sales Tax PDF ──────────────────────────────────────────────────
router.get('/reports/tax-summary/pdf', async (req, res) => {
try {
const startDate = req.query.startDate;
const endDate = req.query.endDate;
const method = req.query.accountingMethod || 'Accrual';
if (!startDate || !endDate) return res.status(400).json({ error: 'startDate and endDate required' });
const report = await accountingService.getTaxSummary({ startDate, endDate, accountingMethod: method });
await renderReportPdf(res,
'SALES TAX LIABILITY',
`Period: ${startDate} to ${endDate} · ${method}`,
`Sales-Tax-${startDate}-to-${endDate}`,
report
);
} catch (err) {
console.error('tax-summary pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ════════════════════════════════════════════════════════════════════
// Phase 2 Lieferung 1 — Sync + Cache-Reads
// ════════════════════════════════════════════════════════════════════
// ─── POST /api/accounting/sync-accounts ─────────────────────────────
// Triggert den Account-Cache-Sync. Vorher: No-Op-Stub. Jetzt: voll.
router.post('/sync-accounts', async (req, res) => {
try {
console.log('🔄 Syncing QBO accounts cache...');
const result = await accountingService.syncAccountsCache();
console.log(`✅ Synced ${result.synced} accounts in ${result.durationMs}ms`);
res.json({
success: true,
cacheName: 'accounts',
synced: result.synced,
durationMs: result.durationMs
});
} catch (err) { handleQboError(err, res, 'sync-accounts'); }
});
// ─── POST /api/accounting/sync-vendors ──────────────────────────────
router.post('/sync-vendors', async (req, res) => {
try {
console.log('🔄 Syncing QBO vendors cache...');
const result = await accountingService.syncVendorsCache();
console.log(`✅ Synced ${result.synced} vendors in ${result.durationMs}ms`);
res.json({
success: true,
cacheName: 'vendors',
synced: result.synced,
durationMs: result.durationMs
});
} catch (err) { handleQboError(err, res, 'sync-vendors'); }
});
// ─── GET /api/accounting/sync-status ────────────────────────────────
// Liefert Status beider Caches + Flag, ob heute schon synchronisiert wurde.
router.get('/sync-status', async (req, res) => {
try {
const accounts = await accountingService.getCacheStatus('accounts');
const vendors = await accountingService.getCacheStatus('vendors');
const accountsStaleToday = await accountingService.cacheIsStaleToday('accounts');
const vendorsStaleToday = await accountingService.cacheIsStaleToday('vendors');
res.json({
accounts: { ...accounts, staleToday: accountsStaleToday },
vendors: { ...vendors, staleToday: vendorsStaleToday }
});
} catch (err) {
console.error('❌ sync-status error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── GET /api/accounting/vendors ────────────────────────────────────
// Aus Cache. Optional ?search=<q>&activeOnly=true&limit=200
router.get('/vendors', async (req, res) => {
try {
const vendors = await accountingService.getVendorsFromCache({
search: req.query.search || '',
activeOnly: req.query.activeOnly !== 'false',
limit: req.query.limit ? Math.min(parseInt(req.query.limit, 10) || 200, 1000) : 200
});
res.json(vendors);
} catch (err) {
console.error('❌ vendors error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── GET /api/accounting/expense-accounts ───────────────────────────
// Aus Cache. Liefert alle Expense-Accounts (für Category-Dropdown).
router.get('/expense-accounts', async (req, res) => {
try {
const accounts = await accountingService.getExpenseAccountsFromCache({
activeOnly: req.query.activeOnly !== 'false'
});
res.json(accounts);
} catch (err) {
console.error('❌ expense-accounts error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── GET /api/accounting/payment-accounts ───────────────────────────
// Aus Cache. Liefert Bank- und Credit-Card-Accounts (für Payment-Account-Dropdown im Expense-Modal).
router.get('/payment-accounts', async (req, res) => {
try {
const accounts = await accountingService.getPaymentAccountsFromCache({
activeOnly: req.query.activeOnly !== 'false'
});
res.json(accounts);
} catch (err) {
console.error('❌ payment-accounts error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── GET /api/accounting/payment-methods ────────────────────────────
// Live aus QBO (kleine, selten ändernde Liste — kein Cache nötig).
router.get('/payment-methods', async (req, res) => {
try {
const methods = await accountingService.getPaymentMethods({
activeOnly: req.query.activeOnly !== 'false'
});
res.json(methods);
} catch (err) { handleQboError(err, res, 'payment-methods'); }
});
// ─── POST /api/accounting/vendors ───────────────────────────────────
// Erstellt einen neuen Vendor in QBO + Cache.
// Body: { name, email?, phone?, address?: {...}, notes? }
// Status: 200 { id, displayName, ..., existed: false }
// 200 { ..., existed: true } ← Idempotent: Vendor war schon da
// 400 wenn name fehlt
router.post('/vendors', express.json(), async (req, res) => {
try {
const result = await accountingService.createVendor(req.body || {});
res.json(result);
} catch (err) { handleQboError(err, res, 'vendor-create'); }
});
// ─── POST /api/accounting/expenses ──────────────────────────────────
// Erstellt eine QBO Purchase (Expense) mit ein oder mehreren Lines.
// Body: { vendorId, paymentAccountId, txnDate, paymentMethodId?, refNo?, memo?, lines: [...] }
router.post('/expenses', express.json(), async (req, res) => {
try {
const result = await accountingService.createExpense(req.body || {});
res.json(result);
} catch (err) { handleQboError(err, res, 'expense-create'); }
});
// ─── GET /api/accounting/expenses ───────────────────────────────────
// ?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&onlyMine=true|false
router.get('/expenses', async (req, res) => {
try {
const expenses = await accountingService.listExpenses({
startDate: req.query.startDate,
endDate: req.query.endDate,
onlyMine: req.query.onlyMine === 'true'
});
res.json(expenses);
} catch (err) { handleQboError(err, res, 'expense-list'); }
});
// ─── POST /api/accounting/expenses/:id/attach ───────────────────────
// Hängt eine Datei (Bild oder PDF) an ein bestehendes QBO Purchase.
// Body: multipart/form-data, field "file"
router.post('/expenses/:id/attach', (req, res, next) => {
// multer-Wrapper, damit wir Multer-Fehler hübsch zurückgeben können
attachUpload.single('file')(req, res, (err) => {
if (err) {
// Spezifische Multer-Fehler übersetzen
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: `File too large. Max ${ATTACHMENT_MAX_MB} MB.`,
context: 'attach'
});
}
return res.status(400).json({ error: err.message, context: 'attach' });
}
next();
});
}, async (req, res) => {
const { id } = req.params;
const file = req.file;
if (!file) {
return res.status(400).json({ error: 'No file uploaded (field name must be "file")' });
}
try {
const result = await accountingService.attachFileToEntity({
entityType: 'Purchase',
entityId: id,
fileBuffer: file.buffer,
fileName: file.originalname,
contentType: file.mimetype,
note: req.body && req.body.note ? String(req.body.note).slice(0, 200) : undefined
});
res.json(result);
} catch (err) { handleQboError(err, res, 'attach'); }
});
// ─── PUT /api/accounting/expenses/:id ───────────────────────────────
// Aktualisiert eine bestehende QBO Purchase (Expense).
// Body wie POST /expenses: { vendorId, paymentAccountId, txnDate, paymentMethodId?, refNo?, memo?, lines: [...] }
router.put('/expenses/:id', express.json(), async (req, res) => {
try {
const result = await accountingService.updateExpense(req.params.id, req.body || {});
res.json(result);
} catch (err) { handleQboError(err, res, 'expense-update'); }
});
// ─── POST /api/accounting/refunds ───────────────────────────────────
// Erstellt einen QBO Deposit für einen Vendor-Refund (Geld kam zurück).
// Body: { vendorId, depositAccountId, categoryAccountId, txnDate, amount, refNo?, memo? }
router.post('/refunds', express.json(), async (req, res) => {
try {
const result = await accountingService.createRefund(req.body || {});
res.json(result);
} catch (err) { handleQboError(err, res, 'refund-create'); }
});
router.get('/attachments/limits', (req, res) => {
res.json({
maxBytes: ATTACHMENT_MAX_BYTES,
maxMb: ATTACHMENT_MAX_MB,
allowedMimeTypes: Array.from(ALLOWED_MIME),
allowedExtensions: ['.png', '.jpg', '.jpeg', '.heic', '.heif', '.pdf']
});
});
module.exports = router;