Files
invoice-system/src/routes/accounting.js
2026-06-10 15:51:43 -05:00

448 lines
19 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 } = 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);
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}. ${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('{{DATE_RANGE}}', dateRange)
.replace('{{GENERATED_DATE}}', generated)
.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 });
}
});
// ════════════════════════════════════════════════════════════════════
// 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;