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"),
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 <code>OrderTransaction.manualPaymentGateway</code> flag,
so any merchant-defined manual payment method (Überweisung, Cash
on Delivery, Money Order, ) qualifies.
</s-paragraph>
<Toggle
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"
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-section>
+8 -11
View File
@@ -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({
+6 -8
View File
@@ -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();
}
+49 -26
View File
@@ -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<boolean> {
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;