Skip to content

Phase 2: Medusa Fit Validation — Kickoff Plan

Status: Ready to start 📋 Predecessor: Phase 1 Kickoff (staging deployed 2026-04-22 — see tcg-staging runbook) Exit criterion (from Project Plan): "TCG taxonomy maps cleanly onto Medusa primitives with documented gaps."

This plan turns the Phase 2 objective — prove Medusa can model TCG products and inventory — into a concrete data model, module scaffold, seed plan, and validation checklist. Implementation on top of Phase 1's live staging environment.


Goal

Ship the src/modules/tcg/ domain module, a realistic seed script, and a gap-analysis findings doc, together demonstrating that Medusa's product / variant / inventory / stock-location primitives can express a real TCG catalogue across singles, sealed, graded, and accessories without silently breaking at the edges.

When Phase 2 exits, Phase 3 (Shopee slice) can start by connecting real marketplace orders to the TCG variants and serialized items seeded here.


Scope

In scope

  • src/modules/tcg/ module with two data models (TCGVariantMetadata, TCGSerializedItem), migrations, Module Links to productVariant, and a minimal service layer.
  • src/scripts/seed-phase-2.ts seed script populating Pokémon / YGO / One Piece fixtures across all supported product types.
  • Three stock locations seeded on staging (Warehouse, Store, Event-A) with transfer scenarios documented.
  • tcg-platform/phase-2-findings.md — gap-analysis output doc, written during validation.
  • Minimal tests: unit tests for TCGSerializedItem status transitions, integration test for a seeded variant queried with linked metadata.

Out of scope

Out of scope Home phase
Admin UI widgets, forms, bulk editors Phase 5
REST API endpoints for tcg entities Phase 5
Product search endpoint joining variant + metadata + serialized items Phase 5
TCGChannelListing table (per-platform listing tracking) Phase 3
Shopee / Lazada / Telegram / Carousell connectors Phase 3, 6, 7
DCA costing, import batches, acquisition_cost backfill Phase 4
Per-channel pricing logic Phase 4
Per-game attribute extensions (Pokémon HP, Magic mana_cost, YGO level) Evaluate in Phase 5
Bulk variant creation from pack-opening workflow Phase 5
Reservation stress testing under concurrent checkout Phase 5
Telegram Mini App variant display Phase 7
POS / event transfer UX Phase 7
Fungible ↔ serialized conversion UX (send raw to PSA, get slab back) Phase 5
Sales-channel publication rules Phase 3+

Product type scope

Type In scope? Approach
Singles Medusa product per printing; variants by condition × foil × language × edition; TCGVariantMetadata row per variant.
Sealed (packs, boxes, ETBs, bundles, tins, promo boxes) Medusa product per sealed SKU; usually 1 variant per product; TCGVariantMetadata with product_type='sealed', condition='SEALED'.
Graded slabs Medusa product per base printing; one placeholder variant (manage_inventory=false); real inventory in TCGSerializedItem rows.
Accessories (sleeves, playmats, deck boxes, etc.) Plain Medusa products. No TCGVariantMetadata row. tcg module does not touch them.
Preconstructed decks Out of Phase 2 scope. Can piggy-back on sealed later if needed.

TCG games in scope

  • Pokémon — primary anchor for seed coverage (most condition × foil × language × variation permutations).
  • Yu-Gi-Oh! — exercises the edition axis (1st Edition vs Unlimited).
  • One Piece TCG — exercises the variation axis (Alt Art / Super Rare / Secret Rare).

Per-game attribute extensions (HP for Pokémon, mana_cost for Magic, level/attribute for YGO) are deliberately not modelled in Phase 2. If needed they land as additional sidecar tables in a future sub-phase, keyed off TCGVariantMetadata.id.


Data model

Two tables in the tcg module.

TCGVariantMetadata — one row per Medusa productVariant (fungible)

id                 pk
product_type       enum: 'single' | 'sealed' | 'graded'
game               enum: 'pokemon' | 'yugioh' | 'one_piece'

-- Card-like (singles + graded) --
set_code           text            e.g. "SV08", "LOB"
set_name           text            e.g. "Surging Sparks"
card_number        text            "045/182"
rarity             text            "Secret Rare", "Alt Art"
language           enum            'en' | 'ja' | 'ko' | 'zh-s' | 'zh-t' | 'de' | 'fr' | 'es' | 'it' | 'pt'
variation          text            "Reverse Holo", "Full Art", "Alt Art"
foil               bool
edition            text            "1st Edition", "Unlimited", "Shadowless"

-- Sealed-specific --
sealed_format      enum            'booster_pack' | 'booster_box' | 'etb' | 'bundle' | 'tin' | 'promo_box'
pack_count         int             # packs inside a box (null for packs)

-- Condition (fungible only; graded condition lives on TCGSerializedItem) --
condition          enum            'NM' | 'LP' | 'MP' | 'HP' | 'DMG' | 'SEALED'

notes              text
created_at, updated_at

TCGSerializedItem — one row per individual physical item

id                       pk
variant_id               fk to productVariant (via Module Link)

-- Grading --
is_graded                bool
grader                   enum: 'PSA' | 'BGS' | 'CGC' | 'ACE' (null for ungraded premium raws)
grade                    numeric(3,1)                         10.0 down to 1.0
cert_number              text

-- Ungraded premium raws --
condition_notes          text                                 free-form

-- Fulfillment state --
status                   enum: 'available' | 'reserved' | 'sold' | 'on_hold'
reserved_by_order_id     fk to order (nullable)

-- Media --
photo_urls               jsonb array

-- Sourcing (Phase 4 populates cost/batch; Phase 2 leaves the columns) --
acquired_at              timestamptz
acquired_batch_id        text                                 string until Phase 4 owns it
acquisition_cost         numeric

-- Housekeeping --
sku                      text unique                          internal shop SKU for physical labeling
notes                    text
created_at, updated_at

Concurrency: Reservation is wrapped in a transaction that does SELECT ... FOR UPDATE on the TCGSerializedItem row, re-reads status, and only transitions available → reserved if still available. A DB-level CHECK constraint enforces the invariant (status = 'reserved') → (reserved_by_order_id IS NOT NULL) so an inconsistent row can't be written.

Link From To Cardinality Query shape
product-variant-tcg-metadata TCGVariantMetadata productVariant 1:1 fields: ["*", "tcg_variant_metadata.*"]
product-variant-serialized-item TCGSerializedItem productVariant N:1 fields: ["*", "tcg_serialized_items.*"]

Both use defineLink() per the Phase 0 decision. Same Postgres DB. No cross-DB joins.


Product type mapping details

Singles

  • Product = one card printing. Example: Charizard ex · Obsidian Flames · 223/197.
  • Variants = cartesian subset of (condition × foil × language × edition) actually in stock. Never auto-create the full matrix; add variants only when stock is acquired.
  • TCGVariantMetadata row per variant with product_type='single'.
  • Stock_level on each variant = copies in that specific condition/language/printing.

Sealed

  • Product = one sealed SKU. Example: Surging Sparks Booster Box · EN.
  • Usually 1 variant per product. Language-as-product rule: an EN booster box and a JP booster box are separate products, not variants of the same product.
  • TCGVariantMetadata row with product_type='sealed', condition='SEALED', sealed_format set.
  • Card-specific fields (card_number, rarity, variation, foil, edition) are null.

Graded slabs

  • Product = the base card printing (like singles, one product per printing).
  • One placeholder variant with manage_inventory=false — stock_level is not meaningful here; real inventory lives in TCGSerializedItem.
  • TCGVariantMetadata row on the placeholder with product_type='graded' and card attributes.
  • TCGSerializedItem rows — one per physical slab, each with its own grader/grade/cert/photos/status.
  • Checkout flow selects a specific TCGSerializedItem (by cert or SKU). Custom reservation transitions status: available → reserved. Phase 5 builds the UX; Phase 2 proves the model.

Accessories

  • Plain Medusa products. No TCGVariantMetadata, no TCGSerializedItem, no Module Link.
  • Verifies accessories pass through standard Medusa flows with zero tcg module involvement.

Edge cases named

  1. Graded rare printings. PSA 10 Charizard Base-Set-Shadowless worth $50k+. Placeholder variant + 1 TCGSerializedItem. Works.
  2. Ungraded premium raws. A raw card the merchant photographs and lists individually. TCGSerializedItem with is_graded=false, condition_notes populated.
  3. Fungible → serialized transition. Merchant ships a raw to PSA; grade returns. Admin action: decrement variant stock by 1, insert TCGSerializedItem row linked to the same base variant. Phase 5 builds the UX; Phase 2 confirms the model supports it.
  4. Misprints / errata. Out of Phase 2 scope. If needed, add to TCGVariantMetadata.variation.

Module layout

src/modules/tcg/
├── index.ts                       module registration + service export
├── models/
│   ├── variant-metadata.ts        TCGVariantMetadata
│   └── serialized-item.ts         TCGSerializedItem
├── service.ts                     reservation / status-transition logic
└── migrations/
    └── 20260423000000_initial.ts

src/links/
├── product-variant-tcg-metadata.ts       1:1
└── product-variant-serialized-item.ts    N:1

Seed plan (src/scripts/seed-phase-2.ts)

Idempotent — checks for existing products by stable external key before inserting. Safe to re-run.

Pokémon (~15 items)

  • Charizard ex · Obsidian Flames · 223/197 — variants: NM EN Normal, LP EN Normal, NM EN Reverse Holo, NM JP Normal (condition × foil × language)
  • Pikachu · Paldean Fates · RC01 — variants: NM EN Normal, NM EN Full Art (variation)
  • Surging Sparks Booster Box (EN) — sealed, sealed_format='booster_box', pack_count=36
  • Surging Sparks Booster Pack (EN) — sealed, pack_count=null
  • Surging Sparks ETB (EN)sealed_format='etb'
  • Paldean Fates Booster Box (JP) — separate product per language-as-product rule
  • PSA 10 Charizard · Base Set · Unlimited — graded placeholder variant + 1 TCGSerializedItem
  • Mint-candidate raw CharizardTCGSerializedItem with is_graded=false

Yu-Gi-Oh! (~5 items)

  • Blue-Eyes White Dragon · LOB-001 — variants: NM 1st Edition EN, NM Unlimited EN (edition)
  • Dark Magician · LOB-005 — variants: NM EN, MP EN (condition)
  • LOB Booster Pack — sealed

One Piece TCG (~5 items)

  • Luffy · OP01-001 — variants: NM EN Normal, NM EN Alt Art (variation)
  • Luffy · OP01-001 JP — separate product per language-as-product rule
  • Zoro · OP01-025 · Super Rare — NM EN
  • OP07 Booster Box — sealed

Accessories (~3 items)

  • Ultra Pro sleeves (100ct black)
  • Dragon Shield deck box
  • Playmat (no game association)

Accessories get zero TCGVariantMetadata or TCGSerializedItem rows — proves they pass through plain Medusa flows.


Stock location validation

Three locations seeded on staging:

  • Warehouse (default, most stock)
  • Store (retail counter)
  • Event-A (simulated upcoming event booth)

Scenarios run via Medusa's native IInventoryService:

  1. Allocate to event. Transfer N Charizard NM EN from Warehouse → Event-A. Verify stock_level updates at both locations.
  2. Partial return. Move unsold stock from Event-A back to Warehouse. Verify balance.
  3. Serialized item location. Associate a TCGSerializedItem (PSA slab) with Warehouse. Currently a metadata field on the slab row (since slabs aren't in Medusa's inventory module). Verify this doesn't conflict with Medusa's stock-location joins.
  4. Sales channel visibility. Put Warehouse in default sales channel + Store in an "in-store" sales channel. Verify admin queries can filter products by location availability.

Any awkwardness goes into phase-2-findings.md as a gap.


Findings doc structure (phase-2-findings.md)

Created during Phase 2 as validation runs. Sections:

  1. Purpose + method — what we set out to prove, how.
  2. Medusa primitives leaned onproduct, productVariant, inventory_item, inventory_level, stock_location, sales_channel.
  3. Custom extensions added — the tcg module's two tables + two Module Links.
  4. Verified scenarios — what works cleanly (singles condition axis, sealed, graded via serialized items, accessories as plain products, stock transfers).
  5. Gaps / concerns surfaced — anticipated candidates:
  6. Variant explosion. 30+ variants per popular card may not scale in Medusa's admin UI. Phase 5 UX decision: custom widget or pure variant table?
  7. Serialized reservation path sits outside Medusa's inventory module. Phase 5 audit needed to ensure checkout/cancel/refund reconcile both.
  8. Bulk stock adjustment UX. Medusa's admin wants one-by-one edits; TCG operators think in "+20 NM after opening a box." Phase 5 tool needed.
  9. Per-channel pricing. Medusa doesn't natively support "same variant, different price per platform." Phase 4 decides: customer-group pricing or ChannelPrice table.
  10. Product search not TCG-aware. "charizard psa 10" won't work. Phase 5 needs a custom endpoint joining product ↔ tcg_variant_metadata ↔ tcg_serialized_item.
  11. Channel listings deferred. TCGChannelListing shape pinned down in Phase 3 with Shopee.
  12. Phase 2 decisions recorded — why two tables not three, why accessories don't get metadata, why language-as-product for sealed.
  13. Open questions for Phases 3+ — specific things Phase 3 must answer (e.g., "how does the Shopee connector resolve a listing ID to a TCGSerializedItem?").

Task breakdown

D1 — Lead

  • [ ] Design TCGVariantMetadata + TCGSerializedItem Mikro-ORM models.
  • [ ] Implement src/modules/tcg/ — index, models, service, migration.
  • [ ] Wire both Module Links in src/links/.
  • [ ] Write TCGSerializedItem reservation / status-transition service methods.
  • [ ] Write phase-2-findings.md during validation.

D2 — Mid

  • [ ] Design seed-phase-2.ts data set (realistic enough to exercise every axis).
  • [ ] Implement the seed script (idempotent, safe to re-run).
  • [ ] Run stock-location validation scenarios on staging; capture results for findings doc.
  • [ ] Verify admin UI (read-only) shows seeded products cleanly — document visual gaps.

D3 — Support

  • [ ] Unit tests for TCGSerializedItem status transitions.
  • [ ] Integration test: seed + query variant with linked metadata.
  • [ ] Update homelab/05_service_deployments/tcg-staging.md with a "Phase 2 applied" entry after seed runs clean.
  • [ ] Contribute to phase-2-findings.md — scenario descriptions, output snippets.

Blockers & dependencies

Upstream

  • Phase 1 complete ✅ (staging deployed 2026-04-22, admin login verified).

Internal assumptions

  • The Phase 0 Module Link approach holds (no cross-DB joins, defineLink() works in Medusa 2.13.6). Confirmed in Phase 0 spike.
  • @medusajs/medusa 2.13.6's product/variant schema does not change significantly in a patch release during Phase 2.

Open questions

Question Owner
Does the merchant want language-as-product for singles too, or only sealed? Owner
Minimum inventory quantity beyond which a variant should auto-flip to "backorder" display vs "out of stock"? Owner (defer to Phase 5)

Exit criteria

  1. src/modules/tcg/ merged to main, module registered, migrations apply cleanly on CT 105 via entrypoint's medusa db:migrate.
  2. TCGVariantMetadata + TCGSerializedItem tables exist in staging Postgres (verified with \dt + \d tcg_*).
  3. Both Module Links verified: productVariant queried with fields: ["*", "tcg_variant_metadata.*", "tcg_serialized_items.*"] returns joined data for a seeded fixture.
  4. yarn medusa exec ./src/scripts/seed-phase-2.ts runs idempotently (twice in a row, no errors) and populates:
    • ~15 Pokémon fixtures across condition × foil × language × variation
    • ~5 Yu-Gi-Oh! fixtures exercising edition
    • ~5 One Piece fixtures exercising variation
    • ~3 accessories (confirming they live without tcg module involvement)
    • ≥1 graded slab (TCGSerializedItem with grader/grade/cert)
    • ≥1 ungraded premium raw (TCGSerializedItem with is_graded=false)
  5. Three stock locations seeded (Warehouse, Store, Event-A) with transfer scenarios passing on staging.
  6. tcg-platform/phase-2-findings.md committed with all seven sections filled.
  7. Minimal test coverage: unit (status transitions) + integration (seed + query with Module Link).
  8. CI green on all Phase 2 PRs. No regression to Phase 1 staging (admin still loads, login works).
  9. homelab/05_service_deployments/tcg-staging.md gains a "Phase 2 applied" line after seed runs cleanly on staging.
  10. Owner signs off on phase-2-findings.md and the seed output in staging.

Rollback posture

  • Migrations are reversible — both tables are new; drop them + drop Module Link join tables for a clean rollback.
  • Proxmox snapshot initial-deploy exists from Phase 1 completion. Further snapshot after Phase 2 lands = phase-2-seeded.