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"; /** * Public endpoint consumed by the checkout / thank-you UI extension to fetch * payment instructions (GiroCode + bank details) for an order. * * Auth: validated Shopify checkout session token (via `authenticate.public.checkout`). * 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) => { const { sessionToken, cors } = await authenticate.public.checkout(request); 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", debug: { shop, orderGid, hasOrder: !!orderInfo, txCount: orderInfo?.txCount ?? 0, manualFlags: orderInfo?.manualFlags ?? [] }, }), ); } 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; const remittance = orderInfo.orderName || orderGid.split("/").pop() || ""; 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; customerLocale?: string; 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 currencyCode customerLocale totalPriceSet { shopMoney { amount } } totalOutstandingSet { shopMoney { amount } } transactions(first: 20) { status manualPaymentGateway } } }`, { variables: { id: orderGid } }, ); const json = (await res.json()) as { data?: { order?: { name?: string; currencyCode?: string; customerLocale?: 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 ?? "", customerLocale: o.customerLocale ?? undefined, txCount: txs.length, manualFlags: txs.map((t) => ({ status: t.status, manual: t.manualPaymentGateway })), }; }