93aec2f368
- 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.
114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
|
|
|
|
import db from "../../db.server";
|
|
import { generateInvoice } from "./generateInvoice.server";
|
|
import { sendInvoiceEmail } from "./email.server";
|
|
|
|
/**
|
|
* 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 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 {
|
|
shopDomain: string;
|
|
admin: AdminApiContext;
|
|
/** Numeric Shopify order id (from REST webhook payload `id`). */
|
|
orderId: string | number;
|
|
/** Customer locale forwarded to the email service for subject/body language. */
|
|
customerLocale?: string;
|
|
}
|
|
|
|
/**
|
|
* Idempotent: if an unsent invoice already exists for the order, it is reused
|
|
* and emailed. If a sent invoice already exists, sending is skipped (we never
|
|
* spam the customer with the same invoice twice from automations).
|
|
*/
|
|
export async function generateAndEmailInvoice(args: AutoEmailArgs): Promise<{
|
|
ok: boolean;
|
|
reason?: string;
|
|
invoiceNumber?: string;
|
|
}> {
|
|
const orderNumeric = String(args.orderId).replace(/^.*\//, "");
|
|
const orderGid = `gid://shopify/Order/${orderNumeric}`;
|
|
|
|
const existing = await db.invoice.findFirst({
|
|
where: {
|
|
shopDomain: args.shopDomain,
|
|
orderId: orderGid,
|
|
kind: "invoice",
|
|
cancelledAt: null,
|
|
},
|
|
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
|
});
|
|
if (existing && existing.sentAt) {
|
|
return { ok: true, reason: "already-sent", invoiceNumber: existing.invoiceNumber };
|
|
}
|
|
|
|
let invoiceId = existing?.id;
|
|
let invoiceNumber = existing?.invoiceNumber;
|
|
if (!invoiceId) {
|
|
const generated = await generateInvoice({
|
|
shopDomain: args.shopDomain,
|
|
admin: args.admin,
|
|
orderId: orderNumeric,
|
|
});
|
|
invoiceId = generated.invoiceId;
|
|
invoiceNumber = generated.invoiceNumber;
|
|
}
|
|
|
|
const result = await sendInvoiceEmail({
|
|
shopDomain: args.shopDomain,
|
|
invoiceId,
|
|
customerLocale: args.customerLocale,
|
|
});
|
|
if (!result.ok) {
|
|
return { ok: false, reason: result.errorMessage ?? "email-failed", invoiceNumber };
|
|
}
|
|
return { ok: true, invoiceNumber };
|
|
}
|