update expense
This commit is contained in:
@@ -268,6 +268,15 @@ router.post('/expenses/:id/attach', (req, res, next) => {
|
||||
res.json(result);
|
||||
} catch (err) { handleQboError(err, res, 'attach'); }
|
||||
});
|
||||
// ─── PUT /api/accounting/expenses/:id ───────────────────────────────
|
||||
// Aktualisiert eine bestehende QBO Purchase (Expense).
|
||||
// Body wie POST /expenses: { vendorId, paymentAccountId, txnDate, paymentMethodId?, refNo?, memo?, lines: [...] }
|
||||
router.put('/expenses/:id', express.json(), async (req, res) => {
|
||||
try {
|
||||
const result = await accountingService.updateExpense(req.params.id, req.body || {});
|
||||
res.json(result);
|
||||
} catch (err) { handleQboError(err, res, 'expense-update'); }
|
||||
});
|
||||
router.get('/attachments/limits', (req, res) => {
|
||||
res.json({
|
||||
maxBytes: ATTACHMENT_MAX_BYTES,
|
||||
|
||||
@@ -898,6 +898,152 @@ async function createExpense(data) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -1121,6 +1267,7 @@ module.exports = {
|
||||
// Phase 2 Lieferung 2 — Mutations + List
|
||||
createVendor,
|
||||
createExpense,
|
||||
updateExpense,
|
||||
listExpenses,
|
||||
|
||||
// Phase 2 Lieferung 3
|
||||
|
||||
Reference in New Issue
Block a user