Fix invoice/correction-invoice summary: rows now actually sum to Gesamt
Validate e-invoices / mustang (push) Successful in 21s

The Netto/MwSt. rows were computed per tax rate with shipping and
discount already proportionally folded in (computeTaxBreakdown's
scale factor — tax-correct, since ancillary costs are apportioned
across rates), but Rabatt/Versand were ALSO shown as their own
separate rows on top. Adding up the visible rows never actually
matched the printed Gesamt — off by exactly the shipping (and any
discount) amount. Confirmed against a real production order
(#EP-0006-ZDX6's Stornorechnung).

Restructured both templates into a genuinely additive chain
(Zwischensumme → Rabatt → Versand → Gesamt, using the raw order
fields directly) with the per-rate tax now shown as an "enthält X%
MwSt." annotation below Gesamt — informational, not part of the sum.
Mirrors the pattern the website's own VatBreakdown component already
uses on /cart and /checkout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 18:50:23 +00:00
parent 48da8c069a
commit 01374c47cd
2 changed files with 88 additions and 54 deletions
+48 -26
View File
@@ -78,15 +78,22 @@ const styles = StyleSheet.create({
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
summary: { alignItems: "flex-end", marginBottom: 24 },
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
// See invoicePdf.tsx's own comment on this same style — groups rows that
// belong together with a bit of breathing room between groups.
summaryCluster: { marginBottom: 6 },
summaryRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2 },
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
summaryValue: { fontSize: 10 },
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
// Same "enthält X% MwSt." annotation-under-Gesamt framing as
// invoicePdf.tsx now uses, fixing the identical double-counting bug this
// file had: the old layout showed Versand as its own row AND ALSO
// folded into the per-rate Netto/MwSt rows below it (via
// computeTaxBreakdown's scale factor) — the visible rows never summed
// to the printed Gesamt. Fixed 2026-07-23.
vatNotes: { marginTop: 6 },
vatNoteRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 1 },
vatNoteLabel: { fontSize: 8, color: TEXT_MUTED },
vatNoteValue: { fontSize: 8, color: TEXT_MUTED },
// `fixed` (on the element, see CorrectionInvoiceDocument below) — always
// pinned to the bottom of the page regardless of content height above,
// same as invoicePdf.tsx's own footer. (One of the two pre-package
@@ -288,34 +295,49 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
<View style={styles.summary}>
<View style={styles.summaryBox}>
{/* Storno reverses the full original invoice, shipping
included (see this file's top-of-file comment on why the
two kinds differ) — shown as its own line instead of
silently folded into the tax-rate groups below, same as
the original invoice's own Versand row. Gutschrift never
reverses shipping, so this never renders for it. */}
{kind === "storno" && order.shippingCost > 0 && (
<View style={[styles.summaryRow, styles.summaryCluster]}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.shippingCost)}</Text>
</View>
{/* Genuinely additive chain for Storno — Zwischensumme, the
discount reversal ("Rabatt (entfällt)", a positive
add-back since the original discount no longer applies
once everything is undone), and Versand always sum
exactly to Gesamt below. Gutschrift never reverses
shipping/discount (see this file's top comment), so its
Gesamt is just the returned lines' own total — nothing to
itemize above it. Previously showed Versand as its own
row AND folded into the per-rate Netto/MwSt rows below
(double-counted, visible rows never summed to Gesamt) —
fixed 2026-07-23, same as invoicePdf.tsx. */}
{kind === "storno" && (
<>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Zwischensumme</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.subtotal)}</Text>
</View>
{order.discountAmount > 0 && (
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Rabatt (entfällt)</Text>
<Text style={styles.summaryValue}>+{formatPrice(order.discountAmount)}</Text>
</View>
)}
{order.shippingCost > 0 && (
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.shippingCost)}</Text>
</View>
)}
</>
)}
{rateGroups.map((g) => (
<View style={styles.summaryCluster} key={g.rate}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Netto</Text>
<Text style={styles.summaryValue}>-{formatPrice(g.net)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
<Text style={styles.summaryValue}>-{formatPrice(g.tax)}</Text>
</View>
</View>
))}
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>-{formatPrice(grandTotal)}</Text>
</View>
<View style={styles.vatNotes}>
{rateGroups.map((g) => (
<View style={styles.vatNoteRow} key={g.rate}>
<Text style={styles.vatNoteLabel}>enthält {g.rate}% MwSt.</Text>
<Text style={styles.vatNoteValue}>-{formatPrice(g.tax)}</Text>
</View>
))}
</View>
</View>
</View>
</View>
+40 -28
View File
@@ -70,18 +70,28 @@ const styles = StyleSheet.create({
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
summary: { alignItems: "flex-end", marginBottom: 24 },
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
// Groups rows that belong together (Rabatt+Versand; each rate's own
// Netto/MwSt pair) with a bit of breathing room between groups, while
// rows *within* a group stay at the original tight spacing — a visual
// cue that e.g. "Netto"/"zzgl. 19% MwSt." are one line item's two halves,
// not just an undifferentiated stack of numbers.
summaryCluster: { marginBottom: 6 },
summaryRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2 },
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
summaryValue: { fontSize: 10 },
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
// "Enthält X% MwSt.: Y €" annotations under Gesamt — same "contained
// within the total, not an additional deduction" framing the website's
// own VatBreakdown component already uses on /cart and /checkout.
// Deliberately NOT part of the summaryRow additive chain above (Zwischen-
// summe → Rabatt → Versand → Gesamt): every row in that chain sums
// exactly to Gesamt on its own; the tax-rate breakdown is informational
// context about what portion of Gesamt is tax, not a further adjustment.
// A previous version showed the same per-rate net/tax figures as their
// own additive-looking rows alongside Rabatt/Versand, which double-
// counted shipping/discount (already proportionally folded into each
// rate's own net/tax by computeTaxBreakdown) — the visible rows never
// actually summed to the printed Gesamt. Fixed 2026-07-23.
vatNotes: { marginTop: 6 },
vatNoteRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 1 },
vatNoteLabel: { fontSize: 8, color: TEXT_MUTED },
vatNoteValue: { fontSize: 8, color: TEXT_MUTED },
// Moved here (below the summary card) from the top meta row — reads more
// naturally right next to the amount it's confirming was paid, and keeps
// the meta row itself to just the three reference numbers. Deliberately
@@ -340,34 +350,36 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
<View style={styles.summary}>
<View style={styles.summaryBox}>
<View style={styles.summaryCluster}>
{order.discountAmount > 0 && (
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Rabatt{order.discountCode ? ` (${order.discountCode})` : ""}</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.discountAmount)}</Text>
</View>
)}
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}</Text>
</View>
{/* Genuinely additive chain — Zwischensumme (raw item gross,
unscaled) Rabatt + Versand always equals Gesamt exactly,
unlike the old layout this replaced (see vatNotes' own
comment above). */}
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Zwischensumme</Text>
<Text style={styles.summaryValue}>{formatPrice(order.subtotal)}</Text>
</View>
{rateGroups.map((g) => (
<View style={styles.summaryCluster} key={g.rate}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Netto</Text>
<Text style={styles.summaryValue}>{formatPrice(g.net)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
<Text style={styles.summaryValue}>{formatPrice(g.tax)}</Text>
</View>
{order.discountAmount > 0 && (
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Rabatt{order.discountCode ? ` (${order.discountCode})` : ""}</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.discountAmount)}</Text>
</View>
))}
)}
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}</Text>
</View>
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>{formatPrice(order.total)}</Text>
</View>
<View style={styles.vatNotes}>
{rateGroups.map((g) => (
<View style={styles.vatNoteRow} key={g.rate}>
<Text style={styles.vatNoteLabel}>enthält {g.rate}% MwSt.</Text>
<Text style={styles.vatNoteValue}>{formatPrice(g.tax)}</Text>
</View>
))}
</View>
</View>
{paid && (
<View style={styles.paidBadgeRow}>