91c1a74c1b
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.
327 lines
11 KiB
TypeScript
327 lines
11 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;
|
|
paidStamp: string;
|
|
paymentMethodLabel: string;
|
|
paymentStatusLabel: string;
|
|
paymentStatusPaid: string;
|
|
paymentStatusUnpaid: string;
|
|
paymentStatusPartial: string;
|
|
paymentStatusRefunded: 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";
|
|
|
|
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;
|
|
default:
|
|
return strings.paymentStatusUnpaid;
|
|
}
|
|
}
|
|
|
|
/** Maps Shopify's `displayFinancialStatus` to our condensed enum. Values not
|
|
* signalling actual receipt of money map to "unpaid". */
|
|
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";
|
|
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.",
|
|
paidStamp: "BEZAHLT",
|
|
paymentMethodLabel: "Zahlart",
|
|
paymentStatusLabel: "Zahlstatus",
|
|
paymentStatusPaid: "Bezahlt",
|
|
paymentStatusUnpaid: "Offen",
|
|
paymentStatusPartial: "Teilweise bezahlt",
|
|
paymentStatusRefunded: "Erstattet",
|
|
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.",
|
|
paidStamp: "PAID",
|
|
paymentMethodLabel: "Payment method",
|
|
paymentStatusLabel: "Payment status",
|
|
paymentStatusPaid: "Paid",
|
|
paymentStatusUnpaid: "Outstanding",
|
|
paymentStatusPartial: "Partially paid",
|
|
paymentStatusRefunded: "Refunded",
|
|
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;
|
|
}
|