From 9a023c019750ebcd8d21e005aaf9a77a32cb34a3 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:44:18 +0200 Subject: [PATCH] feat(accounting): explain dispositions inline, drop markup from pass-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a per-disposition info line under the Disposition dropdown so re-bill vs pass-through vs company expense is clear in-context (en + de). - Markup is a re-bill concept only: the control now renders solely for rebill, and a pass-through always bills at cost. Enforced server-side too (categorizeInbound applies markup only when disposition === 'rebill'). - Clarify "Book to" with a hint — it attributes the supplier cost to an event in the tax report / ledger export, separate from who you re-bill to. --- backend/src/services/expenseService.js | 11 ++++--- frontend/src/i18n/locales/de.json | 12 ++++++-- frontend/src/i18n/locales/en.json | 12 ++++++-- .../admin/accounting/AccountingInboxPage.tsx | 29 +++++++++++++------ 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js index 377143f7..7ee5ccb5 100644 --- a/backend/src/services/expenseService.js +++ b/backend/src/services/expenseService.js @@ -375,7 +375,10 @@ async function categorizeInbound(id, payload, adminId) { // #1: unwind any prior re-bill so the disposition can change. if (doc.billedInvoiceId) await unwindBilledLine(trx, doc); - const markup = billsToCustomer + // Markup is a re-bill concept only. A pass-through (durchlaufender Posten) + // is invoiced at cost / VAT-neutral, so it never carries a markup. + const appliesMarkup = disposition === 'rebill'; + const markup = appliesMarkup ? await resolveMarkup( { markupType: payload.markupType, markupPercent: payload.markupPercent, markupFlatMinor: payload.markupFlatMinor }, payload, payload.contractId, trx, @@ -388,9 +391,9 @@ async function categorizeInbound(id, payload, adminId) { event_id: BOOKING_DISPOSITIONS.includes(disposition) ? (payload.eventId || null) : null, category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null, customer_account_id: customerAccountId, - markup_type: billsToCustomer ? markup.type : 'none', - markup_percent: billsToCustomer && markup.type === 'percent' ? markup.percent : null, - markup_flat_minor: billsToCustomer && markup.type === 'flat' ? markup.flatMinor : null, + markup_type: appliesMarkup ? markup.type : 'none', + markup_percent: appliesMarkup && markup.type === 'percent' ? markup.percent : null, + markup_flat_minor: appliesMarkup && markup.type === 'flat' ? markup.flatMinor : null, // Cleared here; re-set by billInboundNow when we bill immediately. billed_invoice_id: null, billed_invoice_line_item_id: null, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0d341664..6b5e7689 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3502,7 +3502,14 @@ "durchlaufend": "Durchlaufender Posten", "eigener_aufwand": "Eigener Aufwand", "duplikat": "Duplikat", - "abgelehnt": "Abgelehnt" + "abgelehnt": "Abgelehnt", + "help": { + "rebill": "Eigene Lieferantenkosten, die du an einen Kunden weiterverrechnest — meist mit Zuschlag. Wird als Kosten und als weiterverrechneter Ertrag gebucht.", + "durchlaufend": "Ein Betrag, den du nur im Namen des Kunden vorstreckst und exakt durchreichst — kein Zuschlag, MwSt-neutral (durchlaufender Posten). Kunde zuordnen, um ihn zum Selbstkostenpreis weiterzuverrechnen.", + "eigener_aufwand": "Eigene Kosten, die nicht weiterverrechnet werden. Kategorie wählen, damit der Posten richtig in der Erfolgsrechnung landet.", + "duplikat": "Duplikat einer bereits erfassten Rechnung — wird nicht verbucht.", + "abgelehnt": "Dokument ablehnen — wird nicht verbucht." + } }, "markup": { "none": "Keiner / aus Vertrag", @@ -3576,7 +3583,8 @@ "label": "Buchen auf", "company": "Firma", "event": "Event", - "eventId": "Event-ID" + "eventId": "Event-ID", + "inboundHint": "Auf welches Event diese Kosten in Auswertungen & Steuerexport entfallen (Firma = allgemeiner Aufwand). Unabhängig davon, an wen du weiterverrechnest." }, "incoming": { "triageTitle": "Eingangsrechnung kategorisieren", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a1976667..bf7d8828 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3502,7 +3502,14 @@ "durchlaufend": "Pass-through", "eigener_aufwand": "Company expense", "duplikat": "Duplicate", - "abgelehnt": "Declined" + "abgelehnt": "Declined", + "help": { + "rebill": "Your own supplier cost that you invoice on to a client — usually with a markup. Booked as both a cost and re-billed revenue.", + "durchlaufend": "An amount you only front on behalf of a client and pass through at the exact figure — no markup, VAT-neutral (durchlaufender Posten). Attach the client to re-bill it at cost.", + "eigener_aufwand": "Your own cost, not re-billed to anyone. Pick a category so it lands in the right place in your P&L.", + "duplikat": "A duplicate of an invoice you already captured — excluded from the books.", + "abgelehnt": "Decline this document — excluded from the books." + } }, "markup": { "none": "None / from contract", @@ -3576,7 +3583,8 @@ "label": "Book to", "company": "Company", "event": "Event", - "eventId": "Event ID" + "eventId": "Event ID", + "inboundHint": "Which event carries this cost in your reports & tax export (Company = general overhead). This is separate from who you re-bill it to." }, "incoming": { "triageTitle": "Categorize incoming invoice", diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index a1397a7b..ff95c061 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -212,7 +212,8 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null, // Both rebill and passthrough can attach to a customer (#3). customerAccountId: BOOKING_DISPOSITIONS.includes(disposition) && customer[0] ? customer[0].id : null, - ...markupPayload(), + // Markup is a re-bill concept only — a pass-through bills at cost. + ...(disposition === 'rebill' ? markupPayload() : { markupType: 'none', markupPercent: null, markupFlatMinor: null }), }); if (pay) { await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined }); @@ -252,12 +253,18 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ + {/* Explain the selected disposition — re-bill vs pass-through vs + company expense aren't obvious from the labels alone. */} +

+ {t(`accounting.disposition.help.${disposition}`, '')} +

{BOOKING_DISPOSITIONS.includes(disposition) && (
+

{t('accounting.booking.inboundHint', 'Which event carries this cost in your reports & tax export (Company = general overhead). This is separate from who you re-bill it to.')}

)} @@ -276,14 +283,18 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ setCustomer(next.slice(-1))} /> {disposition === 'durchlaufend' &&

{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}

} -
- -
- {markupType !== 'none' && } + {/* Markup is a re-bill concept only. A pass-through is invoiced + at cost (VAT-neutral), so no markup control here. */} + {disposition === 'rebill' && (<> +
+ +
+ {markupType !== 'none' && } + )} )}