448 lines
19 KiB
JavaScript
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; |