From 1680ca1b56587c8a7a3e0913c96d3a4eb9b0612d Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 6 May 2026 15:41:02 -0500 Subject: [PATCH] fix --- public/js/views/accounting-view.js | 81 ++++++++++++++++----- src/services/accounting-service.js | 109 +++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 23 deletions(-) diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js index 3bb89d6..cdb3f88 100644 --- a/public/js/views/accounting-view.js +++ b/public/js/views/accounting-view.js @@ -280,9 +280,17 @@ function renderRegisterTable(result) { const el = document.getElementById('accounting-register-table'); if (!el) return; - const rows = result.rows || []; + let rows = result.rows || []; const meta = result.meta || {}; + // ── Punkt 1: neueste Einträge oben ── + rows = rows.slice().sort((a, b) => { + const da = a.date || ''; + const db = b.date || ''; + if (db !== da) return db.localeCompare(da); + return 0; + }); + if (!rows.length) { el.innerHTML = `
@@ -291,27 +299,20 @@ function renderRegisterTable(result) { return; } - const tbody = rows.map(r => ` - - ${escapeHtml(r.date || '')} - ${escapeHtml(r.type || '')} - ${escapeHtml(r.docNum || '')} - ${escapeHtml(r.payee || '')} - ${escapeHtml(r.splitAccount || '')} - ${escapeHtml((r.memo || '').slice(0, 60))} - - ${r.amount != null ? fmtMoney(r.amount) : ''} - - - `).join(''); + const tbody = rows.map(r => renderRegisterRow(r)).join(''); + // ── Punkt 5: dynamische Anzahl ── el.innerHTML = `
-
- ${escapeHtml(meta.reportName || 'Transaction List')} - ${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''} - ${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''} - · ${rows.length} rows +
+
+ ${escapeHtml(meta.reportName || 'Transaction List')} + ${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''} + ${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''} +
+
+ ${rows.length} ${rows.length === 1 ? 'transaction' : 'transactions'} +
@@ -323,6 +324,7 @@ function renderRegisterTable(result) { + @@ -332,6 +334,47 @@ function renderRegisterTable(result) { `; } +function renderRegisterRow(r) { + const isSplit = r.splitAccount === '-Split-'; + const splitCellContent = isSplit ? renderSplitCell(r) : escapeHtml(r.splitAccount || ''); + + // Punkt 3: Cleared-Status anzeigen mit Farbe + let clrBadge = ''; + if (r.clearedStatus === 'R') { + clrBadge = `R`; + } else if (r.clearedStatus === 'C') { + clrBadge = `C`; + } + + return ` + + + + + + + + + + `; +} + +// Punkt 4: Split-Aufschlüsselung +function renderSplitCell(r) { + if (!r.splits || !r.splits.length) { + return `-Split-`; + } + const lines = r.splits.map(s => ` +
+ ${escapeHtml(s.account || '?')} + ${s.amount != null ? fmtMoney(s.amount) : ''} +
+ `).join(''); + return `
${lines}
`; +} + // ──────────────────────────────────────────────────────────────────── // Reports — P&L + Balance Sheet // ──────────────────────────────────────────────────────────────────── diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js index ab38574..81b89e4 100644 --- a/src/services/accounting-service.js +++ b/src/services/accounting-service.js @@ -113,7 +113,7 @@ async function listAccounts({ type = null, activeOnly = true } = {}) { * @param {string} opts.startDate - YYYY-MM-DD * @param {string} opts.endDate - YYYY-MM-DD */ -async function getRegister({ accountId, startDate, endDate }) { +async function getRegister({ accountId, startDate, endDate, includeSplits = true }) { if (!accountId) throw new Error('accountId is required'); const { companyId, baseUrl } = getClientInfo(); @@ -121,7 +121,6 @@ async function getRegister({ accountId, startDate, endDate }) { const params = new URLSearchParams(); if (startDate) params.set('start_date', startDate); if (endDate) params.set('end_date', endDate); - // account filter: TransactionList akzeptiert eine kommaseparierte Liste params.set('source_account', String(accountId)); params.set('minorversion', QBO_MINOR_VERSION); @@ -131,7 +130,24 @@ async function getRegister({ accountId, startDate, endDate }) { const data = getJson(response); throwIfFault(data, 'TransactionList report'); - return normalizeTransactionListReport(data); + 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; } /** @@ -164,7 +180,7 @@ function normalizeTransactionListReport(report) { 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; @@ -192,6 +208,7 @@ function normalizeTransactionListReport(report) { const memoCell = cellAt(r.ColData, idxMemo); const splitCell = cellAt(r.ColData, idxSplit); const amtCell = cellAt(r.ColData, idxAmount); + const clrCell = cellAt(r.ColData, idxCleared); const qboId = (dateCell && dateCell.id) || @@ -199,6 +216,14 @@ function normalizeTransactionListReport(report) { (typeCell && typeCell.id) || null; + // ── NEU: Cleared-Status normalisieren ── + // QBO liefert "R" (reconciled), "C" (cleared) oder leer (uncleared) + 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, @@ -209,6 +234,7 @@ function normalizeTransactionListReport(report) { amount: amtCell && amtCell.value !== '' && amtCell.value != null ? Number(amtCell.value) : null, splitAccount: splitCell ? splitCell.value : null, + clearedStatus, qboId }); } @@ -278,12 +304,87 @@ async function getBalanceSheet({ asOfDate, accountingMethod = 'Accrual' } = {}) 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 };
Payee Split / Category Memo Amount
${escapeHtml(r.date || '')}${escapeHtml(r.type || '')}${escapeHtml(r.docNum || '')}${escapeHtml(r.payee || '')}${splitCellContent}${escapeHtml(r.memo || '')}${clrBadge} + ${r.amount != null ? fmtMoney(r.amount) : ''} +