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",