feat(accounting): explain dispositions inline, drop markup from pass-through

- 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.
This commit is contained in:
Luca
2026-06-18 12:44:18 +02:00
parent 36a8e42f90
commit 9a023c0197
4 changed files with 47 additions and 17 deletions
+7 -4
View File
@@ -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,
+10 -2
View File
@@ -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",
+10 -2
View File
@@ -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",
@@ -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[
<select value={disposition} onChange={(e) => setDisposition(e.target.value as Disposition)} className={selectCls}>
{DISPOSITIONS.map((d) => <option key={d} value={d}>{t(`accounting.disposition.${d}`, d)}</option>)}
</select>
{/* Explain the selected disposition — re-bill vs pass-through vs
company expense aren't obvious from the labels alone. */}
<p className="mt-1 rounded-md bg-neutral-50 dark:bg-neutral-800/60 px-2.5 py-1.5 text-xs text-neutral-600 dark:text-neutral-400">
{t(`accounting.disposition.help.${disposition}`, '')}
</p>
</div>
{BOOKING_DISPOSITIONS.includes(disposition) && (
<div>
<label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
<EventBookingSelect value={eventId} onChange={setEventId} className={selectCls} />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{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.')}</p>
</div>
)}
@@ -276,14 +283,18 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} />
{disposition === 'durchlaufend' && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}</p>}
</div>
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
</select>
</div>
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
{/* Markup is a re-bill concept only. A pass-through is invoiced
at cost (VAT-neutral), so no markup control here. */}
{disposition === 'rebill' && (<>
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
</select>
</div>
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
</>)}
</div>
)}
</div>