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.
This commit is contained in:
Gerhard Scheikl
2026-05-09 20:31:31 +02:00
parent 0800d1160b
commit 93aec2f368
6 changed files with 71 additions and 58 deletions
+4 -9
View File
@@ -177,7 +177,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
emailBodyHtmlEn: str("emailBodyHtmlEn"), emailBodyHtmlEn: str("emailBodyHtmlEn"),
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"), autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"), autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
wireTransferGatewayNames: str("wireTransferGatewayNames"),
}; };
await db.shopSettings.upsert({ await db.shopSettings.upsert({
@@ -422,8 +421,10 @@ export default function SettingsRoute() {
Flow required (Flow is gated to Plus stores for custom apps). Flow required (Flow is gated to Plus stores for custom apps).
When an automation fires, the invoice is generated (if it doesn't When an automation fires, the invoice is generated (if it doesn't
already exist) and emailed to the customer using the SMTP and already exist) and emailed to the customer using the SMTP and
email-template settings above. A copy is recorded in the email email-template settings above. "Wire-transfer" is detected via
log; failures are logged server-side. Shopify's <code>OrderTransaction.manualPaymentGateway</code> flag,
so any merchant-defined manual payment method (Überweisung, Cash
on Delivery, Money Order, ) qualifies.
</s-paragraph> </s-paragraph>
<Toggle <Toggle
label='Auto-email the invoice when a wire-transfer order is placed (so the customer gets the bank details + GiroCode immediately).' label='Auto-email the invoice when a wire-transfer order is placed (so the customer gets the bank details + GiroCode immediately).'
@@ -435,12 +436,6 @@ export default function SettingsRoute() {
name="autoEmailOnFulfilledNonWireTransfer" name="autoEmailOnFulfilledNonWireTransfer"
checked={settings.autoEmailOnFulfilledNonWireTransfer} checked={settings.autoEmailOnFulfilledNonWireTransfer}
/> />
<Field
label="Wire-transfer payment gateway names (comma-separated, case-insensitive substring match)"
name="wireTransferGatewayNames"
defaultValue={settings.wireTransferGatewayNames}
helpText='Used to classify which orders count as "wire transfer". Leave empty to use the default: manual, Überweisung, Wire Transfer, Bank Transfer, Vorkasse, Bank Deposit.'
/>
</s-stack> </s-stack>
</s-section> </s-section>
+8 -11
View File
@@ -3,14 +3,14 @@ import { authenticate } from "../shopify.server";
import db from "../db.server"; import db from "../db.server";
import { import {
generateAndEmailInvoice, generateAndEmailInvoice,
isWireTransferOrder, isManualPaymentOrder,
} from "../services/invoice/automations.server"; } from "../services/invoice/automations.server";
/** /**
* orders/create — Automation 1: when a wire-transfer order is placed, * orders/create — Automation 1: when a wire-transfer (manual-payment-gateway)
* immediately generate and email the invoice (which includes the bank * order is placed, immediately generate and email the invoice (which includes
* details + GiroCode) so the customer can pay. Other orders are ignored * the bank details + GiroCode) so the customer can pay. Other orders are
* here; they're handled by orders/fulfilled (Automation 2). * ignored here; they're handled by orders/fulfilled (Automation 2).
*/ */
export const action = async ({ request }: ActionFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, session, admin } = await authenticate.webhook(request); const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
@@ -24,12 +24,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const orderId = payload?.id; const orderId = payload?.id;
if (orderId == null) return new Response(); if (orderId == null) return new Response();
const gateways: string[] = Array.isArray(payload?.payment_gateway_names) const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
? payload.payment_gateway_names const isManual = await isManualPaymentOrder(admin, orderGid);
: []; if (!isManual) return new Response();
if (!isWireTransferOrder(gateways, settings.wireTransferGatewayNames)) {
return new Response();
}
try { try {
const result = await generateAndEmailInvoice({ const result = await generateAndEmailInvoice({
+6 -8
View File
@@ -3,13 +3,13 @@ import { authenticate } from "../shopify.server";
import db from "../db.server"; import db from "../db.server";
import { import {
generateAndEmailInvoice, generateAndEmailInvoice,
isWireTransferOrder, isManualPaymentOrder,
} from "../services/invoice/automations.server"; } from "../services/invoice/automations.server";
/** /**
* orders/fulfilled — Automation 2: when an order is fulfilled and is NOT a * 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 * wire-transfer (manual-payment-gateway) order, automatically email the
* to the customer. Wire-transfer orders are intentionally skipped here * invoice to the customer. Manual-gateway orders are intentionally skipped
* because Automation 1 already emailed them at order-create time. * because Automation 1 already emailed them at order-create time.
*/ */
export const action = async ({ request }: ActionFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => {
@@ -27,11 +27,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const orderId = payload?.id; const orderId = payload?.id;
if (orderId == null) return new Response(); if (orderId == null) return new Response();
const gateways: string[] = Array.isArray(payload?.payment_gateway_names) const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
? payload.payment_gateway_names if (await isManualPaymentOrder(admin, orderGid)) {
: []; // Manual / wire-transfer order — handled by Automation 1, skip here.
if (isWireTransferOrder(gateways, settings.wireTransferGatewayNames)) {
// Wire-transfer order — handled by Automation 1, skip here.
return new Response(); return new Response();
} }
+49 -26
View File
@@ -4,32 +4,54 @@ import db from "../../db.server";
import { generateInvoice } from "./generateInvoice.server"; import { generateInvoice } from "./generateInvoice.server";
import { sendInvoiceEmail } from "./email.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 * Returns true when the order has at least one transaction processed by a
* the configured wire-transfer gateway names (case-insensitive substring). * 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( export async function isManualPaymentOrder(
paymentGatewayNames: readonly string[] | null | undefined, admin: AdminApiContext,
configured: string, orderGid: string,
): boolean { ): Promise<boolean> {
if (!paymentGatewayNames || paymentGatewayNames.length === 0) return false; try {
const needles = (configured.trim() ? configured.split(",") : DEFAULT_WIRE_TRANSFER_NAMES) const res = await admin.graphql(
.map((s) => s.trim().toLowerCase()) `#graphql
.filter(Boolean); query OrderManualPaymentCheck($id: ID!) {
if (needles.length === 0) return false; order(id: $id) {
return paymentGatewayNames.some((name) => { transactions(first: 20) {
const n = (name ?? "").toLowerCase(); kind
return needles.some((needle) => n.includes(needle)); 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 { export interface AutoEmailArgs {
@@ -51,7 +73,8 @@ export async function generateAndEmailInvoice(args: AutoEmailArgs): Promise<{
reason?: string; reason?: string;
invoiceNumber?: 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({ const existing = await db.invoice.findFirst({
where: { where: {
@@ -72,7 +95,7 @@ export async function generateAndEmailInvoice(args: AutoEmailArgs): Promise<{
const generated = await generateInvoice({ const generated = await generateInvoice({
shopDomain: args.shopDomain, shopDomain: args.shopDomain,
admin: args.admin, admin: args.admin,
orderId: String(args.orderId), orderId: orderNumeric,
}); });
invoiceId = generated.invoiceId; invoiceId = generated.invoiceId;
invoiceNumber = generated.invoiceNumber; invoiceNumber = generated.invoiceNumber;
@@ -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";
-4
View File
@@ -105,10 +105,6 @@ model ShopSettings {
autoEmailOnWireTransferPlaced Boolean @default(false) autoEmailOnWireTransferPlaced Boolean @default(false)
// 2) Order is fulfilled and is NOT a wire-transfer order → auto-email. // 2) Order is fulfilled and is NOT a wire-transfer order → auto-email.
autoEmailOnFulfilledNonWireTransfer Boolean @default(false) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt