This commit is contained in:
2026-05-25 14:29:24 -05:00
parent f535f35eee
commit 7e490dbc93
5 changed files with 452 additions and 2 deletions

View File

@@ -277,6 +277,15 @@ router.put('/expenses/:id', express.json(), async (req, res) => {
res.json(result);
} catch (err) { handleQboError(err, res, 'expense-update'); }
});
// ─── POST /api/accounting/refunds ───────────────────────────────────
// Erstellt einen QBO Deposit für einen Vendor-Refund (Geld kam zurück).
// Body: { vendorId, depositAccountId, categoryAccountId, txnDate, amount, refNo?, memo? }
router.post('/refunds', express.json(), async (req, res) => {
try {
const result = await accountingService.createRefund(req.body || {});
res.json(result);
} catch (err) { handleQboError(err, res, 'refund-create'); }
});
router.get('/attachments/limits', (req, res) => {
res.json({
maxBytes: ATTACHMENT_MAX_BYTES,

View File

@@ -898,6 +898,144 @@ async function createExpense(data) {
};
}
/**
* Erstellt einen QBO Deposit für einen Vendor-Refund (Geld kam zurück aufs Konto).
*
* Buchhalterisch: Der Refund wird gegen die ursprüngliche Expense-Kategorie
* gebucht, wodurch sich der Aufwand in dieser Kategorie reduziert.
*
* @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;
// ── QBO Deposit Payload ──
const 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 },
Entity: { value: data.vendorId, type: 'Vendor' }
}
}]
};
if (data.refNo) payload.Line[0].DepositLineDetail.CheckNum = String(data.refNo).slice(0, 21);
if (data.memo) payload.PrivateNote = String(data.memo);
const { companyId, baseUrl } = getClientInfo();
const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/deposit`);
const requestSummary = `REFUND | ${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: 'Deposit',
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: 'Deposit',
status: 'error',
requestExcerpt: requestSummary,
responseExcerpt: msg
});
const err = new Error('QBO Deposit (refund) create failed: ' + msg);
err.qboFault = qboResponse.Fault;
throw err;
}
const deposit = qboResponse.Deposit;
if (!deposit || !deposit.Id) throw new Error('QBO returned no Deposit id');
await writeAuditLog({
action: 'refund.create',
entityType: 'Deposit',
entityQboId: deposit.Id,
status: 'success',
requestExcerpt: requestSummary,
responseExcerpt: `Deposit ${deposit.Id} created, total $${Number(deposit.TotalAmt).toFixed(2)}`
});
console.log(`✅ QBO Refund recorded: Deposit ${deposit.Id}${requestSummary}`);
return {
id: deposit.Id,
txnDate: deposit.TxnDate,
totalAmt: Number(deposit.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
@@ -1268,6 +1406,7 @@ module.exports = {
createVendor,
createExpense,
updateExpense,
createRefund,
listExpenses,
// Phase 2 Lieferung 3