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 toproductVariant, and a minimal service layer.src/scripts/seed-phase-2.tsseed 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
TCGSerializedItemstatus 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
editionaxis (1st Edition vs Unlimited). - One Piece TCG — exercises the
variationaxis (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.
Module Links¶
| 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. TCGVariantMetadatarow per variant withproduct_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.
TCGVariantMetadatarow withproduct_type='sealed',condition='SEALED',sealed_formatset.- Card-specific fields (
card_number,rarity,variation,foil,edition) arenull.
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 inTCGSerializedItem. TCGVariantMetadatarow on the placeholder withproduct_type='graded'and card attributes.TCGSerializedItemrows — 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 transitionsstatus: available → reserved. Phase 5 builds the UX; Phase 2 proves the model.
Accessories¶
- Plain Medusa products. No
TCGVariantMetadata, noTCGSerializedItem, no Module Link. - Verifies accessories pass through standard Medusa flows with zero tcg module involvement.
Edge cases named¶
- Graded rare printings. PSA 10 Charizard Base-Set-Shadowless worth $50k+. Placeholder variant + 1
TCGSerializedItem. Works. - Ungraded premium raws. A raw card the merchant photographs and lists individually.
TCGSerializedItemwithis_graded=false,condition_notespopulated. - Fungible → serialized transition. Merchant ships a raw to PSA; grade returns. Admin action: decrement variant stock by 1, insert
TCGSerializedItemrow linked to the same base variant. Phase 5 builds the UX; Phase 2 confirms the model supports it. - 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 Charizard —
TCGSerializedItemwithis_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:
- Allocate to event. Transfer N Charizard NM EN from Warehouse → Event-A. Verify
stock_levelupdates at both locations. - Partial return. Move unsold stock from Event-A back to Warehouse. Verify balance.
- 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. - 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:
- Purpose + method — what we set out to prove, how.
- Medusa primitives leaned on —
product,productVariant,inventory_item,inventory_level,stock_location,sales_channel. - Custom extensions added — the tcg module's two tables + two Module Links.
- Verified scenarios — what works cleanly (singles condition axis, sealed, graded via serialized items, accessories as plain products, stock transfers).
- Gaps / concerns surfaced — anticipated candidates:
- 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?
- Serialized reservation path sits outside Medusa's inventory module. Phase 5 audit needed to ensure checkout/cancel/refund reconcile both.
- 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.
- Per-channel pricing. Medusa doesn't natively support "same variant, different price per platform." Phase 4 decides: customer-group pricing or
ChannelPricetable. - 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.
- Channel listings deferred.
TCGChannelListingshape pinned down in Phase 3 with Shopee. - Phase 2 decisions recorded — why two tables not three, why accessories don't get metadata, why language-as-product for sealed.
- 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+TCGSerializedItemMikro-ORM models. - [ ] Implement
src/modules/tcg/— index, models, service, migration. - [ ] Wire both Module Links in
src/links/. - [ ] Write
TCGSerializedItemreservation / status-transition service methods. - [ ] Write
phase-2-findings.mdduring validation.
D2 — Mid¶
- [ ] Design
seed-phase-2.tsdata 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
TCGSerializedItemstatus transitions. - [ ] Integration test: seed + query variant with linked metadata.
- [ ] Update
homelab/05_service_deployments/tcg-staging.mdwith 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/medusa2.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¶
src/modules/tcg/merged tomain, module registered, migrations apply cleanly on CT 105 via entrypoint'smedusa db:migrate.TCGVariantMetadata+TCGSerializedItemtables exist in staging Postgres (verified with\dt+\d tcg_*).- Both Module Links verified:
productVariantqueried withfields: ["*", "tcg_variant_metadata.*", "tcg_serialized_items.*"]returns joined data for a seeded fixture. yarn medusa exec ./src/scripts/seed-phase-2.tsruns 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 (
TCGSerializedItemwith grader/grade/cert) - ≥1 ungraded premium raw (
TCGSerializedItemwithis_graded=false)
- Three stock locations seeded (Warehouse, Store, Event-A) with transfer scenarios passing on staging.
tcg-platform/phase-2-findings.mdcommitted with all seven sections filled.- Minimal test coverage: unit (status transitions) + integration (seed + query with Module Link).
- CI green on all Phase 2 PRs. No regression to Phase 1 staging (admin still loads, login works).
homelab/05_service_deployments/tcg-staging.mdgains a "Phase 2 applied" line after seed runs cleanly on staging.- Owner signs off on
phase-2-findings.mdand 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-deployexists from Phase 1 completion. Further snapshot after Phase 2 lands =phase-2-seeded.
Links¶
- Project Plan — Phase 2 row, implementation roadmap.
- Product Requirements Document (PRD) — FR-3 (product/SKU management) and FR-2 (inventory / stock locations).
- Phase 0 Notes — Module Link decision preserved here.
- Phase 1 Kickoff Plan — predecessor.
tcg-stagingrunbook — deployment target.