From 93aec2f368258417c2c600c70bae003c6c494762 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sat, 9 May 2026 20:31:31 +0200 Subject: [PATCH] refactor(automations): detect manual payment via OrderTransaction.manualPaymentGateway - Drop wireTransferGatewayNames from ShopSettings (new migration). - Replace string-matching with a GraphQL query against Order.transactions[].manualPaymentGateway, the first-class flag Shopify exposes for any merchant-defined manual payment method. - Both webhook handlers now fetch the order on the fly to classify it, removing the configurable gateway-names field from settings. --- app/routes/app.settings.tsx | 13 +--- app/routes/webhooks.orders.create.tsx | 19 ++--- app/routes/webhooks.orders.fulfilled.tsx | 14 ++-- app/services/invoice/automations.server.ts | 75 ++++++++++++------- .../migration.sql | 4 + prisma/schema.prisma | 4 - 6 files changed, 71 insertions(+), 58 deletions(-) create mode 100644 prisma/migrations/20260512130000_drop_wire_transfer_gateway_names/migration.sql diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index e7d4233..88160fa 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -177,7 +177,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { emailBodyHtmlEn: str("emailBodyHtmlEn"), autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"), autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"), - wireTransferGatewayNames: str("wireTransferGatewayNames"), }; await db.shopSettings.upsert({ @@ -422,8 +421,10 @@ export default function SettingsRoute() { Flow required (Flow is gated to Plus stores for custom apps). When an automation fires, the invoice is generated (if it doesn't already exist) and emailed to the customer using the SMTP and - email-template settings above. A copy is recorded in the email - log; failures are logged server-side. + email-template settings above. "Wire-transfer" is detected via + Shopify's OrderTransaction.manualPaymentGateway flag, + so any merchant-defined manual payment method (Überweisung, Cash + on Delivery, Money Order, …) qualifies. - diff --git a/app/routes/webhooks.orders.create.tsx b/app/routes/webhooks.orders.create.tsx index 6b6f727..0450b7c 100644 --- a/app/routes/webhooks.orders.create.tsx +++ b/app/routes/webhooks.orders.create.tsx @@ -3,14 +3,14 @@ import { authenticate } from "../shopify.server"; import db from "../db.server"; import { generateAndEmailInvoice, - isWireTransferOrder, + isManualPaymentOrder, } from "../services/invoice/automations.server"; /** - * orders/create — Automation 1: when a wire-transfer order is placed, - * immediately generate and email the invoice (which includes the bank - * details + GiroCode) so the customer can pay. Other orders are ignored - * here; they're handled by orders/fulfilled (Automation 2). + * orders/create — Automation 1: when a wire-transfer (manual-payment-gateway) + * order is placed, immediately generate and email the invoice (which includes + * the bank details + GiroCode) so the customer can pay. Other orders are + * ignored here; they're handled by orders/fulfilled (Automation 2). */ export const action = async ({ request }: ActionFunctionArgs) => { const { shop, topic, payload, session, admin } = await authenticate.webhook(request); @@ -24,12 +24,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { const orderId = payload?.id; if (orderId == null) return new Response(); - const gateways: string[] = Array.isArray(payload?.payment_gateway_names) - ? payload.payment_gateway_names - : []; - if (!isWireTransferOrder(gateways, settings.wireTransferGatewayNames)) { - return new Response(); - } + const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`; + const isManual = await isManualPaymentOrder(admin, orderGid); + if (!isManual) return new Response(); try { const result = await generateAndEmailInvoice({ diff --git a/app/routes/webhooks.orders.fulfilled.tsx b/app/routes/webhooks.orders.fulfilled.tsx index ab95d71..807bcaa 100644 --- a/app/routes/webhooks.orders.fulfilled.tsx +++ b/app/routes/webhooks.orders.fulfilled.tsx @@ -3,13 +3,13 @@ import { authenticate } from "../shopify.server"; import db from "../db.server"; import { generateAndEmailInvoice, - isWireTransferOrder, + isManualPaymentOrder, } from "../services/invoice/automations.server"; /** * orders/fulfilled — Automation 2: when an order is fulfilled and is NOT a - * wire-transfer order (e.g. paid by card), automatically email the invoice - * to the customer. Wire-transfer orders are intentionally skipped here + * wire-transfer (manual-payment-gateway) order, automatically email the + * invoice to the customer. Manual-gateway orders are intentionally skipped * because Automation 1 already emailed them at order-create time. */ export const action = async ({ request }: ActionFunctionArgs) => { @@ -27,11 +27,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { const orderId = payload?.id; if (orderId == null) return new Response(); - const gateways: string[] = Array.isArray(payload?.payment_gateway_names) - ? payload.payment_gateway_names - : []; - if (isWireTransferOrder(gateways, settings.wireTransferGatewayNames)) { - // Wire-transfer order — handled by Automation 1, skip here. + const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`; + if (await isManualPaymentOrder(admin, orderGid)) { + // Manual / wire-transfer order — handled by Automation 1, skip here. return new Response(); } diff --git a/app/services/invoice/automations.server.ts b/app/services/invoice/automations.server.ts index a1fa09c..80224bf 100644 --- a/app/services/invoice/automations.server.ts +++ b/app/services/invoice/automations.server.ts @@ -4,32 +4,54 @@ import db from "../../db.server"; import { generateInvoice } from "./generateInvoice.server"; import { sendInvoiceEmail } from "./email.server"; -const DEFAULT_WIRE_TRANSFER_NAMES = [ - "manual", - "Überweisung", - "Wire Transfer", - "Bank Transfer", - "Vorkasse", - "Bank Deposit", -]; - /** - * Returns true when any of the order's `payment_gateway_names` matches one of - * the configured wire-transfer gateway names (case-insensitive substring). + * Returns true when the order has at least one transaction processed by a + * Shopify "manual" payment gateway (wire transfer, cash on delivery, + * money order, custom manual methods, …). This uses + * `OrderTransaction.manualPaymentGateway`, the only first-class flag + * Shopify exposes for distinguishing manual gateways from automated ones. + * + * Falls back to `false` on any GraphQL error so we don't block fulfilment + * automations on transient API issues. */ -export function isWireTransferOrder( - paymentGatewayNames: readonly string[] | null | undefined, - configured: string, -): boolean { - if (!paymentGatewayNames || paymentGatewayNames.length === 0) return false; - const needles = (configured.trim() ? configured.split(",") : DEFAULT_WIRE_TRANSFER_NAMES) - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); - if (needles.length === 0) return false; - return paymentGatewayNames.some((name) => { - const n = (name ?? "").toLowerCase(); - return needles.some((needle) => n.includes(needle)); - }); +export async function isManualPaymentOrder( + admin: AdminApiContext, + orderGid: string, +): Promise { + try { + const res = await admin.graphql( + `#graphql + query OrderManualPaymentCheck($id: ID!) { + order(id: $id) { + transactions(first: 20) { + kind + status + manualPaymentGateway + } + } + }`, + { variables: { id: orderGid } }, + ); + const json = (await res.json()) as { + data?: { + order?: { + transactions?: Array<{ + kind?: string; + status?: string; + manualPaymentGateway?: boolean; + }>; + } | null; + }; + }; + const txs = json.data?.order?.transactions ?? []; + // Any non-failed transaction processed by a manual gateway counts. + return txs.some( + (t) => t.manualPaymentGateway === true && t.status !== "FAILURE" && t.status !== "ERROR", + ); + } catch (err) { + console.warn(`isManualPaymentOrder query failed for ${orderGid}:`, err); + return false; + } } export interface AutoEmailArgs { @@ -51,7 +73,8 @@ export async function generateAndEmailInvoice(args: AutoEmailArgs): Promise<{ reason?: string; invoiceNumber?: string; }> { - const orderGid = `gid://shopify/Order/${String(args.orderId).replace(/^.*\//, "")}`; + const orderNumeric = String(args.orderId).replace(/^.*\//, ""); + const orderGid = `gid://shopify/Order/${orderNumeric}`; const existing = await db.invoice.findFirst({ where: { @@ -72,7 +95,7 @@ export async function generateAndEmailInvoice(args: AutoEmailArgs): Promise<{ const generated = await generateInvoice({ shopDomain: args.shopDomain, admin: args.admin, - orderId: String(args.orderId), + orderId: orderNumeric, }); invoiceId = generated.invoiceId; invoiceNumber = generated.invoiceNumber; diff --git a/prisma/migrations/20260512130000_drop_wire_transfer_gateway_names/migration.sql b/prisma/migrations/20260512130000_drop_wire_transfer_gateway_names/migration.sql new file mode 100644 index 0000000..2273f68 --- /dev/null +++ b/prisma/migrations/20260512130000_drop_wire_transfer_gateway_names/migration.sql @@ -0,0 +1,4 @@ +-- Drop the column added by the previous migration; we now classify +-- wire-transfer orders by querying OrderTransaction.manualPaymentGateway +-- via the GraphQL Admin API instead of by configurable name match. +ALTER TABLE "ShopSettings" DROP COLUMN "wireTransferGatewayNames"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e135237..1247b67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,10 +105,6 @@ model ShopSettings { autoEmailOnWireTransferPlaced Boolean @default(false) // 2) Order is fulfilled and is NOT a wire-transfer order → auto-email. autoEmailOnFulfilledNonWireTransfer Boolean @default(false) - // Comma-separated list of payment-gateway names treated as "wire transfer" - // (matched case-insensitively, substring). Empty falls back to a sensible - // default ("manual,Überweisung,Wire Transfer,Bank Transfer,Vorkasse,Bank Deposit"). - wireTransferGatewayNames String @default("") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt