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"),
|
||||
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 && (
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user