Skip to content

TCG Platform Data Model

Status: Active — Phases 4–7 documented; Phase 8 connector-platform reform (capability registry + cross-connector Listings spine, #235–#242), Lazada catalog-import connector (#255/#257), suppliers management (#256), structured product naming (#259/#260), the 4-status PO lifecycle (#265), Phase 8A cashflow spine (person, cash_location, cashflow_category, cashflow_entryfinance/ module, activated Phase-4 stub links, manage_finance/view_finance capabilities), and Phase 8C profitability (sales_channel_config fee policy + three derived finance reports — order-profit, channel-profitability, inventory-valuation) now reflected. Phase 8D (monthly P&L + finance dashboard UI + CSV/PDF exports) is designed but not built; Phase 8B equity/payouts was dropped. Last updated: 2026-06-19 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 Cross-connector listing spine, keyed by (channel, external_listing_id, external_variation_id)intended one row per marketplace listing/variation, but the index is non-unique so uniqueness is not enforced (concurrent imports can race and insert duplicates; the same variant may have multiple listings on a channel — intentional cross-listings, #215). Phase 8 (#236): carries channel, external_listing_id, external_variation_id, listing_url, status (active \| inactive \| out_of_stock \| paused), last_synced_at. Powers the unified Listings surface and per-SKU coverage badges.
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.
connectors/lazada/ lazada_connector_profile Phase 8 Single-slot connector config (SG region). AES-256-GCM app_secret_encrypted; captures seller_id / country on connect. Migration20260611120000_create_lazada_auth_tables. PR #255.
connectors/lazada/ lazada_auth_token Phase 8 One token row per profile. access_token_encrypted / refresh_token_encrypted (AES-256-GCM); epoch-ms expires_at / refresh_expires_at; diagnostic refresh columns mirror Shopee. raw_token_payload with token bytes nulled. PR #255.
connectors/lazada/ lazada_catalog_import Phase 8 Per-import-run header. status (scanning → scanned → committing → committed \| failed) + classification counts. Migration20260612120000_create_lazada_catalog_import. PRs #255/#257.
connectors/lazada/ lazada_catalog_import_item Phase 8 One row per discovered SKU; match_status (new \| link_only \| already_imported \| conflict), proposed title/price/images/attributes, commit result. PRs #255/#257.
dca/ supplier Phase 4 Admin-managed supplier list. Phase 8 (#256): added country (ISO-3166-1 alpha-2) and archived_at (soft archive); name / country / notes masked from non-admins via view_supplier_details capability. Migration20260508012114 + Migration20260612000000_supplier_country_archived.
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.
dca/ consolidation_event Phase 8 One row per executed DCA consolidation; Module Link → product_variant. Merges a variant's fragmented batch lines into one weighted-average line under the system CONSOLIDATION supplier (cost-only, no inventory change). Migration20260614000000_consolidation.
dca/ consolidation_source Phase 8 Per-source-line drain record for a consolidation; snapshots quantity_drained + cost_at_drain_sgd, and lets recomputeBatchCosts propagate later source-cost adjustments onto the consolidated line.
staff/ user_role Phase 5 Maps Medusa users to admin, ops, finance, or event_staff roles.
finance/ person Phase 8A Root identity (stakeholder / contributor / payee / cashflow POC). linked_user_id audit-only ref to Medusa user. Migration20260614200000_finance_init.
finance/ cash_location Phase 8A Where money lives; admin-managed + seeded (10 rows), archive-only.
finance/ cashflow_category Phase 8A Cashflow categories with a kind (inflow/outflow/both); seeded (12), archive-only.
finance/ cashflow_entry Phase 8A One row per money movement; mirrors Cashflow.csv + cash_location_id. CHECK enforces inflow-XOR-outflow. capital_in_sgd/capital_out_sgd are bigNumber (raw_* companions). Module Links: poc_person_id→person, related_batch_id→import_batch, related_order_id→order.
finance/ sales_channel_config Phase 8C Editable per-channel fee policy. fee_pct + fixed_fee_sgd (bigNumber, raw_ companions); archived soft-archive; Module Link → Medusa sales_channel. Partial-unique on active channel* (UQ_sales_channel_config_active_channel on sales_channel_id WHERE deleted_at IS NULL AND archived = false) — at most one live policy per channel. Idempotent seed (4 non-Shopee channels, fee_pct=0/fixed_fee_sgd=0, ON CONFLICT DO NOTHING). Write path upsertSalesChannelConfig validates fee_pct ∈ [0,100] and fixed_fee_sgd ≥ 0. Migration20260619120000_sales_channel_config. See §8C.

Still pending: order_status_event and the refund workflow remain design references; sales_channel_config shipped in Phase 8C (see §8C). 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.
Finance — people /admin/dashboard/finance/people CRUD for person rows. Requires manage_finance.
Finance — cash locations /admin/dashboard/finance/cash-locations CRUD for cash_location rows. Requires manage_finance.
Finance — categories /admin/dashboard/finance/categories CRUD for cashflow_category rows. Requires manage_finance.
Finance — cashflow /admin/dashboard/finance/cashflow CRUD for cashflow_entry rows. Requires manage_finance (write) or view_finance (read; granted to admin, finance, and ops). Dashboard pages under /finance/cashflow.

Structured product naming (product.metadata.tcg) — Phase 8 (#259 / #260)

Stored in the schemaless product.metadata.tcg JSON column (no migration). Drives a derived display name and connector backfill.

Field Type Notes
brand string Vocab combobox (e.g. Pokemon, Yu-Gi-Oh!, One Piece). Mirrors legacy game code. (#259)
item_name string Product/set name (e.g. 151 Vol. 1 Journey). (#259)
item_type string Format (e.g. Booster Box, ETB, Bundle). Mirrors legacy sealed_format code. (#259)
language string Product-level language (moved up from variant). (#259)
set_code string Set identifier (e.g. 151C, OP-05). (#259)
name_override string | null Operator's manual name; overrides the derived name when set. (#259)
naming_review boolean Set by the reverse-parser when brand or item_type can't be resolved from a title. (#260)

Derived name (deriveProductName, apps/dashboard/lib/product-naming.ts): at dashboard product-create time only, the form seeds product.title as name_override || "<Brand> <Item Name> <Item Type> [<Language>][<Set Code>]" (empty segments drop out; the bracket suffix vanishes when both bracket fields are blank). This is not a persisted invariant — the product API accepts an independent title, PATCH edits title while the TCG metadata stays read-only, so title and the structured fields can later diverge.

Reverse-parse (#260): applyReverseParsedTcg — the inverse of deriveProductName — backfills the five fields by parsing a title (longest-first token matching). Scope (connector-specific): Shopee applies it both when creating a new product (parsing the marketplace row title) and on its ALREADY_IMPORTED refresh pass — where it parses the existing Medusa product title (falling back to the marketplace title only when the product title is blank) and updates metadata only, not the title (#249). Lazada applies it only when creating a new productLINK_ONLY commits just create listing rows and ALREADY_IMPORTED items are skipped, so existing products are not backfilled via Lazada. The merge is additive/idempotent (setIfAbsent, never overwrites operator values); unresolved brand/type sets naming_review = true instead of guessing. Field vocabularies are aggregated at GET /admin/dashboard/products/field-vocab.


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. consolidation_event and consolidation_source shipped in Phase 8 (Migration20260614000000_consolidation). The remaining two (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). Operator-facing identifier — never masked.
name text Full legal name. Admin-only — nulled for callers lacking view_supplier_details.
default_currency text ISO 4217 (JPY, USD, CNY, KRW, SGD).
country text Nullable. ISO 3166-1 alpha-2 (e.g., JP). Admin-only — masked from operators. (#256)
is_system boolean True for special suppliers like CONSOLIDATION, OPENING_BALANCE. Prevents accidental deletion.
notes text Optional free-form. Admin-only — masked from operators.
archived_at timestamptz Nullable. Soft-archive marker; null = active. Archived suppliers stay resolvable in historical POs. (#256)
created_at / updated_at timestamptz Standard.

Masking: non-admin callers (role without view_supplier_details) receive name, country, and notes as null via maskSupplier() (apps/server/src/api/_lib/can-see-supplier-names.ts). code is the unmaskable handle operators use to distinguish suppliers in PO lists. (#256 — supplier codename masking.)

import_batch

Header for one purchase event. Pre-orders are batches in in_transit status that haven't created inventory_level rows yet (payment is tracked separately via paid_at / total_sgd_paid, no longer a status).

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 \| in_transit \| partially_received \| completed. 4-state goods journey only; payment is tracked separately (paid_at / total_sgd_paid). draft → in_transit is the sole operator step ("Mark as ordered"); receiving auto-advances in_transit → partially_received → completed. (Simplified from the original 8-state model in #265; Migration20260613000000_import_batch_status_simplify remaps ordered/paidin_transit and arrived/for_storage/closedcompleted.)
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.
quantity_consolidated_out integer Units of this line drained into a consolidation (default 0). unsold_on_hand = quantity_received − Σ(non-reversed allocations) − quantity_consolidated_out. (Phase 8 consolidation.)
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 Module Link → finance/cashflow_entry. Phase 8A activated: previously a nullable text stub; now a live Module Link. 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 Module Link → finance/person. Phase 8A activated: previously a nullable text stub; now a live Module Link. name_snapshot text column retained for display without a join.
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.
consolidation_source_id text Nullable. When set, this is a D5 epoch-ledger row propagating a source-line cost change onto a consolidated output (see below). Migration20260616000000_batch_adjustment_consolidation_source.
consolidation_epoch integer Nullable. Monotonic epoch for the (consolidation_source_id, consolidation_epoch) ledger key. Migration20260617000000_batch_adjustment_consolidation_epoch.
related_cashflow_id text Module Link → finance/cashflow_entry. Phase 8A activated: previously a nullable text stub; now a live Module Link.
applied_at timestamptz When the adjustment took effect. Immutable per row. Used by period-locking logic in Phase 8 and as the temporal gate for D5 propagation (below).
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).

D5 cost propagation: append-only epoch ledger

Shipped concurrency + cost-correctness hardening.

When a consolidation source line's cost later changes, the new cost is propagated onto the consolidated output line as append-only batch_adjustment rows — never by mutating an existing row. Each propagation row carries consolidation_source_id (the originating consolidation_source), a consolidation_epoch, the incremental share delta in cost_delta_per_unit, and its own immutable applied_at. The ledger key (consolidation_source_id, consolidation_epoch) is protected by a partial unique index (WHERE consolidation_source_id IS NOT NULL), so a given (source, epoch) can be appended at most once — concurrent recompute attempts converge instead of double-applying.

  • Migration20260616000000_batch_adjustment_consolidation_source adds consolidation_source_id and the initial partial-unique on (consolidation_source_id) (IDX_batch_adjustment_consolidation_source_uniq).
  • Migration20260617000000_batch_adjustment_consolidation_epoch adds consolidation_epoch and swaps the partial-unique to the composite (consolidation_source_id, consolidation_epoch) (IDX_batch_adjustment_consolidation_source_epoch_uniq).
Concurrency model

Consolidate / undo / rollback / recompute all serialize per-variant and per-item via Postgres transaction-scoped advisory locks (pg_advisory_xact_lock), with classid 1276 = variant and 1277 = item. recomputeBatchCosts is a fixpoint-retry driver: it computes the transitive consolidation closure, advisory-locks the accumulated scope, and retries in fresh transactions until the closure is a fixpoint (⊆ the advisory scope). Only then does it take row locks in one global order — events → outputs → items — so there is no post-lock scope growth and no lock-order inversion (no deadlocks). Invalid consolidation graphs are rejected up front by cycle detection (Kahn's algorithm), which raises a distinct ConsolidationCycleError rather than looping.

COGS correctness

computeOrderLineProfit applies a system_recompute batch_adjustment to an allocation only when allocation.created_at < adjustment.applied_at (operator adjustments always apply). This prevents D5 propagation from being double-counted against sales that already froze the propagated cost. The batched profit method behind the orders spreadsheet, getOrderLinesEconomics, now applies the same per-allocation temporal gate; previously it summed all adjustments per batch item and could double-count post-propagation sales. The auto-compensation (forgotten-fee) pass excludes D5 rows (consolidation_source_id IS NOT NULL) and is skipped entirely for is_consolidation_output items. D5 propagation itself is processed in topological order over the consolidation graph (parent before child).

consolidation_event

Shipped — Phase 8. Migration20260614000000_consolidation.

DCA consolidation: merges a variant's fragmented batch lines (≥2 lines with unsold-on-hand > 0) into one weighted-average line under the system CONSOLIDATION supplier. Cost-accounting only — no physical inventory change. Undo is allowed only before any sale against the consolidated line. A recompute pass (recomputeBatchCosts) propagates later source-cost adjustments onto the consolidated line via consolidation_source.

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. Medusa bigNumber; companion raw_weighted_avg_cost_per_unit_sgd (jsonb) added by Migration20260614010000_consolidation_raw_columns.
total_quantity integer Sum across all sources.
executed_at timestamptz Standard.
executed_by text User.
notes text Free-form.

consolidation_source

Shipped — Phase 8. Migration20260614000000_consolidation.

Per-source-line drain record for a consolidation. Snapshots quantity_drained and cost_at_drain_sgd at the moment of consolidation, and lets recomputeBatchCosts propagate later source-cost adjustments onto the consolidated line. Each consolidation_source is the propagation key (consolidation_source_id) for the D5 epoch ledger on batch_adjustment — see D5 cost propagation.

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. Medusa bigNumber; companion raw_cost_at_drain_sgd (jsonb) added by Migration20260614010000_consolidation_raw_columns.

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

Shipped — Phase 8C (Migration20260619120000_sales_channel_config)

This Phase 4 design reference was realized in Phase 8C in the finance/ module with a refined shape: fee_pct + fixed_fee_sgd (bigNumber with raw_* companions) replace the single fee_pct numeric(5,4) sketch, an archived flag enables soft-archive, and uniqueness is enforced by a partial-unique index on the active channel (WHERE deleted_at IS NULL AND archived = false) rather than a plain unique constraint — so an archived policy and its replacement can coexist. The fee_scheme_notes / default_shipping_option_id columns were not built. See §8C for the shipped model and how it feeds profit derivation. The historical design sketch below is retained for context.

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 — reshaped Phase 8 #236)

The cross-connector listings spine, keyed by (channel, external_listing_id, external_variation_id). This key has a non-unique index — one row per marketplace listing/variation is intended but not enforced, and concurrent catalog imports can race and insert duplicate rows for the same key (see Lazada commit.ts). The same variant may have multiple listings on the same channel (intentional cross-listings, #215). Linked to either a product_variant or a tcg_serialized_item (polymorphic; CHECK ensures exactly one is set).

Column Type Notes
id text PK (prefix tcl).
variant_id text Nullable. Module Link → product_variant.
serialized_item_id text Nullable text reference to tcg_serialized_item (graded slabs). Module Link deferred — not a DB-enforced link. Exactly one of this / variant_id is set.
channel enum ConnectorName value (shopee, lazada, …).
external_listing_id text Marketplace listing id (e.g. Shopee item_id).
external_variation_id text Nullable. Marketplace variation id (e.g. Shopee model_id).
listing_url text Nullable. Direct link to the listing.
status enum active \| inactive \| out_of_stock \| paused.
last_synced_at datetime When this row was last written by a connector sync.

The Listings API (GET /admin/dashboard/listings) enriches rows with variant SKU/title/condition, summed inventory, and channel display-name/icon from the connector registry. The Phase 4 per-channel boolean toggle was superseded by the status enum on this richer table. Note: coverage currently counts rows regardless of status — inactive/out_of_stock/paused listings still register as covered.


Lazada connector tables (Phase 8 — #255 / #257)

The Lazada connector currently implements catalog import only (OAuth + signed catalog scan/commit). Inventory sync and order ingestion are not yet built — there is intentionally no lazada_order_sync table (see Marketplace status, below).

lazada_connector_profile

Single-slot connector config (one row, SG region). Migration20260611120000_create_lazada_auth_tables.

Column Type Notes
id text PK (prefix lcp); seeded lcp_default.
display_name text Operator label (e.g. "Lazada SG").
region text Default 'SG'.
app_key text Nullable. Lazada Open Platform app key.
app_secret_encrypted text Nullable. AES-256-GCM ciphertext — never plaintext.
seller_id text Nullable. Captured from token country_user_info on connect.
country text Nullable. Country id captured on connect.
sync_enabled boolean Default false. Per-slot sync gate.
created_at / updated_at / deleted_at timestamptz Standard (soft-delete).

lazada_auth_token

One token row per profile (unique profile_id). Mirrors the Shopee token diagnostics model. Migration20260611120000_create_lazada_auth_tables.

Column Type Notes
id text PK (prefix lat).
profile_id text Reference to lazada_connector_profile.idunique index per profile (no DB FK constraint).
access_token_encrypted / refresh_token_encrypted text Nullable. AES-256-GCM ciphertext.
expires_in integer Seconds, verbatim from token response.
expires_at bigNumber Epoch-ms access-token expiry (Medusa bigNumber).
raw_expires_at jsonb Medusa bigNumber backing column — high-precision representation of expires_at.
refresh_expires_at bigNumber Nullable. Epoch-ms refresh-token expiry (Medusa bigNumber).
raw_refresh_expires_at jsonb Medusa bigNumber backing column for refresh_expires_at.
country / seller_id text Nullable. Diagnostic, from country_user_info.
raw_token_payload jsonb Default {}. Verbatim token response with token bytes nulled.
last_refresh_attempt_at / last_refresh_status / last_refresh_error / refresh_token_last_used_at mixed Refresh diagnostics (mirror Shopee).
created_at / updated_at / deleted_at timestamptz Standard.

lazada_catalog_import

Per-import-run header. Migration20260612120000_create_lazada_catalog_import.

Column Type Notes
id text PK (prefix lcimp).
profile_id text Reference to lazada_connector_profile.id (not indexed; no DB FK constraint).
status text (enum) Transitions: scanning → scanned \| failed; scanned \| committed → committing (commit is retryabletryClaimCommit re-claims a committed run); committing → committed \| failed. failed is for run-level exceptions; a run can finish committed while still carrying item-level failures (failed_count > 0), so committed ≠ "all items succeeded".
started_by text Nullable. Medusa actor_id.
total_count / new_count / link_only_count / already_imported_count / conflict_count / committed_count / failed_count integer Classification + result counters (default 0).
error text Nullable. Run-level error.
created_at / updated_at / deleted_at timestamptz Standard.

lazada_catalog_import_item

One row per discovered SKU in a run. Migration20260612120000_create_lazada_catalog_import.

Column Type Notes
id text PK (prefix lcimi).
import_id text indexed reference to lazada_catalog_import.id (no DB FK constraint).
lazada_item_id text Lazada product item_id.
lazada_sku_id text Nullable. Lazada SkuId.
sku text Nullable. SellerSku; null ⇒ conflict (missing SKU).
proposed_title text Proposed product title.
proposed_price_cents integer Nullable. Minor units.
proposed_description text Nullable.
proposed_image_urls jsonb Nullable. Parent image URLs.
proposed_model_image_url text Nullable. This SKU's own image.
proposed_stock_qty integer Nullable. Captured, not yet consumed.
proposed_attributes jsonb Nullable. Structured Lazada attributes → metadata.lazada.
match_status text (enum) new \| link_only \| already_imported \| conflict (CHECK).
conflict_reason text Nullable.
target_variant_id text Nullable. Matched Medusa variant.
include boolean Default true. Operator commit toggle.
committed boolean Default false.
committed_product_id text Nullable. Newly-created Medusa product id for NEW rows; stays null for LINK_ONLY rows (which are marked committed = true without a product id).
error text Nullable. Item-level error.
created_at / updated_at / deleted_at timestamptz Standard.

5. Phase 8 entities — deferred (8B / 8C and beyond)

Phase 8A shipped person, cash_location, cashflow_category, and cashflow_entry — see §3 and the finance/ module rows. Phase 8C shipped sales_channel_config and the derived profitability reports — see §8C. The equity/payout entities below were scoped to the now-dropped Phase 8B and are no longer planned; they are retained here only for historical context.

5.1 ERD (deferred entities)

erDiagram
    PERSON ||--o{ STAKEHOLDER : "is"
    PERSON ||--o{ PAYEE : "is"
    STAKEHOLDER ||--o{ EQUITY_EVENT : "events"
    STAKEHOLDER ||--o{ STAKEHOLDER_PAYOUT : "payouts"
    CASH_LOCATION ||--o{ CASH_BALANCE_SNAPSHOT : "snapshots"
    ASSET_VALUATION_SNAPSHOT ||--o{ ASSET_VALUATION_LINE : "lines"

5.2 Entities (still deferred — Phase 8B / 8C)

  • stakeholder — equity-holding role. Columns: person_id, stake_pct, initial_investment_sgd, monthly_payout_sgd. Pre-seeded: Ivan 45%, ZW 45%, X 10%. (Phase 8B — dropped.)
  • equity_event — adjustments to stakeholder equity over time. (Phase 8B — dropped.)
  • stakeholder_payout — recurring monthly payouts; ad-hoc bonuses recorded separately. (Phase 8B — dropped.)
  • payee — non-equity service providers. Columns: person_id, payee_type (salary / director_fee / bookkeeping / other), amount, period (monthly / yearly).
  • 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 stubs that have now been activated by Phase 8A (see §6 Module Links): - import_batch_contributor.person_id — now a live Module Link → finance/person (previously nullable text stub). - import_batch_fee.related_cashflow_id — now a live Module Link → finance/cashflow_entry (previously nullable text stub). - batch_adjustment.related_cashflow_id — now a live Module Link → finance/cashflow_entry (previously nullable text stub).

5.3 cashflow_entry — shipped correctness & concurrency hardening

The Phase 8A cashflow_entry create path is the only ledger-writing surface, so it carries idempotency, money-precision, and referential guards as shipped (the entity summary is in §3; Migration20260614200000_finance_init created the base tables).

Idempotent create. cashflow_entry gained an idempotency_key (nullable text) column. Create is idempotent via three partial unique indexes — on idempotency_key, related_order_id, and related_batch_id, each WHERE col IS NOT NULL AND deleted_at IS NULL — and the service returns the existing row on a unique-violation conflict (PG SQLSTATE 23505) instead of erroring or double-inserting. Migration Migration20260616120000_cashflow_hardening.

Money precision. capital_in_sgd / capital_out_sgd are validated and normalized at the API/service boundary to an exact 2-dp decimal via integer-cents — rejecting fractional cents, non-finite values, negatives, and any amount over MAX_AMOUNT_SGD (= 1,000,000,000 SGD). No binary-float arithmetic is used for ledger values (BigInt cents internally; persisted as Medusa bigNumber with raw_* companions).

Create-path validation (in-transaction). A create requires a non-deleted, non-archived cashflow_category and cash_location (and poc_person_id when supplied), and enforces category kind versus amount direction — an inflow category accepts capital_in_sgd only, an outflow category accepts capital_out_sgd only — on top of the inflow-XOR-outflow guard (the latter also a DB CHECK).

Race-safe reference seeds. cash_location and cashflow_category each carry a partial unique index on the active name (WHERE deleted_at IS NULL AND archived = false), and seeding uses INSERT … ON CONFLICT DO NOTHING, so concurrent boot/seed paths can't create duplicate active reference rows. Migration Migration20260616130000_finance_reference_unique.

Transfers deferred (v1). A cashflow_entry is single-leg — one cash_location, exactly one of capital_in_sgd / capital_out_sgd > 0 (the schema/CHECK is the guard). There is intentionally no double-entry / journal / paired-leg model in v1; a transfer between two cash locations is recorded as two separate entries.

Server-side response-shape convention. Finance route code defines local, hand-synced response shapes in modules/finance/types/api-shapes.ts rather than importing @tcg/shared-types, because medusa build (tsconfig rootDir = apps/server) forbids importing the shared-types source from outside apps/server. This mirrors the dca/ module's types/api-shapes.ts pattern.


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 Shipped.
batch_allocation order_line_item N:1 4 Shipped. Heart of DCA + profit.
consolidation_event product_variant N:1 4 Shipped.
order_status_event order N:1 4 Shipped.
sales_channel_config sales_channel 1:1 active 8C Shipped in finance/. Partial-unique on the active channel (one live policy per channel); archived rows coexist.
import_batch_contributor (person_id) finance/person N:1 (nullable) 8A Phase 8A activated — previously nullable text stub.
import_batch_fee (related_cashflow_id) finance/cashflow_entry N:1 (nullable) 8A Phase 8A activated — previously nullable text stub.
batch_adjustment (related_cashflow_id) finance/cashflow_entry N:1 (nullable) 8A Phase 8A activated — previously nullable text stub.
cashflow_entry order N:1 (nullable) 8A Active. related_order_id Module Link.
cashflow_entry import_batch N:1 (nullable) 8A Active. related_batch_id Module Link.

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 the cost change onto the consolidated output line as an append-only D5 epoch-ledger batch_adjustment (keyed (consolidation_source_id, consolidation_epoch)). This is the fixpoint-retry, advisory-locked path described under D5 cost propagation: it computes the transitive closure, serializes per-variant/per-item (advisory classids 1276/1277), takes row locks in the global events→outputs→items order, processes the graph in topological order, and rejects cycles via ConsolidationCycleError.

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 = completed. 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.

8.1 Phase 8C profitability (shipped)

Phase 8C makes the derivation above operator-facing. It ships the sales_channel_config fee-policy table (see §3) plus three read-only reporting endpoints and the config CRUD, all in the finance/ module. No new profit state is persisted — every figure is derived at query time from existing inputs (allocations, adjustments, refunds, escrow, fee config).

Endpoints under /admin/dashboard/finance/:

Endpoint Method Capability Returns
reports/order-profit GET view_finance Per-order profit = revenue − refunds − COGS − fees, with the fee_source tag per order.
reports/channel-profitability GET view_finance The same profit rolled up per sales channel.
reports/inventory-valuation GET view_finance Point-in-time on-hand inventory value.
sales-channel-config GET view_finance List active per-channel fee policies.
sales-channel-config POST manage_finance Upsert a channel's fee policy (upsertSalesChannelConfig).

Profit model (per order):

order_profit = revenue − refunds − COGS − fees
  • COGS comes from the DCA cost basis via getOrderLinesEconomics, which applies the same A1-temporal-correct per-allocation adjustment gate as computeOrderLineProfit (an allocation only picks up a system_recompute batch_adjustment when allocation.created_at < adjustment.applied_at) — so post-propagation sales are not double-counted.
  • Refunds are netted once at the order level from order.summary.refunded_total, never per-line, avoiding double-deduction.
  • Fees resolve in priority order, recorded as fee_source: escrow | configured | none:
    1. escrow — Shopee actual escrow settlement (commission + service + seller-transaction + actual-shipping + credit-card fees), used only when a live shopee_order_sync row exists for the order.
    2. configured — otherwise revenue × fee_pct + fixed_fee_sgd from the order's sales_channel_config.
    3. none — otherwise fees are 0.

Inventory valuation:

inventory_value = Σ (quantity_remaining × effective_cost_per_unit_sgd)

evaluated point-in-time via resolveUnsoldOnHand (consolidation-safe: it accounts for quantity_consolidated_out so consolidated lines are not double-counted).

Date ranges: report windows are SGT-local dates converted to UTC as a half-open [from, to) interval, so a day boundary belongs to exactly one period.

8.2 Phase 8D — monthly P&L + finance dashboard UI + exports (designed, not built)

Designed, not implemented

Phase 8D — a monthly P&L view, the finance dashboard UI surfacing the 8C reports, and CSV/PDF exports — is designed but not yet built. The design spec lives in the code repo at docs/superpowers/specs/2026-06-19-phase-8d-finance-dashboard-pnl-exports-design.md. No migrations or endpoints exist yet. Separately, Phase 8B (equity / stakeholder payouts) was dropped; the stakeholder / equity_event / stakeholder_payout entities sketched in §5.2 are no longer planned.


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 (planned — order ingestion not built; catalog-import only shipped #255/#257)
  • 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 Presence of any tcg_channel_listing row for the variant on that channel (#236). Note: coverage currently counts rows regardless of status — inactive/out_of_stock/paused listings still register as covered.
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_ordered − quantity_received) WHERE import_batch.status IN ('in_transit', 'partially_received') (units ordered but not yet received; payment is no longer a status)
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 / For Storage → completed; in-flight → in_transit / partially_received)
Paid import_batch.paid_at / total_sgd_paid (payment is tracked separately, not a status)
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 (date column; Phase 8A shipped)
Description cashflow_entry.description
Buyer cashflow_entry.buyer_name_snapshot (nullable free-text snapshot)
POC cashflow_entry.poc_person_idfinance/person (Phase 8A shipped)
Category cashflow_entry.category_idcashflow_category (Phase 8A shipped; 12 seeded rows)
Capital In (SGD) cashflow_entry.capital_in_sgd (Medusa bigNumber; raw_capital_in_sgd JSONB companion)
Capital Out (SGD) cashflow_entry.capital_out_sgd (Medusa bigNumber; raw_capital_out_sgd JSONB companion)
Cash Location cashflow_entry.cash_location_idcash_location (Phase 8A shipped; 10 seeded rows)

10.5 Finance, PnL

Mostly derived. The Finance sheet's Cash section maps to finance/cash_location + cashflow_entry (Phase 8A shipped — see §3). The Equity / Stakeholder / Manpower / Payee sections map to deferred Phase 8B/8C entities (see §5.2). 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 cross-connector reshape (#236).
  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.