-
-
Balance Sheet
-
+
Balance Sheet
-
-
-
-
-
-
+
+
+
-
-
+
+
@@ -446,58 +429,30 @@ export async function loadProfitLoss() {
plStartDate = document.getElementById('pl-start').value;
plEndDate = document.getElementById('pl-end').value;
plAccountingMethod = document.getElementById('pl-method').value;
-
showLoading('pl-result', 'Loading P&L from QBO…');
try {
- const data = await window.API.accounting.getProfitAndLoss(
- plStartDate, plEndDate, plAccountingMethod
- );
+ const data = await window.API.accounting.getProfitAndLoss(plStartDate, plEndDate, plAccountingMethod);
if (data.error) return showError('pl-result', data.error);
document.getElementById('pl-result').innerHTML = renderQboReport(data);
- } catch (err) {
- showError('pl-result', err.message || 'Failed to load P&L');
- }
+ } catch (err) { showError('pl-result', err.message || 'Failed to load P&L'); }
}
export async function loadBalanceSheet() {
bsAsOfDate = document.getElementById('bs-asof').value;
bsAccountingMethod = document.getElementById('bs-method').value;
-
showLoading('bs-result', 'Loading Balance Sheet from QBO…');
try {
- const data = await window.API.accounting.getBalanceSheet(
- bsAsOfDate, bsAccountingMethod
- );
+ const data = await window.API.accounting.getBalanceSheet(bsAsOfDate, bsAccountingMethod);
if (data.error) return showError('bs-result', data.error);
document.getElementById('bs-result').innerHTML = renderQboReport(data);
- } catch (err) {
- showError('bs-result', err.message || 'Failed to load Balance Sheet');
- }
+ } catch (err) { showError('bs-result', err.message || 'Failed to load Balance Sheet'); }
}
-// ────────────────────────────────────────────────────────────────────
-// Generic QBO Report Renderer (P&L + Balance Sheet)
-// QBO Reports haben rekursive Section/Row-Bäume mit Summary-Zeilen.
-// Wir rendern sie als verschachtelte HTML-Tabelle.
-// ────────────────────────────────────────────────────────────────────
-
function renderQboReport(report) {
- if (!report || !report.Header) {
- return `
No report data.
`;
- }
-
+ if (!report || !report.Header) return `
No report data.
`;
const cols = (report.Columns && report.Columns.Column) || [];
- const colCount = cols.length;
-
- let body = '';
- if (report.Rows && report.Rows.Row) {
- body = renderReportRows(report.Rows.Row, 0, colCount);
- }
-
- const headerRow = cols.map(c =>
- `
${escapeHtml(c.ColTitle || '')} | `
- ).join('');
-
+ const headerRow = cols.map(c => `
${escapeHtml(c.ColTitle || '')} | `).join('');
+ const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : '';
return `
${escapeHtml(report.Header.ReportName || '')}
@@ -506,72 +461,186 @@ function renderQboReport(report) {
-
- ${headerRow}
-
+ ${headerRow}
${body}
`;
}
-function renderReportRows(rows, depth, colCount) {
+function renderReportRows(rows, depth) {
if (!rows) return '';
const arr = Array.isArray(rows) ? rows : [rows];
let html = '';
-
for (const row of arr) {
const isSection = row.type === 'Section' || row.Rows || row.Summary;
- const indentPx = depth * 16;
-
+ const indent = depth * 16;
if (row.Header && row.Header.ColData) {
- // Section header row
- const headerCells = row.Header.ColData.map((c, i) => {
- if (i === 0) {
- return `
${escapeHtml(c.value || '')} | `;
- }
- return `
| `;
- }).join('');
- html += `
${headerCells}
`;
+ const cells = row.Header.ColData.map((c, i) =>
+ i === 0
+ ? `
${escapeHtml(c.value || '')} | `
+ : `
| `
+ ).join('');
+ html += `
${cells}
`;
}
-
- if (isSection && row.Rows && row.Rows.Row) {
- html += renderReportRows(row.Rows.Row, depth + 1, colCount);
- }
-
+ if (isSection && row.Rows && row.Rows.Row) html += renderReportRows(row.Rows.Row, depth + 1);
if (row.Summary && row.Summary.ColData) {
- const sumCells = row.Summary.ColData.map((c, i) => {
- if (i === 0) {
- return `
${escapeHtml(c.value || '')} | `;
- }
- return `
${escapeHtml(c.value || '')} | `;
- }).join('');
- html += `
${sumCells}
`;
+ const cells = row.Summary.ColData.map((c, i) =>
+ i === 0
+ ? `
${escapeHtml(c.value || '')} | `
+ : `
${escapeHtml(c.value || '')} | `
+ ).join('');
+ html += `
${cells}
`;
}
-
if (!isSection && row.ColData) {
- // Plain data row
- const cells = row.ColData.map((c, i) => {
- if (i === 0) {
- return `
${escapeHtml(c.value || '')} | `;
- }
- return `
${escapeHtml(c.value || '')} | `;
- }).join('');
+ const cells = row.ColData.map((c, i) =>
+ i === 0
+ ? `
${escapeHtml(c.value || '')} | `
+ : `
${escapeHtml(c.value || '')} | `
+ ).join('');
html += `
${cells}
`;
}
}
-
return html;
}
+// ════════════════════════════════════════════════════════════════════
+// Phase 2 Lieferung 2 — Expenses Section
+// ════════════════════════════════════════════════════════════════════
+
+export function injectExpensesSection() {
+ const c = document.getElementById('accounting-expenses');
+ if (!c) return;
+ if (!expStartDate) expStartDate = firstOfMonthISO();
+ if (!expEndDate) expEndDate = todayISO();
+
+ c.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+}
+
+export async function loadExpenses() {
+ const startEl = document.getElementById('exp-start');
+ const endEl = document.getElementById('exp-end');
+ const onlyEl = document.getElementById('exp-only-mine');
+ expStartDate = startEl.value;
+ expEndDate = endEl.value;
+ expOnlyMine = onlyEl.checked;
+
+ const slot = 'accounting-expenses-table';
+ showLoading(slot, 'Loading expenses from QBO…');
+
+ try {
+ const list = await window.API.accounting.listExpenses(expStartDate, expEndDate, expOnlyMine);
+ if (list.error) return showError(slot, list.error);
+ renderExpensesTable(list);
+ } catch (err) {
+ showError(slot, err.message || 'Failed to load expenses');
+ }
+}
+
+function renderExpensesTable(expenses) {
+ const el = document.getElementById('accounting-expenses-table');
+ if (!el) return;
+
+ if (!expenses.length) {
+ el.innerHTML = `
No expenses in selected range${expOnlyMine ? ' (created from this app)' : ''}.
`;
+ return;
+ }
+
+ const sorted = expenses.slice().sort((a, b) => (b.txnDate || '').localeCompare(a.txnDate || ''));
+ const tbody = sorted.map(e => {
+ const splitsHtml = e.lines && e.lines.length > 1
+ ? `
${e.lines.length} lines
+
+ ${e.lines.map(l => `
+
+ ${escapeHtml(l.accountName || '?')}
+ ${l.amount != null ? fmtMoney(l.amount) : ''}
+
`).join('')}
+
+ `
+ : escapeHtml(e.lines[0]?.accountName || '');
+
+ return `
+
+ | ${escapeHtml(e.txnDate || '')} |
+ ${escapeHtml(e.vendorName || '')} |
+ ${escapeHtml(e.accountName || '')} |
+ ${splitsHtml} |
+ ${escapeHtml(e.refNo || '')} |
+ ${escapeHtml(e.memo || '')} |
+ ${fmtMoney(e.totalAmt)} |
+
`;
+ }).join('');
+
+ el.innerHTML = `
+
+
+
+
+ | Date |
+ Vendor |
+ Payment Account |
+ Category |
+ Ref |
+ Memo |
+ Amount |
+
+
+ ${tbody}
+
+
${sorted.length} expense${sorted.length === 1 ? '' : 's'}
+
`;
+}
+
+export async function openNewExpense() {
+ await openExpenseModal({
+ onSaved: () => loadExpenses()
+ });
+}
+
// ────────────────────────────────────────────────────────────────────
// Init / Public Entry Points
// ────────────────────────────────────────────────────────────────────
export function renderAccountingView() {
+ autoSyncDoneThisOpen = false;
injectToolbar();
injectRegisterControls();
injectReportsControls();
- loadAccountsOverview();
+ injectExpensesSection();
+
+ // Sequenz: erst Auto-Sync (wenn nötig), DANN Daten laden,
+ // damit das Frontend frische Cache-basierte Daten kriegt.
+ maybeAutoSyncCaches().then(() => {
+ loadAccountsOverview();
+ });
}
export function refreshAll() {
@@ -579,13 +648,15 @@ export function refreshAll() {
if (registerAccountId) loadRegister();
}
-// Expose for onclick handlers
window.accountingView = {
renderAccountingView,
refreshAll,
+ manualSync,
loadAccountsOverview,
loadRegister,
selectRegisterAccount,
loadProfitLoss,
- loadBalanceSheet
-};
+ loadBalanceSheet,
+ loadExpenses,
+ openNewExpense
+};
\ No newline at end of file
diff --git a/src/routes/accounting.js b/src/routes/accounting.js
index c7e2d58..94b7bc4 100644
--- a/src/routes/accounting.js
+++ b/src/routes/accounting.js
@@ -9,10 +9,15 @@ const accountingService = require('../services/accounting-service');
// ────────────────────────────────────────────────────────────────────
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 });
+ const statusCode = err.statusCode || 500;
+ if (statusCode >= 500) {
+ console.error(`❌ Accounting/${context} error:`, err.message);
+ if (err.qboFault) console.error(' QBO Fault:', JSON.stringify(err.qboFault));
+ if (err.stack) console.error(err.stack);
+ } else {
+ console.warn(`⚠️ Accounting/${context} ${statusCode}:`, err.message);
+ }
+ res.status(statusCode).json({ error: err.message || 'Request failed', context });
}
// ════════════════════════════════════════════════════════════════════
@@ -166,4 +171,40 @@ router.get('/payment-methods', async (req, res) => {
} catch (err) { handleQboError(err, res, 'payment-methods'); }
});
+// ─── POST /api/accounting/vendors ───────────────────────────────────
+// Erstellt einen neuen Vendor in QBO + Cache.
+// Body: { name, email?, phone?, address?: {...}, notes? }
+// Status: 200 { id, displayName, ..., existed: false }
+// 200 { ..., existed: true } ← Idempotent: Vendor war schon da
+// 400 wenn name fehlt
+router.post('/vendors', express.json(), async (req, res) => {
+ try {
+ const result = await accountingService.createVendor(req.body || {});
+ res.json(result);
+ } catch (err) { handleQboError(err, res, 'vendor-create'); }
+});
+
+// ─── POST /api/accounting/expenses ──────────────────────────────────
+// Erstellt eine QBO Purchase (Expense) mit ein oder mehreren Lines.
+// Body: { vendorId, paymentAccountId, txnDate, paymentMethodId?, refNo?, memo?, lines: [...] }
+router.post('/expenses', express.json(), async (req, res) => {
+ try {
+ const result = await accountingService.createExpense(req.body || {});
+ res.json(result);
+ } catch (err) { handleQboError(err, res, 'expense-create'); }
+});
+
+// ─── GET /api/accounting/expenses ───────────────────────────────────
+// ?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&onlyMine=true|false
+router.get('/expenses', async (req, res) => {
+ try {
+ const expenses = await accountingService.listExpenses({
+ startDate: req.query.startDate,
+ endDate: req.query.endDate,
+ onlyMine: req.query.onlyMine === 'true'
+ });
+ res.json(expenses);
+ } catch (err) { handleQboError(err, res, 'expense-list'); }
+});
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js
index a623bae..2b9be41 100644
--- a/src/services/accounting-service.js
+++ b/src/services/accounting-service.js
@@ -600,6 +600,389 @@ async function getPaymentMethods({ activeOnly = 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
+ };
+}
+
+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
+ };
+}
+
+
+
// ════════════════════════════════════════════════════════════════════
// Exports
// ════════════════════════════════════════════════════════════════════
@@ -624,6 +1007,11 @@ module.exports = {
getPaymentAccountsFromCache,
getPaymentMethods,
+ // Phase 2 Lieferung 2 — Mutations + List
+ createVendor,
+ createExpense,
+ listExpenses,
+
// Audit
writeAuditLog
};
\ No newline at end of file