Skip to content

Phase 4 Wire-up Implementation Plan — Imports (PO) + DCA + Optional Fees

Status: implemented — see Phase 4 Wire-up Findings for closeout

Design decisions live in Phase 4 Wire-up Kickoff Plan. Skeleton context is in Phase 4 Findings. The PR sequence below has shipped; closeout details and staging smoke results live in Phase 4 Wire-up Findings.

PR sequence below. Each PR is intended to be reviewable in ~30 min and merge independently — later PRs depend on earlier ones for data plumbing but never break older PRs' code paths.

PR-1 — Recompute subscriber + late-fee batch_adjustment auto-creation

Goal: every fee mutation or adjustment insert triggers recomputeBatchCosts automatically; already-sold units pick up the cost delta via auto-created batch_adjustment(reason=forgotten_fee).

Files

File Change
apps/server/src/modules/dca/subscribers/recompute-on-fee-change.ts (new) Listens to import_batch_fee.created, import_batch_fee.updated, import_batch_fee.deleted → calls recomputeBatchCosts(batch_id)
apps/server/src/modules/dca/subscribers/recompute-on-adjustment.ts (new) Listens to batch_adjustment.created → calls recomputeBatchCosts(batch_id)
apps/server/src/modules/dca/service.ts Inside recomputeBatchCosts, after rewriting effective_cost_per_unit_sgd: for each batch line, query batch_allocation rows pointing at it; for any that exist, insert a batch_adjustment(reason=forgotten_fee, cost_delta_per_unit = new − old) row. Suppress recursive recompute — the adjustment subscriber must skip its own forgotten_fee rows (tag them with an internal source flag) or use a sharedContext marker to break the loop.
apps/server/src/modules/dca/workflows/recompute-batch-costs.ts Pass a suppress_subscriber_recursion: true flag down so the workflow's own writes do not retrigger the subscriber chain
apps/server/src/modules/dca/__tests__/recompute-late-fee.integration.spec.ts (new) Scenario: PO has 2 line items, 1 sold (1 batch_allocation row), 0 fees → close → add fee → assert batch_adjustment(reason=forgotten_fee) row exists for the sold unit with correct delta
apps/server/src/modules/dca/__tests__/recompute-subscriber.unit.spec.ts (new) Mock-based: fee CUD events fire → recompute called with right batch_id; adjustment events fire → recompute called; recursive recompute (subscriber's own writes) does not infinite-loop

Decisions baked in

  • Loop prevention: the existing recomputeBatchCosts does not itself emit fee events, but it does insert batch_adjustment rows in the new path. The adjustment subscriber must skip recompute when the adjustment's reason=forgotten_fee (those are recompute's own output, not external input). Cleanest is a source column on batch_adjustment with values operator (default) vs system_recompute — the subscriber filters on source=operator.
  • Subscriber registration: Medusa picks up subscribers from apps/server/src/modules/dca/subscribers/*.ts automatically; no config edit needed.

Test scope

Scenario Expected
Add fee to PO with zero allocations Recompute fires; effective_cost_per_unit_sgd updates; no batch_adjustment rows created (nothing to compensate)
Add fee to PO with 1 allocation Recompute fires; effective_cost_per_unit_sgd updates; 1 batch_adjustment(reason=forgotten_fee, source=system_recompute) row inserted
Add operator batch_adjustment(reason=cost_correction) Recompute fires; subsequent system_recompute adjustments fire correctly without infinite loop
Delete a fee Recompute fires; deltas are negative

PR-2 — Admin API: PO + line items + fees + contributors

Goal: every operator action a future dashboard needs is exposed as an HTTP endpoint.

Routes

Method + URL Handler purpose
GET /admin/dashboard/imports List POs with header columns: id, batch_number, status, supplier, currency, invoice_amount_original, total_sgd_paid, fee_count, has_pending_fees_flag, created_at
POST /admin/dashboard/imports Create draft PO (header only; supplier_id, currency, invoice_amount_original, cost_allocation_method, paid_tax, remarks)
GET /admin/dashboard/imports/:id Detail: header + line items + fees + contributors + summary (sum of fees, total landed cost, allocation method)
PATCH /admin/dashboard/imports/:id Header edits (supplier, currency, amounts, allocation method, paid_tax, remarks)
POST /admin/dashboard/imports/:id/transition Body: { status } — enforces enum transition rules (draft → ordered → paid → in_transit → arrived → for_storage → closed); rejects backward/skip transitions unless body has force=true
POST /admin/dashboard/imports/:id/line-items Add import_batch_item (variant_id, quantity, unit_cost_in_currency, condition, foil, language, set, etc.)
PATCH /admin/dashboard/imports/:id/line-items/:itemId Edit existing item (quantity, unit cost, qty_received)
DELETE /admin/dashboard/imports/:id/line-items/:itemId Remove item (rejected if any batch_allocation row references it — that would orphan sold-unit COGS)
POST /admin/dashboard/imports/:id/fees Add fee (fee_type, amount_sgd, amount_original, currency, paid_at, notes)
PATCH /admin/dashboard/imports/:id/fees/:feeId Edit fee
DELETE /admin/dashboard/imports/:id/fees/:feeId Remove fee
POST /admin/dashboard/imports/:id/contributors Add contributor (name_snapshot, contribution_amount_sgd, capital_share_pct)
DELETE /admin/dashboard/imports/:id/contributors/:contribId Remove contributor
POST /admin/dashboard/imports/:id/recompute Manual "Force recompute" button — calls recomputeBatchCosts(batch_id) synchronously and returns updated line items
GET /admin/dashboard/suppliers List suppliers (for the PO create form's supplier dropdown)
POST /admin/dashboard/suppliers Create supplier (name, country, notes)

Files

  • apps/server/src/api/admin/dashboard/imports/route.ts (new) — GET list + POST create
  • apps/server/src/api/admin/dashboard/imports/[id]/route.ts (new) — GET detail + PATCH header
  • apps/server/src/api/admin/dashboard/imports/[id]/transition/route.ts (new)
  • apps/server/src/api/admin/dashboard/imports/[id]/line-items/route.ts (new) — POST add
  • apps/server/src/api/admin/dashboard/imports/[id]/line-items/[itemId]/route.ts (new) — PATCH + DELETE
  • apps/server/src/api/admin/dashboard/imports/[id]/fees/route.ts (new) — POST add
  • apps/server/src/api/admin/dashboard/imports/[id]/fees/[feeId]/route.ts (new) — PATCH + DELETE
  • apps/server/src/api/admin/dashboard/imports/[id]/contributors/route.ts (new) — POST add
  • apps/server/src/api/admin/dashboard/imports/[id]/contributors/[contribId]/route.ts (new) — DELETE
  • apps/server/src/api/admin/dashboard/imports/[id]/recompute/route.ts (new) — POST
  • apps/server/src/api/admin/dashboard/suppliers/route.ts (new) — GET + POST
  • packages/shared-types/src/imports.ts (new) — full DTO surface: ImportBatchListItem, ImportBatchDetail, ImportBatchCreateRequest, ImportBatchPatchRequest, LineItemCreateRequest, FeeCreateRequest, ContributorCreateRequest, TransitionRequest, SupplierCreateRequest
  • apps/server/src/api/admin/dashboard/imports/__tests__/*.integration.spec.ts (new) — one file per route group, covering happy path + permission gate + validation errors + status-transition rules

Decisions baked in

  • Transition gating: POST /transition validates against an in-handler state machine; backward transitions and skip-by-two require force=true query/body (operator override).
  • Delete-line-item refuses if allocated: a line item with any batch_allocation rows pointing at it cannot be deleted via the API — operator gets a 409 with the allocation count. Sold-unit COGS must not orphan.
  • Suppliers as a flat list: no separate supplier-management page in this wire-up; the PO create form has a dropdown sourced from GET /admin/dashboard/suppliers + an inline "Add new supplier" affordance backed by the POST endpoint.
  • Authn: all routes go through the same admin-session middleware Phase 5 set up (@medusajs/admin-shared). Roles: existing tcg_inventory_manager + tcg_finance roles can mutate; tcg_csr is read-only on these routes (table change in user_role not needed if the permission check is in the handler).

PR-3 — Dashboard /imports list page

Goal: operator sees every PO at a glance; status filter; "Fees pending" badge surfaces uncertainty.

Files

  • apps/dashboard/app/(shell)/imports/page.tsx (new) — server component, calls GET /admin/dashboard/imports via the existing apiFetch helper
  • apps/dashboard/app/(shell)/imports/imports-table.tsx (new) — client component using TanStack Table (matches /orders patterns)
  • apps/dashboard/app/(shell)/imports/types.ts (new) — re-exports from @tcg/shared-types/imports
  • apps/dashboard/components/sidebar.tsx — add "Imports" entry under existing sections (with the appropriate comingSoon: false flip once the page lands)

Columns

Column Source
Batch # batch_number
Status status enum → colored pill
Supplier supplier.name
Invoice total invoice_amount_original + original_currency
Total SGD total_sgd_paid
Fees fee_count (link to detail) + Fees pending badge if status=closed && fee_count === 0
Items item_count
Created created_at (relative)

Filters

  • Status dropdown (single-select)
  • Supplier dropdown (sourced from GET /admin/dashboard/suppliers)
  • "Fees pending" toggle (boolean filter on has_pending_fees_flag)
  • Text search on batch_number + supplier.name

PR-4 — Dashboard /imports/[id] detail page

Goal: operator inspects + edits every facet of a PO; transitions through statuses; force-recomputes if needed.

Files

  • apps/dashboard/app/(shell)/imports/[id]/page.tsx (new) — server component fetch
  • apps/dashboard/app/(shell)/imports/[id]/header-section.tsx (new) — supplier, status, dates, currency, amounts, transition buttons
  • apps/dashboard/app/(shell)/imports/[id]/line-items-section.tsx (new) — inline-editable table of import_batch_item rows (quantity, qty_received, unit cost, effective cost SGD)
  • apps/dashboard/app/(shell)/imports/[id]/fees-section.tsx (new) — fee list + "Add fee" button + inline edit
  • apps/dashboard/app/(shell)/imports/[id]/contributors-section.tsx (new) — contributor list + "Add contributor"
  • apps/dashboard/app/(shell)/imports/[id]/actions.ts (new) — server actions: patchImport, transitionImport, addLineItem, editLineItem, removeLineItem, addFee, editFee, removeFee, addContributor, removeContributor, forceRecompute

Decisions baked in

  • Inline editing for line items + fees: each row's edit button opens an inline form, not a modal. Matches Phase 5 /orders/[id] patterns.
  • Status transition buttons: only show the next valid transitions (e.g. PO in paid shows Mark in transit + Mark arrived (skip) with confirmation). Backward transitions live behind a "Reset status" menu with an explicit operator override.
  • "Force recompute" button: visible to inventory + finance roles on every PO regardless of status; calls the manual POST /recompute endpoint.
  • Allocation breakdown sub-view: under each line item, expandable section showing batch_allocation rows (which orders consumed how many units) + any batch_adjustment rows. Read-only.

PR-5 — Dashboard /imports/new create-PO flow

Goal: operator logs a brand-new PO in under 60 seconds when fees are not yet known.

Files

  • apps/dashboard/app/(shell)/imports/new/page.tsx (new) — single-page form (no multi-step wizard for v1)
  • apps/dashboard/app/(shell)/imports/new/create-form.tsx (new) — client component, RHF + Zod, nested useFieldArray for line items + (optional) fees + (optional) contributors
  • apps/dashboard/app/(shell)/imports/new/actions.ts (new) — createImport(formData) server action; chains POST /imports → for each line item POST /line-items → for each fee POST /fees → for each contributor POST /contributors; redirects to /imports/[id] on success

Form sections

  1. Header (required): supplier (dropdown + "Add new"), currency, invoice_amount_original, cost_allocation_method (default proportional_by_value), paid_tax (default no), remarks
  2. Line items (required, ≥1): variant picker (autocomplete on Medusa products), quantity, unit cost in PO currency, condition/foil/language metadata
  3. Fees (optional, can be skipped entirely): per row — fee_type dropdown, amount_sgd, amount_original + currency, paid_at, notes. "Skip fees for now" affordance.
  4. Contributors (optional, can be skipped): per row — name_snapshot, contribution_amount_sgd, capital_share_pct. "Skip contributors for now" affordance.

Decisions baked in

  • Optional fee section UX: clearly labeled "Optional — you can add these later" with a small explainer link to the runbook ("Why fee timing doesn't matter — late fees backfill COGS automatically").
  • Variant picker: re-uses the existing apps/dashboard/components/variant-picker/ if it exists from Phase 5 inventory work; otherwise add a small autocomplete component.
  • Submit creates draft: PO is created with status=draft; operator transitions via the detail page once they want to commit.

PR-6 — "Receive into inventory" button + endpoint

Goal: closed PO's units land in Medusa inventory_level.stocked_quantity and become sellable.

Files

  • apps/server/src/api/admin/dashboard/imports/[id]/receive/route.ts (new) — POST handler
  • apps/server/src/modules/dca/workflows/receive-into-inventory.ts (new) — workflow that, for each import_batch_item on the PO with a linked Medusa product_variant, calls Medusa's updateInventoryLevelsWorkflow to set stocked_quantity += qty_received
  • apps/server/src/modules/dca/__tests__/receive-into-inventory.integration.spec.ts (new) — scenarios: first receive (stocked_quantity goes from 0 → qty_received); second receive (idempotent — no double-stocking); receive with mismatched qty_received vs quantity (operator-discrepancy path: receive only what was physically received)
  • apps/dashboard/app/(shell)/imports/[id]/header-section.tsx (modified) — add "Receive into inventory" button, gated on status >= arrived

Decisions baked in

  • Idempotent receive: the receive workflow writes the absolute qty_received value (delta = qty_received - already_received_via_this_PO). Clicking twice does not double-stock. Persistence of "what we already pushed" lives on a per-line-item flag (already_received_into_inventory_at: dateTime nullable).
  • Per-line-item granularity: a partial-receive scenario (50 of 100 cards arrived) is handled by the line-item's qty_received column, which the operator sets to the actually-received count. Re-receiving with a corrected qty_received after a recount is idempotent and adjusts inventory by the delta.
  • No status auto-transition: clicking "Receive into inventory" does not flip PO status to for_storage or closed. Status is operator-driven (Decision 2 from the kickoff plan). The button is a one-way stock push, not a workflow choreographer.

PR-7 — manual allocation strategy + per-line override UI

Goal: the CostAllocationMethod.MANUAL enum value (which already exists) becomes a usable strategy.

Files

  • apps/server/src/modules/dca/services/cost-allocation-strategies.ts (modified) — add allocateManual(items, fees) strategy: reads import_batch_item.manual_cost_override_sgd (new nullable column) per row; for rows with the override set, the fee allocation is bypassed and that exact value is used as effective_cost_per_unit_sgd; for rows without override, falls back to proportional_by_value
  • apps/server/src/modules/dca/models/import-batch-item.ts (modified) — add manual_cost_override_sgd: bigNumber nullable
  • apps/server/src/modules/dca/migrations/Migration2026MMDDHHMMSS.ts (new) — adds the column (additive, default null)
  • apps/server/src/modules/dca/service.ts (modified) — wire the manual enum value to the new strategy function inside recomputeBatchCosts
  • apps/dashboard/app/(shell)/imports/[id]/line-items-section.tsx (modified) — when PO's cost_allocation_method === 'manual', each line item row exposes a "Manual cost override (SGD)" input
  • apps/server/src/modules/dca/__tests__/manual-allocation.unit.spec.ts (new)

PR-8 — CSV parity test

Goal: prove the system reproduces the spreadsheet's per-unit cost output for one real historical batch.

Files

  • apps/server/src/modules/dca/__tests__/fixtures/csv-parity-batch-<batch_number>.json (new) — full PO definition (header + line items + fees + contributors) lifted from the chosen Q1 2026 reference batch; sourced from the existing Q1 2026 ExzenTCG - Imports.csv + ... - Additional Import Fees.csv
  • apps/server/src/modules/dca/__tests__/csv-parity.integration.spec.ts (new) — boots a real test DB, seeds the fixture via the DCA service, runs recomputeBatchCosts, and asserts effective_cost_per_unit_sgd of every line item matches the spreadsheet's "Total Cost Per Unit (SGD)" column within rounding tolerance (1e-2 SGD)

Open at PR-8 time

  • Which Q1 2026 batch? (Deferred from kickoff plan §"Open question deferred to Slice G".) Operator picks one closed batch with ≥1 fee, ≥1 contributor, no consolidation; that batch number becomes the fixture filename.
  • Rounding tolerance: if the spreadsheet rounds at a different precision than the DCA engine, the parity assertion may need wider tolerance (1e-1 SGD) and a comment explaining why.

PR-9 — Docs + runbook

Goal: the wire-up is documented end-to-end so the next operator (or contributor) can pick it up without reading code.

Files

  • tcg-platform/phase-4-wire-up-findings.md (new) — closeout doc: what shipped, gotchas surfaced in flight, open follow-ups (e.g. consolidation, refund workflow split, auto-receive toggle if manual receive proves tedious)
  • tcg-platform/index.md (modified) — Phase 4 row flipped from "in progress" to "closed out"; new Wire-up Plan + Wire-up Implementation + Wire-up Findings rows added to Documents table
  • tcg-platform/data-model.md (modified) — flip the remaining-pending list to reflect what's actually closed; note any new columns added (batch_adjustment.source, import_batch_item.manual_cost_override_sgd, import_batch_item.already_received_into_inventory_at)
  • homelab/05_service_deployments/tcg-staging.md (modified) — operator runbook: "How to log a Purchase Order", "How to add a late fee", "How to receive into inventory"
  • mkdocs.yml (modified) — nav entries

Test strategy

Layer Coverage
Unit Subscriber logic (mocked event bus), manual allocation strategy, recompute math edge cases
Integration Full PO lifecycle via admin API (create → add items → add fees → transition → receive); recompute-late-fee scenario; CSV parity
Browser smoke Phase 5-style Playwright walk-through of /imports/new → create PO → /imports/[id] add fee → see recompute → click receive → check /inventory reflects new stock
Staging end-to-end Deploy each PR's image to CT 105, run server-side curl coverage + dashboard browser smoke, capture screenshots into Wire-up Findings

Anti-patterns (do not re-introduce)

Restated from the kickoff plan for code review:

  • Don't write to batch_allocation.cost_per_unit_at_allocation after creation. Use batch_adjustment rows.
  • Don't store derived profit anywhere. computeOrderLineProfit is query-time.
  • Don't call recomputeBatchCosts from admin route handlers. Decision 1 picks subscribers; manual "Force recompute" button is the only exception.
  • Don't auto-fire inventory hand-off from a status-transition subscriber. Decision 2 keeps receive explicit.
  • Don't introduce a fees_expected: boolean column. Decision 3 — fees are optional, badge surfaces uncertainty.
  • Don't pull consolidation / order-status-event / sales-channel-config into this wire-up. Explicitly deferred.

References