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:
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,
})
Module Links¶
// 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.afterzone): 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,
})
Module Links¶
// 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.afterzone): 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:
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:
For production builds:
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>