From c5b6bfc20dc4e65364031ec509b318cbbb15ffdb Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Fri, 15 May 2026 18:12:06 +0200 Subject: [PATCH] refactor(invoice): drop dead paid/paidStamp; classify VOIDED; warn on unknown payment status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit cleanup of payment-status code paths uncovered while shipping the partial-refund fix: #1 Drop `viewModel.paid` (boolean). It was set from `displayFinancialStatus === "PAID"` and never read anywhere. With refunds in the picture it had become a footgun: a fully refunded order that started PAID would still satisfy `paid === true`, but `paymentStatus === "refunded"`. Callers should use `paymentStatus` / `requiresPayment` exclusively. #2 Remove the unused `paidStamp` translation ("BEZAHLT" / "PAID"). Defined in both locales but never rendered. #3 Classify VOIDED orders as a distinct `"voided"` payment status (rendered "Annulliert" / "Voided") instead of "unpaid". A voided order had its authorisation cancelled before capture — no money was received and none is owed. The previous "Offen" / "Outstanding" label combined with a GiroCode would have invited the customer to pay an order that's already been called off. `requiresPayment` now also excludes `"voided"`, so GiroCode + payment-terms paragraph are suppressed (mirrors the `"refunded"` treatment). "Annulliert" is used in German rather than "Storniert" to avoid confusion with our storno cancellation document concept. #6 `derivePaymentStatus` now logs a `console.warn` when it encounters a non-empty `displayFinancialStatus` value that isn't one of the documented Shopify enum members (PAID, PARTIALLY_PAID, REFUNDED, PARTIALLY_REFUNDED, VOIDED, PENDING, AUTHORIZED, EXPIRED). Future Shopify enum additions will surface in logs instead of silently mapping to "unpaid". EXPIRED stays mapped to "unpaid" — abandoned-checkout-style edge case left intentionally for a separate decision (#4 in the audit). Verification: render-sample now also exercises a VOIDED fixture (status row "Annulliert", no GiroCode, no payment terms). tsc / smoke / tests / build all green. --- app/services/invoice/composeInvoice.ts | 8 ++++-- app/services/invoice/i18n.ts | 39 ++++++++++++++++++++++---- app/services/invoice/types.ts | 1 - scripts/render-sample.ts | 23 +++++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index b231aba..748b6d0 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -86,7 +86,6 @@ export function composeInvoice({ ? addDays(invoiceDate, settings.paymentTermDays) : undefined; - const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID"; // 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. @@ -114,7 +113,11 @@ export function composeInvoice({ // 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"; + !storno && + !offer && + paymentStatus !== "paid" && + paymentStatus !== "refunded" && + paymentStatus !== "voided"; const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter( (n) => typeof n === "string" && n.trim().length > 0, ); @@ -157,7 +160,6 @@ export function composeInvoice({ lines, totals, notices, - paid, paymentStatus, requiresPayment, refundedAmount, diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index 735e39e..cd66eb2 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -66,13 +66,13 @@ export interface InvoiceStrings { emailLabel: string; webLabel: string; phoneLabel: string; - paidStamp: string; paymentMethodLabel: string; paymentStatusLabel: string; paymentStatusPaid: string; paymentStatusUnpaid: string; paymentStatusPartial: string; paymentStatusRefunded: string; + paymentStatusVoided: string; orderNumberLabel: string; shippingAddressHeading: string; shippingMethodLabel: string; @@ -97,7 +97,12 @@ export interface InvoiceStrings { /** Status displayed for the order's payment, derived from Shopify's * `displayFinancialStatus`. */ -export type PaymentStatus = "paid" | "unpaid" | "partial" | "refunded"; +export type PaymentStatus = + | "paid" + | "unpaid" + | "partial" + | "refunded" + | "voided"; export function paymentStatusLabel( status: PaymentStatus, @@ -110,13 +115,27 @@ export function paymentStatusLabel( return strings.paymentStatusPartial; case "refunded": return strings.paymentStatusRefunded; + case "voided": + return strings.paymentStatusVoided; default: return strings.paymentStatusUnpaid; } } -/** Maps Shopify's `displayFinancialStatus` to our condensed enum. Values not - * signalling actual receipt of money map to "unpaid". */ +/** Maps Shopify's `displayFinancialStatus` to our condensed enum. + * + * - PAID → paid + * - PARTIALLY_PAID → partial + * - REFUNDED / PARTIALLY_REFUNDED → refunded + * (composeInvoice further reclassifies PARTIALLY_REFUNDED with a + * refund < gross back to "paid".) + * - VOIDED → voided + * (authorisation cancelled before capture; no money was ever + * received and none is owed — distinct from "unpaid".) + * - PENDING / AUTHORIZED / EXPIRED / unknown → unpaid + * + * Unknown values log a warning so we notice when Shopify adds a new + * enum member. */ export function derivePaymentStatus( displayFinancialStatus: string | null | undefined, ): PaymentStatus { @@ -124,6 +143,14 @@ export function derivePaymentStatus( if (v === "PAID") return "paid"; if (v === "PARTIALLY_PAID") return "partial"; if (v === "REFUNDED" || v === "PARTIALLY_REFUNDED") return "refunded"; + if (v === "VOIDED") return "voided"; + if (v && v !== "PENDING" && v !== "AUTHORIZED" && v !== "EXPIRED") { + console.warn( + `[invoice] derivePaymentStatus: unknown displayFinancialStatus ${JSON.stringify( + displayFinancialStatus, + )} — falling back to "unpaid".`, + ); + } return "unpaid"; } @@ -184,13 +211,13 @@ const de: InvoiceStrings = { emailLabel: "E-Mail", webLabel: "Web", phoneLabel: "Tel.", - paidStamp: "BEZAHLT", paymentMethodLabel: "Zahlart", paymentStatusLabel: "Zahlstatus", paymentStatusPaid: "Bezahlt", paymentStatusUnpaid: "Offen", paymentStatusPartial: "Teilweise bezahlt", paymentStatusRefunded: "Erstattet", + paymentStatusVoided: "Annulliert", orderNumberLabel: "Bestellnummer", shippingAddressHeading: "Lieferadresse", shippingMethodLabel: "Versandart", @@ -274,13 +301,13 @@ const en: InvoiceStrings = { emailLabel: "E-mail", webLabel: "Web", phoneLabel: "Tel.", - paidStamp: "PAID", paymentMethodLabel: "Payment method", paymentStatusLabel: "Payment status", paymentStatusPaid: "Paid", paymentStatusUnpaid: "Outstanding", paymentStatusPartial: "Partially paid", paymentStatusRefunded: "Refunded", + paymentStatusVoided: "Voided", orderNumberLabel: "Order no.", shippingAddressHeading: "Shipping address", shippingMethodLabel: "Shipping method", diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts index 3abc79a..8e658b8 100644 --- a/app/services/invoice/types.ts +++ b/app/services/invoice/types.ts @@ -39,7 +39,6 @@ export interface InvoiceViewModel { giroCodePngDataUrl?: string; // Status flags - paid: boolean; /** Condensed payment status derived from Shopify's * `displayFinancialStatus`. */ paymentStatus: PaymentStatus; diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts index 45655b9..4a9d1fc 100644 --- a/scripts/render-sample.ts +++ b/scripts/render-sample.ts @@ -635,6 +635,29 @@ async function main() { assert("requiresPayment still false", vmFull.requiresPayment === false); } + // ---------------------------------------------------------------- + // VOIDED: authorisation cancelled before capture. No money received, + // none owed. Must classify as "voided" (not "unpaid") and suppress + // GiroCode + payment terms. + // ---------------------------------------------------------------- + console.log("• Voided order (VOIDED) classifies as voided, no GiroCode"); + { + const voidedOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "VOIDED" }; + const voidedVm = composeInvoice({ + order: voidedOrder, settings: settings as never, invoiceNumber: "RE-1018", + }); + assertEq("paymentStatus=voided for VOIDED", voidedVm.paymentStatus, "voided"); + assert("requiresPayment=false for voided", voidedVm.requiresPayment === false); + voidedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl; + const voidedText = await pdfToText(await renderInvoicePdf(voidedVm)); + assert("Voided PDF shows the 'Annulliert' status row", + voidedText.includes("Annulliert")); + assert("Voided PDF does NOT show GiroCode caption", + !voidedText.includes("GiroCode")); + assert("Voided PDF does NOT show DE payment terms", + !voidedText.includes("Bitte überweise")); + } + // ---------------------------------------------------------------- // Footer note translation // ----------------------------------------------------------------