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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "invoice-thank-you-payment",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/signals": "^1.3.0",
|
||||||
|
"@shopify/ui-extensions": "^2026.1.0",
|
||||||
|
"preact": "^10.22.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import '@shopify/ui-extensions';
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
declare module './src/Checkout.tsx' {
|
||||||
|
const shopify: import('@shopify/ui-extensions/purchase.thank-you.block.render').Api;
|
||||||
|
const globalThis: { shopify: typeof shopify };
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
api_version = "2026-01"
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
name = "Invoice payment instructions"
|
||||||
|
handle = "invoice-thank-you-payment"
|
||||||
|
type = "ui_extension"
|
||||||
|
uid = "linumiq-invoice-thank-you-payment"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
target = "purchase.thank-you.block.render"
|
||||||
|
module = "./src/Checkout.tsx"
|
||||||
|
|
||||||
|
[extensions.capabilities]
|
||||||
|
network_access = true
|
||||||
|
api_access = false
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import "@shopify/ui-extensions/preact";
|
||||||
|
import { render } from "preact";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
const APP_URL = "https://invoice-app.linumiq.com";
|
||||||
|
|
||||||
|
interface PaymentInstructions {
|
||||||
|
language: "de" | "en";
|
||||||
|
heading: string;
|
||||||
|
giroCodeDataUrl: string;
|
||||||
|
recipient: string;
|
||||||
|
bankName: string;
|
||||||
|
iban: string;
|
||||||
|
bic: string;
|
||||||
|
amountFormatted: string;
|
||||||
|
reference: string;
|
||||||
|
dueDateFormatted: string | null;
|
||||||
|
instructions: string;
|
||||||
|
labels: {
|
||||||
|
recipient: string;
|
||||||
|
bank: string;
|
||||||
|
iban: string;
|
||||||
|
bic: string;
|
||||||
|
amount: string;
|
||||||
|
reference: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
const shopify = (globalThis as any).shopify;
|
||||||
|
const [data, setData] = useState<PaymentInstructions | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const orderId: string | undefined = shopify?.orderConfirmation?.value?.order?.id;
|
||||||
|
if (!orderId) {
|
||||||
|
setDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token: string = await shopify.sessionToken.get();
|
||||||
|
const res = await fetch(
|
||||||
|
`${APP_URL}/api/public/payment-info?orderId=${encodeURIComponent(orderId)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
setDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
showPaymentInstructions: boolean;
|
||||||
|
payload?: PaymentInstructions;
|
||||||
|
};
|
||||||
|
if (cancelled) return;
|
||||||
|
if (json.showPaymentInstructions && json.payload) {
|
||||||
|
setData(json.payload);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — render nothing on error
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setDone(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!done) {
|
||||||
|
return <s-skeleton-paragraph />;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<s-section heading={data.heading}>
|
||||||
|
<s-paragraph>{data.instructions}</s-paragraph>
|
||||||
|
<s-stack direction="inline" gap="base" align-items="start">
|
||||||
|
<s-image src={data.giroCodeDataUrl} alt="GiroCode" />
|
||||||
|
<s-stack direction="block" gap="small-200">
|
||||||
|
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
|
||||||
|
{data.bankName ? (
|
||||||
|
<s-text>{data.labels.bank}: {data.bankName}</s-text>
|
||||||
|
) : null}
|
||||||
|
<s-text>{data.labels.iban}: {data.iban}</s-text>
|
||||||
|
{data.bic ? (
|
||||||
|
<s-text>{data.labels.bic}: {data.bic}</s-text>
|
||||||
|
) : null}
|
||||||
|
<s-text>{data.labels.amount}: {data.amountFormatted}</s-text>
|
||||||
|
<s-text>{data.labels.reference}: {data.reference}</s-text>
|
||||||
|
</s-stack>
|
||||||
|
</s-stack>
|
||||||
|
</s-section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Generated
+41
@@ -77,6 +77,15 @@
|
|||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"extensions/invoice-thank-you-payment": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/signals": "^1.3.0",
|
||||||
|
"@shopify/ui-extensions": "^2026.1.0",
|
||||||
|
"preact": "^10.22.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ardatan/relay-compiler": {
|
"node_modules/@ardatan/relay-compiler": {
|
||||||
"version": "13.0.1",
|
"version": "13.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-13.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-13.0.1.tgz",
|
||||||
@@ -3129,6 +3138,34 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@preact/signals": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@preact/signals-core": "^1.7.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": "10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@preact/signals-core": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
@@ -9242,6 +9279,10 @@
|
|||||||
"resolved": "extensions/invoice-order-block",
|
"resolved": "extensions/invoice-order-block",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/invoice-thank-you-payment": {
|
||||||
|
"resolved": "extensions/invoice-thank-you-payment",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user