Files
linumiq-invoice/app/routes/api.public.payment-info.tsx
T
2026-05-09 21:05:09 +02:00

182 lines
6.4 KiB
TypeScript

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<string, unknown> }) => Promise<Response> },
orderGid: string,
): Promise<OrderInfo | null> {
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 })),
};
}