From 40ee8957192b0349c9f23da9d51f4255bae2f547 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Fri, 15 May 2026 16:45:09 +0200 Subject: [PATCH] feat(invoice): show refund + outstanding-amount rows on the PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Shopify order has been (partially or fully) refunded the PDF now mirrors the order-page totals block: Gesamtbetrag brutto 629,95 EUR Zurückerstattet -629,95 EUR Offener Betrag 0,00 EUR So the customer immediately sees that nothing is owed any more, even though the original invoice gross stays unchanged for tax-document correctness (the refund is itemised as a separate row, not subtracted from the line totals). Plumbing: - GraphQL: added `totalRefundedSet` to OrderForInvoice query. - RawOrderForInvoice: new optional `totalRefundedSet` field (null for drafts/offers — they never have refunds). - InvoiceViewModel: new `refundedAmount: number` (gross, in the same currency as `totals.gross`). Always present, 0 for storno and offer documents and for non-refunded invoices. - composeInvoice parses the gross refund out of `totalRefundedSet` (defensive parseFloat, clamped to >= 0). - InvoiceDocument renders the two extra rows under `grossTotal` only when `refundedAmount > 0`. Uses the existing total-row styles for visual consistency. - i18n: added `refundedLabel` ("Zurückerstattet" / "Refunded") and `outstandingLabel` ("Offener Betrag" / "Outstanding amount") to both languages. Verification: render-sample fixture now mirrors the full gross as refunded and asserts the PDF text contains "Zurückerstattet", "Offener Betrag", and "0,00 EUR" as the final outstanding row, on top of the previous suppressions (no GiroCode, no payment terms). tsc / smoke / tests / build all green. --- app/services/invoice/composeInvoice.ts | 7 +++++ app/services/invoice/i18n.ts | 12 +++++++++ .../invoice/loadDraftOrderForOffer.server.ts | 2 ++ .../invoice/loadOrderForInvoice.server.ts | 8 ++++++ app/services/invoice/pdf/InvoiceDocument.tsx | 16 +++++++++++ app/services/invoice/types.ts | 9 +++++++ scripts/render-sample.ts | 27 ++++++++++++++----- 7 files changed, 74 insertions(+), 7 deletions(-) diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index 5df0815..d44df90 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -94,6 +94,12 @@ export function composeInvoice({ // just whether the money was kept (`paid`) or returned (`refunded`). const requiresPayment = !storno && !offer && paymentStatus !== "paid" && paymentStatus !== "refunded"; + // Refunded gross amount, mirrored from Shopify's `totalRefundedSet`. + // Storno/offer documents don't carry a refund row — a storno *is* + // already the cancellation document, and offers have no payments yet. + const refundedAmount = storno || offer + ? 0 + : Math.max(0, parseFloat(order.totalRefundedSet?.shopMoney.amount ?? "0") || 0); const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter( (n) => typeof n === "string" && n.trim().length > 0, ); @@ -139,6 +145,7 @@ export function composeInvoice({ paid, paymentStatus, requiresPayment, + refundedAmount, paymentGatewayNames, orderName: order.name, separateShippingAddress, diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index 1a09b81..089d200 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -44,6 +44,14 @@ export interface InvoiceStrings { recipientLabel: string; amountLabel: string; referenceLabel: string; + /** Label for the refund row that appears below `grossTotal` when the + * order has been (partially or fully) refunded. Mirrors what Shopify + * shows on the order page ("Zurückerstattet" / "Refunded"). */ + refundedLabel: string; + /** Label for the final outstanding balance row (`grossTotal - + * refundedAmount`) shown when there has been a refund. "Offener + * Betrag" / "Outstanding amount". */ + outstandingLabel: string; addressHeading: string; contactHeading: string; legalHeading: string; @@ -159,6 +167,8 @@ const de: InvoiceStrings = { recipientLabel: "Empfänger", amountLabel: "Betrag", referenceLabel: "Referenz", + refundedLabel: "Zurückerstattet", + outstandingLabel: "Offener Betrag", addressHeading: "Adresse", contactHeading: "Kontakt", legalHeading: "Rechtliches", @@ -246,6 +256,8 @@ const en: InvoiceStrings = { recipientLabel: "Recipient", amountLabel: "Amount", referenceLabel: "Reference", + refundedLabel: "Refunded", + outstandingLabel: "Outstanding amount", addressHeading: "Address", contactHeading: "Contact", legalHeading: "Legal", diff --git a/app/services/invoice/loadDraftOrderForOffer.server.ts b/app/services/invoice/loadDraftOrderForOffer.server.ts index 4e9e75c..7467c1c 100644 --- a/app/services/invoice/loadDraftOrderForOffer.server.ts +++ b/app/services/invoice/loadDraftOrderForOffer.server.ts @@ -172,6 +172,8 @@ export async function loadDraftOrderForOffer( subtotalSet: draft.subtotalPriceSet, totalTaxSet: draft.totalTaxSet, totalPriceSet: draft.totalPriceSet, + // Drafts have no concept of refunds. + totalRefundedSet: null, taxLines: draft.taxLines || [], lineItems: (draft.lineItems?.edges || []).map((e) => { const node = e.node; diff --git a/app/services/invoice/loadOrderForInvoice.server.ts b/app/services/invoice/loadOrderForInvoice.server.ts index 7591966..a4049ab 100644 --- a/app/services/invoice/loadOrderForInvoice.server.ts +++ b/app/services/invoice/loadOrderForInvoice.server.ts @@ -38,6 +38,11 @@ export interface RawOrderForInvoice { subtotalSet: { shopMoney: RawMoney } | null; totalTaxSet: { shopMoney: RawMoney } | null; totalPriceSet: { shopMoney: RawMoney } | null; + /** Cumulative gross amount that has been refunded against this order + * via Shopify (sum of all refund transactions). Always present on + * real orders — may be `null` for synthetic / draft fixtures, in + * which case the composer treats it as 0. */ + totalRefundedSet: { shopMoney: RawMoney } | null; purchasingEntity: { company?: { name: string; @@ -153,6 +158,7 @@ const QUERY = `#graphql subtotalPriceSet { shopMoney { amount currencyCode } } totalTaxSet { shopMoney { amount currencyCode } } totalPriceSet { shopMoney { amount currencyCode } } + totalRefundedSet { shopMoney { amount currencyCode } } taxLines { title rate @@ -247,6 +253,7 @@ interface RawAdminResponse { subtotalPriceSet: { shopMoney: RawMoney } | null; totalTaxSet: { shopMoney: RawMoney } | null; totalPriceSet: { shopMoney: RawMoney } | null; + totalRefundedSet: { shopMoney: RawMoney } | null; taxLines: RawTaxLine[]; discountCode: string | null; discountCodes: string[] | null; @@ -300,6 +307,7 @@ export async function loadOrderForInvoice( subtotalSet: order.subtotalPriceSet, totalTaxSet: order.totalTaxSet, totalPriceSet: order.totalPriceSet, + totalRefundedSet: order.totalRefundedSet ?? null, taxLines: order.taxLines || [], discountCodes: order.discountCodes && order.discountCodes.length > 0 ? order.discountCodes diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index c72095b..faad413 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -428,6 +428,22 @@ export function InvoiceDocument({ invoice }: DocProps) { {formatMoney(invoice.totals.gross, cur, invoice.language)} + {invoice.refundedAmount > 0 && ( + <> + + {t.refundedLabel} + + {formatMoney(-invoice.refundedAmount, cur, invoice.language)} + + + + {t.outstandingLabel} + + {formatMoney(invoice.totals.gross - invoice.refundedAmount, cur, invoice.language)} + + + + )} {invoice.notices.length > 0 && ( diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts index cf4dcef..3abc79a 100644 --- a/app/services/invoice/types.ts +++ b/app/services/invoice/types.ts @@ -57,6 +57,15 @@ export interface InvoiceViewModel { * original total. */ requiresPayment: boolean; + /** Cumulative gross amount that has been refunded against the + * underlying Shopify order, in the same currency as `totals.gross`. + * 0 when there has been no refund (the common case) or when the + * document is structurally not subject to refunds (storno / offer). + * When > 0 the renderer adds two extra rows beneath the gross total: + * a negative "Zurückerstattet" row and a final "Offener Betrag" + * row showing `gross - refundedAmount` so the printed PDF mirrors + * what the merchant sees on the Shopify order page. */ + refundedAmount: number; /** 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 b97b29b..388bfc6 100644 --- a/scripts/render-sample.ts +++ b/scripts/render-sample.ts @@ -218,6 +218,7 @@ function buildAtB2BOrder(): RawOrderForInvoice { subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } }, totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } }, totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } }, + totalRefundedSet: null, purchasingEntity: { company: { name: "Schmidhofer Dienstleistungen", @@ -512,18 +513,24 @@ async function main() { // ---------------------------------------------------------------- console.log("• Refunded order (REFUNDED) suppresses GiroCode + payment terms"); { - const refundedOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "REFUNDED" }; + const baseRefunded = buildAtB2BOrder(); + const refundedOrder = { + ...baseRefunded, + displayFinancialStatus: "REFUNDED", + // Mirror the full gross as refunded so the new "Offener Betrag" + // row should print 0,00 \u20ac. + totalRefundedSet: baseRefunded.totalPriceSet, + }; 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); + assertNear("refundedAmount mirrors totalRefundedSet", refundedVm.refundedAmount, refundedVm.totals.gross); 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. + // The orchestrator gates GiroCode generation on requiresPayment too \u2014 + // simulate a stale QR data URL anyway and verify the PDF render-gate + // independently refuses to render it. refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({ beneficiaryName: settings.companyName, iban: settings.iban, @@ -535,9 +542,15 @@ async function main() { assert("Refunded PDF does NOT show GiroCode caption", !refundedText.includes("GiroCode")); assert("Refunded PDF does NOT show DE payment terms", - !refundedText.includes("Bitte überweise")); + !refundedText.includes("Bitte \u00fcberweise")); assert("Refunded PDF still shows the 'Erstattet' status row", refundedText.includes("Erstattet")); + assert("Refunded PDF shows the 'Zur\u00fcckerstattet' totals row", + refundedText.includes("Zur\u00fcckerstattet")); + assert("Refunded PDF shows the 'Offener Betrag' final row", + refundedText.includes("Offener Betrag")); + assert("Refunded PDF shows 0,00 EUR as outstanding", + refundedText.includes("0,00 EUR")); } // Same gating must apply to PAID orders.