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
@@ -0,0 +1,24 @@
/**
* Migration 144: track the VAT portion of the Mahngebühr separately.
*
* The dunning rework keeps the fee on the invoice ROW as dunning state (gross
* in late_fee_amount_minor) but renders it on a separate Mahnung document, NOT
* on the immutable invoice. `late_fee_vat_minor` records the VAT component
* (0 when VAT-exempt — DE/AT, or the org has no VAT) so the Mahnung can show
* the breakdown and the tax report can later book the Mahngebühr VAT (CH).
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
if (!(await knex.schema.hasColumn('invoices', 'late_fee_vat_minor'))) {
await knex.schema.alterTable('invoices', (t) => {
t.bigInteger('late_fee_vat_minor').notNullable().defaultTo(0);
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
if (await knex.schema.hasColumn('invoices', 'late_fee_vat_minor')) {
await knex.schema.alterTable('invoices', (t) => t.dropColumn('late_fee_vat_minor'));
}
};