From 2a4a7fd983c25436dca7c8de155722a9ce3bd223 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Fri, 15 May 2026 15:51:10 +0200 Subject: [PATCH] fix(invoice): unify customer-facing remittance reference with the printed invoice number Two related fixes around the order/invoice number: 1) The thank-you page and the customer-account order page were showing the bare Shopify order name (e.g. '#1034') as the payment reference, while the PDF (and its GiroCode QR) used the canonical invoice number (e.g. 'RE-1034'). Banks treat each unique reference as a separate payment, and several reject the '#' character outright \u2014 so customers who pasted the thank-you reference into their banking app ended up with a payment the shop couldn't reconcile. New shared helper resolveOrderRemittance() (services/invoice/ remittance.server.ts) returns the single source of truth for the reference: latest non-cancelled Invoice row for the order, falling back to '${prefix}${orderNumber}' when no PDF has been generated yet. Both /api/public/payment-info and /api/public/girocode.png now route through it, so the thank-you page, the customer-account page and the GiroCode QR are guaranteed to match the PDF byte-for-byte. 2) Drop the redundant '\u00b7 Bestellnummer: #1004' suffix from the PDF title when the invoice number's trailing digits already match the Shopify order name (default 'order_number' numbering mode). In that mode the two strings carry identical numeric content and the suffix only adds noise; sequential mode (RE-7 vs #1004) keeps the suffix. - New smoke assertion verifies the suppression triggers on invoiceNumber='RE-1004' + orderName='#1004' and that the invoice number itself is still shown. - Both endpoints now also query 'Order.number' (already covered by read_orders) so the fallback path can build the prefix+order-number string without requiring the Invoice row. --- app/routes/api.public.girocode[.png].tsx | 13 +++++- app/routes/api.public.payment-info.tsx | 20 ++++++++- app/services/invoice/pdf/InvoiceDocument.tsx | 8 +++- app/services/invoice/remittance.server.ts | 47 ++++++++++++++++++++ scripts/render-sample.ts | 13 ++++++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 app/services/invoice/remittance.server.ts diff --git a/app/routes/api.public.girocode[.png].tsx b/app/routes/api.public.girocode[.png].tsx index 8347aff..80fa2f1 100644 --- a/app/routes/api.public.girocode[.png].tsx +++ b/app/routes/api.public.girocode[.png].tsx @@ -3,6 +3,7 @@ import { unauthenticated } from "../shopify.server"; import db from "../db.server"; import { buildGiroCodePngBuffer } from "../services/invoice/girocode"; import { verifyGiroCodeUrl } from "../services/invoice/signedUrl"; +import { resolveOrderRemittance } from "../services/invoice/remittance.server"; /** * Public PNG endpoint that returns the GiroCode QR image bytes for an order. @@ -36,6 +37,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { query GiroCodeOrderInfo($id: ID!) { order(id: $id) { name + number currencyCode totalPriceSet { shopMoney { amount } } totalOutstandingSet { shopMoney { amount } } @@ -47,6 +49,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { data?: { order?: { name?: string; + number?: number | null; currencyCode?: string; totalPriceSet?: { shopMoney: { amount: string } }; totalOutstandingSet?: { shopMoney: { amount: string } }; @@ -61,7 +64,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const total = parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0"); const outstanding = parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0"); const amount = outstanding > 0 ? outstanding : total; - const remittance = o.name ?? numericId; + // Use the canonical invoice number printed on the PDF — keeping the QR + // and the customer-facing thank-you/account page in lockstep so the + // bank treats both as one and the same payment. + const remittance = await resolveOrderRemittance({ + shopDomain: shop, + orderGid, + orderNumber: typeof o.number === "number" ? o.number : null, + settings, + }); const png = await buildGiroCodePngBuffer({ beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "), diff --git a/app/routes/api.public.payment-info.tsx b/app/routes/api.public.payment-info.tsx index cf802c2..5451d46 100644 --- a/app/routes/api.public.payment-info.tsx +++ b/app/routes/api.public.payment-info.tsx @@ -4,6 +4,7 @@ import db from "../db.server"; import { formatMoney, formatDate, addDays } from "../services/invoice/format"; import { getStrings, pickLanguage } from "../services/invoice/i18n"; import { signGiroCodeUrl } from "../services/invoice/signedUrl"; +import { resolveOrderRemittance } from "../services/invoice/remittance.server"; /** * Public endpoint consumed by the checkout / thank-you UI extension AND by @@ -149,7 +150,20 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // Outstanding amount: prefer totalOutstanding (set by Shopify for unpaid), // fall back to totalPrice when zero. const amount = orderInfo.outstandingAmount > 0 ? orderInfo.outstandingAmount : orderInfo.totalAmount; - const remittance = orderInfo.orderName || orderGid.split("/").pop() || ""; + // Always use the canonical invoice number (e.g. "RE-1034") as the + // remittance reference — NEVER the bare Shopify order name ("#1034"), + // because: + // (a) the customer sees this on the thank-you page and pastes it into + // their banking app; if it doesn't match what's printed on the PDF + // (which uses the invoice number), the bank treats them as two + // different payments, and + // (b) several banks reject "#" in the reference field. + const remittance = await resolveOrderRemittance({ + shopDomain: shop, + orderGid, + orderNumber: orderInfo.orderNumber, + settings, + }); const giroCodeUrl = (() => { const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour @@ -204,6 +218,7 @@ interface OrderInfo { outstandingAmount: number; currency: string; orderName: string; + orderNumber: number | null; customerLocale?: string; customerId?: string; processedAtMs?: number; @@ -220,6 +235,7 @@ async function fetchOrderInfo( query OrderPaymentInfo($id: ID!) { order(id: $id) { name + number currencyCode customerLocale processedAt @@ -239,6 +255,7 @@ async function fetchOrderInfo( data?: { order?: { name?: string; + number?: number | null; currencyCode?: string; customerLocale?: string | null; processedAt?: string | null; @@ -262,6 +279,7 @@ async function fetchOrderInfo( outstandingAmount: parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0"), currency: o.currencyCode ?? "EUR", orderName: o.name ?? "", + orderNumber: typeof o.number === "number" ? o.number : null, customerLocale: o.customerLocale ?? undefined, customerId: o.customer?.id ?? undefined, processedAtMs: (() => { diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index 8142bc1..638105b 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -379,7 +379,13 @@ export function InvoiceDocument({ invoice }: DocProps) { ? t.offer : t.invoice}{" "} Nr. {invoice.number} - {invoice.kind === "invoice" && invoice.orderName + {invoice.kind === "invoice" + && invoice.orderName + // Suppress the redundant "· Bestellnummer: #1004" suffix when + // the invoice number is just the Shopify order number with the + // configured prefix (default numbering mode) — they'd carry + // identical trailing digits and only confuse the customer. + && invoice.number.replace(/\D+/g, "") !== invoice.orderName.replace(/\D+/g, "") ? ` · ${t.orderNumberLabel}: ${invoice.orderName}` : ""} diff --git a/app/services/invoice/remittance.server.ts b/app/services/invoice/remittance.server.ts new file mode 100644 index 0000000..5006ae4 --- /dev/null +++ b/app/services/invoice/remittance.server.ts @@ -0,0 +1,47 @@ +import db from "../../db.server"; +import type { ShopSettings } from "@prisma/client"; + +/** + * Returns the canonical remittance reference for an order — i.e. the + * exact string that should appear: + * - on the printed invoice PDF (`invoice.number`), + * - in the GiroCode QR payload, + * - and in the customer-facing payment instructions on the + * thank-you / customer-account pages. + * + * Banking systems treat each unique reference string as a separate + * payment, so all three surfaces MUST use this single source of truth. + * + * Resolution order: + * 1. The latest non-cancelled `Invoice` row for the order — guaranteed + * to match what's printed on the PDF. + * 2. Predicted default-mode number (`${prefix}${orderNumber}`). Safe + * for the default `order_number` numbering mode and a sensible + * best-guess for `prefix_sequential` before the invoice has been + * generated (the customer just sees the order number with the + * shop's invoice prefix instead of the bare Shopify "#1004"). + */ +export async function resolveOrderRemittance(args: { + shopDomain: string; + orderGid: string; + orderNumber: number | null | undefined; + settings: Pick; +}): Promise { + const invoice = await db.invoice.findFirst({ + where: { + shopDomain: args.shopDomain, + orderId: args.orderGid, + kind: "invoice", + cancelledAt: null, + }, + orderBy: [{ version: "desc" }, { createdAt: "desc" }], + select: { invoiceNumber: true }, + }); + if (invoice?.invoiceNumber) return invoice.invoiceNumber; + + const prefix = args.settings.invoicePrefix || ""; + if (args.orderNumber != null) return `${prefix}${args.orderNumber}`; + // Last-ditch: derive numeric tail from the GID. + const tail = args.orderGid.split("/").pop() ?? ""; + return `${prefix}${tail}`; +} diff --git a/scripts/render-sample.ts b/scripts/render-sample.ts index 7f502a4..6eaae78 100644 --- a/scripts/render-sample.ts +++ b/scripts/render-sample.ts @@ -572,6 +572,19 @@ async function main() { assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no.")); assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address")); + // Order-number suppression: when the invoice number's trailing digits + // match the Shopify order name (default numbering mode), the redundant + // "· Bestellnummer: #1004" suffix should be dropped from the title. + const sameNumVm = composeInvoice({ + order, settings: settings as never, invoiceNumber: "RE-1004", + }); + sameNumVm.issuer.logoDataUrl = vm.issuer.logoDataUrl; + const sameNumText = await pdfToText(await renderInvoicePdf(sameNumVm)); + assert("PDF suppresses 'Bestellnummer' suffix when invoice# matches order#", + !sameNumText.includes("Bestellnummer")); + assert("PDF still shows the invoice number itself when suppressed", + sameNumText.includes("RE-1004")); + // ---------------------------------------------------------------- // Delivery date follows latest fulfillment, not processedAt // ----------------------------------------------------------------