51ae8a9c0a
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>
498 lines
26 KiB
TypeScript
498 lines
26 KiB
TypeScript
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),
|
||
},
|
||
};
|
||
}
|