diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts
index f03c95e..5df0815 100644
--- a/app/services/invoice/composeInvoice.ts
+++ b/app/services/invoice/composeInvoice.ts
@@ -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,
diff --git a/app/services/invoice/generateInvoice.server.tsx b/app/services/invoice/generateInvoice.server.tsx
index 5445f99..8633aba 100644
--- a/app/services/invoice/generateInvoice.server.tsx
+++ b/app/services/invoice/generateInvoice.server.tsx
@@ -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({
diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx
index b803339..c72095b 100644
--- a/app/services/invoice/pdf/InvoiceDocument.tsx
+++ b/app/services/invoice/pdf/InvoiceDocument.tsx
@@ -446,7 +446,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
{invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null}
- ) : !invoice.paid && (
+ ) : invoice.requiresPayment && (
{invoice.dueDate
? t.paymentTerms(
@@ -457,7 +457,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
)}
- {invoice.giroCodePngDataUrl && !invoice.paid && (
+ {invoice.giroCodePngDataUrl && invoice.requiresPayment && (
diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts
index a5b0107..cf4dcef 100644
--- a/app/services/invoice/types.ts
+++ b/app/services/invoice/types.ts
@@ -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[];
diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts
index 8a9c24d..b97b29b 100644
--- a/scripts/render-sample.ts
+++ b/scripts/render-sample.ts
@@ -507,6 +507,61 @@ async function main() {
assert("storno PDF > 5 KB", stornoBuf.length > 5_000, `actual ${stornoBuf.length}`);
console.log(` → wrote ${stornoOut} (${stornoBuf.length} bytes)`);
+ // ----------------------------------------------------------------
+ // Refunded order: GiroCode + payment-terms must be suppressed
+ // ----------------------------------------------------------------
+ console.log("• Refunded order (REFUNDED) suppresses GiroCode + payment terms");
+ {
+ const refundedOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "REFUNDED" };
+ const refundedVm = composeInvoice({
+ order: refundedOrder, settings: settings as never, invoiceNumber: "RE-1014",
+ });
+ assertEq("paymentStatus=refunded", refundedVm.paymentStatus, "refunded");
+ assert("requiresPayment=false for refunded", refundedVm.requiresPayment === false);
+ refundedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
+ // The orchestrator gates GiroCode generation on requiresPayment too —
+ // simulate that here by NOT attaching giroCodePngDataUrl. The PDF
+ // render-gate must independently refuse to render even if a stale
+ // QR data URL were attached, so set one anyway and verify both
+ // suppression layers.
+ refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
+ beneficiaryName: settings.companyName,
+ iban: settings.iban,
+ bic: settings.bic,
+ amount: refundedVm.totals.gross,
+ remittance: refundedVm.number,
+ });
+ const refundedText = await pdfToText(await renderInvoicePdf(refundedVm));
+ assert("Refunded PDF does NOT show GiroCode caption",
+ !refundedText.includes("GiroCode"));
+ assert("Refunded PDF does NOT show DE payment terms",
+ !refundedText.includes("Bitte überweise"));
+ assert("Refunded PDF still shows the 'Erstattet' status row",
+ refundedText.includes("Erstattet"));
+ }
+
+ // Same gating must apply to PAID orders.
+ console.log("• Paid order (PAID) suppresses GiroCode + payment terms");
+ {
+ const paidOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "PAID" };
+ const paidVm = composeInvoice({
+ order: paidOrder, settings: settings as never, invoiceNumber: "RE-1015",
+ });
+ assert("requiresPayment=false for paid", paidVm.requiresPayment === false);
+ paidVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
+ paidVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
+ beneficiaryName: settings.companyName,
+ iban: settings.iban,
+ bic: settings.bic,
+ amount: paidVm.totals.gross,
+ remittance: paidVm.number,
+ });
+ const paidText = await pdfToText(await renderInvoicePdf(paidVm));
+ assert("Paid PDF does NOT show GiroCode caption", !paidText.includes("GiroCode"));
+ assert("Paid PDF does NOT show DE payment terms", !paidText.includes("Bitte überweise"));
+ assert("Paid PDF shows the 'Bezahlt' status row", paidText.includes("Bezahlt"));
+ }
+
// ----------------------------------------------------------------
// Footer note translation
// ----------------------------------------------------------------