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
recomputeBatchCostsdoes not itself emit fee events, but it does insertbatch_adjustmentrows in the new path. The adjustment subscriber must skip recompute when the adjustment'sreason=forgotten_fee(those are recompute's own output, not external input). Cleanest is asourcecolumn onbatch_adjustmentwith valuesoperator(default) vssystem_recompute— the subscriber filters onsource=operator. - Subscriber registration: Medusa picks up subscribers from
apps/server/src/modules/dca/subscribers/*.tsautomatically; 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 createapps/server/src/api/admin/dashboard/imports/[id]/route.ts(new) — GET detail + PATCH headerapps/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 addapps/server/src/api/admin/dashboard/imports/[id]/line-items/[itemId]/route.ts(new) — PATCH + DELETEapps/server/src/api/admin/dashboard/imports/[id]/fees/route.ts(new) — POST addapps/server/src/api/admin/dashboard/imports/[id]/fees/[feeId]/route.ts(new) — PATCH + DELETEapps/server/src/api/admin/dashboard/imports/[id]/contributors/route.ts(new) — POST addapps/server/src/api/admin/dashboard/imports/[id]/contributors/[contribId]/route.ts(new) — DELETEapps/server/src/api/admin/dashboard/imports/[id]/recompute/route.ts(new) — POSTapps/server/src/api/admin/dashboard/suppliers/route.ts(new) — GET + POSTpackages/shared-types/src/imports.ts(new) — full DTO surface:ImportBatchListItem,ImportBatchDetail,ImportBatchCreateRequest,ImportBatchPatchRequest,LineItemCreateRequest,FeeCreateRequest,ContributorCreateRequest,TransitionRequest,SupplierCreateRequestapps/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 /transitionvalidates against an in-handler state machine; backward transitions and skip-by-two requireforce=truequery/body (operator override). - Delete-line-item refuses if allocated: a line item with any
batch_allocationrows 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: existingtcg_inventory_manager+tcg_financeroles can mutate;tcg_csris read-only on these routes (table change inuser_rolenot 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, callsGET /admin/dashboard/importsvia the existingapiFetchhelperapps/dashboard/app/(shell)/imports/imports-table.tsx(new) — client component using TanStack Table (matches/orderspatterns)apps/dashboard/app/(shell)/imports/types.ts(new) — re-exports from@tcg/shared-types/importsapps/dashboard/components/sidebar.tsx— add "Imports" entry under existing sections (with the appropriatecomingSoon: falseflip 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 fetchapps/dashboard/app/(shell)/imports/[id]/header-section.tsx(new) — supplier, status, dates, currency, amounts, transition buttonsapps/dashboard/app/(shell)/imports/[id]/line-items-section.tsx(new) — inline-editable table ofimport_batch_itemrows (quantity, qty_received, unit cost, effective cost SGD)apps/dashboard/app/(shell)/imports/[id]/fees-section.tsx(new) — fee list + "Add fee" button + inline editapps/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
paidshowsMark 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 /recomputeendpoint. - Allocation breakdown sub-view: under each line item, expandable section showing
batch_allocationrows (which orders consumed how many units) + anybatch_adjustmentrows. 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, nesteduseFieldArrayfor line items + (optional) fees + (optional) contributorsapps/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¶
- Header (required): supplier (dropdown + "Add new"), currency, invoice_amount_original, cost_allocation_method (default
proportional_by_value), paid_tax (defaultno), remarks - Line items (required, ≥1): variant picker (autocomplete on Medusa products), quantity, unit cost in PO currency, condition/foil/language metadata
- Fees (optional, can be skipped entirely): per row — fee_type dropdown, amount_sgd, amount_original + currency, paid_at, notes. "Skip fees for now" affordance.
- 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 handlerapps/server/src/modules/dca/workflows/receive-into-inventory.ts(new) — workflow that, for eachimport_batch_itemon the PO with a linked Medusaproduct_variant, calls Medusa'supdateInventoryLevelsWorkflowto setstocked_quantity += qty_receivedapps/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 onstatus >= arrived
Decisions baked in¶
- Idempotent receive: the receive workflow writes the absolute
qty_receivedvalue (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_receivedcolumn, which the operator sets to the actually-received count. Re-receiving with a correctedqty_receivedafter 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_storageorclosed. 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) — addallocateManual(items, fees)strategy: readsimport_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 aseffective_cost_per_unit_sgd; for rows without override, falls back toproportional_by_valueapps/server/src/modules/dca/models/import-batch-item.ts(modified) — addmanual_cost_override_sgd: bigNumber nullableapps/server/src/modules/dca/migrations/Migration2026MMDDHHMMSS.ts(new) — adds the column (additive, default null)apps/server/src/modules/dca/service.ts(modified) — wire themanualenum value to the new strategy function insiderecomputeBatchCostsapps/dashboard/app/(shell)/imports/[id]/line-items-section.tsx(modified) — when PO'scost_allocation_method === 'manual', each line item row exposes a "Manual cost override (SGD)" inputapps/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 existingQ1 2026 ExzenTCG - Imports.csv+... - Additional Import Fees.csvapps/server/src/modules/dca/__tests__/csv-parity.integration.spec.ts(new) — boots a real test DB, seeds the fixture via the DCA service, runsrecomputeBatchCosts, and assertseffective_cost_per_unit_sgdof 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 tabletcg-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_allocationafter creation. Usebatch_adjustmentrows. - Don't store derived profit anywhere.
computeOrderLineProfitis query-time. - Don't call
recomputeBatchCostsfrom 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: booleancolumn. 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¶
- Phase 4 Wire-up Kickoff Plan — design decisions + slice scope
- Phase 4 Findings — skeleton (PR #53) outcome + open questions
- Data Model — column-level reference
- Phase 5 Implementation — admin API patterns + dashboard patterns this wire-up reuses
- Phase 6 Implementation — example of a PR-by-PR plan in this same format