Phase 1: accounting

This commit is contained in:
2026-05-06 14:10:46 -05:00
parent 373c1cb945
commit d6d70e641c
8 changed files with 1241 additions and 9 deletions

View File

@@ -0,0 +1,274 @@
// 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 }) {
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);
// account filter: TransactionList akzeptiert eine kommaseparierte Liste
params.set('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');
return normalizeTransactionListReport(data);
}
/**
* Normalisiert die QBO TransactionList Report Antwort in eine flache
* Liste mit { date, type, docNum, payee, account, memo, amount, qboId }.
*
* Der Report liefert Columns dynamisch — wir bauen eine Index-Map und
* lesen die Zellen darüber aus.
*/
function normalizeTransactionListReport(report) {
const columns = (report.Columns && report.Columns.Column) || [];
const colIndex = {};
columns.forEach((c, i) => {
// ColType ist z.B. "tx_date", "txn_type", "doc_num", "name", "account_name",
// "memo", "subt_nat_amount", "split_acc"
if (c.ColType) colIndex[c.ColType] = i;
});
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;
}
// Data row
if (!r.ColData) continue;
const cell = (key) => {
const idx = colIndex[key];
if (idx == null) return null;
const c = r.ColData[idx];
return c ? c : null;
};
const dateCell = cell('tx_date');
const typeCell = cell('txn_type');
const docCell = cell('doc_num');
const payeeCell = cell('name');
const acctCell = cell('account_name');
const memoCell = cell('memo');
const amtCell = cell('subt_nat_amount');
const splitCell = cell('split_acc');
// QBO setzt die qbo Txn Id meistens als value im Date-Cell oder DocNum-Cell.
// Wir greifen sicherheitshalber an mehreren Stellen.
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 !== '' ? 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 })),
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;
}
module.exports = {
listAccounts,
getRegister,
getProfitAndLoss,
getBalanceSheet,
// exposed for testing/debugging
normalizeTransactionListReport
};