diff --git a/app/routes/api.public.payment-info.tsx b/app/routes/api.public.payment-info.tsx new file mode 100644 index 0000000..e46fb91 --- /dev/null +++ b/app/routes/api.public.payment-info.tsx @@ -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 }) => Promise }, + orderGid: string, +): Promise { + 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, + }; +} diff --git a/extensions/invoice-thank-you-payment/package.json b/extensions/invoice-thank-you-payment/package.json new file mode 100644 index 0000000..2263100 --- /dev/null +++ b/extensions/invoice-thank-you-payment/package.json @@ -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" + } +} diff --git a/extensions/invoice-thank-you-payment/shopify.d.ts b/extensions/invoice-thank-you-payment/shopify.d.ts new file mode 100644 index 0000000..cca04c5 --- /dev/null +++ b/extensions/invoice-thank-you-payment/shopify.d.ts @@ -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 }; +} diff --git a/extensions/invoice-thank-you-payment/shopify.extension.toml b/extensions/invoice-thank-you-payment/shopify.extension.toml new file mode 100644 index 0000000..7a05c81 --- /dev/null +++ b/extensions/invoice-thank-you-payment/shopify.extension.toml @@ -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 diff --git a/extensions/invoice-thank-you-payment/src/Checkout.tsx b/extensions/invoice-thank-you-payment/src/Checkout.tsx new file mode 100644 index 0000000..a61ca7a --- /dev/null +++ b/extensions/invoice-thank-you-payment/src/Checkout.tsx @@ -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(, document.body); +}; + +function Extension() { + const shopify = (globalThis as any).shopify; + const [data, setData] = useState(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 ; + } + if (!data) { + return null; + } + + return ( + + {data.instructions} + + + + {data.labels.recipient}: {data.recipient} + {data.bankName ? ( + {data.labels.bank}: {data.bankName} + ) : null} + {data.labels.iban}: {data.iban} + {data.bic ? ( + {data.labels.bic}: {data.bic} + ) : null} + {data.labels.amount}: {data.amountFormatted} + {data.labels.reference}: {data.reference} + + + + ); +} diff --git a/extensions/invoice-thank-you-payment/tsconfig.json b/extensions/invoice-thank-you-payment/tsconfig.json new file mode 100644 index 0000000..84713c4 --- /dev/null +++ b/extensions/invoice-thank-you-payment/tsconfig.json @@ -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/**/*"] +} diff --git a/package-lock.json b/package-lock.json index 199471e..4634db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,15 @@ "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": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-13.0.1.tgz", @@ -3129,6 +3138,34 @@ "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": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", @@ -9242,6 +9279,10 @@ "resolved": "extensions/invoice-order-block", "link": true }, + "node_modules/invoice-thank-you-payment": { + "resolved": "extensions/invoice-thank-you-payment", + "link": true + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",