diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx
index f6bcaed..e7d4233 100644
--- a/app/routes/app.settings.tsx
+++ b/app/routes/app.settings.tsx
@@ -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() {
+
+
+
+ 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.
+
+
+
+
+
+
+
{actionData?.ok && (
diff --git a/app/routes/webhooks.orders.create.tsx b/app/routes/webhooks.orders.create.tsx
index 98bb4ee..6b6f727 100644
--- a/app/routes/webhooks.orders.create.tsx
+++ b/app/routes/webhooks.orders.create.tsx
@@ -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();
};
diff --git a/app/routes/webhooks.orders.fulfilled.tsx b/app/routes/webhooks.orders.fulfilled.tsx
new file mode 100644
index 0000000..ab95d71
--- /dev/null
+++ b/app/routes/webhooks.orders.fulfilled.tsx
@@ -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();
+};
diff --git a/app/services/invoice/automations.server.ts b/app/services/invoice/automations.server.ts
new file mode 100644
index 0000000..a1fa09c
--- /dev/null
+++ b/app/services/invoice/automations.server.ts
@@ -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 };
+}
diff --git a/prisma/migrations/20260512120000_add_automation_settings/migration.sql b/prisma/migrations/20260512120000_add_automation_settings/migration.sql
new file mode 100644
index 0000000..1cc72a9
--- /dev/null
+++ b/prisma/migrations/20260512120000_add_automation_settings/migration.sql
@@ -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 '';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 451d83c..e135237 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
diff --git a/shopify.app.dev.toml b/shopify.app.dev.toml
index 9768d0d..89abd7a 100644
--- a/shopify.app.dev.toml
+++ b/shopify.app.dev.toml
@@ -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",
diff --git a/shopify.app.prod.toml b/shopify.app.prod.toml
index 17cb10d..9c69c01 100644
--- a/shopify.app.prod.toml
+++ b/shopify.app.prod.toml
@@ -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",
diff --git a/shopify.app.toml b/shopify.app.toml
index 06bcbc0..7e2f918 100644
--- a/shopify.app.toml
+++ b/shopify.app.toml
@@ -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",