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 // ----------------------------------------------------------------