1666 lines
66 KiB
JavaScript
1666 lines
66 KiB
JavaScript
// src/services/accounting-service.js
|
|
/**
|
|
* Accounting Service
|
|
*
|
|
* 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');
|
|
|
|
const QBO_MINOR_VERSION = '75';
|
|
const QBO_PAGE_SIZE = 1000;
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Common helpers
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
function getClientInfo() {
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = getQboBaseUrl();
|
|
return { oauthClient, companyId, baseUrl };
|
|
}
|
|
|
|
function getJson(response) {
|
|
return response.getJson ? response.getJson() : response.json;
|
|
}
|
|
|
|
function withMinorVersion(url) {
|
|
return url + (url.includes('?') ? '&' : '?') + 'minorversion=' + QBO_MINOR_VERSION;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const normalizedType = type === 'CreditCard' ? 'Credit Card' : type;
|
|
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) || [];
|
|
return accounts.map(a => ({
|
|
id: a.Id,
|
|
name: a.Name,
|
|
fullyQualifiedName: a.FullyQualifiedName,
|
|
accountType: a.AccountType,
|
|
accountSubType: a.AccountSubType,
|
|
classification: a.Classification,
|
|
currentBalance: a.CurrentBalance != null ? Number(a.CurrentBalance) : null,
|
|
currency: a.CurrencyRef ? a.CurrencyRef.value : null,
|
|
active: a.Active === true,
|
|
syncToken: a.SyncToken
|
|
}));
|
|
}
|
|
|
|
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);
|
|
|
|
// Splits 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;
|
|
}
|
|
|
|
function normalizeTransactionListReport(report) {
|
|
const columns = (report.Columns && report.Columns.Column) || [];
|
|
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 cellAt = (colData, idx) => {
|
|
if (idx == null) return 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.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 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 !== '' && amtCell.value != null
|
|
? 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 || c.type })),
|
|
rows
|
|
};
|
|
}
|
|
|
|
async function fetchSplitDetails(splitRows) {
|
|
if (!splitRows || splitRows.length === 0) return {};
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
const result = {};
|
|
|
|
for (const row of splitRows) {
|
|
if (!row.qboId) continue;
|
|
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);
|
|
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) {
|
|
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;
|
|
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;
|
|
}
|
|
|
|
async function getTaxSummary({ startDate, endDate, accountingMethod = 'Accrual' } = {}) {
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
const qboQuery = (sql) => {
|
|
const url = `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(sql)}&minorversion=${QBO_MINOR_VERSION}`;
|
|
return makeQboApiCall({ url, method: 'GET' });
|
|
};
|
|
|
|
const rateMap = {};
|
|
try {
|
|
const rateRes = await qboQuery('SELECT * FROM TaxRate MAXRESULTS 1000');
|
|
const rateData = getJson(rateRes);
|
|
throwIfFault(rateData, 'TaxRate query');
|
|
const rates = rateData?.QueryResponse?.TaxRate || [];
|
|
for (const r of rates) {
|
|
rateMap[r.Id] = {
|
|
name: r.Name || `Tax Rate ${r.Id}`,
|
|
rateValue: parseFloat(r.RateValue) || 0
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error('TaxRate query failed, proceeding without rate names:', e.message);
|
|
}
|
|
|
|
const whereClause = `TxnDate >= '${startDate}' AND TxnDate <= '${endDate}'`;
|
|
const agg = {};
|
|
let totalSales = 0;
|
|
let totalTaxable = 0;
|
|
|
|
for (const entity of ['Invoice', 'SalesReceipt']) {
|
|
try {
|
|
const res = await qboQuery(`SELECT * FROM ${entity} WHERE ${whereClause} MAXRESULTS 1000`);
|
|
const data = getJson(res);
|
|
throwIfFault(data, `${entity} query`);
|
|
const docs = data?.QueryResponse?.[entity] || [];
|
|
for (const doc of docs) {
|
|
const taxDetail = doc.TxnTaxDetail;
|
|
const totalAmt = parseFloat(doc.TotalAmt) || 0;
|
|
const totalTax = taxDetail ? (parseFloat(taxDetail.TotalTax) || 0) : 0;
|
|
totalSales += (totalAmt - totalTax);
|
|
|
|
if (!taxDetail || !taxDetail.TaxLine) continue;
|
|
|
|
const taxLines = Array.isArray(taxDetail.TaxLine) ? taxDetail.TaxLine : [taxDetail.TaxLine];
|
|
let docTaxable = 0;
|
|
for (const line of taxLines) {
|
|
const detail = line.TaxLineDetail;
|
|
if (!detail || !detail.TaxRateRef) continue;
|
|
const rateId = String(detail.TaxRateRef.value);
|
|
const taxable = parseFloat(detail.NetAmountTaxable) || 0;
|
|
const collected = parseFloat(line.Amount) || 0;
|
|
if (!agg[rateId]) agg[rateId] = { taxableSales: 0, taxCollected: 0 };
|
|
agg[rateId].taxableSales += taxable;
|
|
agg[rateId].taxCollected += collected;
|
|
docTaxable = Math.max(docTaxable, taxable);
|
|
}
|
|
totalTaxable += docTaxable;
|
|
}
|
|
} catch (e) {
|
|
console.error(`${entity} query failed:`, e.message);
|
|
}
|
|
}
|
|
|
|
const nontaxableSales = totalSales - totalTaxable;
|
|
|
|
const roundTotalSales = Math.round(totalSales);
|
|
const roundTotalTaxable = Math.round(totalTaxable);
|
|
const roundNontaxable = roundTotalSales - roundTotalTaxable; // derived, prevents drift
|
|
|
|
const rows = Object.entries(agg).map(([rateId, amounts]) => {
|
|
const info = rateMap[rateId] || { name: `Tax Rate ${rateId}`, rateValue: 0 };
|
|
const ratePct = info.rateValue.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
|
const roundTaxable = Math.round(amounts.taxableSales);
|
|
const roundNontaxableJurisdiction = roundTotalSales - roundTaxable;
|
|
return {
|
|
type: 'Section',
|
|
Header: {
|
|
ColData: [
|
|
{ value: info.name },
|
|
{ value: '' },
|
|
{ value: '' },
|
|
{ value: '' },
|
|
{ value: '' },
|
|
{ value: `${ratePct}%` }
|
|
]
|
|
},
|
|
Summary: {
|
|
ColData: [
|
|
{ value: 'Total' },
|
|
{ value: String(roundTotalSales) },
|
|
{ value: String(roundNontaxableJurisdiction) },
|
|
{ value: String(roundTaxable) },
|
|
{ value: amounts.taxCollected.toFixed(2) },
|
|
{ value: `${ratePct}%` }
|
|
]
|
|
}
|
|
};
|
|
});
|
|
|
|
rows.sort((a, b) => {
|
|
const na = a.Header.ColData[0].value;
|
|
const nb = b.Header.ColData[0].value;
|
|
if (na.includes('State') && !nb.includes('State')) return -1;
|
|
if (!na.includes('State') && nb.includes('State')) return 1;
|
|
return na.localeCompare(nb);
|
|
});
|
|
|
|
const grandTaxCollected = Object.values(agg).reduce((s, a) => s + a.taxCollected, 0);
|
|
rows.push({
|
|
type: 'Section',
|
|
Header: {
|
|
ColData: [
|
|
{ value: 'GRAND TOTAL' },
|
|
{ value: '' }, { value: '' }, { value: '' }, { value: '' }, { value: '' }
|
|
]
|
|
},
|
|
Summary: {
|
|
ColData: [
|
|
{ value: 'Total' },
|
|
{ value: String(roundTotalSales) },
|
|
{ value: String(roundNontaxable) },
|
|
{ value: String(roundTotalTaxable) },
|
|
{ value: grandTaxCollected.toFixed(2) },
|
|
{ value: '' }
|
|
]
|
|
}
|
|
});
|
|
|
|
return {
|
|
Header: {
|
|
ReportName: 'Sales Tax Liability',
|
|
StartPeriod: startDate,
|
|
EndPeriod: endDate,
|
|
ReportBasis: accountingMethod
|
|
},
|
|
Columns: {
|
|
Column: [
|
|
{ ColTitle: '', ColType: 'Text' },
|
|
{ ColTitle: 'Total Sales', ColType: 'Money' },
|
|
{ ColTitle: 'Nontaxable', ColType: 'Money' },
|
|
{ ColTitle: 'Taxable', ColType: 'Money' },
|
|
{ ColTitle: 'Tax Collected', ColType: 'Money' },
|
|
{ ColTitle: 'Tax Rate', ColType: 'Percent' }
|
|
]
|
|
},
|
|
Rows: {
|
|
Row: rows
|
|
}
|
|
};
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Sales Tax Periods — local source of truth for tax filings
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
async function getTaxPeriods() {
|
|
const result = await pool.query(
|
|
'SELECT * FROM sales_tax_periods ORDER BY period_start DESC'
|
|
);
|
|
return result.rows;
|
|
}
|
|
|
|
async function upsertTaxPeriod(period) {
|
|
const result = await pool.query(
|
|
`INSERT INTO sales_tax_periods
|
|
(period_start, period_end, total_sales, nontaxable_sales, taxable_sales, tax_collected,
|
|
adjustment_amount, adjustment_reason, adjustment_account_id, adjustment_account_name,
|
|
net_paid, bank_account_id, bank_account_name,
|
|
sales_tax_payable_id, sales_tax_payable_name, status)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
|
ON CONFLICT (period_start, period_end) DO UPDATE SET
|
|
total_sales = EXCLUDED.total_sales,
|
|
nontaxable_sales = EXCLUDED.nontaxable_sales,
|
|
taxable_sales = EXCLUDED.taxable_sales,
|
|
tax_collected = EXCLUDED.tax_collected,
|
|
adjustment_amount = COALESCE(sales_tax_periods.adjustment_amount, EXCLUDED.adjustment_amount),
|
|
adjustment_reason = COALESCE(sales_tax_periods.adjustment_reason, EXCLUDED.adjustment_reason),
|
|
adjustment_account_id = COALESCE(sales_tax_periods.adjustment_account_id, EXCLUDED.adjustment_account_id),
|
|
adjustment_account_name = COALESCE(sales_tax_periods.adjustment_account_name, EXCLUDED.adjustment_account_name),
|
|
net_paid = COALESCE(sales_tax_periods.net_paid, EXCLUDED.net_paid),
|
|
bank_account_id = COALESCE(sales_tax_periods.bank_account_id, EXCLUDED.bank_account_id),
|
|
bank_account_name = COALESCE(sales_tax_periods.bank_account_name, EXCLUDED.bank_account_name),
|
|
sales_tax_payable_id = COALESCE(sales_tax_periods.sales_tax_payable_id, EXCLUDED.sales_tax_payable_id),
|
|
sales_tax_payable_name = COALESCE(sales_tax_periods.sales_tax_payable_name, EXCLUDED.sales_tax_payable_name),
|
|
status = COALESCE(sales_tax_periods.status, EXCLUDED.status),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
RETURNING *`,
|
|
[
|
|
period.period_start, period.period_end,
|
|
period.total_sales, period.nontaxable_sales, period.taxable_sales, period.tax_collected,
|
|
period.adjustment_amount || 0, period.adjustment_reason || null,
|
|
period.adjustment_account_id || null, period.adjustment_account_name || null,
|
|
period.net_paid || null, period.bank_account_id || null, period.bank_account_name || null,
|
|
period.sales_tax_payable_id || null, period.sales_tax_payable_name || null,
|
|
period.status || 'open'
|
|
]
|
|
);
|
|
return result.rows[0];
|
|
}
|
|
|
|
async function markTaxPaidExternal(periodId, paidDate = null) {
|
|
const result = await pool.query(
|
|
`UPDATE sales_tax_periods
|
|
SET status = 'paid', booked_at = $2, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1 AND status = 'open'
|
|
RETURNING *`,
|
|
[periodId, paidDate || new Date().toISOString().split('T')[0]]
|
|
);
|
|
if (result.rows.length === 0) {
|
|
const existing = await pool.query('SELECT status FROM sales_tax_periods WHERE id = $1', [periodId]);
|
|
const currentStatus = existing.rows[0]?.status || 'unknown';
|
|
throw new Error(`Cannot mark as paid: period is already ${currentStatus}`);
|
|
}
|
|
return result.rows[0];
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// 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
|
|
}));
|
|
}
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Phase 2 Lieferung 2 — Vendor Create
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Erstellt einen neuen Vendor in QBO und schreibt ihn in den Cache.
|
|
*
|
|
* Idempotenz: Wenn ein aktiver Vendor mit gleichem display_name (case-insensitive)
|
|
* bereits im Cache existiert, wird KEIN neuer angelegt — stattdessen wird der
|
|
* existierende zurückgegeben mit { existed: true }.
|
|
*
|
|
* @param {Object} data
|
|
* @param {string} data.name - Pflicht: DisplayName
|
|
* @param {string} [data.email]
|
|
* @param {string} [data.phone]
|
|
* @param {Object} [data.address] - { line1, line2, city, state, zip, country }
|
|
* @param {string} [data.notes]
|
|
* @returns {{ id, displayName, email, phone, existed: boolean }}
|
|
*/
|
|
async function createVendor(data) {
|
|
const name = (data.name || '').trim();
|
|
if (!name) {
|
|
const err = new Error('Vendor name is required');
|
|
err.statusCode = 400;
|
|
throw err;
|
|
}
|
|
|
|
// ── Idempotenz-Check ──
|
|
const existingResult = await pool.query(
|
|
`SELECT qbo_id, display_name, primary_email, primary_phone
|
|
FROM qbo_vendor_cache
|
|
WHERE active = true AND LOWER(display_name) = LOWER($1)
|
|
LIMIT 1`,
|
|
[name]
|
|
);
|
|
if (existingResult.rows.length > 0) {
|
|
const v = existingResult.rows[0];
|
|
return {
|
|
id: v.qbo_id,
|
|
displayName: v.display_name,
|
|
email: v.primary_email,
|
|
phone: v.primary_phone,
|
|
existed: true
|
|
};
|
|
}
|
|
|
|
// ── QBO Create ──
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/vendor`);
|
|
|
|
const payload = {
|
|
DisplayName: name,
|
|
CompanyName: name,
|
|
Active: true
|
|
};
|
|
if (data.email) payload.PrimaryEmailAddr = { Address: data.email };
|
|
if (data.phone) payload.PrimaryPhone = { FreeFormNumber: data.phone };
|
|
if (data.notes) payload.Notes = data.notes;
|
|
|
|
if (data.address && (data.address.line1 || data.address.city)) {
|
|
const a = data.address;
|
|
const billAddr = {};
|
|
if (a.line1) billAddr.Line1 = a.line1;
|
|
if (a.line2) billAddr.Line2 = a.line2;
|
|
if (a.city) billAddr.City = a.city;
|
|
if (a.state) billAddr.CountrySubDivisionCode = a.state;
|
|
if (a.zip) billAddr.PostalCode = a.zip;
|
|
if (a.country) billAddr.Country = a.country;
|
|
payload.BillAddr = billAddr;
|
|
}
|
|
|
|
let qboResponse;
|
|
try {
|
|
const response = await makeQboApiCall({
|
|
url,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
qboResponse = getJson(response);
|
|
} catch (err) {
|
|
await writeAuditLog({
|
|
action: 'vendor.create',
|
|
entityType: 'Vendor',
|
|
status: 'error',
|
|
requestExcerpt: JSON.stringify(payload).slice(0, 1000),
|
|
responseExcerpt: err.message
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
if (qboResponse.Fault) {
|
|
const msg = qboResponse.Fault.Error.map(e => `${e.code}: ${e.Message}`).join('; ');
|
|
await writeAuditLog({
|
|
action: 'vendor.create',
|
|
entityType: 'Vendor',
|
|
status: 'error',
|
|
requestExcerpt: JSON.stringify(payload).slice(0, 1000),
|
|
responseExcerpt: msg
|
|
});
|
|
const err = new Error('QBO Vendor create failed: ' + msg);
|
|
err.qboFault = qboResponse.Fault;
|
|
throw err;
|
|
}
|
|
|
|
const v = qboResponse.Vendor;
|
|
if (!v || !v.Id) {
|
|
throw new Error('QBO returned no vendor id');
|
|
}
|
|
|
|
// ── In Cache schreiben ──
|
|
const email = v.PrimaryEmailAddr ? v.PrimaryEmailAddr.Address : null;
|
|
const phone = v.PrimaryPhone ? v.PrimaryPhone.FreeFormNumber : null;
|
|
|
|
await pool.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]
|
|
);
|
|
|
|
await writeAuditLog({
|
|
action: 'vendor.create',
|
|
entityType: 'Vendor',
|
|
entityQboId: v.Id,
|
|
status: 'success',
|
|
requestExcerpt: JSON.stringify(payload).slice(0, 1000),
|
|
responseExcerpt: `Vendor ${v.Id} (${v.DisplayName}) created`
|
|
});
|
|
|
|
console.log(`✅ QBO Vendor created: ${v.Id} (${v.DisplayName})`);
|
|
|
|
return {
|
|
id: v.Id,
|
|
displayName: v.DisplayName,
|
|
email,
|
|
phone,
|
|
existed: false
|
|
};
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Phase 2 Lieferung 2 — Expense Create
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Erstellt eine QBO Purchase (= "Expense" in QBO-Sprech).
|
|
*
|
|
* @param {Object} data
|
|
* @param {string} data.vendorId - Pflicht
|
|
* @param {string} data.paymentAccountId - Pflicht (Bank- oder Credit-Card-Account)
|
|
* @param {string} data.txnDate - Pflicht, YYYY-MM-DD
|
|
* @param {string} [data.paymentMethodId]
|
|
* @param {string} [data.refNo]
|
|
* @param {string} [data.memo]
|
|
* @param {Array} data.lines - Pflicht, mind. 1 Line
|
|
* Line: { accountId, amount, description? }
|
|
*
|
|
* @returns {{ id, txnDate, totalAmt, lineCount, vendorName, accountName }}
|
|
*/
|
|
async function createExpense(data) {
|
|
// ── Validierung ──
|
|
if (!data.vendorId) throw badRequest('vendorId is required');
|
|
if (!data.paymentAccountId) throw badRequest('paymentAccountId is required');
|
|
if (!data.txnDate) throw badRequest('txnDate is required');
|
|
if (!Array.isArray(data.lines) || data.lines.length === 0) {
|
|
throw badRequest('At least one line is required');
|
|
}
|
|
for (const [i, line] of data.lines.entries()) {
|
|
if (!line.accountId) throw badRequest(`Line ${i + 1}: accountId is required`);
|
|
const amt = Number(line.amount);
|
|
if (!isFinite(amt) || amt <= 0) {
|
|
throw badRequest(`Line ${i + 1}: amount must be a positive number`);
|
|
}
|
|
}
|
|
|
|
// ── Account-Type des Payment-Accounts bestimmen ──
|
|
// Bank → PaymentType "Check" (default in QBO)
|
|
// Credit Card → PaymentType "CreditCard"
|
|
const acctRow = await pool.query(
|
|
`SELECT qbo_id, name, account_type FROM qbo_account_cache WHERE qbo_id = $1`,
|
|
[data.paymentAccountId]
|
|
);
|
|
if (acctRow.rows.length === 0) {
|
|
throw badRequest(`Payment account ${data.paymentAccountId} not in cache. Run sync first.`);
|
|
}
|
|
const paymentAcct = acctRow.rows[0];
|
|
const paymentType = paymentAcct.account_type === 'Credit Card' ? 'CreditCard' : 'Check';
|
|
|
|
// ── Vendor-Name aus Cache (für Logging/Response) ──
|
|
const vendorRow = await pool.query(
|
|
`SELECT display_name FROM qbo_vendor_cache WHERE qbo_id = $1`,
|
|
[data.vendorId]
|
|
);
|
|
const vendorName = vendorRow.rows[0]?.display_name || data.vendorId;
|
|
|
|
// ── QBO Purchase Payload ──
|
|
const totalAmt = data.lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
|
|
|
const payload = {
|
|
AccountRef: { value: paymentAcct.qbo_id, name: paymentAcct.name },
|
|
EntityRef: { value: data.vendorId, type: 'Vendor', name: vendorName },
|
|
TxnDate: data.txnDate,
|
|
PaymentType: paymentType,
|
|
Line: data.lines.map(line => ({
|
|
DetailType: 'AccountBasedExpenseLineDetail',
|
|
Amount: Number(line.amount),
|
|
Description: line.description || undefined,
|
|
AccountBasedExpenseLineDetail: {
|
|
AccountRef: { value: String(line.accountId) }
|
|
}
|
|
}))
|
|
};
|
|
|
|
if (data.refNo) payload.DocNumber = String(data.refNo).slice(0, 21);
|
|
if (data.memo) payload.PrivateNote = String(data.memo);
|
|
|
|
if (data.paymentMethodId) {
|
|
payload.PaymentMethodRef = { value: String(data.paymentMethodId) };
|
|
}
|
|
|
|
// ── QBO POST ──
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase`);
|
|
|
|
const requestSummary = `${vendorName} | ${paymentAcct.name} | ${data.txnDate} | $${totalAmt.toFixed(2)} | ${data.lines.length} line(s)`;
|
|
|
|
let qboResponse;
|
|
try {
|
|
const response = await makeQboApiCall({
|
|
url,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
qboResponse = getJson(response);
|
|
} catch (err) {
|
|
await writeAuditLog({
|
|
action: 'expense.create',
|
|
entityType: 'Purchase',
|
|
status: 'error',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: err.message
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
if (qboResponse.Fault) {
|
|
const msg = qboResponse.Fault.Error.map(e =>
|
|
`${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}`
|
|
).join('; ');
|
|
await writeAuditLog({
|
|
action: 'expense.create',
|
|
entityType: 'Purchase',
|
|
status: 'error',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: msg
|
|
});
|
|
const err = new Error('QBO Purchase create failed: ' + msg);
|
|
err.qboFault = qboResponse.Fault;
|
|
throw err;
|
|
}
|
|
|
|
const purchase = qboResponse.Purchase;
|
|
if (!purchase || !purchase.Id) throw new Error('QBO returned no Purchase id');
|
|
|
|
await writeAuditLog({
|
|
action: 'expense.create',
|
|
entityType: 'Purchase',
|
|
entityQboId: purchase.Id,
|
|
status: 'success',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: `Purchase ${purchase.Id} created, total $${Number(purchase.TotalAmt).toFixed(2)}`
|
|
});
|
|
|
|
console.log(`✅ QBO Expense created: Purchase ${purchase.Id} — ${requestSummary}`);
|
|
|
|
return {
|
|
id: purchase.Id,
|
|
txnDate: purchase.TxnDate,
|
|
totalAmt: Number(purchase.TotalAmt),
|
|
lineCount: data.lines.length,
|
|
vendorName,
|
|
accountName: paymentAcct.name
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* Erstellt einen QBO Deposit oder Purchase Credit für einen Vendor-Refund.
|
|
*
|
|
* @param {Object} data
|
|
* @param {string} data.vendorId - Pflicht (von wem kam der Refund)
|
|
* @param {string} data.depositAccountId - Pflicht (Bank/CC-Konto, wo das Geld ankam)
|
|
* @param {string} data.txnDate - Pflicht, YYYY-MM-DD
|
|
* @param {string} data.categoryAccountId - Pflicht (ursprüngliche Expense-Kategorie)
|
|
* @param {number} data.amount - Pflicht, positiver Betrag
|
|
* @param {string} [data.refNo]
|
|
* @param {string} [data.memo]
|
|
* @returns {{ id, txnDate, totalAmt, vendorName, depositAccountName, categoryName }}
|
|
*/
|
|
async function createRefund(data) {
|
|
if (!data.vendorId) throw badRequest('vendorId is required');
|
|
if (!data.depositAccountId) throw badRequest('depositAccountId is required');
|
|
if (!data.categoryAccountId) throw badRequest('categoryAccountId is required');
|
|
if (!data.txnDate) throw badRequest('txnDate is required');
|
|
|
|
const amount = Number(data.amount);
|
|
if (!isFinite(amount) || amount <= 0) {
|
|
throw badRequest('amount must be a positive number (the refund value)');
|
|
}
|
|
|
|
// ── Deposit-Konto aus Cache ──
|
|
const depRow = await pool.query(
|
|
`SELECT qbo_id, name, account_type FROM qbo_account_cache WHERE qbo_id = $1`,
|
|
[data.depositAccountId]
|
|
);
|
|
if (depRow.rows.length === 0) {
|
|
throw badRequest(`Deposit account ${data.depositAccountId} not in cache. Run sync first.`);
|
|
}
|
|
const depositAcct = depRow.rows[0];
|
|
|
|
// ── Kategorie-Konto aus Cache ──
|
|
const catRow = await pool.query(
|
|
`SELECT qbo_id, name FROM qbo_account_cache WHERE qbo_id = $1`,
|
|
[data.categoryAccountId]
|
|
);
|
|
if (catRow.rows.length === 0) {
|
|
throw badRequest(`Category account ${data.categoryAccountId} not in cache. Run sync first.`);
|
|
}
|
|
const categoryAcct = catRow.rows[0];
|
|
|
|
// ── Vendor-Name aus Cache ──
|
|
const vendorRow = await pool.query(
|
|
`SELECT display_name FROM qbo_vendor_cache WHERE qbo_id = $1`,
|
|
[data.vendorId]
|
|
);
|
|
const vendorName = vendorRow.rows[0]?.display_name || data.vendorId;
|
|
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
const isCreditCard = depositAcct.account_type === 'Credit Card';
|
|
|
|
let url;
|
|
let payload;
|
|
let entityTypeLabel;
|
|
|
|
// ── Verzweigung: Credit Card Credit (Purchase) vs. Bank (Deposit) ──
|
|
if (isCreditCard) {
|
|
entityTypeLabel = 'Purchase';
|
|
url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase`);
|
|
payload = {
|
|
AccountRef: { value: depositAcct.qbo_id, name: depositAcct.name },
|
|
EntityRef: { value: data.vendorId, type: 'Vendor', name: vendorName },
|
|
TxnDate: data.txnDate,
|
|
PaymentType: 'CreditCard',
|
|
Credit: true, // Zwingend erforderlich für einen CC Refund
|
|
Line: [{
|
|
DetailType: 'AccountBasedExpenseLineDetail',
|
|
Amount: amount,
|
|
Description: data.memo || `Refund from ${vendorName}`,
|
|
AccountBasedExpenseLineDetail: {
|
|
AccountRef: { value: categoryAcct.qbo_id, name: categoryAcct.name }
|
|
}
|
|
}]
|
|
};
|
|
if (data.refNo) payload.DocNumber = String(data.refNo).slice(0, 21);
|
|
if (data.memo) payload.PrivateNote = String(data.memo);
|
|
|
|
} else {
|
|
entityTypeLabel = 'Deposit';
|
|
url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/deposit`);
|
|
payload = {
|
|
DepositToAccountRef: { value: depositAcct.qbo_id, name: depositAcct.name },
|
|
TxnDate: data.txnDate,
|
|
Line: [{
|
|
DetailType: 'DepositLineDetail',
|
|
Amount: amount,
|
|
Description: data.memo || `Refund from ${vendorName}`,
|
|
DepositLineDetail: {
|
|
AccountRef: { value: categoryAcct.qbo_id, name: categoryAcct.name },
|
|
// Korrigierte QBO Entity-Struktur
|
|
Entity: {
|
|
Type: 'Vendor',
|
|
EntityRef: { value: data.vendorId, name: vendorName }
|
|
}
|
|
}
|
|
}]
|
|
};
|
|
if (data.refNo) payload.Line[0].DepositLineDetail.CheckNum = String(data.refNo).slice(0, 21);
|
|
if (data.memo) payload.PrivateNote = String(data.memo);
|
|
}
|
|
|
|
const requestSummary = `REFUND (${entityTypeLabel}) | ${vendorName} → ${depositAcct.name} | ${categoryAcct.name} | ${data.txnDate} | $${amount.toFixed(2)}`;
|
|
|
|
let qboResponse;
|
|
try {
|
|
const response = await makeQboApiCall({
|
|
url,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
qboResponse = getJson(response);
|
|
} catch (err) {
|
|
await writeAuditLog({
|
|
action: 'refund.create',
|
|
entityType: entityTypeLabel,
|
|
status: 'error',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: err.message
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
if (qboResponse.Fault) {
|
|
const msg = qboResponse.Fault.Error.map(e =>
|
|
`${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}`
|
|
).join('; ');
|
|
await writeAuditLog({
|
|
action: 'refund.create',
|
|
entityType: entityTypeLabel,
|
|
status: 'error',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: msg
|
|
});
|
|
const err = new Error(`QBO ${entityTypeLabel} (refund) create failed: ${msg}`);
|
|
err.qboFault = qboResponse.Fault;
|
|
throw err;
|
|
}
|
|
|
|
// Dynamisches Extrahieren basierend auf dem Entity-Typ
|
|
const resultEntity = isCreditCard ? qboResponse.Purchase : qboResponse.Deposit;
|
|
if (!resultEntity || !resultEntity.Id) throw new Error(`QBO returned no ${entityTypeLabel} id`);
|
|
|
|
await writeAuditLog({
|
|
action: 'refund.create',
|
|
entityType: entityTypeLabel,
|
|
entityQboId: resultEntity.Id,
|
|
status: 'success',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: `${entityTypeLabel} ${resultEntity.Id} created, total $${Number(resultEntity.TotalAmt).toFixed(2)}`
|
|
});
|
|
|
|
console.log(`✅ QBO Refund recorded: ${entityTypeLabel} ${resultEntity.Id} — ${requestSummary}`);
|
|
|
|
return {
|
|
id: resultEntity.Id,
|
|
txnDate: resultEntity.TxnDate,
|
|
totalAmt: Number(resultEntity.TotalAmt),
|
|
vendorName,
|
|
depositAccountName: depositAcct.name,
|
|
categoryName: categoryAcct.name
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Aktualisiert eine bestehende QBO Purchase (Expense).
|
|
* QBO erfordert ein VOLLSTÄNDIGES Update — die komplette Line-Liste muss
|
|
* mitgeschickt werden, sonst gehen Zeilen verloren.
|
|
*
|
|
* @param {string} purchaseId
|
|
* @param {Object} data — gleiche Struktur wie createExpense
|
|
* @returns {{ id, txnDate, totalAmt, lineCount, vendorName, accountName }}
|
|
*/
|
|
async function updateExpense(purchaseId, data) {
|
|
if (!purchaseId) throw badRequest('purchaseId is required');
|
|
if (!data.vendorId) throw badRequest('vendorId is required');
|
|
if (!data.paymentAccountId) throw badRequest('paymentAccountId is required');
|
|
if (!data.txnDate) throw badRequest('txnDate is required');
|
|
if (!Array.isArray(data.lines) || data.lines.length === 0) {
|
|
throw badRequest('At least one line is required');
|
|
}
|
|
for (const [i, line] of data.lines.entries()) {
|
|
if (!line.accountId) throw badRequest(`Line ${i + 1}: accountId is required`);
|
|
const amt = Number(line.amount);
|
|
if (!isFinite(amt) || amt <= 0) {
|
|
throw badRequest(`Line ${i + 1}: amount must be a positive number`);
|
|
}
|
|
}
|
|
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
|
|
// ── Aktuelle Purchase laden (für SyncToken) ──
|
|
const getUrl = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase/${purchaseId}`);
|
|
const getResponse = await makeQboApiCall({ url: getUrl, method: 'GET' });
|
|
const getData = getJson(getResponse);
|
|
throwIfFault(getData, 'Purchase fetch');
|
|
|
|
const current = getData.Purchase;
|
|
if (!current || !current.Id) {
|
|
throw badRequest(`Purchase ${purchaseId} not found in QBO`);
|
|
}
|
|
|
|
// ── Payment-Account-Type bestimmen ──
|
|
const acctRow = await pool.query(
|
|
`SELECT qbo_id, name, account_type FROM qbo_account_cache WHERE qbo_id = $1`,
|
|
[data.paymentAccountId]
|
|
);
|
|
if (acctRow.rows.length === 0) {
|
|
throw badRequest(`Payment account ${data.paymentAccountId} not in cache. Run sync first.`);
|
|
}
|
|
const paymentAcct = acctRow.rows[0];
|
|
const paymentType = paymentAcct.account_type === 'Credit Card' ? 'CreditCard' : 'Check';
|
|
|
|
const vendorRow = await pool.query(
|
|
`SELECT display_name FROM qbo_vendor_cache WHERE qbo_id = $1`,
|
|
[data.vendorId]
|
|
);
|
|
const vendorName = vendorRow.rows[0]?.display_name || data.vendorId;
|
|
|
|
const totalAmt = data.lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
|
|
|
// ── Update-Payload — vollständig, mit Id + SyncToken ──
|
|
const payload = {
|
|
Id: current.Id,
|
|
SyncToken: current.SyncToken,
|
|
AccountRef: { value: paymentAcct.qbo_id, name: paymentAcct.name },
|
|
EntityRef: { value: data.vendorId, type: 'Vendor', name: vendorName },
|
|
TxnDate: data.txnDate,
|
|
PaymentType: paymentType,
|
|
Line: data.lines.map(line => ({
|
|
DetailType: 'AccountBasedExpenseLineDetail',
|
|
Amount: Number(line.amount),
|
|
Description: line.description || undefined,
|
|
AccountBasedExpenseLineDetail: {
|
|
AccountRef: { value: String(line.accountId) }
|
|
}
|
|
}))
|
|
};
|
|
|
|
payload.DocNumber = data.refNo ? String(data.refNo).slice(0, 21) : undefined;
|
|
payload.PrivateNote = data.memo ? String(data.memo) : undefined;
|
|
if (data.paymentMethodId) {
|
|
payload.PaymentMethodRef = { value: String(data.paymentMethodId) };
|
|
}
|
|
|
|
const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase`);
|
|
const requestSummary = `UPDATE ${purchaseId} | ${vendorName} | ${data.txnDate} | $${totalAmt.toFixed(2)} | ${data.lines.length} line(s)`;
|
|
|
|
let qboResponse;
|
|
try {
|
|
const response = await makeQboApiCall({
|
|
url,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
qboResponse = getJson(response);
|
|
} catch (err) {
|
|
await writeAuditLog({
|
|
action: 'expense.update',
|
|
entityType: 'Purchase',
|
|
entityQboId: purchaseId,
|
|
status: 'error',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: err.message
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
if (qboResponse.Fault) {
|
|
const msg = qboResponse.Fault.Error.map(e =>
|
|
`${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}`
|
|
).join('; ');
|
|
await writeAuditLog({
|
|
action: 'expense.update',
|
|
entityType: 'Purchase',
|
|
entityQboId: purchaseId,
|
|
status: 'error',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: msg
|
|
});
|
|
const err = new Error('QBO Purchase update failed: ' + msg);
|
|
err.qboFault = qboResponse.Fault;
|
|
throw err;
|
|
}
|
|
|
|
const purchase = qboResponse.Purchase;
|
|
if (!purchase || !purchase.Id) throw new Error('QBO returned no Purchase id');
|
|
|
|
await writeAuditLog({
|
|
action: 'expense.update',
|
|
entityType: 'Purchase',
|
|
entityQboId: purchase.Id,
|
|
status: 'success',
|
|
requestExcerpt: requestSummary,
|
|
responseExcerpt: `Purchase ${purchase.Id} updated, total $${Number(purchase.TotalAmt).toFixed(2)}`
|
|
});
|
|
|
|
console.log(`✅ QBO Expense updated: Purchase ${purchase.Id} — ${requestSummary}`);
|
|
|
|
return {
|
|
id: purchase.Id,
|
|
txnDate: purchase.TxnDate,
|
|
totalAmt: Number(purchase.TotalAmt),
|
|
lineCount: data.lines.length,
|
|
vendorName,
|
|
accountName: paymentAcct.name
|
|
};
|
|
}
|
|
|
|
function badRequest(msg) {
|
|
const err = new Error(msg);
|
|
err.statusCode = 400;
|
|
return err;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Phase 2 Lieferung 2 — Expense List (read)
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Liefert eine Liste von QBO Purchases (=Expenses) für ein Datums-Intervall.
|
|
*
|
|
* @param {Object} opts
|
|
* @param {string} opts.startDate - Pflicht
|
|
* @param {string} opts.endDate - Pflicht
|
|
* @param {boolean} [opts.onlyMine] - Wenn true, nur Purchases die in unserem
|
|
* accounting_sync_log mit action='expense.create'
|
|
* erfolgreich exportiert wurden
|
|
* @returns {Array<{ id, txnDate, totalAmt, vendorName, accountName, refNo, memo, lines }>}
|
|
*/
|
|
async function listExpenses({ startDate, endDate, onlyMine = false } = {}) {
|
|
if (!startDate || !endDate) throw badRequest('startDate and endDate are required');
|
|
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
|
|
// Wir queryen Purchases in dem Date-Range. PaymentType filtern wir nicht —
|
|
// QBO speichert auch Expense-Buchungen mit AccountBasedExpenseLineDetail.
|
|
const safeStart = startDate.replace(/'/g, '');
|
|
const safeEnd = endDate.replace(/'/g, '');
|
|
const sql = `SELECT * FROM Purchase WHERE TxnDate >= '${safeStart}' AND TxnDate <= '${safeEnd}' ORDERBY TxnDate DESC MAXRESULTS 1000`;
|
|
|
|
const url = withMinorVersion(
|
|
`${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(sql)}`
|
|
);
|
|
const response = await makeQboApiCall({ url, method: 'GET' });
|
|
const data = getJson(response);
|
|
throwIfFault(data, 'Purchase query');
|
|
|
|
let purchases = (data.QueryResponse && data.QueryResponse.Purchase) || [];
|
|
|
|
// Filter: nur App-eigene Expenses
|
|
if (onlyMine) {
|
|
const r = await pool.query(
|
|
`SELECT DISTINCT entity_qbo_id FROM accounting_sync_log
|
|
WHERE action = 'expense.create' AND status = 'success' AND entity_qbo_id IS NOT NULL`
|
|
);
|
|
const myIds = new Set(r.rows.map(row => row.entity_qbo_id));
|
|
purchases = purchases.filter(p => myIds.has(p.Id));
|
|
}
|
|
|
|
return purchases.map(p => normalizePurchase(p));
|
|
}
|
|
|
|
function normalizePurchase(p) {
|
|
const lines = (p.Line || [])
|
|
.filter(l => l.DetailType !== 'SubTotalLineDetail')
|
|
.map(l => {
|
|
const detail = l.AccountBasedExpenseLineDetail || {};
|
|
const acctRef = detail.AccountRef || {};
|
|
return {
|
|
accountId: acctRef.value || null,
|
|
accountName: acctRef.name || null,
|
|
amount: l.Amount != null ? Number(l.Amount) : null,
|
|
description: l.Description || null
|
|
};
|
|
});
|
|
|
|
return {
|
|
id: p.Id,
|
|
txnDate: p.TxnDate,
|
|
totalAmt: p.TotalAmt != null ? Number(p.TotalAmt) : 0,
|
|
vendorName: p.EntityRef ? p.EntityRef.name : null,
|
|
vendorId: p.EntityRef ? p.EntityRef.value : null,
|
|
accountName: p.AccountRef ? p.AccountRef.name : null,
|
|
accountId: p.AccountRef ? p.AccountRef.value : null,
|
|
paymentType: p.PaymentType,
|
|
refNo: p.DocNumber || null,
|
|
memo: p.PrivateNote || null,
|
|
lines
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hängt ein File an eine bestehende QBO-Transaktion (Purchase, Invoice, ...).
|
|
*
|
|
* QBO erwartet multipart/form-data mit zwei Parts:
|
|
* - file_metadata_0 (JSON: Attachable-Object)
|
|
* - file_content_0 (binary)
|
|
*
|
|
* @param {Object} opts
|
|
* @param {string} opts.entityType - 'Purchase' | 'Invoice' | etc.
|
|
* @param {string} opts.entityId - QBO id der Transaktion
|
|
* @param {Buffer} opts.fileBuffer - Binärdaten
|
|
* @param {string} opts.fileName - z.B. "receipt.png"
|
|
* @param {string} opts.contentType - z.B. "image/png", "application/pdf"
|
|
* @param {string} [opts.note] - Anzeige-Text in QBO (default = fileName)
|
|
* @returns {{ id, fileName }}
|
|
*/
|
|
async function attachFileToEntity({ entityType, entityId, fileBuffer, fileName, contentType, note }) {
|
|
if (!entityType || !entityId) throw badRequest('entityType and entityId are required');
|
|
if (!fileBuffer || !fileBuffer.length) throw badRequest('fileBuffer is empty');
|
|
if (!fileName) throw badRequest('fileName is required');
|
|
if (!contentType) throw badRequest('contentType is required');
|
|
|
|
const { companyId, baseUrl } = getClientInfo();
|
|
|
|
// Boundary für multipart
|
|
const boundary = '----QBOAttachBoundary' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
const CRLF = '\r\n';
|
|
|
|
const metadata = {
|
|
AttachableRef: [{
|
|
EntityRef: { type: entityType, value: String(entityId) }
|
|
}],
|
|
FileName: fileName,
|
|
ContentType: contentType,
|
|
Note: note || fileName
|
|
};
|
|
|
|
// Multipart body als Buffer (Header + Metadata-JSON + File-Bytes + Footer)
|
|
const head = Buffer.from(
|
|
`--${boundary}${CRLF}` +
|
|
`Content-Disposition: form-data; name="file_metadata_0"${CRLF}` +
|
|
`Content-Type: application/json${CRLF}${CRLF}` +
|
|
JSON.stringify(metadata) + CRLF +
|
|
`--${boundary}${CRLF}` +
|
|
`Content-Disposition: form-data; name="file_content_0"; filename="${fileName}"${CRLF}` +
|
|
`Content-Type: ${contentType}${CRLF}${CRLF}`,
|
|
'utf8'
|
|
);
|
|
const tail = Buffer.from(`${CRLF}--${boundary}--${CRLF}`, 'utf8');
|
|
const body = Buffer.concat([head, fileBuffer, tail]);
|
|
|
|
const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/upload`);
|
|
|
|
let qboResponse;
|
|
try {
|
|
const response = await makeQboApiCall({
|
|
url,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
body
|
|
});
|
|
qboResponse = getJson(response);
|
|
} catch (err) {
|
|
await writeAuditLog({
|
|
action: 'attachment.upload',
|
|
entityType,
|
|
entityQboId: String(entityId),
|
|
status: 'error',
|
|
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
|
responseExcerpt: err.message
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
// QBO antwortet mit AttachableResponse[0].Attachable bei Erfolg,
|
|
// oder AttachableResponse[0].Fault bei Fehler.
|
|
const responses = qboResponse.AttachableResponse || [];
|
|
const first = responses[0] || {};
|
|
|
|
if (first.Fault) {
|
|
const msg = (first.Fault.Error || []).map(e => `${e.code}: ${e.Message}`).join('; ');
|
|
await writeAuditLog({
|
|
action: 'attachment.upload',
|
|
entityType,
|
|
entityQboId: String(entityId),
|
|
status: 'error',
|
|
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
|
responseExcerpt: msg
|
|
});
|
|
const err = new Error('QBO attach failed: ' + msg);
|
|
err.qboFault = first.Fault;
|
|
throw err;
|
|
}
|
|
|
|
const att = first.Attachable;
|
|
if (!att || !att.Id) throw new Error('QBO returned no attachable id');
|
|
|
|
await writeAuditLog({
|
|
action: 'attachment.upload',
|
|
entityType,
|
|
entityQboId: String(entityId),
|
|
status: 'success',
|
|
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
|
responseExcerpt: `Attachable ${att.Id}`
|
|
});
|
|
|
|
console.log(`📎 QBO Attachable created: ${att.Id} (${fileName}) → ${entityType} ${entityId}`);
|
|
return { id: att.Id, fileName };
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Exports
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
module.exports = {
|
|
// Phase 1
|
|
listAccounts,
|
|
getRegister,
|
|
getProfitAndLoss,
|
|
getBalanceSheet,
|
|
getTaxSummary,
|
|
getTaxPeriods,
|
|
upsertTaxPeriod,
|
|
markTaxPaidExternal,
|
|
normalizeTransactionListReport,
|
|
|
|
// Phase 2 Lieferung 1 — Sync
|
|
syncAccountsCache,
|
|
syncVendorsCache,
|
|
getCacheStatus,
|
|
cacheIsStaleToday,
|
|
|
|
// Phase 2 Lieferung 1 — Reads
|
|
getVendorsFromCache,
|
|
getExpenseAccountsFromCache,
|
|
getPaymentAccountsFromCache,
|
|
getPaymentMethods,
|
|
|
|
// Phase 2 Lieferung 2 — Mutations + List
|
|
createVendor,
|
|
createExpense,
|
|
updateExpense,
|
|
createRefund,
|
|
listExpenses,
|
|
|
|
// Phase 2 Lieferung 3
|
|
attachFileToEntity,
|
|
|
|
// Audit
|
|
writeAuditLog
|
|
}; |