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:
Gerhard Scheikl
2026-05-09 20:21:41 +02:00
parent a99dbc51c5
commit 0800d1160b
9 changed files with 244 additions and 4 deletions
+32
View File
@@ -175,6 +175,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
emailBodyHtmlDe: str("emailBodyHtmlDe"),
emailSubjectEn: str("emailSubjectEn"),
emailBodyHtmlEn: str("emailBodyHtmlEn"),
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
wireTransferGatewayNames: str("wireTransferGatewayNames"),
};
await db.shopSettings.upsert({
@@ -412,6 +415,35 @@ export default function SettingsRoute() {
</s-stack>
</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-stack direction="block" gap="base">
{actionData?.ok && (
+42 -4
View File
@@ -1,11 +1,49 @@
import type { ActionFunctionArgs } from "react-router";
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
// hook point for future work (e.g. cache invalidation).
/**
* 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).
*/
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}`);
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();
};
+53
View File
@@ -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 '';
+11
View File
@@ -99,6 +99,17 @@ model ShopSettings {
emailSubjectEn 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())
updatedAt DateTime @updatedAt
+4
View File
@@ -31,6 +31,10 @@ api_version = "2026-07"
uri = "/webhooks/orders/updated"
topics = [ "orders/updated" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/fulfilled"
topics = [ "orders/fulfilled" ]
[auth]
redirect_urls = [
"https://invoice-app-dev.linumiq.com/auth/callback",
+4
View File
@@ -31,6 +31,10 @@ api_version = "2026-07"
uri = "/webhooks/orders/updated"
topics = [ "orders/updated" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/fulfilled"
topics = [ "orders/fulfilled" ]
[auth]
redirect_urls = [
"https://invoice-app.linumiq.com/auth/callback",
+4
View File
@@ -31,6 +31,10 @@ api_version = "2026-07"
uri = "/webhooks/orders/updated"
topics = [ "orders/updated" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/fulfilled"
topics = [ "orders/fulfilled" ]
[auth]
redirect_urls = [
"https://invoice-app.linumiq.com/auth/callback",