diff --git a/public/js/modals/expense-modal.js b/public/js/modals/expense-modal.js
index a703853..450ebfc 100644
--- a/public/js/modals/expense-modal.js
+++ b/public/js/modals/expense-modal.js
@@ -33,6 +33,7 @@ let isSaving = false;
let selectedFile = null; // File object aus
let attachmentLimits = null; // { maxBytes, maxMb, allowedMimeTypes, allowedExtensions }
+let editingExpenseId = null; // null = Create-Modus, sonst = Purchase-ID im Edit-Modus
// ────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────
@@ -138,14 +139,14 @@ function renderFilePreview() {
// Public Entry
// ────────────────────────────────────────────────────────────────────
-export async function openExpenseModal({ onSaved } = {}) {
+export async function openExpenseModal({ onSaved, expense = null } = {}) {
onSavedCb = onSaved || null;
selectedVendorId = null;
selectedVendorName = '';
lineCount = 0;
isSaving = false;
+ editingExpenseId = expense ? expense.id : null;
- // Lade Stammdaten parallel (alles aus dem Cache → schnell)
try {
[vendors, expenseAccounts, paymentAccounts, paymentMethods, attachmentLimits] = await Promise.all([
window.API.accounting.getVendors('', 1000),
@@ -155,7 +156,6 @@ export async function openExpenseModal({ onSaved } = {}) {
window.API.accounting.getAttachmentLimits()
]);
- // Errors aus dem Backend abfangen
for (const [name, data] of [['vendors', vendors], ['expenseAccounts', expenseAccounts], ['paymentAccounts', paymentAccounts], ['paymentMethods', paymentMethods]]) {
if (data && data.error) {
alert(`Failed to load ${name}: ${data.error}\n\nTry "Sync from QBO" first.`);
@@ -168,6 +168,11 @@ export async function openExpenseModal({ onSaved } = {}) {
}
renderModal();
+
+ // Im Edit-Modus: Formular mit bestehenden Daten befüllen
+ if (expense) {
+ prefillForm(expense);
+ }
}
function closeModal() {
@@ -192,6 +197,43 @@ function resetForm() {
updateTotal();
}
+function prefillForm(expense) {
+ // Vendor
+ if (expense.vendorId) {
+ selectVendor(expense.vendorId, expense.vendorName || expense.vendorId);
+ }
+ // Payment Account
+ if (expense.accountId) {
+ const pa = document.getElementById('exp-payment-account');
+ if (pa) pa.value = expense.accountId;
+ }
+ // Datum
+ if (expense.txnDate) {
+ document.getElementById('exp-date').value = expense.txnDate;
+ }
+ // Payment Method
+ if (expense.paymentMethodId) {
+ const pm = document.getElementById('exp-payment-method');
+ if (pm) pm.value = expense.paymentMethodId;
+ }
+ // Ref / Memo
+ if (expense.refNo) document.getElementById('exp-ref-no').value = expense.refNo;
+ if (expense.memo) document.getElementById('exp-memo').value = expense.memo;
+
+ // Lines — bestehende Zeilen ersetzen
+ document.getElementById('exp-lines-tbody').innerHTML = '';
+ lineCount = 0;
+ const lines = expense.lines && expense.lines.length ? expense.lines : [{}];
+ lines.forEach(l => {
+ addLine();
+ const rows = document.querySelectorAll('#exp-lines-tbody tr');
+ const tr = rows[rows.length - 1];
+ if (l.accountId) tr.querySelector('.exp-line-account').value = l.accountId;
+ if (l.description) tr.querySelector('.exp-line-desc').value = l.description;
+ if (l.amount != null) tr.querySelector('.exp-line-amount').value = l.amount;
+ });
+ updateTotal();
+}
// ────────────────────────────────────────────────────────────────────
// Render
// ────────────────────────────────────────────────────────────────────
@@ -203,7 +245,7 @@ function renderModal() {
-
📝 New Expense
+
${editingExpenseId ? '✏️ Edit Expense' : '📝 New Expense'}
-
-
+ ${editingExpenseId ? '' : `
+ `}
@@ -676,11 +718,13 @@ async function save(mode) {
setButtonsDisabled(true);
try {
- const result = await window.API.accounting.createExpense(payload);
- if (result.error) {
- showError(result.error);
- return;
- }
+ const result = editingExpenseId
+ ? await window.API.accounting.updateExpense(editingExpenseId, payload)
+ : await window.API.accounting.createExpense(payload);
+ if (result.error) {
+ showError(result.error);
+ return;
+ }
// Attachment hochladen, falls vorhanden
if (selectedFile && result.id) {
@@ -728,7 +772,7 @@ function setButtonsDisabled(disabled, customText) {
if (b) b.disabled = disabled;
const text = customText || (disabled ? 'Saving…' : null);
if (a) a.textContent = text || 'Save & New';
- if (b) b.textContent = text || 'Save & Close';
+ if (b) b.textContent = text || (editingExpenseId ? 'Save Changes' : 'Save & Close');
}
function showError(msg) {
diff --git a/public/js/utils/api.js b/public/js/utils/api.js
index 311a5eb..0eab879 100644
--- a/public/js/utils/api.js
+++ b/public/js/utils/api.js
@@ -169,6 +169,12 @@ const API = {
body: JSON.stringify(data)
}).then(r => r.json()),
+ updateExpense: (id, data) => fetch(`/api/accounting/expenses/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ }).then(r => r.json()),
+
attachToExpense: (expenseId, file, note) => {
const fd = new FormData();
fd.append('file', file);
diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js
index c7182d5..2684745 100644
--- a/public/js/views/accounting-view.js
+++ b/public/js/views/accounting-view.js
@@ -623,6 +623,11 @@ function renderExpensesTable(expenses) {
`
: escapeHtml(e.lines[0]?.accountName || '');
+ const editBtn = expOnlyMine
+ ? `
`
+ : `
—`;
+
return `
| ${escapeHtml(e.txnDate || '')} |
@@ -632,6 +637,7 @@ function renderExpensesTable(expenses) {
${escapeHtml(e.refNo || '')} |
${escapeHtml(e.memo || '')} |
${fmtMoney(e.totalAmt)} |
+ ${editBtn} |
`;
}).join('');
@@ -647,6 +653,7 @@ function renderExpensesTable(expenses) {
Ref |
Memo |
Amount |
+
Action |
${tbody}
@@ -683,7 +690,19 @@ export function refreshAll() {
loadAccountsOverview();
if (registerAccountId) loadRegister();
}
-
+export async function editExpense(expenseJson) {
+ let expense;
+ try {
+ expense = typeof expenseJson === 'string' ? JSON.parse(expenseJson) : expenseJson;
+ } catch (e) {
+ alert('Could not open expense for editing.');
+ return;
+ }
+ await openExpenseModal({
+ expense,
+ onSaved: () => loadExpenses()
+ });
+}
window.accountingView = {
renderAccountingView,
refreshAll,
@@ -695,5 +714,6 @@ window.accountingView = {
loadBalanceSheet,
loadExpenses,
openNewExpense,
+ editExpense,
toggleSection
};
\ No newline at end of file
diff --git a/src/routes/accounting.js b/src/routes/accounting.js
index 21c4bd6..291ab7d 100644
--- a/src/routes/accounting.js
+++ b/src/routes/accounting.js
@@ -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,
diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js
index d4d4c0a..7a7f469 100644
--- a/src/services/accounting-service.js
+++ b/src/services/accounting-service.js
@@ -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