// 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; } /** 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; }