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