diff --git a/src/routes/accounting.js b/src/routes/accounting.js index 7787e2b..c7e2d58 100644 --- a/src/routes/accounting.js +++ b/src/routes/accounting.js @@ -1,115 +1,169 @@ /** - * Accounting Routes - * /api/accounting/* + * Accounting Routes — /api/accounting/* * - * Phase 1 — read-only: - * GET /accounts - * POST /sync-accounts (Phase-1 Stub) - * GET /register - * GET /reports/profit-loss - * GET /reports/balance-sheet + * Phase 1 + Phase 2 Lieferung 1 (Sync, Cache-Reads, Sync-Status) */ const express = require('express'); const router = express.Router(); - const accountingService = require('../services/accounting-service'); -/** - * Helper: einheitliches Error-Mapping QBO → HTTP - * Fault-Details landen im Server-Log, der User sieht die kurze Message. - */ +// ──────────────────────────────────────────────────────────────────── function handleQboError(err, res, context) { console.error(`❌ Accounting/${context} error:`, err.message); - if (err.qboFault) { - console.error(' QBO Fault detail:', JSON.stringify(err.qboFault)); - } + if (err.qboFault) console.error(' QBO Fault detail:', JSON.stringify(err.qboFault)); if (err.stack) console.error(err.stack); - - res.status(500).json({ - error: err.message || 'QBO request failed', - context - }); + res.status(500).json({ error: err.message || 'QBO request failed', context }); } -// ─── GET /api/accounting/accounts ─────────────────────────────────── -// Optional ?type=Bank|CreditCard|Expense|Income|... ?activeOnly=false +// ════════════════════════════════════════════════════════════════════ +// Phase 1 — read-only +// ════════════════════════════════════════════════════════════════════ + router.get('/accounts', async (req, res) => { try { - const type = req.query.type || null; - const activeOnly = req.query.activeOnly === 'false' ? false : true; - - const accounts = await accountingService.listAccounts({ type, activeOnly }); + const accounts = await accountingService.listAccounts({ + type: req.query.type || null, + activeOnly: req.query.activeOnly !== 'false' + }); res.json(accounts); - } catch (err) { - handleQboError(err, res, 'accounts'); - } + } catch (err) { handleQboError(err, res, 'accounts'); } }); -// ─── POST /api/accounting/sync-accounts ───────────────────────────── -// Phase-1 Stub. Voll implementiert in Phase 2 mit qbo_account_cache. -router.post('/sync-accounts', (req, res) => { - res.json({ - success: true, - synced: 0, - cached: false, - message: - 'Account-Sync wird in Phase 2 aktiviert (qbo_account_cache). ' + - 'In Phase 1 werden Accounts direkt live aus QBO geladen.' - }); -}); - -// ─── GET /api/accounting/register ─────────────────────────────────── -// ?accountId=&startDate=YYYY-MM-DD&endDate=YYYY-MM-DD router.get('/register', async (req, res) => { const { accountId, startDate, endDate } = req.query; - - if (!accountId) { - return res.status(400).json({ error: 'accountId is required' }); - } - + if (!accountId) return res.status(400).json({ error: 'accountId is required' }); try { - const result = await accountingService.getRegister({ - accountId, - startDate, - endDate - }); - res.json(result); - } catch (err) { - handleQboError(err, res, 'register'); - } + res.json(await accountingService.getRegister({ accountId, startDate, endDate })); + } catch (err) { handleQboError(err, res, 'register'); } }); -// ─── GET /api/accounting/reports/profit-loss ──────────────────────── -// ?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&accountingMethod=Accrual|Cash router.get('/reports/profit-loss', async (req, res) => { - const { startDate, endDate, accountingMethod } = req.query; - try { - const data = await accountingService.getProfitAndLoss({ - startDate, - endDate, - accountingMethod: accountingMethod || 'Accrual' - }); - res.json(data); - } catch (err) { - handleQboError(err, res, 'profit-loss'); - } + 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'); } }); -// ─── GET /api/accounting/reports/balance-sheet ────────────────────── -// ?asOfDate=YYYY-MM-DD&accountingMethod=Accrual|Cash router.get('/reports/balance-sheet', async (req, res) => { - const { asOfDate, accountingMethod } = req.query; - try { - const data = await accountingService.getBalanceSheet({ - asOfDate, - accountingMethod: accountingMethod || 'Accrual' + res.json(await accountingService.getBalanceSheet({ + asOfDate: req.query.asOfDate, + accountingMethod: req.query.accountingMethod || 'Accrual' + })); + } catch (err) { handleQboError(err, res, 'balance-sheet'); } +}); + +// ════════════════════════════════════════════════════════════════════ +// 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 } }); - res.json(data); } catch (err) { - handleQboError(err, res, 'balance-sheet'); + console.error('❌ sync-status error:', err.message); + res.status(500).json({ error: err.message }); } }); -module.exports = router; +// ─── GET /api/accounting/vendors ──────────────────────────────────── +// Aus Cache. Optional ?search=&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'); } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js index 1774e5b..a623bae 100644 --- a/src/services/accounting-service.js +++ b/src/services/accounting-service.js @@ -1,15 +1,23 @@ // src/services/accounting-service.js /** * Accounting Service - * Read-only wrappers around QBO Accounts, TransactionList Register - * and the P&L / Balance Sheet reports. * - * Phase 1 — read-only. Keine lokale Cache-Tabelle, alles live aus QBO. + * Phase 1: read-only Account-/Register-/Report-Wrapper + * Phase 2 (Lieferung 1): Cache-Synchronisation für Accounts und Vendors, + * Sync-Status, Audit-Log, Payment-Methods + * + * QBO bleibt System of Record. Caches werden manuell oder + * beim ersten Modal-Open pro Tag aktualisiert. */ const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); +const { pool } = require('../config/database'); -// QBO minor version — fixiert für stabilen Field-Support const QBO_MINOR_VERSION = '75'; +const QBO_PAGE_SIZE = 1000; + +// ──────────────────────────────────────────────────────────────────── +// Common helpers +// ──────────────────────────────────────────────────────────────────── function getClientInfo() { const oauthClient = getOAuthClient(); @@ -18,23 +26,14 @@ function getClientInfo() { return { oauthClient, companyId, baseUrl }; } -/** - * Helper: extrahiert .json() aus QBO Response (kompatibel zu intuit-oauth) - */ function getJson(response) { return response.getJson ? response.getJson() : response.json; } -/** - * Helper: hängt minorversion an URLs an, ohne bestehende Query-Strings zu zerschießen - */ function withMinorVersion(url) { return url + (url.includes('?') ? '&' : '?') + 'minorversion=' + QBO_MINOR_VERSION; } -/** - * Wirft einen lesbaren Fehler bei QBO Faults - */ function throwIfFault(data, context) { if (data && data.Fault && data.Fault.Error) { const msg = data.Fault.Error.map(e => @@ -47,51 +46,126 @@ function throwIfFault(data, context) { } // ──────────────────────────────────────────────────────────────────── -// Accounts +// Audit Log helper // ──────────────────────────────────────────────────────────────────── +async function writeAuditLog({ action, entityType, entityQboId, status, requestExcerpt, responseExcerpt, userId }) { + try { + await pool.query( + `INSERT INTO accounting_sync_log + (action, entity_type, entity_qbo_id, status, request_excerpt, response_excerpt, user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + action, + entityType || null, + entityQboId || null, + status, + requestExcerpt ? String(requestExcerpt).slice(0, 4000) : null, + responseExcerpt ? String(responseExcerpt).slice(0, 4000) : null, + userId || null + ] + ); + } catch (err) { + // Audit-Log darf den eigentlichen Vorgang nicht killen + console.error('⚠️ Failed to write audit log:', err.message); + } +} + +async function setCacheStatus(cacheName, { count = null, error = null } = {}) { + await pool.query( + `UPDATE qbo_cache_status + SET last_synced_at = $1, + last_sync_count = $2, + last_sync_error = $3 + WHERE cache_name = $4`, + [error ? null : new Date(), count, error, cacheName] + ); +} + +async function getCacheStatus(cacheName) { + const r = await pool.query( + `SELECT cache_name, last_synced_at, last_sync_count, last_sync_error + FROM qbo_cache_status WHERE cache_name = $1`, + [cacheName] + ); + return r.rows[0] || null; +} + /** - * Lädt Accounts aus QBO. Optional gefiltert nach AccountType. - * - * @param {Object} opts - * @param {string|null} opts.type - z.B. 'Bank', 'Credit Card', 'Expense', 'Income' - * (akzeptiert auch 'CreditCard' für URL-Bequemlichkeit) - * @param {boolean} opts.activeOnly - default true + * Gibt true zurück, wenn der Cache heute (nach 00:00 lokal) noch nicht + * synchronisiert wurde — wird vom Frontend für "first-open-of-day"-Auto-Sync genutzt. */ +async function cacheIsStaleToday(cacheName) { + const status = await getCacheStatus(cacheName); + if (!status || !status.last_synced_at) return true; + const last = new Date(status.last_synced_at); + const now = new Date(); + return last.getFullYear() !== now.getFullYear() + || last.getMonth() !== now.getMonth() + || last.getDate() !== now.getDate(); +} + +// ──────────────────────────────────────────────────────────────────── +// QBO Query helpers (paginated) +// ──────────────────────────────────────────────────────────────────── + +async function queryAll(entity, where = '', orderBy = '') { + const { companyId, baseUrl } = getClientInfo(); + const all = []; + let startPosition = 1; + + while (true) { + const wherePart = where ? ` WHERE ${where}` : ''; + const orderPart = orderBy ? ` ORDERBY ${orderBy}` : ''; + const sql = `SELECT * FROM ${entity}${wherePart}${orderPart} STARTPOSITION ${startPosition} MAXRESULTS ${QBO_PAGE_SIZE}`; + const url = withMinorVersion( + `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(sql)}` + ); + + const response = await makeQboApiCall({ url, method: 'GET' }); + const data = getJson(response); + throwIfFault(data, `${entity} query`); + + const list = (data.QueryResponse && data.QueryResponse[entity]) || []; + all.push(...list); + if (list.length < QBO_PAGE_SIZE) break; + startPosition += list.length; + } + return all; +} + +// ════════════════════════════════════════════════════════════════════ +// Phase 1 (read-only) — bleibt unverändert +// ════════════════════════════════════════════════════════════════════ + async function listAccounts({ type = null, activeOnly = true } = {}) { const { companyId, baseUrl } = getClientInfo(); let where = []; if (activeOnly) where.push("Active = true"); if (type) { - // Erlaube 'CreditCard' als URL-freundliche Variante const normalizedType = type === 'CreditCard' ? 'Credit Card' : type; - // Apostrophe in QBO-Strings escaped man durch Verdoppelung const safe = normalizedType.replace(/'/g, "''"); where.push(`AccountType = '${safe}'`); } - const whereClause = where.length ? ' WHERE ' + where.join(' AND ') : ''; const query = `SELECT * FROM Account${whereClause} ORDERBY Name ASC MAXRESULTS 1000`; const url = withMinorVersion( `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(query)}` ); - const response = await makeQboApiCall({ url, method: 'GET' }); const data = getJson(response); throwIfFault(data, 'Account query'); const accounts = (data.QueryResponse && data.QueryResponse.Account) || []; - - // Schlanke, frontend-freundliche Form return accounts.map(a => ({ id: a.Id, name: a.Name, fullyQualifiedName: a.FullyQualifiedName, accountType: a.AccountType, accountSubType: a.AccountSubType, - classification: a.Classification, // Asset, Liability, Equity, Revenue, Expense + classification: a.Classification, currentBalance: a.CurrentBalance != null ? Number(a.CurrentBalance) : null, currency: a.CurrencyRef ? a.CurrencyRef.value : null, active: a.Active === true, @@ -99,23 +173,8 @@ async function listAccounts({ type = null, activeOnly = true } = {}) { })); } -// ──────────────────────────────────────────────────────────────────── -// Register (TransactionList Report) -// ──────────────────────────────────────────────────────────────────── - -/** - * Liefert den Register eines Accounts (read-only). - * Verwendet QBOs TransactionList Report — der ist für genau diesen Zweck gedacht - * und liefert Date / TxnType / DocNum / Name / Account / Amount sauber zurück. - * - * @param {Object} opts - * @param {string} opts.accountId - QBO Account Id (Pflicht) - * @param {string} opts.startDate - YYYY-MM-DD - * @param {string} opts.endDate - YYYY-MM-DD - */ async function getRegister({ accountId, startDate, endDate, includeSplits = true }) { if (!accountId) throw new Error('accountId is required'); - const { companyId, baseUrl } = getClientInfo(); const params = new URLSearchParams(); @@ -125,39 +184,27 @@ async function getRegister({ accountId, startDate, endDate, includeSplits = true params.set('minorversion', QBO_MINOR_VERSION); const url = `${baseUrl}/v3/company/${companyId}/reports/TransactionList?${params.toString()}`; - const response = await makeQboApiCall({ url, method: 'GET' }); const data = getJson(response); throwIfFault(data, 'TransactionList report'); const result = normalizeTransactionListReport(data); - // Nur Split-Details nachladen (für -Split- Zeilen) — Cleared-Status nicht + // Splits nachladen if (includeSplits) { - const splitRows = result.rows.filter(r => - r.splitAccount === '-Split-' && r.qboId - ); + const splitRows = result.rows.filter(r => r.splitAccount === '-Split-' && r.qboId); if (splitRows.length) { const splits = await fetchSplitDetails(splitRows); result.rows.forEach(r => { - if (r.qboId && splits[r.qboId]) { - r.splits = splits[r.qboId]; - } + if (r.qboId && splits[r.qboId]) r.splits = splits[r.qboId]; }); } } - return result; } -/** - * Normalisiert die QBO TransactionList Report Antwort in eine flache Liste. - * Wir mappen über ColTitle (immer vorhanden), nicht über ColType (manchmal leer). - */ function normalizeTransactionListReport(report) { const columns = (report.Columns && report.Columns.Column) || []; - - // Map: ColTitle (lowercase) → Index const colIndex = {}; columns.forEach((c, i) => { if (c.ColTitle) colIndex[c.ColTitle.toLowerCase()] = i; @@ -180,24 +227,18 @@ function normalizeTransactionListReport(report) { const idxMemo = resolve('Memo/Description', 'Memo', 'memo'); const idxSplit = resolve('Split', 'split_acc'); const idxAmount = resolve('Amount', 'subt_nat_amount', 'subt_nat_home_amount'); - const idxCleared = resolve('Cleared', 'cleared_status', 'clr'); const cellAt = (colData, idx) => { if (idx == null) return null; - const c = colData[idx]; - return c || null; + return colData[idx] || null; }; const rows = []; - function walk(rowGroup) { if (!rowGroup) return; const items = Array.isArray(rowGroup) ? rowGroup : (rowGroup.Row || []); for (const r of items) { - if (r.type === 'Section' || r.Rows) { - walk(r.Rows && r.Rows.Row); - continue; - } + if (r.type === 'Section' || r.Rows) { walk(r.Rows && r.Rows.Row); continue; } if (!r.ColData) continue; const dateCell = cellAt(r.ColData, idxDate); @@ -208,7 +249,6 @@ function normalizeTransactionListReport(report) { const memoCell = cellAt(r.ColData, idxMemo); const splitCell = cellAt(r.ColData, idxSplit); const amtCell = cellAt(r.ColData, idxAmount); - const clrCell = cellAt(r.ColData, idxCleared); const qboId = (dateCell && dateCell.id) || @@ -216,12 +256,6 @@ function normalizeTransactionListReport(report) { (typeCell && typeCell.id) || null; - let clearedStatus = null; - if (clrCell && clrCell.value) { - const v = String(clrCell.value).trim().toUpperCase(); - if (v === 'R' || v === 'C') clearedStatus = v; - } - rows.push({ date: dateCell ? dateCell.value : null, type: typeCell ? typeCell.value : null, @@ -232,12 +266,10 @@ function normalizeTransactionListReport(report) { amount: amtCell && amtCell.value !== '' && amtCell.value != null ? Number(amtCell.value) : null, splitAccount: splitCell ? splitCell.value : null, - clearedStatus, qboId }); } } - walk(report.Rows && report.Rows.Row); return { @@ -253,84 +285,20 @@ function normalizeTransactionListReport(report) { }; } -// ──────────────────────────────────────────────────────────────────── -// Reports — Profit & Loss, Balance Sheet -// ──────────────────────────────────────────────────────────────────── - -function buildReportUrl(reportName, params) { - const { companyId, baseUrl } = getClientInfo(); - const usp = new URLSearchParams(); - Object.entries(params).forEach(([k, v]) => { - if (v != null && v !== '') usp.set(k, v); - }); - usp.set('minorversion', QBO_MINOR_VERSION); - return `${baseUrl}/v3/company/${companyId}/reports/${reportName}?${usp.toString()}`; -} - -/** - * Profit & Loss Report - * @param {Object} opts - * @param {string} opts.startDate - YYYY-MM-DD - * @param {string} opts.endDate - YYYY-MM-DD - * @param {string} opts.accountingMethod - 'Accrual' | 'Cash' (default 'Accrual') - */ -async function getProfitAndLoss({ startDate, endDate, accountingMethod = 'Accrual' } = {}) { - const url = buildReportUrl('ProfitAndLoss', { - start_date: startDate, - end_date: endDate, - accounting_method: accountingMethod - }); - const response = await makeQboApiCall({ url, method: 'GET' }); - const data = getJson(response); - throwIfFault(data, 'ProfitAndLoss report'); - return data; -} - -/** - * Balance Sheet Report - * @param {Object} opts - * @param {string} opts.asOfDate - YYYY-MM-DD (mapped to end_date) - * @param {string} opts.accountingMethod - 'Accrual' | 'Cash' (default 'Accrual') - */ -async function getBalanceSheet({ asOfDate, accountingMethod = 'Accrual' } = {}) { - const url = buildReportUrl('BalanceSheet', { - end_date: asOfDate, - accounting_method: accountingMethod - }); - const response = await makeQboApiCall({ url, method: 'GET' }); - const data = getJson(response); - throwIfFault(data, 'BalanceSheet report'); - return data; -} -/** - * Lädt für eine Liste von Split-Transaktionen die Einzel-Lines aus QBO. - * Wir laden nur das, was als Split markiert ist und einen qboId hat. - * - * Returns: Map> - */ async function fetchSplitDetails(splitRows) { if (!splitRows || splitRows.length === 0) return {}; - const { companyId, baseUrl } = getClientInfo(); - - // Group by Type, weil QBO unterschiedliche Endpoints für Purchase/Deposit/JournalEntry hat const result = {}; for (const row of splitRows) { if (!row.qboId) continue; - - // Type → QBO endpoint name const endpoint = mapTypeToEndpoint(row.type); if (!endpoint) continue; try { - const url = withMinorVersion( - `${baseUrl}/v3/company/${companyId}/${endpoint}/${row.qboId}` - ); + const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/${endpoint}/${row.qboId}`); const response = await makeQboApiCall({ url, method: 'GET' }); const data = getJson(response); - - // Response shape: { Purchase: {...} } or { Deposit: {...} } etc. const txn = data[capitalize(endpoint)] || data[endpoint]; if (!txn || !txn.Line) continue; @@ -349,40 +317,313 @@ async function fetchSplitDetails(splitRows) { }; }) .filter(l => l.account || l.amount != null); - if (lines.length) result[row.qboId] = lines; } catch (err) { - // Einzelne Fehler nicht den ganzen Register killen lassen - console.warn(`Split detail fetch failed for ${row.type} ${row.qboId}:`, err.message); + console.warn(`Split fetch failed for ${row.type} ${row.qboId}:`, err.message); } } - return result; } function mapTypeToEndpoint(type) { if (!type) return null; const t = type.toLowerCase(); - if (t.includes('expense') || t.includes('check')) return 'purchase'; - if (t.includes('deposit')) return 'deposit'; - if (t.includes('journal')) return 'journalentry'; - if (t.includes('bill payment')) return 'billpayment'; - if (t.includes('bill')) return 'bill'; - if (t.includes('credit card')) return 'purchase'; - if (t.includes('paycheck') || t.includes('payroll')) return null; // QBO blockt Paycheck-API - if (t.includes('tax payment')) return null; // dito + if (t.includes('expense') || t.includes('check')) return 'purchase'; + if (t.includes('deposit')) return 'deposit'; + if (t.includes('journal')) return 'journalentry'; + if (t.includes('bill payment')) return 'billpayment'; + if (t.includes('bill')) return 'bill'; + if (t.includes('credit card')) return 'purchase'; + if (t.includes('paycheck') || t.includes('payroll')) return null; + if (t.includes('tax payment')) return null; return null; } function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } + +function buildReportUrl(reportName, params) { + const { companyId, baseUrl } = getClientInfo(); + const usp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { if (v != null && v !== '') usp.set(k, v); }); + usp.set('minorversion', QBO_MINOR_VERSION); + return `${baseUrl}/v3/company/${companyId}/reports/${reportName}?${usp.toString()}`; +} + +async function getProfitAndLoss({ startDate, endDate, accountingMethod = 'Accrual' } = {}) { + const url = buildReportUrl('ProfitAndLoss', { start_date: startDate, end_date: endDate, accounting_method: accountingMethod }); + const response = await makeQboApiCall({ url, method: 'GET' }); + const data = getJson(response); + throwIfFault(data, 'ProfitAndLoss report'); + return data; +} + +async function getBalanceSheet({ asOfDate, accountingMethod = 'Accrual' } = {}) { + const url = buildReportUrl('BalanceSheet', { end_date: asOfDate, accounting_method: accountingMethod }); + const response = await makeQboApiCall({ url, method: 'GET' }); + const data = getJson(response); + throwIfFault(data, 'BalanceSheet report'); + return data; +} + +// ════════════════════════════════════════════════════════════════════ +// Phase 2 Lieferung 1 — Caches und Sync +// ════════════════════════════════════════════════════════════════════ + +/** + * Synchronisiert den Account-Cache mit QBO. + * Liest alle aktiven Accounts und schreibt sie nach qbo_account_cache. + * Ergebnis: { synced: N, total: M, durationMs: T } + */ +async function syncAccountsCache() { + const startTs = Date.now(); + let count = 0; + try { + const accounts = await queryAll('Account', 'Active = true', 'Name ASC'); + + // UPSERT: alles in einer Transaktion + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Strategie: alle als inaktiv flaggen, dann die aktuellen upserten, + // damit gelöschte/inaktivierte Accounts auch im Cache nicht mehr aktiv sind. + await client.query('UPDATE qbo_account_cache SET active = false'); + + for (const a of accounts) { + await client.query( + `INSERT INTO qbo_account_cache + (qbo_id, name, fully_qualified_name, account_type, account_sub_type, + classification, current_balance, currency, active, sync_token, cached_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP) + ON CONFLICT (qbo_id) DO UPDATE SET + name = EXCLUDED.name, + fully_qualified_name = EXCLUDED.fully_qualified_name, + account_type = EXCLUDED.account_type, + account_sub_type = EXCLUDED.account_sub_type, + classification = EXCLUDED.classification, + current_balance = EXCLUDED.current_balance, + currency = EXCLUDED.currency, + active = EXCLUDED.active, + sync_token = EXCLUDED.sync_token, + cached_at = CURRENT_TIMESTAMP`, + [ + a.Id, a.Name, a.FullyQualifiedName || null, + a.AccountType || null, a.AccountSubType || null, + a.Classification || null, + a.CurrentBalance != null ? Number(a.CurrentBalance) : null, + a.CurrencyRef ? a.CurrencyRef.value : null, + a.Active === true, + a.SyncToken || null + ] + ); + count++; + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + + await setCacheStatus('accounts', { count }); + return { synced: count, durationMs: Date.now() - startTs }; + + } catch (err) { + await setCacheStatus('accounts', { error: err.message }); + throw err; + } +} + +/** + * Synchronisiert den Vendor-Cache mit QBO. + */ +async function syncVendorsCache() { + const startTs = Date.now(); + let count = 0; + try { + const vendors = await queryAll('Vendor', '', 'DisplayName ASC'); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('UPDATE qbo_vendor_cache SET active = false'); + + for (const v of vendors) { + const email = v.PrimaryEmailAddr ? v.PrimaryEmailAddr.Address : null; + const phone = v.PrimaryPhone ? v.PrimaryPhone.FreeFormNumber : null; + + await client.query( + `INSERT INTO qbo_vendor_cache + (qbo_id, display_name, company_name, primary_email, primary_phone, + active, sync_token, cached_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP) + ON CONFLICT (qbo_id) DO UPDATE SET + display_name = EXCLUDED.display_name, + company_name = EXCLUDED.company_name, + primary_email = EXCLUDED.primary_email, + primary_phone = EXCLUDED.primary_phone, + active = EXCLUDED.active, + sync_token = EXCLUDED.sync_token, + cached_at = CURRENT_TIMESTAMP`, + [ + v.Id, v.DisplayName, v.CompanyName || null, + email, phone, + v.Active === true, + v.SyncToken || null + ] + ); + count++; + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + + await setCacheStatus('vendors', { count }); + return { synced: count, durationMs: Date.now() - startTs }; + + } catch (err) { + await setCacheStatus('vendors', { error: err.message }); + throw err; + } +} + +// ──────────────────────────────────────────────────────────────────── +// Cache-Reads (Frontend nutzt diese statt Live-QBO-Calls) +// ──────────────────────────────────────────────────────────────────── + +/** + * Holt Vendors aus dem Cache, optional nach Suchstring gefiltert. + */ +async function getVendorsFromCache({ search = '', activeOnly = true, limit = 200 } = {}) { + const where = []; + const params = []; + if (activeOnly) where.push('active = true'); + if (search) { + params.push(`%${search.toLowerCase()}%`); + where.push(`LOWER(display_name) LIKE $${params.length}`); + } + params.push(limit); + const sql = ` + SELECT qbo_id, display_name, company_name, primary_email, primary_phone, active + FROM qbo_vendor_cache + ${where.length ? 'WHERE ' + where.join(' AND ') : ''} + ORDER BY display_name ASC + LIMIT $${params.length} + `; + const r = await pool.query(sql, params); + return r.rows.map(row => ({ + id: row.qbo_id, + displayName: row.display_name, + companyName: row.company_name, + email: row.primary_email, + phone: row.primary_phone, + active: row.active + })); +} + +/** + * Holt Expense-Accounts aus dem Cache (alles wo Classification=Expense oder Type=Expense). + * Wird im Expense-Modal als Category-Dropdown genutzt. + */ +async function getExpenseAccountsFromCache({ activeOnly = true } = {}) { + const where = ['(classification = $1 OR account_type = $1)']; + const params = ['Expense']; + if (activeOnly) where.push('active = true'); + + const sql = ` + SELECT qbo_id, name, fully_qualified_name, account_type, account_sub_type + FROM qbo_account_cache + WHERE ${where.join(' AND ')} + ORDER BY name ASC + `; + const r = await pool.query(sql, params); + return r.rows.map(row => ({ + id: row.qbo_id, + name: row.name, + fullyQualifiedName: row.fully_qualified_name, + accountType: row.account_type, + accountSubType: row.account_sub_type + })); +} + +/** + * Holt Bank- und Credit-Card-Accounts aus dem Cache (für Payment-Account-Dropdown). + */ +async function getPaymentAccountsFromCache({ activeOnly = true } = {}) { + const where = ["account_type IN ('Bank', 'Credit Card')"]; + if (activeOnly) where.push('active = true'); + const sql = ` + SELECT qbo_id, name, account_type, current_balance + FROM qbo_account_cache + WHERE ${where.join(' AND ')} + ORDER BY account_type ASC, name ASC + `; + const r = await pool.query(sql); + return r.rows.map(row => ({ + id: row.qbo_id, + name: row.name, + accountType: row.account_type, + currentBalance: row.current_balance != null ? Number(row.current_balance) : null + })); +} + +// ──────────────────────────────────────────────────────────────────── +// Payment Methods — direkt von QBO geholt, nicht gecached +// (sehr kleine, sehr selten ändernde Liste; Cache wäre overkill) +// ──────────────────────────────────────────────────────────────────── + +async function getPaymentMethods({ activeOnly = true } = {}) { + const { companyId, baseUrl } = getClientInfo(); + const where = activeOnly ? ' WHERE Active = true' : ''; + const query = `SELECT * FROM PaymentMethod${where} ORDERBY Name ASC MAXRESULTS 200`; + + const url = withMinorVersion( + `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(query)}` + ); + const response = await makeQboApiCall({ url, method: 'GET' }); + const data = getJson(response); + throwIfFault(data, 'PaymentMethod query'); + + const list = (data.QueryResponse && data.QueryResponse.PaymentMethod) || []; + return list.map(p => ({ + id: p.Id, + name: p.Name, + type: p.Type, + active: p.Active === true + })); +} + +// ════════════════════════════════════════════════════════════════════ +// Exports +// ════════════════════════════════════════════════════════════════════ + module.exports = { + // Phase 1 listAccounts, getRegister, getProfitAndLoss, getBalanceSheet, - fetchSplitDetails, - // exposed for testing/debugging - normalizeTransactionListReport -}; + normalizeTransactionListReport, + + // Phase 2 Lieferung 1 — Sync + syncAccountsCache, + syncVendorsCache, + getCacheStatus, + cacheIsStaleToday, + + // Phase 2 Lieferung 1 — Reads + getVendorsFromCache, + getExpenseAccountsFromCache, + getPaymentAccountsFromCache, + getPaymentMethods, + + // Audit + writeAuditLog +}; \ No newline at end of file