0800d1160b
- New ShopSettings fields: autoEmailOnWireTransferPlaced, autoEmailOnFulfilledNonWireTransfer, wireTransferGatewayNames. - New Automations section in settings with two toggles + gateway list. - orders/create webhook now fires automation 1 (wire-transfer placed). - New orders/fulfilled webhook fires automation 2 (non-wire-transfer fulfilled). - Shared helper services/invoice/automations.server.ts handles classification and idempotent generate+send (skips if already sent). - Webhook subscription for orders/fulfilled added to all 3 app tomls. This is the non-Plus fallback for Shopify Flow, whose custom-app actions are gated to Plus stores only.
91 lines
2.8 KiB
TypeScript
91 lines
2.8 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";
|
|
|
|
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).
|
|
*/
|
|
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 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 orderGid = `gid://shopify/Order/${String(args.orderId).replace(/^.*\//, "")}`;
|
|
|
|
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: String(args.orderId),
|
|
});
|
|
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 };
|
|
}
|