Files
einfach-produktiv-invoicing/src/einvoice/buildEInvoiceData.ts
T
Marco 51ae8a9c0a
Validate e-invoices / mustang (push) Successful in 43s
Add Kleinunternehmerregelung (§19 UStG) support
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>
2026-07-24 12:51:13 +00:00

498 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Invoice } from "@e-invoice-eu/core";
import type { InvoiceOrder } from "../invoicePdf";
import type { CorrectionInvoiceKind, CorrectionInvoiceOrder } from "../correctionInvoicePdf";
import type { InvoiceSeller } from "../seller";
import { countryCode } from "./countryCode";
// @e-invoice-eu/core only re-exports the top-level `Invoice` type, not the
// individual leaf field types (PaymentMeansTypeCode, SellerCountryCode,
// ...) — derived here via indexed access instead of reaching into the
// package's internal dist paths, which would be fragile against any
// future restructuring on their end.
type UblInvoice = Invoice["ubl:Invoice"];
type PaymentMeansTypeCode = NonNullable<UblInvoice["cac:PaymentMeans"]>[number]["cbc:PaymentMeansCode"];
type SellerCountryCode = UblInvoice["cac:AccountingSupplierParty"]["cac:Party"]["cac:PostalAddress"]["cac:Country"]["cbc:IdentificationCode"];
type BuyerCountryCode = UblInvoice["cac:AccountingCustomerParty"]["cac:Party"]["cac:PostalAddress"]["cac:Country"]["cbc:IdentificationCode"];
// EN16931 VAT category — 'S' ("Standard rated") is the correct code for
// *any* positive VAT rate under EN16931/Peppol BIS convention, not just
// the statutory default rate; the actual percentage (19%, 7%, ...) goes
// in the category's own `cbc:Percent`, not the code itself. This shop
// only ever sells goods at a positive German VAT rate — no exports,
// reverse-charge, or exempt sales exist yet — so every line and every
// summed group uses this same code.
const VAT_CATEGORY = "S";
// 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": id,
"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/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, vatMode),
...(vatMode === "intra-community" ? { "cbc:TaxExemptionReasonCode": "VATEX-EU-IC", "cbc:TaxExemptionReason": "Innergemeinschaftliche Lieferung" } : {}),
...(vatMode === "kleinunternehmer" ? { "cbc:TaxExemptionReason": "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." } : {}),
};
}
// 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
// line uses the same code.
const UNIT_CODE = "C62";
// UNCL4461 payment-means codes — only the ones this shop's actual
// `payment-methods` collection can produce (see the Payload README's
// "Per-product tax rates"-adjacent Commerce section). '1' ("Instrument
// not defined") is the fallback for a payment method title this mapping
// doesn't recognize, not an error — new payment methods get added in
// Payload without a matching code deploy here otherwise breaking e-invoice
// generation entirely.
const PAYMENT_MEANS_CODE: Record<string, PaymentMeansTypeCode> = {
Überweisung: "30", // SEPA credit transfer
Kreditkarte: "48", // Bank card
PayPal: "68", // Online payment service
};
function paymentMeansCode(paymentMethodTitle: string): PaymentMeansTypeCode {
return PAYMENT_MEANS_CODE[paymentMethodTitle] ?? "1";
}
// Same definition as invoicePdf.tsx's own isPaidImmediately() — "Überweisung"
// (bank transfer) is the only payment method on this shop that isn't
// settled at checkout; everything else (Kreditkarte, PayPal, ...) captures
// immediately, matching the visual PDF's "✓ Bereits beglichen" badge.
function isPaidImmediately(paymentMethodTitle: string): boolean {
return paymentMethodTitle !== "Überweisung";
}
// EN16931 amount fields are XML-serialized strings, not numbers
// (confirmed against @e-invoice-eu/core's own generated types — every
// `*Amount`/`*Quantity`/`*Rate` type alias resolves to `string`). Every
// `*Amount` field also turned out (at runtime, via the library's ajv
// schema — not visible in the .d.ts types at all) to *require* a sibling
// `*@currencyID` key the moment the amount itself is present. `amtCents()`
// returns both keys at once via object spread, so call sites can't add one
// without the other.
//
// Takes integer *cents*, not a float euro amount — every amount field in
// this document is built from a shared integer-cents pipeline (see
// `computeLines()`/`computeRateGroups()` below) specifically so that every
// EN16931 "declared total = Σ its own parts" business rule (BR-CO-10,
// BR-CO-12, BR-CO-13, BR-CO-14, BR-S-08) holds *exactly*, not just
// approximately. Rounding a float sum once at the very end (the first cut
// at this file) drifts from the sum of independently-rounded parts by a
// cent as soon as an order combines a discount/shipping with more than one
// VAT rate — caught by Mustang's Phase 4 CI check on the very first
// fixture that did.
function amtCents(key: string, cents: number): Record<string, string> {
return { [key]: (cents / 100).toFixed(2), [`${key}@currencyID`]: "EUR" };
}
// Same "runtime-required sibling key" story as `amtCents()`, but for
// `cbc:InvoicedQuantity@unitCode`.
function qty(value: number): Record<string, string> {
return { "cbc:InvoicedQuantity": String(value), "cbc:InvoicedQuantity@unitCode": UNIT_CODE };
}
function isoDate(iso: string): string {
return iso.slice(0, 10);
}
// CII's SupplyChainTradeTransaction is a fixed Agreement/Delivery/
// Settlement element sequence — @e-invoice-eu/core's UBL→CII conversion
// drops the whole `ram:ApplicableHeaderTradeDelivery` container unless at
// least one of `cac:Delivery`'s own children actually resolves to a value
// (an empty `{}` isn't enough — the converter recurses into children and
// only vivifies the destination element if one of them produced output),
// which fails schema validation (Mustang: "Invalid content ...
// ApplicableHeaderTradeSettlement. One of ApplicableHeaderTradeDelivery is
// expected", caught by the Phase 4 CI check on the very first real run —
// an empty object there didn't fix it, this date is the actual fix).
//
// This shop doesn't track a separate delivery date distinct from the
// invoice itself (no shipped-at timestamp exists at original-invoice time
// — that's generated at checkout, before shipping even happens), so the
// invoice's own issue date is used as the closest available proxy — BT-72
// is optional under EN16931, and Mustang only checks structural/schema
// conformance here, not the business accuracy of the date's value.
function delivery(issuedAtIso: string): UblInvoice["cac:Delivery"] {
return { "cbc:ActualDeliveryDate": isoDate(issuedAtIso) };
}
function paymentMeans(seller: InvoiceSeller, paymentMethodTitle: string): UblInvoice["cac:PaymentMeans"] {
if (!seller.iban) return undefined;
return [
{
"cbc:PaymentMeansCode": paymentMeansCode(paymentMethodTitle),
"cac:PayeeFinancialAccount": {
"cbc:ID": seller.iban,
...(seller.bankName ? { "cbc:Name": seller.bankName } : {}),
},
},
];
}
function sellerParty(seller: InvoiceSeller): UblInvoice["cac:AccountingSupplierParty"] {
return {
"cac:Party": {
"cac:PostalAddress": {
"cbc:StreetName": seller.sellerStreet,
"cbc:CityName": seller.sellerCity,
"cbc:PostalZone": seller.sellerZip,
"cac:Country": { "cbc:IdentificationCode": countryCode(seller.sellerCountry) as SellerCountryCode },
},
"cac:PartyTaxScheme": [
{
"cbc:CompanyID": seller.vatId,
"cac:TaxScheme": { "cbc:ID": "VAT" },
},
],
"cac:PartyLegalEntity": { "cbc:RegistrationName": seller.sellerName },
"cac:Contact": { "cbc:ElectronicMail": seller.sellerEmail },
},
};
}
function buyerParty(name: string, street: string | null | undefined, zip: string, city: string, country: string): UblInvoice["cac:AccountingCustomerParty"] {
return {
"cac:Party": {
"cac:PostalAddress": {
...(street ? { "cbc:StreetName": street } : {}),
"cbc:CityName": city,
"cbc:PostalZone": zip,
"cac:Country": { "cbc:IdentificationCode": countryCode(country) as BuyerCountryCode },
},
"cac:PartyLegalEntity": { "cbc:RegistrationName": name },
},
};
}
type LineInput = { productName: string; variantName?: string | null; quantity: number; unitPrice: number; taxRatePercent: number };
type ComputedLine = { item: LineInput; netCents: number };
type RateGroup = { rate: number; lineNetCents: number; allowanceCents: number; chargeCents: number; taxableCents: number; taxCents: number };
// BT-131/BT-146 (line net amount / item net price) — `item.unitPrice` is
// this shop's normal gross (VAT-inclusive) customer-facing price, same
// value the visual PDF shows, but EN16931 invoice lines are always net;
// de-grossed here per line's own rate, then rounded to the nearest cent
// once — every other amount in this document is ultimately built from
// summing these same per-line integer-cent values, never re-derived from
// the original float unit price a second time.
function computeLines(items: LineInput[]): ComputedLine[] {
return items.map((item) => {
const netUnitPrice = item.unitPrice / (1 + item.taxRatePercent / 100);
return { item, netCents: Math.round(item.quantity * netUnitPrice * 100) };
});
}
// Groups the already-rounded per-line net cents by VAT rate, then
// allocates document-level discount/shipping across those groups
// proportional to each group's own share of the total line net — the
// EN16931-correct place for shipping/discount to live is an explicit
// per-rate `cac:AllowanceCharge` (BT-92/BT-99), not folded silently into a
// scaled net total the way the visual PDF's summary table gets away with.
// Every field below (`taxableCents`, `taxCents`, and every total this feeds
// into) is built purely from sums/rounds of the same per-line/per-group
// integer cents, so BR-CO-10/12/13/14 and BR-S-08 all hold exactly by
// construction — none of them are checking against the *original*
// discountAmount/shippingCost float inputs, only that this document's own
// declared totals equal the sum of its own declared parts.
function computeRateGroups(lines: ComputedLine[], discountAmount: number, shippingCost: number): RateGroup[] {
const byRate = new Map<number, number>();
for (const { item, netCents } of lines) {
byRate.set(item.taxRatePercent, (byRate.get(item.taxRatePercent) ?? 0) + netCents);
}
const totalNetCents = Array.from(byRate.values()).reduce((sum, c) => sum + c, 0);
// `discountAmount`/`shippingCost` are this shop's normal GROSS
// (VAT-inclusive) figures, same as `unitPrice` — allocated proportionally
// per rate group in gross terms (same ratio basis as the line amounts),
// then degrossed via that group's own rate, exactly like every other
// amount in this document. Missing this degross step was a real, shipped
// bug (2026-07-23): every allowance/charge came out ~19%/7% too large,
// which BR-CO-12/13/14 couldn't catch (those only check this document's
// own numbers reconcile with each other, not against the original
// discountAmount/shippingCost) — surfaced instead as an oversized
// PayableRoundingAmount (a real order showed >1 EUR, not a rounding-scale
// difference) when an external checker flagged it. With degrossing
// correctly applied, TaxInclusiveAmount reconciles to `order.total`
// exactly and PayableRoundingAmount is 0 for a normal order.
const discountCents = Math.round(discountAmount * 100);
const shippingCents = Math.round(shippingCost * 100);
return Array.from(byRate.entries())
.map(([rate, lineNetCents]) => {
const ratio = totalNetCents > 0 ? lineNetCents / totalNetCents : 0;
const allowanceCents = discountCents > 0 ? Math.round((discountCents * ratio) / (1 + rate / 100)) : 0;
const chargeCents = shippingCents > 0 ? Math.round((shippingCents * ratio) / (1 + rate / 100)) : 0;
const taxableCents = lineNetCents - allowanceCents + chargeCents;
const taxCents = Math.round((taxableCents * rate) / 100);
return { rate, lineNetCents, allowanceCents, chargeCents, taxableCents, taxCents };
})
.sort((a, b) => b.rate - a.rate);
}
function taxTotal(rateGroups: RateGroup[], vatMode: VatMode): UblInvoice["cac:TaxTotal"] {
const totalTaxCents = rateGroups.reduce((sum, g) => sum + g.taxCents, 0);
return [
{
...amtCents("cbc:TaxAmount", totalTaxCents),
"cac:TaxSubtotal": rateGroups.map((g) => ({
...amtCents("cbc:TaxableAmount", g.taxableCents),
...amtCents("cbc:TaxAmount", g.taxCents),
"cac:TaxCategory": vatTaxSubtotalCategory(g.rate, vatMode),
})),
},
] as unknown as UblInvoice["cac:TaxTotal"];
}
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
// the library's generated VAT-category-code union type then rejects;
// matches invoiceLines()/taxTotal()'s own established pattern for the
// exact same widening issue.
const entries: Record<string, unknown>[] = [];
for (const g of rateGroups) {
const taxCategory = vatTaxCategory(g.rate, vatMode);
if (g.allowanceCents > 0) {
entries.push({
"cbc:ChargeIndicator": "false",
"cbc:AllowanceChargeReason": "Rabatt",
...amtCents("cbc:Amount", g.allowanceCents),
"cac:TaxCategory": taxCategory,
});
}
if (g.chargeCents > 0) {
entries.push({
"cbc:ChargeIndicator": "true",
"cbc:AllowanceChargeReason": "Versandkosten",
...amtCents("cbc:Amount", g.chargeCents),
"cac:TaxCategory": taxCategory,
});
}
}
return entries.length ? (entries as unknown as UblInvoice["cac:AllowanceCharge"]) : undefined;
}
function invoiceLines(lines: ComputedLine[], vatMode: VatMode): UblInvoice["cac:InvoiceLine"] {
return lines.map(({ item, netCents }, i) => {
const netUnitPrice = item.unitPrice / (1 + item.taxRatePercent / 100);
return {
"cbc:ID": String(i + 1),
...qty(item.quantity),
...amtCents("cbc:LineExtensionAmount", netCents),
"cac:Item": {
"cbc:Name": item.variantName ? `${item.productName} (${item.variantName})` : item.productName,
"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
// checks LineExtensionAmount sums, never PriceAmount.
"cac:Price": amtCents("cbc:PriceAmount", Math.round(netUnitPrice * 100)),
};
}) as unknown as UblInvoice["cac:InvoiceLine"];
}
// `chargedCents` is the amount actually charged (the original invoice's
// own stored `order.total`, or — for a correction — the natural total
// this same breakdown derives, see the two call sites). It can in
// principle differ from this document's own internally-consistent
// TaxInclusiveAmount by a cent or two (e.g. `order.total` comes from
// checkout's own independent cart/discount math, not this per-rate
// proportional allocation) — EN16931 has a dedicated field for exactly
// that gap, `cbc:PayableRoundingAmount` (BT-114): BR-CO-16 requires
// PayableAmount = TaxInclusiveAmount PrepaidAmount + RoundingAmount, so
// any gap is declared explicitly rather than either figure silently
// disagreeing with where it actually comes from.
//
// `prepaid`: whether the order was already paid at checkout (Kreditkarte/
// PayPal, everything except Überweisung — same `isPaidImmediately()`
// definition invoicePdf.tsx's own "✓ Bereits beglichen" badge uses). If
// so, the whole charged amount is declared as PrepaidAmount (BT-113) and
// PayableAmount (BT-115) becomes 0 — nothing outstanding, matching what
// the visual PDF's badge already tells a human reader, now machine-
// readable too. This also satisfies BR-CO-25 (a positive BT-115 needs a
// due date or payment terms) by construction: an already-settled invoice
// has a zero, not positive, amount due.
function legalMonetaryTotal(rateGroups: RateGroup[], chargedCents: number, prepaid: boolean): UblInvoice["cac:LegalMonetaryTotal"] {
const lineExtensionCents = rateGroups.reduce((sum, g) => sum + g.lineNetCents, 0);
const allowanceTotalCents = rateGroups.reduce((sum, g) => sum + g.allowanceCents, 0);
const chargeTotalCents = rateGroups.reduce((sum, g) => sum + g.chargeCents, 0);
const taxExclusiveCents = lineExtensionCents - allowanceTotalCents + chargeTotalCents;
const taxInclusiveCents = taxExclusiveCents + rateGroups.reduce((sum, g) => sum + g.taxCents, 0);
const prepaidCents = prepaid ? chargedCents : 0;
const payableCents = prepaid ? 0 : chargedCents;
const roundingCents = payableCents - taxInclusiveCents + prepaidCents;
return {
...amtCents("cbc:LineExtensionAmount", lineExtensionCents),
...(allowanceTotalCents > 0 ? amtCents("cbc:AllowanceTotalAmount", allowanceTotalCents) : {}),
...(chargeTotalCents > 0 ? amtCents("cbc:ChargeTotalAmount", chargeTotalCents) : {}),
...amtCents("cbc:TaxExclusiveAmount", taxExclusiveCents),
...amtCents("cbc:TaxInclusiveAmount", taxInclusiveCents),
...(prepaidCents > 0 ? amtCents("cbc:PrepaidAmount", prepaidCents) : {}),
...(roundingCents !== 0 ? amtCents("cbc:PayableRoundingAmount", roundingCents) : {}),
...amtCents("cbc:PayableAmount", payableCents),
} as unknown as UblInvoice["cac:LegalMonetaryTotal"];
}
// BR-CO-25: a positive Amount due for payment (BT-115) needs either a due
// date or payment terms text. Applies to the not-already-paid case
// (Überweisung on an original invoice, or any correction invoice — see
// call sites) — this shop doesn't track a formal payment-term policy
// anywhere, so a standard, safe default phrase is used rather than
// inventing a due-date business rule that isn't actually modeled.
function paymentTerms(note: string): UblInvoice["cac:PaymentTerms"] {
return { "cbc:Note": note } as 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 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, vatMode);
const paid = isPaidImmediately(order.paymentMethodTitle);
return {
"ubl:Invoice": {
"cbc:ID": order.invoiceNumber,
"cbc:IssueDate": isoDate(order.invoiceIssuedAt),
"cbc:InvoiceTypeCode": "380",
"cbc:DocumentCurrencyCode": "EUR",
"cac:AccountingSupplierParty": sellerParty(seller),
"cac:AccountingCustomerParty": buyerParty(
`${order.customerFirstName} ${order.customerLastName}`,
order.deliveryMethod === "address" ? order.street : null,
order.zip,
order.city,
order.country,
),
"cac:Delivery": delivery(order.invoiceIssuedAt),
"cac:PaymentMeans": paymentMeans(seller, order.paymentMethodTitle),
// BR-CO-25: a positive Amount due (BT-115) needs a due date or
// payment terms. Only needed on the not-already-paid path — a paid
// 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, 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, vatMode),
},
};
}
// Stornorechnung/Gutschrift — same UBL `Invoice` shape (this library has
// no separate "credit note" type), but InvoiceTypeCode 381 ("Credit
// note") instead of 380, so a receiving AP system reads it as a
// reduction, not a second charge. Amounts stay positive (the credited
// amount, not a negative number) — EN16931/UBL convention puts the
// polarity in the type code, not the sign, same as the PDF's own visual
// "-{amount}" is a *display* convention layered on top of positive
// underlying numbers (see correctionInvoicePdf.tsx's groupByTaxRate()).
// Mirrors resolveLineItems() in correctionInvoicePdf.tsx exactly — same
// 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 vatMode: VatMode = order.kleinunternehmer ? "kleinunternehmer" : order.vatExempt ? "intra-community" : "standard";
const effectiveItems =
kind === "storno"
? order.items
: order.items.filter((item) => (item.returnQuantity ?? 0) > 0).map((item) => ({ ...item, quantity: item.returnQuantity as number }));
const lines = computeLines(effectiveItems);
const rateGroups = computeRateGroups(lines, kind === "storno" ? order.discountAmount : 0, kind === "storno" ? order.shippingCost : 0);
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
// derived from it directly (computed inside legalMonetaryTotal()) rather
// than passed in independently.
const taxInclusiveCents =
rateGroups.reduce((sum, g) => sum + g.lineNetCents - g.allowanceCents + g.chargeCents, 0) + rateGroups.reduce((sum, g) => sum + g.taxCents, 0);
return {
"ubl:Invoice": {
"cbc:ID": order.correctionInvoiceNumber,
"cbc:IssueDate": isoDate(order.correctionInvoiceIssuedAt),
"cbc:InvoiceTypeCode": "381",
"cbc:DocumentCurrencyCode": "EUR",
"cac:BillingReference": [
{
"cac:InvoiceDocumentReference": {
"cbc:ID": order.invoiceNumber,
"cbc:IssueDate": isoDate(order.invoiceIssuedAt),
},
},
],
"cac:AccountingSupplierParty": sellerParty(seller),
"cac:AccountingCustomerParty": buyerParty(
`${order.customerFirstName} ${order.customerLastName}`,
order.deliveryMethod === "address" ? order.street : null,
order.zip,
order.city,
order.country,
),
"cac:Delivery": delivery(order.correctionInvoiceIssuedAt),
"cac:PaymentMeans": paymentMeans(seller, "Überweisung"),
// BR-CO-25 — a credit note's "amount due" represents a refund owed to
// the customer, not an open invoice payment, so there's no "already
// paid"/PrepaidAmount concept here (unlike buildEInvoiceData's paid
// branch above) — payment terms text satisfies the rule instead.
"cac:PaymentTerms": paymentTerms("Der Rechnungsbetrag wird erstattet."),
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
"cac:TaxTotal": taxTotal(rateGroups, vatMode),
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents, false),
"cac:InvoiceLine": invoiceLines(lines, vatMode),
},
};
}