fix(invoice): suppress GiroCode + payment terms for refunded (and paid) orders

User reported that fully refunded orders still rendered a SEPA GiroCode
QR asking the customer to wire the original total. The existing gate
("!viewModel.paid") only excluded literally-PAID orders; REFUNDED,
PARTIALLY_REFUNDED, VOIDED, AUTHORIZED, etc. all sneaked through and
produced a confusing payment request for an order whose outstanding
balance is in fact 0.

Root cause: the view model exposed two related but ambiguous flags
("paid" and "paymentStatus") and the renderer mixed them
inconsistently. Both the GiroCode generation step in
generateInvoice.server.tsx AND the GiroCode/payment-terms render gates
in InvoiceDocument.tsx checked the wrong one.

Fix: introduce a single derived "requiresPayment" flag on the view
model (composeInvoice.ts) that is true only when:
  - the document is a regular invoice (not a storno or an offer), AND
  - paymentStatus is neither "paid" nor "refunded".

That single flag now drives:
  - GiroCode QR generation (skip QR fetch for paid/refunded)
  - GiroCode block render in the PDF
  - payment-terms paragraph render in the PDF

The existing "Zahlstatus: Erstattet" / "Payment status: Refunded"
meta-row continues to communicate the refund visually — the change
just removes the contradictory call-to-pay.

Side benefits:
  - Storno (cancellation invoice) PDFs no longer emit the German
    "Bitte überweise …" payment-terms paragraph (kind=storno was
    falling through the same not-paid branch).
  - Same suppression for fully PAID orders (covered by a second smoke
    fixture) so the QR doesn't suggest re-payment after the fact.

Verification: new smoke fixtures (REFUNDED + PAID) build a real
GiroCode data URL and verify the PDF text neither contains the
"GiroCode" caption nor the "Bitte überweise" German payment terms,
while still showing the corresponding "Erstattet" / "Bezahlt" status
row. tsc / smoke / tests / build all green.
This commit is contained in:
Gerhard Scheikl
2026-05-15 16:33:34 +02:00
parent fe54f6e64a
commit 9c732618e1
5 changed files with 83 additions and 4 deletions
+7
View File
@@ -88,6 +88,12 @@ export function composeInvoice({
const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID";
const paymentStatus = derivePaymentStatus(order.displayFinancialStatus);
// A document only requires payment when it's a regular invoice (not a
// storno or an offer) AND money is still actually owed. Refunded and
// paid orders both have a 0 outstanding balance — the difference is
// just whether the money was kept (`paid`) or returned (`refunded`).
const requiresPayment =
!storno && !offer && paymentStatus !== "paid" && paymentStatus !== "refunded";
const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter(
(n) => typeof n === "string" && n.trim().length > 0,
);
@@ -132,6 +138,7 @@ export function composeInvoice({
notices,
paid,
paymentStatus,
requiresPayment,
paymentGatewayNames,
orderName: order.name,
separateShippingAddress,
@@ -95,12 +95,15 @@ export async function generateInvoice(
// Product images for each line (best-effort, parallel, in-process cache).
await attachLineItemImages(viewModel.lines);
// GiroCode (only for invoices that are unpaid + IBAN configured + enabled).
// GiroCode (only when the invoice is actually outstanding + IBAN
// configured + enabled). `requiresPayment` already encodes "regular
// invoice AND money still owed" — so paid, refunded, storno and
// offer documents all skip QR generation and the PDF stays tidy.
if (
kind === "invoice" &&
settings.giroCodeEnabled &&
settings.iban &&
!viewModel.paid &&
viewModel.requiresPayment &&
viewModel.totals.gross > 0
) {
viewModel.giroCodePngDataUrl = await buildGiroCodeDataUrl({
+2 -2
View File
@@ -446,7 +446,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
<Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null}
</Text>
) : !invoice.paid && (
) : invoice.requiresPayment && (
<Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate
? t.paymentTerms(
@@ -457,7 +457,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
</Text>
)}
{invoice.giroCodePngDataUrl && !invoice.paid && (
{invoice.giroCodePngDataUrl && invoice.requiresPayment && (
<View style={styles.giroBlock}>
<Image src={invoice.giroCodePngDataUrl} style={styles.giroImage} />
<View>
+14
View File
@@ -43,6 +43,20 @@ export interface InvoiceViewModel {
/** Condensed payment status derived from Shopify's
* `displayFinancialStatus`. */
paymentStatus: PaymentStatus;
/**
* True when this document represents an *outstanding* request for
* money — i.e. the customer still owes the issuer something. False
* when the invoice has already been settled (`paid`), the order has
* been fully refunded (`refunded`), or the document is structurally
* not a payment request (offers, cancellation invoices).
*
* Drives the GiroCode generation gate, the GiroCode/payment-block
* render gate, and the payment-terms paragraph below the items
* table. Without this flag a fully refunded order would still be
* printed with a SEPA QR code asking the customer to wire the
* original total.
*/
requiresPayment: boolean;
/** Names of the payment gateways used (e.g. ["bogus"], ["manual",
* "shopify_payments"]). Empty when unknown / draft. */
paymentGatewayNames: string[];