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;