This commit is contained in:
2026-05-06 21:34:21 -05:00
parent 9aae0e3c13
commit e01c5367bc
2 changed files with 532 additions and 237 deletions

View File

@@ -1,115 +1,169 @@
/** /**
* Accounting Routes * Accounting Routes — /api/accounting/*
* /api/accounting/*
* *
* Phase 1 — read-only: * Phase 1 + Phase 2 Lieferung 1 (Sync, Cache-Reads, Sync-Status)
* GET /accounts
* POST /sync-accounts (Phase-1 Stub)
* GET /register
* GET /reports/profit-loss
* GET /reports/balance-sheet
*/ */
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const accountingService = require('../services/accounting-service'); 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) { function handleQboError(err, res, context) {
console.error(`❌ Accounting/${context} error:`, err.message); console.error(`❌ Accounting/${context} error:`, err.message);
if (err.qboFault) { if (err.qboFault) console.error(' QBO Fault detail:', JSON.stringify(err.qboFault));
console.error(' QBO Fault detail:', JSON.stringify(err.qboFault));
}
if (err.stack) console.error(err.stack); 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) => { router.get('/accounts', async (req, res) => {
try { try {
const type = req.query.type || null; const accounts = await accountingService.listAccounts({
const activeOnly = req.query.activeOnly === 'false' ? false : true; type: req.query.type || null,
activeOnly: req.query.activeOnly !== 'false'
const accounts = await accountingService.listAccounts({ type, activeOnly }); });
res.json(accounts); res.json(accounts);
} catch (err) { } catch (err) { handleQboError(err, res, 'accounts'); }
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=<id>&startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
router.get('/register', async (req, res) => { router.get('/register', async (req, res) => {
const { accountId, startDate, endDate } = req.query; 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 { try {
const result = await accountingService.getRegister({ res.json(await accountingService.getRegister({ accountId, startDate, endDate }));
accountId, } catch (err) { handleQboError(err, res, 'register'); }
startDate,
endDate
});
res.json(result);
} 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) => { router.get('/reports/profit-loss', async (req, res) => {
const { startDate, endDate, accountingMethod } = req.query;
try { try {
const data = await accountingService.getProfitAndLoss({ res.json(await accountingService.getProfitAndLoss({
startDate, startDate: req.query.startDate,
endDate, endDate: req.query.endDate,
accountingMethod: accountingMethod || 'Accrual' accountingMethod: req.query.accountingMethod || 'Accrual'
}); }));
res.json(data); } catch (err) { handleQboError(err, res, 'profit-loss'); }
} 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) => { router.get('/reports/balance-sheet', async (req, res) => {
const { asOfDate, accountingMethod } = req.query;
try { try {
const data = await accountingService.getBalanceSheet({ res.json(await accountingService.getBalanceSheet({
asOfDate, asOfDate: req.query.asOfDate,
accountingMethod: accountingMethod || 'Accrual' 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) { } catch (err) {
handleQboError(err, res, 'balance-sheet'); 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'); }
});
module.exports = router; module.exports = router;

View File

@@ -1,15 +1,23 @@
// src/services/accounting-service.js // src/services/accounting-service.js
/** /**
* Accounting Service * 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 { 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_MINOR_VERSION = '75';
const QBO_PAGE_SIZE = 1000;
// ────────────────────────────────────────────────────────────────────
// Common helpers
// ────────────────────────────────────────────────────────────────────
function getClientInfo() { function getClientInfo() {
const oauthClient = getOAuthClient(); const oauthClient = getOAuthClient();
@@ -18,23 +26,14 @@ function getClientInfo() {
return { oauthClient, companyId, baseUrl }; return { oauthClient, companyId, baseUrl };
} }
/**
* Helper: extrahiert .json() aus QBO Response (kompatibel zu intuit-oauth)
*/
function getJson(response) { function getJson(response) {
return response.getJson ? response.getJson() : response.json; return response.getJson ? response.getJson() : response.json;
} }
/**
* Helper: hängt minorversion an URLs an, ohne bestehende Query-Strings zu zerschießen
*/
function withMinorVersion(url) { function withMinorVersion(url) {
return url + (url.includes('?') ? '&' : '?') + 'minorversion=' + QBO_MINOR_VERSION; return url + (url.includes('?') ? '&' : '?') + 'minorversion=' + QBO_MINOR_VERSION;
} }
/**
* Wirft einen lesbaren Fehler bei QBO Faults
*/
function throwIfFault(data, context) { function throwIfFault(data, context) {
if (data && data.Fault && data.Fault.Error) { if (data && data.Fault && data.Fault.Error) {
const msg = data.Fault.Error.map(e => 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. * 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.
* @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
*/ */
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 } = {}) { async function listAccounts({ type = null, activeOnly = true } = {}) {
const { companyId, baseUrl } = getClientInfo(); const { companyId, baseUrl } = getClientInfo();
let where = []; let where = [];
if (activeOnly) where.push("Active = true"); if (activeOnly) where.push("Active = true");
if (type) { if (type) {
// Erlaube 'CreditCard' als URL-freundliche Variante
const normalizedType = type === 'CreditCard' ? 'Credit Card' : type; const normalizedType = type === 'CreditCard' ? 'Credit Card' : type;
// Apostrophe in QBO-Strings escaped man durch Verdoppelung
const safe = normalizedType.replace(/'/g, "''"); const safe = normalizedType.replace(/'/g, "''");
where.push(`AccountType = '${safe}'`); where.push(`AccountType = '${safe}'`);
} }
const whereClause = where.length ? ' WHERE ' + where.join(' AND ') : ''; const whereClause = where.length ? ' WHERE ' + where.join(' AND ') : '';
const query = `SELECT * FROM Account${whereClause} ORDERBY Name ASC MAXRESULTS 1000`; const query = `SELECT * FROM Account${whereClause} ORDERBY Name ASC MAXRESULTS 1000`;
const url = withMinorVersion( const url = withMinorVersion(
`${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(query)}` `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(query)}`
); );
const response = await makeQboApiCall({ url, method: 'GET' }); const response = await makeQboApiCall({ url, method: 'GET' });
const data = getJson(response); const data = getJson(response);
throwIfFault(data, 'Account query'); throwIfFault(data, 'Account query');
const accounts = (data.QueryResponse && data.QueryResponse.Account) || []; const accounts = (data.QueryResponse && data.QueryResponse.Account) || [];
// Schlanke, frontend-freundliche Form
return accounts.map(a => ({ return accounts.map(a => ({
id: a.Id, id: a.Id,
name: a.Name, name: a.Name,
fullyQualifiedName: a.FullyQualifiedName, fullyQualifiedName: a.FullyQualifiedName,
accountType: a.AccountType, accountType: a.AccountType,
accountSubType: a.AccountSubType, accountSubType: a.AccountSubType,
classification: a.Classification, // Asset, Liability, Equity, Revenue, Expense classification: a.Classification,
currentBalance: a.CurrentBalance != null ? Number(a.CurrentBalance) : null, currentBalance: a.CurrentBalance != null ? Number(a.CurrentBalance) : null,
currency: a.CurrencyRef ? a.CurrencyRef.value : null, currency: a.CurrencyRef ? a.CurrencyRef.value : null,
active: a.Active === true, 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 }) { async function getRegister({ accountId, startDate, endDate, includeSplits = true }) {
if (!accountId) throw new Error('accountId is required'); if (!accountId) throw new Error('accountId is required');
const { companyId, baseUrl } = getClientInfo(); const { companyId, baseUrl } = getClientInfo();
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -125,39 +184,27 @@ async function getRegister({ accountId, startDate, endDate, includeSplits = true
params.set('minorversion', QBO_MINOR_VERSION); params.set('minorversion', QBO_MINOR_VERSION);
const url = `${baseUrl}/v3/company/${companyId}/reports/TransactionList?${params.toString()}`; const url = `${baseUrl}/v3/company/${companyId}/reports/TransactionList?${params.toString()}`;
const response = await makeQboApiCall({ url, method: 'GET' }); const response = await makeQboApiCall({ url, method: 'GET' });
const data = getJson(response); const data = getJson(response);
throwIfFault(data, 'TransactionList report'); throwIfFault(data, 'TransactionList report');
const result = normalizeTransactionListReport(data); const result = normalizeTransactionListReport(data);
// Nur Split-Details nachladen (für -Split- Zeilen) — Cleared-Status nicht // Splits nachladen
if (includeSplits) { if (includeSplits) {
const splitRows = result.rows.filter(r => const splitRows = result.rows.filter(r => r.splitAccount === '-Split-' && r.qboId);
r.splitAccount === '-Split-' && r.qboId
);
if (splitRows.length) { if (splitRows.length) {
const splits = await fetchSplitDetails(splitRows); const splits = await fetchSplitDetails(splitRows);
result.rows.forEach(r => { result.rows.forEach(r => {
if (r.qboId && splits[r.qboId]) { if (r.qboId && splits[r.qboId]) r.splits = splits[r.qboId];
r.splits = splits[r.qboId];
}
}); });
} }
} }
return result; 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) { function normalizeTransactionListReport(report) {
const columns = (report.Columns && report.Columns.Column) || []; const columns = (report.Columns && report.Columns.Column) || [];
// Map: ColTitle (lowercase) → Index
const colIndex = {}; const colIndex = {};
columns.forEach((c, i) => { columns.forEach((c, i) => {
if (c.ColTitle) colIndex[c.ColTitle.toLowerCase()] = i; if (c.ColTitle) colIndex[c.ColTitle.toLowerCase()] = i;
@@ -180,24 +227,18 @@ function normalizeTransactionListReport(report) {
const idxMemo = resolve('Memo/Description', 'Memo', 'memo'); const idxMemo = resolve('Memo/Description', 'Memo', 'memo');
const idxSplit = resolve('Split', 'split_acc'); const idxSplit = resolve('Split', 'split_acc');
const idxAmount = resolve('Amount', 'subt_nat_amount', 'subt_nat_home_amount'); const idxAmount = resolve('Amount', 'subt_nat_amount', 'subt_nat_home_amount');
const idxCleared = resolve('Cleared', 'cleared_status', 'clr');
const cellAt = (colData, idx) => { const cellAt = (colData, idx) => {
if (idx == null) return null; if (idx == null) return null;
const c = colData[idx]; return colData[idx] || null;
return c || null;
}; };
const rows = []; const rows = [];
function walk(rowGroup) { function walk(rowGroup) {
if (!rowGroup) return; if (!rowGroup) return;
const items = Array.isArray(rowGroup) ? rowGroup : (rowGroup.Row || []); const items = Array.isArray(rowGroup) ? rowGroup : (rowGroup.Row || []);
for (const r of items) { for (const r of items) {
if (r.type === 'Section' || r.Rows) { if (r.type === 'Section' || r.Rows) { walk(r.Rows && r.Rows.Row); continue; }
walk(r.Rows && r.Rows.Row);
continue;
}
if (!r.ColData) continue; if (!r.ColData) continue;
const dateCell = cellAt(r.ColData, idxDate); const dateCell = cellAt(r.ColData, idxDate);
@@ -208,7 +249,6 @@ function normalizeTransactionListReport(report) {
const memoCell = cellAt(r.ColData, idxMemo); const memoCell = cellAt(r.ColData, idxMemo);
const splitCell = cellAt(r.ColData, idxSplit); const splitCell = cellAt(r.ColData, idxSplit);
const amtCell = cellAt(r.ColData, idxAmount); const amtCell = cellAt(r.ColData, idxAmount);
const clrCell = cellAt(r.ColData, idxCleared);
const qboId = const qboId =
(dateCell && dateCell.id) || (dateCell && dateCell.id) ||
@@ -216,12 +256,6 @@ function normalizeTransactionListReport(report) {
(typeCell && typeCell.id) || (typeCell && typeCell.id) ||
null; null;
let clearedStatus = null;
if (clrCell && clrCell.value) {
const v = String(clrCell.value).trim().toUpperCase();
if (v === 'R' || v === 'C') clearedStatus = v;
}
rows.push({ rows.push({
date: dateCell ? dateCell.value : null, date: dateCell ? dateCell.value : null,
type: typeCell ? typeCell.value : null, type: typeCell ? typeCell.value : null,
@@ -232,12 +266,10 @@ function normalizeTransactionListReport(report) {
amount: amtCell && amtCell.value !== '' && amtCell.value != null amount: amtCell && amtCell.value !== '' && amtCell.value != null
? Number(amtCell.value) : null, ? Number(amtCell.value) : null,
splitAccount: splitCell ? splitCell.value : null, splitAccount: splitCell ? splitCell.value : null,
clearedStatus,
qboId qboId
}); });
} }
} }
walk(report.Rows && report.Rows.Row); walk(report.Rows && report.Rows.Row);
return { 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<qboId, Array<{account, amount, description}>>
*/
async function fetchSplitDetails(splitRows) { async function fetchSplitDetails(splitRows) {
if (!splitRows || splitRows.length === 0) return {}; if (!splitRows || splitRows.length === 0) return {};
const { companyId, baseUrl } = getClientInfo(); const { companyId, baseUrl } = getClientInfo();
// Group by Type, weil QBO unterschiedliche Endpoints für Purchase/Deposit/JournalEntry hat
const result = {}; const result = {};
for (const row of splitRows) { for (const row of splitRows) {
if (!row.qboId) continue; if (!row.qboId) continue;
// Type → QBO endpoint name
const endpoint = mapTypeToEndpoint(row.type); const endpoint = mapTypeToEndpoint(row.type);
if (!endpoint) continue; if (!endpoint) continue;
try { try {
const url = withMinorVersion( const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/${endpoint}/${row.qboId}`);
`${baseUrl}/v3/company/${companyId}/${endpoint}/${row.qboId}`
);
const response = await makeQboApiCall({ url, method: 'GET' }); const response = await makeQboApiCall({ url, method: 'GET' });
const data = getJson(response); const data = getJson(response);
// Response shape: { Purchase: {...} } or { Deposit: {...} } etc.
const txn = data[capitalize(endpoint)] || data[endpoint]; const txn = data[capitalize(endpoint)] || data[endpoint];
if (!txn || !txn.Line) continue; if (!txn || !txn.Line) continue;
@@ -349,14 +317,11 @@ async function fetchSplitDetails(splitRows) {
}; };
}) })
.filter(l => l.account || l.amount != null); .filter(l => l.account || l.amount != null);
if (lines.length) result[row.qboId] = lines; if (lines.length) result[row.qboId] = lines;
} catch (err) { } catch (err) {
// Einzelne Fehler nicht den ganzen Register killen lassen console.warn(`Split fetch failed for ${row.type} ${row.qboId}:`, err.message);
console.warn(`Split detail fetch failed for ${row.type} ${row.qboId}:`, err.message);
} }
} }
return result; return result;
} }
@@ -369,20 +334,296 @@ function mapTypeToEndpoint(type) {
if (t.includes('bill payment')) return 'billpayment'; if (t.includes('bill payment')) return 'billpayment';
if (t.includes('bill')) return 'bill'; if (t.includes('bill')) return 'bill';
if (t.includes('credit card')) return 'purchase'; if (t.includes('credit card')) return 'purchase';
if (t.includes('paycheck') || t.includes('payroll')) return null; // QBO blockt Paycheck-API if (t.includes('paycheck') || t.includes('payroll')) return null;
if (t.includes('tax payment')) return null; // dito if (t.includes('tax payment')) return null;
return null; return null;
} }
function capitalize(s) { function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1); 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 = { module.exports = {
// Phase 1
listAccounts, listAccounts,
getRegister, getRegister,
getProfitAndLoss, getProfitAndLoss,
getBalanceSheet, getBalanceSheet,
fetchSplitDetails, normalizeTransactionListReport,
// exposed for testing/debugging
normalizeTransactionListReport // Phase 2 Lieferung 1 — Sync
syncAccountsCache,
syncVendorsCache,
getCacheStatus,
cacheIsStaleToday,
// Phase 2 Lieferung 1 — Reads
getVendorsFromCache,
getExpenseAccountsFromCache,
getPaymentAccountsFromCache,
getPaymentMethods,
// Audit
writeAuditLog
}; };