c5b6bfc20d
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.
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
// Translatable strings for invoice rendering. Two languages: de (default), en.
|
|
|
|
export type InvoiceLanguage = "de" | "en";
|
|
|
|
export interface InvoiceStrings {
|
|
invoice: string;
|
|
stornoInvoice: string;
|
|
offer: string;
|
|
offerNumber: string;
|
|
offerDate: string;
|
|
offerValidUntil: (until: string) => string;
|
|
stornoReference: (originalNumber: string) => string;
|
|
invoiceNumber: string;
|
|
invoiceDate: string;
|
|
deliveryDate: string;
|
|
customerVatId: string;
|
|
position: string;
|
|
description: string;
|
|
quantity: string;
|
|
unitPrice: string;
|
|
totalPrice: string;
|
|
netTotal: string;
|
|
vatLine: (ratePct: string) => string;
|
|
grossTotal: string;
|
|
salutationGeneric: string;
|
|
thankYouLine: string;
|
|
closing: string;
|
|
paymentTerms: (days: number, dueDate: string) => string;
|
|
paymentTermsImmediate: string;
|
|
giroCodeCaption: string;
|
|
reverseChargeNotice: string;
|
|
exportNotice: string;
|
|
kleinunternehmerNotice: string;
|
|
pieceUnit: string;
|
|
page: (current: number, total: number) => string;
|
|
legalCourtLabel: string;
|
|
fnLabel: string;
|
|
vatIdLabel: string;
|
|
taxNumberLabel: string;
|
|
ownerLabel: string;
|
|
ibanLabel: string;
|
|
bicLabel: string;
|
|
bankLabel: string;
|
|
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;
|
|
/** 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;
|
|
bankHeading: string;
|
|
emailLabel: string;
|
|
webLabel: string;
|
|
phoneLabel: string;
|
|
paymentMethodLabel: string;
|
|
paymentStatusLabel: string;
|
|
paymentStatusPaid: string;
|
|
paymentStatusUnpaid: string;
|
|
paymentStatusPartial: string;
|
|
paymentStatusRefunded: string;
|
|
paymentStatusVoided: string;
|
|
orderNumberLabel: string;
|
|
shippingAddressHeading: string;
|
|
shippingMethodLabel: string;
|
|
trackingLabel: string;
|
|
shippingItemPrefix: string;
|
|
discountCodeLabel: string;
|
|
pickupLabel: string;
|
|
/** Used as the meta-row label when the order is a local pickup. The row
|
|
* value is then the pickup location name (e.g. "Lager Graz"). */
|
|
pickupLocationLabel: string;
|
|
/** Localized labels for Shopify's built-in payment-gateway names. The
|
|
* Admin GraphQL API only ever returns the *English* template name
|
|
* (e.g. "Bank Deposit") in `Order.paymentGatewayNames`, even when the
|
|
* storefront / order-confirmation page renders the localized variant
|
|
* ("Banküberweisung"). We mirror Shopify's checkout copy here so the
|
|
* printed PDF matches what the customer saw at checkout. Lookup is
|
|
* case-insensitive on the normalized key (lowercased, separators
|
|
* collapsed). Unknown gateways fall back to a title-cased rendering
|
|
* of the raw name. */
|
|
paymentGatewayLabels: Record<string, string>;
|
|
}
|
|
|
|
/** Status displayed for the order's payment, derived from Shopify's
|
|
* `displayFinancialStatus`. */
|
|
export type PaymentStatus =
|
|
| "paid"
|
|
| "unpaid"
|
|
| "partial"
|
|
| "refunded"
|
|
| "voided";
|
|
|
|
export function paymentStatusLabel(
|
|
status: PaymentStatus,
|
|
strings: InvoiceStrings,
|
|
): string {
|
|
switch (status) {
|
|
case "paid":
|
|
return strings.paymentStatusPaid;
|
|
case "partial":
|
|
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.
|
|
*
|
|
* - 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 {
|
|
const v = (displayFinancialStatus || "").toUpperCase();
|
|
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";
|
|
}
|
|
|
|
const de: InvoiceStrings = {
|
|
invoice: "Rechnung",
|
|
stornoInvoice: "Stornorechnung",
|
|
offer: "Angebot",
|
|
offerNumber: "Angebots-Nr.",
|
|
offerDate: "Angebotsdatum",
|
|
offerValidUntil: (d) => `Dieses Angebot ist gültig bis ${d}.`,
|
|
stornoReference: (n) => `Storno zu Rechnung Nr. ${n}`,
|
|
invoiceNumber: "Rechnungs-Nr.",
|
|
invoiceDate: "Rechnungsdatum",
|
|
deliveryDate: "Lieferdatum",
|
|
customerVatId: "Deine USt-Id.",
|
|
position: "Pos.",
|
|
description: "Beschreibung",
|
|
quantity: "Menge",
|
|
unitPrice: "Einzelpreis",
|
|
totalPrice: "Gesamtpreis",
|
|
netTotal: "Gesamtbetrag netto",
|
|
vatLine: (r) => `zzgl. Umsatzsteuer ${r}`,
|
|
grossTotal: "Gesamtbetrag brutto",
|
|
salutationGeneric: "Hallo,",
|
|
thankYouLine:
|
|
"Vielen Dank für deine Bestellung. Wir berechnen dir folgende Leistungen:",
|
|
closing: "Danke für deinen Einkauf",
|
|
paymentTerms: (days, due) =>
|
|
`Bitte überweise den Rechnungsbetrag innerhalb von ${days} Tagen, spätestens bis zum ${due}, auf das unten angegebene Konto. Bei Fragen zur Rechnung sind wir gerne für dich da.`,
|
|
paymentTermsImmediate:
|
|
"Der Rechnungsbetrag ist sofort nach Erhalt zur Zahlung fällig.",
|
|
giroCodeCaption: "GiroCode",
|
|
reverseChargeNotice:
|
|
"Steuerschuldnerschaft des Leistungsempfängers gemäß Art. 196 MwStSystRL (Reverse Charge).",
|
|
exportNotice: "Steuerfreie Ausfuhrlieferung gemäß § 7 UStG.",
|
|
kleinunternehmerNotice:
|
|
"Gemäß § 6 Abs. 1 Z 27 UStG wird keine Umsatzsteuer ausgewiesen (Kleinunternehmer).",
|
|
pieceUnit: "Stk",
|
|
page: (c, t) => `${c}/${t}`,
|
|
legalCourtLabel: "Amtsgericht",
|
|
fnLabel: "FN",
|
|
vatIdLabel: "UID",
|
|
taxNumberLabel: "St.Nr.",
|
|
ownerLabel: "Inhaber",
|
|
ibanLabel: "IBAN",
|
|
bicLabel: "BIC",
|
|
bankLabel: "Bank",
|
|
recipientLabel: "Empfänger",
|
|
amountLabel: "Betrag",
|
|
referenceLabel: "Referenz",
|
|
refundedLabel: "Zurückerstattet",
|
|
outstandingLabel: "Offener Betrag",
|
|
finalAmountLabel: "Endbetrag",
|
|
addressHeading: "Adresse",
|
|
contactHeading: "Kontakt",
|
|
legalHeading: "Rechtliches",
|
|
bankHeading: "Bankverbindung",
|
|
emailLabel: "E-Mail",
|
|
webLabel: "Web",
|
|
phoneLabel: "Tel.",
|
|
paymentMethodLabel: "Zahlart",
|
|
paymentStatusLabel: "Zahlstatus",
|
|
paymentStatusPaid: "Bezahlt",
|
|
paymentStatusUnpaid: "Offen",
|
|
paymentStatusPartial: "Teilweise bezahlt",
|
|
paymentStatusRefunded: "Erstattet",
|
|
paymentStatusVoided: "Annulliert",
|
|
orderNumberLabel: "Bestellnummer",
|
|
shippingAddressHeading: "Lieferadresse",
|
|
shippingMethodLabel: "Versandart",
|
|
trackingLabel: "Sendungsnummer",
|
|
shippingItemPrefix: "Versand",
|
|
discountCodeLabel: "Rabattcode",
|
|
pickupLabel: "Abholung",
|
|
pickupLocationLabel: "Abholort",
|
|
paymentGatewayLabels: {
|
|
// Built-in Shopify manual payment methods (template names).
|
|
"bank deposit": "Banküberweisung",
|
|
"bank transfer": "Banküberweisung",
|
|
"money order": "Postanweisung",
|
|
"cash on delivery": "Nachnahme",
|
|
"cash on delivery (cod)": "Nachnahme",
|
|
// Generic / technical gateways.
|
|
manual: "Manuelle Zahlung",
|
|
bogus: "Bogus (Test)",
|
|
"shopify payments": "Shopify Payments",
|
|
paypal: "PayPal",
|
|
"paypal express checkout": "PayPal",
|
|
klarna: "Klarna",
|
|
sofort: "Sofort",
|
|
giropay: "Giropay",
|
|
},
|
|
};
|
|
|
|
const en: InvoiceStrings = {
|
|
invoice: "Invoice",
|
|
stornoInvoice: "Cancellation invoice",
|
|
offer: "Offer",
|
|
offerNumber: "Offer no.",
|
|
offerDate: "Offer date",
|
|
offerValidUntil: (d) => `This offer is valid until ${d}.`,
|
|
stornoReference: (n) => `Cancels invoice no. ${n}`,
|
|
invoiceNumber: "Invoice no.",
|
|
invoiceDate: "Invoice date",
|
|
deliveryDate: "Delivery date",
|
|
customerVatId: "Your VAT ID",
|
|
position: "Pos.",
|
|
description: "Description",
|
|
quantity: "Qty",
|
|
unitPrice: "Unit price",
|
|
totalPrice: "Total",
|
|
netTotal: "Net total",
|
|
vatLine: (r) => `plus VAT ${r}`,
|
|
grossTotal: "Gross total",
|
|
salutationGeneric: "Dear Sir or Madam,",
|
|
thankYouLine:
|
|
"Thank you for your order. We hereby invoice you for the following:",
|
|
closing: "Thank you for your purchase.",
|
|
paymentTerms: (days, due) =>
|
|
`Please transfer the invoice amount within ${days} days, no later than ${due}, to the bank account shown below.`,
|
|
paymentTermsImmediate: "The invoice amount is due immediately upon receipt.",
|
|
giroCodeCaption: "GiroCode",
|
|
reverseChargeNotice:
|
|
"Reverse charge: VAT to be accounted for by the recipient pursuant to Art. 196 of Council Directive 2006/112/EC.",
|
|
exportNotice: "Tax-exempt export delivery pursuant to § 7 UStG.",
|
|
kleinunternehmerNotice:
|
|
"VAT is not charged pursuant to § 6 (1) 27 UStG (small-business exemption).",
|
|
pieceUnit: "pcs",
|
|
page: (c, t) => `${c}/${t}`,
|
|
legalCourtLabel: "Commercial court",
|
|
fnLabel: "FN",
|
|
vatIdLabel: "VAT ID",
|
|
taxNumberLabel: "Tax no.",
|
|
ownerLabel: "Owner",
|
|
ibanLabel: "IBAN",
|
|
bicLabel: "BIC",
|
|
bankLabel: "Bank",
|
|
recipientLabel: "Recipient",
|
|
amountLabel: "Amount",
|
|
referenceLabel: "Reference",
|
|
refundedLabel: "Refunded",
|
|
outstandingLabel: "Outstanding amount",
|
|
finalAmountLabel: "Total",
|
|
addressHeading: "Address",
|
|
contactHeading: "Contact",
|
|
legalHeading: "Legal",
|
|
bankHeading: "Bank details",
|
|
emailLabel: "E-mail",
|
|
webLabel: "Web",
|
|
phoneLabel: "Tel.",
|
|
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",
|
|
trackingLabel: "Tracking no.",
|
|
shippingItemPrefix: "Shipping",
|
|
discountCodeLabel: "Discount code",
|
|
pickupLabel: "Pick-up",
|
|
pickupLocationLabel: "Pick-up location",
|
|
paymentGatewayLabels: {
|
|
"bank deposit": "Bank deposit",
|
|
"bank transfer": "Bank transfer",
|
|
"money order": "Money order",
|
|
"cash on delivery": "Cash on delivery",
|
|
"cash on delivery (cod)": "Cash on delivery (COD)",
|
|
manual: "Manual",
|
|
bogus: "Bogus (Test)",
|
|
"shopify payments": "Shopify Payments",
|
|
paypal: "PayPal",
|
|
"paypal express checkout": "PayPal",
|
|
klarna: "Klarna",
|
|
sofort: "Sofort",
|
|
giropay: "Giropay",
|
|
},
|
|
};
|
|
|
|
// Locale → invoice language. We only render in German (`de`) when the
|
|
// caller is explicitly German-speaking (de, de-AT, de-DE, de_CH, …).
|
|
// Everything else (it, fr, es, en, …) falls back to English so that
|
|
// non-German-speaking customers don't receive a German invoice. Callers
|
|
// that have a per-shop default fall back to it via
|
|
// `pickLanguage(customerLocale ?? settings.defaultLanguage)`, which is why
|
|
// `null`/`undefined` still maps to `de` (the legacy default for the
|
|
// Austrian shops this app was built for).
|
|
export function pickLanguage(input: string | null | undefined): InvoiceLanguage {
|
|
if (!input) return "de";
|
|
const v = input.toLowerCase();
|
|
if (v.startsWith("de")) return "de";
|
|
return "en";
|
|
}
|
|
|
|
export function getStrings(language: InvoiceLanguage): InvoiceStrings {
|
|
return language === "en" ? en : de;
|
|
}
|