Skip to content

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:

  1. Log a Purchase Order — no admin API, no UI; the import_batch rows exist in the DB but there is no way to create them outside psql / seed scripts.
  2. See costs recalculate when fees arriverecomputeBatchCosts is a workflow, but nothing triggers it automatically. A fee row inserted via raw SQL leaves the per-unit costs stale.
  3. Hand off received units to Medusa inventory — the import_batch_item.qty_received column is set in code but never propagates to inventory_level.stocked_quantity. Units stay "bought but not sellable."
  4. 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_adjustment path 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:

  1. Purchase Order Module (Imports) — operators can create/edit/close POs from the dashboard.
  2. DCA for Imports — per-unit landed cost recalculates automatically when fees / adjustments change.
  3. 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_fee insert / update / delete
  • batch_adjustment insert

…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:

  1. Operator opens the closed PO and adds the fee row.
  2. The Decision-1 subscriber fires recomputeBatchCosts(batch_id).
  3. For each import_batch_item on the batch, effective_cost_per_unit_sgd is rewritten.
  4. For every already-sold unit (any batch_allocation row pointing at the batch's items), the recompute workflow auto-creates a batch_adjustment with reason=forgotten_fee and cost_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 historical batch_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_fee row
  • At least one import_batch_contributor row
  • 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_allocation after creation. That column is the audit-faithful snapshot of "what did the books say at sale time." Post-hoc corrections go through batch_adjustment rows; the recompute workflow handles the math.
  • Don't store derived profit anywhere. computeOrderLineProfit is query-time on purpose. A profit column would drift the moment a batch_adjustment lands and would be impossible to keep correct under concurrent recomputes.
  • Don't call recomputeBatchCosts from 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: boolean column 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_config into 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:

  1. 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 Medusa inventory_level.stocked_quantity and are sellable.
  2. Goal #2 working end-to-end: operator adds a fee to a PO via the dashboard; recomputeBatchCosts fires automatically; the line items' effective_cost_per_unit_sgd reflects the new fee.
  3. 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.
  4. Slice G parity test passes: the chosen Q1 2026 reference batch, when re-imported through the new admin flow, produces effective_cost_per_unit_sgd matching 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 the recompute-batch-costs workflow.
  • Dashboard surfaces to create: apps/dashboard/app/(shell)/imports/ (new section, sibling to /orders and /connectors).