Skip to content

Product Catalog Schema: RUO Research Chemicals

This document defines the complete product data model for Research Relay's RUO (Research Use Only) research chemical catalog in MedusaJS v2. It maps standard Medusa fields, defines custom RUO compliance fields, establishes the category taxonomy, and documents all business rules that affect products at checkout and fulfillment.


1. Standard MedusaJS v2 Product Fields

1.1 Product-Level Fields

These are built-in fields on the Medusa Product data model. Every product in the catalog uses these directly.

Field Type Required RR Usage Example
id string (auto) Auto Primary key prod_01H...
title string Yes Chemical common name "BPC-157 (Body Protection Compound)"
subtitle string No IUPAC name or alternate name "Pentadecapeptide BPC 157"
handle string (auto) Auto URL slug, auto-generated from title "bpc-157-body-protection-compound"
description string Yes Detailed product description (plain text or HTML) Chemical description, applications, handling notes
status enum Yes Product lifecycle status draft / proposed / published / rejected
thumbnail string No Primary product image URL "https://cdn.research-relay.com/products/bpc-157-thumb.jpg"
images Image[] No Additional product images (vial, packaging, molecular structure) Array of image URLs
is_giftcard boolean Auto Always false for chemicals false
discountable boolean Yes Whether discount codes apply true (most products)
type ProductType Yes Product type classification "Peptide", "Reagent", "Solvent"
collection ProductCollection No Marketing groupings "Best Sellers", "New Arrivals"
categories ProductCategory[] Yes Taxonomy categories (see Section 3) ["Peptides", "Peptides > Protective"]
tags ProductTag[] No Search/filter tags ["cold-chain", "lyophilized", "popular"]
weight string No Product weight (set at variant level instead) --
length string No Product length (set at variant level instead) --
width string No Product width (set at variant level instead) --
height string No Product height (set at variant level instead) --
hs_code string No Harmonized System code (set at variant level instead) --
origin_country string No Country of manufacture (set at variant level if varies) "US"
material string No Not used for chemicals --
external_id string No ID in external system (supplier catalog number) "SUP-BPC157-001"
metadata JSON Yes Custom RUO fields stored here (see Section 2) { "cas_number": "137317-11-0", ... }

1.2 Product Variant Fields

Each product has one or more variants representing different sizes/quantities. Variants carry the shipping-relevant physical attributes.

Field Type Required RR Usage Example
id string (auto) Auto Primary key variant_01H...
title string Yes Size description "5mg Lyophilized Powder"
sku string Yes Stock keeping unit "BPC157-5MG-LYO"
barcode string No Barcode if applicable --
ean string No EAN code --
upc string No UPC code --
hs_code string Yes Harmonized System tariff code "2933.99.9750"
origin_country string Yes Country of manufacture "US"
mid_code string No Manufacturer ID code --
material string No Not used --
weight number Yes Weight in grams (for shipping calc) 15
length number Yes Length in mm (packaged) 120
width number Yes Width in mm (packaged) 40
height number Yes Height in mm (packaged) 40
allow_backorder boolean Yes Whether to accept orders when out of stock false
manage_inventory boolean Yes Whether Medusa tracks inventory true
metadata JSON No Variant-specific overrides {}
options OptionValue[] Yes Variant option values { "Size": "5mg" }

1.3 Product Options

Product options define the axes of variation. For research chemicals, the primary option is size/quantity.

Option Name Values (examples) Applies To
Size 1mg, 5mg, 10mg, 25mg, 50mg, 100mg, 1g, 5g, 10g, 25g Peptides, amino acids, reference standards
Volume 10mL, 25mL, 50mL, 100mL, 500mL, 1L Solvents, solutions, buffers
Concentration 1mg/mL, 5mg/mL, 10mg/mL Pre-mixed solutions

1.4 Pricing

Prices are set per variant. Research Relay operates in USD only at launch (US domestic only per Decision D7/D11).

Field Type Example
currency_code string "usd"
amount number 3999 (represents $39.99, stored in cents)

Pricing is configured through Medusa's Pricing Module and linked to variants via the standard ProductVariant <-> Price link.


2. Custom RUO Metadata Fields

Research chemicals require domain-specific data beyond what Medusa provides out of the box. These fields are stored in the product's metadata JSON field for the initial implementation, with a migration path to a dedicated custom module for structured querying (see Section 8).

2.1 Product Metadata Schema

All custom fields are stored under product.metadata. Every product in the catalog must have these fields populated before being set to published status.

Field Type Required Example Purpose
cas_number string Yes "137317-11-0" Chemical Abstracts Service registry number. Unique chemical identifier.
molecular_formula string Yes "C62H98N16O22" Molecular formula per IUPAC convention.
molecular_weight number Yes 1419.53 Molecular weight in g/mol.
purity_percent number Yes 98.0 Minimum guaranteed purity percentage.
form enum Yes "powder" Physical form. One of: powder, liquid, crystal, solution, lyophilized.
grade enum Yes "research" Product grade. One of: research, analytical, reagent, reference.
sds_url string Yes "https://cdn.research-relay.com/sds/bpc-157.pdf" URL to Safety Data Sheet PDF. Required by OSHA/GHS.
storage_class enum Yes "frozen" Storage temperature classification. One of: ambient, refrigerated, frozen.
storage_temp_min number No -20 Minimum recommended storage temperature in degrees Celsius.
storage_temp_max number No -15 Maximum recommended storage temperature in degrees Celsius.
is_cold_chain boolean Yes true Whether the product requires cold-chain shipping. Triggers $15 surcharge (Decision D15).
is_hazmat boolean Yes false Whether the product is classified as hazardous material under DOT 49 CFR.
hazmat_class string No "6.1" UN hazmat class. Required if is_hazmat is true.
un_number string No "UN2811" UN identification number. Required if is_hazmat is true.
packaging_group string No "III" Packing group (I, II, or III). Required if is_hazmat is true.
shelf_life_days number No 730 Shelf life in days from date of manufacture.
restricted_states string[] No ["CA", "NY"] US state postal codes where this product cannot be shipped. Checked at checkout.
requires_signature boolean Auto true Auto-calculated: true when any variant price >= $150 (Decision D14). Can be overridden to true for high-value chemicals regardless of price.
ruo_only boolean Yes true Always true. Marks product as Research Use Only. Enforces RUO checkout flow.
lot_trackable boolean Yes true Whether lot numbers are tracked for this product. true for all chemicals, false for lab supplies.

2.2 Storage Class Definitions

Storage Class Temperature Range Shipping Requirement Examples
ambient 15-25 C Standard packaging Most reagents, solvents, dry amino acids
refrigerated 2-8 C Cold-chain required (is_cold_chain: true) Certain peptide solutions, enzymes
frozen -20 C or below Cold-chain required (is_cold_chain: true) Lyophilized peptides, sensitive compounds

2.3 Hazmat Field Requirements

When is_hazmat is true, the following fields become required:

Field Validation
hazmat_class Must be a valid DOT hazmat class (e.g., "3", "6.1", "8", "9")
un_number Must match pattern UN\d{4} (e.g., "UN2811")
packaging_group Must be one of "I", "II", "III"

Hazmat products are subject to carrier-specific shipping restrictions. The specific product-by-product hazmat review is deferred to catalog finalization (Decision D16).

2.4 Metadata Validation Rules

Before a product can transition from draft to published:

  1. All Required: Yes fields must be populated
  2. cas_number must be a valid CAS format (\d{2,7}-\d{2}-\d)
  3. purity_percent must be between 0 and 100
  4. molecular_weight must be a positive number
  5. sds_url must be a valid URL pointing to an accessible PDF
  6. If is_hazmat is true, all hazmat sub-fields must be populated
  7. If storage_class is refrigerated or frozen, is_cold_chain must be true
  8. ruo_only must be true (enforced, cannot be set to false)

3. Product Categories and Collections

3.1 Category Taxonomy

Categories use Medusa's built-in ProductCategory model, which supports hierarchical nesting. The taxonomy is organized by chemical/product type.

Research Chemicals (root)
├── Peptides
│   ├── Protective Peptides
│   ├── Growth Factor Peptides
│   ├── Antimicrobial Peptides
│   ├── Metabolic Peptides
│   └── Custom / Specialty Peptides
├── Amino Acids
│   ├── Standard Amino Acids
│   ├── Non-Standard Amino Acids
│   └── Modified Amino Acids
├── Reference Standards
│   ├── Analytical Reference Standards
│   └── Pharmacopeial Reference Standards
├── Reagents
│   ├── Organic Reagents
│   ├── Inorganic Reagents
│   └── Biochemical Reagents
├── Solvents
│   ├── HPLC-Grade Solvents
│   ├── ACS-Grade Solvents
│   └── General-Purpose Solvents
├── Buffers & Solutions
│   ├── Buffer Solutions
│   ├── Reconstitution Solutions
│   └── Bacteriostatic Water
└── Lab Supplies
    ├── Vials & Containers
    ├── Syringes & Needles
    └── Mixing Supplies

Notes:

  • Categories are used for storefront navigation and filtering.
  • A product can belong to multiple categories (many-to-many).
  • The Lab Supplies category contains non-chemical products that do not require RUO metadata fields like cas_number or molecular_formula. These products still carry ruo_only: false and lot_trackable: false.

3.2 Product Types

Medusa's ProductType is a flat classification (not hierarchical). Use it for the top-level chemical type:

Product Type Description
Peptide Synthetic peptides for research
Amino Acid Individual amino acids
Reference Standard Certified reference materials
Reagent Chemical reagents
Solvent Laboratory solvents
Buffer Buffer solutions and reconstitution media
Lab Supply Non-chemical laboratory consumables

3.3 Collections

Collections are marketing-driven groupings managed via the Medusa admin. Planned collections:

Collection Purpose
Best Sellers High-volume products for homepage display
New Arrivals Recently added products
Starter Kits Curated bundles for new researchers
Cold-Chain Products All products requiring cold-chain shipping
Sale Products with active promotions

3.4 Product Tags

Tags provide flexible, non-hierarchical labeling for filtering and internal organization.

Tag Category Example Tags
Shipping cold-chain, hazmat, signature-required, oversized
Form lyophilized, powder, solution, crystal
Storage ambient, refrigerated, frozen
Popularity popular, trending, staff-pick
Compliance state-restricted, new-formula

4. Variant Strategy

4.1 Variant Structure

Each product has variants representing different purchasable sizes/quantities. Every variant is independently priced, stocked, and has its own shipping dimensions.

Peptide example (BPC-157):

Variant Title SKU Option (Size) Price (USD) Weight (g)
5mg Lyophilized Powder BPC157-5MG-LYO 5mg $39.99 12
10mg Lyophilized Powder BPC157-10MG-LYO 10mg $69.99 14
25mg Lyophilized Powder BPC157-25MG-LYO 25mg $149.99 18
50mg Lyophilized Powder BPC157-50MG-LYO 50mg $259.99 22

Solvent example (Bacteriostatic Water):

Variant Title SKU Option (Volume) Price (USD) Weight (g)
10mL Vial BAC-WATER-10ML 10mL $8.99 25
30mL Vial BAC-WATER-30ML 30mL $12.99 55

4.2 SKU Naming Convention

{PRODUCT_CODE}-{SIZE}{UNIT}-{FORM_CODE}
Component Description Examples
PRODUCT_CODE Short product identifier BPC157, BAC-WATER, TB500
SIZE Numeric quantity 5, 10, 25, 100
UNIT Unit of measure MG, G, ML, L
FORM_CODE Physical form abbreviation LYO (lyophilized), PWD (powder), SOL (solution), CRY (crystal)

4.3 Inventory Per Variant

Each variant independently tracks:

  • Inventory quantity via Medusa's Inventory Module
  • Lot assignments via the Product Compliance Module (lot-to-variant link)
  • Pricing via Medusa's Pricing Module
  • Shipping dimensions (weight, length, width, height) on the variant record

4.4 When to Create Separate Products vs. Variants

Scenario Approach
Same chemical, different sizes Variants of one product
Same chemical, different purities (98% vs 99.5%) Separate products (different metadata)
Same chemical, different forms (powder vs solution) Separate products (different handling/storage)
Same chemical, different grades (research vs analytical) Separate products (different metadata, pricing)
Completely different chemicals Separate products

5. Shipping Classification Logic

These business rules are derived from the decisions documented in docs/compliance/business-decisions.md. They affect how products are handled during checkout and fulfillment.

5.1 Cold-Chain Products (Decision D15)

IF any item in cart has metadata.is_cold_chain == true:
    ADD $15.00 flat surcharge to shipping cost (once per order, not per item)
    RESTRICT shipping methods to Monday-Wednesday dispatch
    ADD cold-chain packaging (insulated container + gel packs or dry ice)
  • The $15 surcharge is a flat fee per order, not per item.
  • Products flagged is_cold_chain: true must also have storage_class set to refrigerated or frozen.
  • Expedited or overnight shipping may be required during warm months (June-September). This is handled operationally, not enforced in the system at launch.

5.2 Hazmat Products (Decision D16)

IF any item in cart has metadata.is_hazmat == true:
    RESTRICT shipping methods to hazmat-approved carriers/services
    APPLY carrier-specific hazmat surcharge (passed through at cost)
    REQUIRE proper DOT 49 CFR labeling on package
    LOG hazmat_class, un_number, packaging_group for shipping label generation
  • Specific hazmat products will be identified during catalog finalization.
  • Carrier agreements for hazmat shipping must be in place before listing hazmat products.
  • Some carriers (USPS) do not accept certain hazmat classes. The fulfillment module must filter available shipping methods accordingly.

5.3 Signature Requirement (Decision D14)

IF order total >= $150.00:
    SET requires_signature = true on shipment
    REQUIRE carrier signature confirmation service
ELSE IF any item has metadata.requires_signature == true:
    SET requires_signature = true on shipment
  • The $150 threshold is on the order total (after discounts, before tax and shipping).
  • Individual products can override this with requires_signature: true in metadata for high-value or sensitive chemicals regardless of price.

5.4 Free Shipping (Decision D11)

IF order subtotal >= $200.00:
    OFFER free standard shipping (USPS Priority Mail)
    Customer may upgrade to expedited/overnight at additional cost
  • Free shipping applies to standard shipping only. Expedited and overnight options remain at calculated rates.
  • Cold-chain surcharge ($15) still applies even with free shipping.

5.5 State Restriction Checking

AT CHECKOUT (before payment):
    FOR each item in cart:
        IF item.product.metadata.restricted_states CONTAINS shipping_address.state:
            BLOCK checkout
            DISPLAY: "One or more items in your cart cannot be shipped to {state}."
            IDENTIFY restricted items for the customer
  • State restrictions are checked at the cart/checkout level, not at the product browsing level. Customers can view all products but cannot complete checkout if restricted.
  • The restricted_states array is populated per product during the regulatory review before catalog launch (Decision D8).
  • The restriction check must happen server-side (in a workflow step or API middleware) to prevent bypass.

5.6 Shipping Method Selection

Shipping Method Carrier Estimated Transit Hazmat Allowed P.O. Box (D9)
Standard USPS Priority Mail 3-5 business days Limited Yes
Expedited UPS 2-Day / FedEx 2Day 2 business days Yes (with agreement) No
Overnight UPS Next Day Air / FedEx Overnight 1 business day Yes (with agreement) No

Rates are calculated in real-time via ShipStation integration (Decision D10).


6. Checkout Compliance Fields

The checkout flow must capture compliance data before an order can be completed. This is enforced by the Compliance Checkout Module (see project plan, Section 2.4).

6.1 Required Checkout Attestation

Before the cart can be completed, the customer must submit a checkout attestation with the following fields:

Field Type Required Validation Source
ruo_disclaimer_accepted boolean Yes Must be true Checkbox: "I acknowledge these products are for Research Use Only"
ruo_disclaimer_version string Auto Current active disclaimer version System-provided from RuoDisclaimer model
age_verified boolean Yes Must be true Checkbox: "I am at least 18 years of age" (Decision D1)
research_use_attested boolean Yes Must be true Checkbox: "I am purchasing for legitimate research purposes"
institution_name string No Optional free text Text field: research institution or lab name
researcher_name string No Optional free text Text field: name of responsible researcher

6.2 Checkout Attestation Text

The exact text displayed at checkout (from docs/compliance/ruo-disclosure.md):

By completing this purchase, I confirm that:

  • I am at least 18 years of age.
  • I am purchasing these products for legitimate research use only.
  • I understand these products are not for human or animal consumption.
  • I will handle, store, and dispose of these products in accordance with all applicable laws and safety standards.
  • I have read and agree to Research Relay's Terms of Service and RUO Disclosure.

All five points must be acknowledged via a single checkbox (clickwrap agreement).

6.3 Attestation Data Captured Automatically

These fields are captured server-side and stored with the attestation record:

Field Type Purpose
cart_id string Links attestation to the cart
order_id string Populated when order is placed (via order.placed subscriber)
customer_id string Linked customer account (if logged in)
ip_address string Customer's IP address for audit trail
user_agent string Browser user agent for audit trail
attested_at datetime Timestamp of attestation submission

6.4 Checkout Flow Sequence

1. Customer adds items to cart
2. Customer enters shipping address
   └── State restriction check runs (Section 5.5)
3. Customer selects shipping method
   └── Cold-chain surcharge applied if applicable (Section 5.1)
4. Customer submits compliance attestation (Section 6.1)
   └── POST /store/compliance/attest { cart_id, attestation fields }
5. Customer selects payment method (BTC/Lightning or ACH)
6. Cart completion workflow runs
   └── validate-compliance step checks attestation is complete
   └── If attestation incomplete, cart completion is blocked
7. Payment is processed
8. Order is created
   └── order.placed subscriber archives attestation with order_id

7. Sample Product Entry

BPC-157 (Body Protection Compound-157)

A common research peptide used as a reference example. This shows all fields fully populated.

Product Record

{
  "title": "BPC-157 (Body Protection Compound-157)",
  "subtitle": "Pentadecapeptide BPC 157",
  "handle": "bpc-157-body-protection-compound-157",
  "description": "BPC-157 is a synthetic pentadecapeptide consisting of 15 amino acids (Gly-Glu-Pro-Pro-Pro-Gly-Lys-Pro-Ala-Asp-Asp-Ala-Gly-Leu-Val) with a molecular weight of 1419.53 g/mol. This research-grade peptide is supplied as a lyophilized powder for reconstitution. Suitable for in vitro research applications. Not for human consumption.",
  "status": "published",
  "thumbnail": "https://cdn.research-relay.com/products/bpc-157/thumb.jpg",
  "images": [
    { "url": "https://cdn.research-relay.com/products/bpc-157/vial-5mg.jpg" },
    { "url": "https://cdn.research-relay.com/products/bpc-157/molecular-structure.png" },
    { "url": "https://cdn.research-relay.com/products/bpc-157/hplc-chromatogram.png" }
  ],
  "is_giftcard": false,
  "discountable": true,
  "type": { "value": "Peptide" },
  "collection": { "title": "Best Sellers" },
  "categories": [
    { "name": "Peptides" },
    { "name": "Protective Peptides" }
  ],
  "tags": [
    { "value": "cold-chain" },
    { "value": "lyophilized" },
    { "value": "popular" }
  ],
  "origin_country": "US",
  "metadata": {
    "cas_number": "137317-11-0",
    "molecular_formula": "C62H98N16O22",
    "molecular_weight": 1419.53,
    "purity_percent": 98.0,
    "form": "lyophilized",
    "grade": "research",
    "sds_url": "https://cdn.research-relay.com/sds/bpc-157-sds.pdf",
    "storage_class": "frozen",
    "storage_temp_min": -20,
    "storage_temp_max": -15,
    "is_cold_chain": true,
    "is_hazmat": false,
    "hazmat_class": null,
    "un_number": null,
    "packaging_group": null,
    "shelf_life_days": 730,
    "restricted_states": [],
    "requires_signature": false,
    "ruo_only": true,
    "lot_trackable": true
  }
}

Product Options

{
  "options": [
    {
      "title": "Size",
      "values": ["5mg", "10mg", "25mg", "50mg"]
    }
  ]
}

Variants

{
  "variants": [
    {
      "title": "5mg Lyophilized Powder",
      "sku": "BPC157-5MG-LYO",
      "hs_code": "2933.99.9750",
      "origin_country": "US",
      "weight": 12,
      "length": 100,
      "width": 35,
      "height": 35,
      "allow_backorder": false,
      "manage_inventory": true,
      "options": { "Size": "5mg" },
      "prices": [
        { "currency_code": "usd", "amount": 3999 }
      ]
    },
    {
      "title": "10mg Lyophilized Powder",
      "sku": "BPC157-10MG-LYO",
      "hs_code": "2933.99.9750",
      "origin_country": "US",
      "weight": 14,
      "length": 100,
      "width": 35,
      "height": 35,
      "allow_backorder": false,
      "manage_inventory": true,
      "options": { "Size": "10mg" },
      "prices": [
        { "currency_code": "usd", "amount": 6999 }
      ]
    },
    {
      "title": "25mg Lyophilized Powder",
      "sku": "BPC157-25MG-LYO",
      "hs_code": "2933.99.9750",
      "origin_country": "US",
      "weight": 18,
      "length": 110,
      "width": 40,
      "height": 40,
      "allow_backorder": false,
      "manage_inventory": true,
      "options": { "Size": "25mg" },
      "prices": [
        { "currency_code": "usd", "amount": 14999 }
      ]
    },
    {
      "title": "50mg Lyophilized Powder",
      "sku": "BPC157-50MG-LYO",
      "hs_code": "2933.99.9750",
      "origin_country": "US",
      "weight": 22,
      "length": 120,
      "width": 45,
      "height": 45,
      "allow_backorder": false,
      "manage_inventory": true,
      "options": { "Size": "50mg" },
      "prices": [
        { "currency_code": "usd", "amount": 25999 }
      ]
    }
  ]
}

Linked Compliance Data (via Product Compliance Module)

{
  "lot": {
    "lot_number": "BPC157-2026-001",
    "manufacture_date": "2026-01-15T00:00:00Z",
    "expiration_date": "2028-01-15T00:00:00Z",
    "quantity_produced": 500,
    "quantity_remaining": 342,
    "status": "active",
    "coa": {
      "file_url": "https://cdn.research-relay.com/coa/BPC157-2026-001.pdf",
      "purity_percentage": 98.7,
      "analysis_date": "2026-01-18T00:00:00Z",
      "lab_name": "Analytical Testing Services Inc.",
      "lab_reference": "ATS-2026-00142",
      "verified": true,
      "verified_by": "admin_01H...",
      "purity_records": [
        {
          "test_method": "HPLC",
          "parameter": "purity",
          "result_value": "98.7",
          "unit": "%",
          "specification": ">98%",
          "passes": true
        },
        {
          "test_method": "MS",
          "parameter": "identity",
          "result_value": "1419.5",
          "unit": "m/z",
          "specification": "1419.53 +/- 0.5",
          "passes": true
        },
        {
          "test_method": "LAL",
          "parameter": "endotoxin",
          "result_value": "0.05",
          "unit": "EU/mg",
          "specification": "<0.1 EU/mg",
          "passes": true
        }
      ]
    }
  }
}

8. MedusaJS v2 Implementation Notes

8.1 Metadata vs. Custom Module: Decision Framework

There are two approaches for storing the custom RUO fields defined in Section 2.

Approach A: Product Metadata (Recommended for Phase A)

Store all custom fields in the product's built-in metadata JSON field.

// Creating a product with RUO metadata via Admin API
const product = await medusa.admin.products.create({
  title: "BPC-157",
  // ... standard fields
  metadata: {
    cas_number: "137317-11-0",
    molecular_formula: "C62H98N16O22",
    molecular_weight: 1419.53,
    purity_percent: 98.0,
    form: "lyophilized",
    grade: "research",
    sds_url: "https://cdn.research-relay.com/sds/bpc-157-sds.pdf",
    storage_class: "frozen",
    storage_temp_min: -20,
    storage_temp_max: -15,
    is_cold_chain: true,
    is_hazmat: false,
    ruo_only: true,
    lot_trackable: true,
    restricted_states: [],
  },
})

Pros:

  • No custom module needed. Works with vanilla Medusa immediately.
  • Product metadata is included in standard API responses.
  • Fastest path to a working catalog (Phase A goal).

Cons:

  • No database-level validation or typing on metadata fields.
  • Cannot query/filter by metadata fields efficiently (no indexes).
  • No admin UI form fields without a custom widget.

Approach B: Custom Product Data Module (Recommended for Phase B+)

Create a dedicated module with a typed data model and link it to the Product.

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

const RuoProductData = model.define("ruo_product_data", {
  id: model.id().primaryKey(),
  cas_number: model.text(),
  molecular_formula: model.text(),
  molecular_weight: model.float(),
  purity_percent: model.float(),
  form: model.enum(["powder", "liquid", "crystal", "solution", "lyophilized"]),
  grade: model.enum(["research", "analytical", "reagent", "reference"]),
  sds_url: model.text(),
  storage_class: model.enum(["ambient", "refrigerated", "frozen"]),
  storage_temp_min: model.number().nullable(),
  storage_temp_max: model.number().nullable(),
  is_cold_chain: model.boolean().default(false),
  is_hazmat: model.boolean().default(false),
  hazmat_class: model.text().nullable(),
  un_number: model.text().nullable(),
  packaging_group: model.text().nullable(),
  shelf_life_days: model.number().nullable(),
  restricted_states: model.json().default([]),
  requires_signature: model.boolean().default(false),
  ruo_only: model.boolean().default(true),
  lot_trackable: model.boolean().default(true),
  metadata: model.json().nullable(),
})

export default RuoProductData
// src/links/product-ruo-data.ts
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import RuoDataModule from "../modules/product-ruo-data"

export default defineLink(
  ProductModule.linkable.product,
  RuoDataModule.linkable.ruoProductData
)

Pros:

  • Full database-level type safety and validation.
  • Indexed columns for efficient querying (e.g., find all cold-chain products).
  • Clean separation of concerns.
  • Admin widget can use typed form fields.

Cons:

  • Requires module development, migration generation, and link setup.
  • Requires custom API routes or workflow steps to populate data.
  • Additional query joins to fetch product + RUO data together.

Recommended Path:

  1. Phase A: Use metadata to get the catalog live quickly. Validate metadata in the admin widget and seed script.
  2. Phase B: Build the product-ruo-data module alongside the Product Compliance Module. Migrate data from metadata to the structured module.

8.2 State Restriction Checking at Checkout

State restrictions must be enforced server-side during cart completion. Implement as a workflow step that runs before payment authorization.

// src/workflows/steps/validate-state-restrictions.ts
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"

type ValidateStateRestrictionsInput = {
  cart_id: string
}

export const validateStateRestrictionsStep = createStep(
  "validate-state-restrictions",
  async ({ cart_id }: ValidateStateRestrictionsInput, { container }) => {
    const cartService = container.resolve("cartModuleService")
    const productService = container.resolve("productModuleService")

    // Retrieve cart with items and shipping address
    const cart = await cartService.retrieveCart(cart_id, {
      relations: ["items", "shipping_address"],
    })

    if (!cart.shipping_address?.province) {
      throw new Error("Shipping address with state is required.")
    }

    const state = cart.shipping_address.province.toUpperCase()

    // Check each item's product for state restrictions
    for (const item of cart.items) {
      const product = await productService.retrieveProduct(item.product_id)
      const restrictedStates = product.metadata?.restricted_states as string[] || []

      if (restrictedStates.includes(state)) {
        throw new Error(
          `"${product.title}" cannot be shipped to ${state} due to regulatory restrictions. ` +
          `Please remove this item from your cart to continue.`
        )
      }
    }

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

8.3 Cold-Chain Surcharge Logic

The $15 cold-chain surcharge is applied as a shipping adjustment. Implement in the shipping option calculation or as a cart middleware.

// src/workflows/steps/apply-cold-chain-surcharge.ts
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"

const COLD_CHAIN_SURCHARGE_CENTS = 1500 // $15.00

export const applyColdChainSurchargeStep = createStep(
  "apply-cold-chain-surcharge",
  async ({ cart_id }: { cart_id: string }, { container }) => {
    const cartService = container.resolve("cartModuleService")
    const productService = container.resolve("productModuleService")

    const cart = await cartService.retrieveCart(cart_id, {
      relations: ["items"],
    })

    // Check if any item requires cold-chain
    let requiresColdChain = false
    for (const item of cart.items) {
      const product = await productService.retrieveProduct(item.product_id)
      if (product.metadata?.is_cold_chain === true) {
        requiresColdChain = true
        break
      }
    }

    return new StepResponse({
      requires_cold_chain: requiresColdChain,
      surcharge_amount: requiresColdChain ? COLD_CHAIN_SURCHARGE_CENTS : 0,
    })
  }
)

The surcharge can be applied via:

  • A custom shipping price calculation in the ShipStation fulfillment module
  • A cart adjustment/line item added during shipping method selection
  • A middleware on the shipping option retrieval endpoint

The exact integration point will be determined during Phase F (ShipStation integration).

8.4 Admin Widget for RUO Product Data

A product detail widget injects a custom form into the admin product edit page, providing structured input for RUO metadata fields.

// src/admin/widgets/product-ruo-data-widget.tsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { DetailWidgetProps, AdminProduct } from "@medusajs/framework/types"

const RuoProductDataWidget = ({ data }: DetailWidgetProps<AdminProduct>) => {
  const metadata = data.metadata || {}

  return (
    <div>
      <h2>RUO Chemical Data</h2>
      {/* Form fields for CAS number, molecular formula, etc. */}
      {/* Read metadata values for display, POST to update */}
      <dl>
        <dt>CAS Number</dt>
        <dd>{metadata.cas_number || "Not set"}</dd>
        <dt>Molecular Formula</dt>
        <dd>{metadata.molecular_formula || "Not set"}</dd>
        <dt>Purity</dt>
        <dd>{metadata.purity_percent ? `${metadata.purity_percent}%` : "Not set"}</dd>
        <dt>Storage Class</dt>
        <dd>{metadata.storage_class || "Not set"}</dd>
        <dt>Cold Chain Required</dt>
        <dd>{metadata.is_cold_chain ? "Yes" : "No"}</dd>
        <dt>Hazmat</dt>
        <dd>{metadata.is_hazmat ? "Yes" : "No"}</dd>
      </dl>
    </div>
  )
}

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

export default RuoProductDataWidget

8.5 Seed Script Structure

The seed script should create sample products with all fields populated for development and testing.

// src/scripts/seed.ts (simplified structure)
import { ExecArgs } from "@medusajs/framework/types"

export default async function seed({ container }: ExecArgs) {
  const productService = container.resolve("productModuleService")

  // Create product types
  const peptideType = await productService.createProductTypes([
    { value: "Peptide" },
    { value: "Amino Acid" },
    { value: "Reagent" },
    { value: "Solvent" },
    { value: "Buffer" },
    { value: "Lab Supply" },
  ])

  // Create categories
  const categories = await productService.createProductCategories([
    { name: "Research Chemicals", is_internal: false },
    { name: "Peptides", parent_category_id: "..." },
    { name: "Protective Peptides", parent_category_id: "..." },
    // ... etc
  ])

  // Create BPC-157 with full metadata
  await productService.createProducts([{
    title: "BPC-157 (Body Protection Compound-157)",
    handle: "bpc-157",
    status: "published",
    // ... all fields from Section 7
  }])
}

8.6 Implementation Phasing

Phase What Gets Built Product Schema Impact
Phase A Vanilla Medusa + basic catalog Products created with metadata for RUO fields. Manual validation.
Phase B Product Compliance Module Lot tracking and COA management linked to variants. RUO data optionally migrated to custom module.
Phase E Compliance Checkout Module Attestation flow enforced. State restrictions checked. Cold-chain surcharge applied.
Phase F ShipStation Integration Shipping calculations use variant weight/dimensions. Hazmat flags affect carrier selection.
Phase H Admin UI Customizations Product widget provides structured form for RUO metadata. Validation enforced in admin.

Appendix A: Decision References

All business decisions referenced in this document come from docs/compliance/business-decisions.md. Key decisions affecting the product catalog:

Decision Summary Impact on Catalog
D1 Age requirement: 18+ Checkout attestation requires age verification
D8 State restrictions: defer to product review restricted_states field populated per product before launch
D9 P.O. Box: allow for USPS No product-level impact, shipping method selection
D10 Carriers: real-time calculated rates Variant weight/dimensions required for rate calculation
D11 Free shipping: $200+ No product-level impact, cart-level rule
D14 Signature: $150+ requires_signature auto-calculated from variant price
D15 Cold-chain: $15 flat surcharge is_cold_chain flag on product metadata
D16 Hazmat: defer to product review is_hazmat and related fields populated per product before launch

Appendix B: HS Code Reference

Common HS codes for research chemical products:

Product Type HS Code Description
Synthetic peptides 2933.99.9750 Other heterocyclic compounds, other
Amino acids 2922.49.4300 Amino-acids and their esters, other
Organic reagents 2942.00.5000 Other organic compounds, other
Solvents (DMSO) 2930.90.4600 Organo-sulfur compounds, other
Bacteriostatic water 3004.90.9290 Medicaments, other (for research use)
Lab supplies (vials) 7010.90.5000 Glass containers

These HS codes are set at the variant level. Confirm with a customs broker before using for any future international shipping.