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 |
| #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¶
- Purchase Order Module (Imports) — Goal #1 → Slices A + C
- Operator creates a PO via the dashboard form; adds line items inline or later from the detail page
- Status transitions enforced by an in-handler state machine (
force=truefor backward / skip) - Receive button pushes units into Medusa
inventory_levelat the chosen stock location, idempotent on re-click -
Delete-line-item refuses if
batch_allocationrows exist (orphan COGS prevention) -
DCA for Imports — Goal #2 → Slices D + E
- Adding / editing / deleting a fee auto-fires
recomputeBatchCostsvia the new subscriber (no operator click required) - Already-sold units pick up late-fee deltas via auto-created
forgotten_feeadjustments (weighted-average compensation across all allocations of an item) manualallocation strategy makes the enum slot real: operator drives per-unit cost directly via the inline override input-
Force-recompute button remains available as the explicit knob
-
Optional fees (log PO first, fees later) — Goal #3 → Slices A + D
- PO can transition all the way to
closedwith zero fee rows - "Fees pending" badge surfaces uncertainty on the list view AND the detail view header
- 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
recomputeBatchCostscalls from admin route handlers — subscriber is canonical. Only exceptions: the manual/recomputeendpoint (operator override) and thesetManualOverrideActionserver action (chains PATCH + recompute as a single operator gesture) - No
fees_expected: booleancolumn — 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_methodortotal_sgd_paiddoes 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_feesflag — correctlytrueafter closing a PO with zero fees; correctly clears tofalseafter a fee is added + recompute fires - ✅ Recompute math (force path) —
POST /recomputeagainst a 100-unit PO at $800 base + $80 shipping correctly producedeffective_cost_per_unit_sgd = 8.8; with $85 fees,8.85. Both match(total_sgd_paid + total_fees) / units - ✅ Manual allocation strategy end-to-end —
PATCH cost_allocation_method=manual→PATCH manual_cost_override_sgd=15.5→POST /recompute→effective_cost_per_unit_sgd = 15.5verbatim - ✅ 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 guard —
closed → draftwithoutforcereturned409; withforce=truereturned200
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:
MEDUSA_WORKER_MODE=server— the compose env on CT 105 (and.env.development.example) was set toserver, which runs HTTP-only with NO background event-bus worker. Events emitted byMedusaServicefactory 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" — actuallyshareddoes that;serveris HTTP-only). Fix: tcg-platform #89- Event name format mismatch — Medusa's
moduleEventBuilderFactoryemits events as{moduleServiceName}.{kebab-entity}.{action}. For the DCA module the actual event name isdcaModuleService.import-batch-fee.created, NOTimport_batch_fee.created. The Shopee subscriber has the same naming bug, but its handler dispatchesprocessEventinline 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
/feeswithamount_sgd: 4→total_fees=102immediately - 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.createdfired both times - ✅ Weighted-average math correct:
target_system_sum = effective − operator_sum − weighted_frozen→ step 1 produced0.08 − 0 = 0.08, step 2 produced0.10 − 0.08 = 0.02(incremental delta, not a rewrite of step 1) - ✅ Loop-prevention works —
batch_adjustment.createdevents forsource = system_recomputerows are skipped by the adjustment subscriber, so adjustment count stayed at 2 (not ∞) - ✅ Audit trail intact — each adjustment row has a
notesfield describing the bridge it applied - ✅
computeOrderLineProfitwill readcost_per_unit_at_allocation + Σ cost_delta_per_unit = 9.02 + 0.08 + 0.02 = 9.12for 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
404on 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_allocationrow (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 devfrom 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'scookieOptions.secure=true+ plain HTTP request meansexpress-sessionwon't emitSet-Cookieon/auth/session. AddedX-Forwarded-Proto: httpsto the dashboard'slib/auth.tsstep-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.) - ✅
/importslist 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_recomputeforgotten_feeadjustment row appeared in the DB withdelta=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¶
- 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 onsmoke/phase-4-wire-up(rebuilt commit-level fix is durable; subscriber fires within 1s of fee POST). - Backport the api-shapes pivot (
93be55a) to PR-2 (#82) — server routes should not import@tcg/shared-typesdirectly. Either land as an amendment on #82 or open a small fix PR - Fix Shopee subscriber event name too (cosmetic — handler dispatches
processEventinline so the cascade doesn't depend on it, but the subscriber is dead code as written). Changeshopee_raw_event.created→shopeeConnectorService.shopee-raw-event.createdinapps/server/src/subscribers/shopee-raw-event-created.ts - Send
X-Forwarded-Proto: httpson the dashboard's/auth/sessionexchange — small upstream of the smoke-only patch inapps/dashboard/lib/auth.ts. In dev/LAN deployments where the dashboard talks to Medusa over plain HTTP, Medusa'ssecure: truecookieOptions blockSet-Cookieemission. 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. - 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 - 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:
- Pick a Q1 2026 batch from
Q1 2026 ExzenTCG - Imports.csvmatching the selection criteria: closed status, ≥1 fee, ≥1 contributor, no consolidation events - Update
apps/server/src/modules/dca/__tests__/fixtures/csv-parity-batch-synthetic.json(or rename tocsv-parity-batch-<batch_number>.json+ update thepath.joinin the spec) with the real values - Compute
expected.line_items[].effective_cost_per_unit_sgdfrom the spreadsheet's "Total Cost Per Unit (SGD)" column and update - 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
sourceenum 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_expectedboolean
What this enables next¶
- Phase 4 follow-ups (deferred, not blockers for the three goals):
- Consolidation tables +
consolidateBatchesworkflow + reverse-cascade order_status_eventcross-channel logsales_channel_configper-channel fee schemestcg_channel_listing.is_listedextensionrefundOrderworkflow (goods-returned vs money-only branching)- Phase 8 entry points the wire-up sets up:
import_batch_contributor.name_snapshot→person_idModule Linkimport_batch_fee.related_cashflow_id→cashflow_entrybatch_adjustment.related_cashflow_id→cashflow_entry
References¶
- Phase 4 Wire-up Plan — kickoff (locked design)
- Phase 4 Wire-up Implementation — PR-by-PR breakdown
- Phase 4 Findings (skeleton) — Ivan's PR #53 closeout
- Data Model — column-level reference; the wire-up adds
batch_adjustment.source,import_batch_item.received_into_inventory_at,import_batch_item.manual_cost_override_sgd - Code stack: PRs #81 through #88 on
tcg-platform