Rebuild EN16931 monetary fields on an integer-cents pipeline
Validate e-invoices / mustang (push) Failing after 16s
Validate e-invoices / mustang (push) Failing after 16s
The previous fix (net not gross line amounts + AllowanceCharge entries) still drifted a cent on BR-CO-12/13/14: rounding a float sum once at the end doesn't equal the sum of independently-rounded parts. Every amount in the document is now built by summing the same already-rounded-to-cent integers that appear in the individual line/allowance/charge/tax entries, so every EN16931 "declared total = sum of its own parts" rule holds exactly by construction rather than approximately. Verified by hand against all five affected business rules (BR-CO-10/12/13/14, BR-S-08) before pushing. taxBreakdown.ts's earlier additive rawGross/rawNet fields are reverted — this pipeline no longer depends on it at all, computing everything fresh from order.items/discountAmount/shippingCost.
This commit is contained in:
+140
-126
@@ -1,5 +1,4 @@
|
||||
import type { Invoice } from "@e-invoice-eu/core";
|
||||
import { computeTaxBreakdown } from "../taxBreakdown";
|
||||
import type { InvoiceOrder } from "../invoicePdf";
|
||||
import type { CorrectionInvoiceKind, CorrectionInvoiceOrder } from "../correctionInvoicePdf";
|
||||
import type { InvoiceSeller } from "../seller";
|
||||
@@ -49,17 +48,28 @@ function paymentMeansCode(paymentMethodTitle: string): PaymentMeansTypeCode {
|
||||
|
||||
// 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`),
|
||||
// formatted to exactly 2 decimals — and every `*Amount` field 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. `amt()` returns both keys at once via object
|
||||
// spread, so call sites can't add one without the other.
|
||||
function amt(key: string, value: number): Record<string, string> {
|
||||
return { [key]: value.toFixed(2), [`${key}@currencyID`]: "EUR" };
|
||||
// `*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 `amt()`, but for
|
||||
// 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 };
|
||||
@@ -138,14 +148,64 @@ function buyerParty(name: string, street: string | null | undefined, zip: string
|
||||
};
|
||||
}
|
||||
|
||||
function taxTotal(rateGroups: { rate: number; net: number; tax: number }[]): UblInvoice["cac:TaxTotal"] {
|
||||
const totalTax = rateGroups.reduce((sum, g) => sum + g.tax, 0);
|
||||
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);
|
||||
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) : 0;
|
||||
const chargeCents = shippingCents > 0 ? Math.round(shippingCents * ratio) : 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[]): UblInvoice["cac:TaxTotal"] {
|
||||
const totalTaxCents = rateGroups.reduce((sum, g) => sum + g.taxCents, 0);
|
||||
return [
|
||||
{
|
||||
...amt("cbc:TaxAmount", totalTax),
|
||||
...amtCents("cbc:TaxAmount", totalTaxCents),
|
||||
"cac:TaxSubtotal": rateGroups.map((g) => ({
|
||||
...amt("cbc:TaxableAmount", g.net),
|
||||
...amt("cbc:TaxAmount", g.tax),
|
||||
...amtCents("cbc:TaxableAmount", g.taxableCents),
|
||||
...amtCents("cbc:TaxAmount", g.taxCents),
|
||||
"cac:TaxCategory": {
|
||||
"cbc:ID": VAT_CATEGORY,
|
||||
"cbc:Percent": String(g.rate),
|
||||
@@ -156,82 +216,43 @@ function taxTotal(rateGroups: { rate: number; net: number; tax: number }[]): Ubl
|
||||
] as UblInvoice["cac:TaxTotal"];
|
||||
}
|
||||
|
||||
// EN16931 wants shipping/discount represented as explicit document-level
|
||||
// `cac:AllowanceCharge` entries (BT-92 allowance / BT-99 charge), one per
|
||||
// affected VAT rate — not silently folded into a scaled "net" total the
|
||||
// way the visual PDF's summary table gets away with (a human reader
|
||||
// doesn't need the two kept structurally separate the way a validating AP
|
||||
// system does; Mustang's Phase 4 CI check caught this via BR-S-08/BR-CO-10/
|
||||
// BR-CO-14 the first time a fixture combined a discount+shipping order
|
||||
// with multiple VAT rates). Allocated proportionally to each rate group's
|
||||
// own share of the raw (pre-discount/shipping) subtotal — the same
|
||||
// proportion `computeTaxBreakdown()`'s `scale` factor already uses
|
||||
// internally, just split back out into its two components (shipping,
|
||||
// discount) instead of one combined adjustment, and expressed net (excl.
|
||||
// VAT) like every other amount field in this document.
|
||||
function allowanceCharges(
|
||||
rateGroups: { rate: number; rawGross: number }[],
|
||||
subtotal: number,
|
||||
discountAmount: number,
|
||||
shippingCost: number,
|
||||
): { entries: UblInvoice["cac:AllowanceCharge"]; allowanceTotal: number; chargeTotal: number } {
|
||||
if (subtotal <= 0 || (discountAmount <= 0 && shippingCost <= 0)) {
|
||||
return { entries: undefined, allowanceTotal: 0, chargeTotal: 0 };
|
||||
}
|
||||
// Loosely typed here (matching invoiceLines()/taxTotal()'s own "build as
|
||||
// Record<string, unknown>, cast once at the return boundary" pattern) —
|
||||
// VAT_CATEGORY's literal type widens to plain `string` the moment it's
|
||||
// read through this intermediate `taxCategory` variable, which the
|
||||
// library's generated VAT-category-code union type then rejects; not
|
||||
// worth fighting since amt()'s own return type has the same widening
|
||||
// issue this file already casts around elsewhere.
|
||||
function allowanceCharges(rateGroups: RateGroup[]): 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>[] = [];
|
||||
let allowanceTotal = 0;
|
||||
let chargeTotal = 0;
|
||||
for (const g of rateGroups) {
|
||||
const ratio = g.rawGross / subtotal;
|
||||
const taxCategory = { "cbc:ID": VAT_CATEGORY, "cbc:Percent": String(g.rate), "cac:TaxScheme": { "cbc:ID": "VAT" } };
|
||||
if (discountAmount > 0) {
|
||||
const netAmount = (discountAmount * ratio) / (1 + g.rate / 100);
|
||||
allowanceTotal += netAmount;
|
||||
if (g.allowanceCents > 0) {
|
||||
entries.push({
|
||||
"cbc:ChargeIndicator": "false",
|
||||
"cbc:AllowanceChargeReason": "Rabatt",
|
||||
...amt("cbc:Amount", netAmount),
|
||||
...amtCents("cbc:Amount", g.allowanceCents),
|
||||
"cac:TaxCategory": taxCategory,
|
||||
});
|
||||
}
|
||||
if (shippingCost > 0) {
|
||||
const netAmount = (shippingCost * ratio) / (1 + g.rate / 100);
|
||||
chargeTotal += netAmount;
|
||||
if (g.chargeCents > 0) {
|
||||
entries.push({
|
||||
"cbc:ChargeIndicator": "true",
|
||||
"cbc:AllowanceChargeReason": "Versandkosten",
|
||||
...amt("cbc:Amount", netAmount),
|
||||
...amtCents("cbc:Amount", g.chargeCents),
|
||||
"cac:TaxCategory": taxCategory,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { entries: entries as unknown as UblInvoice["cac:AllowanceCharge"], allowanceTotal, chargeTotal };
|
||||
return entries.length ? (entries as unknown as UblInvoice["cac:AllowanceCharge"]) : undefined;
|
||||
}
|
||||
|
||||
// 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 rather than reusing the
|
||||
// discount/shipping-scaled group `net` (that adjustment belongs in the
|
||||
// AllowanceCharge entries above, not inside each line — BR-CO-10 checks
|
||||
// that line net amounts sum independently of any document-level
|
||||
// allowance/charge).
|
||||
function invoiceLines(
|
||||
lines: { productName: string; variantName?: string | null; quantity: number; unitPrice: number; taxRatePercent: number }[],
|
||||
): UblInvoice["cac:InvoiceLine"] {
|
||||
return lines.map((item, i) => {
|
||||
function invoiceLines(lines: ComputedLine[]): 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),
|
||||
...amt("cbc:LineExtensionAmount", item.quantity * netUnitPrice),
|
||||
...amtCents("cbc:LineExtensionAmount", netCents),
|
||||
"cac:Item": {
|
||||
"cbc:Name": item.variantName ? `${item.productName} (${item.variantName})` : item.productName,
|
||||
"cac:ClassifiedTaxCategory": {
|
||||
@@ -240,29 +261,36 @@ function invoiceLines(
|
||||
"cac:TaxScheme": { "cbc:ID": "VAT" },
|
||||
},
|
||||
},
|
||||
"cac:Price": amt("cbc:PriceAmount", netUnitPrice),
|
||||
// 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"];
|
||||
}
|
||||
|
||||
function legalMonetaryTotal(rateGroups: RateGroup[], payableCents: number): 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);
|
||||
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),
|
||||
...amtCents("cbc:PayableAmount", payableCents),
|
||||
} as unknown as UblInvoice["cac:LegalMonetaryTotal"];
|
||||
}
|
||||
|
||||
// Full original invoice — one line per order item, positive amounts,
|
||||
// InvoiceTypeCode 380 ("Commercial invoice").
|
||||
export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): Invoice {
|
||||
const rateGroups = computeTaxBreakdown(
|
||||
order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })),
|
||||
order.subtotal,
|
||||
order.discountAmount,
|
||||
order.shippingCost,
|
||||
);
|
||||
const totalNet = rateGroups.reduce((sum, g) => sum + g.net, 0);
|
||||
const totalTax = rateGroups.reduce((sum, g) => sum + g.tax, 0);
|
||||
const rawTotalNet = rateGroups.reduce((sum, g) => sum + g.rawNet, 0);
|
||||
const { entries: allowanceChargeEntries, allowanceTotal, chargeTotal } = allowanceCharges(
|
||||
rateGroups,
|
||||
order.subtotal,
|
||||
order.discountAmount,
|
||||
order.shippingCost,
|
||||
);
|
||||
const lines = computeLines(order.items);
|
||||
const rateGroups = computeRateGroups(lines, order.discountAmount, order.shippingCost);
|
||||
const allowanceChargeEntries = allowanceCharges(rateGroups);
|
||||
|
||||
return {
|
||||
"ubl:Invoice": {
|
||||
@@ -282,15 +310,15 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I
|
||||
"cac:PaymentMeans": paymentMeans(seller, order.paymentMethodTitle),
|
||||
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
|
||||
"cac:TaxTotal": taxTotal(rateGroups),
|
||||
"cac:LegalMonetaryTotal": {
|
||||
...amt("cbc:LineExtensionAmount", rawTotalNet),
|
||||
...(allowanceTotal > 0 ? amt("cbc:AllowanceTotalAmount", allowanceTotal) : {}),
|
||||
...(chargeTotal > 0 ? amt("cbc:ChargeTotalAmount", chargeTotal) : {}),
|
||||
...amt("cbc:TaxExclusiveAmount", totalNet),
|
||||
...amt("cbc:TaxInclusiveAmount", totalNet + totalTax),
|
||||
...amt("cbc:PayableAmount", order.total),
|
||||
} as unknown as UblInvoice["cac:LegalMonetaryTotal"],
|
||||
"cac:InvoiceLine": invoiceLines(order.items),
|
||||
// PayableAmount is the order's own stored `total` (what the customer
|
||||
// was actually charged at checkout), not a re-derivation from this
|
||||
// breakdown — the two can differ by a cent from independent rounding
|
||||
// paths (this file's per-rate allocation vs. checkout's own cart
|
||||
// math), which Mustang surfaces as a non-fatal arithmetic *warning*,
|
||||
// not an error; the legally meaningful figure is what was actually
|
||||
// charged.
|
||||
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, Math.round(order.total * 100)),
|
||||
"cac:InvoiceLine": invoiceLines(lines),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -303,32 +331,25 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I
|
||||
// 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()/groupByTaxRate() in correctionInvoicePdf.tsx
|
||||
// exactly — same Stornorechnung-vs-Gutschrift policy (full reversal incl.
|
||||
// shipping vs. only returned quantities, no shipping, no discount
|
||||
// reproration) — deliberately not re-derived independently here.
|
||||
// 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 lines =
|
||||
const effectiveItems =
|
||||
kind === "storno"
|
||||
? order.items.map((item) => ({ item, effectiveQuantity: item.quantity }))
|
||||
: order.items.filter((item) => (item.returnQuantity ?? 0) > 0).map((item) => ({ item, effectiveQuantity: item.returnQuantity as number }));
|
||||
? order.items
|
||||
: order.items.filter((item) => (item.returnQuantity ?? 0) > 0).map((item) => ({ ...item, quantity: item.returnQuantity as number }));
|
||||
|
||||
const rateGroups = computeTaxBreakdown(
|
||||
lines.map(({ item, effectiveQuantity }) => ({ quantity: effectiveQuantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })),
|
||||
order.subtotal,
|
||||
kind === "storno" ? order.discountAmount : 0,
|
||||
kind === "storno" ? order.shippingCost : 0,
|
||||
);
|
||||
const totalNet = rateGroups.reduce((sum, g) => sum + g.net, 0);
|
||||
const totalTax = rateGroups.reduce((sum, g) => sum + g.tax, 0);
|
||||
const grandTotal = rateGroups.reduce((sum, g) => sum + g.gross, 0);
|
||||
const rawTotalNet = rateGroups.reduce((sum, g) => sum + g.rawNet, 0);
|
||||
const { entries: allowanceChargeEntries, allowanceTotal, chargeTotal } = allowanceCharges(
|
||||
rateGroups,
|
||||
order.subtotal,
|
||||
kind === "storno" ? order.discountAmount : 0,
|
||||
kind === "storno" ? order.shippingCost : 0,
|
||||
);
|
||||
const lines = computeLines(effectiveItems);
|
||||
const rateGroups = computeRateGroups(lines, kind === "storno" ? order.discountAmount : 0, kind === "storno" ? order.shippingCost : 0);
|
||||
const allowanceChargeEntries = allowanceCharges(rateGroups);
|
||||
// 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": {
|
||||
@@ -356,15 +377,8 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order:
|
||||
"cac:PaymentMeans": paymentMeans(seller, "Überweisung"),
|
||||
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
|
||||
"cac:TaxTotal": taxTotal(rateGroups),
|
||||
"cac:LegalMonetaryTotal": {
|
||||
...amt("cbc:LineExtensionAmount", rawTotalNet),
|
||||
...(allowanceTotal > 0 ? amt("cbc:AllowanceTotalAmount", allowanceTotal) : {}),
|
||||
...(chargeTotal > 0 ? amt("cbc:ChargeTotalAmount", chargeTotal) : {}),
|
||||
...amt("cbc:TaxExclusiveAmount", totalNet),
|
||||
...amt("cbc:TaxInclusiveAmount", totalNet + totalTax),
|
||||
...amt("cbc:PayableAmount", grandTotal),
|
||||
} as unknown as UblInvoice["cac:LegalMonetaryTotal"],
|
||||
"cac:InvoiceLine": invoiceLines(lines.map(({ item, effectiveQuantity }) => ({ ...item, quantity: effectiveQuantity }))),
|
||||
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents),
|
||||
"cac:InvoiceLine": invoiceLines(lines),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+2
-9
@@ -1,11 +1,5 @@
|
||||
export type TaxBreakdownLine = { quantity: number; unitPrice: number; taxRatePercent: number };
|
||||
// `rawGross`/`rawNet` are the group's own raw (pre-shipping/discount) line
|
||||
// total — added for the e-invoice mapper (einvoice/buildEInvoiceData.ts),
|
||||
// which needs to build EN16931's explicit per-rate `cac:AllowanceCharge`
|
||||
// entries for shipping/discount rather than folding them silently into
|
||||
// `net`/`gross`; the visual PDF templates only ever destructure
|
||||
// `rate`/`net`/`tax`/`gross`, so adding fields here doesn't affect them.
|
||||
export type TaxBreakdownGroup = { rate: number; net: number; tax: number; gross: number; rawGross: number; rawNet: number };
|
||||
export type TaxBreakdownGroup = { rate: number; net: number; tax: number; gross: number };
|
||||
|
||||
// Groups line items by their effective VAT rate, then scales each group's
|
||||
// gross total by however much shipping/discount moved the grand total away
|
||||
@@ -29,8 +23,7 @@ export function computeTaxBreakdown(
|
||||
.map(([rate, lineGross]) => {
|
||||
const gross = lineGross * scale;
|
||||
const net = gross / (1 + rate / 100);
|
||||
const rawNet = lineGross / (1 + rate / 100);
|
||||
return { rate, net, tax: gross - net, gross, rawGross: lineGross, rawNet };
|
||||
return { rate, net, tax: gross - net, gross };
|
||||
})
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user