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_entry — finance/ 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_idcolumns, no tenant routing, no subscription tables. The merchant's identity is environment config, not data. - Configurable over hardcoded. Every business-policy knob — channel fee %, item margin, cost-allocation method, refund handling, listing visibility — lives in editable DB tables. An admin edits these via UI without code changes. Hardcoding the merchant's specific rules is forbidden.
- Module Links over foreign keys to Medusa internals. Custom tables connect to Medusa entities (
product_variant,order,stock_location,customer_group,sales_channel) viadefineLink()only — never raw FK to Medusa internal table names. This insulates the schema from Medusa upgrades pulled by Dependabot. - Order is the source of truth for "what happened to this sale." Revenue and COGS reverse on goods-returned refunds. Profit is derived from inputs at query time, never stored as a column. Adjustments are first-class events.
- TCG semantics live behind a clean module boundary. A future non-TCG merchant could disable
src/modules/tcg/and run Medusa's vanilla product model.
2. What Medusa already provides¶
The platform uses these Medusa primitives unchanged. Custom modules link to them via Module Links.
| Domain | Medusa entity | Purpose |
|---|---|---|
| Catalog | product, product_variant |
Base product structure (extended in Phase 2 by tcg_variant_metadata via Module Link). |
| Inventory | inventory_item, inventory_level |
Stock-by-location (created on batch arrival, never on pre-order). |
| Locations | stock_location |
Warehouse / Store / Event-A — base structure for transfers and event flows. |
| Orders | order, order_line_item, order_payment_collection, order_fulfillment |
Order lifecycle, payments, fulfillments. Channel attribution via sales_channel_id. |
| Channels | sales_channel |
Shopee / Lazada / Telegram / POS / Manual / future TikTok. |
| Pricing | price_set, price_rule, customer-group rules |
Per-channel and per-tier prices, plus quantity-based bulk pricing. |
| Customer tiers | customer_group |
Partner / Streamer / Standard / Shopee / Lazada price segments. |
| Region | region |
SG with SGD currency. |
| Workflows | Workflow framework + hooks | All custom orchestration runs through Medusa workflow primitives. |
3. Existing custom tables (shipped / active)¶
These are already in the database; this section documents them for completeness.
| Module | Table | Owner | Notes |
|---|---|---|---|
tcg/ |
tcg_variant_metadata |
Phase 2 | 1:1 link to product_variant; condition, foil, grading, language, set, etc. |
tcg/ |
tcg_serialized_item |
Phase 2 | N:1 to product_variant for graded slabs and premium raws. |
tcg/ |
tcg_channel_listing |
Phase 3 | 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 product — LINK_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/paid → in_transit and arrived/for_storage/closed → completed.) |
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_sourceaddsconsolidation_source_idand the initial partial-unique on(consolidation_source_id)(IDX_batch_adjustment_consolidation_source_uniq).Migration20260617000000_batch_adjustment_consolidation_epochaddsconsolidation_epochand 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.id — unique 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 retryable — tryClaimCommit 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.
6. Module Links inventory¶
Every cross-module relationship is realized via Medusa's defineLink(). No raw FKs point at Medusa internal tables.
| Custom side | Medusa side | Link type | Phase | Notes |
|---|---|---|---|---|
tcg_variant_metadata |
product_variant |
1:1 | 2 | Already shipped. |
tcg_serialized_item |
product_variant |
N:1 | 2 | Already shipped. |
tcg_channel_listing |
product_variant |
N:1 | 3 | Already shipped. |
shopee_order_sync |
order |
1:1 | 3 | Already shipped. |
import_batch_item |
product_variant |
N:1 | 4 | 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 ascomputeOrderLineProfit(an allocation only picks up asystem_recomputebatch_adjustmentwhenallocation.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:escrow— Shopee actual escrow settlement (commission + service + seller-transaction + actual-shipping + credit-card fees), used only when a liveshopee_order_syncrow exists for the order.configured— otherwiserevenue × fee_pct + fixed_fee_sgdfrom the order'ssales_channel_config.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_id → finance/person (Phase 8A shipped) |
| Category | cashflow_entry.category_id → cashflow_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_id → cash_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 module —
stocktake_session+stocktake_linefor periodic physical counts. Master Inventory'sActual Stocktakecolumn suggests this exists informally. Phase 5 (admin UI) is the natural home. - Box composition / kits —
Contents,num_packs,cards_per_packcolumns. Decision deferred per user direction; revisit when Phase 7 (Event/POS) needs box-opening flows. - Person model — when Phase 8 introduces the unified
personentity, decide whether Ivan-as-stakeholder, Ivan-as-contributor, Ivan-as-payee are one row or separate. Affectsimport_batch_contributor.person_idresolution. - Per-batch contributor repayment policy — pooled vs. batch-specific repayment, schedules, partial settlement. Phase 8.
- Cash discrepancy handling — Finance sheet's
Discrepancies -$10,959.45line. Tolerated drift or system-enforced reconciliation? Phase 8 policy. - Channel-fee complexity —
sales_channel_config.fee_pctis single-value. If Shopee's commission scheme later requires tiered or category-specific rates, extend to asales_channel_fee_ruletable.
12. Implementation order recommendation¶
supplier,import_batch,import_batch_item,import_batch_fee,import_batch_contributor(skeleton tables — let admin start recording imports).batch_allocation+ Module Link toorder_line_item(cost flows to orders → unblocks profit derivation).batch_adjustment+ therecomputeBatchCostsworkflow (cascading recompute).consolidation_event,consolidation_source, theconsolidateBatchesworkflow.order_status_event, the columns added toorder.metadata.sales_channel_config.tcg_channel_listingcross-connector reshape (#236).- Refund workflow (
refundOrder). - Admin UI surfaces (Phase 5 territory).
Phase 8 entities deferred. CSV migration (FR-15) runs after step 4 against staging to validate batch_allocation and DCA arithmetic against the spreadsheet.
Links¶
- Project Plan
- PRD
- Phase 2 Findings — TCG metadata + serialized item validation.
- Phase 3 Findings — Shopee connector lessons.
- tcg-platform CLAUDE.md — code-side orientation.