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 && (