Skip to content

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_id columns, 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) via defineLink() 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. The related_* 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.


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_idcashflow_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 modulestocktake_session + stocktake_line for periodic physical counts. Master Inventory's Actual Stocktake column suggests this exists informally. Phase 5 (admin UI) is the natural home.
  • Box composition / kitsContents, num_packs, cards_per_pack columns. Decision deferred per user direction; revisit when Phase 7 (Event/POS) needs box-opening flows.
  • Person model — when Phase 8 introduces the unified person entity, decide whether Ivan-as-stakeholder, Ivan-as-contributor, Ivan-as-payee are one row or separate. Affects import_batch_contributor.person_id resolution.
  • Per-batch contributor repayment policy — pooled vs. batch-specific repayment, schedules, partial settlement. Phase 8.
  • Cash discrepancy handling — Finance sheet's Discrepancies -$10,959.45 line. Tolerated drift or system-enforced reconciliation? Phase 8 policy.
  • Channel-fee complexitysales_channel_config.fee_pct is single-value. If Shopee's commission scheme later requires tiered or category-specific rates, extend to a sales_channel_fee_rule table.

12. Implementation order recommendation

  1. supplier, import_batch, import_batch_item, import_batch_fee, import_batch_contributor (skeleton tables — let admin start recording imports).
  2. batch_allocation + Module Link to order_line_item (cost flows to orders → unblocks profit derivation).
  3. batch_adjustment + the recomputeBatchCosts workflow (cascading recompute).
  4. consolidation_event, consolidation_source, the consolidateBatches workflow.
  5. order_status_event, the columns added to order.metadata.
  6. sales_channel_config.
  7. tcg_channel_listing.is_listed extension.
  8. Refund workflow (refundOrder).
  9. 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.