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
useQueryGraphStepwill return the order data. Ifcustomer.emailis null (guest checkout without email), thesendNotificationsStepwill 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-sendgridinstalled 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-createdtemplate. - 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-createdcreated - 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
failedaction. 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
InvoiceSettledtwice for an already-captured payment is a no-op. - Late payments (after invoice expiry): BTCPay may send
InvoiceSettledwithafterExpiration: 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 invoiceentries 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_SECRETin environment - Webhook registered in BTCPay with correct URL and events
- MedusaJS endpoint
https://api.research-relay.com/hooks/payment/btcpay_btcpayreachable 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 checkto 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_thresholdvalue set in metadata for each inventory item (default: 10) -
OPERATOR_EMAILenvironment variable set - SendGrid template
low-stock-alertcreated - 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,
})
Step 4: Link to Inventory Item¶
// 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¶
Step 6: Generate and Run Migration¶
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_numberhas 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
updateLotsfails, 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 checkandEXPIRED: Lotentries. - 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_dateset on all lots during receiving - SendGrid template
expiry-alertwith sections for each severity level -
OPERATOR_EMAILenvironment 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 summaryandDaily sales summary sent.
Dependencies¶
- SendGrid template
daily-sales-summarycreated with sections for all metrics -
OPERATOR_EMAILenvironment 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
takeparameter 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 scanentries.
Dependencies¶
- Products in Medusa have
titleanddescriptionpopulated - SendGrid template
compliance-scan-reportcreated - Prohibited keyword list reviewed and approved
-
OPERATOR_EMAILenvironment 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 forentries. 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-alertcreated - Product metadata convention established:
coa_urlfield in product metadata - RUO disclaimer text standardized for product descriptions
-
OPERATOR_EMAILenvironment 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_idbefore 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_idin their metadata. - Failure alerts: Any
zoho-sync-failureemail requires immediate investigation -- it means orders are not being recorded in accounting. - MedusaJS logs: Filter for
synced to Zoho BooksandFailed to syncentries.
Dependencies¶
- Zoho Books account (Free plan works for < $50K revenue)
- Zoho API Console: Create a Server-based Application to get
client_idandclient_secret - Generate initial
refresh_tokenvia OAuth2 flow (one-time manual process) - Environment variables:
ZOHO_CLIENT_ID,ZOHO_CLIENT_SECRET,ZOHO_REFRESH_TOKEN,ZOHO_ORGANIZATION_ID - SendGrid template
zoho-sync-failurefor 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 |