feat(crm): Mahngebühr on a separate Mahnung document; invoice stays immutable

Corrected dunning model (Mara): a Mahnung is a reminder LETTER showing the new
total (original + Mahngebühr), NOT a separate invoice and NOT a mutation of the
issued invoice.

- The invoice PDF no longer shows the fee (buildInvoiceRenderContext reports
  lateFeeAmountMinor 0) and is NEVER re-rendered by a reminder — it stays
  immutable (§14/§11).
- applyReminder now: tracks the fee as dunning state on the row (gross
  late_fee_amount_minor + new late_fee_vat_minor for the VAT portion, migration
  144), renders a separate MAHNUNG PDF (pdfService 'mahnung' kind — reuses the
  invoice layout: same lines + Mahngebühr row + new total, 'Mahnung' title, no
  QR), stored under storage/business-docs/mahnung/, and attaches BOTH the
  unchanged original invoice + the Mahnung to the reminder email.
- Fee resolvers split into net + VAT-rate (toggle + org-rate gated); a gross
  wrapper feeds the payment-check preview. en + de PDF title.

Outstanding/collections still read late_fee_amount_minor (now dunning state).
P3 (tax-report/Banana booking of the Mahngebühr VAT) stays Treuhänder-gated.
Syntax + 17/17 workflow/invoice tests green.

NOTE: the Mahnung PDF render path isn't unit-tested (PDF rendering is flaky in
the test env) — eyeball on the dev box: fire a level-2 reminder, confirm the
Mahnung PDF shows the new total and the original invoice PDF is unchanged.
This commit is contained in:
Luca
2026-06-23 13:08:19 +02:00
parent eaceb7e71c
commit 5ed2fec2fe
4 changed files with 107 additions and 69 deletions
+8 -2
View File
@@ -1477,6 +1477,10 @@ function renderDocument(type, context) {
// family — Storni share the invoice renderer surface, only
// the cosmetic + accounting-sign branches differ.
const isStorno = type === 'invoice' && ctx.doc.kind === 'storno';
// Mahnung (reminder letter) reuses the invoice surface: same line items +
// a Mahngebühr row + the new grand total, but a "Mahnung" title and NO
// QR (the QR would encode the original amount, not the new total).
const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung';
// ---- document number (above) + date (below), both right-aligned
// The number sits directly under the sender address block so the
@@ -1516,7 +1520,9 @@ function renderDocument(type, context) {
? t(ctx.locale, 'quote_title')
: isStorno
? t(ctx.locale, 'storno_title')
: t(ctx.locale, 'invoice_title');
: isMahnung
? t(ctx.locale, 'mahnung_title')
: t(ctx.locale, 'invoice_title');
y = drawTitle(doc, title, leftX, y + 2);
// Mandatory Storno reference line — "Bezug: Storno zu Rechnung
@@ -1700,7 +1706,7 @@ function renderDocument(type, context) {
// Both append a fresh page; 'none' is a no-op.
// Suppressed on Stornorechnungen — negative-amount QR codes
// aren't a defined construct in either spec.
if (type === 'invoice' && !isStorno) {
if (type === 'invoice' && !isStorno && !isMahnung) {
if (ctx.qrFormat === 'swiss') {
appendSwissQrBill(doc, ctx);
} else if (ctx.qrFormat === 'epc') {