Phase 4: Costing / DCA — Findings (skeleton)¶
Status: In progress ⏳ (skeleton merged 2026-05-14; consolidation, order-status, sales-channel-config, refund workflow, and admin UI still outstanding) Predecessor: Data Model (serves as Phase 4 plan — no separate kickoff doc) Exit criterion target: "DCA output matches the existing spreadsheet for a reference batch."
1. Purpose + method¶
Stand up the cost-tracking spine of the platform: import batches, fees, contributors, line items, adjustments, allocations, and the cost-allocation engine. The downstream profit derivation (revenue − COGS at query time) hangs off batch_allocation rows produced when orders consume batch stock.
Phase 4 is intentionally split into two passes:
- Skeleton (this commit) — tables, models, services, migrations, unit + integration tests, no admin UI. Confirms the data model + cost-allocation strategies are correct in isolation.
- Wire-up (next) — consolidation events, order-status event log, sales-channel-config, refund workflow, and the Phase-5 admin UI surfaces that drive operator workflows.
The skeleton went in via PR #53 (merge commit 6a17240 on main).
2. What shipped¶
2.1 Tables (7 new)¶
All under src/modules/dca/ and registered in apps/server/medusa-config.ts.
| Table | Source migration | Purpose |
|---|---|---|
supplier |
Migration20260508012114 |
Admin-managed supplier list. |
import_batch |
Migration20260508012114 |
Per-purchase header (one purchase event). |
import_batch_item |
Migration20260508012114 |
One line per SKU within a batch — the cost-tracking + allocation unit. |
import_batch_fee |
Migration20260508012114 |
Per-batch fees with typed fee_type enum. |
import_batch_contributor |
Migration20260508012114 |
Per-batch contributors with name_snapshot (Phase 8 person not yet introduced). |
batch_allocation |
Migration20260508021023 |
Heart of DCA + profit. One row per (order line, source batch line, quantity); cost is frozen at allocation. Indexed on order_line_item_id for profit queries (Migration20260508030000_batch_allocation_order_line_item_id_idx). |
batch_adjustment |
Migration20260508021023 |
Unified post-creation correction surface with typed reason enum. |
FK constraints stay scoped within DCA tables only — no destructive DROPs on Medusa core or other modules' tables (mitigation for the Phase 3 migration-generator gotcha).
2.2 Module Links (2 new)¶
import_batch_item.variant_id→ Medusaproduct_variant(N:1).batch_allocation.order_line_item_id→ Medusaorder_line_item(N:1; backed by an additional plain text column for direct WHERE queries without traversing the link join table).
2.3 Enums (typed; matches data-model.md exactly)¶
ImportBatchStatus—draft | ordered | paid | in_transit | arrived | for_storage | closedCostAllocationMethod—proportional_by_value | proportional_by_quantity | equal_split | manualPaidTax—yes_taxless | no | paidImportBatchFeeType—shipping_overseas | shipping_local | gst | customs_duty | bank_fee | fx_loss | otherBatchAdjustmentReason—cost_correction | forgotten_fee | fx_relock | supplier_shortfall | supplier_refund | write_off | quantity_correction | customer_return | return_cost_difference
2.4 Services + workflows¶
DcaModuleService.recomputeBatchCosts(batch_id)— re-runs cost-allocation against current fees + adjustments and rewriteseffective_cost_per_unit_sgdfor every line on the batch. Thin workflow wrapper atsrc/workflows/recompute-batch-costs.tsdelegates to the service.sharedContextthreads through child calls so it composes inside a transaction.DcaModuleService.computeOrderLineProfit({ order_line_item_id, unit_price, quantity, refunded_amount })— derives revenue, COGS (frozen allocation cost +batch_adjustmentdeltas), and profit at query time. Profit is never stored. Reversed allocations contribute zero COGS.src/modules/dca/services/cost-allocation-strategies.ts— pure functionsallocateProportionalByValue,allocateProportionalByQuantity,allocateEqualSplit. Unit-tested in isolation.
2.5 Tests¶
- 27 DCA tests pass: service unit tests, cost-allocation-strategy unit tests, and 3
recomputeBatchCostsintegration scenarios (forgotten-fee redistribution, cost_correction propagation, supplier_shortfall via reduced received quantity). - 5
computeOrderLineProfitintegration scenarios cover: no allocations, frozen cost, deltas, goods-returned refund (revenue=0, COGS=0, profit=0), money-only refund (COGS unchanged, profit=−COGS).
3. What is not in the skeleton¶
Carried into a follow-up phase (or Phase 5 ops UI):
consolidation_event+consolidation_sourcetables +consolidateBatchesworkflow — drains fragmented batch lines into a single weighted-average line. Spec exists in data-model §4.2 + §7.2.order_status_event— cross-channel status transition log. Spec in data-model §4.2 + §9.sales_channel_config— per-channelfee_pct/fee_scheme_notes/default_shipping_option_id. Spec in data-model §4.2.tcg_channel_listing.is_listedextension — replaces the spreadsheet'sListedcolumn with per-channel granularity.refundOrderworkflow — goods-returned vs money-only branching (Phase 4 belongs functionally; needs Phase 3 refund hook + Phase 5 UI surface to operate).- Admin UI surfaces — import-batch creation/edit, fee entry, contributor entry, batch view with allocation breakdown, manual
batch_adjustmententry. These belong in Phase 5. - CSV → DCA migration test — re-importing one historical Q1 2026 reference batch from the spreadsheet and asserting the engine output matches the spreadsheet's "Total Cost Per Unit (SGD)" column. This is the formal exit criterion for Phase 4.
4. Gaps / concerns surfaced during the skeleton¶
- Monorepo path move (PR #51) reshaped Phase 4 file locations.
feat/phase-4-dca-skeletonwas originally rooted atsrc/modules/dca/; after the merge frommain, everything moved toapps/server/src/modules/dca/. Git's rename detection handled it; no content changes. All future Phase 4 paths in this venture's docs assumeapps/server/. tsc --noEmitrejected string literals where enum members are required. Four DCA test fixtures used"shipping_local"/"cost_correction"etc. instead of the typed enum members — fine pre-monorepo (the workspace tsconfig was looser), broken post-monorepo. Fixed in commitd1e3e43; an enum import was missing inrecompute-batch-costs.spec.ts. Going forward: prefer enum members in test fixtures from the start.seed.tsdepends on.medusa/types/query-entry-points. Generated byyarn workspace @tcg/server buildahead oftsc. CI runsMedusa buildthenType check, so this works in CI; runningtsc --noEmitdirectly on a fresh checkout fails until you build once. Documented in.github/workflows/ci.ymlstep ordering.- Pre-existing test failure note from commit
cf888fe("rejects duplicate batch_number" — Medusa's mapped error message format) was resolved in commit2be51f8by broadening the regex. Closed.
5. Phase 4 decisions recorded¶
- Profit is derived, not stored. A column for profit would invariably drift after a
batch_adjustmentlands. Query-time derivation viabatch_allocation.cost_per_unit_at_allocation+ sum of relevantbatch_adjustment.cost_delta_per_unitis authoritative. Materialized views are a Phase 9 caching optimization if dashboards demand it. batch_allocation.cost_per_unit_at_allocationis frozen at the moment of allocation. Post-hoc adjustments are layered on viabatch_adjustmentrather than rewriting historical allocations. This preserves audit fidelity (you can always reconstruct what the books said at sale time).batch_allocation.is_reversed = truezeros that row's COGS contribution. Goods-returned refunds set it; money-only refunds do not (the cost stays in the books — that's the real loss).batch_allocation.order_line_item_idis stored as a nullable text column AND a Module Link. The duplicate column exists socomputeOrderLineProfitcanWHEREdirectly without traversing the link join table; indexed for that query path. Module Link still defines the relationship for Medusa's RemoteJoiner and admin queries.- No raw FK from
import_batch_contributorto apersontable —person_idis nullable text,name_snapshotcarries the display name until Phase 8 introducesperson. Same pattern forbatch_adjustment.related_cashflow_idandimport_batch_fee.related_cashflow_id.
6. Open questions for the wire-up phase¶
recomputeBatchCoststrigger points. Perdata-model.md §7.1, recompute should fire on insert/update/delete ofimport_batch_feeand on insert ofbatch_adjustment. Currently the workflow is callable on demand only — no Medusa subscriber wires it to those events yet. Decision needed: Medusa subscriber per event, or call recompute from the admin-UI mutation handlers as Phase 5 lands?- Consolidation reverse-cascade. When a source batch line's
effective_cost_per_unit_sgdchanges after it has been consolidated, do we propagate the delta into the consolidated output line automatically (via abatch_adjustment(reason = cost_correction)on the output), or surface it as a queue item for the operator?data-model.md §7.1step 6 implies automatic; needs confirmation under operator workflow. - CSV migration target batch. Pick one historical Q1 2026 batch from
Q1 2026 ExzenTCG - Imports.csv+... - Additional Import Fees.csvto be the reference for the spreadsheet-parity exit-criterion test. Candidate: any closed batch with at least one fee and one contributor, no consolidation events. paid_taxenum surface in admin UI. Spreadsheet's free-text "Paid Tax" column collapses cleanly toyes_taxless | no | paid, but operators may want notes. Decide whether to add apaid_tax_notestext column.
Links¶
- Data Model — Phase 4 spec; this findings doc just records what landed and what hasn't.
- PR #53 — Phase 4 DCA skeleton
- Phase 3 Findings — predecessor; SHIPPED lifecycle gap still blocks downstream cost capture from real Shopee orders.
- tcg-platform CLAUDE.md — code-side orientation.