Skip to content

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

Status: full smoke green (API + UI + subscriber + forgotten-fee bridging) on 2026-05-15

Phase 4 wire-up landed across 8 PRs on tcg-platform plus 2 docs PRs on ExzenTCG-Homelab. The merchant's three stated goals (PO module, DCA for imports, optional fees) are implemented end-to-end. Smoke coverage: 15-step API walkthrough + subscriber auto-fire after clean rebuild + forgotten-fee weighted-average bridging on a seeded sold unit + loop-prevention + local-dev Playwright UI walkthrough (login → list → detail → add fee → state-machine transition) all green. The subscriber bug surfaced mid-smoke was root-caused and fixed as tcg-platform #89 + amendment on #81. Three follow-ups remain operator-input-blocked: production-host UI smoke through Cloudflare Access SSO, real-Shopee-order forgotten-fee path, and CSV-parity fixture swap to a real Q1 2026 batch.

See Phase 4 Wire-up Plan for design decisions and Phase 4 Wire-up Implementation for the PR-by-PR breakdown.

What shipped

Code (8 PRs on tcg-platform)

PR Slice Scope
#81 D Recompute subscribers (import_batch_fee.* + batch_adjustment.created) + late-fee auto-compensation via system_recompute forgotten_fee adjustments. New BatchAdjustmentSource enum + additive migration. 33 DCA tests pass (27 prior + 6 new)
#82 A (data) 15 admin routes under /admin/dashboard/imports/* + /admin/dashboard/suppliers/*. view_imports + manage_imports capabilities added to the staff matrix. Full DTO surface in @tcg/shared-types/imports. HTTP integration tests cover permissions × every endpoint × every role + happy-path lifecycle
#83 A (UI list) /imports server-component list page with status / supplier / pending-fees / search filters; pagination; "New PO" gated on manage_imports; "Fees pending" badge surfaces Decision 3
#84 A (UI detail) /imports/[id] page with four sections (header + items + fees + contributors); 6 server actions; AddFeeForm + AddContributorForm client components with useActionState; "Mark " + "Force recompute" header buttons
#85 A (UI create) /imports/new create-PO flow with dynamic line-items array; chains POST /imports → POST /line-items; redirects to detail page on success
#86 C "Receive into inventory" — manual button per Decision 2. POST /admin/dashboard/imports/:id/receive resolves variant → inventory_item via Module Link, upserts inventory_level at chosen location, stamps received_into_inventory_at (idempotent). New /admin/dashboard/stock-locations endpoint for the location dropdown
#87 E manual allocation strategy. New manual_cost_override_sgd column; allocateManual strategy returns the override verbatim; ItemsSection surfaces an inline editor only when the PO's method is manual
#88 G (scaffold) CSV parity integration test + synthetic reference fixture. Hand-computed expected values; swap-in instructions documented in the fixture's _meta.swap_instructions

Docs (2 PRs on ExzenTCG-Homelab)

  • #41 — Phase 4 wire-up kickoff plan + implementation breakdown
  • This findings doc — closeout (TBD merge)

(Both docs PRs stack cleanly behind Ivan's #38, which added Ivan's original Phase 4 skeleton findings.)

How the three merchant goals map to what shipped

  1. Purchase Order Module (Imports) — Goal #1 → Slices A + C
  2. Operator creates a PO via the dashboard form; adds line items inline or later from the detail page
  3. Status transitions enforced by an in-handler state machine (force=true for backward / skip)
  4. Receive button pushes units into Medusa inventory_level at the chosen stock location, idempotent on re-click
  5. Delete-line-item refuses if batch_allocation rows exist (orphan COGS prevention)

  6. DCA for Imports — Goal #2 → Slices D + E

  7. Adding / editing / deleting a fee auto-fires recomputeBatchCosts via the new subscriber (no operator click required)
  8. Already-sold units pick up late-fee deltas via auto-created forgotten_fee adjustments (weighted-average compensation across all allocations of an item)
  9. manual allocation strategy makes the enum slot real: operator drives per-unit cost directly via the inline override input
  10. Force-recompute button remains available as the explicit knob

  11. Optional fees (log PO first, fees later) — Goal #3 → Slices A + D

  12. PO can transition all the way to closed with zero fee rows
  13. "Fees pending" badge surfaces uncertainty on the list view AND the detail view header
  14. When the late fee finally lands weeks later: subscriber fires → recompute → bridging adjustments for any already-sold units → operator sees the refreshed effective cost immediately

Anti-patterns avoided (per the kickoff plan)

  • No recomputeBatchCosts calls from admin route handlers — subscriber is canonical. Only exceptions: the manual /recompute endpoint (operator override) and the setManualOverrideAction server action (chains PATCH + recompute as a single operator gesture)
  • No fees_expected: boolean column — Decision 3 makes fees fully optional; the "Fees pending" badge surfaces uncertainty without schema change
  • No auto-fire on status-transition for receive — Decision 2 keeps the receive step explicit; the manual button is the only path
  • No header subscriber on cost-affecting fields — PATCH to cost_allocation_method or total_sgd_paid does not auto-recompute; operator clicks /recompute (documented in the PATCH handler's JSDoc)
  • No consolidation / order-status-event / sales-channel-config / refund-workflow — all explicitly deferred to a future phase per the kickoff plan

Validation results — 2026-05-15 staging smoke

The merged 8-PR stack (combined on smoke/phase-4-wire-up for the smoke deploy) was rebuilt on CT 105 against the live staging Postgres + Redis. All three Phase 4 migrations auto-applied at boot (batch_adjustment.source, import_batch_item.received_into_inventory_at, import_batch_item.manual_cost_override_sgd). A 15-step bash walkthrough hit every admin route directly with curl from inside the container.

Deploy fixes needed during the smoke

Two issues surfaced during the Docker build that didn't fail locally (local tsc --noEmit was lenient where yarn medusa build's strict tsc is not). Both are captured as additional smoke-branch commits and need backporting to the underlying PRs (or shipped as their own follow-up fix PR) before formal closeout:

Commit Fix
b79e37a Dockerfile: added COPY packages/shared-types/ ./packages/shared-types/ before yarn medusa build. Without it, the workspace package.json resolves but the symlinked src/ is missing in the build context, producing TS2307 Cannot find module @tcg/shared-types.
93be55a Pivoted server-side away from @tcg/shared-types entirely. Added apps/server/src/modules/dca/types/api-shapes.ts with locally-defined DTOs hand-synced to the dashboard's @tcg/shared-types/imports. Updated 9 server route files. Reason: even with the source COPY in place, apps/server/tsconfig.json's rootDir: "./" rejects the workspace import as TS6059 file not under rootDir. This matches the existing staff Capability convention (see modules/staff/permissions.ts comment) and is the durable fix; once landed, the Dockerfile change in b79e37a becomes unnecessary and can be reverted.

What the smoke proved

13 of 15 steps passed cleanly; the remaining 2 surfaced the one real bug below.

  • Supplier + PO + line item + contributor create — all 200/201
  • State machine forward path — 6 transitions (draft → ordered → paid → in_transit → arrived → for_storage → closed) all 200
  • has_pending_fees flag — correctly true after closing a PO with zero fees; correctly clears to false after a fee is added + recompute fires
  • Recompute math (force path)POST /recompute against a 100-unit PO at $800 base + $80 shipping correctly produced effective_cost_per_unit_sgd = 8.8; with $85 fees, 8.85. Both match (total_sgd_paid + total_fees) / units
  • Manual allocation strategy end-to-endPATCH cost_allocation_method=manualPATCH manual_cost_override_sgd=15.5POST /recomputeeffective_cost_per_unit_sgd = 15.5 verbatim
  • List endpoint + search filter — supplier-name-fragment query returned the new PO
  • Stock-locations endpoint — returned the seeded locations (Warehouse, Store, Event-A) for the receive dropdown
  • Backward transition guardclosed → draft without force returned 409; with force=true returned 200

The subscriber gap (root-caused and fixed)

POST /admin/dashboard/imports/{id}/fees wrote the fee row correctly (summary.total_fees_sgd incremented immediately), but the import_batch_fee.created subscriber did NOT fire on staging. effective_cost_per_unit_sgd did not update until the operator clicked "Force recompute" (or the route handler called POST /recompute directly).

Investigation against the running CT 105 container + Medusa 2.x source pinned down two compounding causes:

  1. MEDUSA_WORKER_MODE=server — the compose env on CT 105 (and .env.development.example) was set to server, which runs HTTP-only with NO background event-bus worker. Events emitted by MedusaService factory methods get pushed to the BullMQ Redis queue (RedisEventBusService:events-queue) but nothing dequeues them. The compose comment had the meaning inverted ("server mode means this instance handles both HTTP and background work" — actually shared does that; server is HTTP-only). Fix: tcg-platform #89
  2. Event name format mismatch — Medusa's moduleEventBuilderFactory emits events as {moduleServiceName}.{kebab-entity}.{action}. For the DCA module the actual event name is dcaModuleService.import-batch-fee.created, NOT import_batch_fee.created. The Shopee subscriber has the same naming bug, but its handler dispatches processEvent inline from the webhook route (webhook/handler.ts:158-166), so the broken subscriber never blocked the Phase 3 cascade. Fix: amendment commit on tcg-platform PR-1 (#81)

Direct evidence captured via redis-cli MONITOR during a fee POST:

"name" "dcaModuleService.import-batch-fee.created"
"data" "{\"data\":{\"id\":\"01KRNAA6R78W31VM91B2P3DVX4\"},\"metadata\":{\"source\":\"dcaModuleService\",\"object\":\"import_batch_fee\",\"action\":\"created\"}}"

The event-bus-redis worker does exact-name lookup against eventToSubscribersMap, so a subscriber on the snake-case short form never matches.

Live verification after both fixes were applied to the CT 105 container (patched compiled JS + flipped compose env):

  • Before fee: effective=8.85, fees=98
  • POST /fees with amount_sgd: 4total_fees=102 immediately
  • After +1s: effective=9.02, matching (800 + 102) / 100 = 9.02 ✓ subscriber auto-fired

Then re-verified after a clean docker rebuild from the smoke branch with both source-level fixes (no in-container patches): POST /fees with amount_sgd: 5 produced effective=9.07 ((800 + 107) / 100) within 1s. The fix is durable across rebuilds, not just the live-patched container.

The auto-recompute on fee mutation is now end-to-end working; "Force recompute" remains as the explicit knob for header-field PATCHes (which intentionally have no subscriber per Decision 1).

Forgotten-fee weighted-average bridging on already-sold units

Seeded a batch_allocation row directly (20 units at cost_per_unit_at_allocation = 9.02, simulating 20 sold units before the fee landed), then ran two consecutive fee POSTs:

Step Fee added total_fees_sgd effective New batch_adjustment row
1 shipping_local $3 110 9.10 delta=0.08, forgotten_fee, system_recompute, notes frozen 9.02 → effective 9.10
2 fx_loss $2 112 9.12 delta=0.02, forgotten_fee, system_recompute, notes frozen 9.02 → effective 9.12

What this proves:

  • ✅ Subscriber on dcaModuleService.import-batch-fee.created fired both times
  • ✅ Weighted-average math correct: target_system_sum = effective − operator_sum − weighted_frozen → step 1 produced 0.08 − 0 = 0.08, step 2 produced 0.10 − 0.08 = 0.02 (incremental delta, not a rewrite of step 1)
  • ✅ Loop-prevention works — batch_adjustment.created events for source = system_recompute rows are skipped by the adjustment subscriber, so adjustment count stayed at 2 (not ∞)
  • ✅ Audit trail intact — each adjustment row has a notes field describing the bridge it applied
  • computeOrderLineProfit will read cost_per_unit_at_allocation + Σ cost_delta_per_unit = 9.02 + 0.08 + 0.02 = 9.12 for any future sale of those 20 units, matching the current effective cost

Goals still operator-input-blocked

The smoke did not exercise these (no real merchant data on staging yet):

  • Receive into inventory — endpoint deployed and stock-locations dropdown populated, but no real product_variant Module Links exist on staging POs yet. Smoke confirmed 404 on the receive call with the synthetic PO (no linked variant); the happy path requires either the operator-driven smoke against a real PO or a seed script
  • End-to-end forgotten-fee path with a real Shopee sale — the bridging-adjustment math is proven on a hand-seeded batch_allocation row (see Forgotten-fee weighted-average bridging above). The next layer is a real Shopee order against a seeded variant flowing through the connector to insert the allocation row — that requires merchant variant setup
  • Dashboard UI walkthrough on dashboard-staging.exzentcg.com — that public host is gated by Cloudflare Access (302 → SSO) and only the operator can drive it end-to-end through the SSO flow. A local-dev Playwright smoke against the same source (next dev from the smoke branch pointed at the CT 105 Medusa) was completed instead — see Local-dev UI smoke below — and covers the same code paths; the production-host SSO smoke is still operator-input-blocked

Local-dev UI smoke via Playwright

Ran next dev from the smoke branch on this dev box, pointed at CT 105's Medusa over the LAN (MEDUSA_BACKEND_URL=http://192.168.0.55:9000), then drove the UI with Playwright. End-to-end log:

  • ✅ Login flow — admin@exzentcg.com / 123 → redirect to /imports. (One LAN-only gotcha surfaced: Medusa's cookieOptions.secure=true + plain HTTP request means express-session won't emit Set-Cookie on /auth/session. Added X-Forwarded-Proto: https to the dashboard's lib/auth.ts step-2 fetch as a smoke-only workaround. In prod the Cloudflare Tunnel + NPM already injects that header, so the upstream fix is to send it from the dashboard for parity — captured as a follow-up in Remaining work.)
  • /imports list renders — PO #1778830856, supplier filter pre-populated with both suppliers from the API smoke, "New PO" gate visible, status pill "Draft"
  • /imports/[id] detail renders all four sections — header (effective SGD 9.12, landed SGD 912, 11 fees pre-add, allocation "proportional by value"), Line items (1 item, effective rendered live), Fees (11 rows matching the API smoke's history), Contributors (Alice / SGD 500)
  • ✅ "Add fee" form (Bank fee, SGD 10) → header refreshes inline: total fees SGD 112 → 122, landed SGD 912 → 922, effective SGD 9.12 → 9.22 matching (800 + 122) / 100. Subscriber + Server Action revalidation chain proven through the UI.
  • ✅ Forgotten-fee bridging fired through the UI path too — third system_recompute forgotten_fee adjustment row appeared in the DB with delta=0.10 (so the 20 already-sold units pick up the 10c/unit gap from this new fee), bringing the running sum to 0.08 + 0.02 + 0.10 = 0.20 = (9.22 − 9.02). Math holds.
  • ✅ State machine transition button — clicked "Mark ordered" → header status pill flipped Draft → Ordered, next-state button became "Mark paid"

Remaining work before formal closeout

  1. Merge the two subscriber fixes — tcg-platform #89 (worker mode → shared) and the amendment commit on #81 (event-name format). Re-deploy and re-smoke fee mutations end-to-end on a clean CT 105 build — already done on smoke/phase-4-wire-up (rebuilt commit-level fix is durable; subscriber fires within 1s of fee POST).
  2. Backport the api-shapes pivot (93be55a) to PR-2 (#82) — server routes should not import @tcg/shared-types directly. Either land as an amendment on #82 or open a small fix PR
  3. Fix Shopee subscriber event name too (cosmetic — handler dispatches processEvent inline so the cascade doesn't depend on it, but the subscriber is dead code as written). Change shopee_raw_event.createdshopeeConnectorService.shopee-raw-event.created in apps/server/src/subscribers/shopee-raw-event-created.ts
  4. Send X-Forwarded-Proto: https on the dashboard's /auth/session exchange — small upstream of the smoke-only patch in apps/dashboard/lib/auth.ts. In dev/LAN deployments where the dashboard talks to Medusa over plain HTTP, Medusa's secure: true cookieOptions block Set-Cookie emission. In prod the header is set by Nginx (NPM) anyway, so emitting it from the dashboard is harmless and prevents the dev-only login break.
  5. Operator-driven UI smoke against dashboard-staging.exzentcg.com — the local-dev Playwright run covered the same code paths but did not exercise the Cloudflare Access SSO redirect / production cookie domain
  6. CSV-parity swap — same as the original Validation pending item below

CSV-parity swap to a real Q1 2026 batch

PR #88 ships a synthetic fixture + a working parity test. To make the test load-bearing on real data:

  1. Pick a Q1 2026 batch from Q1 2026 ExzenTCG - Imports.csv matching the selection criteria: closed status, ≥1 fee, ≥1 contributor, no consolidation events
  2. Update apps/server/src/modules/dca/__tests__/fixtures/csv-parity-batch-synthetic.json (or rename to csv-parity-batch-<batch_number>.json + update the path.join in the spec) with the real values
  3. Compute expected.line_items[].effective_cost_per_unit_sgd from the spreadsheet's "Total Cost Per Unit (SGD)" column and update
  4. Re-run the test. If the assertion fails, that delta is the load-bearing finding — the engine math needs investigation before Phase 4 can close

If the assertion passes against real data, Phase 4 is formally complete.

Architecture decisions that didn't change

The three decisions locked at kickoff held up through implementation:

  • Subscriber-driven recompute (Decision 1) — the implementation surfaced the loop-prevention question (system_recompute adjustments would re-fire the subscriber); the source enum split solved it cleanly. No regret on the subscriber path
  • Manual receive-into-inventory (Decision 2) — built as planned; auto-fire would have been one subscriber addition away but Decision 2 was the right call given the high-value-inventory context
  • Fee-less PO close + "Fees pending" badge (Decision 3) — the badge surfaces on both list + detail views; the late-fee auto-adjustment flow works end-to-end. No need for the fees_expected boolean

What this enables next

  • Phase 4 follow-ups (deferred, not blockers for the three goals):
  • Consolidation tables + consolidateBatches workflow + reverse-cascade
  • order_status_event cross-channel log
  • sales_channel_config per-channel fee schemes
  • tcg_channel_listing.is_listed extension
  • refundOrder workflow (goods-returned vs money-only branching)
  • Phase 8 entry points the wire-up sets up:
  • import_batch_contributor.name_snapshotperson_id Module Link
  • import_batch_fee.related_cashflow_idcashflow_entry
  • batch_adjustment.related_cashflow_idcashflow_entry

References