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.
This commit is contained in:
Gerhard Scheikl
2026-05-15 15:51:10 +02:00
parent a2b3c14022
commit 2a4a7fd983
5 changed files with 98 additions and 3 deletions
+12 -1
View File
@@ -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(" "),
+19 -1
View File
@@ -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: (() => {