// src/services/accounting-service.js /** * Accounting Service * * 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'); const QBO_MINOR_VERSION = '75'; const QBO_PAGE_SIZE = 1000; // ──────────────────────────────────────────────────────────────────── // Common helpers // ──────────────────────────────────────────────────────────────────── function getClientInfo() { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); return { oauthClient, companyId, baseUrl }; } function getJson(response) { return response.getJson ? response.getJson() : response.json; } function withMinorVersion(url) { return url + (url.includes('?') ? '&' : '?') + 'minorversion=' + QBO_MINOR_VERSION; } 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; } } // ──────────────────────────────────────────────────────────────────── // 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; } /** * 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) { const normalizedType = type === 'CreditCard' ? 'Credit Card' : type; 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) || []; return accounts.map(a => ({ id: a.Id, name: a.Name, fullyQualifiedName: a.FullyQualifiedName, accountType: a.AccountType, accountSubType: a.AccountSubType, classification: a.Classification, currentBalance: a.CurrentBalance != null ? Number(a.CurrentBalance) : null, currency: a.CurrencyRef ? a.CurrencyRef.value : null, active: a.Active === true, syncToken: a.SyncToken })); } 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); // Splits 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; } function normalizeTransactionListReport(report) { const columns = (report.Columns && report.Columns.Column) || []; 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 cellAt = (colData, idx) => { if (idx == null) return 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.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 qboId = (dateCell && dateCell.id) || (docCell && docCell.id) || (typeCell && typeCell.id) || null; 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, 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 }; } async function fetchSplitDetails(splitRows) { if (!splitRows || splitRows.length === 0) return {}; const { companyId, baseUrl } = getClientInfo(); const result = {}; for (const row of splitRows) { if (!row.qboId) continue; 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); 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) { 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; 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; } async function getTaxSummary({ startDate, endDate, accountingMethod = 'Accrual' } = {}) { const { companyId, baseUrl } = getClientInfo(); const qboQuery = (sql) => { const url = `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(sql)}&minorversion=${QBO_MINOR_VERSION}`; return makeQboApiCall({ url, method: 'GET' }); }; const rateMap = {}; try { const rateRes = await qboQuery('SELECT * FROM TaxRate MAXRESULTS 1000'); const rateData = getJson(rateRes); throwIfFault(rateData, 'TaxRate query'); const rates = rateData?.QueryResponse?.TaxRate || []; for (const r of rates) { rateMap[r.Id] = { name: r.Name || `Tax Rate ${r.Id}`, rateValue: parseFloat(r.RateValue) || 0 }; } } catch (e) { console.error('TaxRate query failed, proceeding without rate names:', e.message); } const whereClause = `TxnDate >= '${startDate}' AND TxnDate <= '${endDate}'`; const agg = {}; let totalSales = 0; let totalTaxable = 0; for (const entity of ['Invoice', 'SalesReceipt']) { try { const res = await qboQuery(`SELECT * FROM ${entity} WHERE ${whereClause} MAXRESULTS 1000`); const data = getJson(res); throwIfFault(data, `${entity} query`); const docs = data?.QueryResponse?.[entity] || []; for (const doc of docs) { const taxDetail = doc.TxnTaxDetail; const totalAmt = parseFloat(doc.TotalAmt) || 0; const totalTax = taxDetail ? (parseFloat(taxDetail.TotalTax) || 0) : 0; totalSales += (totalAmt - totalTax); if (!taxDetail || !taxDetail.TaxLine) continue; const taxLines = Array.isArray(taxDetail.TaxLine) ? taxDetail.TaxLine : [taxDetail.TaxLine]; let docTaxable = 0; for (const line of taxLines) { const detail = line.TaxLineDetail; if (!detail || !detail.TaxRateRef) continue; const rateId = String(detail.TaxRateRef.value); const taxable = parseFloat(detail.NetAmountTaxable) || 0; const collected = parseFloat(line.Amount) || 0; if (!agg[rateId]) agg[rateId] = { taxableSales: 0, taxCollected: 0 }; agg[rateId].taxableSales += taxable; agg[rateId].taxCollected += collected; docTaxable = Math.max(docTaxable, taxable); } totalTaxable += docTaxable; } } catch (e) { console.error(`${entity} query failed:`, e.message); } } const nontaxableSales = totalSales - totalTaxable; const roundTotalSales = Math.round(totalSales); const roundTotalTaxable = Math.round(totalTaxable); const roundNontaxable = roundTotalSales - roundTotalTaxable; // derived, prevents drift const rows = Object.entries(agg).map(([rateId, amounts]) => { const info = rateMap[rateId] || { name: `Tax Rate ${rateId}`, rateValue: 0 }; const ratePct = info.rateValue.toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); const roundTaxable = Math.round(amounts.taxableSales); const roundNontaxableJurisdiction = roundTotalSales - roundTaxable; return { type: 'Section', Header: { ColData: [ { value: info.name }, { value: '' }, { value: '' }, { value: '' }, { value: '' }, { value: `${ratePct}%` } ] }, Summary: { ColData: [ { value: 'Total' }, { value: String(roundTotalSales) }, { value: String(roundNontaxableJurisdiction) }, { value: String(roundTaxable) }, { value: amounts.taxCollected.toFixed(2) }, { value: `${ratePct}%` } ] } }; }); rows.sort((a, b) => { const na = a.Header.ColData[0].value; const nb = b.Header.ColData[0].value; if (na.includes('State') && !nb.includes('State')) return -1; if (!na.includes('State') && nb.includes('State')) return 1; return na.localeCompare(nb); }); const grandTaxCollected = Object.values(agg).reduce((s, a) => s + a.taxCollected, 0); rows.push({ type: 'Section', Header: { ColData: [ { value: 'GRAND TOTAL' }, { value: '' }, { value: '' }, { value: '' }, { value: '' }, { value: '' } ] }, Summary: { ColData: [ { value: 'Total' }, { value: String(roundTotalSales) }, { value: String(roundNontaxable) }, { value: String(roundTotalTaxable) }, { value: grandTaxCollected.toFixed(2) }, { value: '' } ] } }); return { Header: { ReportName: 'Sales Tax Liability', StartPeriod: startDate, EndPeriod: endDate, ReportBasis: accountingMethod }, Columns: { Column: [ { ColTitle: '', ColType: 'Text' }, { ColTitle: 'Total Sales', ColType: 'Money' }, { ColTitle: 'Nontaxable', ColType: 'Money' }, { ColTitle: 'Taxable', ColType: 'Money' }, { ColTitle: 'Tax Collected', ColType: 'Money' }, { ColTitle: 'Tax Rate', ColType: 'Percent' } ] }, Rows: { Row: rows } }; } // ════════════════════════════════════════════════════════════════════ // Sales Tax Periods — local source of truth for tax filings // ════════════════════════════════════════════════════════════════════ async function getTaxPeriods() { const result = await pool.query( 'SELECT * FROM sales_tax_periods ORDER BY period_start DESC' ); return result.rows; } async function upsertTaxPeriod(period) { const result = await pool.query( `INSERT INTO sales_tax_periods (period_start, period_end, total_sales, nontaxable_sales, taxable_sales, tax_collected, adjustment_amount, adjustment_reason, adjustment_account_id, adjustment_account_name, net_paid, bank_account_id, bank_account_name, sales_tax_payable_id, sales_tax_payable_name, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT (period_start, period_end) DO UPDATE SET total_sales = EXCLUDED.total_sales, nontaxable_sales = EXCLUDED.nontaxable_sales, taxable_sales = EXCLUDED.taxable_sales, tax_collected = EXCLUDED.tax_collected, adjustment_amount = COALESCE(sales_tax_periods.adjustment_amount, EXCLUDED.adjustment_amount), adjustment_reason = COALESCE(sales_tax_periods.adjustment_reason, EXCLUDED.adjustment_reason), adjustment_account_id = COALESCE(sales_tax_periods.adjustment_account_id, EXCLUDED.adjustment_account_id), adjustment_account_name = COALESCE(sales_tax_periods.adjustment_account_name, EXCLUDED.adjustment_account_name), net_paid = COALESCE(sales_tax_periods.net_paid, EXCLUDED.net_paid), bank_account_id = COALESCE(sales_tax_periods.bank_account_id, EXCLUDED.bank_account_id), bank_account_name = COALESCE(sales_tax_periods.bank_account_name, EXCLUDED.bank_account_name), sales_tax_payable_id = COALESCE(sales_tax_periods.sales_tax_payable_id, EXCLUDED.sales_tax_payable_id), sales_tax_payable_name = COALESCE(sales_tax_periods.sales_tax_payable_name, EXCLUDED.sales_tax_payable_name), status = COALESCE(sales_tax_periods.status, EXCLUDED.status), updated_at = CURRENT_TIMESTAMP RETURNING *`, [ period.period_start, period.period_end, period.total_sales, period.nontaxable_sales, period.taxable_sales, period.tax_collected, period.adjustment_amount || 0, period.adjustment_reason || null, period.adjustment_account_id || null, period.adjustment_account_name || null, period.net_paid || null, period.bank_account_id || null, period.bank_account_name || null, period.sales_tax_payable_id || null, period.sales_tax_payable_name || null, period.status || 'open' ] ); return result.rows[0]; } async function markTaxPaidExternal(periodId, paidDate = null) { const result = await pool.query( `UPDATE sales_tax_periods SET status = 'paid', booked_at = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND status = 'open' RETURNING *`, [periodId, paidDate || new Date().toISOString().split('T')[0]] ); if (result.rows.length === 0) { const existing = await pool.query('SELECT status FROM sales_tax_periods WHERE id = $1', [periodId]); const currentStatus = existing.rows[0]?.status || 'unknown'; throw new Error(`Cannot mark as paid: period is already ${currentStatus}`); } return result.rows[0]; } // ════════════════════════════════════════════════════════════════════ // 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 })); } // ──────────────────────────────────────────────────────────────────── // Phase 2 Lieferung 2 — Vendor Create // ──────────────────────────────────────────────────────────────────── /** * Erstellt einen neuen Vendor in QBO und schreibt ihn in den Cache. * * Idempotenz: Wenn ein aktiver Vendor mit gleichem display_name (case-insensitive) * bereits im Cache existiert, wird KEIN neuer angelegt — stattdessen wird der * existierende zurückgegeben mit { existed: true }. * * @param {Object} data * @param {string} data.name - Pflicht: DisplayName * @param {string} [data.email] * @param {string} [data.phone] * @param {Object} [data.address] - { line1, line2, city, state, zip, country } * @param {string} [data.notes] * @returns {{ id, displayName, email, phone, existed: boolean }} */ async function createVendor(data) { const name = (data.name || '').trim(); if (!name) { const err = new Error('Vendor name is required'); err.statusCode = 400; throw err; } // ── Idempotenz-Check ── const existingResult = await pool.query( `SELECT qbo_id, display_name, primary_email, primary_phone FROM qbo_vendor_cache WHERE active = true AND LOWER(display_name) = LOWER($1) LIMIT 1`, [name] ); if (existingResult.rows.length > 0) { const v = existingResult.rows[0]; return { id: v.qbo_id, displayName: v.display_name, email: v.primary_email, phone: v.primary_phone, existed: true }; } // ── QBO Create ── const { companyId, baseUrl } = getClientInfo(); const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/vendor`); const payload = { DisplayName: name, CompanyName: name, Active: true }; if (data.email) payload.PrimaryEmailAddr = { Address: data.email }; if (data.phone) payload.PrimaryPhone = { FreeFormNumber: data.phone }; if (data.notes) payload.Notes = data.notes; if (data.address && (data.address.line1 || data.address.city)) { const a = data.address; const billAddr = {}; if (a.line1) billAddr.Line1 = a.line1; if (a.line2) billAddr.Line2 = a.line2; if (a.city) billAddr.City = a.city; if (a.state) billAddr.CountrySubDivisionCode = a.state; if (a.zip) billAddr.PostalCode = a.zip; if (a.country) billAddr.Country = a.country; payload.BillAddr = billAddr; } let qboResponse; try { const response = await makeQboApiCall({ url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); qboResponse = getJson(response); } catch (err) { await writeAuditLog({ action: 'vendor.create', entityType: 'Vendor', status: 'error', requestExcerpt: JSON.stringify(payload).slice(0, 1000), responseExcerpt: err.message }); throw err; } if (qboResponse.Fault) { const msg = qboResponse.Fault.Error.map(e => `${e.code}: ${e.Message}`).join('; '); await writeAuditLog({ action: 'vendor.create', entityType: 'Vendor', status: 'error', requestExcerpt: JSON.stringify(payload).slice(0, 1000), responseExcerpt: msg }); const err = new Error('QBO Vendor create failed: ' + msg); err.qboFault = qboResponse.Fault; throw err; } const v = qboResponse.Vendor; if (!v || !v.Id) { throw new Error('QBO returned no vendor id'); } // ── In Cache schreiben ── const email = v.PrimaryEmailAddr ? v.PrimaryEmailAddr.Address : null; const phone = v.PrimaryPhone ? v.PrimaryPhone.FreeFormNumber : null; await pool.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] ); await writeAuditLog({ action: 'vendor.create', entityType: 'Vendor', entityQboId: v.Id, status: 'success', requestExcerpt: JSON.stringify(payload).slice(0, 1000), responseExcerpt: `Vendor ${v.Id} (${v.DisplayName}) created` }); console.log(`✅ QBO Vendor created: ${v.Id} (${v.DisplayName})`); return { id: v.Id, displayName: v.DisplayName, email, phone, existed: false }; } // ──────────────────────────────────────────────────────────────────── // Phase 2 Lieferung 2 — Expense Create // ──────────────────────────────────────────────────────────────────── /** * Erstellt eine QBO Purchase (= "Expense" in QBO-Sprech). * * @param {Object} data * @param {string} data.vendorId - Pflicht * @param {string} data.paymentAccountId - Pflicht (Bank- oder Credit-Card-Account) * @param {string} data.txnDate - Pflicht, YYYY-MM-DD * @param {string} [data.paymentMethodId] * @param {string} [data.refNo] * @param {string} [data.memo] * @param {Array} data.lines - Pflicht, mind. 1 Line * Line: { accountId, amount, description? } * * @returns {{ id, txnDate, totalAmt, lineCount, vendorName, accountName }} */ async function createExpense(data) { // ── Validierung ── if (!data.vendorId) throw badRequest('vendorId is required'); if (!data.paymentAccountId) throw badRequest('paymentAccountId is required'); if (!data.txnDate) throw badRequest('txnDate is required'); if (!Array.isArray(data.lines) || data.lines.length === 0) { throw badRequest('At least one line is required'); } for (const [i, line] of data.lines.entries()) { if (!line.accountId) throw badRequest(`Line ${i + 1}: accountId is required`); const amt = Number(line.amount); if (!isFinite(amt) || amt <= 0) { throw badRequest(`Line ${i + 1}: amount must be a positive number`); } } // ── Account-Type des Payment-Accounts bestimmen ── // Bank → PaymentType "Check" (default in QBO) // Credit Card → PaymentType "CreditCard" const acctRow = await pool.query( `SELECT qbo_id, name, account_type FROM qbo_account_cache WHERE qbo_id = $1`, [data.paymentAccountId] ); if (acctRow.rows.length === 0) { throw badRequest(`Payment account ${data.paymentAccountId} not in cache. Run sync first.`); } const paymentAcct = acctRow.rows[0]; const paymentType = paymentAcct.account_type === 'Credit Card' ? 'CreditCard' : 'Check'; // ── Vendor-Name aus Cache (für Logging/Response) ── const vendorRow = await pool.query( `SELECT display_name FROM qbo_vendor_cache WHERE qbo_id = $1`, [data.vendorId] ); const vendorName = vendorRow.rows[0]?.display_name || data.vendorId; // ── QBO Purchase Payload ── const totalAmt = data.lines.reduce((sum, l) => sum + Number(l.amount), 0); const payload = { AccountRef: { value: paymentAcct.qbo_id, name: paymentAcct.name }, EntityRef: { value: data.vendorId, type: 'Vendor', name: vendorName }, TxnDate: data.txnDate, PaymentType: paymentType, Line: data.lines.map(line => ({ DetailType: 'AccountBasedExpenseLineDetail', Amount: Number(line.amount), Description: line.description || undefined, AccountBasedExpenseLineDetail: { AccountRef: { value: String(line.accountId) } } })) }; if (data.refNo) payload.DocNumber = String(data.refNo).slice(0, 21); if (data.memo) payload.PrivateNote = String(data.memo); if (data.paymentMethodId) { payload.PaymentMethodRef = { value: String(data.paymentMethodId) }; } // ── QBO POST ── const { companyId, baseUrl } = getClientInfo(); const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase`); const requestSummary = `${vendorName} | ${paymentAcct.name} | ${data.txnDate} | $${totalAmt.toFixed(2)} | ${data.lines.length} line(s)`; let qboResponse; try { const response = await makeQboApiCall({ url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); qboResponse = getJson(response); } catch (err) { await writeAuditLog({ action: 'expense.create', entityType: 'Purchase', status: 'error', requestExcerpt: requestSummary, responseExcerpt: err.message }); throw err; } if (qboResponse.Fault) { const msg = qboResponse.Fault.Error.map(e => `${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}` ).join('; '); await writeAuditLog({ action: 'expense.create', entityType: 'Purchase', status: 'error', requestExcerpt: requestSummary, responseExcerpt: msg }); const err = new Error('QBO Purchase create failed: ' + msg); err.qboFault = qboResponse.Fault; throw err; } const purchase = qboResponse.Purchase; if (!purchase || !purchase.Id) throw new Error('QBO returned no Purchase id'); await writeAuditLog({ action: 'expense.create', entityType: 'Purchase', entityQboId: purchase.Id, status: 'success', requestExcerpt: requestSummary, responseExcerpt: `Purchase ${purchase.Id} created, total $${Number(purchase.TotalAmt).toFixed(2)}` }); console.log(`✅ QBO Expense created: Purchase ${purchase.Id} — ${requestSummary}`); return { id: purchase.Id, txnDate: purchase.TxnDate, totalAmt: Number(purchase.TotalAmt), lineCount: data.lines.length, vendorName, accountName: paymentAcct.name }; } /** * Erstellt einen QBO Deposit oder Purchase Credit für einen Vendor-Refund. * * @param {Object} data * @param {string} data.vendorId - Pflicht (von wem kam der Refund) * @param {string} data.depositAccountId - Pflicht (Bank/CC-Konto, wo das Geld ankam) * @param {string} data.txnDate - Pflicht, YYYY-MM-DD * @param {string} data.categoryAccountId - Pflicht (ursprüngliche Expense-Kategorie) * @param {number} data.amount - Pflicht, positiver Betrag * @param {string} [data.refNo] * @param {string} [data.memo] * @returns {{ id, txnDate, totalAmt, vendorName, depositAccountName, categoryName }} */ async function createRefund(data) { if (!data.vendorId) throw badRequest('vendorId is required'); if (!data.depositAccountId) throw badRequest('depositAccountId is required'); if (!data.categoryAccountId) throw badRequest('categoryAccountId is required'); if (!data.txnDate) throw badRequest('txnDate is required'); const amount = Number(data.amount); if (!isFinite(amount) || amount <= 0) { throw badRequest('amount must be a positive number (the refund value)'); } // ── Deposit-Konto aus Cache ── const depRow = await pool.query( `SELECT qbo_id, name, account_type FROM qbo_account_cache WHERE qbo_id = $1`, [data.depositAccountId] ); if (depRow.rows.length === 0) { throw badRequest(`Deposit account ${data.depositAccountId} not in cache. Run sync first.`); } const depositAcct = depRow.rows[0]; // ── Kategorie-Konto aus Cache ── const catRow = await pool.query( `SELECT qbo_id, name FROM qbo_account_cache WHERE qbo_id = $1`, [data.categoryAccountId] ); if (catRow.rows.length === 0) { throw badRequest(`Category account ${data.categoryAccountId} not in cache. Run sync first.`); } const categoryAcct = catRow.rows[0]; // ── Vendor-Name aus Cache ── const vendorRow = await pool.query( `SELECT display_name FROM qbo_vendor_cache WHERE qbo_id = $1`, [data.vendorId] ); const vendorName = vendorRow.rows[0]?.display_name || data.vendorId; const { companyId, baseUrl } = getClientInfo(); const isCreditCard = depositAcct.account_type === 'Credit Card'; let url; let payload; let entityTypeLabel; // ── Verzweigung: Credit Card Credit (Purchase) vs. Bank (Deposit) ── if (isCreditCard) { entityTypeLabel = 'Purchase'; url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase`); payload = { AccountRef: { value: depositAcct.qbo_id, name: depositAcct.name }, EntityRef: { value: data.vendorId, type: 'Vendor', name: vendorName }, TxnDate: data.txnDate, PaymentType: 'CreditCard', Credit: true, // Zwingend erforderlich für einen CC Refund Line: [{ DetailType: 'AccountBasedExpenseLineDetail', Amount: amount, Description: data.memo || `Refund from ${vendorName}`, AccountBasedExpenseLineDetail: { AccountRef: { value: categoryAcct.qbo_id, name: categoryAcct.name } } }] }; if (data.refNo) payload.DocNumber = String(data.refNo).slice(0, 21); if (data.memo) payload.PrivateNote = String(data.memo); } else { entityTypeLabel = 'Deposit'; url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/deposit`); payload = { DepositToAccountRef: { value: depositAcct.qbo_id, name: depositAcct.name }, TxnDate: data.txnDate, Line: [{ DetailType: 'DepositLineDetail', Amount: amount, Description: data.memo || `Refund from ${vendorName}`, DepositLineDetail: { AccountRef: { value: categoryAcct.qbo_id, name: categoryAcct.name }, // Korrigierte QBO Entity-Struktur Entity: { Type: 'Vendor', EntityRef: { value: data.vendorId, name: vendorName } } } }] }; if (data.refNo) payload.Line[0].DepositLineDetail.CheckNum = String(data.refNo).slice(0, 21); if (data.memo) payload.PrivateNote = String(data.memo); } const requestSummary = `REFUND (${entityTypeLabel}) | ${vendorName} → ${depositAcct.name} | ${categoryAcct.name} | ${data.txnDate} | $${amount.toFixed(2)}`; let qboResponse; try { const response = await makeQboApiCall({ url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); qboResponse = getJson(response); } catch (err) { await writeAuditLog({ action: 'refund.create', entityType: entityTypeLabel, status: 'error', requestExcerpt: requestSummary, responseExcerpt: err.message }); throw err; } if (qboResponse.Fault) { const msg = qboResponse.Fault.Error.map(e => `${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}` ).join('; '); await writeAuditLog({ action: 'refund.create', entityType: entityTypeLabel, status: 'error', requestExcerpt: requestSummary, responseExcerpt: msg }); const err = new Error(`QBO ${entityTypeLabel} (refund) create failed: ${msg}`); err.qboFault = qboResponse.Fault; throw err; } // Dynamisches Extrahieren basierend auf dem Entity-Typ const resultEntity = isCreditCard ? qboResponse.Purchase : qboResponse.Deposit; if (!resultEntity || !resultEntity.Id) throw new Error(`QBO returned no ${entityTypeLabel} id`); await writeAuditLog({ action: 'refund.create', entityType: entityTypeLabel, entityQboId: resultEntity.Id, status: 'success', requestExcerpt: requestSummary, responseExcerpt: `${entityTypeLabel} ${resultEntity.Id} created, total $${Number(resultEntity.TotalAmt).toFixed(2)}` }); console.log(`✅ QBO Refund recorded: ${entityTypeLabel} ${resultEntity.Id} — ${requestSummary}`); return { id: resultEntity.Id, txnDate: resultEntity.TxnDate, totalAmt: Number(resultEntity.TotalAmt), vendorName, depositAccountName: depositAcct.name, categoryName: categoryAcct.name }; } /** * Aktualisiert eine bestehende QBO Purchase (Expense). * QBO erfordert ein VOLLSTÄNDIGES Update — die komplette Line-Liste muss * mitgeschickt werden, sonst gehen Zeilen verloren. * * @param {string} purchaseId * @param {Object} data — gleiche Struktur wie createExpense * @returns {{ id, txnDate, totalAmt, lineCount, vendorName, accountName }} */ async function updateExpense(purchaseId, data) { if (!purchaseId) throw badRequest('purchaseId is required'); if (!data.vendorId) throw badRequest('vendorId is required'); if (!data.paymentAccountId) throw badRequest('paymentAccountId is required'); if (!data.txnDate) throw badRequest('txnDate is required'); if (!Array.isArray(data.lines) || data.lines.length === 0) { throw badRequest('At least one line is required'); } for (const [i, line] of data.lines.entries()) { if (!line.accountId) throw badRequest(`Line ${i + 1}: accountId is required`); const amt = Number(line.amount); if (!isFinite(amt) || amt <= 0) { throw badRequest(`Line ${i + 1}: amount must be a positive number`); } } const { companyId, baseUrl } = getClientInfo(); // ── Aktuelle Purchase laden (für SyncToken) ── const getUrl = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase/${purchaseId}`); const getResponse = await makeQboApiCall({ url: getUrl, method: 'GET' }); const getData = getJson(getResponse); throwIfFault(getData, 'Purchase fetch'); const current = getData.Purchase; if (!current || !current.Id) { throw badRequest(`Purchase ${purchaseId} not found in QBO`); } // ── Payment-Account-Type bestimmen ── const acctRow = await pool.query( `SELECT qbo_id, name, account_type FROM qbo_account_cache WHERE qbo_id = $1`, [data.paymentAccountId] ); if (acctRow.rows.length === 0) { throw badRequest(`Payment account ${data.paymentAccountId} not in cache. Run sync first.`); } const paymentAcct = acctRow.rows[0]; const paymentType = paymentAcct.account_type === 'Credit Card' ? 'CreditCard' : 'Check'; const vendorRow = await pool.query( `SELECT display_name FROM qbo_vendor_cache WHERE qbo_id = $1`, [data.vendorId] ); const vendorName = vendorRow.rows[0]?.display_name || data.vendorId; const totalAmt = data.lines.reduce((sum, l) => sum + Number(l.amount), 0); // ── Update-Payload — vollständig, mit Id + SyncToken ── const payload = { Id: current.Id, SyncToken: current.SyncToken, AccountRef: { value: paymentAcct.qbo_id, name: paymentAcct.name }, EntityRef: { value: data.vendorId, type: 'Vendor', name: vendorName }, TxnDate: data.txnDate, PaymentType: paymentType, Line: data.lines.map(line => ({ DetailType: 'AccountBasedExpenseLineDetail', Amount: Number(line.amount), Description: line.description || undefined, AccountBasedExpenseLineDetail: { AccountRef: { value: String(line.accountId) } } })) }; payload.DocNumber = data.refNo ? String(data.refNo).slice(0, 21) : undefined; payload.PrivateNote = data.memo ? String(data.memo) : undefined; if (data.paymentMethodId) { payload.PaymentMethodRef = { value: String(data.paymentMethodId) }; } const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase`); const requestSummary = `UPDATE ${purchaseId} | ${vendorName} | ${data.txnDate} | $${totalAmt.toFixed(2)} | ${data.lines.length} line(s)`; let qboResponse; try { const response = await makeQboApiCall({ url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); qboResponse = getJson(response); } catch (err) { await writeAuditLog({ action: 'expense.update', entityType: 'Purchase', entityQboId: purchaseId, status: 'error', requestExcerpt: requestSummary, responseExcerpt: err.message }); throw err; } if (qboResponse.Fault) { const msg = qboResponse.Fault.Error.map(e => `${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}` ).join('; '); await writeAuditLog({ action: 'expense.update', entityType: 'Purchase', entityQboId: purchaseId, status: 'error', requestExcerpt: requestSummary, responseExcerpt: msg }); const err = new Error('QBO Purchase update failed: ' + msg); err.qboFault = qboResponse.Fault; throw err; } const purchase = qboResponse.Purchase; if (!purchase || !purchase.Id) throw new Error('QBO returned no Purchase id'); await writeAuditLog({ action: 'expense.update', entityType: 'Purchase', entityQboId: purchase.Id, status: 'success', requestExcerpt: requestSummary, responseExcerpt: `Purchase ${purchase.Id} updated, total $${Number(purchase.TotalAmt).toFixed(2)}` }); console.log(`✅ QBO Expense updated: Purchase ${purchase.Id} — ${requestSummary}`); return { id: purchase.Id, txnDate: purchase.TxnDate, totalAmt: Number(purchase.TotalAmt), lineCount: data.lines.length, vendorName, accountName: paymentAcct.name }; } function badRequest(msg) { const err = new Error(msg); err.statusCode = 400; return err; } // ──────────────────────────────────────────────────────────────────── // Phase 2 Lieferung 2 — Expense List (read) // ──────────────────────────────────────────────────────────────────── /** * Liefert eine Liste von QBO Purchases (=Expenses) für ein Datums-Intervall. * * @param {Object} opts * @param {string} opts.startDate - Pflicht * @param {string} opts.endDate - Pflicht * @param {boolean} [opts.onlyMine] - Wenn true, nur Purchases die in unserem * accounting_sync_log mit action='expense.create' * erfolgreich exportiert wurden * @returns {Array<{ id, txnDate, totalAmt, vendorName, accountName, refNo, memo, lines }>} */ async function listExpenses({ startDate, endDate, onlyMine = false } = {}) { if (!startDate || !endDate) throw badRequest('startDate and endDate are required'); const { companyId, baseUrl } = getClientInfo(); // Wir queryen Purchases in dem Date-Range. PaymentType filtern wir nicht — // QBO speichert auch Expense-Buchungen mit AccountBasedExpenseLineDetail. const safeStart = startDate.replace(/'/g, ''); const safeEnd = endDate.replace(/'/g, ''); const sql = `SELECT * FROM Purchase WHERE TxnDate >= '${safeStart}' AND TxnDate <= '${safeEnd}' ORDERBY TxnDate DESC MAXRESULTS 1000`; const url = withMinorVersion( `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(sql)}` ); const response = await makeQboApiCall({ url, method: 'GET' }); const data = getJson(response); throwIfFault(data, 'Purchase query'); let purchases = (data.QueryResponse && data.QueryResponse.Purchase) || []; // Filter: nur App-eigene Expenses if (onlyMine) { const r = await pool.query( `SELECT DISTINCT entity_qbo_id FROM accounting_sync_log WHERE action = 'expense.create' AND status = 'success' AND entity_qbo_id IS NOT NULL` ); const myIds = new Set(r.rows.map(row => row.entity_qbo_id)); purchases = purchases.filter(p => myIds.has(p.Id)); } return purchases.map(p => normalizePurchase(p)); } function normalizePurchase(p) { const lines = (p.Line || []) .filter(l => l.DetailType !== 'SubTotalLineDetail') .map(l => { const detail = l.AccountBasedExpenseLineDetail || {}; const acctRef = detail.AccountRef || {}; return { accountId: acctRef.value || null, accountName: acctRef.name || null, amount: l.Amount != null ? Number(l.Amount) : null, description: l.Description || null }; }); return { id: p.Id, txnDate: p.TxnDate, totalAmt: p.TotalAmt != null ? Number(p.TotalAmt) : 0, vendorName: p.EntityRef ? p.EntityRef.name : null, vendorId: p.EntityRef ? p.EntityRef.value : null, accountName: p.AccountRef ? p.AccountRef.name : null, accountId: p.AccountRef ? p.AccountRef.value : null, paymentType: p.PaymentType, refNo: p.DocNumber || null, memo: p.PrivateNote || null, lines }; } /** * Hängt ein File an eine bestehende QBO-Transaktion (Purchase, Invoice, ...). * * QBO erwartet multipart/form-data mit zwei Parts: * - file_metadata_0 (JSON: Attachable-Object) * - file_content_0 (binary) * * @param {Object} opts * @param {string} opts.entityType - 'Purchase' | 'Invoice' | etc. * @param {string} opts.entityId - QBO id der Transaktion * @param {Buffer} opts.fileBuffer - Binärdaten * @param {string} opts.fileName - z.B. "receipt.png" * @param {string} opts.contentType - z.B. "image/png", "application/pdf" * @param {string} [opts.note] - Anzeige-Text in QBO (default = fileName) * @returns {{ id, fileName }} */ async function attachFileToEntity({ entityType, entityId, fileBuffer, fileName, contentType, note }) { if (!entityType || !entityId) throw badRequest('entityType and entityId are required'); if (!fileBuffer || !fileBuffer.length) throw badRequest('fileBuffer is empty'); if (!fileName) throw badRequest('fileName is required'); if (!contentType) throw badRequest('contentType is required'); const { companyId, baseUrl } = getClientInfo(); // Boundary für multipart const boundary = '----QBOAttachBoundary' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10); const CRLF = '\r\n'; const metadata = { AttachableRef: [{ EntityRef: { type: entityType, value: String(entityId) } }], FileName: fileName, ContentType: contentType, Note: note || fileName }; // Multipart body als Buffer (Header + Metadata-JSON + File-Bytes + Footer) const head = Buffer.from( `--${boundary}${CRLF}` + `Content-Disposition: form-data; name="file_metadata_0"${CRLF}` + `Content-Type: application/json${CRLF}${CRLF}` + JSON.stringify(metadata) + CRLF + `--${boundary}${CRLF}` + `Content-Disposition: form-data; name="file_content_0"; filename="${fileName}"${CRLF}` + `Content-Type: ${contentType}${CRLF}${CRLF}`, 'utf8' ); const tail = Buffer.from(`${CRLF}--${boundary}--${CRLF}`, 'utf8'); const body = Buffer.concat([head, fileBuffer, tail]); const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/upload`); let qboResponse; try { const response = await makeQboApiCall({ url, method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Accept': 'application/json' }, body }); qboResponse = getJson(response); } catch (err) { await writeAuditLog({ action: 'attachment.upload', entityType, entityQboId: String(entityId), status: 'error', requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`, responseExcerpt: err.message }); throw err; } // QBO antwortet mit AttachableResponse[0].Attachable bei Erfolg, // oder AttachableResponse[0].Fault bei Fehler. const responses = qboResponse.AttachableResponse || []; const first = responses[0] || {}; if (first.Fault) { const msg = (first.Fault.Error || []).map(e => `${e.code}: ${e.Message}`).join('; '); await writeAuditLog({ action: 'attachment.upload', entityType, entityQboId: String(entityId), status: 'error', requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`, responseExcerpt: msg }); const err = new Error('QBO attach failed: ' + msg); err.qboFault = first.Fault; throw err; } const att = first.Attachable; if (!att || !att.Id) throw new Error('QBO returned no attachable id'); await writeAuditLog({ action: 'attachment.upload', entityType, entityQboId: String(entityId), status: 'success', requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`, responseExcerpt: `Attachable ${att.Id}` }); console.log(`📎 QBO Attachable created: ${att.Id} (${fileName}) → ${entityType} ${entityId}`); return { id: att.Id, fileName }; } // ════════════════════════════════════════════════════════════════════ // Exports // ════════════════════════════════════════════════════════════════════ module.exports = { // Phase 1 listAccounts, getRegister, getProfitAndLoss, getBalanceSheet, getTaxSummary, getTaxPeriods, upsertTaxPeriod, markTaxPaidExternal, normalizeTransactionListReport, // Phase 2 Lieferung 1 — Sync syncAccountsCache, syncVendorsCache, getCacheStatus, cacheIsStaleToday, // Phase 2 Lieferung 1 — Reads getVendorsFromCache, getExpenseAccountsFromCache, getPaymentAccountsFromCache, getPaymentMethods, // Phase 2 Lieferung 2 — Mutations + List createVendor, createExpense, updateExpense, createRefund, listExpenses, // Phase 2 Lieferung 3 attachFileToEntity, // Audit writeAuditLog };