import type { LoaderFunctionArgs } from "react-router"; import { authenticate, unauthenticated } from "../shopify.server"; 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 * the customer-account order page extension to fetch payment instructions * (GiroCode + bank details) for an order. * * Auth: validated Shopify session token. The handler tries * `authenticate.public.customerAccount` first and falls back to * `authenticate.public.checkout` so a single endpoint serves both surfaces. * The shop domain is derived from `sessionToken.dest`; the order id is read * from the `?orderId=` query parameter (numeric or GID, both accepted). * * Returns: * { showPaymentInstructions: boolean, payload?: { ... } } * * `payload` is only populated when: * - the order has at least one transaction processed by a manual payment * gateway (Shopify's `manualPaymentGateway` flag), and * - the shop has an IBAN configured. */ export const loader = async ({ request }: LoaderFunctionArgs) => { type AuthSource = "customerAccount" | "checkout"; type SessionTokenLike = { dest?: string; sub?: string }; type CorsFn = (res: Response) => Response; let sessionToken: SessionTokenLike | null = null; let cors: CorsFn = (r) => r; let authSource: AuthSource | null = null; try { const auth = await authenticate.public.customerAccount(request); sessionToken = auth.sessionToken as SessionTokenLike; cors = auth.cors as CorsFn; authSource = "customerAccount"; } catch { try { const auth = await authenticate.public.checkout(request); sessionToken = auth.sessionToken as SessionTokenLike; cors = auth.cors as CorsFn; authSource = "checkout"; } catch (err) { throw err; } } const shop = (sessionToken?.dest ?? "").toString().replace(/^https?:\/\//, ""); if (!shop) { return cors(Response.json({ showPaymentInstructions: false, error: "no-shop" }, { status: 400 })); } const url = new URL(request.url); const orderIdRaw = url.searchParams.get("orderId"); if (!orderIdRaw) { return cors(Response.json({ showPaymentInstructions: false, error: "no-order-id" }, { status: 400 })); } // The thank-you page exposes the order id as an `OrderIdentity` GID // (e.g. `gid://shopify/OrderIdentity/123`). For the Admin API we need an // `Order` GID. The numeric id is the same — just rewrite the type segment. const numericId = orderIdRaw.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, ""); if (!numericId) { return cors(Response.json({ showPaymentInstructions: false, error: "bad-order-id" }, { status: 400 })); } const orderGid = `gid://shopify/Order/${numericId}`; const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } }); if (!settings?.iban || !settings.giroCodeEnabled) { // No bank details / GiroCode disabled — nothing to render. return cors(Response.json({ showPaymentInstructions: false, reason: "no-iban-or-disabled" })); } let orderInfo: OrderInfo | null = null; try { const { admin } = await unauthenticated.admin(shop); // Brief retry: the Order may not be queryable for a moment after creation. let lastErr: unknown = null; for (let attempt = 0; attempt < 3; attempt++) { try { orderInfo = await fetchOrderInfo(admin, orderGid); if (orderInfo) break; } catch (e) { lastErr = e; } await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); } if (!orderInfo && lastErr) throw lastErr; } catch (err) { const msg = (err as Error)?.message ?? String(err); console.warn(`payment-info: failed to load order ${orderGid} for ${shop}:`, err); return cors( Response.json( { showPaymentInstructions: false, error: "order-load-failed", detail: msg.slice(0, 500) }, { status: 502 }, ), ); } if (!orderInfo || !orderInfo.isManual) { return cors( Response.json({ showPaymentInstructions: false, reason: "not-manual-payment", }), ); } // ---- Ownership check ---- // Without this, any authenticated buyer of the shop could enumerate // arbitrary orderIds and harvest the shop's bank details / amounts. // // - customerAccount tokens always carry a customer GID in `sub`. We require // that the order's customer matches. // - Checkout (thank-you page) tokens are issued at the end of checkout and // are short-lived. For logged-in buyers we still bind to the customer // GID; for guest checkouts (no customer on the order) we fall back to a // recency window (the order must have been placed in the last hour). const tokenSub = (sessionToken?.sub ?? "").toString(); const tokenCustomerNumeric = tokenSub.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, ""); const orderCustomerNumeric = (orderInfo.customerId ?? "").replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, ""); let ownershipOk = false; if (orderCustomerNumeric && tokenCustomerNumeric) { ownershipOk = orderCustomerNumeric === tokenCustomerNumeric; } else if (authSource === "checkout" && !orderCustomerNumeric) { // Guest checkout: no customer to bind against. Accept only if the order // is fresh (i.e. the buyer has just completed checkout for it). const placedAtMs = orderInfo.processedAtMs ?? 0; const RECENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS; } if (!ownershipOk) { console.warn( `payment-info: ownership check failed for shop=${shop} order=${orderGid} ` + `authSource=${authSource} tokenSub=${tokenSub || "-"}`, ); return cors( Response.json( { showPaymentInstructions: false, error: "forbidden" }, { status: 403 }, ), ); } const language = pickLanguage(orderInfo.customerLocale ?? settings.defaultLanguage); const t = getStrings(language); // Outstanding amount: prefer totalOutstanding (set by Shopify for unpaid), // fall back to totalPrice when zero. const amount = orderInfo.outstandingAmount > 0 ? orderInfo.outstandingAmount : orderInfo.totalAmount; // 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 const reqUrl = new URL(request.url); // Behind a reverse proxy that terminates TLS the inbound URL is http. // Trust X-Forwarded-Proto, otherwise force https for any non-localhost host. const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim(); const isLocal = reqUrl.hostname === "localhost" || reqUrl.hostname === "127.0.0.1"; const proto = forwardedProto ?? (isLocal ? reqUrl.protocol.replace(":", "") : "https"); const origin = `${proto}://${reqUrl.host}`; const qs = signGiroCodeUrl({ shop, orderId: numericId, exp }); return `${origin}/api/public/girocode.png?${qs}`; })(); const dueDate = settings.paymentTermDays > 0 ? addDays(new Date(), settings.paymentTermDays) : null; return cors( Response.json({ showPaymentInstructions: true, payload: { language, heading: t.giroCodeCaption, giroCodeUrl, recipient: [settings.companyName, settings.legalForm].filter(Boolean).join(" "), bankName: settings.bankName, iban: settings.iban, bic: settings.bic, amountFormatted: formatMoney(amount, orderInfo.currency, language), reference: remittance, dueDateFormatted: dueDate ? formatDate(dueDate, language) : null, instructions: dueDate ? t.paymentTerms(settings.paymentTermDays, formatDate(dueDate, language)) : t.paymentTermsImmediate, labels: { recipient: t.recipientLabel, bank: t.bankLabel, iban: t.ibanLabel, bic: t.bicLabel, amount: t.amountLabel, reference: t.referenceLabel, }, }, }), ); }; interface OrderInfo { isManual: boolean; totalAmount: number; outstandingAmount: number; currency: string; orderName: string; orderNumber: number | null; customerLocale?: string; customerId?: string; processedAtMs?: number; txCount: number; manualFlags: Array<{ status?: string; manual?: boolean }>; } async function fetchOrderInfo( admin: { graphql: (q: string, opts?: { variables?: Record }) => Promise }, orderGid: string, ): Promise { const res = await admin.graphql( `#graphql query OrderPaymentInfo($id: ID!) { order(id: $id) { name number currencyCode customerLocale processedAt createdAt customer { id } totalPriceSet { shopMoney { amount } } totalOutstandingSet { shopMoney { amount } } transactions(first: 20) { status manualPaymentGateway } } }`, { variables: { id: orderGid } }, ); const json = (await res.json()) as { data?: { order?: { name?: string; number?: number | null; currencyCode?: string; customerLocale?: string | null; processedAt?: string | null; createdAt?: string | null; customer?: { id?: string } | null; totalPriceSet?: { shopMoney: { amount: string } }; totalOutstandingSet?: { shopMoney: { amount: string } }; transactions?: Array<{ status?: string; manualPaymentGateway?: boolean }>; } | null; }; }; const o = json.data?.order; if (!o) return null; const txs = o.transactions ?? []; const isManual = txs.some( (t) => t.manualPaymentGateway === true && t.status !== "FAILURE" && t.status !== "ERROR", ); return { isManual, totalAmount: parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0"), 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: (() => { const raw = o.processedAt ?? o.createdAt ?? null; if (!raw) return undefined; const t = Date.parse(raw); return Number.isFinite(t) ? t : undefined; })(), txCount: txs.length, manualFlags: txs.map((t) => ({ status: t.status, manual: t.manualPaymentGateway })), }; }