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();
};