/** * 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 += `
&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;