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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user