Phase 4 Wire-up Kickoff Plan — Imports (PO) + DCA + Optional Fees¶
Status: design locked 2026-05-15 — ready for PR sequencing
The Phase 4 skeleton (7 tables, cost-allocation strategies, recomputeBatchCosts workflow, computeOrderLineProfit service) shipped 2026-05-14 as PR #53. See Phase 4 Findings for what landed.
This wire-up plan covers the operator-facing layer that turns the skeleton into a usable Imports module. Implementation breakdown lives in Phase 4 Wire-up Implementation Plan.
Why a wire-up phase¶
Ivan's skeleton put the cost-engine and the schema in place but stopped at the service layer. After the skeleton merged, an operator still cannot:
- Log a Purchase Order — no admin API, no UI; the
import_batchrows exist in the DB but there is no way to create them outsidepsql/ seed scripts. - See costs recalculate when fees arrive —
recomputeBatchCostsis a workflow, but nothing triggers it automatically. A fee row inserted via raw SQL leaves the per-unit costs stale. - Hand off received units to Medusa inventory — the
import_batch_item.qty_receivedcolumn is set in code but never propagates toinventory_level.stocked_quantity. Units stay "bought but not sellable." - Capture late fees correctly — the merchant's actual workflow ("we may pay off the fees very late on and just want to log the Purchase Order first") needs an explicit
batch_adjustmentpath so already-sold units pick up the COGS delta.
The wire-up phase closes that gap.
Scope: the three stated goals¶
The merchant locked three goals during 2026-05-15 scoping:
- Purchase Order Module (Imports) — operators can create/edit/close POs from the dashboard.
- DCA for Imports — per-unit landed cost recalculates automatically when fees / adjustments change.
- Optional fees — POs can be logged (and even closed) before any fees are entered; late fees flow through correctly without manual rework on already-sold units.
These goals map to slices A–E + G below. Slice F (consolidation) and the other Phase 4 entities listed in data-model.md §4.2 (order_status_event, sales_channel_config, tcg_channel_listing.is_listed, the refund workflow split) are deferred — they are part of broader Phase 4 but do not block the three stated goals.
Design decisions¶
All three were locked via direct conversation with the merchant on 2026-05-15.
1. recomputeBatchCosts fires via Medusa subscriber (not from UI mutation handlers)¶
A subscriber listens to:
import_batch_feeinsert / update / deletebatch_adjustmentinsert
…and calls recomputeBatchCosts(batch_id) automatically. The batch_id is read from the changed row.
| Option considered | Decision |
|---|---|
| (A) Subscriber on the events ✓ | Framework-idiomatic. Future code paths (CSV importers, n8n workflows, admin tools) that touch the underlying tables get correct recomputation for free. |
| (B) Call from admin-UI mutation handlers | Rejected. Brittle — every new mutation path has to remember to call recompute, and the next contributor will forget. Couples cost-engine semantics to UI code. |
| (C) Both | Rejected. Double-fires on every UI mutation for no benefit; the subscriber would no-op-redundantly. |
This resolves the open question Ivan flagged in Phase 4 Findings §6.
2. Inventory hand-off is a manual operator button (not status-transition-driven)¶
A "Receive into inventory" button on the PO detail page pushes import_batch_item.qty_received into inventory_level.stocked_quantity for the linked Medusa variant. The button is gated on PO status being at least arrived.
| Option considered | Decision |
|---|---|
| (A) Manual button ✓ | TCG inventory is high-value; miscounts and condition surprises are expensive. The operator's spreadsheet workflow today has a deliberate review step before stuff goes live — the button preserves that. |
(B) Auto-fire on arrived → for_storage subscriber |
Deferred. Easy to add later (one-line subscriber addition + a sales_channel_config.auto_receive toggle) once the operator confirms the manual flow is tedious. |
(C) Auto-fire on arrived |
Rejected. Earliest hand-off; lets units in transit be sold before they are physically on shelves. |
The receive workflow is idempotent: clicking twice does not double-stock the warehouse (it writes the absolute qty_received, not a delta).
3. Fee-less PO close is allowed; "Fees pending" badge surfaces uncertainty¶
A PO can transition all the way to closed with zero import_batch_fee rows. The PO list view surfaces closed · fees pending as a visual badge so operators do not forget to log fees later.
When the late fee arrives weeks/months after close:
- Operator opens the closed PO and adds the fee row.
- The Decision-1 subscriber fires
recomputeBatchCosts(batch_id). - For each
import_batch_itemon the batch,effective_cost_per_unit_sgdis rewritten. - For every already-sold unit (any
batch_allocationrow pointing at the batch's items), the recompute workflow auto-creates abatch_adjustmentwithreason=forgotten_feeandcost_delta_per_unit = new_cost − old_cost. This is what makes the late-fee story end-to-end correct — already-sold units' COGS picks up the delta without rewriting historicalbatch_allocation.cost_per_unit_at_allocation.
This matches the merchant's stated workflow verbatim: "we may pay off the fees very late on and just want to log the Purchase Order first."
| Option considered | Decision |
|---|---|
| (A) Yes, fees optional + badge ✓ | Matches merchant workflow. Late-fee batch_adjustment(reason=forgotten_fee) keeps audit fidelity. |
| (B) Warn but allow | Rejected. Confirmation dialogs are noise once the operator knows what they are doing. The badge already surfaces uncertainty without blocking. |
| (C) No, must mark "no fees expected" before close | Rejected. Adds explicit friction to the merchant's stated late-fee workflow. |
Slice scope¶
| Slice | Goal | What it covers | Size |
|---|---|---|---|
| A | #1 PO module | Admin API routes (CRUD on PO header + line items + fees + contributors), dashboard /imports list + detail + create flow |
Large |
| B | #3 optional fees | "Fees pending" badge logic; late-fee batch_adjustment(reason=forgotten_fee) auto-creation inside the recompute workflow |
Small |
| C | #1 PO module | PO → Medusa inventory_level hand-off via "Receive into inventory" button (manual, per Decision 2) |
Medium |
| D | #2 DCA | Medusa subscriber on import_batch_fee + batch_adjustment events, calling recomputeBatchCosts(batch_id) (per Decision 1) |
Small |
| E | #2 DCA | manual allocation strategy implementation + per-line manual-cost override UI surface |
Small |
| G | exit criterion | CSV → DCA parity test: re-import one Q1 2026 reference batch from the spreadsheet, assert effective_cost_per_unit_sgd matches the spreadsheet's "Total Cost Per Unit (SGD)" column |
Small |
Deferred (out of scope for this wire-up)¶
| Item | Reason | Future home |
|---|---|---|
Slice F: consolidation (consolidation_event + consolidation_source tables + consolidateBatches workflow + reverse-cascade) |
Not in the three stated goals. Drains fragmented batch lines into weighted-average output lines — useful but not load-bearing. | Phase 4.5 / Phase 7 |
order_status_event table |
Cross-channel status transition log; orthogonal to PO + DCA. | Phase 7 (operator observability) |
sales_channel_config table |
Per-channel fee% / scheme / default shipping; lives next to Phase 5 channel listings rather than buy-side DCA. | Phase 5 follow-up or Phase 7 |
tcg_channel_listing.is_listed extension |
Per-channel toggle; sell-side, not buy-side. | Phase 5 follow-up |
refundOrder workflow (goods-returned vs money-only branching) |
Functionally Phase 4 (controls when batch_allocation.is_reversed flips), but operationally needs Phase 3 refund hook + Phase 5 UI surface. The skeleton's is_reversed column is in place; nothing flips it yet. |
Phase 4.5 (after this wire-up) |
paid_tax_notes text column |
Operator-facing nicety; revisit if the yes_taxless / no / paid enum proves insufficient in real use. |
Operational follow-up |
Open question deferred to Slice G¶
Which Q1 2026 batch is the spreadsheet-parity reference? Ivan's findings flagged this. Candidate criteria (per Phase 4 Findings §6):
- Closed status
- At least one
import_batch_feerow - At least one
import_batch_contributorrow - No consolidation events (consolidation is deferred, so a batch that needed it would not parity-test cleanly)
Pick happens at Slice G time. Operator can scan Q1 2026 ExzenTCG - Imports.csv + ... - Additional Import Fees.csv once and call out the chosen batch number.
PR sequence¶
| PR | Slice | Scope | Risk |
|---|---|---|---|
| PR-1 | D | Recompute subscriber + late-fee batch_adjustment auto-creation inside recomputeBatchCosts |
Low — purely additive to skeleton; integration tests already cover recompute math |
| PR-2 | A (data) | Admin API: CRUD on PO header + line items + fees + contributors + status transitions; Zod schemas; integration tests | Medium — many routes, all under apps/server/src/api/admin/dashboard/imports/ |
| PR-3 | A (UI list) | Dashboard /imports list page: TanStack Table, status filter, "Fees pending" badge |
Low — mirrors /orders patterns |
| PR-4 | A (UI detail) | Dashboard /imports/[id] detail page: header, line items table, fees section, contributors section, status-transition buttons |
Medium — many surfaces, similar to Phase 5 /orders/[id] |
| PR-5 | A (UI create) | Dashboard /imports/new create-PO flow: header form, line-items inline table, optional fees + contributors sections |
Medium — RHF + Zod with nested arrays |
| PR-6 | C | "Receive into inventory" button + POST /admin/dashboard/imports/:id/receive endpoint; idempotent write into inventory_level.stocked_quantity |
Medium — touches Medusa inventory; needs careful subscriber-vs-direct decision (see implementation plan) |
| PR-7 | E | manual allocation strategy + per-line manual-cost override input in the line-items UI |
Small |
| PR-8 | G | CSV parity test fixture + integration test; one chosen Q1 2026 batch as the reference; assertion on effective_cost_per_unit_sgd matching the spreadsheet |
Small |
| PR-9 | docs | Wire-up findings doc + runbook updates (admin steps for logging a PO, adding late fees, receiving into inventory) | Zero — docs |
Slice B does not get its own PR — its scope (badge + late-fee adjustment) is split between PR-1 (the adjustment auto-creation) and PR-3 (the badge UI).
Anti-patterns (do not re-introduce)¶
- Don't write to
batch_allocation.cost_per_unit_at_allocationafter creation. That column is the audit-faithful snapshot of "what did the books say at sale time." Post-hoc corrections go throughbatch_adjustmentrows; the recompute workflow handles the math. - Don't store derived profit anywhere.
computeOrderLineProfitis query-time on purpose. A profit column would drift the moment abatch_adjustmentlands and would be impossible to keep correct under concurrent recomputes. - Don't call
recomputeBatchCostsfrom admin route handlers. Decision 1 picks subscribers; route handlers should mutate the underlying row and let the event-bus do the rest. Exception: the manual "Force recompute" button on the PO detail page is allowed and explicitly intended. - Don't auto-fire inventory hand-off from a status-transition subscriber. Decision 2 keeps the receive step explicit. If the operator workflow becomes "click receive every time" → tedious, the right fix is a config flag, not a silent subscriber.
- Don't introduce a
fees_expected: booleancolumn to gate PO close. Decision 3 rules this out — operator can close with zero fees and add them later; the badge surfaces the state without schema change. - Don't move
consolidation_event/order_status_event/sales_channel_configinto this wire-up. They are explicitly deferred. If a slice's design starts pulling them in, that is a signal to stop and re-scope.
Exit criteria¶
The wire-up phase closes when:
- Goal #1 working end-to-end: operator creates a PO from the dashboard, adds line items, transitions through
draft → ordered → paid → in_transit → arrived, and clicks "Receive into inventory"; units appear in Medusainventory_level.stocked_quantityand are sellable. - Goal #2 working end-to-end: operator adds a fee to a PO via the dashboard;
recomputeBatchCostsfires automatically; the line items'effective_cost_per_unit_sgdreflects the new fee. - Goal #3 working end-to-end: operator closes a PO with no fees; PO list shows "Fees pending" badge; operator adds a fee weeks later; a
batch_adjustment(reason=forgotten_fee)row is auto-created for any already-sold units on that batch. - Slice G parity test passes: the chosen Q1 2026 reference batch, when re-imported through the new admin flow, produces
effective_cost_per_unit_sgdmatching the spreadsheet's "Total Cost Per Unit (SGD)" column.
Implementation references¶
- Phase 4 Wire-up Implementation Plan — PR-by-PR file lists, test scope, and step-by-step.
- Phase 4 Findings — what the skeleton shipped + the three open questions this plan resolves.
- Data Model — column-level reference for the existing 7 tables + the deferred entities.
- Server module:
apps/server/src/modules/dca/—service.ts,services/cost-allocation-strategies.ts, the seven models, and therecompute-batch-costsworkflow. - Dashboard surfaces to create:
apps/dashboard/app/(shell)/imports/(new section, sibling to/ordersand/connectors).