update
This commit is contained in:
@@ -1,15 +1,23 @@
|
||||
// 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.
|
||||
* Phase 1: read-only Account-/Register-/Report-Wrapper
|
||||
* Phase 2 (Lieferung 1): Cache-Synchronisation für Accounts und Vendors,
|
||||
* Sync-Status, Audit-Log, Payment-Methods
|
||||
*
|
||||
* QBO bleibt System of Record. Caches werden manuell oder
|
||||
* beim ersten Modal-Open pro Tag aktualisiert.
|
||||
*/
|
||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
// QBO minor version — fixiert für stabilen Field-Support
|
||||
const QBO_MINOR_VERSION = '75';
|
||||
const QBO_PAGE_SIZE = 1000;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Common helpers
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getClientInfo() {
|
||||
const oauthClient = getOAuthClient();
|
||||
@@ -18,23 +26,14 @@ function getClientInfo() {
|
||||
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 =>
|
||||
@@ -47,51 +46,126 @@ function throwIfFault(data, context) {
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Accounts
|
||||
// Audit Log helper
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function writeAuditLog({ action, entityType, entityQboId, status, requestExcerpt, responseExcerpt, userId }) {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO accounting_sync_log
|
||||
(action, entity_type, entity_qbo_id, status, request_excerpt, response_excerpt, user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
action,
|
||||
entityType || null,
|
||||
entityQboId || null,
|
||||
status,
|
||||
requestExcerpt ? String(requestExcerpt).slice(0, 4000) : null,
|
||||
responseExcerpt ? String(responseExcerpt).slice(0, 4000) : null,
|
||||
userId || null
|
||||
]
|
||||
);
|
||||
} catch (err) {
|
||||
// Audit-Log darf den eigentlichen Vorgang nicht killen
|
||||
console.error('⚠️ Failed to write audit log:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function setCacheStatus(cacheName, { count = null, error = null } = {}) {
|
||||
await pool.query(
|
||||
`UPDATE qbo_cache_status
|
||||
SET last_synced_at = $1,
|
||||
last_sync_count = $2,
|
||||
last_sync_error = $3
|
||||
WHERE cache_name = $4`,
|
||||
[error ? null : new Date(), count, error, cacheName]
|
||||
);
|
||||
}
|
||||
|
||||
async function getCacheStatus(cacheName) {
|
||||
const r = await pool.query(
|
||||
`SELECT cache_name, last_synced_at, last_sync_count, last_sync_error
|
||||
FROM qbo_cache_status WHERE cache_name = $1`,
|
||||
[cacheName]
|
||||
);
|
||||
return r.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Gibt true zurück, wenn der Cache heute (nach 00:00 lokal) noch nicht
|
||||
* synchronisiert wurde — wird vom Frontend für "first-open-of-day"-Auto-Sync genutzt.
|
||||
*/
|
||||
async function cacheIsStaleToday(cacheName) {
|
||||
const status = await getCacheStatus(cacheName);
|
||||
if (!status || !status.last_synced_at) return true;
|
||||
const last = new Date(status.last_synced_at);
|
||||
const now = new Date();
|
||||
return last.getFullYear() !== now.getFullYear()
|
||||
|| last.getMonth() !== now.getMonth()
|
||||
|| last.getDate() !== now.getDate();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// QBO Query helpers (paginated)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function queryAll(entity, where = '', orderBy = '') {
|
||||
const { companyId, baseUrl } = getClientInfo();
|
||||
const all = [];
|
||||
let startPosition = 1;
|
||||
|
||||
while (true) {
|
||||
const wherePart = where ? ` WHERE ${where}` : '';
|
||||
const orderPart = orderBy ? ` ORDERBY ${orderBy}` : '';
|
||||
const sql = `SELECT * FROM ${entity}${wherePart}${orderPart} STARTPOSITION ${startPosition} MAXRESULTS ${QBO_PAGE_SIZE}`;
|
||||
const url = withMinorVersion(
|
||||
`${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(sql)}`
|
||||
);
|
||||
|
||||
const response = await makeQboApiCall({ url, method: 'GET' });
|
||||
const data = getJson(response);
|
||||
throwIfFault(data, `${entity} query`);
|
||||
|
||||
const list = (data.QueryResponse && data.QueryResponse[entity]) || [];
|
||||
all.push(...list);
|
||||
if (list.length < QBO_PAGE_SIZE) break;
|
||||
startPosition += list.length;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Phase 1 (read-only) — bleibt unverändert
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
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
|
||||
classification: a.Classification,
|
||||
currentBalance: a.CurrentBalance != null ? Number(a.CurrentBalance) : null,
|
||||
currency: a.CurrencyRef ? a.CurrencyRef.value : null,
|
||||
active: a.Active === true,
|
||||
@@ -99,23 +173,8 @@ async function listAccounts({ type = null, activeOnly = true } = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 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();
|
||||
@@ -125,39 +184,27 @@ async function getRegister({ accountId, startDate, endDate, includeSplits = true
|
||||
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);
|
||||
|
||||
// Nur Split-Details nachladen (für -Split- Zeilen) — Cleared-Status nicht
|
||||
// Splits nachladen
|
||||
if (includeSplits) {
|
||||
const splitRows = result.rows.filter(r =>
|
||||
r.splitAccount === '-Split-' && r.qboId
|
||||
);
|
||||
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];
|
||||
}
|
||||
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;
|
||||
@@ -180,24 +227,18 @@ function normalizeTransactionListReport(report) {
|
||||
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;
|
||||
return colData[idx] || 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.type === 'Section' || r.Rows) { walk(r.Rows && r.Rows.Row); continue; }
|
||||
if (!r.ColData) continue;
|
||||
|
||||
const dateCell = cellAt(r.ColData, idxDate);
|
||||
@@ -208,7 +249,6 @@ 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) ||
|
||||
@@ -216,12 +256,6 @@ function normalizeTransactionListReport(report) {
|
||||
(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,
|
||||
@@ -232,12 +266,10 @@ function normalizeTransactionListReport(report) {
|
||||
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 {
|
||||
@@ -253,84 +285,20 @@ function normalizeTransactionListReport(report) {
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 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 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;
|
||||
|
||||
@@ -349,40 +317,313 @@ async function fetchSplitDetails(splitRows) {
|
||||
};
|
||||
})
|
||||
.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);
|
||||
console.warn(`Split 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
|
||||
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;
|
||||
if (t.includes('tax payment')) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Phase 2 Lieferung 1 — Caches und Sync
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Synchronisiert den Account-Cache mit QBO.
|
||||
* Liest alle aktiven Accounts und schreibt sie nach qbo_account_cache.
|
||||
* Ergebnis: { synced: N, total: M, durationMs: T }
|
||||
*/
|
||||
async function syncAccountsCache() {
|
||||
const startTs = Date.now();
|
||||
let count = 0;
|
||||
try {
|
||||
const accounts = await queryAll('Account', 'Active = true', 'Name ASC');
|
||||
|
||||
// UPSERT: alles in einer Transaktion
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Strategie: alle als inaktiv flaggen, dann die aktuellen upserten,
|
||||
// damit gelöschte/inaktivierte Accounts auch im Cache nicht mehr aktiv sind.
|
||||
await client.query('UPDATE qbo_account_cache SET active = false');
|
||||
|
||||
for (const a of accounts) {
|
||||
await client.query(
|
||||
`INSERT INTO qbo_account_cache
|
||||
(qbo_id, name, fully_qualified_name, account_type, account_sub_type,
|
||||
classification, current_balance, currency, active, sync_token, cached_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (qbo_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
fully_qualified_name = EXCLUDED.fully_qualified_name,
|
||||
account_type = EXCLUDED.account_type,
|
||||
account_sub_type = EXCLUDED.account_sub_type,
|
||||
classification = EXCLUDED.classification,
|
||||
current_balance = EXCLUDED.current_balance,
|
||||
currency = EXCLUDED.currency,
|
||||
active = EXCLUDED.active,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
cached_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
a.Id, a.Name, a.FullyQualifiedName || null,
|
||||
a.AccountType || null, a.AccountSubType || null,
|
||||
a.Classification || null,
|
||||
a.CurrentBalance != null ? Number(a.CurrentBalance) : null,
|
||||
a.CurrencyRef ? a.CurrencyRef.value : null,
|
||||
a.Active === true,
|
||||
a.SyncToken || null
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
await setCacheStatus('accounts', { count });
|
||||
return { synced: count, durationMs: Date.now() - startTs };
|
||||
|
||||
} catch (err) {
|
||||
await setCacheStatus('accounts', { error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert den Vendor-Cache mit QBO.
|
||||
*/
|
||||
async function syncVendorsCache() {
|
||||
const startTs = Date.now();
|
||||
let count = 0;
|
||||
try {
|
||||
const vendors = await queryAll('Vendor', '', 'DisplayName ASC');
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('UPDATE qbo_vendor_cache SET active = false');
|
||||
|
||||
for (const v of vendors) {
|
||||
const email = v.PrimaryEmailAddr ? v.PrimaryEmailAddr.Address : null;
|
||||
const phone = v.PrimaryPhone ? v.PrimaryPhone.FreeFormNumber : null;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO qbo_vendor_cache
|
||||
(qbo_id, display_name, company_name, primary_email, primary_phone,
|
||||
active, sync_token, cached_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (qbo_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
company_name = EXCLUDED.company_name,
|
||||
primary_email = EXCLUDED.primary_email,
|
||||
primary_phone = EXCLUDED.primary_phone,
|
||||
active = EXCLUDED.active,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
cached_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
v.Id, v.DisplayName, v.CompanyName || null,
|
||||
email, phone,
|
||||
v.Active === true,
|
||||
v.SyncToken || null
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
await setCacheStatus('vendors', { count });
|
||||
return { synced: count, durationMs: Date.now() - startTs };
|
||||
|
||||
} catch (err) {
|
||||
await setCacheStatus('vendors', { error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Cache-Reads (Frontend nutzt diese statt Live-QBO-Calls)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Holt Vendors aus dem Cache, optional nach Suchstring gefiltert.
|
||||
*/
|
||||
async function getVendorsFromCache({ search = '', activeOnly = true, limit = 200 } = {}) {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (activeOnly) where.push('active = true');
|
||||
if (search) {
|
||||
params.push(`%${search.toLowerCase()}%`);
|
||||
where.push(`LOWER(display_name) LIKE $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
const sql = `
|
||||
SELECT qbo_id, display_name, company_name, primary_email, primary_phone, active
|
||||
FROM qbo_vendor_cache
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY display_name ASC
|
||||
LIMIT $${params.length}
|
||||
`;
|
||||
const r = await pool.query(sql, params);
|
||||
return r.rows.map(row => ({
|
||||
id: row.qbo_id,
|
||||
displayName: row.display_name,
|
||||
companyName: row.company_name,
|
||||
email: row.primary_email,
|
||||
phone: row.primary_phone,
|
||||
active: row.active
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Expense-Accounts aus dem Cache (alles wo Classification=Expense oder Type=Expense).
|
||||
* Wird im Expense-Modal als Category-Dropdown genutzt.
|
||||
*/
|
||||
async function getExpenseAccountsFromCache({ activeOnly = true } = {}) {
|
||||
const where = ['(classification = $1 OR account_type = $1)'];
|
||||
const params = ['Expense'];
|
||||
if (activeOnly) where.push('active = true');
|
||||
|
||||
const sql = `
|
||||
SELECT qbo_id, name, fully_qualified_name, account_type, account_sub_type
|
||||
FROM qbo_account_cache
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY name ASC
|
||||
`;
|
||||
const r = await pool.query(sql, params);
|
||||
return r.rows.map(row => ({
|
||||
id: row.qbo_id,
|
||||
name: row.name,
|
||||
fullyQualifiedName: row.fully_qualified_name,
|
||||
accountType: row.account_type,
|
||||
accountSubType: row.account_sub_type
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Bank- und Credit-Card-Accounts aus dem Cache (für Payment-Account-Dropdown).
|
||||
*/
|
||||
async function getPaymentAccountsFromCache({ activeOnly = true } = {}) {
|
||||
const where = ["account_type IN ('Bank', 'Credit Card')"];
|
||||
if (activeOnly) where.push('active = true');
|
||||
const sql = `
|
||||
SELECT qbo_id, name, account_type, current_balance
|
||||
FROM qbo_account_cache
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY account_type ASC, name ASC
|
||||
`;
|
||||
const r = await pool.query(sql);
|
||||
return r.rows.map(row => ({
|
||||
id: row.qbo_id,
|
||||
name: row.name,
|
||||
accountType: row.account_type,
|
||||
currentBalance: row.current_balance != null ? Number(row.current_balance) : null
|
||||
}));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Payment Methods — direkt von QBO geholt, nicht gecached
|
||||
// (sehr kleine, sehr selten ändernde Liste; Cache wäre overkill)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getPaymentMethods({ activeOnly = true } = {}) {
|
||||
const { companyId, baseUrl } = getClientInfo();
|
||||
const where = activeOnly ? ' WHERE Active = true' : '';
|
||||
const query = `SELECT * FROM PaymentMethod${where} ORDERBY Name ASC MAXRESULTS 200`;
|
||||
|
||||
const url = withMinorVersion(
|
||||
`${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
const response = await makeQboApiCall({ url, method: 'GET' });
|
||||
const data = getJson(response);
|
||||
throwIfFault(data, 'PaymentMethod query');
|
||||
|
||||
const list = (data.QueryResponse && data.QueryResponse.PaymentMethod) || [];
|
||||
return list.map(p => ({
|
||||
id: p.Id,
|
||||
name: p.Name,
|
||||
type: p.Type,
|
||||
active: p.Active === true
|
||||
}));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Exports
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
module.exports = {
|
||||
// Phase 1
|
||||
listAccounts,
|
||||
getRegister,
|
||||
getProfitAndLoss,
|
||||
getBalanceSheet,
|
||||
fetchSplitDetails,
|
||||
// exposed for testing/debugging
|
||||
normalizeTransactionListReport
|
||||
};
|
||||
normalizeTransactionListReport,
|
||||
|
||||
// Phase 2 Lieferung 1 — Sync
|
||||
syncAccountsCache,
|
||||
syncVendorsCache,
|
||||
getCacheStatus,
|
||||
cacheIsStaleToday,
|
||||
|
||||
// Phase 2 Lieferung 1 — Reads
|
||||
getVendorsFromCache,
|
||||
getExpenseAccountsFromCache,
|
||||
getPaymentAccountsFromCache,
|
||||
getPaymentMethods,
|
||||
|
||||
// Audit
|
||||
writeAuditLog
|
||||
};
|
||||
Reference in New Issue
Block a user