import type { LoaderFunctionArgs } from "react-router"; import { authenticate, unauthenticated } from "../shopify.server"; import db from "../db.server"; import { buildGiroCodeDataUrl } from "../services/invoice/girocode"; import { formatMoney, formatDate, addDays } from "../services/invoice/format"; import { getStrings, pickLanguage } from "../services/invoice/i18n"; /** * 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 })); } const orderGid = orderIdRaw.startsWith("gid://") ? orderIdRaw : `gid://shopify/Order/${orderIdRaw.replace(/[^0-9]/g, "")}`; 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); orderInfo = await fetchOrderInfo(admin, orderGid); } 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 giroCodeDataUrl = await buildGiroCodeDataUrl({ beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "), iban: settings.iban, bic: settings.bic, amount, currency: orderInfo.currency, remittance, }); const dueDate = settings.paymentTermDays > 0 ? addDays(new Date(), settings.paymentTermDays) : null; return cors( Response.json({ showPaymentInstructions: true, payload: { language, heading: t.giroCodeCaption, giroCodeDataUrl, 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 })), }; }