refactor(invoice): drop dead paid/paidStamp; classify VOIDED; warn on unknown payment status

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.
This commit is contained in:
Gerhard Scheikl
2026-05-15 18:12:06 +02:00
parent 91c1a74c1b
commit c5b6bfc20d
4 changed files with 61 additions and 10 deletions
+5 -3
View File
@@ -86,7 +86,6 @@ export function composeInvoice({
? addDays(invoiceDate, settings.paymentTermDays) ? addDays(invoiceDate, settings.paymentTermDays)
: undefined; : undefined;
const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID";
// Refunded gross amount, mirrored from Shopify's `totalRefundedSet`. // Refunded gross amount, mirrored from Shopify's `totalRefundedSet`.
// Storno/offer documents don't carry a refund row — a storno *is* // Storno/offer documents don't carry a refund row — a storno *is*
// already the cancellation document, and offers have no payments yet. // 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 // paid orders both have a 0 outstanding balance — the difference is
// just whether the money was kept (`paid`) or returned (`refunded`). // just whether the money was kept (`paid`) or returned (`refunded`).
const requiresPayment = const requiresPayment =
!storno && !offer && paymentStatus !== "paid" && paymentStatus !== "refunded"; !storno &&
!offer &&
paymentStatus !== "paid" &&
paymentStatus !== "refunded" &&
paymentStatus !== "voided";
const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter( const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter(
(n) => typeof n === "string" && n.trim().length > 0, (n) => typeof n === "string" && n.trim().length > 0,
); );
@@ -157,7 +160,6 @@ export function composeInvoice({
lines, lines,
totals, totals,
notices, notices,
paid,
paymentStatus, paymentStatus,
requiresPayment, requiresPayment,
refundedAmount, refundedAmount,
+33 -6
View File
@@ -66,13 +66,13 @@ export interface InvoiceStrings {
emailLabel: string; emailLabel: string;
webLabel: string; webLabel: string;
phoneLabel: string; phoneLabel: string;
paidStamp: string;
paymentMethodLabel: string; paymentMethodLabel: string;
paymentStatusLabel: string; paymentStatusLabel: string;
paymentStatusPaid: string; paymentStatusPaid: string;
paymentStatusUnpaid: string; paymentStatusUnpaid: string;
paymentStatusPartial: string; paymentStatusPartial: string;
paymentStatusRefunded: string; paymentStatusRefunded: string;
paymentStatusVoided: string;
orderNumberLabel: string; orderNumberLabel: string;
shippingAddressHeading: string; shippingAddressHeading: string;
shippingMethodLabel: string; shippingMethodLabel: string;
@@ -97,7 +97,12 @@ export interface InvoiceStrings {
/** Status displayed for the order's payment, derived from Shopify's /** Status displayed for the order's payment, derived from Shopify's
* `displayFinancialStatus`. */ * `displayFinancialStatus`. */
export type PaymentStatus = "paid" | "unpaid" | "partial" | "refunded"; export type PaymentStatus =
| "paid"
| "unpaid"
| "partial"
| "refunded"
| "voided";
export function paymentStatusLabel( export function paymentStatusLabel(
status: PaymentStatus, status: PaymentStatus,
@@ -110,13 +115,27 @@ export function paymentStatusLabel(
return strings.paymentStatusPartial; return strings.paymentStatusPartial;
case "refunded": case "refunded":
return strings.paymentStatusRefunded; return strings.paymentStatusRefunded;
case "voided":
return strings.paymentStatusVoided;
default: default:
return strings.paymentStatusUnpaid; return strings.paymentStatusUnpaid;
} }
} }
/** Maps Shopify's `displayFinancialStatus` to our condensed enum. Values not /** Maps Shopify's `displayFinancialStatus` to our condensed enum.
* signalling actual receipt of money map to "unpaid". */ *
* - 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( export function derivePaymentStatus(
displayFinancialStatus: string | null | undefined, displayFinancialStatus: string | null | undefined,
): PaymentStatus { ): PaymentStatus {
@@ -124,6 +143,14 @@ export function derivePaymentStatus(
if (v === "PAID") return "paid"; if (v === "PAID") return "paid";
if (v === "PARTIALLY_PAID") return "partial"; if (v === "PARTIALLY_PAID") return "partial";
if (v === "REFUNDED" || v === "PARTIALLY_REFUNDED") return "refunded"; 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"; return "unpaid";
} }
@@ -184,13 +211,13 @@ const de: InvoiceStrings = {
emailLabel: "E-Mail", emailLabel: "E-Mail",
webLabel: "Web", webLabel: "Web",
phoneLabel: "Tel.", phoneLabel: "Tel.",
paidStamp: "BEZAHLT",
paymentMethodLabel: "Zahlart", paymentMethodLabel: "Zahlart",
paymentStatusLabel: "Zahlstatus", paymentStatusLabel: "Zahlstatus",
paymentStatusPaid: "Bezahlt", paymentStatusPaid: "Bezahlt",
paymentStatusUnpaid: "Offen", paymentStatusUnpaid: "Offen",
paymentStatusPartial: "Teilweise bezahlt", paymentStatusPartial: "Teilweise bezahlt",
paymentStatusRefunded: "Erstattet", paymentStatusRefunded: "Erstattet",
paymentStatusVoided: "Annulliert",
orderNumberLabel: "Bestellnummer", orderNumberLabel: "Bestellnummer",
shippingAddressHeading: "Lieferadresse", shippingAddressHeading: "Lieferadresse",
shippingMethodLabel: "Versandart", shippingMethodLabel: "Versandart",
@@ -274,13 +301,13 @@ const en: InvoiceStrings = {
emailLabel: "E-mail", emailLabel: "E-mail",
webLabel: "Web", webLabel: "Web",
phoneLabel: "Tel.", phoneLabel: "Tel.",
paidStamp: "PAID",
paymentMethodLabel: "Payment method", paymentMethodLabel: "Payment method",
paymentStatusLabel: "Payment status", paymentStatusLabel: "Payment status",
paymentStatusPaid: "Paid", paymentStatusPaid: "Paid",
paymentStatusUnpaid: "Outstanding", paymentStatusUnpaid: "Outstanding",
paymentStatusPartial: "Partially paid", paymentStatusPartial: "Partially paid",
paymentStatusRefunded: "Refunded", paymentStatusRefunded: "Refunded",
paymentStatusVoided: "Voided",
orderNumberLabel: "Order no.", orderNumberLabel: "Order no.",
shippingAddressHeading: "Shipping address", shippingAddressHeading: "Shipping address",
shippingMethodLabel: "Shipping method", shippingMethodLabel: "Shipping method",
-1
View File
@@ -39,7 +39,6 @@ export interface InvoiceViewModel {
giroCodePngDataUrl?: string; giroCodePngDataUrl?: string;
// Status flags // Status flags
paid: boolean;
/** Condensed payment status derived from Shopify's /** Condensed payment status derived from Shopify's
* `displayFinancialStatus`. */ * `displayFinancialStatus`. */
paymentStatus: PaymentStatus; paymentStatus: PaymentStatus;
+23
View File
@@ -635,6 +635,29 @@ async function main() {
assert("requiresPayment still false", vmFull.requiresPayment === false); 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 // Footer note translation
// ---------------------------------------------------------------- // ----------------------------------------------------------------