From 3b70a09773f22e942ae80962b9b69a7712b08ff3 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:20:55 +0200 Subject: [PATCH] fix(accounting): 'Save & mark paid' actually pays; incoming invoices appear in tax/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 Triage 'Save & mark paid' now marks the invoice paid directly (categorize + markInboundPaid with the entered reference) instead of opening the pay dialog and leaving it unpaid. Removed the PayModal chain. #2 Cost side missed captured incoming invoices: the query required currency='CHF', but email/upload invoices often have a null currency → silently excluded. Now include null-currency rows (treated as the report currency). Also replaced COALESCE(invoice_date, created_at) with a split date filter (invoice_date BETWEEN, else created_at range) to avoid the mixed date/timestamp comparison risk on Postgres. Same fix in the ledger export (buildPostings). en/de: categorizedPaidToast. --- backend/src/services/ledgerService.js | 11 ++++++-- backend/src/services/taxReportService.js | 17 +++++++++-- frontend/src/i18n/locales/de.json | 3 +- frontend/src/i18n/locales/en.json | 3 +- .../admin/accounting/AccountingInboxPage.tsx | 28 +++++++++---------- 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/backend/src/services/ledgerService.js b/backend/src/services/ledgerService.js index f1ac83f7..363c7ff8 100644 --- a/backend/src/services/ledgerService.js +++ b/backend/src/services/ledgerService.js @@ -297,10 +297,15 @@ async function buildPostings({ from, to, currency } = {}) { .modify((q) => { if (hasCatCol) q.leftJoin('expense_categories', 'inbound_documents.category_id', 'expense_categories.id'); }) - .whereRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) >= ? AND COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) <= ?', [from, toEnd]) - .where('inbound_documents.currency', cur) + .where((qb) => { + qb.whereBetween('inbound_documents.invoice_date', [from, to]) + .orWhere((q2) => q2.whereNull('inbound_documents.invoice_date') + .andWhere('inbound_documents.created_at', '>=', from) + .andWhere('inbound_documents.created_at', '<=', toEnd)); + }) + .where((qb) => { qb.where('inbound_documents.currency', cur).orWhereNull('inbound_documents.currency'); }) .whereNotIn('inbound_documents.status', ['declined', 'duplicate']) - .orderByRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) asc') + .orderBy('inbound_documents.created_at', 'asc') .select( 'inbound_documents.id', 'inbound_documents.invoice_number', 'inbound_documents.invoice_date', 'inbound_documents.created_at', 'inbound_documents.supplier_name', 'inbound_documents.tax_treatment', diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js index cbbbd088..f0592d2c 100644 --- a/backend/src/services/taxReportService.js +++ b/backend/src/services/taxReportService.js @@ -213,10 +213,21 @@ async function loadCosts({ from, to, cur }) { if (await db.schema.hasTable('inbound_documents')) { const inbound = await db('inbound_documents') .leftJoin('events', 'inbound_documents.event_id', 'events.id') - .whereRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) >= ? AND COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) <= ?', [from, toEnd]) - .where('inbound_documents.currency', cur) + // Date in range: invoice_date (a DATE) when set, else created_at (a + // TIMESTAMP). Split instead of COALESCE so we never compare mixed + // date/timestamp types (a Postgres error the mocked tests can't see). + .where((qb) => { + qb.whereBetween('inbound_documents.invoice_date', [from, to]) + .orWhere((q2) => q2.whereNull('inbound_documents.invoice_date') + .andWhere('inbound_documents.created_at', '>=', from) + .andWhere('inbound_documents.created_at', '<=', toEnd)); + }) + // Currency match, but INCLUDE rows with no currency set — captured + // invoices (email/upload) often have a null currency; treat them as the + // report currency rather than silently dropping them from the cost side. + .where((qb) => { qb.where('inbound_documents.currency', cur).orWhereNull('inbound_documents.currency'); }) .whereNotIn('inbound_documents.status', ['declined', 'duplicate']) - .orderByRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) asc') + .orderBy('inbound_documents.created_at', 'asc') .select( 'inbound_documents.id', 'inbound_documents.invoice_date', diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 89256a55..b36e5ca4 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3567,7 +3567,8 @@ "confirmPaid": "Als bezahlt markieren", "paid": "Bezahlt", "paidToast": "Als bezahlt markiert.", - "categorizedToast": "Kategorisiert." + "categorizedToast": "Kategorisiert.", + "categorizedPaidToast": "Kategorisiert und als bezahlt markiert." }, "expense": { "kind": "Art", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9d5fcae1..c2a455d0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3567,7 +3567,8 @@ "confirmPaid": "Mark paid", "paid": "Paid", "paidToast": "Marked as paid.", - "categorizedToast": "Categorized." + "categorizedToast": "Categorized.", + "categorizedPaidToast": "Categorized and marked paid." }, "expense": { "kind": "Type", diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index b60e1c84..fb97ac83 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -164,7 +164,7 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do ); }; -const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: (pay?: boolean) => void }> = ({ doc, categories, onClose, onDone }) => { +const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => { const { t } = useTranslation(); const [supplier, setSupplier] = useState(doc.supplierName || ''); const [amountMajor, setAmountMajor] = useState(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN); @@ -186,10 +186,11 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, }); - // `pay` is threaded through as the mutation variable so onSuccess can decide - // whether to continue into the mark-paid step (#5). + // `pay` decides whether to also mark the supplier invoice paid in the same + // step (#5/#1). When true we mark it paid directly (using the reference + // entered) — no second dialog — so "Save & mark paid" actually pays. const save = useMutation({ - mutationFn: async (_pay: boolean) => { + mutationFn: async (pay: boolean) => { await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null }); await accountingService.categorizeInbound(doc.id, { disposition, @@ -198,8 +199,14 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null, ...markupPayload(), }); + if (pay) { + await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined }); + } + }, + onSuccess: (_data, pay) => { + toast.success(pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.')); + onDone(); }, - onSuccess: (_data, pay) => { toast.success(t('accounting.incoming.categorizedToast', 'Categorized.')); onDone(pay); }, onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), }); @@ -304,16 +311,7 @@ export const AccountingInboxPage: React.FC = () => { }; const refresh = () => qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); - // #5: after categorizing, optionally continue straight into the mark-paid - // dialog (re-fetch so the reference just entered prefills the pay form). - const handleTriageDone = async (pay?: boolean) => { - const id = triageDoc?.id; - setTriageDoc(null); - refresh(); - if (pay && id != null) { - try { setPayDoc(await accountingService.getInbound(id)); } catch (_e) { /* leave it categorized */ } - } - }; + const handleTriageDone = () => { setTriageDoc(null); refresh(); }; const items = data?.items ?? [];