TCG Platform Data Model¶
Status: Active — Phase 4 DCA + PO flow stable on staging; Phase 5 RBAC, Phase 6 Shopee multi-env, Phase 7 Shopee profile tables, and post-cutover token cleanup documented. Last updated: 2026-05-18 Predecessors: Project Plan · PRD · Phase 2 Findings · Phase 3 Findings · Phase 4 Findings
This document is the cross-phase source of truth for every custom table, column, and relationship the TCG Commerce Operations Platform adds on top of Medusa core. Phase 4 reads from here for migrations and module scaffolding. Phase 8 reuses the sketched-only entities at the bottom.
1. Scope and design principles¶
- Single-tenant. Per project-plan.md "Deferred: SaaS & Multi-Tenancy", no
tenant_idcolumns, no tenant routing, no subscription tables. The merchant's identity is environment config, not data. - Configurable over hardcoded. Every business-policy knob — channel fee %, item margin, cost-allocation method, refund handling, listing visibility — lives in editable DB tables. An admin edits these via UI without code changes. Hardcoding the merchant's specific rules is forbidden.
- Module Links over foreign keys to Medusa internals. Custom tables connect to Medusa entities (
product_variant,order,stock_location,customer_group,sales_channel) viadefineLink()only — never raw FK to Medusa internal table names. This insulates the schema from Medusa upgrades pulled by Dependabot. - Order is the source of truth for "what happened to this sale." Revenue and COGS reverse on goods-returned refunds. Profit is derived from inputs at query time, never stored as a column. Adjustments are first-class events.
- TCG semantics live behind a clean module boundary. A future non-TCG merchant could disable
src/modules/tcg/and run Medusa's vanilla product model.
2. What Medusa already provides¶
The platform uses these Medusa primitives unchanged. Custom modules link to them via Module Links.
| Domain | Medusa entity | Purpose |
|---|---|---|
| Catalog | product, product_variant |
Base product structure (extended in Phase 2 by tcg_variant_metadata via Module Link). |
| Inventory | inventory_item, inventory_level |
Stock-by-location (created on batch arrival, never on pre-order). |
| Locations | stock_location |
Warehouse / Store / Event-A — base structure for transfers and event flows. |
| Orders | order, order_line_item, order_payment_collection, order_fulfillment |
Order lifecycle, payments, fulfillments. Channel attribution via sales_channel_id. |
| Channels | sales_channel |
Shopee / Lazada / Telegram / POS / Manual / future TikTok. |
| Pricing | price_set, price_rule, customer-group rules |
Per-channel and per-tier prices, plus quantity-based bulk pricing. |
| Customer tiers | customer_group |
Partner / Streamer / Standard / Shopee / Lazada price segments. |
| Region | region |
SG with SGD currency. |
| Workflows | Workflow framework + hooks | All custom orchestration runs through Medusa workflow primitives. |
3. Existing custom tables (shipped / active)¶
These are already in the database; this section documents them for completeness.
| Module | Table | Owner | Notes |
|---|---|---|---|
tcg/ |
tcg_variant_metadata |
Phase 2 | 1:1 link to product_variant; condition, foil, grading, language, set, etc. |
tcg/ |
tcg_serialized_item |
Phase 2 | N:1 to product_variant for graded slabs and premium raws. |
tcg/ |
tcg_channel_listing |
Phase 3 | Per-channel listing mapping. Phase 4 extension: add is_listed: boolean for per-channel admin toggle. |
connectors/shopee/ |
shopee_raw_event |
Phase 3 | Raw webhook payloads + processed flag. Phase 6: environment column and per-env idempotency. |
connectors/shopee/ |
shopee_order_sync |
Phase 3 | Per-Shopee-order shadow row, holds shopee_status. Module Link to Medusa order. Phase 6: environment column and per-env ordersn uniqueness. |
connectors/shopee/ |
shopee_escrow |
Phase 3 | Settlement detail consumed by Phase 4 finance. |
connectors/shopee/ |
shopee_auth_token |
Phase 3 | OAuth token persistence. Phase 7: access_token_encrypted and refresh_token_encrypted are the only token-byte columns after Migration20260516220000_drop_legacy_credentials_and_plaintext_tokens; plaintext access_token / refresh_token columns were dropped. Protected by proactive refresh middleware via advisory locks. Diagnostic columns (last_refresh_attempt_at, last_refresh_status, last_refresh_error, refresh_token_last_used_at, scopes) support the metadata-only diagnostics modal. raw_token_payload is retained for forward-compatible non-secret SDK fields, with token keys stripped/null-cleared by Migration20260516240000_strip_token_keys_from_raw_payload. |
connectors/shopee/ |
shopee_environment_credentials |
Phase 6 | Historical Phase 6 table. It seeded the Phase 7 profile rows, then Migration20260516220000_drop_legacy_credentials_and_plaintext_tokens re-synced any drift and dropped the table. Current code reads connector config from shopee_connector_profile, not this table. |
connectors/shopee/ |
shopee_connector_profile |
Phase 7 | N-profile model; one active profile per env_type (sandbox/live). Enforces ShopeeRegion constraints and carries partner/shop credentials. |
connectors/shopee/ |
shopee_warehouse_location |
Phase 7 | Local cache of Shopee warehouses for a profile. Drives the default location dropdown. |
connectors/shopee/ |
shopee_active_environment |
Phase 6 | Singleton tracking the currently active environment (sandbox/live). |
connectors/shared/ |
connector_exception |
Phase 3 | Generic failure surface, used by all connectors. Phase 6: nullable environment column for env-tagged triage. |
dca/ |
supplier |
Phase 4 | Admin-managed supplier list. Migration20260508012114. |
dca/ |
import_batch |
Phase 4 | Per-purchase header. Tracks po_date, expected_delivery_date, and partially_received lifecycle. |
dca/ |
import_batch_item |
Phase 4 | One line per SKU per batch; Module Link -> product_variant. Tracks explicit quantity_received, received_into_inventory_at, and manual_cost_override_sgd. |
dca/ |
import_batch_receipt |
Phase 4 | Log of physical receipt events. Snapshots effective_cost_at_receipt_sgd and optional inventory_level_id. |
dca/ |
import_batch_fee |
Phase 4 | Typed fee_type enum. Triggers recompute-batch-costs via subscriber. |
dca/ |
import_batch_contributor |
Phase 4 | Per-batch capital contributors; name_snapshot until Phase 8 person. Migration20260508012114. |
dca/ |
batch_allocation |
Phase 4 | Heart of DCA + profit; frozen cost_per_unit_at_allocation; Module Link → order_line_item + nullable order_line_item_id indexed column for direct profit queries. Migration20260508021023 + Migration20260508030000_batch_allocation_order_line_item_id_idx. |
dca/ |
batch_adjustment |
Phase 4 | Typed reason enum; source (operator / system_recompute) prevents recompute loops. Operator rows trigger recompute via subscriber. |
staff/ |
user_role |
Phase 5 | Maps Medusa users to admin, ops, finance, or event_staff roles. |
Still pending: consolidation_event, consolidation_source, order_status_event, sales_channel_config, and refund workflow. See Phase 4 Findings, Phase 4 Wire-up Findings, and Phase 4 PO Flow Findings for the staged split.
3.1 Current admin API surfaces¶
The operator dashboard now creates catalog shells and variants outside the stock Medusa admin flow:
| Surface | Endpoint | Notes |
|---|---|---|
| Product shell create | POST /admin/dashboard/products |
Creates a single or sealed TCG product shell with product-level metadata.tcg. |
| SKU preview | POST /admin/dashboard/products/sku-generator |
Pure preview endpoint for deterministic SKU expansion before publishing variants. |
| Bulk variant publish | POST /admin/dashboard/products/:id/variants/bulk |
Creates variants on an existing product, attaches TCG metadata, and skips existing SKUs idempotently. |
4. Phase 4 tables — design reference¶
The active Phase 4 DCA scope. The base seven DCA tables shipped 2026-05-14, the operator wire-up shipped 2026-05-15, and the PO-flow follow-up added partial receipts on 2026-05-16. The remaining four (consolidation_event, consolidation_source, order_status_event, sales_channel_config) are still design references.
4.1 ERD¶
erDiagram
SUPPLIER ||--o{ IMPORT_BATCH : "supplies"
IMPORT_BATCH ||--|{ IMPORT_BATCH_ITEM : "contains lines"
IMPORT_BATCH ||--o{ IMPORT_BATCH_FEE : "incurs fees"
IMPORT_BATCH ||--o{ IMPORT_BATCH_CONTRIBUTOR : "funded by"
IMPORT_BATCH_ITEM ||--o{ BATCH_ALLOCATION : "allocates to"
IMPORT_BATCH_ITEM ||--o{ BATCH_ADJUSTMENT : "corrected by"
IMPORT_BATCH_ITEM ||--o{ CONSOLIDATION_SOURCE : "drained by"
CONSOLIDATION_EVENT ||--|{ CONSOLIDATION_SOURCE : "drains"
CONSOLIDATION_EVENT ||--|| IMPORT_BATCH_ITEM : "creates"
BATCH_ALLOCATION }o--|| MEDUSA_ORDER_LINE_ITEM : "links to"
IMPORT_BATCH_ITEM }o--|| MEDUSA_PRODUCT_VARIANT : "links to"
ORDER_STATUS_EVENT }o--|| MEDUSA_ORDER : "logs transitions for"
SALES_CHANNEL_CONFIG }o--|| MEDUSA_SALES_CHANNEL : "extends"
4.2 Tables¶
supplier¶
Admin-managed list. Replaces the spreadsheet's free-text supplier codes (T, M, MM, CY, K, R, D, DCA, Buyback, Conversion).
| Column | Type | Notes |
|---|---|---|
id |
text | PK, ULID. |
code |
text | Unique short code (e.g., T, MM, CONSOLIDATION). |
name |
text | Full name. |
default_currency |
text | ISO 4217 (JPY, USD, CNY, KRW, SGD). |
is_system |
boolean | True for special suppliers like CONSOLIDATION, OPENING_BALANCE. Prevents accidental deletion. |
notes |
text | Optional. |
created_at / updated_at |
timestamptz | Standard. |
import_batch¶
Header for one purchase event. Pre-orders are batches in paid or in_transit status that haven't created inventory_level rows yet.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
batch_number |
integer | Unique business identifier (matches the spreadsheet's "Batch" column). |
supplier_id |
text | FK → supplier. |
status |
enum | draft \| ordered \| paid \| in_transit \| partially_received \| arrived \| for_storage \| closed. |
original_currency |
text | ISO 4217. |
invoice_amount_original |
numeric(18,2) | Foreign-currency invoice (e.g., 1,548,300 yen). |
total_sgd_paid |
numeric(18,2) | Actual SGD that left the bank. |
paid_at |
date | When the SGD payment was made. |
arrived_at |
date | Nullable. When stock was physically received. |
po_date |
date | Operator-entered PO creation date. |
expected_delivery_date |
date | Nullable. Drives overdue chips while the PO is open or partially received. |
cost_allocation_method |
enum | proportional_by_value (default) | proportional_by_quantity | equal_split | manual. Editable per batch. |
paid_tax |
enum | yes_taxless \| no \| paid (matches spreadsheet's "Paid Tax" column). |
remarks |
text | Free-form. |
created_at / updated_at |
timestamptz | Standard. |
import_batch_item¶
One line per SKU within a batch. The unit of cost-tracking and order allocation.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
batch_id |
text | FK → import_batch. |
variant_id |
text | Module Link → Medusa product_variant. |
quantity_ordered |
integer | Units paid for. |
quantity_received |
integer | Explicit counter maintained by receiveBatchItem; starts at 0 and increments on physical receipt events. |
quantity_remaining |
integer | Current sellable stock; decrements on batch_allocation. |
invoice_value_original |
numeric(18,2) | Pre-fee, pre-FX line value in original currency. |
cost_per_unit_sgd_at_creation |
numeric(18,4) | Frozen at line creation. |
effective_cost_per_unit_sgd |
numeric(18,4) | Computed by recomputeBatchCosts; includes allocated fees + adjustments. |
intended_margin_shopee |
numeric(5,4) | Editable per item. Decimal (0.10 = 10%). |
intended_margin_lazada |
numeric(5,4) | Editable per item. |
intended_margin_standard |
numeric(5,4) | Editable per item. |
intended_margin_partner |
numeric(5,4) | Editable per item. |
is_consolidation_output |
boolean | True if this line was created by a consolidation_event (not a real purchase). |
received_into_inventory_at |
timestamptz | Nullable idempotency marker for the legacy receive-into-inventory path. |
manual_cost_override_sgd |
numeric(18,4) | Nullable per-unit override used when the batch allocation method is manual. |
created_at / updated_at |
timestamptz | Standard. |
import_batch_receipt¶
One physical receipt event against an import batch item. This is distinct from batch_adjustment: receipts are the normal PO flow; adjustments are corrections, refunds, and write-offs.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
batch_item_id |
text | FK -> import_batch_item. |
quantity_received |
integer | Quantity physically received in this event. |
effective_cost_at_receipt_sgd |
numeric(18,4) | Snapshot of import_batch_item.effective_cost_per_unit_sgd at receipt time. |
inventory_level_id |
text | Nullable Medusa inventory-level reference incremented by the workflow. |
received_at |
timestamptz | Receipt timestamp. |
received_by |
text | Medusa user ID of the operator who received the stock. |
notes |
text | Optional receipt notes. |
created_at / updated_at |
timestamptz | Standard. |
import_batch_fee¶
Each fee on a batch. Triggers recomputeBatchCosts on insert/update/delete.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
batch_id |
text | FK → import_batch. |
fee_type |
enum | shipping_overseas \| shipping_local \| gst \| customs_duty \| bank_fee \| fx_loss \| other. |
amount_sgd |
numeric(18,2) | The figure used for allocation. |
amount_original |
numeric(18,2) | Nullable (some fees are SGD-native). |
currency |
text | ISO 4217. |
paid_at |
date | When the fee was paid. |
notes |
text | Free-form (e.g., "Forgotten Shipping Fee surfaced 2026-03-24"). |
related_cashflow_id |
text | Nullable FK → cashflow_entry (Phase 8). Lets a cashflow entry promote into a batch fee. |
import_batch_contributor¶
Per-batch capital contributors. Repayment policy deferred to Phase 8.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
batch_id |
text | FK → import_batch. |
person_id |
text | FK → Phase 8 person. Phase 4 stub: allow nullable + a name_snapshot text column for now. |
amount_sgd |
numeric(18,2) | Contribution. |
notes |
text | Free-form. |
batch_allocation¶
The heart of DCA + profit. One row per (order line, source batch line, quantity).
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
order_line_item_id |
text | Module Link → Medusa order_line_item. |
batch_item_id |
text | FK → import_batch_item. |
quantity_allocated |
integer | How many units this allocation covers. |
cost_per_unit_at_allocation |
numeric(18,4) | Frozen at the moment of allocation. |
is_reversed |
boolean | True if a goods-returned refund reversed this allocation. |
reversed_at |
timestamptz | Nullable. |
created_at |
timestamptz | Standard. |
A single order line can produce multiple allocations if the line spans batches.
batch_adjustment¶
Unified table for all post-creation changes to a batch line. Triggers recomputeBatchCosts(batch_id) on insert.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
batch_item_id |
text | FK → import_batch_item. |
cost_delta_per_unit |
numeric(18,4) | Nullable. |
quantity_delta |
integer | Nullable. Negative for shortfall/refund/write-off. Positive for receipt corrections or returns. |
reason |
enum | cost_correction \| forgotten_fee \| fx_relock \| supplier_shortfall \| supplier_refund \| write_off \| quantity_correction \| customer_return \| return_cost_difference. |
source |
enum | operator (default) or system_recompute. Subscribers skip system_recompute rows to prevent loops. |
related_cashflow_id |
text | Nullable FK → cashflow_entry (Phase 8). |
applied_at |
timestamptz | When the adjustment took effect. Used by period-locking logic in Phase 8. |
applied_by |
text | User who applied it. |
notes |
text | Free-form. |
The combination of cost_delta_per_unit and quantity_delta lets one row express compound corrections (e.g., supplier refund: qty −5 and cost reallocation across remaining lines via the recompute service).
consolidation_event¶
DCA consolidation: a workflow that drains source batch lines and creates a new consolidated line.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
variant_id |
text | Module Link → Medusa product_variant. |
output_batch_item_id |
text | FK → import_batch_item (the new consolidated line). |
weighted_avg_cost_per_unit_sgd |
numeric(18,4) | Computed at consolidation time. |
total_quantity |
integer | Sum across all sources. |
executed_at |
timestamptz | Standard. |
executed_by |
text | User. |
notes |
text | Free-form. |
consolidation_source¶
Per-source-batch row for a consolidation. Lets recomputeBatchCosts propagate post-consolidation cost adjustments into the consolidated batch.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
event_id |
text | FK → consolidation_event. |
source_batch_item_id |
text | FK → import_batch_item. |
quantity_drained |
integer | What was drained from this source. |
cost_at_drain_sgd |
numeric(18,4) | Effective cost at consolidation time. |
order_status_event¶
Cross-channel transition log. One row per status change, regardless of which status field changed.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
order_id |
text | Module Link → Medusa order. |
status_field |
text | 'ops_status' \| 'finance_status' \| 'shopee_status' \| 'lazada_status' \| 'tiktok_status' \| .... |
from_value |
text | Previous value (nullable for first transition). |
to_value |
text | New value. |
at |
timestamptz | When the transition occurred. |
by |
text | User or 'system' or connector name. |
reason |
text | Free-form. |
Connector-native status (Shopee, Lazada, future TikTok) lives on the connector's own *_order_sync table. This events table is a unified replay log; consumers (audit reports, exception triage) query by status_field.
sales_channel_config¶
Editable per-channel policy. Extends Medusa's sales_channel via Module Link.
| Column | Type | Notes |
|---|---|---|
id |
text | PK. |
sales_channel_id |
text | Module Link → Medusa sales_channel. Unique. |
fee_pct |
numeric(5,4) | Channel platform fee (editable; depends on merchant's scheme on Shopee/Lazada). |
fee_scheme_notes |
text | Free-form (e.g., "SIP scheme — 6% commission + 2% transaction"). |
default_shipping_option_id |
text | Optional reference for outbound order creation. |
Alternative implementation: store fee_pct in sales_channel.metadata JSONB. A standalone table is preferred for type safety and indexing; the metadata fallback is a Phase 4 implementation choice if Module Link complexity is undesirable.
4.3 Columns added to existing tables¶
Medusa order.metadata (extended)¶
These are not new tables — they live in Medusa's existing metadata JSONB column on order. Phase 4 just defines the keys.
| Key | Type | Values | Notes |
|---|---|---|---|
ops_status |
text | Allocated \| Completed \| Refunded \| Cancelled \| Error |
Universal. Operator's daily-driver status. |
finance_status |
text | Marketplace orders: Pending Order Confirmation \| Pending Order Received Confirmation \| Ready to Release \| Released to My Balance \| Cancelled. Manual orders: Unpaid \| Partial \| Paid \| Refunded. |
Same column, different valid-transition graphs depending on order origin. |
deposit_amount |
numeric | Optional. Tracks deposit when finance_status = Partial. |
|
refunded_amount |
numeric | Accumulates as refunds happen. Net revenue = total_sale − refunded_amount. |
|
bot_order_id |
text | Optional. The Telegram-bot 8-char hex ID for manual/Mini-App orders. |
tcg_channel_listing (Phase 3 — extended)¶
Add: is_listed: boolean (default true). Editable per channel per listing. Replaces the spreadsheet's Listed column with a per-channel granularity.
5. Phase 8 entities — sketched, not scaffolded¶
These are documented here so Phase 4 tables don't accidentally block them. No migrations until Phase 8 begins.
5.1 ERD (deferred entities)¶
erDiagram
PERSON ||--o{ STAKEHOLDER : "is"
PERSON ||--o{ PAYEE : "is"
PERSON ||--o{ IMPORT_BATCH_CONTRIBUTOR : "is"
PERSON ||--o{ CASHFLOW_ENTRY : "POC"
STAKEHOLDER ||--o{ EQUITY_EVENT : "events"
STAKEHOLDER ||--o{ STAKEHOLDER_PAYOUT : "payouts"
CASHFLOW_ENTRY }o--|| CASH_LOCATION : "moves through"
CASHFLOW_ENTRY }o--|| CASHFLOW_CATEGORY : "classified as"
CASH_LOCATION ||--o{ CASH_BALANCE_SNAPSHOT : "snapshots"
ASSET_VALUATION_SNAPSHOT ||--o{ ASSET_VALUATION_LINE : "lines"
5.2 Entities¶
person— single identity that can play multiple roles (stakeholder, payee, contributor, cashflow POC). One row per real human.stakeholder— equity-holding role. Columns:person_id,stake_pct,initial_investment_sgd,monthly_payout_sgd. Pre-seeded: Ivan 45%, ZW 45%, X 10%.equity_event— adjustments to stakeholder equity over time.stakeholder_payout— recurring monthly payouts; ad-hoc bonuses recorded separately.payee— non-equity service providers. Columns:person_id,payee_type(salary / director_fee / bookkeeping / other),amount,period(monthly / yearly).cashflow_entry— date, description,category_id,cash_location_id,capital_in_sgd,capital_out_sgd,poc_person_id,related_batch_id,related_order_id. Therelated_*FKs let a cashflow entry promote into a batch fee or refund.cashflow_category— admin-managed list. Initial seed: Salary, Investment, Withdrawal, Refund, Sale, Marketing, Commission, Card Show Booth Fee, Supplier Payment, Additional Purchase, Transport, Interest.cash_location— Maribank, OCBC, Wise Business, Wise Personal, Shopee Cash, Shopee Cash In Transit, Physical Cash, etc.cash_balance_snapshot— periodic actual count per cash_location, used for discrepancy calculation.asset_valuation_snapshot+asset_valuation_line— periodic inventory valuation by category (Sealed JP / EN / CH / KR / Others / Outstanding / Singles).
Phase 4 leaves room for these by:
- Storing import_batch_contributor.person_id as nullable text now (Module Link target deferred until Phase 8 introduces person).
- Storing batch_adjustment.related_cashflow_id and import_batch_fee.related_cashflow_id as nullable text, populated only after Phase 8 lands.
- Storing the bot_order_id and poc_person_id similarly.
6. Module Links inventory¶
Every cross-module relationship is realized via Medusa's defineLink(). No raw FKs point at Medusa internal tables.
| Custom side | Medusa side | Link type | Phase | Notes |
|---|---|---|---|---|
tcg_variant_metadata |
product_variant |
1:1 | 2 | Already shipped. |
tcg_serialized_item |
product_variant |
N:1 | 2 | Already shipped. |
tcg_channel_listing |
product_variant |
N:1 | 3 | Already shipped. |
shopee_order_sync |
order |
1:1 | 3 | Already shipped. |
import_batch_item |
product_variant |
N:1 | 4 | New. |
batch_allocation |
order_line_item |
N:1 | 4 | New. Heart of DCA + profit. |
consolidation_event |
product_variant |
N:1 | 4 | New. |
order_status_event |
order |
N:1 | 4 | New. |
sales_channel_config |
sales_channel |
1:1 | 4 | New. |
cashflow_entry |
order |
N:1 (nullable) | 8 | Sketched only. |
cashflow_entry |
import_batch |
N:1 (nullable) | 8 | Sketched only. |
7. Workflows¶
Three load-bearing workflows are implied by this data model. Implementation lives in src/modules/dca/workflows/ and src/modules/ops/workflows/.
7.1 recomputeBatchCosts(batch_id)¶
Triggered on:
- Insert, update, or delete of import_batch_fee for the batch.
- Insert of batch_adjustment against any of the batch's lines.
- Insert, update, or delete of import_batch_item belonging to the batch.
Steps:
1. Sum invoice value across all batch lines.
2. Compute each line's value share according to import_batch.cost_allocation_method.
3. Sum batch fees; allocate per line by share.
4. Compute effective_cost_per_unit_sgd from line invoice, allocated fees, manual overrides where applicable, and operator-entered batch_adjustment.cost_delta_per_unit rows. system_recompute rows bridge already-sold allocations and do not feed the unsold-stock effective cost.
5. Update each line's effective_cost_per_unit_sgd.
6. If any line is a source for a consolidation_event, propagate: re-run consolidation math and emit a batch_adjustment on the consolidated output line.
7.2 consolidateBatches(variant_id, source_batch_item_ids?)¶
Operator-triggered (or auto-triggered when stock is fragmented). Replaces the spreadsheet's Method = Conversion mechanism cleanly, without phantom orders.
Steps:
1. If source_batch_item_ids is null, select all import_batch_item rows for variant_id where quantity_remaining > 0.
2. Compute weighted-average cost = Σ(quantity_remaining × effective_cost_per_unit) / Σ(quantity_remaining).
3. Create a new import_batch with supplier code CONSOLIDATION (system supplier), status = arrived.
4. Create a single import_batch_item on the new batch with the consolidated quantity and weighted-avg cost; mark is_consolidation_output = true.
5. Create a consolidation_event row pointing at the new batch_item.
6. For each source: create a consolidation_source row capturing quantity_drained and cost_at_drain_sgd; set source's quantity_remaining = 0.
7.3 refundOrder(order_id, refund_type, amount, line_items?)¶
Operator chooses refund_type at refund time:
- goods_returned — customer returns the item; we restock.
- money_only — customer keeps the goods; we eat the loss.
For goods_returned:
1. Run Medusa native return workflow on the order line.
2. For each affected batch_allocation:
- If source import_batch_item.quantity_remaining > 0 (still active): mark allocation reversed; source's quantity_remaining += quantity.
- If source has been consolidated/closed: emit batch_adjustment(batch_item_id = current_active_for_variant, quantity_delta = +qty, reason = customer_return) plus a second batch_adjustment(cost_delta_per_unit = original_cost − active_cost, reason = return_cost_difference).
3. Update order.refunded_amount += amount.
4. Set order.ops_status = Refunded.
5. For manual orders: create cashflow_entry (Capital Out). For marketplace orders: rely on escrow tracking — no separate cashflow line.
For money_only:
1. No inventory change.
2. Update order.refunded_amount += amount.
3. Set order.ops_status = Refunded.
4. For manual orders: create cashflow_entry. Marketplace: escrow.
Profit then derives correctly: goods-returned → revenue 0, COGS 0, profit 0. Money-only → revenue 0, COGS unchanged, profit = −COGS (real loss surfaces in P&L).
8. Profit derivation¶
Profit is not stored. It is derived at query time from four inputs:
effective_cost_per_unit(allocation) =
cost_per_unit_at_allocation
+ Σ batch_adjustment.cost_delta_per_unit
WHERE adjustment.batch_item_id = allocation.batch_item_id
AND adjustment.applied_at ≤ as_of_date
line_cogs = effective_cost_per_unit × quantity_allocated × (allocation.is_reversed ? 0 : 1)
line_revenue = unit_price × quantity − refunded_amount_proportional
line_profit = line_revenue − line_cogs
This guarantees that any batch_adjustment automatically flows into every affected order's profit on the next query. No denormalized columns to update, no risk of stale values.
For dashboards at scale, materialized views (profit_by_order_mv, profit_by_channel_mv) refreshed on batch_adjustment insert are an acceptable optimization. They are caches, not the source of truth.
9. Status state machines¶
9.1 ops_status¶
Universal across all order origins. Operator's primary daily indicator.
Allocated → Completed → (terminal)
Allocated → Cancelled → (terminal)
Allocated → Error (recoverable)
Completed → Refunded (terminal)
Allocation happens automatically on order ingestion (Phase 3 already wires this for Shopee). The Apps Script "For Allocation" intermediate state is dropped — the new system allocates synchronously.
9.2 finance_status¶
Same column, two enum families depending on order origin.
Marketplace (Shopee, Lazada, future TikTok):
Pending Order Confirmation
→ Pending Order Received Confirmation
→ Ready to Release
→ Released to My Balance
→ (terminal)
* → Cancelled
Manual (Telegram, POS, Card Show, Web):
Unpaid → Partial → Paid → (terminal)
* → Refunded
9.3 Channel-native status¶
Not stored on order. Lives on the connector's own sync table:
shopee_order_sync.shopee_status(Phase 3 — shipped)lazada_order_sync.lazada_status(Phase 6 — planned)- Future
tiktok_order_sync.tiktok_status(post-MVP)
For Telegram / POS / Card Show / Web orders, there is no channel-native status — ops_status and finance_status are sufficient.
This pattern means adding a new platform requires zero changes to the order table. The connector module brings its own sync table; order_status_event picks up transitions automatically via the status_field string.
9.4 Composite "fully done" view¶
There is no canonical "the order is done" status. The UI surfaces all three views and computes:
fully_done = (
ops_status = Completed
AND finance_status IN ('Released to My Balance', 'Paid')
AND channel_native_status IN (terminal_set OR null)
)
Operators reading the order list see all three columns side by side, matching how the spreadsheets present them today.
10. CSV → schema mapping¶
Every column in the merchant's nine Q1 2026 spreadsheets, mapped to its destination.
10.1 Master Inventory¶
| Spreadsheet column | Destination |
|---|---|
| TCG | tcg_variant_metadata.game (Phase 2) |
| Language | tcg_variant_metadata.language (Phase 2) |
| Set Number | tcg_variant_metadata.set_number (Phase 2) |
| Item Name, Item Type, Product Name, Variation Name | Medusa product.title / product_variant.title + tcg_variant_metadata |
| Parent SKU, SKU | Medusa product and product_variant.sku |
| Listed | tcg_channel_listing.is_listed per channel (Phase 4 extension) |
| GTIN | Medusa product_variant.barcode |
| Remaining Imports Stock | Derived: Σ import_batch_item.quantity_remaining for variant |
| Supposed Remaining Stock | Derived: inventory_level.stocked_quantity − reserved_quantity |
| Pre-Order Stock | Derived: Σ import_batch_item.quantity WHERE import_batch.status IN ('paid', 'in_transit') |
| Actual Stocktake | New stocktake_line.counted_quantity (deferred — see open questions) |
| Shopee Stock, Lazada Stock | Derived from inventory_level × sales_channel ↔ stock_location linkage |
| Requires Restock?, Minimum Stock Required | New columns on tcg_variant_metadata (Phase 4 add) |
| Total Orders (This Month / All Time) | Derived from Medusa order_line_item aggregates |
| Contents, num_packs, cards_per_pack | Deferred — kit composition tabled |
10.2 Imports¶
| Spreadsheet column | Destination |
|---|---|
| Batch | import_batch.batch_number |
| Date | import_batch.arrived_at (or paid_at depending on context) |
| Language, Item Name, Item Type, Variation Name | Resolved to a product_variant via SKU lookup |
| Status | import_batch.status (mapping: Arrived → arrived, For Storage → for_storage) |
| Paid | import_batch.status derives this (paid/in_transit/arrived) |
| Cost | import_batch_item.cost_per_unit_sgd_at_creation |
| Quantity | import_batch_item.quantity_ordered |
| Quantity Remaining | import_batch_item.quantity_remaining |
| Intended Shopee/Lazada/Standard/Partner Margin | import_batch_item.intended_margin_* |
| Total Cost Per Unit (SGD) | import_batch_item.effective_cost_per_unit_sgd (computed) |
| Shopee/Lazada/Standard/Partner Prices | Computed by price-suggestion service; written to Medusa Pricing |
| Profit | Derived per-batch-line: (standard_price − effective_cost_per_unit) × quantity_remaining |
| SKU | Resolves to product_variant.sku |
| Total Cost (Yen) | import_batch_item.invoice_value_original |
| Total Cost | Derived: cost_per_unit × quantity |
| Total Assets Remaining | Derived |
| Shopee Cost, Lazada Cost | Not stored. Computed at price-publish time using sales_channel_config.fee_pct. |
| Bulk Pricing Enabled / Threshold / Price / Limit | Medusa Pricing quantity tiers |
10.3 Additional Import Fees¶
| Spreadsheet column | Destination |
|---|---|
| Batch | import_batch.batch_number |
| Invoice Amount (w/o shipping) | import_batch.invoice_amount_original |
| Total SGD Paid | import_batch.total_sgd_paid |
| Ivan, Ivan's Mum, Petra, Xuan Qi, ZW's Sis | One import_batch_contributor row per non-empty cell |
| GST | import_batch_fee (fee_type=gst) |
| Paid Tax | import_batch.paid_tax |
| Supplier | import_batch.supplier_id (FK to supplier) |
| Remarks | import_batch.remarks |
10.4 Cashflow¶
| Spreadsheet column | Destination |
|---|---|
| Date | cashflow_entry.entry_date (Phase 8) |
| Description | cashflow_entry.description |
| Buyer | cashflow_entry.counterparty (free-text or FK to Phase 8 customer) |
| POC | cashflow_entry.poc_person_id (Phase 8) |
| Category | cashflow_entry.category_id → cashflow_category (Phase 8) |
| Capital In (SGD) | cashflow_entry.capital_in_sgd |
| Capital Out (SGD) | cashflow_entry.capital_out_sgd |
10.5 Finance, PnL¶
Mostly derived (see §5.2). The Finance sheet's Asset / Cash / Equity / Source / Stakeholder / Manpower / Payee sections all map to Phase 8 entities. PnL is a derived monthly aggregate.
10.6 Orders, Shopee Orders, Lazada Orders¶
| Spreadsheet column | Destination |
|---|---|
| Order ID | Medusa order.display_id (or metadata.external_order_id for marketplace) |
| Bot Order ID | order.metadata.bot_order_id |
| Date / Date & Time | Medusa order.created_at |
| Buyer | Medusa order.email / customer |
| Platform | Medusa sales_channel_id |
| Item Name, Item Type, Variation Name, SKU | Resolves to order_line_item.variant_id |
| Parent SKU | Derived via variant → product |
| Quantity | order_line_item.quantity |
| Total Sale | order_line_item.unit_price × quantity |
| Delivery Fee | order.shipping_total (Phase 3 SHIPPED gap fix unblocks this) |
| Status (internal) | order.metadata.ops_status |
| Shopee Status / Lazada Status | shopee_order_sync.shopee_status / future lazada_order_sync.lazada_status |
| Finance Status (Lazada) | order.metadata.finance_status |
| Method (Conversion / Self-Collection / Shopee Express / Card Show) | Method=Conversion → consolidation_event. Others → order.metadata.fulfillment_method |
| Allocated Batch, Allocated Quantity | batch_allocation rows |
| Cost | Derived: Σ batch_allocation.cost_per_unit_at_allocation × quantity (with adjustments) |
| Profit | Derived at query time |
| Payment Status | Medusa payment_status |
| Deposit Amount | order.metadata.deposit_amount |
| Remarks, Notes | order.metadata.remarks. Status-transition history → order_status_event |
| Address | Medusa order.shipping_address |
| Current Prices | Medusa Pricing module (live lookup) |
11. Open questions (deferred)¶
- Period locking policy for
batch_adjustment.applied_at— when March's books are closed, do late adjustments redirect to current period or are they allowed retroactively? Phase 8 decision. - Stocktake module —
stocktake_session+stocktake_linefor periodic physical counts. Master Inventory'sActual Stocktakecolumn suggests this exists informally. Phase 5 (admin UI) is the natural home. - Box composition / kits —
Contents,num_packs,cards_per_packcolumns. Decision deferred per user direction; revisit when Phase 7 (Event/POS) needs box-opening flows. - Person model — when Phase 8 introduces the unified
personentity, decide whether Ivan-as-stakeholder, Ivan-as-contributor, Ivan-as-payee are one row or separate. Affectsimport_batch_contributor.person_idresolution. - Per-batch contributor repayment policy — pooled vs. batch-specific repayment, schedules, partial settlement. Phase 8.
- Cash discrepancy handling — Finance sheet's
Discrepancies -$10,959.45line. Tolerated drift or system-enforced reconciliation? Phase 8 policy. - Channel-fee complexity —
sales_channel_config.fee_pctis single-value. If Shopee's commission scheme later requires tiered or category-specific rates, extend to asales_channel_fee_ruletable.
12. Implementation order recommendation¶
supplier,import_batch,import_batch_item,import_batch_fee,import_batch_contributor(skeleton tables — let admin start recording imports).batch_allocation+ Module Link toorder_line_item(cost flows to orders → unblocks profit derivation).batch_adjustment+ therecomputeBatchCostsworkflow (cascading recompute).consolidation_event,consolidation_source, theconsolidateBatchesworkflow.order_status_event, the columns added toorder.metadata.sales_channel_config.tcg_channel_listing.is_listedextension.- Refund workflow (
refundOrder). - Admin UI surfaces (Phase 5 territory).
Phase 8 entities deferred. CSV migration (FR-15) runs after step 4 against staging to validate batch_allocation and DCA arithmetic against the spreadsheet.
Links¶
- Project Plan
- PRD
- Phase 2 Findings — TCG metadata + serialized item validation.
- Phase 3 Findings — Shopee connector lessons.
- tcg-platform CLAUDE.md — code-side orientation.