From 55a0dd03f28fca210ba5b5441d94d127a4b31dd7 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Fri, 15 May 2026 11:26:26 +0200 Subject: [PATCH] feat(invoice): informal German tone + show payment method and status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i18n.de: switch Sie/Ihren to du/dein for salutation, thank-you line, customer-VAT label and payment-terms paragraph. Closing line was already informal. - i18n: add paymentMethodLabel/paymentStatusLabel + per-status labels (paid/unpaid/partial/refunded) for both DE and EN, plus derivePaymentStatus helper that condenses Shopify's displayFinancialStatus (PAID, PARTIALLY_PAID, REFUNDED, …) into a 4-value enum. - loadOrderForInvoice: query Order.paymentGatewayNames and propagate it on the raw view-model. - composeInvoice + types: expose paymentStatus + paymentGatewayNames on InvoiceViewModel (filtered/trimmed). loadDraftOrderForOffer keeps paymentGatewayNames empty (drafts have no gateway yet). - InvoiceDocument: render two new meta rows on real invoices — 'Zahlart / Payment method' (joined, prettified gateway names) and 'Zahlstatus / Payment status' (translated label). Storno + offer kinds intentionally omit them. - scripts/render-sample.ts: extend smoke checks to assert the informal DE wording, the new payment-method/status rows and the paymentStatus/paymentGatewayNames composer outputs. --- app/services/invoice/composeInvoice.ts | 8 ++- app/services/invoice/i18n.ts | 58 +++++++++++++++++-- .../invoice/loadDraftOrderForOffer.server.ts | 1 + .../invoice/loadOrderForInvoice.server.ts | 4 ++ app/services/invoice/pdf/InvoiceDocument.tsx | 43 +++++++++++++- app/services/invoice/types.ts | 8 ++- scripts/render-sample.ts | 18 ++++++ 7 files changed, 133 insertions(+), 7 deletions(-) diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index a71cf86..939b5eb 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -11,7 +11,7 @@ import type { VatBreakdownEntry, } from "./types"; import { addDays } from "./format"; -import { pickLanguage, type InvoiceLanguage } from "./i18n"; +import { derivePaymentStatus, pickLanguage, type InvoiceLanguage } from "./i18n"; interface ComposeArgs { order: RawOrderForInvoice; @@ -69,6 +69,10 @@ export function composeInvoice({ : undefined; const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID"; + const paymentStatus = derivePaymentStatus(order.displayFinancialStatus); + const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter( + (n) => typeof n === "string" && n.trim().length > 0, + ); if (storno) { lines = lines.map((l) => ({ @@ -107,6 +111,8 @@ export function composeInvoice({ totals, notices, paid, + paymentStatus, + paymentGatewayNames, }; } diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index d898b0a..f601cbb 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -52,6 +52,44 @@ export interface InvoiceStrings { webLabel: string; phoneLabel: string; paidStamp: string; + paymentMethodLabel: string; + paymentStatusLabel: string; + paymentStatusPaid: string; + paymentStatusUnpaid: string; + paymentStatusPartial: string; + paymentStatusRefunded: 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 = { @@ -65,7 +103,7 @@ const de: InvoiceStrings = { invoiceNumber: "Rechnungs-Nr.", invoiceDate: "Rechnungsdatum", deliveryDate: "Lieferdatum", - customerVatId: "Ihre USt-Id.", + customerVatId: "Deine USt-Id.", position: "Pos.", description: "Beschreibung", quantity: "Menge", @@ -74,12 +112,12 @@ const de: InvoiceStrings = { netTotal: "Gesamtbetrag netto", vatLine: (r) => `zzgl. Umsatzsteuer ${r}`, grossTotal: "Gesamtbetrag brutto", - salutationGeneric: "Sehr geehrte Damen und Herren,", + salutationGeneric: "Hallo,", thankYouLine: - "vielen Dank für Ihren Auftrag. Wir erlauben uns, Ihnen folgende Leistungen in Rechnung zu stellen:", + "vielen Dank für deine Bestellung. Wir berechnen dir folgende Leistungen:", closing: "Danke für deinen Einkauf", paymentTerms: (days, due) => - `Bitte überweisen Sie den Rechnungsbetrag innerhalb von ${days} Tagen, spätestens bis zum ${due}, auf das unten angegebene Konto. Bei Fragen zur Rechnung stehen wir Ihnen gerne zur Verfügung.`, + `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", @@ -109,6 +147,12 @@ const de: InvoiceStrings = { webLabel: "Web", phoneLabel: "Tel.", paidStamp: "BEZAHLT", + paymentMethodLabel: "Zahlart", + paymentStatusLabel: "Zahlstatus", + paymentStatusPaid: "Bezahlt", + paymentStatusUnpaid: "Offen", + paymentStatusPartial: "Teilweise bezahlt", + paymentStatusRefunded: "Erstattet", }; const en: InvoiceStrings = { @@ -165,6 +209,12 @@ const en: InvoiceStrings = { webLabel: "Web", phoneLabel: "Tel.", paidStamp: "PAID", + paymentMethodLabel: "Payment method", + paymentStatusLabel: "Payment status", + paymentStatusPaid: "Paid", + paymentStatusUnpaid: "Outstanding", + paymentStatusPartial: "Partially paid", + paymentStatusRefunded: "Refunded", }; // Locale → invoice language. We only render in German (`de`) when the diff --git a/app/services/invoice/loadDraftOrderForOffer.server.ts b/app/services/invoice/loadDraftOrderForOffer.server.ts index bc8de74..b460b0f 100644 --- a/app/services/invoice/loadDraftOrderForOffer.server.ts +++ b/app/services/invoice/loadDraftOrderForOffer.server.ts @@ -160,6 +160,7 @@ export async function loadDraftOrderForOffer( processedAt: null, currencyCode: draft.currencyCode, displayFinancialStatus: null, + paymentGatewayNames: [], taxesIncluded: draft.taxesIncluded, customer: draft.customer, billingAddress: draft.billingAddress, diff --git a/app/services/invoice/loadOrderForInvoice.server.ts b/app/services/invoice/loadOrderForInvoice.server.ts index 1de9086..0039b84 100644 --- a/app/services/invoice/loadOrderForInvoice.server.ts +++ b/app/services/invoice/loadOrderForInvoice.server.ts @@ -12,6 +12,7 @@ export interface RawOrderForInvoice { processedAt: string | null; currencyCode: string; displayFinancialStatus: string | null; + paymentGatewayNames: string[]; customer: { firstName: string | null; lastName: string | null; @@ -77,6 +78,7 @@ const QUERY = `#graphql processedAt currencyCode displayFinancialStatus + paymentGatewayNames taxesIncluded customer { firstName @@ -161,6 +163,7 @@ interface RawAdminResponse { processedAt: string | null; currencyCode: string; displayFinancialStatus: string | null; + paymentGatewayNames: string[] | null; taxesIncluded: boolean; customer: { firstName: string | null; @@ -213,6 +216,7 @@ export async function loadOrderForInvoice( processedAt: order.processedAt, currencyCode: order.currencyCode, displayFinancialStatus: order.displayFinancialStatus, + paymentGatewayNames: order.paymentGatewayNames ?? [], taxesIncluded: order.taxesIncluded, customer: order.customer, billingAddress: order.billingAddress, diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index 2845b28..c34008b 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -11,7 +11,7 @@ import { import React from "react"; import { formatDate, formatMoney, formatQuantity, formatTaxRate } from "../format"; -import { getStrings } from "../i18n"; +import { getStrings, paymentStatusLabel as getPaymentStatusLabel } from "../i18n"; import type { InvoiceLanguage } from "../i18n"; import type { InvoiceViewModel, InvoiceLine, IssuerData, RecipientData } from "../types"; @@ -287,6 +287,22 @@ export function InvoiceDocument({ invoice }: DocProps) { {invoice.recipientVatId} ) : null} + {invoice.kind === "invoice" && invoice.paymentGatewayNames.length > 0 && ( + + {t.paymentMethodLabel} + + {invoice.paymentGatewayNames.map(prettifyGatewayName).join(", ")} + + + )} + {invoice.kind === "invoice" && ( + + {t.paymentStatusLabel} + + {getPaymentStatusLabel(invoice.paymentStatus, t)} + + + )} @@ -545,3 +561,28 @@ function normaliseWebUrl(url: string): string { if (/^https?:\/\//i.test(trimmed)) return trimmed; return `https://${trimmed.replace(/^\/\//, "")}`; } + +/** + * Turn a Shopify payment-gateway machine name (e.g. `shopify_payments`, + * `manual`, `bogus`) into something a customer can read on the invoice. We + * keep this purely cosmetic — the underlying value is preserved for any + * downstream automation. + */ +function prettifyGatewayName(name: string): string { + const known: Record = { + manual: "Manual", + bogus: "Bogus (Test)", + shopify_payments: "Shopify Payments", + paypal: "PayPal", + cash_on_delivery: "Cash on delivery", + "cash-on-delivery": "Cash on delivery", + }; + const key = name.trim().toLowerCase(); + if (known[key]) return known[key]; + // Replace separators and title-case each word. + return key + .split(/[_\s-]+/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts index 77ab8bd..c9e884f 100644 --- a/app/services/invoice/types.ts +++ b/app/services/invoice/types.ts @@ -1,4 +1,4 @@ -import type { InvoiceLanguage } from "./i18n"; +import type { InvoiceLanguage, PaymentStatus } from "./i18n"; /** * The view model passed into the PDF renderer. Decouples the PDF layer from @@ -40,6 +40,12 @@ export interface InvoiceViewModel { // Status flags paid: boolean; + /** Condensed payment status derived from Shopify's + * `displayFinancialStatus`. */ + paymentStatus: PaymentStatus; + /** Names of the payment gateways used (e.g. ["bogus"], ["manual", + * "shopify_payments"]). Empty when unknown / draft. */ + paymentGatewayNames: string[]; } export interface IssuerData { diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts index 3eb591c..9ae9293 100644 --- a/scripts/render-sample.ts +++ b/scripts/render-sample.ts @@ -134,6 +134,7 @@ function buildAtB2BOrder(): RawOrderForInvoice { processedAt: "2026-04-15T10:00:00Z", currencyCode: "EUR", displayFinancialStatus: "PENDING", + paymentGatewayNames: ["manual"], taxesIncluded: false, customer: { firstName: "Lukas", @@ -278,6 +279,8 @@ async function main() { assertEq("no notices for AT B2B with VAT charged", vm.notices.length, 0); assert("due date 14 days after invoice date", !!vm.dueDate && Math.round((vm.dueDate.getTime() - vm.invoiceDate.getTime()) / 86400000) === 14); + assertEq("paymentGatewayNames propagated", vm.paymentGatewayNames.join(","), "manual"); + assertEq("paymentStatus derived from displayFinancialStatus=PENDING", vm.paymentStatus, "unpaid"); console.log("• EU B2B reverse-charge notice"); const euOrder = buildEuB2BReverseChargeOrder(); @@ -398,6 +401,21 @@ async function main() { !/Thank you for your purchase\.[\s\S]{0,40}Gerhard Berger/.test(enText), ); + // Informal German tone (du/dein) — make sure no formal "Sie/Ihren" remains + // in the strings we control (footer / signature lines come from settings). + assert("DE PDF uses informal salutation 'Hallo,'", deText.includes("Hallo,")); + assert("DE PDF no longer uses 'Sehr geehrte Damen und Herren'", !deText.includes("Sehr geehrte Damen und Herren")); + assert("DE PDF uses informal 'deine Bestellung'", deText.includes("deine Bestellung")); + assert( + "DE PDF payment-terms uses informal 'überweise … für dich'", + deText.includes("Bitte überweise") && deText.includes("für dich"), + ); + assert("DE PDF shows payment status row", deText.includes("Zahlstatus")); + assert("DE PDF shows payment status value 'Offen' for PENDING", deText.includes("Offen")); + assert("DE PDF shows payment method row", deText.includes("Zahlart")); + assert("EN PDF shows payment status row", enText.includes("Payment status")); + assert("EN PDF shows payment status value 'Outstanding' for PENDING", enText.includes("Outstanding")); + // Fallback: when footerNoteEn is empty, English uses the German note. console.log("• Footer note fallback (en → de when EN empty)"); const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;