321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
import crypto from "node:crypto";
|
|
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) {
|
|
// Log the upstream detail server-side only — never echo internal error
|
|
// messages (which may contain Admin API internals / order data) to the
|
|
// public client.
|
|
console.error(`payment-info: failed to load order ${orderGid} for ${shop}:`, err);
|
|
return cors(
|
|
Response.json(
|
|
{ showPaymentInstructions: false, error: "order-load-failed" },
|
|
{ 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.
|
|
//
|
|
// Token claims available (see @shopify/shopify-api `JwtPayload`): only the
|
|
// standard JWT fields — iss, dest, aud, sub, exp, nbf, iat, jti, sid. There
|
|
// is NO order- or checkout-scoped claim in either the checkout or the
|
|
// customer-account session token, so we cannot bind the token to the
|
|
// requested orderId directly. We therefore bind by customer identity where
|
|
// possible and fall back to a tightened recency window for true guests.
|
|
//
|
|
// - customerAccount tokens always carry a customer GID in `sub`. We require
|
|
// that the order's customer matches (strong binding — preferred path).
|
|
// - Checkout (thank-you page) tokens for logged-in buyers also carry the
|
|
// customer GID in `sub`; we bind to it identically.
|
|
// - For guest checkouts (no customer on the order, checkout source only) we
|
|
// have nothing in the token to bind against. We accept only when the order
|
|
// was placed within a SHORT window — the thank-you page is rendered
|
|
// immediately after checkout, so a few minutes is ample for the legitimate
|
|
// flow. Residual risk: within this small window an attacker holding ANY
|
|
// valid checkout token for this shop could enumerate the numeric ids of
|
|
// very-recently-placed guest orders and read their amount/reference. This
|
|
// is mitigated (not eliminated) by (a) the short window, (b) per-IP rate
|
|
// limiting on /api/public/* (see server.js), and (c) numeric order ids
|
|
// being unguessable in the short window. The customer-account path remains
|
|
// the preferred, fully-bound surface.
|
|
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
|
|
// very fresh (the buyer has just completed checkout for it). Kept short to
|
|
// shrink the enumeration window — see residual-risk note above.
|
|
const placedAtMs = orderInfo.processedAtMs ?? 0;
|
|
const RECENT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
|
ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS;
|
|
}
|
|
|
|
if (!ownershipOk) {
|
|
// Minimal correlation log: never log raw customer identifiers (PII). Hash
|
|
// the token subject (sha256, truncated) so repeated abuse from the same
|
|
// principal is still correlatable without storing the GID itself.
|
|
const subHash = tokenSub
|
|
? crypto.createHash("sha256").update(tokenSub).digest("hex").slice(0, 12)
|
|
: "-";
|
|
console.warn(
|
|
`payment-info: ownership check failed for shop=${shop} order=${orderGid} ` +
|
|
`authSource=${authSource} subHash=${subHash}`,
|
|
);
|
|
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<string, unknown> }) => Promise<Response> },
|
|
orderGid: string,
|
|
): Promise<OrderInfo | null> {
|
|
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 })),
|
|
};
|
|
}
|