Add Kleinunternehmerregelung (§19 UStG) support
Validate e-invoices / mustang (push) Successful in 43s

InvoiceOrder/CorrectionInvoiceOrder gain an optional order-level
kleinunternehmer flag, snapshotted per order (same pattern as vatExempt)
so a later toggle of the tenant's setting never rewrites an
already-issued invoice. Takes precedence over vatExempt.

- Visual PDF: "enthält X% MwSt." becomes the §19 UStG notice.
- E-invoice XML: new UNTDID 5305 category "E" with a free-text exemption
  reason (no VATEX code — §19 UStG has none, BR-E-10 allows text alone).
- New Mustang CI fixture + buildEInvoiceData unit test for the "E" path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-24 12:51:13 +00:00
parent c28ceb7bd3
commit 51ae8a9c0a
7 changed files with 202 additions and 38 deletions
+50 -30
View File
@@ -23,17 +23,33 @@ type BuyerCountryCode = UblInvoice["cac:AccountingCustomerParty"]["cac:Party"]["
// summed group uses this same code.
const VAT_CATEGORY = "S";
// UNTDID 5305 code 'K' — "VAT exempt for EEA intra-community supply of
// goods and services" — the EN16931 category for an innergemeinschaftliche
// Lieferung (§4 Nr. 1b UStG / Art. 138 VAT Directive), distinct from 'S'
// (positive standard rate), 'Z' (zero-rated but still taxable), and 'AE'
// (domestic reverse charge under §13b UStG — doesn't apply here at all,
// see the frontend repo's lib/vatExemption.ts for why this shop's checkout
// only ever produces this 'K' exemption for a cross-border validated B2B
// sale, never 'AE').
function vatTaxCategory(rate: number, vatExempt: boolean): Record<string, unknown> {
// Which of this shop's three mutually-exclusive VAT treatments an
// invoice/correction-invoice was issued under — 'standard' (normal
// Regelbesteuerung), 'intra-community' (validated cross-border B2B
// exemption, §4 Nr. 1b UStG), or 'kleinunternehmer' (§19 UStG small
// business — the seller never charges VAT at all, on any sale). Derived
// once per document from the order's own snapshotted
// vatExempt/kleinunternehmer flags (see buildEInvoiceData/
// buildCorrectionEInvoiceData below) and threaded through every function
// in this file that used to take a plain `vatExempt: boolean` — a
// Kleinunternehmer sale is never also an intra-community exemption
// (there's no VAT to exempt in the first place), so this is a 3-way
// choice, not two independent booleans.
type VatMode = "standard" | "intra-community" | "kleinunternehmer";
// UNTDID 5305 codes: 'K' — "VAT exempt for EEA intra-community supply of
// goods and services" (§4 Nr. 1b UStG / Art. 138 VAT Directive). 'E' —
// "Exempt from tax", used here for §19 UStG Kleinunternehmer — Germany's
// small-business exemption is a national provision, not one of the
// EU-wide reasons that has its own dedicated UNTDID/VATEX category, so
// 'E' (the general exemption code) plus a free-text reason (BT-120, see
// vatTaxSubtotalCategory below) is the correct EN16931 shape rather than
// forcing it into 'K'. Distinct from 'S' (positive standard rate) and 'Z'
// (zero-rated but still taxable) — neither applies to either exemption.
function vatTaxCategory(rate: number, vatMode: VatMode): Record<string, unknown> {
const id = vatMode === "kleinunternehmer" ? "E" : vatMode === "intra-community" ? "K" : VAT_CATEGORY;
return {
"cbc:ID": vatExempt ? "K" : VAT_CATEGORY,
"cbc:ID": id,
"cbc:Percent": String(rate),
"cac:TaxScheme": { "cbc:ID": "VAT" },
};
@@ -48,13 +64,17 @@ function vatTaxCategory(rate: number, vatExempt: boolean): Record<string, unknow
// generated type despite conceptually being "the same" UBL TaxCategory
// complex type) — caught locally before ever reaching Mustang/CI, exactly
// the kind of runtime-only-visible constraint this pipeline has hit before
// (see amtCents()'s own comment). BR-K-10 requires either field whenever
// category is 'K', satisfied here since this is the one place they're
// actually allowed to live.
function vatTaxSubtotalCategory(rate: number, vatExempt: boolean): Record<string, unknown> {
// (see amtCents()'s own comment). BR-K-10/BR-E-10 require either field
// whenever category is 'K'/'E', satisfied here since this is the one place
// they're actually allowed to live. §19 UStG has no EU-wide VATEX code (a
// purely national exemption reason) — BR-E-10 accepts the free-text
// TaxExemptionReason alone without a TaxExemptionReasonCode, so
// kleinunternehmer only sets the text, unlike intra-community's VATEX-EU-IC.
function vatTaxSubtotalCategory(rate: number, vatMode: VatMode): Record<string, unknown> {
return {
...vatTaxCategory(rate, vatExempt),
...(vatExempt ? { "cbc:TaxExemptionReasonCode": "VATEX-EU-IC", "cbc:TaxExemptionReason": "Innergemeinschaftliche Lieferung" } : {}),
...vatTaxCategory(rate, vatMode),
...(vatMode === "intra-community" ? { "cbc:TaxExemptionReasonCode": "VATEX-EU-IC", "cbc:TaxExemptionReason": "Innergemeinschaftliche Lieferung" } : {}),
...(vatMode === "kleinunternehmer" ? { "cbc:TaxExemptionReason": "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." } : {}),
};
}
@@ -254,7 +274,7 @@ function computeRateGroups(lines: ComputedLine[], discountAmount: number, shippi
.sort((a, b) => b.rate - a.rate);
}
function taxTotal(rateGroups: RateGroup[], vatExempt: boolean): UblInvoice["cac:TaxTotal"] {
function taxTotal(rateGroups: RateGroup[], vatMode: VatMode): UblInvoice["cac:TaxTotal"] {
const totalTaxCents = rateGroups.reduce((sum, g) => sum + g.taxCents, 0);
return [
{
@@ -262,13 +282,13 @@ function taxTotal(rateGroups: RateGroup[], vatExempt: boolean): UblInvoice["cac:
"cac:TaxSubtotal": rateGroups.map((g) => ({
...amtCents("cbc:TaxableAmount", g.taxableCents),
...amtCents("cbc:TaxAmount", g.taxCents),
"cac:TaxCategory": vatTaxSubtotalCategory(g.rate, vatExempt),
"cac:TaxCategory": vatTaxSubtotalCategory(g.rate, vatMode),
})),
},
] as unknown as UblInvoice["cac:TaxTotal"];
}
function allowanceCharges(rateGroups: RateGroup[], vatExempt: boolean): UblInvoice["cac:AllowanceCharge"] {
function allowanceCharges(rateGroups: RateGroup[], vatMode: VatMode): UblInvoice["cac:AllowanceCharge"] {
// Loosely typed here (build as Record<string, unknown>, cast once at the
// return boundary) — VAT_CATEGORY's literal type widens to plain
// `string` the moment it's read through an intermediate variable, which
@@ -277,7 +297,7 @@ function allowanceCharges(rateGroups: RateGroup[], vatExempt: boolean): UblInvoi
// exact same widening issue.
const entries: Record<string, unknown>[] = [];
for (const g of rateGroups) {
const taxCategory = vatTaxCategory(g.rate, vatExempt);
const taxCategory = vatTaxCategory(g.rate, vatMode);
if (g.allowanceCents > 0) {
entries.push({
"cbc:ChargeIndicator": "false",
@@ -298,7 +318,7 @@ function allowanceCharges(rateGroups: RateGroup[], vatExempt: boolean): UblInvoi
return entries.length ? (entries as unknown as UblInvoice["cac:AllowanceCharge"]) : undefined;
}
function invoiceLines(lines: ComputedLine[], vatExempt: boolean): UblInvoice["cac:InvoiceLine"] {
function invoiceLines(lines: ComputedLine[], vatMode: VatMode): UblInvoice["cac:InvoiceLine"] {
return lines.map(({ item, netCents }, i) => {
const netUnitPrice = item.unitPrice / (1 + item.taxRatePercent / 100);
return {
@@ -307,7 +327,7 @@ function invoiceLines(lines: ComputedLine[], vatExempt: boolean): UblInvoice["ca
...amtCents("cbc:LineExtensionAmount", netCents),
"cac:Item": {
"cbc:Name": item.variantName ? `${item.productName} (${item.variantName})` : item.productName,
"cac:ClassifiedTaxCategory": vatTaxCategory(item.taxRatePercent, vatExempt),
"cac:ClassifiedTaxCategory": vatTaxCategory(item.taxRatePercent, vatMode),
},
// Unit price, not a summed/reconciled total — kept as the plain
// (unrounded-to-cent) net unit price for reference; BR-CO-10 only
@@ -372,10 +392,10 @@ function paymentTerms(note: string): UblInvoice["cac:PaymentTerms"] {
// Full original invoice — one line per order item, positive amounts,
// InvoiceTypeCode 380 ("Commercial invoice").
export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): Invoice {
const vatExempt = Boolean(order.vatExempt);
const vatMode: VatMode = order.kleinunternehmer ? "kleinunternehmer" : order.vatExempt ? "intra-community" : "standard";
const lines = computeLines(order.items);
const rateGroups = computeRateGroups(lines, order.discountAmount, order.shippingCost);
const allowanceChargeEntries = allowanceCharges(rateGroups, vatExempt);
const allowanceChargeEntries = allowanceCharges(rateGroups, vatMode);
const paid = isPaidImmediately(order.paymentMethodTitle);
return {
@@ -399,13 +419,13 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I
// invoice's BT-115 is 0 (see legalMonetaryTotal()'s `prepaid` param).
...(paid ? {} : { "cac:PaymentTerms": paymentTerms("Zahlbar sofort ohne Abzug.") }),
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
"cac:TaxTotal": taxTotal(rateGroups, vatExempt),
"cac:TaxTotal": taxTotal(rateGroups, vatMode),
// `order.total` is what the customer was actually charged at
// checkout — any cent of drift against this breakdown's own
// TaxInclusiveAmount is declared via PayableRoundingAmount inside
// legalMonetaryTotal(), not silently absorbed into either figure.
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, Math.round(order.total * 100), paid),
"cac:InvoiceLine": invoiceLines(lines, vatExempt),
"cac:InvoiceLine": invoiceLines(lines, vatMode),
},
};
}
@@ -422,7 +442,7 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I
// Stornorechnung-vs-Gutschrift policy (full reversal incl. shipping vs.
// only returned quantities, no shipping, no discount reproration).
export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Invoice {
const vatExempt = Boolean(order.vatExempt);
const vatMode: VatMode = order.kleinunternehmer ? "kleinunternehmer" : order.vatExempt ? "intra-community" : "standard";
const effectiveItems =
kind === "storno"
? order.items
@@ -430,7 +450,7 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order:
const lines = computeLines(effectiveItems);
const rateGroups = computeRateGroups(lines, kind === "storno" ? order.discountAmount : 0, kind === "storno" ? order.shippingCost : 0);
const allowanceChargeEntries = allowanceCharges(rateGroups, vatExempt);
const allowanceChargeEntries = allowanceCharges(rateGroups, vatMode);
// No separately-stored "charged total" exists for a correction event the
// way `order.total` does for the original invoice — the reversal amount
// *is* this breakdown's own TaxInclusiveAmount, so PayableAmount is
@@ -469,9 +489,9 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order:
// branch above) — payment terms text satisfies the rule instead.
"cac:PaymentTerms": paymentTerms("Der Rechnungsbetrag wird erstattet."),
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
"cac:TaxTotal": taxTotal(rateGroups, vatExempt),
"cac:TaxTotal": taxTotal(rateGroups, vatMode),
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents, false),
"cac:InvoiceLine": invoiceLines(lines, vatExempt),
"cac:InvoiceLine": invoiceLines(lines, vatMode),
},
};
}