feat(automations): auto-email invoice on wire-transfer placed and on fulfillment
- 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.
This commit is contained in:
@@ -175,6 +175,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
emailBodyHtmlDe: str("emailBodyHtmlDe"),
|
emailBodyHtmlDe: str("emailBodyHtmlDe"),
|
||||||
emailSubjectEn: str("emailSubjectEn"),
|
emailSubjectEn: str("emailSubjectEn"),
|
||||||
emailBodyHtmlEn: str("emailBodyHtmlEn"),
|
emailBodyHtmlEn: str("emailBodyHtmlEn"),
|
||||||
|
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
|
||||||
|
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
|
||||||
|
wireTransferGatewayNames: str("wireTransferGatewayNames"),
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.shopSettings.upsert({
|
await db.shopSettings.upsert({
|
||||||
@@ -412,6 +415,35 @@ export default function SettingsRoute() {
|
|||||||
</s-stack>
|
</s-stack>
|
||||||
</s-section>
|
</s-section>
|
||||||
|
|
||||||
|
<s-section heading="Automations">
|
||||||
|
<s-stack direction="block" gap="base">
|
||||||
|
<s-paragraph>
|
||||||
|
These trigger directly from Shopify order webhooks — no Shopify
|
||||||
|
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.
|
||||||
|
</s-paragraph>
|
||||||
|
<Toggle
|
||||||
|
label='Auto-email the invoice when a wire-transfer order is placed (so the customer gets the bank details + GiroCode immediately).'
|
||||||
|
name="autoEmailOnWireTransferPlaced"
|
||||||
|
checked={settings.autoEmailOnWireTransferPlaced}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label='Auto-email the invoice when an order is fulfilled and is NOT a wire-transfer order (e.g. the customer paid by card and we send the invoice with the shipment).'
|
||||||
|
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>
|
||||||
|
|
||||||
<s-section>
|
<s-section>
|
||||||
<s-stack direction="block" gap="base">
|
<s-stack direction="block" gap="base">
|
||||||
{actionData?.ok && (
|
{actionData?.ok && (
|
||||||
|
|||||||
@@ -1,11 +1,49 @@
|
|||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import {
|
||||||
|
generateAndEmailInvoice,
|
||||||
|
isWireTransferOrder,
|
||||||
|
} from "../services/invoice/automations.server";
|
||||||
|
|
||||||
// We don't auto-generate invoices on order create. This handler just
|
/**
|
||||||
// acknowledges the webhook so Shopify keeps it healthy and gives us a
|
* orders/create — Automation 1: when a wire-transfer order is placed,
|
||||||
// hook point for future work (e.g. cache invalidation).
|
* 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) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { shop, topic } = await authenticate.webhook(request);
|
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
|
||||||
console.log(`Received ${topic} webhook for ${shop}`);
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
|
if (!session || !admin) return new Response();
|
||||||
|
|
||||||
|
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
||||||
|
if (!settings?.autoEmailOnWireTransferPlaced) return new Response();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await generateAndEmailInvoice({
|
||||||
|
shopDomain: shop,
|
||||||
|
admin,
|
||||||
|
orderId,
|
||||||
|
customerLocale: typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
console.warn(`auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`auto-email (wire-transfer placed) crashed for order ${orderId} on ${shop}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response();
|
return new Response();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import {
|
||||||
|
generateAndEmailInvoice,
|
||||||
|
isWireTransferOrder,
|
||||||
|
} 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
|
||||||
|
* because Automation 1 already emailed them at order-create time.
|
||||||
|
*/
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
|
if (!session || !admin) {
|
||||||
|
// App was uninstalled before the webhook drained — nothing to do.
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
||||||
|
if (!settings?.autoEmailOnFulfilledNonWireTransfer) return new Response();
|
||||||
|
|
||||||
|
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.
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await generateAndEmailInvoice({
|
||||||
|
shopDomain: shop,
|
||||||
|
admin,
|
||||||
|
orderId,
|
||||||
|
customerLocale: typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
console.warn(`auto-email (fulfilled) failed for order ${orderId} on ${shop}: ${result.reason}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`auto-email (fulfilled) crashed for order ${orderId} on ${shop}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ShopSettings" ADD COLUMN "autoEmailOnWireTransferPlaced" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "ShopSettings" ADD COLUMN "autoEmailOnFulfilledNonWireTransfer" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "ShopSettings" ADD COLUMN "wireTransferGatewayNames" TEXT NOT NULL DEFAULT '';
|
||||||
@@ -99,6 +99,17 @@ model ShopSettings {
|
|||||||
emailSubjectEn String @default("")
|
emailSubjectEn String @default("")
|
||||||
emailBodyHtmlEn String @default("")
|
emailBodyHtmlEn String @default("")
|
||||||
|
|
||||||
|
// Automations (webhook-driven, as a fallback to Shopify Flow which only
|
||||||
|
// exposes custom-app actions on Plus stores).
|
||||||
|
// 1) Wire-transfer order is placed → auto-email the invoice immediately.
|
||||||
|
autoEmailOnWireTransferPlaced Boolean @default(false)
|
||||||
|
// 2) Order is fulfilled and is NOT a wire-transfer order → auto-email.
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ api_version = "2026-07"
|
|||||||
uri = "/webhooks/orders/updated"
|
uri = "/webhooks/orders/updated"
|
||||||
topics = [ "orders/updated" ]
|
topics = [ "orders/updated" ]
|
||||||
|
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/orders/fulfilled"
|
||||||
|
topics = [ "orders/fulfilled" ]
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = [
|
redirect_urls = [
|
||||||
"https://invoice-app-dev.linumiq.com/auth/callback",
|
"https://invoice-app-dev.linumiq.com/auth/callback",
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ api_version = "2026-07"
|
|||||||
uri = "/webhooks/orders/updated"
|
uri = "/webhooks/orders/updated"
|
||||||
topics = [ "orders/updated" ]
|
topics = [ "orders/updated" ]
|
||||||
|
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/orders/fulfilled"
|
||||||
|
topics = [ "orders/fulfilled" ]
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = [
|
redirect_urls = [
|
||||||
"https://invoice-app.linumiq.com/auth/callback",
|
"https://invoice-app.linumiq.com/auth/callback",
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ api_version = "2026-07"
|
|||||||
uri = "/webhooks/orders/updated"
|
uri = "/webhooks/orders/updated"
|
||||||
topics = [ "orders/updated" ]
|
topics = [ "orders/updated" ]
|
||||||
|
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/orders/fulfilled"
|
||||||
|
topics = [ "orders/fulfilled" ]
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = [
|
redirect_urls = [
|
||||||
"https://invoice-app.linumiq.com/auth/callback",
|
"https://invoice-app.linumiq.com/auth/callback",
|
||||||
|
|||||||
Reference in New Issue
Block a user