import type { LoaderFunctionArgs } from "react-router"; 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. * Auth: short-lived HMAC-signed URL (issued by /api/public/payment-info). * * Required query params: shop, orderId, exp, sig. */ export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); const verified = verifyGiroCodeUrl(url.searchParams); if (!verified.ok) { return new Response(`unauthorized: ${verified.reason ?? "invalid"}`, { status: 401 }); } const { shop, orderId } = verified; if (!shop || !orderId) { return new Response("bad request", { status: 400 }); } const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } }); if (!settings?.iban) { return new Response("not found", { status: 404 }); } // Recompute payload server-side from order + settings (don't trust client). const numericId = orderId.replace(/[^0-9]/g, ""); const orderGid = `gid://shopify/Order/${numericId}`; const { admin } = await unauthenticated.admin(shop); const res = await admin.graphql( `#graphql query GiroCodeOrderInfo($id: ID!) { order(id: $id) { name number currencyCode totalPriceSet { shopMoney { amount } } totalOutstandingSet { shopMoney { amount } } } }`, { variables: { id: orderGid } }, ); const json = (await res.json()) as { data?: { order?: { name?: string; number?: number | null; currencyCode?: string; totalPriceSet?: { shopMoney: { amount: string } }; totalOutstandingSet?: { shopMoney: { amount: string } }; } | null; }; }; const o = json.data?.order; if (!o) { return new Response("not found", { status: 404 }); } const total = parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0"); const outstanding = parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0"); const amount = outstanding > 0 ? outstanding : total; // 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(" "), iban: settings.iban, bic: settings.bic, amount, currency: o.currencyCode ?? "EUR", remittance, }); const body = new Uint8Array(png); return new Response(body, { status: 200, headers: { "Content-Type": "image/png", "Cache-Control": "private, max-age=300", "Access-Control-Allow-Origin": "*", }, }); };