Add innergemeinschaftliche-Lieferung (VAT exemption) support
Validate e-invoices / mustang (push) Successful in 18s
Validate e-invoices / mustang (push) Successful in 18s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<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
|
||||
@@ -246,7 +277,7 @@ function allowanceCharges(rateGroups: RateGroup[]): UblInvoice["cac:AllowanceCha
|
||||
// exact same widening issue.
|
||||
const entries: Record<string, unknown>[] = [];
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user