From 91c1a74c1b9a2a69b3e7c83431be41a8a08e09a1 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Fri, 15 May 2026 16:56:45 +0200 Subject: [PATCH] fix(invoice): partial refund stays "Bezahlt" + use "Endbetrag" final-row label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs surfaced when a paid order was partially refunded (Shopify flips `displayFinancialStatus` to PARTIALLY_REFUNDED as soon as *any* refund is posted, even a small one): 1. The status row showed "Erstattet" / "Refunded" even though the customer paid in full and the merchant kept the difference. The correct status is "Bezahlt" / "Paid" — only when the refund equals (or, defensively, exceeds) the gross is the order genuinely refunded. 2. The final row beneath the new refund block was labelled "Offener Betrag" / "Outstanding amount", falsely suggesting the customer still owes the kept portion. For an order that has been refunded but is no longer owing anything, that row is just the final amount the merchant kept — "Endbetrag" / "Total". Truth table now implemented: displayFinancialStatus | refunded | paymentStatus | final-row label -----------------------+--------------+---------------+----------------- PAID | 0 | paid | (no refund rows) PAID | >0 | paid | Endbetrag PARTIALLY_REFUNDED | < gross | paid (NEW) | Endbetrag (NEW) PARTIALLY_REFUNDED | == gross | refunded | Endbetrag REFUNDED | == gross | refunded | Endbetrag PARTIALLY_PAID | 0 | partial | (no refund rows) PARTIALLY_PAID | >0 (exotic) | partial | Offener Betrag PENDING/AUTHORIZED/etc | 0 | unpaid | (no refund rows) storno / offer | 0 (forced) | n/a | n/a Implementation: - composeInvoice.ts: after computing refundedAmount, reclassify paymentStatus="refunded" → "paid" when 0 < refundedAmount < totals.gross. requiresPayment is derived from paymentStatus, so it correctly stays false for partial-refund-on-paid (no GiroCode, no payment terms — nothing is owed). - i18n.ts: new `finalAmountLabel` ("Endbetrag" / "Total") in both languages. - InvoiceDocument.tsx: the final-row label now picks outstandingLabel vs. finalAmountLabel based on requiresPayment, so PARTIALLY_PAID with a refund still says "Offener Betrag" while PARTIALLY_REFUNDED says "Endbetrag". Verification: render-sample now runs four refund scenarios — paid + no refund (regression guard), full refund (status=Erstattet, final row=Endbetrag 0,00 EUR), partial refund on a paid order (status= Bezahlt, final row=Endbetrag, no Erstattet), and PARTIALLY_REFUNDED with refund==gross (status stays refunded). tsc / smoke / tests / build all green. --- app/services/invoice/composeInvoice.ts | 29 ++++++--- app/services/invoice/i18n.ts | 9 +++ app/services/invoice/pdf/InvoiceDocument.tsx | 4 +- scripts/render-sample.ts | 64 +++++++++++++++++++- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index d44df90..b231aba 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -87,19 +87,34 @@ export function composeInvoice({ : undefined; 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"; // 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); + let paymentStatus = derivePaymentStatus(order.displayFinancialStatus); + // Reclassification: Shopify flips `displayFinancialStatus` to + // PARTIALLY_REFUNDED as soon as *any* refund is posted against a + // paid order, even when the customer only got back a small fraction. + // For our purposes such an order is still "paid" — the merchant kept + // the difference — and showing "Erstattet" / "Refunded" in the + // status row would falsely imply the customer got everything back. + // Only when the refund equals (or, defensively, exceeds) the gross + // do we keep the "refunded" status. + if ( + paymentStatus === "refunded" && + refundedAmount > 0 && + refundedAmount < totals.gross + ) { + paymentStatus = "paid"; + } + // 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, ); diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index 089d200..735e39e 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -52,6 +52,13 @@ export interface InvoiceStrings { * refundedAmount`) shown when there has been a refund. "Offener * Betrag" / "Outstanding amount". */ outstandingLabel: string; + /** Label used in place of `outstandingLabel` when the order has been + * refunded but nothing is actually owed any more (i.e. the customer + * paid in full and got back only part — or all — of the gross via + * refunds). "Endbetrag" / "Total". The distinction matters for + * PARTIALLY_REFUNDED orders, where calling the kept portion + * "outstanding" would falsely suggest the customer still owes it. */ + finalAmountLabel: string; addressHeading: string; contactHeading: string; legalHeading: string; @@ -169,6 +176,7 @@ const de: InvoiceStrings = { referenceLabel: "Referenz", refundedLabel: "Zurückerstattet", outstandingLabel: "Offener Betrag", + finalAmountLabel: "Endbetrag", addressHeading: "Adresse", contactHeading: "Kontakt", legalHeading: "Rechtliches", @@ -258,6 +266,7 @@ const en: InvoiceStrings = { referenceLabel: "Reference", refundedLabel: "Refunded", outstandingLabel: "Outstanding amount", + finalAmountLabel: "Total", addressHeading: "Address", contactHeading: "Contact", legalHeading: "Legal", diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index faad413..4f4115a 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -437,7 +437,9 @@ export function InvoiceDocument({ invoice }: DocProps) { - {t.outstandingLabel} + + {invoice.requiresPayment ? t.outstandingLabel : t.finalAmountLabel} + {formatMoney(invoice.totals.gross - invoice.refundedAmount, cur, invoice.language)} diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts index 388bfc6..45655b9 100644 --- a/scripts/render-sample.ts +++ b/scripts/render-sample.ts @@ -547,8 +547,8 @@ async function main() { 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 labels the final row 'Endbetrag' (nothing is outstanding)", + refundedText.includes("Endbetrag") && !refundedText.includes("Offener Betrag")); assert("Refunded PDF shows 0,00 EUR as outstanding", refundedText.includes("0,00 EUR")); } @@ -575,6 +575,66 @@ async function main() { assert("Paid PDF shows the 'Bezahlt' status row", paidText.includes("Bezahlt")); } + // ---------------------------------------------------------------- + // Partial refund on a paid order: status stays "Bezahlt", the final + // row is labelled "Endbetrag" (not "Offener Betrag"), and the kept + // amount is shown. + // ---------------------------------------------------------------- + console.log("• Partial refund on a paid order (PARTIALLY_REFUNDED)"); + { + const basePartial = buildAtB2BOrder(); + const grossStr = basePartial.totalPriceSet?.shopMoney.amount ?? "0"; + const grossNum = parseFloat(grossStr); + const partialRefund = +(grossNum * 0.25).toFixed(2); + const partialOrder = { + ...basePartial, + displayFinancialStatus: "PARTIALLY_REFUNDED", + totalRefundedSet: { shopMoney: { amount: partialRefund.toFixed(2), currencyCode: "EUR" } }, + }; + const partialVm = composeInvoice({ + order: partialOrder, settings: settings as never, invoiceNumber: "RE-1016", + }); + assertEq("paymentStatus reclassified to paid (partial refund < gross)", + partialVm.paymentStatus, "paid"); + assert("requiresPayment=false for partially refunded paid order", + partialVm.requiresPayment === false); + assertNear("refundedAmount mirrors partial refund", + partialVm.refundedAmount, partialRefund); + partialVm.issuer.logoDataUrl = vm.issuer.logoDataUrl; + const partialText = await pdfToText(await renderInvoicePdf(partialVm)); + assert("Partial-refund PDF shows 'Bezahlt' status row (not Erstattet)", + partialText.includes("Bezahlt") && !partialText.includes("Erstattet")); + assert("Partial-refund PDF shows 'Zurückerstattet' totals row", + partialText.includes("Zurückerstattet")); + assert("Partial-refund PDF labels the final row 'Endbetrag' (not 'Offener Betrag')", + partialText.includes("Endbetrag") && !partialText.includes("Offener Betrag")); + assert("Partial-refund PDF does NOT show GiroCode caption", + !partialText.includes("GiroCode")); + assert("Partial-refund PDF does NOT show DE payment terms", + !partialText.includes("Bitte überweise")); + } + + // ---------------------------------------------------------------- + // Defensive: PARTIALLY_REFUNDED where the refund equals the gross + // (Shopify hasn't flipped to REFUNDED yet) must still classify as + // refunded. + // ---------------------------------------------------------------- + console.log("• PARTIALLY_REFUNDED with refund==gross stays 'refunded'"); + { + const baseFull = buildAtB2BOrder(); + const fullRefundOrder = { + ...baseFull, + displayFinancialStatus: "PARTIALLY_REFUNDED", + totalRefundedSet: baseFull.totalPriceSet, + }; + const vmFull = composeInvoice({ + order: fullRefundOrder, settings: settings as never, invoiceNumber: "RE-1017", + }); + assertEq("paymentStatus stays refunded when refund==gross", + vmFull.paymentStatus, "refunded"); + assert("requiresPayment still false", vmFull.requiresPayment === false); + } + // ---------------------------------------------------------------- // Footer note translation // ----------------------------------------------------------------