Move §19/§4-Nr.-1b-UStG notice out of the summary card
Validate e-invoices / mustang (push) Successful in 22s

Same fix invoicePdf.tsx's own unpaid-notice row already got: the
exemption/Kleinunternehmer notice used to live inside summaryBox's
narrow, right-aligned column, squeezing what's actually a full
sentence. Now a full-width sibling row below the card, same treatment
in both invoicePdf.tsx and correctionInvoicePdf.tsx, and ordered ahead
of the unpaid-notice row when both apply.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-25 19:48:08 +00:00
parent b3bd5c3f07
commit bcdf8d2889
4 changed files with 67 additions and 35 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing stra
`InvoiceOrder.vatExempt`/`CorrectionInvoiceOrder.vatExempt` (optional, default falsy) — set by the consuming app after its own checkout confirms (live VIES lookup) that a sale qualifies as an innergemeinschaftliche Lieferung. This package never decides the exemption itself — by the time an order reaches these renderers, every item's `unitPrice` is already de-grossed (net) and `taxRatePercent` already `0`; `vatExempt` only controls **display**:
- Visual PDF (`invoicePdf.tsx`/`correctionInvoicePdf.tsx`): the "enthält X% MwSt." annotation under Gesamt becomes "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead.
- Visual PDF (`invoicePdf.tsx`/`correctionInvoicePdf.tsx`): the "enthält X% MwSt." annotation under Gesamt becomes "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)" instead — rendered as its own full-width row below the summary card (same treatment as the Vorkasse/unpaid notice), not squeezed into the card's narrow right-aligned column. Moved out 2026-07-25, at the user's explicit request.
- E-invoice XML (`buildEInvoiceData.ts`): VAT category `K` + VATEX-EU-IC exemption reason — see "E-invoicing" above for exactly where those fields are (and aren't) allowed to live.
See the frontend repo's own README ("VAT exemption" section) for the actual VIES lookup, de-grossing math, and checkout UI this feeds from, and the Payload backend's README ("B2B checkout & VAT exemption") for the persisted `Orders.vatExempt`/`vatIdValidatedAt` fields and audit-trail reasoning.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@einfach-produktiv/invoicing",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"description": "Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, consumed as a git dependency by both the einfach-produktiv frontend and the payload backend — not published to npm.",
"type": "module",
+25 -10
View File
@@ -96,6 +96,11 @@ const styles = StyleSheet.create({
vatNoteRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 1 },
vatNoteLabel: { fontSize: 8, color: TEXT_MUTED },
vatNoteValue: { fontSize: 8, color: TEXT_MUTED },
// Same fix as invoicePdf.tsx's own taxExemptionNoticeRow/Text — the
// §19/§4-Nr.-1b-UStG notice moved out of the narrow summaryBox card to
// its own full-width sibling row below it, 2026-07-25.
taxExemptionNoticeText: { fontSize: 9, lineHeight: 1.5, color: TEXT_MUTED, textAlign: "left" },
taxExemptionNoticeRow: { width: "100%", alignSelf: "stretch", marginTop: 20 },
// `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
@@ -346,20 +351,19 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
ordering fix as invoicePdf.tsx: "enthält X% MwSt."
describes Gesamt, not Netto (which has the tax already
taken out), so it must not read as attached to Netto. */}
<View style={styles.vatNotes}>
{order.kleinunternehmer ? (
<Text style={styles.vatNoteLabel}>Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</Text>
) : order.vatExempt ? (
<Text style={styles.vatNoteLabel}>Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</Text>
) : (
rateGroups.map((g) => (
{/* Exempt/Kleinunternehmer orders get their own full-width
notice below the card instead (see taxExemptionNoticeRow
below) — same fix as invoicePdf.tsx. */}
{!order.kleinunternehmer && !order.vatExempt && (
<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>
)}
{/* Netto — same reasoning as invoicePdf.tsx's own row: a
single well-defined figure (Gesamt minus every rate's own
tax, summed) regardless of how many rates are present.
@@ -383,6 +387,17 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
)}
</View>
</View>
{/* Sibling of `summary`, not nested inside it — same fix as
invoicePdf.tsx's own taxExemptionNoticeRow. */}
{(order.kleinunternehmer || order.vatExempt) && (
<View style={styles.taxExemptionNoticeRow}>
<Text style={styles.taxExemptionNoticeText}>
{order.kleinunternehmer
? "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet."
: "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)"}
</Text>
</View>
)}
</View>
<View style={styles.footer} fixed>
+40 -23
View File
@@ -64,6 +64,16 @@ const styles = StyleSheet.create({
// important instruction than a one-line confirmation, needs to read as
// its own section, not crowd the summary card above it.
unpaidNoticeRow: { width: "100%", alignSelf: "stretch", marginTop: 20 },
// Same full-width-below-the-card treatment as unpaidNoticeText/Row
// above, same reasoning: the §19/§4-Nr.-1b-UStG notice used to live
// *inside* summaryBox (see vatNotes' own comment), where its narrow
// right-aligned column squeezed what's actually an important legal
// statement about the invoice as a whole, not a figure belonging to the
// Zwischensumme/Rabatt/Versand/Gesamt build-up. Moved out to its own
// sibling row 2026-07-25, at the user's explicit request, once the
// unpaid-notice fix above had already established the pattern.
taxExemptionNoticeText: { fontSize: 9, lineHeight: 1.5, color: TEXT_MUTED, textAlign: "left" },
taxExemptionNoticeRow: { width: "100%", alignSelf: "stretch", marginTop: 20 },
table: { borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: BORDER, marginTop: 8, marginBottom: 16 },
tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 },
// Every item row shares this same tinted background now — no more
@@ -416,30 +426,20 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>{formatPrice(order.total)}</Text>
</View>
<View style={styles.vatNotes}>
{/* Exempt orders already have every item at 0% (see this
file's own InvoiceOrder.vatExempt comment) — "enthält
0% MwSt.: 0,00 €" would be a meaningless thing to print,
so this replaces the whole per-rate breakdown with the
actual legal basis instead. Kept directly under Gesamt
(not after Netto) — "enthält X% MwSt." describes what's
contained in Gesamt; sandwiching Netto between the two
made it read as if Netto were what contained the tax,
which is backwards (Netto is the figure with tax
already taken out). */}
{order.kleinunternehmer ? (
<Text style={styles.vatNoteLabel}>Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</Text>
) : order.vatExempt ? (
<Text style={styles.vatNoteLabel}>Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</Text>
) : (
rateGroups.map((g) => (
{/* Exempt/Kleinunternehmer orders get their own full-width
notice below the card instead (see taxExemptionNoticeRow
below) — this per-rate breakdown only ever applies to the
normal, non-exempt case now. */}
{!order.kleinunternehmer && !order.vatExempt && (
<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>
)}
{/* Netto (Gesamt minus every rate's own tax, summed — a
single well-defined figure regardless of how many
distinct rates the order actually has) — businesses read
@@ -477,9 +477,26 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
</View>
)}
</View>
{/* Sibling of `summary`, not nested inside it — `summary`'s own
`alignItems: "flex-end"` is meant for the narrow paid-badge
case, not this full-width, multi-sentence instruction. */}
{/* Both siblings of `summary`, not nested inside it — `summary`'s
own `alignItems: "flex-end"` is meant for the narrow
paid-badge case, not these full-width, multi-sentence
notices. Legal notice first, payment instruction second —
when both apply (an unpaid, innergemeinschaftlich-exempt
Vorkasse order), the tax-exemption statement is the more
legally foundational one of the two. Yoga (react-pdf's
flexbox) doesn't collapse margins the way CSS does, so
`summary`'s own marginBottom: 24 plus each row's own
marginTop: 20 below still read as two clearly separated
blocks, not squeezed together. */}
{(order.kleinunternehmer || order.vatExempt) && (
<View style={styles.taxExemptionNoticeRow}>
<Text style={styles.taxExemptionNoticeText}>
{order.kleinunternehmer
? "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet."
: "Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)"}
</Text>
</View>
)}
{!paid && (
<View style={styles.unpaidNoticeRow}>
<Text style={styles.unpaidNoticeText}>