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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user