// 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. */ const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // QBO minor version — fixiert für stabilen Field-Support const QBO_MINOR_VERSION = '75'; function getClientInfo() { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); 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 => `${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}` ).join('; '); const err = new Error(`QBO ${context} failed: ${msg}`); err.qboFault = data.Fault; throw err; } } // ──────────────────────────────────────────────────────────────────── // Accounts // ──────────────────────────────────────────────────────────────────── /** * 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 */ 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 currentBalance: a.CurrentBalance != null ? Number(a.CurrentBalance) : null, currency: a.CurrencyRef ? a.CurrencyRef.value : null, active: a.Active === true, syncToken: a.SyncToken })); } // ──────────────────────────────────────────────────────────────────── // 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(); if (startDate) params.set('start_date', startDate); if (endDate) params.set('end_date', endDate); params.set('source_account', String(accountId)); 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); // ── NEU: Split-Details nachladen ── if (includeSplits) { 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]; } }); } } 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; if (c.ColType) colIndex[c.ColType.toLowerCase()] = i; }); const resolve = (...candidates) => { for (const k of candidates) { const idx = colIndex[k.toLowerCase()]; if (idx != null) return idx; } return null; }; const idxDate = resolve('Date', 'tx_date'); const idxType = resolve('Transaction Type', 'Type', 'txn_type'); const idxDocNum = resolve('Num', 'No.', 'doc_num'); const idxPayee = resolve('Name', 'Payee', 'name'); const idxAccount = resolve('Account', 'account_name'); 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; }; 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.ColData) continue; const dateCell = cellAt(r.ColData, idxDate); const typeCell = cellAt(r.ColData, idxType); const docCell = cellAt(r.ColData, idxDocNum); const payeeCell = cellAt(r.ColData, idxPayee); const acctCell = cellAt(r.ColData, idxAccount); 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) || (docCell && docCell.id) || (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, docNum: docCell ? docCell.value : null, payee: payeeCell ? payeeCell.value : null, account: acctCell ? acctCell.value : null, memo: memoCell ? memoCell.value : null, 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 { meta: { reportName: report.Header && report.Header.ReportName, startPeriod: report.Header && report.Header.StartPeriod, endPeriod: report.Header && report.Header.EndPeriod, currency: report.Header && report.Header.Currency, time: report.Header && report.Header.Time }, columns: columns.map(c => ({ title: c.ColTitle, type: c.ColType || c.type })), rows }; } // ──────────────────────────────────────────────────────────────────── // 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 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; const lines = txn.Line .filter(l => l.DetailType !== 'SubTotalLineDetail') .map(l => { const detail = l.AccountBasedExpenseLineDetail || l.DepositLineDetail || l.JournalEntryLineDetail || {}; const acctRef = detail.AccountRef || {}; return { account: acctRef.name || null, amount: l.Amount != null ? Number(l.Amount) : null, description: l.Description || null }; }) .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); } } 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 return null; } function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } module.exports = { listAccounts, getRegister, getProfitAndLoss, getBalanceSheet, fetchSplitDetails, // exposed for testing/debugging normalizeTransactionListReport };