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:
@@ -3,6 +3,7 @@ import { unauthenticated } from "../shopify.server";
|
|||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { buildGiroCodePngBuffer } from "../services/invoice/girocode";
|
import { buildGiroCodePngBuffer } from "../services/invoice/girocode";
|
||||||
import { verifyGiroCodeUrl } from "../services/invoice/signedUrl";
|
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.
|
* 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!) {
|
query GiroCodeOrderInfo($id: ID!) {
|
||||||
order(id: $id) {
|
order(id: $id) {
|
||||||
name
|
name
|
||||||
|
number
|
||||||
currencyCode
|
currencyCode
|
||||||
totalPriceSet { shopMoney { amount } }
|
totalPriceSet { shopMoney { amount } }
|
||||||
totalOutstandingSet { shopMoney { amount } }
|
totalOutstandingSet { shopMoney { amount } }
|
||||||
@@ -47,6 +49,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
data?: {
|
data?: {
|
||||||
order?: {
|
order?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
number?: number | null;
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
totalPriceSet?: { shopMoney: { amount: string } };
|
totalPriceSet?: { shopMoney: { amount: string } };
|
||||||
totalOutstandingSet?: { 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 total = parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0");
|
||||||
const outstanding = parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0");
|
const outstanding = parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0");
|
||||||
const amount = outstanding > 0 ? outstanding : total;
|
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({
|
const png = await buildGiroCodePngBuffer({
|
||||||
beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
|
beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import db from "../db.server";
|
|||||||
import { formatMoney, formatDate, addDays } from "../services/invoice/format";
|
import { formatMoney, formatDate, addDays } from "../services/invoice/format";
|
||||||
import { getStrings, pickLanguage } from "../services/invoice/i18n";
|
import { getStrings, pickLanguage } from "../services/invoice/i18n";
|
||||||
import { signGiroCodeUrl } from "../services/invoice/signedUrl";
|
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
|
* 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),
|
// Outstanding amount: prefer totalOutstanding (set by Shopify for unpaid),
|
||||||
// fall back to totalPrice when zero.
|
// fall back to totalPrice when zero.
|
||||||
const amount = orderInfo.outstandingAmount > 0 ? orderInfo.outstandingAmount : orderInfo.totalAmount;
|
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 giroCodeUrl = (() => {
|
||||||
const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
|
const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
|
||||||
@@ -204,6 +218,7 @@ interface OrderInfo {
|
|||||||
outstandingAmount: number;
|
outstandingAmount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
orderName: string;
|
orderName: string;
|
||||||
|
orderNumber: number | null;
|
||||||
customerLocale?: string;
|
customerLocale?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
processedAtMs?: number;
|
processedAtMs?: number;
|
||||||
@@ -220,6 +235,7 @@ async function fetchOrderInfo(
|
|||||||
query OrderPaymentInfo($id: ID!) {
|
query OrderPaymentInfo($id: ID!) {
|
||||||
order(id: $id) {
|
order(id: $id) {
|
||||||
name
|
name
|
||||||
|
number
|
||||||
currencyCode
|
currencyCode
|
||||||
customerLocale
|
customerLocale
|
||||||
processedAt
|
processedAt
|
||||||
@@ -239,6 +255,7 @@ async function fetchOrderInfo(
|
|||||||
data?: {
|
data?: {
|
||||||
order?: {
|
order?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
number?: number | null;
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
customerLocale?: string | null;
|
customerLocale?: string | null;
|
||||||
processedAt?: string | null;
|
processedAt?: string | null;
|
||||||
@@ -262,6 +279,7 @@ async function fetchOrderInfo(
|
|||||||
outstandingAmount: parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0"),
|
outstandingAmount: parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0"),
|
||||||
currency: o.currencyCode ?? "EUR",
|
currency: o.currencyCode ?? "EUR",
|
||||||
orderName: o.name ?? "",
|
orderName: o.name ?? "",
|
||||||
|
orderNumber: typeof o.number === "number" ? o.number : null,
|
||||||
customerLocale: o.customerLocale ?? undefined,
|
customerLocale: o.customerLocale ?? undefined,
|
||||||
customerId: o.customer?.id ?? undefined,
|
customerId: o.customer?.id ?? undefined,
|
||||||
processedAtMs: (() => {
|
processedAtMs: (() => {
|
||||||
|
|||||||
@@ -379,7 +379,13 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
|||||||
? t.offer
|
? t.offer
|
||||||
: t.invoice}{" "}
|
: t.invoice}{" "}
|
||||||
Nr. {invoice.number}
|
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}`
|
? ` · ${t.orderNumberLabel}: ${invoice.orderName}`
|
||||||
: ""}
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -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<ShopSettings, "invoicePrefix">;
|
||||||
|
}): Promise<string> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -572,6 +572,19 @@ async function main() {
|
|||||||
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
|
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
|
||||||
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
|
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
|
// Delivery date follows latest fulfillment, not processedAt
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user