expenses
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user