Phase 4 PO Flow Follow-up — Loyverse-Style Receiving + Variant Picker¶
Status: design locked 2026-05-15 — ready for PR sequencing
Builds on Phase 4 Wire-up Findings (closeout 2026-05-15). The wire-up shipped the cost-engine surface (/imports, fee subscriber, manual receive button) but stopped short of operator-usable PO data-entry: line items today are anonymous SKU strings with no inventory linkage, and the receive button is all-or-nothing. The merchant's stated target is a Loyverse-style flow — "add a purchase order, search for the product I want to import → add cost → import → set to arrive → inventory updates" — which this follow-up unblocks.
All eight design decisions (D1–D8) locked via direct merchant conversation 2026-05-15. Implementation breakdown lives in the Proposed PR breakdown below.
Why a follow-up phase¶
The wire-up phase (phase-4-wire-up-plan.md) explicitly decided that "Receive into inventory" is a manual operator button, idempotent on absolute qty_received. That design is correct for the wire-up scope, but it forecloses three things the merchant now needs:
- Item search on the line-item form. Schema supports it (
import_batch_item.variant_idis a Module Link toproduct_variant), but/imports/newand the line-item create endpoint do not render a variant picker. Line items today are anonymous — they carry money but no product reference. - Iterative partial receiving. PR-6's receive button writes the line's absolute
qty_receivedonce. Loyverse's "receive 3, then 5, then 2 over a week as boxes arrive" pattern needs a per-event receipt log with a running counter. - PO date + expected delivery date as first-class header fields. Today
import_batch.paid_atandimport_batch.arrived_atare the only dates on the header. There is no user-editable PO creation date and no expected-arrival date for thePendingstate to be meaningful in dashboards.
The follow-up closes those three gaps and tightens DCA semantics around partial receipts.
Scope¶
In:
- Variant picker on line-item create/edit (combobox on
/imports/new+/imports/[id]). - Existing stock preview on the line-item row (read-only
inventory_level.stocked_quantityfor the picked variant). - Per-event partial-receive table (
import_batch_receipt) replacing the all-or-nothing receive button. - New header fields:
po_date,expected_delivery_date. - Status auto-transition to
closedwhenΣ received == Σ expectedacross all batch items. - Workflow
receiveBatchItem(item_id, qty)bundling the receipt log + inventory increment + auto-transition check, with Medusa workflow compensation on failure.
Out (deferred to later passes):
- FIFO/LIFO lot tracking on
inventory_level(see Risk R4 in DCA Integration). Weighted-average via existingconsolidation_eventremains the default. sales_channel_config.auto_receivetoggle — the merchant said in wire-up scoping that the manual button is desired; this follow-up keeps the manual model but allows it to fire repeatedly.- The remaining wire-up-deferred Phase 4 tables (
order_status_event,sales_channel_config,tcg_channel_listing.is_listed, refund workflow split). Unchanged from wire-up's deferral list.
Design decisions¶
D1. Partial receipts go in a new import_batch_receipt table — not as batch_adjustment rows¶
batch_adjustment exists for cost/quantity corrections with semantic reasons (supplier_shortfall, forgotten_fee, customer_return, …). Overloading it for normal receipts would muddy the audit log: "you received 3 units" is not a correction — it's the planned flow.
| Option considered | Decision |
|---|---|
(A) New import_batch_receipt table ✓ |
Clean audit separation. DCA recompute can subscribe to a dedicated event. Receipt rows hold a effective_cost_at_receipt_sgd snapshot (Risk R1 mitigation). |
(B) Use batch_adjustment(reason=quantity_correction, quantity_delta=+n) |
Rejected. Conflates receipts with corrections. batch_adjustment audit becomes ambiguous: was qty=+3 a planned receipt or a supplier-shortfall reversal? |
(C) Mutate import_batch_item.qty_received directly |
Rejected. No event log; cannot answer "what was capitalized on which date?" — critical for monthly asset valuation. |
D2. quantity_received becomes explicit, summed from import_batch_receipt¶
Today: quantity_received is a generated column = quantity_ordered + Σ batch_adjustment.quantity_delta. This was correct under the wire-up's "start at full qty, adjust down" assumption. Under iterative receiving the semantics flip — start at 0, add up.
The minimum-surgery fix: drop the generated-column definition, make it a regular integer NOT NULL DEFAULT 0, update it transactionally inside receiveBatchItem. Adjustments to quantity from batch_adjustment continue to flow through (a supplier shortfall is still expressed as quantity_delta=-n against a line — the receipt count was right, but stock was short).
Computed-vs-stored trade-off accepted: import_batch_item is small (one row per SKU per PO), writes are operator-paced (single-digit per minute), no concurrency concerns.
D3. Status auto-transition fires inside the receive workflow, not via a separate subscriber¶
Auto-transition logic: after each receiveBatchItem call, check if Σ import_batch_receipt.quantity_received equals Σ import_batch_item.quantity_ordered across all items on the batch. If yes, transition status arrived → closed.
| Option considered | Decision |
|---|---|
| (A) Inline in the workflow ✓ | One atomic transaction. Status + inventory + receipt log either all succeed or all roll back via Medusa workflow compensation. |
(B) Separate subscriber on import_batch_receipt.created |
Rejected. Cross-transaction race: receipt commits, subscriber fires asynchronously, operator sees "arrived" briefly before it flips to "closed". Plus risk of subscriber drift (see [reference_medusa_subscriber_wiring]). |
| (C) Cron sweep that closes batches with full receipts | Rejected. Latency, plus a footgun if cron is paused. |
D4. Status enum: add partially_received, keep arrived semantics narrow¶
Required by the merchant's lifecycle spec — Goods Received (partial) is a real state, not derivable from arrived + a count check at the UI layer.
| Current state | After follow-up |
|---|---|
draft |
unchanged |
ordered, paid, in_transit |
unchanged — collectively the "Pending" UX state |
| (none) | partially_received — Σ received > 0 AND Σ received < Σ expected |
arrived |
now means "fully received but not yet closed" (operator may still be reviewing) |
for_storage |
unchanged — operator-driven sub-state |
closed |
unchanged — auto-fires from D3 OR manual transition |
The transition partially_received → arrived fires inside receiveBatchItem once the count is complete; the operator can then manually push arrived → closed, or D3's auto-transition handles it if no review step is needed.
D5. Asset valuation snapshots cost at receipt time — fees mutating later do NOT re-capitalize¶
Decided to resolve Risk R1 by snapshot-at-receipt rather than mark-to-market.
| Option considered | Decision |
|---|---|
(A) Snapshot effective_cost_per_unit_sgd into import_batch_receipt.effective_cost_at_receipt_sgd ✓ |
Inventory asset balance stays stable when fees mutate. The fee-driven cost delta still flows through COGS via batch_adjustment(reason=forgotten_fee) on already-sold units (the wire-up's late-fee story is preserved). Un-sold-but-already-received stock keeps its receipt-time cost; if you want to re-mark, you do so via an explicit batch_adjustment. |
(B) Mark-to-market (use live effective_cost_per_unit_sgd) |
Rejected. Fee mutations silently move the balance sheet retroactively. Hard to explain to an auditor. |
| (C) FIFO/LIFO lot ledger | Deferred. Bigger lift than this follow-up justifies. The receipt table is the foundation if FIFO is ever needed. |
D6. Overdue-delivery badge on the PO list view¶
When import_batch.expected_delivery_date < NOW()::date AND status ∈ {ordered, paid, in_transit, partially_received}, the list view renders an "Overdue: N days" chip on the row. Chip uses the warning palette (matches Phase 5 design system's amber tone). Sortable column.
| Option considered | Decision |
|---|---|
| (A) Yes, render an overdue chip ✓ | Surfaces lateness without an extra click. Required by the merchant's daily-driver flow — overseas shipments are the typical late case and the operator wants to chase suppliers proactively. |
| (B) No chip — operator checks manually | Rejected. Defeats the purpose of having expected_delivery_date as a first-class field. |
| (C) Notification / email on overdue | Deferred. n8n or Medusa scheduled-job territory; not part of this follow-up. |
D7. Force-receive over expected quantity is allowed via a force=true flag¶
When the supplier overships (10 ordered, 12 arrived — common in TCG bulk buys), the operator can tick a "Receive overage" toggle on the receive form. The workflow then auto-creates a batch_adjustment(quantity_delta=+N, reason=quantity_correction, notes="Auto: supplier overship") for the surplus, then proceeds with the full receipt.
| Option considered | Decision |
|---|---|
(A) Allow with force=true flag ✓ |
Matches real-world supplier behavior (especially Japanese / Chinese consolidators throwing in extras). Keeps audit fidelity via the auto-created batch_adjustment. |
| (B) Reject — operator must manually file the adjustment first | Rejected. Two-step flow for a common case; operators will work around it via the SQL console which is worse. |
(C) Allow silently (no batch_adjustment trail) |
Rejected. Audit-hostile; "where did the extra 2 units come from?" has to be answerable. |
The force flag is off by default; the UI requires an explicit toggle so accidental over-receives don't silently inflate stock.
D8. Receipts on consolidation-output lines are rejected¶
import_batch_item.is_consolidation_output = true items are synthetic — created by consolidationEvent draining other batches, not by a supplier shipment. The receiveBatchItem workflow's validation step rejects these unconditionally with a friendly message ("This line is a consolidation output — stock is already on-hand from the source batches. No receive flow.").
| Option considered | Decision |
|---|---|
| (A) Reject unconditionally ✓ | Defensive guardrail. The synthetic line's stock was already accounted for at consolidation time; receiving against it would double-count. |
| (B) Allow (operator can override) | Rejected. No real-world scenario warrants it; the few edge cases (consolidation needs reversal) are better handled by an explicit reverseConsolidation workflow (out of scope here). |
Schema delta¶
New table: import_batch_receipt¶
| Column | Type | Notes |
|---|---|---|
id |
text | PK, ULID. |
batch_item_id |
text | FK → import_batch_item. Indexed. |
quantity_received |
integer | This event only. Positive (corrections live on batch_adjustment). |
effective_cost_at_receipt_sgd |
numeric(18,4) | Snapshot of the line's effective_cost_per_unit_sgd at receipt time. Used for inventory asset valuation per D5. |
inventory_level_id |
text | Nullable. Module Link → Medusa inventory_level row that was incremented. Lets us trace asset → receipt. |
received_at |
timestamptz | When the receipt was logged. Distinct from created_at to allow back-dating. |
received_by |
text | User ID. |
notes |
text | Free-form (e.g., "Box 2 of 3 arrived"). |
created_at / updated_at |
timestamptz | Standard. |
Columns added to import_batch¶
| Column | Type | Notes |
|---|---|---|
po_date |
date | User-editable PO creation date. Defaults to created_at::date on insert if omitted. |
expected_delivery_date |
date | Nullable. Surfaced on dashboards while status ∈ {ordered, paid, in_transit, partially_received}. |
Columns changed on import_batch_item¶
| Column | Change | Notes |
|---|---|---|
quantity_received |
Drop generated definition; redefine as integer NOT NULL DEFAULT 0 |
Maintained transactionally by receiveBatchItem. |
Status enum: add partially_received¶
draft → ordered → paid → in_transit → partially_received → arrived → for_storage → closed
↑___________________|
(back-edge: more receipts after a "complete" mark is rare but possible
if a corrective re-open happens; handled via manual transition + force=true)
Migration sketch¶
-- Migration20260516010000_po_flow_followup.sql
-- 1. Header date columns
ALTER TABLE import_batch
ADD COLUMN po_date date,
ADD COLUMN expected_delivery_date date;
UPDATE import_batch SET po_date = created_at::date WHERE po_date IS NULL;
ALTER TABLE import_batch ALTER COLUMN po_date SET NOT NULL;
-- 2. Drop the generated-column definition and reset as plain column
ALTER TABLE import_batch_item DROP COLUMN quantity_received;
ALTER TABLE import_batch_item
ADD COLUMN quantity_received integer NOT NULL DEFAULT 0;
-- Backfill from existing batch_adjustment qty_delta history
UPDATE import_batch_item ibi
SET quantity_received = ibi.quantity_ordered + COALESCE(
(SELECT SUM(quantity_delta) FROM batch_adjustment ba WHERE ba.batch_item_id = ibi.id AND ba.quantity_delta IS NOT NULL),
0
);
-- 3. Status enum: add 'partially_received'
ALTER TYPE import_batch_status ADD VALUE 'partially_received' BEFORE 'arrived';
-- 4. New receipt table
CREATE TABLE import_batch_receipt (
id text PRIMARY KEY,
batch_item_id text NOT NULL REFERENCES import_batch_item(id) ON DELETE CASCADE,
quantity_received integer NOT NULL CHECK (quantity_received > 0),
effective_cost_at_receipt_sgd numeric(18,4) NOT NULL,
inventory_level_id text,
received_at timestamptz NOT NULL DEFAULT NOW(),
received_by text NOT NULL,
notes text,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_import_batch_receipt_batch_item_id ON import_batch_receipt(batch_item_id);
CREATE INDEX idx_import_batch_receipt_received_at ON import_batch_receipt(received_at);
A second migration adds the Module Link import_batch_receipt ↔ inventory_level via Medusa's defineLink() — the raw inventory_level_id column is the storage, but cross-module access goes through the link.
New workflow: receiveBatchItem¶
Replaces the wire-up's "Receive into inventory" mutation (PR-6's POST /admin/dashboard/imports/[id]/items/[item_id]/receive). Lives in apps/server/src/modules/dca/workflows/receive-batch-item.ts.
receiveBatchItem(input: {
batch_item_id: string
quantity: number // positive; this receipt event's qty
stock_location_id: string // which warehouse this lands in
notes?: string
received_by: string // user id from session
force?: boolean // D7: allow Σ receipts to exceed expected qty
}) → { receipt: ImportBatchReceipt, new_status: ImportBatchStatus | null, overage_adjustment_id?: string }
Steps (Medusa workflow steps with compensation):
- Validate —
- Load
import_batch_item; reject ifis_consolidation_output = trueper D8 with a 422 + friendly message. - Check
quantity > 0. - Compute
expected = quantity_ordered + Σ batch_adjustment.quantity_deltaandwould_be_total = Σ existing receipts + quantity. - If
would_be_total > expected:- If
force = true(per D7): insert abatch_adjustment(quantity_delta = would_be_total − expected, reason = quantity_correction, notes = "Auto: supplier overship", source = "system_overship")BEFORE the receipt. Setoverage_adjustment_idon the workflow output. Compensation: delete the adjustment. - Else: reject with 422 "Would over-receive by N units; pass
force=trueto record an overship adjustment automatically."
- If
- Load
- Snapshot cost — read current
effective_cost_per_unit_sgdfrom the item. - Insert
import_batch_receiptrow with the snapshot. Compensation: delete the row. - Increment
inventory_level.stocked_quantityvia Medusa's inventory module API (NOT raw SQL —medusa.inventoryService.adjustInventory()or equivalent). Compensation: decrement back. - Update
import_batch_item.quantity_received += quantity. Compensation: subtract back. - Check completion — sum receipts vs. ordered across all batch items.
- If
Σ received == 0for every item: no transition (defensive — shouldn't happen post-step-5). - If
Σ received > 0ANDΣ received < Σ expectedon this item: transition batch →partially_received(if not already there). Compensation: revert to previous status. - If
Σ received == Σ expectedon this item AND all other items are also complete: transition batch →arrived. Compensation: revert. - If a
sales_channel_config.auto_close_on_full_receiptflag is set (deferred — not in this follow-up): transitionarrived → closed. - Emit event
dcaModuleService.import-batch-receipt.created. (Reserved for future subscribers — DCA recompute does NOT subscribe to this; cost is already snapshotted per D5.)
Idempotency: clicking "Receive 3" twice creates two receipt rows totaling 6. The operator sees the running count in the UI before clicking, so accidental double-clicks surface as visible "you've received 6 of 10" not silent doubling. If true idempotency is needed, the UI can send a client-generated dedup key — out of scope for this follow-up.
UI changes¶
Variant picker on line-item form¶
Combobox on /imports/new and /imports/[id] line-item create. Searches /admin/products (or a new /admin/dashboard/variants/search if pagination semantics differ). Shows:
- SKU
- Product title + variant title
- Current
inventory_level.stocked_quantitysummed across stock locations (the "Existing Stock" preview)
On select, writes variant_id into the LineItemCreateRequest (the field already exists on the server-side DTO per apps/server/src/modules/dca/types/api-shapes.ts but is currently not populated by the dashboard form).
Line-item row: incoming qty + invoice-per-unit + landed-cost preview¶
The form fields per the spec:
- Incoming Quantity →
quantity_ordered - Invoice Amount per Unit (foreign) → derived to
invoice_value_original = qty × per_unit - Landed Cost / Unit (read-only) → live preview via
previewLandedCost(batch_draft)service
The preview service is a pure function (no DB writes) that runs the same allocation math as recomputeBatchCosts against the draft form state. Lives in apps/server/src/modules/dca/services/landed-cost-preview.ts. Returns effective_cost_per_unit_sgd for each line given the current draft total_sgd_paid + Σ fees + line invoice values.
Receive panel on PO detail¶
Replaces PR-6's single button with:
- Per-line receive form: Qty input, Notes input, "Receive" button
- Receipt history table per line: date, qty, cost-at-receipt-snapshot, received-by, notes
- Running counter on the line header:
Received: 6 / 10
Receive form additions per D7: an unchecked-by-default "Receive overage" toggle below the qty input. Visible only when would_be_total > expected; ticking it surfaces a tooltip explaining the auto-batch_adjustment that will be filed. The form posts force: true to the workflow.
Auto-transition badges:
Pending(status ∈ {ordered, paid, in_transit})Partially Received: 12 / 30(status = partially_received)Goods Received(status = arrived)Completed(status = closed)
PO list view: overdue chip (D6)¶
When expected_delivery_date < NOW()::date AND status ∈ {ordered, paid, in_transit, partially_received}, the list row renders an Overdue: N days chip in the warning palette next to the status badge. The "Expected delivery" column is sortable and surfaces as a separate column on the list (alongside PO Date, Supplier, Status). No notification / email integration in this follow-up — the chip is enough.
DCA integration — asset valuation policy¶
Reconciles with the existing wire-up DCA story:
- COGS path (already correct):
batch_allocation.cost_per_unit_at_allocationfreezes the cost at order time.batch_adjustment(reason=forgotten_fee)adds deltas for already-sold units when fees mutate later. Unchanged by this follow-up. - Inventory asset path (new): Value of un-sold-but-received stock =
Σ over unallocated_receipts (quantity_received × effective_cost_at_receipt_sgd). Snapshot-at-receipt per D5. - The two paths diverge after a fee mutation, by design:
- If a forgotten fee adds
+0.20 SGD/unitto a line, and 5 units were already sold + 3 units already received-but-not-sold:- COGS: the 5 sold units pick up
+0.20 × 5 = +1.00viabatch_adjustment(reason=forgotten_fee). Already-flowing through P&L correctly. - Asset: the 3 received-but-not-sold units stay at their receipt-time cost. The
+0.20 × 3 = +0.60of the new fee is effectively absorbed into the period's expense (or, more cleanly, surfaces as a separate "fee not absorbed by inventory" line on the P&L — Phase 8 finance work).
- COGS: the 5 sold units pick up
- If the merchant wants those 3 units re-marked to the new cost, they file an explicit
batch_adjustment(reason=cost_correction, cost_delta_per_unit=+0.20)against the receipts. Manual, traceable, no silent balance-sheet movement.
This is the "no surprises in the asset balance" policy. Document explicitly in the Phase 4 findings update.
Proposed PR breakdown¶
Mirrors the wire-up's per-PR cadence. All on tcg-platform; docs PR on ExzenTCG-Homelab lands last.
| PR | Title | Surface |
|---|---|---|
| PR-1 | Add po_date + expected_delivery_date to import_batch; surface on form & list with overdue chip (D6) |
Server migration + admin API + dashboard form fields + list-view chip |
| PR-2 | Drop quantity_received generated column; backfill explicit value |
Server migration only (no UI; behavioral change happens in PR-4) |
| PR-3 | Variant picker on line-item form + stock-level preview + landed-cost preview service | Dashboard combobox + server preview service |
| PR-4 | New import_batch_receipt table + receiveBatchItem workflow (incl. D7 force + D8 consolidation reject) + status enum addition |
Server migration + workflow + admin API endpoint |
| PR-5 | Dashboard receive panel (replaces PR-6's button) + receipt history + status badges + D7 overage toggle | Dashboard-only |
| PR-6 | Auto-transition logic inside receiveBatchItem + integration tests for partial → full + D8 consolidation-reject test |
Server-only (already designed in D3, this PR validates) |
| PR-7 | Docs: this plan locked + findings update on ExzenTCG-Homelab |
Docs hub only |
Estimated size: 4 small + 2 medium + 1 docs. Smaller than the wire-up's 8 PRs because the schema is already in place.
Risks¶
Risk R-A — Drop-and-recreate of quantity_received loses history mid-flight¶
PR-2 drops the generated column and recreates as a plain integer. If any in-flight POs exist on staging at migration time with non-zero Σ batch_adjustment.quantity_delta, the backfill (UPDATE … SET quantity_received = quantity_ordered + COALESCE(SUM(...), 0)) covers them — but operators currently using the manual-receive button need to know that a fresh receipt event must be logged post-migration for any not-yet-handled stock. Mitigation: Drain in-flight POs before deploying PR-2; staging has known state (the smoke-validated batch from wire-up).
Risk R-B — Status enum migration is non-trivial under load¶
ALTER TYPE … ADD VALUE in Postgres requires the new value to be committed before it's usable. Inside a transaction with subsequent UPDATEs that use the new value, the migration must split into two transactions (or use a BEGIN; ALTER TYPE; COMMIT; BEGIN; UPDATE …; COMMIT; pattern). Medusa's migration runner handles this; flag in the migration's comments.
Risk R-C — Overship batch_adjustment confuses cost recompute¶
D7's auto-batch_adjustment(reason=quantity_correction) for supplier overships will trigger recomputeBatchCosts via the wire-up's subscriber. The recompute will see quantity_received increase and re-divide allocated fees across the larger denominator — making per-unit cost cheaper for overshipped batches. This is correct behavior (you got more units for the same fee pool), but flag in the integration test so the expected-value assertion is right.
Links¶
- Phase 4 Wire-up Plan — predecessor; design decisions on subscriber/receive-button/optional-fees.
- Phase 4 Wire-up Implementation — PR-1 → PR-8 that shipped 2026-05-15.
- Phase 4 Wire-up Findings — closeout + the three operator-input-blocked follow-ups.
- Data Model — §4.2 DCA tables, §7.1
recomputeBatchCostsworkflow, §10.2 Imports CSV → schema mapping. - Phase 4 Findings — skeleton merge notes; original open questions Ivan flagged for wire-up.