Skip to content

Automation Runbooks

Business: Research Relay LLC -- RUO peptide / research chemical e-commerce Stack: MedusaJS v2 + BTCPay Server + Mercury + Zoho Books Free + Zoho Mail + Cloudflare Prepared: February 2026

Implementation runbooks for the top 10 highest-priority automations from the opportunity map. Each runbook provides everything needed to build, deploy, and monitor the automation.


Runbook 1: Order Confirmation Email

Opportunity Map Reference: 1.1 Priority: High | Complexity: Simple | Effort: S

Trigger

MedusaJS emits the order.placed event when a customer completes checkout (via completeCartWorkflow) or when a draft order is converted to an order (via convertDraftOrderWorkflow).

Implementation Approach

MedusaJS subscriber at src/subscribers/order-placed.ts that listens to order.placed and executes a workflow to send the confirmation email.

Step 1: Configure Notification Module Provider

Register SendGrid as the notification provider in medusa-config.ts:

// medusa-config.ts
module.exports = defineConfig({
  modules: [
    {
      resolve: "@medusajs/medusa/notification",
      options: {
        providers: [
          {
            resolve: "@medusajs/medusa/notification-sendgrid",
            id: "sendgrid",
            options: {
              channels: ["email"],
              api_key: process.env.SENDGRID_API_KEY,
              from: process.env.SENDGRID_FROM_EMAIL, // orders@research-relay.com
            },
          },
        ],
      },
    },
  ],
})

Step 2: Create the Workflow

// src/workflows/send-order-confirmation.ts
import { createWorkflow } from "@medusajs/framework/workflows-sdk"
import {
  sendNotificationsStep,
  useQueryGraphStep,
} from "@medusajs/medusa/core-flows"

type WorkflowInput = {
  id: string
}

export const sendOrderConfirmationWorkflow = createWorkflow(
  "send-order-confirmation",
  ({ id }: WorkflowInput) => {
    const { data: orders } = useQueryGraphStep({
      entity: "order",
      fields: [
        "*",
        "items.*",
        "items.variant.*",
        "items.variant.product.*",
        "shipping_address.*",
        "customer.*",
      ],
      filters: { id },
    })

    sendNotificationsStep({
      to: orders[0].customer.email,
      channel: "email",
      template: "order-confirmation",
      data: {
        order: orders[0],
        ruo_disclaimer:
          "RESEARCH USE ONLY -- All products shipped by Research Relay LLC " +
          "are for research and laboratory use only. Not for human consumption. " +
          "Not for therapeutic, diagnostic, or medicinal use.",
      },
    })
  }
)

Step 3: Create the Subscriber

// src/subscribers/order-placed.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
import { sendOrderConfirmationWorkflow } from "../workflows/send-order-confirmation"

export default async function orderPlacedHandler({
  event: { data },
  container,
}: SubscriberArgs<{ id: string }>) {
  const logger = container.resolve("logger")
  logger.info(`Order placed: ${data.id} -- sending confirmation email`)

  await sendOrderConfirmationWorkflow(container).run({
    input: { id: data.id },
  })
}

export const config: SubscriberConfig = {
  event: "order.placed",
}

Step 4: Create SendGrid Template

In SendGrid dashboard, create a dynamic template order-confirmation with: - Order ID and date - Line items with product names, quantities, and prices - Shipping address - Payment method summary - RUO disclaimer footer (passed via data.ruo_disclaimer)

Data Flow

Customer completes checkout
  -> completeCartWorkflow runs
  -> Medusa emits "order.placed" event with { id: orderId }
  -> Subscriber fires, resolves order details via Query
  -> sendNotificationsStep calls SendGrid API
  -> Customer receives confirmation email

Error Handling

  • SendGrid API failure: The workflow step will throw. MedusaJS logs the error. The order itself is not affected (subscriber runs outside the main order flow).
  • Missing customer email: The useQueryGraphStep will return the order data. If customer.email is null (guest checkout without email), the sendNotificationsStep will fail. Guard with a check before sending.
  • Template not found: SendGrid returns a 400 error. Logged by Medusa. Fix by verifying template ID matches.

Retry strategy: Subscribers do not automatically retry. If email delivery is critical, wrap the workflow execution in a try/catch and implement manual retry logic, or rely on SendGrid's built-in delivery retry.

Monitoring

  • SendGrid Activity Feed: Check for successful deliveries, bounces, and blocks.
  • MedusaJS logs: Filter for Order placed: log messages and any subsequent errors.
  • Verification test: Place a test order and confirm email arrives within 60 seconds.
  • Ongoing: Include email delivery success rate in the daily sales summary (Runbook 7).

Dependencies

  • SendGrid account created and API key generated
  • SendGrid sender identity verified for orders@research-relay.com
  • SendGrid dynamic template created with ID stored in environment
  • @medusajs/medusa/notification-sendgrid installed and configured
  • Medusa worker mode running (subscribers execute on the worker)

Runbook 2: Fulfillment Created Notification

Opportunity Map Reference: 1.2 Priority: High | Complexity: Simple | Effort: S

Trigger

MedusaJS emits the order.fulfillment_created event when an operator creates a fulfillment for an order in the admin (via createOrderFulfillmentWorkflow).

Implementation Approach

MedusaJS subscriber at src/subscribers/fulfillment-created.ts that sends a notification to the customer when their order has been packed.

// src/workflows/send-fulfillment-notification.ts
import { createWorkflow } from "@medusajs/framework/workflows-sdk"
import {
  sendNotificationsStep,
  useQueryGraphStep,
} from "@medusajs/medusa/core-flows"

type WorkflowInput = {
  id: string
}

export const sendFulfillmentNotificationWorkflow = createWorkflow(
  "send-fulfillment-notification",
  ({ id }: WorkflowInput) => {
    const { data: orders } = useQueryGraphStep({
      entity: "order",
      fields: [
        "*",
        "items.*",
        "fulfillments.*",
        "fulfillments.items.*",
        "shipping_address.*",
        "customer.*",
      ],
      filters: { id },
    })

    sendNotificationsStep({
      to: orders[0].customer.email,
      channel: "email",
      template: "fulfillment-created",
      data: {
        order: orders[0],
        fulfillment: orders[0].fulfillments?.[orders[0].fulfillments.length - 1],
        ruo_disclaimer:
          "RESEARCH USE ONLY -- All products shipped by Research Relay LLC " +
          "are for research and laboratory use only. Not for human consumption.",
      },
    })
  }
)
// src/subscribers/fulfillment-created.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
import { sendFulfillmentNotificationWorkflow } from "../workflows/send-fulfillment-notification"

export default async function fulfillmentCreatedHandler({
  event: { data },
  container,
}: SubscriberArgs<{ id: string }>) {
  const logger = container.resolve("logger")
  logger.info(`Fulfillment created for order: ${data.id}`)

  await sendFulfillmentNotificationWorkflow(container).run({
    input: { id: data.id },
  })
}

export const config: SubscriberConfig = {
  event: "order.fulfillment_created",
}

Data Flow

Operator clicks "Create Fulfillment" in Medusa Admin
  -> createOrderFulfillmentWorkflow runs
  -> Inventory reserved -> stocked_quantity decremented
  -> Medusa emits "order.fulfillment_created" with { id: orderId }
  -> Subscriber fires, resolves order + fulfillment details
  -> sendNotificationsStep sends email via SendGrid
  -> Customer receives "Your order is being prepared" email

Error Handling

  • Same pattern as Runbook 1. The fulfillment itself is not affected by email failure.
  • If the order has multiple fulfillments (partial fulfillment), the workflow fetches the latest fulfillment from the array.

Monitoring

  • SendGrid Activity Feed: Monitor deliveries for fulfillment-created template.
  • MedusaJS logs: Filter for Fulfillment created for order: entries.
  • Correlation check: Verify that every fulfillment in Medusa Admin has a corresponding SendGrid delivery within 5 minutes.

Dependencies

  • Runbook 1 dependencies (SendGrid configured)
  • SendGrid dynamic template fulfillment-created created
  • Template includes: order details, items being fulfilled, estimated shipping timeline

Runbook 3: BTC Payment Status Automation

Opportunity Map Reference: 1.6 Priority: High | Complexity: Simple | Effort: S

Trigger

BTCPay Server sends webhook events to MedusaJS when a BTC payment's status changes. Key events: - InvoiceProcessing -- payment seen in mempool (or Lightning payment received) - InvoiceSettled -- payment has sufficient confirmations (1 on-chain) or Lightning settled - InvoiceExpired -- customer did not pay within the invoice timeout window - InvoiceInvalid -- payment became invalid

Implementation Approach

This is built into the BTCPay payment provider module. The getWebhookActionAndData() method on the payment provider service handles all incoming webhooks.

MedusaJS payment module provider at src/modules/btcpay/service.ts.

Webhook Handler

// src/modules/btcpay/service.ts (relevant excerpt)
import { AbstractPaymentProvider } from "@medusajs/framework/utils"
import { BigNumber } from "@medusajs/framework/utils"
import crypto from "crypto"

async getWebhookActionAndData(
  payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
  const { data, rawData, headers } = payload

  // Step 1: Verify webhook signature (HMAC-SHA256)
  const sig = headers["btcpay-sig"] as string
  if (!sig) {
    return {
      action: "not_supported",
      data: { session_id: "", amount: new BigNumber(0) },
    }
  }

  const expectedSig = crypto
    .createHmac("sha256", this.options_.webhookSecret)
    .update(rawData as string)
    .digest("hex")

  if (`sha256=${expectedSig}` !== sig) {
    this.logger_.warn("BTCPay webhook signature verification failed")
    return {
      action: "failed",
      data: { session_id: "", amount: new BigNumber(0) },
    }
  }

  // Step 2: Extract event data
  const event = data as {
    type: string
    invoiceId: string
    metadata?: { medusa_session_id?: string }
    afterExpiration?: boolean
    paymentMethod?: string
  }

  const sessionId = event.metadata?.medusa_session_id || ""

  // Step 3: Fetch invoice details from BTCPay for amount
  const invoice = await this.btcpayClient_.getInvoice(event.invoiceId)
  const amount = new BigNumber(invoice.amount)

  // Step 4: Map BTCPay event to Medusa action
  switch (event.type) {
    case "InvoiceSettled":
      this.logger_.info(
        `BTCPay invoice ${event.invoiceId} settled. Amount: ${amount}`
      )
      return { action: "captured", data: { session_id: sessionId, amount } }

    case "InvoiceProcessing":
      this.logger_.info(
        `BTCPay invoice ${event.invoiceId} processing (payment in mempool)`
      )
      return { action: "authorized", data: { session_id: sessionId, amount } }

    case "InvoiceExpired":
      this.logger_.info(`BTCPay invoice ${event.invoiceId} expired`)
      return { action: "failed", data: { session_id: sessionId, amount } }

    case "InvoiceInvalid":
      this.logger_.warn(`BTCPay invoice ${event.invoiceId} invalid`)
      return { action: "failed", data: { session_id: sessionId, amount } }

    default:
      return {
        action: "not_supported",
        data: { session_id: sessionId, amount: new BigNumber(0) },
      }
  }
}

BTCPay Webhook Registration

Register the webhook in BTCPay Server (one-time setup):

Via BTCPay Admin UI: 1. Go to Store Settings > Webhooks > Create Webhook 2. Payload URL: https://api.research-relay.com/hooks/payment/btcpay_btcpay 3. Secret: Generate and store in Medusa .env as BTCPAY_WEBHOOK_SECRET 4. Events: InvoiceCreated, InvoiceProcessing, InvoiceSettled, InvoiceExpired, InvoiceInvalid, InvoicePaymentSettled 5. Enable automatic redelivery

Via BTCPay Greenfield API:

curl -X POST "https://btcpay.research-relay.com/api/v1/stores/{storeId}/webhooks" \
  -H "Authorization: token $BTCPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.research-relay.com/hooks/payment/btcpay_btcpay",
    "authorizedEvents": {
      "everything": false,
      "specificEvents": [
        "InvoiceProcessing",
        "InvoiceSettled",
        "InvoiceExpired",
        "InvoiceInvalid",
        "InvoicePaymentSettled"
      ]
    },
    "secret": "'$BTCPAY_WEBHOOK_SECRET'"
  }'

Data Flow

Customer sends BTC payment
  -> BTCPay Server detects transaction
  -> For Lightning: InvoiceProcessing + InvoiceSettled fire instantly
  -> For on-chain: InvoiceProcessing fires on mempool detection
                   InvoiceSettled fires after 1 confirmation (~10 min)
  -> BTCPay sends POST to /hooks/payment/btcpay_btcpay
  -> MedusaJS routes to payment provider's getWebhookActionAndData()
  -> Signature verified against BTCPAY_WEBHOOK_SECRET
  -> Event mapped to Medusa action (authorized/captured/failed)
  -> Medusa updates payment session status
  -> If captured: order.placed event fires (triggers Runbook 1)

Error Handling

  • Signature mismatch: Log warning, return failed action. Investigate potential man-in-the-middle or misconfigured secret.
  • BTCPay API unreachable (when fetching invoice details): Catch the error, log it, return not_supported. BTCPay will redeliver the webhook (up to 6 retries).
  • Duplicate webhooks: Medusa's payment module handles idempotency. Processing the same InvoiceSettled twice for an already-captured payment is a no-op.
  • Late payments (after invoice expiry): BTCPay may send InvoiceSettled with afterExpiration: true. Handle by checking this flag and either auto-refunding or flagging for manual review.

Monitoring

  • BTCPay Server webhook log: Settings > Webhooks > click webhook > Recent Deliveries. Shows HTTP status codes for each delivery.
  • MedusaJS logs: Filter for BTCPay invoice entries to see all payment state transitions.
  • Reconciliation: Weekly comparison of BTCPay settled invoices vs Medusa captured payments.
  • Alert on failures: If the webhook endpoint returns 5xx for 3+ consecutive attempts, BTCPay stops retrying. Monitor BTCPay webhook health.

Dependencies

  • BTCPay Server running and accessible at btcpay.research-relay.com
  • BTCPay payment module provider implemented and registered in medusa-config.ts
  • BTCPAY_API_KEY, BTCPAY_STORE_ID, BTCPAY_SERVER_URL, BTCPAY_WEBHOOK_SECRET in environment
  • Webhook registered in BTCPay with correct URL and events
  • MedusaJS endpoint https://api.research-relay.com/hooks/payment/btcpay_btcpay reachable from BTCPay server

Runbook 4: Low Stock Alerts

Opportunity Map Reference: 2.1 Priority: High | Complexity: Simple | Effort: S

Trigger

MedusaJS scheduled job runs daily at 7:00 AM Pacific.

Implementation Approach

// src/jobs/low-stock-alert.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"

export default async function lowStockAlertJob(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const inventoryService = container.resolve(Modules.INVENTORY)
  const productService = container.resolve(Modules.PRODUCT)
  const notificationService = container.resolve(Modules.NOTIFICATION)
  const query = container.resolve("query")

  logger.info("Running low stock alert check...")

  // Fetch all inventory levels
  const [inventoryLevels] = await inventoryService.listInventoryLevels(
    {},
    { take: 1000 }
  )

  const lowStockItems: Array<{
    sku: string
    title: string
    available: number
    threshold: number
  }> = []

  for (const level of inventoryLevels) {
    const available = level.stocked_quantity - level.reserved_quantity

    // Fetch the linked product variant for threshold and display info
    const { data: links } = await query.graph({
      entity: "inventory_item",
      fields: ["id", "sku", "title", "metadata"],
      filters: { id: level.inventory_item_id },
    })

    const item = links[0]
    const reorderThreshold = item?.metadata?.reorder_threshold ?? 10

    if (available <= reorderThreshold) {
      lowStockItems.push({
        sku: item?.sku || "UNKNOWN",
        title: item?.title || "Unknown Item",
        available,
        threshold: reorderThreshold,
      })
    }
  }

  if (lowStockItems.length === 0) {
    logger.info("No low stock items found.")
    return
  }

  logger.warn(`Found ${lowStockItems.length} low stock items.`)

  // Send alert email
  const itemList = lowStockItems
    .map(
      (item) =>
        `- ${item.title} (${item.sku}): ${item.available} remaining (threshold: ${item.threshold})`
    )
    .join("\n")

  await notificationService.createNotifications({
    to: process.env.OPERATOR_EMAIL || "ops@research-relay.com",
    channel: "email",
    template: "low-stock-alert",
    data: {
      item_count: lowStockItems.length,
      items: lowStockItems,
      item_list_text: itemList,
      date: new Date().toISOString().split("T")[0],
    },
  })

  logger.info(`Low stock alert sent for ${lowStockItems.length} items.`)
}

export const config = {
  name: "low-stock-alert",
  schedule: "0 14 * * *", // 7 AM Pacific = 14:00 UTC
}

Data Flow

Cron fires at 7:00 AM Pacific (14:00 UTC)
  -> Job queries Inventory Module for all inventory levels
  -> For each level: calculate available = stocked - reserved
  -> Compare against reorder_threshold from inventory item metadata
  -> Collect all items below threshold
  -> Format into email summary
  -> Send via Notification Module (SendGrid)
  -> Operator receives email with action items for the day

Error Handling

  • Inventory Module query failure: Job catches error, logs it, does not send email. Will retry on next scheduled run (next day).
  • Notification send failure: Log the error. Low stock data is still logged to Medusa logs for manual review.
  • No low stock items: Job logs "No low stock items found" and exits cleanly. No email sent (avoid alert fatigue).

Monitoring

  • MedusaJS logs: Filter for Running low stock alert check to verify the job fires daily.
  • Email delivery: Check that the alert email arrives by 7:05 AM on days when stock is low.
  • False negatives: Periodically cross-check inventory in Medusa Admin against the last alert email to verify thresholds are correct.

Dependencies

  • reorder_threshold value set in metadata for each inventory item (default: 10)
  • OPERATOR_EMAIL environment variable set
  • SendGrid template low-stock-alert created
  • Medusa worker mode running (scheduled jobs execute on the worker)

Runbook 5: Lot / Batch Tracking Module

Opportunity Map Reference: 2.3 Priority: High | Complexity: Medium | Effort: L

Trigger

Operator creates/manages lots via custom admin UI when receiving inventory from suppliers. Lots are assigned to fulfillments during pick-and-pack.

Implementation Approach

This is a custom MedusaJS module with data model, service, API routes, Module Link, and admin widget.

Step 1: Define the Data Model

// src/modules/lot/models/lot.ts
import { model } from "@medusajs/framework/utils"

export const Lot = model.define("lot", {
  id: model.id().primaryKey(),
  lot_number: model.text().unique(),
  manufacture_date: model.dateTime().nullable(),
  expiry_date: model.dateTime().nullable(),
  purity_percentage: model.float().nullable(),
  coa_url: model.text().nullable(),
  storage_requirements: model.text().nullable(),
  quantity_received: model.number().default(0),
  quantity_remaining: model.number().default(0),
  supplier_name: model.text().nullable(),
  notes: model.text().nullable(),
})

Step 2: Create the Module Service

// src/modules/lot/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import { Lot } from "./models/lot"

class LotModuleService extends MedusaService({ Lot }) {}

export default LotModuleService

Step 3: Module Definition

// src/modules/lot/index.ts
import LotModuleService from "./service"
import { Module } from "@medusajs/framework/utils"

export const LOT_MODULE = "lotModuleService"

export default Module(LOT_MODULE, {
  service: LotModuleService,
})
// src/links/inventory-lot.ts
import { defineLink } from "@medusajs/framework/utils"
import { Modules } from "@medusajs/framework/utils"
import LotModule from "../modules/lot"

export default defineLink(
  Modules.INVENTORY,
  LotModule.linkable.lot
)

Step 5: Register in Config

// medusa-config.ts (add to modules array)
{
  resolve: "./src/modules/lot",
  options: {},
}

Step 6: Generate and Run Migration

npx medusa db:generate lotModule
npx medusa db:migrate

Step 7: Create API Routes

// src/api/admin/lots/route.ts
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { LOT_MODULE } from "../../../modules/lot"

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const lotService = req.scope.resolve(LOT_MODULE)
  const [lots, count] = await lotService.listAndCountLots(
    {},
    { take: 50, skip: 0, order: { created_at: "DESC" } }
  )
  res.json({ lots, count })
}

export async function POST(req: MedusaRequest, res: MedusaResponse) {
  const lotService = req.scope.resolve(LOT_MODULE)
  const lot = await lotService.createLots(req.body)
  res.json({ lot })
}

Step 8: Admin Widget for Lot Management

// src/admin/widgets/lot-info.tsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { Container, Heading, Text, Badge } from "@medusajs/ui"

const LotInfoWidget = ({ data }: { data: any }) => {
  // Fetch lots linked to this inventory item or product
  // Display lot number, expiry, purity, COA link
  return (
    <Container>
      <Heading level="h2">Lot Information</Heading>
      {/* Lot details rendered here */}
    </Container>
  )
}

export const config = defineWidgetConfig({
  zone: "product.details.after",
})

export default LotInfoWidget

Data Flow

Supplier ships inventory
  -> Operator receives product, notes lot number from supplier COA
  -> Operator creates Lot record via admin UI or API:
     { lot_number, expiry_date, purity_percentage, coa_url, quantity_received }
  -> Operator links Lot to Inventory Item via Module Link
  -> During fulfillment, operator selects lot(s) being shipped
  -> Lot number stored in fulfillment metadata
  -> Lot quantity_remaining decremented
  -> COA URL available for customer notification (Runbook 2, opportunity 4.4)

Error Handling

  • Duplicate lot number: lot_number has a unique constraint. The service will throw a unique constraint violation. Admin UI should display a user-friendly error.
  • Missing COA URL: Not a blocking error -- the lot is created without it. COA collection reminders (opportunity 8.1) will flag it.
  • Expiry date in past: Validation in the API route should warn but allow (for recording historical lots). Expiry alert job (opportunity 2.4) will flag it immediately.

Monitoring

  • Data quality check: Weekly scheduled job counts lots missing COA URLs or expiry dates. Sends summary to operator.
  • Admin widget: Visual indicator (red badge) on products with lots expiring within 30 days.
  • Audit trail: All lot operations logged via MedusaJS service (automatic created_at, updated_at).

Dependencies

  • MedusaJS custom module support (standard in v2)
  • Database migration run after module creation
  • Admin widget built and tested
  • API routes for CRUD operations
  • Module Link between Inventory and Lot

Runbook 6: Expiration Date Alerts

Opportunity Map Reference: 2.4 Priority: High | Complexity: Medium | Effort: M

Trigger

MedusaJS scheduled job runs daily at 6:00 AM Pacific (before the low stock alert at 7:00 AM).

Implementation Approach

// src/jobs/expiry-alert.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { LOT_MODULE } from "../modules/lot"

export default async function expiryAlertJob(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const lotService = container.resolve(LOT_MODULE)
  const notificationService = container.resolve(Modules.NOTIFICATION)

  logger.info("Running expiry date alert check...")

  const now = new Date()
  const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
  const in60Days = new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000)
  const in90Days = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000)

  // Fetch all lots that have an expiry date
  const [lots] = await lotService.listLots(
    { expiry_date: { $ne: null } },
    { take: 1000 }
  )

  const expired: typeof lots = []
  const expiring30: typeof lots = []
  const expiring60: typeof lots = []
  const expiring90: typeof lots = []

  for (const lot of lots) {
    if (!lot.expiry_date) continue
    const expiryDate = new Date(lot.expiry_date)

    if (expiryDate <= now) {
      expired.push(lot)
    } else if (expiryDate <= in30Days) {
      expiring30.push(lot)
    } else if (expiryDate <= in60Days) {
      expiring60.push(lot)
    } else if (expiryDate <= in90Days) {
      expiring90.push(lot)
    }
  }

  // Auto-quarantine expired lots
  for (const lot of expired) {
    if (lot.quantity_remaining > 0) {
      await lotService.updateLots(lot.id, { quantity_remaining: 0 })
      logger.warn(
        `EXPIRED: Lot ${lot.lot_number} quarantined (quantity set to 0)`
      )
    }
  }

  const hasAlerts =
    expired.length > 0 ||
    expiring30.length > 0 ||
    expiring60.length > 0 ||
    expiring90.length > 0

  if (!hasAlerts) {
    logger.info("No expiry alerts.")
    return
  }

  await notificationService.createNotifications({
    to: process.env.OPERATOR_EMAIL || "ops@research-relay.com",
    channel: "email",
    template: "expiry-alert",
    data: {
      date: now.toISOString().split("T")[0],
      expired,
      expiring_30_days: expiring30,
      expiring_60_days: expiring60,
      expiring_90_days: expiring90,
      expired_count: expired.length,
      expiring_30_count: expiring30.length,
    },
  })

  logger.info(
    `Expiry alert sent: ${expired.length} expired, ` +
    `${expiring30.length} within 30d, ${expiring60.length} within 60d, ` +
    `${expiring90.length} within 90d`
  )
}

export const config = {
  name: "expiry-alert",
  schedule: "0 13 * * *", // 6 AM Pacific = 13:00 UTC
}

Data Flow

Cron fires at 6:00 AM Pacific
  -> Job queries Lot module for all lots with expiry dates
  -> Categorizes into: expired, 30-day, 60-day, 90-day buckets
  -> Auto-quarantines expired lots (sets quantity_remaining to 0)
  -> Sends graduated alert email to operator
  -> Operator reviews and takes action:
     - Expired: dispose and adjust inventory in Medusa
     - 30-day: discount, sell-through, or plan disposal
     - 60/90-day: informational, plan reorder of fresh stock

Error Handling

  • Lot module query failure: Log error, skip email. Next day's run will catch it.
  • Auto-quarantine failure: If updateLots fails, log the error with lot details. The lot will be flagged again the next day.
  • No expiry date on lot: Lots without expiry dates are silently skipped. A separate data quality check (Runbook 5 monitoring) catches missing expiry dates.

Monitoring

  • MedusaJS logs: Filter for Running expiry date alert check and EXPIRED: Lot entries.
  • Email delivery: Verify alert emails arrive before the morning routine.
  • Quarantine verification: After auto-quarantine, verify in Medusa Admin that affected lots show zero remaining quantity.

Dependencies

  • Lot module (Runbook 5) implemented and populated with data
  • expiry_date set on all lots during receiving
  • SendGrid template expiry-alert with sections for each severity level
  • OPERATOR_EMAIL environment variable set

Runbook 7: Daily Sales Summary

Opportunity Map Reference: 6.1 Priority: High | Complexity: Medium | Effort: M

Trigger

MedusaJS scheduled job runs daily at 11:00 PM Pacific.

Implementation Approach

// src/jobs/daily-sales-summary.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"

export default async function dailySalesSummaryJob(
  container: MedusaContainer
) {
  const logger = container.resolve("logger")
  const query = container.resolve("query")
  const notificationService = container.resolve(Modules.NOTIFICATION)

  logger.info("Generating daily sales summary...")

  const now = new Date()
  const dayStart = new Date(now)
  dayStart.setHours(0, 0, 0, 0)

  // Fetch today's orders
  const { data: orders } = await query.graph({
    entity: "order",
    fields: [
      "*",
      "items.*",
      "items.variant.*",
      "items.variant.product.*",
      "customer.*",
      "payment_collections.*",
      "payment_collections.payments.*",
    ],
    filters: {
      created_at: { $gte: dayStart.toISOString() },
    },
  })

  // Calculate metrics
  const totalRevenue = orders.reduce(
    (sum: number, order: any) => sum + (order.total || 0),
    0
  )
  const orderCount = orders.length
  const avgOrderValue = orderCount > 0 ? totalRevenue / orderCount : 0

  // Revenue by payment method
  const revenueByMethod: Record<string, number> = {}
  for (const order of orders) {
    const collections = order.payment_collections || []
    for (const collection of collections) {
      for (const payment of collection.payments || []) {
        const provider = payment.provider_id || "unknown"
        revenueByMethod[provider] =
          (revenueByMethod[provider] || 0) + (payment.amount || 0)
      }
    }
  }

  // Top selling products
  const productSales: Record<string, { title: string; quantity: number; revenue: number }> = {}
  for (const order of orders) {
    for (const item of order.items || []) {
      const key = item.variant?.product?.id || item.title
      if (!productSales[key]) {
        productSales[key] = {
          title: item.variant?.product?.title || item.title,
          quantity: 0,
          revenue: 0,
        }
      }
      productSales[key].quantity += item.quantity
      productSales[key].revenue += item.total || 0
    }
  }

  const topProducts = Object.values(productSales)
    .sort((a, b) => b.revenue - a.revenue)
    .slice(0, 5)

  // New vs returning customers
  let newCustomers = 0
  let returningCustomers = 0
  for (const order of orders) {
    if (order.customer?.created_at) {
      const customerCreated = new Date(order.customer.created_at)
      if (customerCreated >= dayStart) {
        newCustomers++
      } else {
        returningCustomers++
      }
    }
  }

  await notificationService.createNotifications({
    to: process.env.OPERATOR_EMAIL || "ops@research-relay.com",
    channel: "email",
    template: "daily-sales-summary",
    data: {
      date: now.toISOString().split("T")[0],
      total_revenue_cents: totalRevenue,
      total_revenue_display: (totalRevenue / 100).toFixed(2),
      order_count: orderCount,
      avg_order_value_display: (avgOrderValue / 100).toFixed(2),
      revenue_by_method: revenueByMethod,
      top_products: topProducts,
      new_customers: newCustomers,
      returning_customers: returningCustomers,
    },
  })

  logger.info(
    `Daily sales summary sent: ${orderCount} orders, $${(totalRevenue / 100).toFixed(2)} revenue`
  )
}

export const config = {
  name: "daily-sales-summary",
  schedule: "0 6 * * *", // 11 PM Pacific = 06:00 UTC (next day)
}

Data Flow

Cron fires at 11:00 PM Pacific
  -> Job queries Order Module for all orders created today
  -> Aggregates: total revenue, order count, AOV
  -> Breaks down revenue by payment method (BTC vs Stripe)
  -> Identifies top 5 selling products by revenue
  -> Counts new vs returning customers
  -> Sends formatted email summary
  -> Operator reviews before end of day

Error Handling

  • No orders today: Send an email with "No orders placed today" instead of empty data. Still useful as confirmation the job ran.
  • Query failure: Log error. The summary can be generated manually from Medusa Admin if the job fails.
  • Amount precision: All monetary values stored in cents. Division by 100 for display only.

Monitoring

  • Daily receipt: Operator should receive the email every night. Missing email indicates job failure.
  • Cross-check: Periodically compare summary numbers against Medusa Admin order list for the same day.
  • MedusaJS logs: Filter for Generating daily sales summary and Daily sales summary sent.

Dependencies

  • SendGrid template daily-sales-summary created with sections for all metrics
  • OPERATOR_EMAIL environment variable set
  • Medusa worker running
  • Sufficient order data for meaningful summaries (even zero-order days are reported)

Runbook 8: Website Compliance Scanning

Opportunity Map Reference: 5.1 Priority: High | Complexity: Medium | Effort: M

Trigger

MedusaJS scheduled job runs weekly on Mondays at 8:00 AM Pacific.

Implementation Approach

// src/jobs/compliance-scan.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"

// Prohibited keywords and phrases for RUO compliance
const PROHIBITED_PATTERNS: Array<{ pattern: RegExp; category: string }> = [
  // Therapeutic / medical claims
  { pattern: /\bcure[sd]?\b/i, category: "therapeutic_claim" },
  { pattern: /\btreat(s|ed|ment|ing)?\b/i, category: "therapeutic_claim" },
  { pattern: /\bheal(s|ed|ing)?\b/i, category: "therapeutic_claim" },
  { pattern: /\btherap(y|eutic|ies)\b/i, category: "therapeutic_claim" },
  { pattern: /\bdiagnos(is|tic|e)\b/i, category: "diagnostic_claim" },
  { pattern: /\bpreven(t|ts|ted|tion)\b/i, category: "therapeutic_claim" },
  { pattern: /\bremedy\b/i, category: "therapeutic_claim" },
  { pattern: /\bmitigat(e|es|ed|ing)\b/i, category: "therapeutic_claim" },

  // Health / body claims
  { pattern: /\bweight\s*loss\b/i, category: "health_claim" },
  { pattern: /\banti[\s-]*aging\b/i, category: "health_claim" },
  { pattern: /\bmuscle\s*(growth|gain|building)\b/i, category: "health_claim" },
  { pattern: /\bfat\s*(loss|burn(ing)?)\b/i, category: "health_claim" },
  { pattern: /\bimmun(e|ity)\s*(boost|support|enhance)\b/i, category: "health_claim" },

  // Regulatory claims
  { pattern: /\bFDA\s*approved\b/i, category: "regulatory_claim" },
  { pattern: /\bclinically\s*proven\b/i, category: "regulatory_claim" },
  { pattern: /\bpharmaceutical\s*grade\b/i, category: "regulatory_claim" },

  // Administration / dosage terms (implies human use)
  { pattern: /\bdosage\b/i, category: "administration" },
  { pattern: /\bdose[sd]?\b/i, category: "administration" },
  { pattern: /\binject(ion|ed|able|ing)?\b/i, category: "administration" },
  { pattern: /\badminister(ed|ing)?\b/i, category: "administration" },
  { pattern: /\boral(ly)?\b/i, category: "administration" },
  { pattern: /\bsubcutaneous(ly)?\b/i, category: "administration" },
  { pattern: /\bintraven(ous|ously)\b/i, category: "administration" },
  { pattern: /\bmg\s*\/\s*kg\b/i, category: "administration" },
  { pattern: /\bper\s*day\b/i, category: "administration" },

  // Consumption terms
  { pattern: /\bconsume[sd]?\b/i, category: "consumption" },
  { pattern: /\bingest(ion|ed|ing)?\b/i, category: "consumption" },
  { pattern: /\bsupplement\b/i, category: "consumption" },
]

// Allowlist: contexts where prohibited words are acceptable
const ALLOWLIST_CONTEXTS = [
  /not\s+(for|intended\s+for).*?(human|consumption|therapeutic|diagnostic)/i,
  /research\s+use\s+only/i,
  /must\s+not\s+be\s+used/i,
  /not\s+intended\s+for/i,
]

export default async function complianceScanJob(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const productService = container.resolve(Modules.PRODUCT)
  const notificationService = container.resolve(Modules.NOTIFICATION)

  logger.info("Running weekly compliance scan...")

  const [products] = await productService.listProducts(
    { status: "published" },
    { take: 500, select: ["id", "title", "description", "handle", "metadata"] }
  )

  const violations: Array<{
    product_id: string
    product_title: string
    handle: string
    field: string
    keyword: string
    category: string
    context: string
  }> = []

  for (const product of products) {
    const fieldsToScan = [
      { name: "title", value: product.title || "" },
      { name: "description", value: product.description || "" },
    ]

    for (const field of fieldsToScan) {
      for (const { pattern, category } of PROHIBITED_PATTERNS) {
        const match = field.value.match(pattern)
        if (match) {
          // Check if the match is in an allowlisted context
          const surroundingText = field.value.substring(
            Math.max(0, (match.index || 0) - 80),
            (match.index || 0) + match[0].length + 80
          )

          const isAllowlisted = ALLOWLIST_CONTEXTS.some((ctx) =>
            ctx.test(surroundingText)
          )

          if (!isAllowlisted) {
            violations.push({
              product_id: product.id,
              product_title: product.title || "Untitled",
              handle: product.handle || "",
              field: field.name,
              keyword: match[0],
              category,
              context: surroundingText.trim(),
            })
          }
        }
      }
    }
  }

  if (violations.length === 0) {
    logger.info("Compliance scan complete: no violations found.")
    return
  }

  logger.warn(`Compliance scan found ${violations.length} potential violations.`)

  await notificationService.createNotifications({
    to: process.env.OPERATOR_EMAIL || "ops@research-relay.com",
    channel: "email",
    template: "compliance-scan-report",
    data: {
      date: new Date().toISOString().split("T")[0],
      violation_count: violations.length,
      violations,
      products_affected: [...new Set(violations.map((v) => v.product_title))].length,
    },
  })

  logger.info(`Compliance scan report sent: ${violations.length} violations.`)
}

export const config = {
  name: "compliance-scan",
  schedule: "0 15 * * 1", // 8 AM Pacific Monday = 15:00 UTC Monday
}

Data Flow

Cron fires Monday 8:00 AM Pacific
  -> Job fetches all published products from Product Module
  -> Scans title and description for prohibited keywords/phrases
  -> Filters out matches that are in allowlisted contexts
     (e.g., "not for therapeutic use" contains "therapeutic" but is a disclaimer)
  -> Collects violations with surrounding context for review
  -> Sends compliance report email listing all flagged content
  -> Operator reviews and corrects product listings

Error Handling

  • Product query failure: Log error. Scan skipped for the week. Will run again next Monday.
  • False positives: The allowlist context filter reduces false positives from RUO disclaimers. Operator reviews all flagged items -- false positives are expected and acceptable (better safe than sorry for compliance).
  • Large product catalog: The job processes up to 500 products. If the catalog exceeds this, increase the take parameter or paginate.

Monitoring

  • Weekly email receipt: Operator should receive the scan report every Monday morning.
  • Violation trend: Track violation count over time. Increasing counts may indicate a content process issue.
  • MedusaJS logs: Filter for Running weekly compliance scan entries.

Dependencies

  • Products in Medusa have title and description populated
  • SendGrid template compliance-scan-report created
  • Prohibited keyword list reviewed and approved
  • OPERATOR_EMAIL environment variable set

Runbook 9: Product Listing Compliance Check

Opportunity Map Reference: 5.2 Priority: High | Complexity: Medium | Effort: M

Trigger

MedusaJS subscriber fires on product.created and product.updated events, providing real-time compliance checking whenever a product is added or modified.

Implementation Approach

// src/workflows/check-product-compliance.ts
import {
  createWorkflow,
  createStep,
  StepResponse,
} from "@medusajs/framework/workflows-sdk"
import { Modules } from "@medusajs/framework/utils"
import { sendNotificationsStep } from "@medusajs/medusa/core-flows"

// Reuse the prohibited patterns from the compliance scan job
import { PROHIBITED_PATTERNS, ALLOWLIST_CONTEXTS } from "../lib/compliance-patterns"

type ComplianceInput = {
  product_id: string
}

const checkProductComplianceStep = createStep(
  "check-product-compliance",
  async ({ product_id }: ComplianceInput, { container }) => {
    const query = container.resolve("query")
    const logger = container.resolve("logger")

    const { data: products } = await query.graph({
      entity: "product",
      fields: ["id", "title", "description", "handle", "status", "metadata"],
      filters: { id: product_id },
    })

    const product = products[0]
    if (!product) {
      return new StepResponse({ violations: [], missing: [], product_title: "Unknown" })
    }

    const violations: Array<{ field: string; keyword: string; category: string }> = []
    const missing: string[] = []

    // Check 1: RUO disclaimer in description
    if (
      !product.description ||
      !/research\s+use\s+only/i.test(product.description)
    ) {
      missing.push("RUO disclaimer missing from product description")
    }

    // Check 2: COA link in metadata
    if (!product.metadata?.coa_url) {
      missing.push("COA URL missing from product metadata")
    }

    // Check 3: Prohibited keyword scan on title and description
    const fieldsToScan = [
      { name: "title", value: product.title || "" },
      { name: "description", value: product.description || "" },
    ]

    for (const field of fieldsToScan) {
      for (const { pattern, category } of PROHIBITED_PATTERNS) {
        const match = field.value.match(pattern)
        if (match) {
          const surroundingText = field.value.substring(
            Math.max(0, (match.index || 0) - 80),
            (match.index || 0) + match[0].length + 80
          )
          const isAllowlisted = ALLOWLIST_CONTEXTS.some((ctx) =>
            ctx.test(surroundingText)
          )
          if (!isAllowlisted) {
            violations.push({
              field: field.name,
              keyword: match[0],
              category,
            })
          }
        }
      }
    }

    logger.info(
      `Compliance check for "${product.title}": ` +
      `${violations.length} violations, ${missing.length} missing elements`
    )

    return new StepResponse({
      violations,
      missing,
      product_title: product.title,
      product_handle: product.handle,
    })
  }
)

export const checkProductComplianceWorkflow = createWorkflow(
  "check-product-compliance",
  ({ product_id }: ComplianceInput) => {
    const result = checkProductComplianceStep({ product_id })

    // Only send notification if there are issues
    // Note: conditional logic in workflows uses when()
    // For simplicity, the notification step is always called
    // but the subscriber checks the result before executing

    return result
  }
)
// src/subscribers/product-compliance-check.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
import { Modules } from "@medusajs/framework/utils"
import { checkProductComplianceWorkflow } from "../workflows/check-product-compliance"

export default async function productComplianceHandler({
  event: { data },
  container,
}: SubscriberArgs<{ id: string }>) {
  const logger = container.resolve("logger")

  const { result } = await checkProductComplianceWorkflow(container).run({
    input: { product_id: data.id },
  })

  const hasIssues = result.violations.length > 0 || result.missing.length > 0

  if (hasIssues) {
    const notificationService = container.resolve(Modules.NOTIFICATION)

    await notificationService.createNotifications({
      to: process.env.OPERATOR_EMAIL || "ops@research-relay.com",
      channel: "email",
      template: "product-compliance-alert",
      data: {
        product_title: result.product_title,
        product_handle: result.product_handle,
        violations: result.violations,
        missing: result.missing,
        violation_count: result.violations.length,
        missing_count: result.missing.length,
      },
    })

    logger.warn(
      `Compliance issues found for product "${result.product_title}": ` +
      `${result.violations.length} violations, ${result.missing.length} missing`
    )
  }
}

export const config: SubscriberConfig = {
  event: ["product.created", "product.updated"],
}

Data Flow

Operator creates or updates a product in Medusa Admin
  -> Medusa emits "product.created" or "product.updated"
  -> Subscriber triggers compliance check workflow
  -> Workflow fetches full product data
  -> Checks: RUO disclaimer present, COA link exists, no prohibited keywords
  -> If issues found: sends immediate alert email to operator
  -> Operator receives alert and corrects the product listing

Error Handling

  • Product not found: If the product was deleted between the event emission and the workflow execution, the step returns empty violations. No alert sent.
  • Email failure: Log the error. The compliance issues still appear in MedusaJS logs for review.
  • False positives from updates: The subscriber fires on every product update, including non-content changes (e.g., price update). The scan only checks text fields, so non-content updates will pass cleanly.

Monitoring

  • MedusaJS logs: Filter for Compliance check for entries. Every product create/update should have a corresponding log.
  • Alert volume: If alerts are frequent, review whether the prohibited keyword list is too aggressive.
  • Weekly scan correlation: Cross-check with the weekly full-catalog scan (Runbook 8) to ensure no products slip through.

Dependencies

  • Prohibited keyword patterns extracted to shared module src/lib/compliance-patterns.ts
  • SendGrid template product-compliance-alert created
  • Product metadata convention established: coa_url field in product metadata
  • RUO disclaimer text standardized for product descriptions
  • OPERATOR_EMAIL environment variable set

Runbook 10: Medusa Orders to Zoho Books Invoice Sync

Opportunity Map Reference: 3.2 Priority: High | Complexity: Complex | Effort: L

Trigger

MedusaJS subscriber on order.placed event. Each new order generates an invoice in Zoho Books in near real-time.

Implementation Approach

This is a multi-system integration requiring Zoho Books API OAuth2 authentication, customer mapping, and invoice creation.

Step 1: Zoho Books API Authentication Module

// src/modules/zoho-books/client.ts
type ZohoBooksConfig = {
  clientId: string
  clientSecret: string
  refreshToken: string
  organizationId: string
  baseUrl: string // https://www.zohoapis.com/books/v3
}

class ZohoBooksClient {
  private config: ZohoBooksConfig
  private accessToken: string | null = null
  private tokenExpiry: Date | null = null

  constructor(config: ZohoBooksConfig) {
    this.config = config
  }

  // Refresh the access token using the refresh token
  private async refreshAccessToken(): Promise<string> {
    const params = new URLSearchParams({
      refresh_token: this.config.refreshToken,
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      grant_type: "refresh_token",
    })

    const response = await fetch(
      `https://accounts.zoho.com/oauth/v2/token?${params}`,
      { method: "POST" }
    )

    if (!response.ok) {
      throw new Error(`Zoho OAuth refresh failed: ${response.statusText}`)
    }

    const data = await response.json()
    this.accessToken = data.access_token
    this.tokenExpiry = new Date(Date.now() + data.expires_in * 1000)
    return this.accessToken
  }

  private async getToken(): Promise<string> {
    if (
      !this.accessToken ||
      !this.tokenExpiry ||
      this.tokenExpiry <= new Date()
    ) {
      return this.refreshAccessToken()
    }
    return this.accessToken
  }

  // Find or create a customer in Zoho Books
  async findOrCreateContact(customer: {
    email: string
    first_name: string
    last_name: string
  }): Promise<string> {
    const token = await this.getToken()

    // Search by email
    const searchResponse = await fetch(
      `${this.config.baseUrl}/contacts?organization_id=${this.config.organizationId}&email=${encodeURIComponent(customer.email)}`,
      { headers: { Authorization: `Zoho-oauthtoken ${token}` } }
    )

    const searchData = await searchResponse.json()

    if (searchData.contacts?.length > 0) {
      return searchData.contacts[0].contact_id
    }

    // Create new contact
    const createResponse = await fetch(
      `${this.config.baseUrl}/contacts?organization_id=${this.config.organizationId}`,
      {
        method: "POST",
        headers: {
          Authorization: `Zoho-oauthtoken ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          contact_name: `${customer.first_name} ${customer.last_name}`,
          email: customer.email,
          contact_type: "customer",
        }),
      }
    )

    const createData = await createResponse.json()
    return createData.contact.contact_id
  }

  // Create an invoice from a Medusa order
  async createInvoice(order: any, contactId: string): Promise<string> {
    const token = await this.getToken()

    const lineItems = (order.items || []).map((item: any) => ({
      name: item.title || item.variant?.product?.title || "Product",
      description: item.variant?.title || "",
      rate: (item.unit_price || 0) / 100, // cents to dollars
      quantity: item.quantity,
      tax_id: "", // Map to Zoho tax ID if using Zoho tax engine
    }))

    // Add shipping as a line item if present
    if (order.shipping_total && order.shipping_total > 0) {
      lineItems.push({
        name: "Shipping",
        description: order.shipping_methods?.[0]?.name || "Standard Shipping",
        rate: order.shipping_total / 100,
        quantity: 1,
        tax_id: "",
      })
    }

    const invoiceData = {
      customer_id: contactId,
      date: new Date().toISOString().split("T")[0],
      line_items: lineItems,
      reference_number: order.display_id?.toString() || order.id,
      notes: `Medusa Order ID: ${order.id}\nRESEARCH USE ONLY`,
      terms:
        "All products are for research use only. " +
        "Not for human consumption or therapeutic use.",
    }

    const response = await fetch(
      `${this.config.baseUrl}/invoices?organization_id=${this.config.organizationId}`,
      {
        method: "POST",
        headers: {
          Authorization: `Zoho-oauthtoken ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(invoiceData),
      }
    )

    if (!response.ok) {
      const error = await response.text()
      throw new Error(`Zoho Books invoice creation failed: ${error}`)
    }

    const result = await response.json()
    return result.invoice.invoice_id
  }
}

export { ZohoBooksClient, ZohoBooksConfig }

Step 2: Create the Sync Workflow

// src/workflows/sync-order-to-zoho.ts
import {
  createWorkflow,
  createStep,
  StepResponse,
} from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"
import { ZohoBooksClient } from "../modules/zoho-books/client"

type SyncInput = {
  order_id: string
}

const syncToZohoBooksStep = createStep(
  "sync-to-zoho-books",
  async ({ order }: { order: any }, { container }) => {
    const logger = container.resolve("logger")

    const client = new ZohoBooksClient({
      clientId: process.env.ZOHO_CLIENT_ID!,
      clientSecret: process.env.ZOHO_CLIENT_SECRET!,
      refreshToken: process.env.ZOHO_REFRESH_TOKEN!,
      organizationId: process.env.ZOHO_ORGANIZATION_ID!,
      baseUrl: "https://www.zohoapis.com/books/v3",
    })

    // Find or create the customer in Zoho Books
    const contactId = await client.findOrCreateContact({
      email: order.customer?.email || order.email,
      first_name: order.customer?.first_name || "Guest",
      last_name: order.customer?.last_name || "Customer",
    })

    // Create the invoice
    const invoiceId = await client.createInvoice(order, contactId)

    logger.info(
      `Synced order ${order.id} to Zoho Books invoice ${invoiceId}`
    )

    return new StepResponse({
      zoho_invoice_id: invoiceId,
      zoho_contact_id: contactId,
    })
  },
  // Compensation: mark invoice as void if the workflow rolls back
  async ({ zoho_invoice_id }, { container }) => {
    if (zoho_invoice_id) {
      const logger = container.resolve("logger")
      logger.warn(
        `Rolling back: would void Zoho Books invoice ${zoho_invoice_id}`
      )
      // In production, call Zoho Books API to void the invoice
    }
  }
)

export const syncOrderToZohoWorkflow = createWorkflow(
  "sync-order-to-zoho",
  ({ order_id }: SyncInput) => {
    const { data: orders } = useQueryGraphStep({
      entity: "order",
      fields: [
        "*",
        "items.*",
        "items.variant.*",
        "items.variant.product.*",
        "customer.*",
        "shipping_address.*",
        "shipping_methods.*",
      ],
      filters: { id: order_id },
    })

    const result = syncToZohoBooksStep({ order: orders[0] })

    return result
  }
)

Step 3: Create the Subscriber

// src/subscribers/order-to-zoho.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
import { Modules } from "@medusajs/framework/utils"
import { syncOrderToZohoWorkflow } from "../workflows/sync-order-to-zoho"

export default async function orderToZohoHandler({
  event: { data },
  container,
}: SubscriberArgs<{ id: string }>) {
  const logger = container.resolve("logger")

  try {
    const { result } = await syncOrderToZohoWorkflow(container).run({
      input: { order_id: data.id },
    })

    // Store Zoho invoice ID in order metadata for cross-reference
    const orderService = container.resolve(Modules.ORDER)
    await orderService.updateOrders(data.id, {
      metadata: {
        zoho_invoice_id: result.zoho_invoice_id,
        zoho_contact_id: result.zoho_contact_id,
      },
    })

    logger.info(
      `Order ${data.id} synced to Zoho Books. Invoice: ${result.zoho_invoice_id}`
    )
  } catch (error) {
    logger.error(
      `Failed to sync order ${data.id} to Zoho Books: ${error.message}`
    )

    // Send failure alert -- do not block the order
    const notificationService = container.resolve(Modules.NOTIFICATION)
    await notificationService.createNotifications({
      to: process.env.OPERATOR_EMAIL || "ops@research-relay.com",
      channel: "email",
      template: "zoho-sync-failure",
      data: {
        order_id: data.id,
        error_message: error.message,
        timestamp: new Date().toISOString(),
      },
    })
  }
}

export const config: SubscriberConfig = {
  event: "order.placed",
}

Data Flow

Customer places order
  -> Medusa emits "order.placed"
  -> Subscriber fires syncOrderToZohoWorkflow
  -> Workflow fetches full order details via Query
  -> ZohoBooksClient refreshes OAuth2 access token
  -> Client searches for customer by email in Zoho Books
     -> If found: use existing contact_id
     -> If not found: create new contact
  -> Client creates invoice in Zoho Books with:
     - Line items mapped from order items
     - Shipping as separate line item
     - Reference number = Medusa order display_id
     - RUO disclaimer in notes and terms
  -> Zoho invoice_id stored in Medusa order metadata
  -> If any step fails: alert email sent to operator

Error Handling

  • Zoho API rate limit (100 req/min): For normal order volume this is not a concern. If bulk-importing historical orders, implement exponential backoff.
  • OAuth token refresh failure: If the refresh token is revoked or expired, the client throws. The subscriber catches it and sends a failure alert. Operator must re-authenticate with Zoho and update the refresh token.
  • Duplicate invoice: If the subscriber fires twice for the same order (rare), the second call creates a duplicate invoice. Mitigation: check order.metadata.zoho_invoice_id before syncing. If it already exists, skip.
  • Zoho Books API error: Log the full error response. Common issues: invalid contact data, missing required fields, organization quota exceeded.
  • Workflow rollback: If a later step in the workflow fails, the compensation function logs a warning. In production, extend it to void the created invoice.

Monitoring

  • Zoho Books invoice count: Compare Medusa order count against Zoho Books invoice count weekly. They should match.
  • Order metadata: Spot-check that recent orders have zoho_invoice_id in their metadata.
  • Failure alerts: Any zoho-sync-failure email requires immediate investigation -- it means orders are not being recorded in accounting.
  • MedusaJS logs: Filter for synced to Zoho Books and Failed to sync entries.

Dependencies

  • Zoho Books account (Free plan works for < $50K revenue)
  • Zoho API Console: Create a Server-based Application to get client_id and client_secret
  • Generate initial refresh_token via OAuth2 flow (one-time manual process)
  • Environment variables: ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_ORGANIZATION_ID
  • SendGrid template zoho-sync-failure for error alerts
  • Chart of Accounts configured in Zoho Books (per accounting-stack.md)
  • Tax settings configured in Zoho Books for CA sales tax

Cross-Runbook Dependencies

Runbook 1 (Order Confirmation)
  |
  +-- depends on: SendGrid provider configured
  |
  +-- required by: Runbook 2, 7, 8, 9 (all use Notification Module)

Runbook 3 (BTC Payment Status)
  |
  +-- depends on: BTCPay payment module provider
  |
  +-- triggers: Runbook 1 (order.placed fires after payment captured)

Runbook 5 (Lot Module)
  |
  +-- depends on: database migration
  |
  +-- required by: Runbook 6 (Expiry Alerts)

Runbook 8 (Compliance Scan) <---> Runbook 9 (Product Compliance Check)
  |
  +-- share: prohibited keyword patterns (src/lib/compliance-patterns.ts)

Runbook 10 (Zoho Sync)
  |
  +-- depends on: Zoho Books API credentials
  |
  +-- fires on same event as: Runbook 1 (both subscribe to order.placed)

Environment Variables Summary

All runbooks require a subset of the following environment variables:

Variable Used By Description
SENDGRID_API_KEY 1, 2, 4, 6, 7, 8, 9, 10 SendGrid API key for email delivery
SENDGRID_FROM_EMAIL 1, 2, 4, 6, 7, 8, 9, 10 Sender email (e.g., orders@research-relay.com)
OPERATOR_EMAIL 4, 6, 7, 8, 9, 10 Where operator alerts are delivered
BTCPAY_API_KEY 3 BTCPay Server Greenfield API key
BTCPAY_SERVER_URL 3 BTCPay Server URL (e.g., https://btcpay.research-relay.com)
BTCPAY_STORE_ID 3 BTCPay Server store identifier
BTCPAY_WEBHOOK_SECRET 3 HMAC secret for webhook signature verification
ZOHO_CLIENT_ID 10 Zoho API OAuth2 client ID
ZOHO_CLIENT_SECRET 10 Zoho API OAuth2 client secret
ZOHO_REFRESH_TOKEN 10 Zoho API OAuth2 refresh token
ZOHO_ORGANIZATION_ID 10 Zoho Books organization identifier

SendGrid Templates Required

Template ID Used By Description
order-confirmation Runbook 1 Order placed confirmation with line items and RUO disclaimer
fulfillment-created Runbook 2 Order being prepared notification
low-stock-alert Runbook 4 Operator alert for low inventory items
expiry-alert Runbook 6 Operator alert for expiring/expired lots
daily-sales-summary Runbook 7 Daily revenue and order metrics
compliance-scan-report Runbook 8 Weekly compliance violation report
product-compliance-alert Runbook 9 Real-time product compliance violation alert
zoho-sync-failure Runbook 10 Alert when Zoho Books sync fails