Skip to content

MedusaJS v2 Project Plan: Research Relay LLC

Overview

Research Relay LLC sells RUO (Research Use Only) peptides and research chemicals. This document defines the MedusaJS v2 project structure and implementation roadmap for the operational backbone, including custom modules for product compliance, payment integrations, and RUO-specific checkout flows.


1. Project Structure

1.1 Scaffolding a New Medusa v2 Project

Create the project using the create-medusa-app CLI tool:

yarn dlx create-medusa-app@latest research-relay

This command: - Creates a Medusa application in a research-relay/ directory - Sets up a PostgreSQL database named research-relay - Installs the Medusa server (Node.js) and embedded Vite admin dashboard - Optionally installs the Next.js Starter Storefront in a sibling directory

Prerequisites: - Node.js v20+ (LTS versions only) - Git CLI - PostgreSQL installed and running

1.2 Directory Structure

After scaffolding, the project has this structure:

research-relay/
├── .medusa/                    # Auto-generated types (do not modify or commit)
├── src/
│   ├── admin/                  # Admin dashboard customizations
│   │   ├── routes/             # Custom UI routes (new pages)
│   │   │   ├── compliance/     # Compliance management pages
│   │   │   │   └── page.tsx
│   │   │   └── settings/       # Custom settings pages
│   │   │       └── compliance/
│   │   │           └── page.tsx
│   │   └── widgets/            # Widgets injected into existing pages
│   │       └── product-compliance-widget.tsx
│   ├── api/                    # Custom API routes
│   │   ├── admin/              # Admin-authenticated routes
│   │   │   ├── compliance/
│   │   │   │   └── route.ts
│   │   │   └── coa/
│   │   │       └── route.ts
│   │   ├── store/              # Customer-authenticated routes
│   │   │   └── compliance/
│   │   │       └── route.ts
│   │   ├── hooks/              # Webhook endpoints
│   │   │   └── btcpay/
│   │   │       └── route.ts
│   │   └── middlewares.ts      # Route middleware definitions
│   ├── jobs/                   # Scheduled jobs (cron tasks)
│   │   └── sync-accounting.ts
│   ├── links/                  # Module links (cross-module associations)
│   │   └── product-compliance.ts
│   ├── modules/                # Custom modules
│   │   ├── product-compliance/ # RUO compliance module
│   │   │   ├── models/
│   │   │   ├── services/
│   │   │   ├── loaders/
│   │   │   ├── migrations/
│   │   │   ├── service.ts
│   │   │   └── index.ts
│   │   ├── btcpay/             # BTCPay payment provider
│   │   │   ├── service.ts
│   │   │   └── index.ts
│   │   ├── authorize-net/      # Authorize.net payment provider
│   │   │   ├── service.ts
│   │   │   └── index.ts
│   │   └── compliance-checkout/# Checkout compliance module
│   │       ├── models/
│   │       ├── service.ts
│   │       └── index.ts
│   ├── scripts/                # Custom CLI scripts
│   ├── subscribers/            # Event handlers
│   │   ├── order-placed.ts
│   │   └── payment-captured.ts
│   └── workflows/              # Custom workflow definitions
│       ├── create-compliant-product.ts
│       └── process-btcpay-payment.ts
├── medusa-config.ts            # Application configuration
├── package.json
├── tsconfig.json
└── .env                        # Environment variables

1.3 Key Conventions

Customization Location Pattern
Custom modules src/modules/<name>/ Module() export in index.ts, service in service.ts, models in models/
Custom API routes src/api/<path>/route.ts Export named HTTP methods (GET, POST, etc.)
Subscribers src/subscribers/<name>.ts Default export = handler function, named export config = event subscription
Scheduled jobs src/jobs/<name>.ts Default export = job function, named export config = schedule
Workflows src/workflows/<name>.ts createWorkflow() composing createStep() functions
Admin widgets src/admin/widgets/<name>.tsx React component + defineWidgetConfig()
Admin UI routes src/admin/routes/<path>/page.tsx React component + defineRouteConfig()
Module links src/links/<name>.ts defineLink() between module data models

2. Custom Module Specifications

2.1 Product Compliance Module

Module name: productComplianceModule Purpose: Manage lot tracking, Certificates of Analysis (COAs), purity records, and RUO disclaimer enforcement for all products.

Data Models

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

const Lot = model.define("lot", {
  id: model.id().primaryKey(),
  lot_number: model.text().unique(),
  product_variant_id: model.text(),     // linked via module link to Product variant
  manufacture_date: model.dateTime(),
  expiration_date: model.dateTime().nullable(),
  quantity_produced: model.number(),
  quantity_remaining: model.number(),
  status: model.enum(["active", "quarantined", "expired", "recalled"]).default("active"),
  notes: model.text().nullable(),
  metadata: model.json().nullable(),
})
// src/modules/product-compliance/models/coa.ts
import { model } from "@medusajs/framework/utils"

const Coa = model.define("coa", {
  id: model.id().primaryKey(),
  lot_id: model.text(),
  file_url: model.text(),               // S3/storage URL for COA PDF
  file_key: model.text(),               // Storage key for deletion
  purity_percentage: model.float().nullable(),
  analysis_date: model.dateTime(),
  lab_name: model.text().nullable(),
  lab_reference: model.text().nullable(),
  verified: model.boolean().default(false),
  verified_by: model.text().nullable(),
  verified_at: model.dateTime().nullable(),
  metadata: model.json().nullable(),
})
// src/modules/product-compliance/models/purity-record.ts
import { model } from "@medusajs/framework/utils"

const PurityRecord = model.define("purity_record", {
  id: model.id().primaryKey(),
  coa_id: model.text(),
  test_method: model.text(),            // e.g., "HPLC", "MS", "NMR"
  parameter: model.text(),              // e.g., "purity", "identity", "endotoxin"
  result_value: model.text(),
  unit: model.text().nullable(),        // e.g., "%", "EU/mg"
  specification: model.text().nullable(), // e.g., ">98%"
  passes: model.boolean().default(true),
  metadata: model.json().nullable(),
})
// src/modules/product-compliance/models/ruo-disclaimer.ts
import { model } from "@medusajs/framework/utils"

const RuoDisclaimer = model.define("ruo_disclaimer", {
  id: model.id().primaryKey(),
  version: model.text(),                // e.g., "1.0", "1.1"
  content: model.text(),                // Full disclaimer text
  effective_date: model.dateTime(),
  is_active: model.boolean().default(true),
  metadata: model.json().nullable(),
})

Service

// src/modules/product-compliance/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import Lot from "./models/lot"
import Coa from "./models/coa"
import PurityRecord from "./models/purity-record"
import RuoDisclaimer from "./models/ruo-disclaimer"

class ProductComplianceModuleService extends MedusaService({
  Lot,
  Coa,
  PurityRecord,
  RuoDisclaimer,
}) {
  // Service factory auto-generates CRUD methods:
  // createLots, retrieveLot, listLots, updateLots, deleteLots
  // createCoas, retrieveCoa, listCoas, updateCoas, deleteCoas
  // createPurityRecords, etc.
  // createRuoDisclaimers, etc.
}

Module Definition

// src/modules/product-compliance/index.ts
import ProductComplianceModuleService from "./service"
import { Module } from "@medusajs/framework/utils"

export const PRODUCT_COMPLIANCE_MODULE = "productComplianceModule"

export default Module(PRODUCT_COMPLIANCE_MODULE, {
  service: ProductComplianceModuleService,
})
// src/links/product-compliance.ts
import ProductComplianceModule from "../modules/product-compliance"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"

// A product variant can have many lots
export default defineLink(
  ProductModule.linkable.productVariant,
  {
    linkable: ProductComplianceModule.linkable.lot,
    isList: true,
  }
)

API Routes

Method Path Purpose
GET /admin/compliance/lots List lots with filtering and pagination
POST /admin/compliance/lots Create a new lot
GET /admin/compliance/lots/[id] Retrieve lot details with COAs
PUT /admin/compliance/lots/[id] Update lot status/details
POST /admin/compliance/lots/[id]/coa Upload COA for a lot
GET /admin/compliance/coa/[id] Retrieve COA details with purity records
GET /store/compliance/lots/[lot_number] Public lot lookup (limited fields)
GET /store/compliance/coa/[id] Public COA download
GET /store/compliance/disclaimer Get current active RUO disclaimer

Events Emitted

Event Trigger Payload
lot.created New lot registered { id }
lot.status_changed Lot status updated (e.g., quarantined) { id, old_status, new_status }
coa.uploaded New COA uploaded for a lot { id, lot_id }
coa.verified COA marked as verified { id, lot_id, verified_by }

Admin UI

  • Product detail widget (product.details.after zone): Shows linked lots, COAs, and purity data for the product
  • Compliance UI route (/admin/compliance): Dashboard showing all lots, COA status, expiration alerts
  • Settings page (/admin/settings/compliance): Manage RUO disclaimer versions, configure expiration thresholds

2.2 BTCPay Payment Module

Module name: BTCPay payment module provider (registered under Payment Module) Purpose: Accept BTC and Lightning payments via self-hosted BTCPay Server.

See docs/payments/btcpay-architecture.md for BTCPay Server deployment details.

Service (Payment Provider)

// src/modules/btcpay/service.ts
import { AbstractPaymentProvider } from "@medusajs/framework/utils"

type BTCPayOptions = {
  serverUrl: string       // e.g., "https://btcpay.researchrelay.com"
  storeId: string         // BTCPay store ID
  apiKey: string          // BTCPay API key
  webhookSecret: string   // Webhook HMAC secret
}

class BTCPayPaymentProviderService extends AbstractPaymentProvider<BTCPayOptions> {
  static identifier = "btcpay"
  // Methods implemented below
}

Required Methods

Method Purpose
initiatePayment(input) Create a BTCPay invoice via API. Store invoice_id in data. Return invoice URL for storefront redirect.
authorizePayment(input) Check BTCPay invoice status. Return authorized if invoice is Settled or Processing.
capturePayment(input) BTC payments are settled on-chain. Mark as captured if invoice is Settled.
refundPayment(input) BTC refunds require manual processing. Record refund request, emit event for manual handling.
cancelPayment(input) Mark BTCPay invoice as invalid via API.
deletePayment(input) Clean up session data.
retrievePayment(input) Fetch current invoice status from BTCPay API.
updatePayment(input) Update invoice metadata if amount changes.
getWebhookActionAndData(payload) Parse BTCPay webhook events (see below).

Module Provider Definition

// src/modules/btcpay/index.ts
import BTCPayPaymentProviderService from "./service"
import { ModuleProvider, Modules } from "@medusajs/framework/utils"

export default ModuleProvider(Modules.PAYMENT, {
  services: [BTCPayPaymentProviderService],
})

Webhook Handling

BTCPay Server sends webhooks to /hooks/payment/btcpay_btcpay (format: /hooks/payment/<identifier>_<id>).

The getWebhookActionAndData method maps BTCPay events to Medusa actions:

BTCPay Event Medusa Action Effect
InvoiceSettled authorized Authorizes payment session, completes cart, creates order
InvoiceProcessing authorized Payment detected on-chain (0-conf or Lightning)
InvoicePaymentSettled captured Payment fully confirmed
InvoiceExpired failed Invoice expired without payment
InvoiceInvalid failed Invoice marked invalid

The webhook handler must: 1. Verify the HMAC signature using webhookSecret 2. Extract session_id from invoice metadata (stored during initiatePayment) 3. Calculate the amount as a BigNumber 4. Return the appropriate action and data

Registration in medusa-config.ts

{
  resolve: "@medusajs/medusa/payment",
  options: {
    providers: [
      {
        resolve: "./src/modules/btcpay",
        id: "btcpay",
        options: {
          serverUrl: process.env.BTCPAY_SERVER_URL,
          storeId: process.env.BTCPAY_STORE_ID,
          apiKey: process.env.BTCPAY_API_KEY,
          webhookSecret: process.env.BTCPAY_WEBHOOK_SECRET,
        },
      },
    ],
  },
}

2.3 Authorize.net Payment Module

Module name: Authorize.net payment module provider (registered under Payment Module) Purpose: Accept card payments via Authorize.net gateway through high-risk ISO (Easy Pay Direct or Durango).

See docs/payments/iso-options.md for ISO provider comparison.

Service (Payment Provider)

// src/modules/authorize-net/service.ts
import { AbstractPaymentProvider } from "@medusajs/framework/utils"

type AuthorizeNetOptions = {
  apiLoginId: string       // Authorize.net API Login ID
  transactionKey: string   // Authorize.net Transaction Key
  signatureKey: string     // Signature key for webhook verification
  environment: "sandbox" | "production"
}

class AuthorizeNetPaymentProviderService extends AbstractPaymentProvider<AuthorizeNetOptions> {
  static identifier = "authorize-net"
  // Methods implemented below
}

Required Methods

Method Purpose
initiatePayment(input) Create a payment transaction profile or hosted payment page token. Store transactionId in data.
authorizePayment(input) Submit authOnlyTransaction to Authorize.net. Return authorized on approval.
capturePayment(input) Submit priorAuthCaptureTransaction referencing the authorized transaction.
refundPayment(input) Submit refundTransaction to Authorize.net.
cancelPayment(input) Submit voidTransaction to cancel authorized but uncaptured payment.
deletePayment(input) Clean up session data.
retrievePayment(input) Fetch transaction details from Authorize.net API.
updatePayment(input) Update transaction amount if cart changes.
getWebhookActionAndData(payload) Parse Authorize.net webhook notifications.

Module Provider Definition

// src/modules/authorize-net/index.ts
import AuthorizeNetPaymentProviderService from "./service"
import { ModuleProvider, Modules } from "@medusajs/framework/utils"

export default ModuleProvider(Modules.PAYMENT, {
  services: [AuthorizeNetPaymentProviderService],
})

Webhook Handling

Authorize.net webhooks arrive at /hooks/payment/authorize-net_authorize-net.

Authorize.net Event Medusa Action
net.authorize.payment.authcapture.created captured
net.authorize.payment.authorization.created authorized
net.authorize.payment.void.created canceled
net.authorize.payment.refund.created not_supported (handled internally)
net.authorize.payment.fraud.held failed
net.authorize.payment.fraud.declined failed

The webhook handler must: 1. Verify the webhook signature using the signatureKey 2. Extract the session_id from transaction metadata 3. Return the mapped action and amount

Registration in medusa-config.ts

{
  resolve: "@medusajs/medusa/payment",
  options: {
    providers: [
      // ... btcpay provider above
      {
        resolve: "./src/modules/authorize-net",
        id: "authorize-net",
        options: {
          apiLoginId: process.env.AUTHNET_API_LOGIN_ID,
          transactionKey: process.env.AUTHNET_TRANSACTION_KEY,
          signatureKey: process.env.AUTHNET_SIGNATURE_KEY,
          environment: process.env.AUTHNET_ENVIRONMENT || "sandbox",
        },
      },
    ],
  },
}

2.4 Compliance Checkout Module

Module name: complianceCheckoutModule Purpose: Enforce RUO acknowledgment, age verification, and research-use attestation during checkout.

Data Models

// src/modules/compliance-checkout/models/checkout-attestation.ts
import { model } from "@medusajs/framework/utils"

const CheckoutAttestation = model.define("checkout_attestation", {
  id: model.id().primaryKey(),
  cart_id: model.text(),
  order_id: model.text().nullable(),
  customer_id: model.text().nullable(),
  ruo_disclaimer_accepted: model.boolean().default(false),
  ruo_disclaimer_version: model.text(),
  age_verified: model.boolean().default(false),
  date_of_birth: model.dateTime().nullable(),
  research_use_attested: model.boolean().default(false),
  institution_name: model.text().nullable(),
  researcher_name: model.text().nullable(),
  ip_address: model.text().nullable(),
  user_agent: model.text().nullable(),
  attested_at: model.dateTime(),
  metadata: model.json().nullable(),
})

Service

// src/modules/compliance-checkout/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import CheckoutAttestation from "./models/checkout-attestation"

class ComplianceCheckoutModuleService extends MedusaService({
  CheckoutAttestation,
}) {
  // Auto-generated: createCheckoutAttestations, retrieveCheckoutAttestation, etc.
  //
  // Custom methods to add:
  // - validateAttestation(cartId): checks all required fields are complete
  // - getAttestationForCart(cartId): retrieve attestation by cart ID
  // - getAttestationForOrder(orderId): retrieve attestation by order ID
}

Module Definition

// src/modules/compliance-checkout/index.ts
import ComplianceCheckoutModuleService from "./service"
import { Module } from "@medusajs/framework/utils"

export const COMPLIANCE_CHECKOUT_MODULE = "complianceCheckoutModule"

export default Module(COMPLIANCE_CHECKOUT_MODULE, {
  service: ComplianceCheckoutModuleService,
})
// src/links/cart-attestation.ts
import ComplianceCheckoutModule from "../modules/compliance-checkout"
import CartModule from "@medusajs/medusa/cart"
import { defineLink } from "@medusajs/framework/utils"

export default defineLink(
  CartModule.linkable.cart,
  ComplianceCheckoutModule.linkable.checkoutAttestation
)
// src/links/order-attestation.ts
import ComplianceCheckoutModule from "../modules/compliance-checkout"
import OrderModule from "@medusajs/medusa/order"
import { defineLink } from "@medusajs/framework/utils"

export default defineLink(
  OrderModule.linkable.order,
  ComplianceCheckoutModule.linkable.checkoutAttestation
)

API Routes

Method Path Purpose
POST /store/compliance/attest Submit checkout attestation (RUO ack, age, research use)
GET /store/compliance/attest/[cart_id] Get attestation status for current cart
GET /admin/compliance/attestations List all attestations (admin)
GET /admin/compliance/attestations/[id] View attestation details (admin)

Workflow Integration

The compliance attestation must be validated before cart completion. This is achieved by hooking into the cart completion workflow:

// src/workflows/validate-compliance-checkout.ts
import { createStep, StepResponse, createWorkflow } from "@medusajs/framework/workflows-sdk"

const validateComplianceStep = createStep(
  "validate-compliance",
  async ({ cart_id }, { container }) => {
    const complianceService = container.resolve("complianceCheckoutModule")
    const attestation = await complianceService.listCheckoutAttestations({
      filters: { cart_id },
    })

    if (!attestation.length ||
        !attestation[0].ruo_disclaimer_accepted ||
        !attestation[0].age_verified ||
        !attestation[0].research_use_attested) {
      throw new Error("Compliance attestation incomplete. RUO disclaimer, age verification, and research-use attestation are required.")
    }

    return new StepResponse({ valid: true })
  }
)

Events Consumed

Event Handler Action
order.placed Copy attestation from cart to order, archive for compliance records

Admin UI

  • Order detail widget (order.details.after zone): Shows the compliance attestation for the order (RUO ack, age verification, research attestation)
  • Compliance attestations page (/admin/compliance/attestations): List/search all attestations with export capability

3. Payment Provider Implementations

3.1 How MedusaJS v2 Payment Providers Work

In Medusa v2, payment processing is handled by Payment Module Providers. A provider is a module whose service extends AbstractPaymentProvider from @medusajs/framework/utils.

Key concepts: - The Payment Module manages payment sessions, collections, and records - The Payment Module Provider only handles third-party payment processing logic - The provider's service must have a static identifier property - The provider is registered with a composite ID: pp_<identifier>_<config-id>

Module Provider Definition pattern:

// index.ts
import MyPaymentService from "./service"
import { ModuleProvider, Modules } from "@medusajs/framework/utils"

export default ModuleProvider(Modules.PAYMENT, {
  services: [MyPaymentService],
})

3.2 Required Methods

Every payment provider must implement these methods:

Method Signature Purpose
initiatePayment (input: InitiatePaymentInput) => Promise<InitiatePaymentOutput> Initialize payment with third-party. Returns id and data stored in payment session.
authorizePayment (input: AuthorizePaymentInput) => Promise<AuthorizePaymentOutput> Authorize the payment. Returns status ("authorized") and data.
capturePayment (input: CapturePaymentInput) => Promise<CapturePaymentOutput> Capture an authorized payment.
refundPayment (input: RefundPaymentInput) => Promise<RefundPaymentOutput> Refund a captured payment (full or partial).
cancelPayment (input: CancelPaymentInput) => Promise<CancelPaymentOutput> Cancel/void an authorized but uncaptured payment.
deletePayment (input: DeletePaymentInput) => Promise<DeletePaymentOutput> Delete/clean up a payment session.
retrievePayment (input: RetrievePaymentInput) => Promise<RetrievePaymentOutput> Retrieve current payment status from provider.
updatePayment (input: UpdatePaymentInput) => Promise<UpdatePaymentOutput> Update payment details (e.g., amount change).
getWebhookActionAndData (payload: ProviderWebhookPayload["payload"]) => Promise<WebhookActionResult> Process webhook events from the provider.

3.3 Webhook Handling

Medusa v2 provides a built-in webhook listener at:

POST /hooks/payment/<identifier>_<id>

This route calls getWebhookActionAndData() on the matching provider. The method receives: - data: Parsed request body - rawData: Raw request body (for signature verification) - headers: Request headers

The method must return a WebhookActionResult with: - action: One of "authorized", "captured", "failed", "canceled", "not_supported" - data: Object with session_id (string) and amount (BigNumber)

Actions and their effects: - authorized: Sets payment session to authorized, completes cart if not already done, creates order - captured: Captures the payment within Medusa - failed: Marks the payment session as failed - canceled: Cancels the payment session - not_supported: No action taken (unrecognized event type)

3.4 Payment Status Mapping

Provider State Medusa Payment Session Status Medusa Order Status
Invoice/transaction created pending N/A (cart stage)
Payment detected/authorized authorized Order created
Payment settled/captured captured Payment captured
Payment failed/expired error N/A or order canceled
Payment voided canceled Order canceled
Refund processed refunded Refund recorded

4. Integration Architecture

4.1 MedusaJS <-> BTCPay Server

┌─────────────┐         ┌──────────────────┐         ┌─────────────────┐
│  Storefront  │         │  Medusa Server   │         │  BTCPay Server  │
│  (Next.js)   │         │  (Node.js)       │         │  (NixOS/Docker) │
└──────┬───────┘         └────────┬─────────┘         └────────┬────────┘
       │                          │                             │
       │ 1. Select BTC payment    │                             │
       │─────────────────────────>│                             │
       │                          │ 2. POST /api/v1/stores/     │
       │                          │    {storeId}/invoices       │
       │                          │────────────────────────────>│
       │                          │                             │
       │                          │ 3. Invoice created          │
       │                          │<────────────────────────────│
       │                          │                             │
       │ 4. Redirect to BTCPay    │                             │
       │    checkout page         │                             │
       │<─────────────────────────│                             │
       │                          │                             │
       │           5. Customer pays via BTC/Lightning           │
       │─────────────────────────────────────────────────────-->│
       │                          │                             │
       │                          │ 6. Webhook: InvoiceSettled  │
       │                          │<────────────────────────────│
       │                          │                             │
       │                          │ 7. Authorize payment,       │
       │                          │    complete cart,            │
       │                          │    create order              │
       │                          │                             │
       │ 8. Order confirmation    │                             │
       │<─────────────────────────│                             │

Key details: - BTCPay Server is self-hosted on NixOS via nix-bitcoin (see docs/payments/btcpay-architecture.md) - Communication uses BTCPay Greenfield API v1 - Webhook secret is HMAC-based for signature verification - Lightning payments provide near-instant settlement - On-chain payments may use 0-confirmation for low-value orders (configurable)

4.2 MedusaJS <-> Stripe ACH (Conditional)

Status: Uncertain. Research chemicals are on Stripe's prohibited/restricted list. ACH integration is contingent on Stripe approval.

If approved, Stripe ACH uses Medusa's built-in Stripe payment provider:

// medusa-config.ts
{
  resolve: "@medusajs/medusa/payment",
  options: {
    providers: [
      {
        resolve: "@medusajs/medusa/payment-stripe",
        id: "stripe",
        options: {
          apiKey: process.env.STRIPE_API_KEY,
        },
      },
    ],
  },
}

See docs/payments/ach-provider-options.md and docs/payments/ach-risk-matrix.md for risk assessment.

4.3 MedusaJS <-> Authorize.net

┌─────────────┐         ┌──────────────────┐         ┌─────────────────┐
│  Storefront  │         │  Medusa Server   │         │  Authorize.net  │
│  (Next.js)   │         │  (Node.js)       │         │  (via ISO)      │
└──────┬───────┘         └────────┬─────────┘         └────────┬────────┘
       │                          │                             │
       │ 1. Enter card details    │                             │
       │    (Accept.js tokenizes) │                             │
       │─────────────────────────>│                             │
       │                          │ 2. authOnlyTransaction     │
       │                          │    with payment nonce       │
       │                          │────────────────────────────>│
       │                          │                             │
       │                          │ 3. Auth approved            │
       │                          │<────────────────────────────│
       │                          │                             │
       │                          │ 4. Cart completed,          │
       │                          │    order created             │
       │                          │                             │
       │ 5. Order confirmation    │                             │
       │<─────────────────────────│                             │
       │                          │                             │
       │    [On fulfillment]      │ 6. priorAuthCapture         │
       │                          │────────────────────────────>│

Key details: - Card processing goes through high-risk ISO (Easy Pay Direct or Durango; see docs/payments/iso-options.md) - Storefront uses Accept.js for PCI-compliant card tokenization (card data never touches Medusa server) - Authorization at checkout, capture on fulfillment - Webhook notifications for fraud holds and payment events

4.4 MedusaJS <-> Avalara (Tax Provider)

Avalara integrates as a Tax Module Provider using the ITaxProvider interface:

// src/modules/avalara/service.ts
// Implements ITaxProvider with getTaxLines() method
// Uses avatax SDK to calculate taxes based on ship-to address
// medusa-config.ts
{
  resolve: "@medusajs/medusa/tax",
  options: {
    providers: [
      {
        resolve: "./src/modules/avalara",
        id: "avalara",
        options: {
          username: process.env.AVALARA_USERNAME,
          password: process.env.AVALARA_PASSWORD,
          companyCode: process.env.AVALARA_COMPANY_CODE,
          companyId: parseInt(process.env.AVALARA_COMPANY_ID || "0"),
          appEnvironment: process.env.AVALARA_ENVIRONMENT || "sandbox",
        },
      },
    ],
  },
}

The Avalara integration guide in the Medusa docs provides a complete implementation reference. Key methods: - getTaxLines(): Called during checkout to calculate tax for cart items based on customer location - Product tax codes should be synced to Avalara using a subscriber on product.created / product.updated - Transactions are committed in Avalara on order.placed via a subscriber

4.5 MedusaJS <-> SendGrid (Notification Provider)

SendGrid is a first-party notification module provider in Medusa v2:

// medusa-config.ts
{
  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 templates needed (SendGrid Dynamic Templates): - Order confirmation (with RUO disclaimer and lot info) - Shipping notification (with tracking) - Payment receipt (BTC amount and transaction hash for crypto payments) - COA available notification - Password reset - Welcome / account creation

Subscribers to create:

Event Email Template
order.placed Order confirmation with RUO disclaimer
order.fulfillment_created Shipping notification
order.payment_captured Payment receipt
auth.password_reset Password reset link

4.6 MedusaJS <-> ShipStation (Fulfillment Provider)

ShipStation integrates as a Fulfillment Module Provider using AbstractFulfillmentProviderService:

// medusa-config.ts
{
  resolve: "@medusajs/medusa/fulfillment",
  options: {
    providers: [
      {
        resolve: "@medusajs/medusa/fulfillment-manual",
        id: "manual",
      },
      {
        resolve: "./src/modules/shipstation",
        id: "shipstation",
        options: {
          api_key: process.env.SHIPSTATION_API_KEY,
        },
      },
    ],
  },
}

The Medusa docs include a complete ShipStation integration tutorial. Key methods to implement: - getFulfillmentOptions(): Return available shipping methods from ShipStation carriers - validateFulfillmentData(): Validate shipping data - createFulfillment(): Create shipment in ShipStation, purchase label - cancelFulfillment(): Void shipment/label in ShipStation - calculatePrice(): Get shipping rates from ShipStation

Additional considerations for RUO products: - Shipping labels must not include product descriptions that could be misinterpreted - Certain products may have shipping restrictions (documented in compliance module) - Package weight/dimensions come from product metadata


5. Development Environment

5.1 Prerequisites

Requirement Version Notes
Node.js v20+ LTS v22 LTS recommended; v25+ not supported with Next.js storefront
PostgreSQL 14+ Local or Docker; required for Medusa data
Redis 7+ Required for event bus, workflow engine, caching in production; optional for development
Git Latest Required for create-medusa-app
yarn or pnpm Latest pnpm recommended for speed

5.2 Local Development Setup

# 1. Create the Medusa project
yarn dlx create-medusa-app@latest research-relay --with-nextjs-starter

# 2. Navigate to the project
cd research-relay

# 3. Copy environment template
cp .env.template .env

# 4. Configure environment variables
# Edit .env with database URL, API keys, etc.

# 5. Start the development server
yarn dev

The yarn dev command: - Starts the Medusa server on http://localhost:9000 - Starts the admin dashboard on http://localhost:9000/app - Watches for file changes and recompiles

5.3 Running Migrations

# Generate migrations for a specific module
yarn medusa db:generate <module-name>
# Example: yarn medusa db:generate productComplianceModule

# Run all pending migrations
yarn medusa db:migrate

# Sync module links (creates link tables)
yarn medusa db:sync-links

# Both migrate and sync in one command
yarn medusa db:migrate
# (db:migrate also syncs links)

5.4 Seeding Test Data

# Run the default seed script
yarn medusa exec ./src/scripts/seed.ts

# Or create a custom seed script
# src/scripts/seed-compliance.ts

A custom seed script should: 1. Create test products with variants 2. Create lots linked to product variants 3. Create COAs with purity records for each lot 4. Create a sample RUO disclaimer 5. Set up test payment providers in regions

5.5 Running the Admin Dashboard

The admin dashboard runs as part of the Medusa server in development:

yarn dev
# Admin is available at http://localhost:9000/app

For production builds:

yarn build
yarn start

5.6 Testing Approach

Unit tests (for modules): - Test data model validation - Test service method logic - Test custom service methods (e.g., compliance validation) - Use Medusa's testing utilities

Integration tests (for workflows): - Test workflow step composition - Test compensation (rollback) logic - Test end-to-end workflow execution with mocked services

API route tests: - Test request validation (Zod schemas) - Test authentication/authorization - Test response format

Payment provider tests: - Mock third-party API responses - Test webhook signature verification - Test status mapping - Test error handling for network failures

Recommended test structure:

src/
├── modules/
│   └── product-compliance/
│       └── __tests__/
│           ├── service.test.ts
│           └── models.test.ts
├── workflows/
│   └── __tests__/
│       └── create-compliant-product.test.ts
└── api/
    └── admin/
        └── compliance/
            └── __tests__/
                └── route.test.ts


6. Implementation Roadmap

Phase A: Vanilla Medusa Setup + Basic Product Catalog

Goal: Working Medusa v2 instance with products, regions, and basic configuration.

Tasks: 1. Scaffold project with create-medusa-app 2. Configure medusa-config.ts with database, CORS, and session settings 3. Set up PostgreSQL database 4. Create initial admin user 5. Configure at least one region (United States) with USD currency 6. Create product categories for peptide/chemical classifications 7. Add sample products with variants (sizes, quantities) 8. Install and configure Next.js Starter Storefront 9. Verify storefront can browse products and admin can manage catalog

Files created/modified: - medusa-config.ts - .env - src/scripts/seed.ts (custom seed data)

Done when: - Admin dashboard accessible at /app - Products visible in storefront - Cart and basic checkout flow works with system payment provider


Phase B: Product Compliance Module (Lot Tracking, COAs)

Goal: Custom module for lot tracking, COA management, and purity records linked to products.

Tasks: 1. Create src/modules/product-compliance/ directory structure 2. Define data models: Lot, Coa, PurityRecord, RuoDisclaimer 3. Create module service extending MedusaService 4. Create module definition with Module() 5. Register module in medusa-config.ts 6. Define module link: ProductVariant <-> Lot 7. Generate and run migrations 8. Sync module links 9. Create admin API routes for lot/COA CRUD 10. Create store API routes for public lot lookup and COA download 11. Add request validation with Zod schemas 12. Create subscribers for lot.created, coa.uploaded, coa.verified events 13. Write unit tests for service methods

Files created: - src/modules/product-compliance/models/lot.ts - src/modules/product-compliance/models/coa.ts - src/modules/product-compliance/models/purity-record.ts - src/modules/product-compliance/models/ruo-disclaimer.ts - src/modules/product-compliance/service.ts - src/modules/product-compliance/index.ts - src/links/product-compliance.ts - src/api/admin/compliance/lots/route.ts - src/api/admin/compliance/lots/[id]/route.ts - src/api/admin/compliance/lots/[id]/coa/route.ts - src/api/admin/compliance/coa/[id]/route.ts - src/api/store/compliance/lots/[lot_number]/route.ts - src/api/store/compliance/coa/[id]/route.ts - src/api/store/compliance/disclaimer/route.ts - src/api/admin/compliance/lots/validators.ts - src/subscribers/lot-created.ts - src/subscribers/coa-uploaded.ts

Done when: - Admin can create lots and upload COAs via API - Lots are linked to product variants - Public lot lookup returns limited compliance data - COA PDFs can be downloaded by customers - All unit tests pass


Phase C: BTCPay Payment Integration

Goal: Accept BTC and Lightning payments through self-hosted BTCPay Server.

Tasks: 1. Create src/modules/btcpay/ directory 2. Implement BTCPayPaymentProviderService extending AbstractPaymentProvider 3. Implement all required methods (initiate, authorize, capture, refund, cancel, etc.) 4. Implement getWebhookActionAndData for BTCPay webhook events 5. Create BTCPay API client class for Greenfield API communication 6. Create module provider definition with ModuleProvider() 7. Register provider in medusa-config.ts under Payment Module 8. Configure webhook URL in BTCPay Server 9. Add middleware to preserve raw body for webhook signature verification 10. Test payment flow end-to-end in BTCPay regtest mode 11. Write unit tests for webhook handler and status mapping

Files created: - src/modules/btcpay/service.ts - src/modules/btcpay/index.ts - src/modules/btcpay/client.ts (BTCPay Greenfield API client) - src/modules/btcpay/types.ts - src/api/middlewares.ts (add raw body preservation for webhook route)

Done when: - Customer can select BTC payment at checkout - BTCPay invoice is created and customer is redirected - Webhook processes payment confirmation - Order is created on successful payment - Payment status is visible in admin


Phase D: Authorize.net Payment Integration

Goal: Accept card payments via Authorize.net through high-risk ISO.

Tasks: 1. Create src/modules/authorize-net/ directory 2. Implement AuthorizeNetPaymentProviderService extending AbstractPaymentProvider 3. Implement all required methods 4. Implement getWebhookActionAndData for Authorize.net webhooks 5. Create Authorize.net API client (XML or JSON API) 6. Create module provider definition 7. Register provider in medusa-config.ts 8. Configure storefront to use Accept.js for card tokenization 9. Add webhook signature verification middleware 10. Test with Authorize.net sandbox 11. Write unit tests

Files created: - src/modules/authorize-net/service.ts - src/modules/authorize-net/index.ts - src/modules/authorize-net/client.ts - src/modules/authorize-net/types.ts

Done when: - Customer can enter card details (tokenized via Accept.js) - Authorization succeeds via Authorize.net sandbox - Capture works on fulfillment - Refund and void operations work - Fraud hold webhooks are handled


Phase E: Compliance Checkout Flow

Goal: Enforce RUO acknowledgment, age verification, and research-use attestation at checkout.

Tasks: 1. Create src/modules/compliance-checkout/ directory 2. Define CheckoutAttestation data model 3. Create module service 4. Register module and create module links to Cart and Order 5. Generate and run migrations, sync links 6. Create store API route for submitting attestation 7. Create workflow step to validate compliance before cart completion 8. Hook into cart completion workflow to enforce attestation 9. Create subscriber on order.placed to copy attestation to order 10. Create storefront UI components for attestation form 11. Write tests for validation logic

Files created: - src/modules/compliance-checkout/models/checkout-attestation.ts - src/modules/compliance-checkout/service.ts - src/modules/compliance-checkout/index.ts - src/links/cart-attestation.ts - src/links/order-attestation.ts - src/api/store/compliance/attest/route.ts - src/api/store/compliance/attest/[cart_id]/route.ts - src/api/admin/compliance/attestations/route.ts - src/api/admin/compliance/attestations/[id]/route.ts - src/workflows/validate-compliance-checkout.ts - src/subscribers/order-placed-attestation.ts

Done when: - Checkout requires RUO acknowledgment, age verification, and research attestation - Cart cannot complete without valid attestation - Attestation data is stored with the order - Admin can view attestation records


Phase F: Shipping + Fulfillment Integration

Goal: Integrate ShipStation for shipping label generation and fulfillment management.

Tasks: 1. Create src/modules/shipstation/ directory 2. Implement ShipStationProviderService extending AbstractFulfillmentProviderService 3. Implement required methods: getFulfillmentOptions, createFulfillment, cancelFulfillment, etc. 4. Create ShipStation API client 5. Register as fulfillment provider in medusa-config.ts 6. Configure shipping options in admin (link to stock locations) 7. Create subscriber for order.fulfillment_created to sync with ShipStation 8. Test shipping rate calculation and label purchase

Files created: - src/modules/shipstation/service.ts - src/modules/shipstation/index.ts - src/modules/shipstation/client.ts - src/modules/shipstation/types.ts

Done when: - Shipping rates appear at checkout - Fulfillment creates shipment in ShipStation - Labels can be purchased - Tracking numbers sync back to Medusa


Phase G: Tax Calculation Integration

Goal: Integrate Avalara (AvaTax) for accurate tax calculation based on customer location.

Tasks: 1. Create src/modules/avalara/ directory 2. Implement tax provider service implementing ITaxProvider 3. Implement getTaxLines() method using AvaTax SDK 4. Register as tax provider in medusa-config.ts 5. Configure tax region in admin to use Avalara provider 6. Create subscriber to sync products to Avalara on creation/update 7. Create subscriber to commit transactions on order.placed 8. Test tax calculation with sandbox credentials

Files created: - src/modules/avalara/service.ts - src/modules/avalara/index.ts - src/modules/avalara/types.ts - src/subscribers/sync-product-to-avalara.ts - src/subscribers/commit-avalara-transaction.ts

Done when: - Tax is calculated correctly during checkout based on ship-to address - Tax line items appear on cart and order - Transactions are committed in Avalara when orders are placed - Products are synced to Avalara with correct tax codes


Phase H: Admin UI Customizations

Goal: Custom admin dashboard widgets and pages for compliance management.

Tasks: 1. Create product detail widget showing lot/COA data 2. Create order detail widget showing compliance attestation 3. Create compliance dashboard UI route (/admin/compliance) 4. Create compliance settings page (/admin/settings/compliance) 5. Create COA upload form widget 6. Create lot management page with filtering and status updates 7. Style using @medusajs/ui components

Files created: - src/admin/widgets/product-compliance-widget.tsx - src/admin/widgets/order-attestation-widget.tsx - src/admin/routes/compliance/page.tsx - src/admin/routes/compliance/lots/page.tsx - src/admin/routes/compliance/lots/[id]/page.tsx - src/admin/routes/compliance/attestations/page.tsx - src/admin/routes/settings/compliance/page.tsx

Done when: - Product detail page shows linked lots and COAs - Order detail page shows compliance attestation - Compliance dashboard shows lot status overview - Admin can manage lots, COAs, and disclaimers from the dashboard


Phase I: Accounting Sync Automation

Goal: Automated sync of order and payment data to Zoho Books for accounting, with Mercury bank reconciliation.

Tasks: 1. Create scheduled job for daily order sync to Zoho Books 2. Create workflow for creating Zoho Books invoices from Medusa orders 3. Create subscriber on order.placed to create Zoho invoice 4. Create subscriber on order.payment_captured to record payment in Zoho 5. Create scheduled job for Mercury bank reconciliation 6. Create admin settings page for accounting configuration

Files created: - src/jobs/sync-accounting.ts - src/jobs/reconcile-banking.ts - src/workflows/sync-order-to-zoho.ts - src/workflows/reconcile-mercury.ts - src/subscribers/order-placed-accounting.ts - src/subscribers/payment-captured-accounting.ts - src/admin/routes/settings/accounting/page.tsx

Done when: - Orders automatically create invoices in Zoho Books - Payment captures record receipts in Zoho Books - Mercury bank transactions are reconciled against Medusa orders - Admin can view sync status and trigger manual sync


Appendix: medusa-config.ts Reference

The complete module registration in medusa-config.ts will look like:

import { loadEnv, defineConfig, Modules } from "@medusajs/framework/utils"

loadEnv(process.env.NODE_ENV || "development", process.cwd())

module.exports = defineConfig({
  projectConfig: {
    databaseUrl: process.env.DATABASE_URL,
    redisUrl: process.env.REDIS_URL,
    http: {
      storeCors: process.env.STORE_CORS!,
      adminCors: process.env.ADMIN_CORS!,
      authCors: process.env.AUTH_CORS!,
      jwtSecret: process.env.JWT_SECRET || "supersecret",
      cookieSecret: process.env.COOKIE_SECRET || "supersecret",
    },
  },
  modules: [
    // Custom modules
    { resolve: "./src/modules/product-compliance" },
    { resolve: "./src/modules/compliance-checkout" },

    // Payment providers
    {
      resolve: "@medusajs/medusa/payment",
      options: {
        providers: [
          {
            resolve: "./src/modules/btcpay",
            id: "btcpay",
            options: {
              serverUrl: process.env.BTCPAY_SERVER_URL,
              storeId: process.env.BTCPAY_STORE_ID,
              apiKey: process.env.BTCPAY_API_KEY,
              webhookSecret: process.env.BTCPAY_WEBHOOK_SECRET,
            },
          },
          {
            resolve: "./src/modules/authorize-net",
            id: "authorize-net",
            options: {
              apiLoginId: process.env.AUTHNET_API_LOGIN_ID,
              transactionKey: process.env.AUTHNET_TRANSACTION_KEY,
              signatureKey: process.env.AUTHNET_SIGNATURE_KEY,
              environment: process.env.AUTHNET_ENVIRONMENT || "sandbox",
            },
          },
          // Stripe (conditional - if ACH approved)
          // {
          //   resolve: "@medusajs/medusa/payment-stripe",
          //   id: "stripe",
          //   options: {
          //     apiKey: process.env.STRIPE_API_KEY,
          //   },
          // },
        ],
      },
    },

    // Tax provider
    {
      resolve: "@medusajs/medusa/tax",
      options: {
        providers: [
          {
            resolve: "./src/modules/avalara",
            id: "avalara",
            options: {
              username: process.env.AVALARA_USERNAME,
              password: process.env.AVALARA_PASSWORD,
              companyCode: process.env.AVALARA_COMPANY_CODE,
              companyId: parseInt(process.env.AVALARA_COMPANY_ID || "0"),
              appEnvironment: process.env.AVALARA_ENVIRONMENT || "sandbox",
            },
          },
        ],
      },
    },

    // Notification provider
    {
      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,
            },
          },
        ],
      },
    },

    // Fulfillment provider
    {
      resolve: "@medusajs/medusa/fulfillment",
      options: {
        providers: [
          {
            resolve: "@medusajs/medusa/fulfillment-manual",
            id: "manual",
          },
          {
            resolve: "./src/modules/shipstation",
            id: "shipstation",
            options: {
              api_key: process.env.SHIPSTATION_API_KEY,
            },
          },
        ],
      },
    },

    // Production infrastructure (uncomment for production)
    // {
    //   resolve: "@medusajs/medusa/event-bus-redis",
    //   options: { redisUrl: process.env.REDIS_URL },
    // },
    // {
    //   resolve: "@medusajs/medusa/workflow-engine-redis",
    //   options: { redis: { redisUrl: process.env.REDIS_URL } },
    // },
  ],
})

Appendix: Environment Variables

# Database
DATABASE_URL=postgres://postgres@localhost/research-relay

# Redis (production)
REDIS_URL=redis://localhost:6379

# HTTP / CORS
STORE_CORS=http://localhost:8000
ADMIN_CORS=http://localhost:9000
AUTH_CORS=http://localhost:8000,http://localhost:9000
JWT_SECRET=<generate-secure-secret>
COOKIE_SECRET=<generate-secure-secret>

# BTCPay Server
BTCPAY_SERVER_URL=https://btcpay.researchrelay.com
BTCPAY_STORE_ID=<btcpay-store-id>
BTCPAY_API_KEY=<btcpay-api-key>
BTCPAY_WEBHOOK_SECRET=<btcpay-webhook-secret>

# Authorize.net
AUTHNET_API_LOGIN_ID=<authorize-net-login-id>
AUTHNET_TRANSACTION_KEY=<authorize-net-transaction-key>
AUTHNET_SIGNATURE_KEY=<authorize-net-signature-key>
AUTHNET_ENVIRONMENT=sandbox

# Stripe (conditional)
# STRIPE_API_KEY=<stripe-secret-key>

# Avalara
AVALARA_USERNAME=<avalara-username>
AVALARA_PASSWORD=<avalara-password>
AVALARA_COMPANY_CODE=<avalara-company-code>
AVALARA_COMPANY_ID=<avalara-company-id>
AVALARA_ENVIRONMENT=sandbox

# SendGrid
SENDGRID_API_KEY=<sendgrid-api-key>
SENDGRID_FROM=orders@researchrelay.com

# ShipStation
SHIPSTATION_API_KEY=<shipstation-api-key>