Phase 1: accounting
This commit is contained in:
@@ -25,6 +25,7 @@ const invoiceRoutes = require('./routes/invoices');
|
||||
const paymentRoutes = require('./routes/payments');
|
||||
const qboRoutes = require('./routes/qbo');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
const accountingRoutes = require('./routes/accounting');
|
||||
|
||||
// Import PDF service for browser initialization
|
||||
const { setBrowser } = require('./services/pdf-service');
|
||||
@@ -120,6 +121,7 @@ app.use('/api/quotes', quoteRoutes);
|
||||
app.use('/api/invoices', invoiceRoutes);
|
||||
app.use('/api/payments', paymentRoutes);
|
||||
app.use('/api/qbo', qboRoutes);
|
||||
app.use('/api/accounting', accountingRoutes);
|
||||
app.use('/api', settingsRoutes);
|
||||
|
||||
// Start server
|
||||
|
||||
115
src/routes/accounting.js
Normal file
115
src/routes/accounting.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Accounting Routes
|
||||
* /api/accounting/*
|
||||
*
|
||||
* Phase 1 — read-only:
|
||||
* GET /accounts
|
||||
* POST /sync-accounts (Phase-1 Stub)
|
||||
* GET /register
|
||||
* GET /reports/profit-loss
|
||||
* GET /reports/balance-sheet
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const accountingService = require('../services/accounting-service');
|
||||
|
||||
/**
|
||||
* Helper: einheitliches Error-Mapping QBO → HTTP
|
||||
* Fault-Details landen im Server-Log, der User sieht die kurze Message.
|
||||
*/
|
||||
function handleQboError(err, res, context) {
|
||||
console.error(`❌ Accounting/${context} error:`, err.message);
|
||||
if (err.qboFault) {
|
||||
console.error(' QBO Fault detail:', JSON.stringify(err.qboFault));
|
||||
}
|
||||
if (err.stack) console.error(err.stack);
|
||||
|
||||
res.status(500).json({
|
||||
error: err.message || 'QBO request failed',
|
||||
context
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET /api/accounting/accounts ───────────────────────────────────
|
||||
// Optional ?type=Bank|CreditCard|Expense|Income|... ?activeOnly=false
|
||||
router.get('/accounts', async (req, res) => {
|
||||
try {
|
||||
const type = req.query.type || null;
|
||||
const activeOnly = req.query.activeOnly === 'false' ? false : true;
|
||||
|
||||
const accounts = await accountingService.listAccounts({ type, activeOnly });
|
||||
res.json(accounts);
|
||||
} catch (err) {
|
||||
handleQboError(err, res, 'accounts');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/accounting/sync-accounts ─────────────────────────────
|
||||
// Phase-1 Stub. Voll implementiert in Phase 2 mit qbo_account_cache.
|
||||
router.post('/sync-accounts', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
synced: 0,
|
||||
cached: false,
|
||||
message:
|
||||
'Account-Sync wird in Phase 2 aktiviert (qbo_account_cache). ' +
|
||||
'In Phase 1 werden Accounts direkt live aus QBO geladen.'
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/accounting/register ───────────────────────────────────
|
||||
// ?accountId=<id>&startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
|
||||
router.get('/register', async (req, res) => {
|
||||
const { accountId, startDate, endDate } = req.query;
|
||||
|
||||
if (!accountId) {
|
||||
return res.status(400).json({ error: 'accountId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await accountingService.getRegister({
|
||||
accountId,
|
||||
startDate,
|
||||
endDate
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
handleQboError(err, res, 'register');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/accounting/reports/profit-loss ────────────────────────
|
||||
// ?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&accountingMethod=Accrual|Cash
|
||||
router.get('/reports/profit-loss', async (req, res) => {
|
||||
const { startDate, endDate, accountingMethod } = req.query;
|
||||
|
||||
try {
|
||||
const data = await accountingService.getProfitAndLoss({
|
||||
startDate,
|
||||
endDate,
|
||||
accountingMethod: accountingMethod || 'Accrual'
|
||||
});
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
handleQboError(err, res, 'profit-loss');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/accounting/reports/balance-sheet ──────────────────────
|
||||
// ?asOfDate=YYYY-MM-DD&accountingMethod=Accrual|Cash
|
||||
router.get('/reports/balance-sheet', async (req, res) => {
|
||||
const { asOfDate, accountingMethod } = req.query;
|
||||
|
||||
try {
|
||||
const data = await accountingService.getBalanceSheet({
|
||||
asOfDate,
|
||||
accountingMethod: accountingMethod || 'Accrual'
|
||||
});
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
handleQboError(err, res, 'balance-sheet');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
274
src/services/accounting-service.js
Normal file
274
src/services/accounting-service.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user