This commit is contained in:
2026-05-06 15:41:02 -05:00
parent 1ea750d001
commit 1680ca1b56
2 changed files with 167 additions and 23 deletions

View File

@@ -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<qboId, Array<{account, amount, description}>>
*/
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
};