From 942b8a86322b165a09e7b91af686b6c3538989f4 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 23 Jul 2026 18:59:19 +0000 Subject: [PATCH] Add innergemeinschaftliche-Lieferung (VAT exemption) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InvoiceOrder/CorrectionInvoiceOrder gained an optional vatExempt flag — items already carry 0% rate and de-grossed prices when true (the caller's responsibility), this only controls display/XML mapping: - Visual PDF: replaces the "enthält X% MwSt." annotation with "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" on both the invoice and its Storno/Gutschrift. - EN16931 XML (buildEInvoiceData.ts): VAT category 'K' instead of 'S' on every line/tax-subtotal, plus BT-120/BT-121 exemption reason (VATEX-EU-IC) — but only on cac:TaxTotal's own TaxSubtotal.TaxCategory, not on InvoiceLine/AllowanceCharge's TaxCategory, which @e-invoice-eu/core's schema rejects those same two fields on (caught locally before Mustang ever saw it — a fully-rendered PDF/A-3 with a hand-inflated CII XML confirmed CategoryCode/ExemptionReasonCode/ ExemptionReason land correctly). Also surfaced (not fixed, unrelated to this change): a seller with no iban configured throws in @e-invoice-eu/core's UBL→CII conversion — doesn't affect this shop's own production data (iban is always set), noting for whoever touches this next. Co-Authored-By: Claude Sonnet 5 --- src/correctionInvoicePdf.tsx | 21 ++++++--- src/einvoice/buildEInvoiceData.ts | 71 ++++++++++++++++++++++--------- src/invoicePdf.tsx | 29 ++++++++++--- 3 files changed, 88 insertions(+), 33 deletions(-) diff --git a/src/correctionInvoicePdf.tsx b/src/correctionInvoicePdf.tsx index 9599b51..b299b0d 100644 --- a/src/correctionInvoicePdf.tsx +++ b/src/correctionInvoicePdf.tsx @@ -139,6 +139,11 @@ export type CorrectionInvoiceOrder = { // identification as the original invoice it corrects. companyName?: string | null; vatId?: string | null; + // Same as invoicePdf.tsx's own InvoiceOrder.vatExempt — a Storno/ + // Gutschrift for an exempt original invoice reverses the same 0%-rated, + // de-grossed figures, and shows the same exemption note instead of a + // meaningless "enthält 0% MwSt." line. + vatExempt?: boolean; deliveryMethod: "address" | "packstation"; street?: string | null; packstationNumber?: string | null; @@ -331,12 +336,16 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn -{formatPrice(grandTotal)} - {rateGroups.map((g) => ( - - enthält {g.rate}% MwSt. - -{formatPrice(g.tax)} - - ))} + {order.vatExempt ? ( + Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG) + ) : ( + rateGroups.map((g) => ( + + enthält {g.rate}% MwSt. + -{formatPrice(g.tax)} + + )) + )} diff --git a/src/einvoice/buildEInvoiceData.ts b/src/einvoice/buildEInvoiceData.ts index 562e7ac..af93e2d 100644 --- a/src/einvoice/buildEInvoiceData.ts +++ b/src/einvoice/buildEInvoiceData.ts @@ -23,6 +23,41 @@ 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 { + return { + "cbc:ID": vatExempt ? "K" : VAT_CATEGORY, + "cbc:Percent": String(rate), + "cac:TaxScheme": { "cbc:ID": "VAT" }, + }; +} + +// BT-120/BT-121 (VAT exemption reason text/code) are document-level fields +// that only exist on cac:TaxTotal's own cac:TaxSubtotal.cac:TaxCategory — +// confirmed empirically: @e-invoice-eu/core's ajv schema rejects +// TaxExemptionReasonCode/TaxExemptionReason as "additional properties" the +// moment they're placed on an cac:InvoiceLine's ClassifiedTaxCategory or a +// cac:AllowanceCharge's TaxCategory (both use their own, more restrictive +// 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 { + return { + ...vatTaxCategory(rate, vatExempt), + ...(vatExempt ? { "cbc:TaxExemptionReasonCode": "VATEX-EU-IC", "cbc:TaxExemptionReason": "Innergemeinschaftliche Lieferung" } : {}), + }; +} + // UN/ECE Recommendation 20 unit-of-measure code for "one" (a countable // piece/unit) — this shop sells discrete products (ToDo-Karten sets, // notebooks, ...), never anything sold by weight/length/volume, so every @@ -219,7 +254,7 @@ function computeRateGroups(lines: ComputedLine[], discountAmount: number, shippi .sort((a, b) => b.rate - a.rate); } -function taxTotal(rateGroups: RateGroup[]): UblInvoice["cac:TaxTotal"] { +function taxTotal(rateGroups: RateGroup[], vatExempt: boolean): UblInvoice["cac:TaxTotal"] { const totalTaxCents = rateGroups.reduce((sum, g) => sum + g.taxCents, 0); return [ { @@ -227,17 +262,13 @@ function taxTotal(rateGroups: RateGroup[]): UblInvoice["cac:TaxTotal"] { "cac:TaxSubtotal": rateGroups.map((g) => ({ ...amtCents("cbc:TaxableAmount", g.taxableCents), ...amtCents("cbc:TaxAmount", g.taxCents), - "cac:TaxCategory": { - "cbc:ID": VAT_CATEGORY, - "cbc:Percent": String(g.rate), - "cac:TaxScheme": { "cbc:ID": "VAT" }, - }, + "cac:TaxCategory": vatTaxSubtotalCategory(g.rate, vatExempt), })), }, - ] as UblInvoice["cac:TaxTotal"]; + ] as unknown as UblInvoice["cac:TaxTotal"]; } -function allowanceCharges(rateGroups: RateGroup[]): UblInvoice["cac:AllowanceCharge"] { +function allowanceCharges(rateGroups: RateGroup[], vatExempt: boolean): UblInvoice["cac:AllowanceCharge"] { // Loosely typed here (build as Record, 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 @@ -246,7 +277,7 @@ function allowanceCharges(rateGroups: RateGroup[]): UblInvoice["cac:AllowanceCha // exact same widening issue. const entries: Record[] = []; for (const g of rateGroups) { - const taxCategory = { "cbc:ID": VAT_CATEGORY, "cbc:Percent": String(g.rate), "cac:TaxScheme": { "cbc:ID": "VAT" } }; + const taxCategory = vatTaxCategory(g.rate, vatExempt); if (g.allowanceCents > 0) { entries.push({ "cbc:ChargeIndicator": "false", @@ -267,7 +298,7 @@ function allowanceCharges(rateGroups: RateGroup[]): UblInvoice["cac:AllowanceCha return entries.length ? (entries as unknown as UblInvoice["cac:AllowanceCharge"]) : undefined; } -function invoiceLines(lines: ComputedLine[]): UblInvoice["cac:InvoiceLine"] { +function invoiceLines(lines: ComputedLine[], vatExempt: boolean): UblInvoice["cac:InvoiceLine"] { return lines.map(({ item, netCents }, i) => { const netUnitPrice = item.unitPrice / (1 + item.taxRatePercent / 100); return { @@ -276,11 +307,7 @@ function invoiceLines(lines: ComputedLine[]): UblInvoice["cac:InvoiceLine"] { ...amtCents("cbc:LineExtensionAmount", netCents), "cac:Item": { "cbc:Name": item.variantName ? `${item.productName} (${item.variantName})` : item.productName, - "cac:ClassifiedTaxCategory": { - "cbc:ID": VAT_CATEGORY, - "cbc:Percent": String(item.taxRatePercent), - "cac:TaxScheme": { "cbc:ID": "VAT" }, - }, + "cac:ClassifiedTaxCategory": vatTaxCategory(item.taxRatePercent, vatExempt), }, // Unit price, not a summed/reconciled total — kept as the plain // (unrounded-to-cent) net unit price for reference; BR-CO-10 only @@ -345,9 +372,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 lines = computeLines(order.items); const rateGroups = computeRateGroups(lines, order.discountAmount, order.shippingCost); - const allowanceChargeEntries = allowanceCharges(rateGroups); + const allowanceChargeEntries = allowanceCharges(rateGroups, vatExempt); const paid = isPaidImmediately(order.paymentMethodTitle); return { @@ -371,13 +399,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), + "cac:TaxTotal": taxTotal(rateGroups, vatExempt), // `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), + "cac:InvoiceLine": invoiceLines(lines, vatExempt), }, }; } @@ -394,6 +422,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 effectiveItems = kind === "storno" ? order.items @@ -401,7 +430,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); + const allowanceChargeEntries = allowanceCharges(rateGroups, vatExempt); // 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 @@ -440,9 +469,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), + "cac:TaxTotal": taxTotal(rateGroups, vatExempt), "cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents, false), - "cac:InvoiceLine": invoiceLines(lines), + "cac:InvoiceLine": invoiceLines(lines, vatExempt), }, }; } diff --git a/src/invoicePdf.tsx b/src/invoicePdf.tsx index a8b09f7..070bd92 100644 --- a/src/invoicePdf.tsx +++ b/src/invoicePdf.tsx @@ -145,6 +145,14 @@ export type InvoiceOrder = { // the address. Neither implies the other (see Orders.ts's own comment). companyName?: string | null; vatId?: string | null; + // Innergemeinschaftliche Lieferung (§4 Nr. 1b UStG) — decided server-side + // at checkout via a live VIES lookup (see the frontend's api/checkout/ + // route.ts and lib/vatExemption.ts), never guessed from vatId's mere + // presence/format. When true, every item's own taxRatePercent is already + // 0 and unitPrice already de-grossed (the actual charged/persisted + // figures) — this flag only controls the exemption note shown here, + // it doesn't itself change any arithmetic in this file. + vatExempt?: boolean; deliveryMethod: "address" | "packstation"; street?: string | null; packstationNumber?: string | null; @@ -373,12 +381,21 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller {formatPrice(order.total)} - {rateGroups.map((g) => ( - - enthält {g.rate}% MwSt. - {formatPrice(g.tax)} - - ))} + {/* Exempt orders already have every item at 0% (see this + file's own InvoiceOrder.vatExempt comment) — "enthält + 0% MwSt.: 0,00 €" would be a meaningless thing to print, + so this replaces the whole per-rate breakdown with the + actual legal basis instead. */} + {order.vatExempt ? ( + Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG) + ) : ( + rateGroups.map((g) => ( + + enthält {g.rate}% MwSt. + {formatPrice(g.tax)} + + )) + )} {paid && (