feat(thank-you): payment instructions extension (GiroCode + bank details) for manual payment orders
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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" }));
|
||||
}
|
||||
|
||||
const { admin } = await unauthenticated.admin(shop);
|
||||
let orderInfo: OrderInfo | null = null;
|
||||
try {
|
||||
orderInfo = await fetchOrderInfo(admin, orderGid);
|
||||
} catch (err) {
|
||||
console.warn(`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" }));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user