389 lines
15 KiB
JavaScript
389 lines
15 KiB
JavaScript
// 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<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
|
|
};
|