Skip to content

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:

  1. Item search on the line-item form. Schema supports it (import_batch_item.variant_id is a Module Link to product_variant), but /imports/new and the line-item create endpoint do not render a variant picker. Line items today are anonymous — they carry money but no product reference.
  2. Iterative partial receiving. PR-6's receive button writes the line's absolute qty_received once. 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.
  3. PO date + expected delivery date as first-class header fields. Today import_batch.paid_at and import_batch.arrived_at are the only dates on the header. There is no user-editable PO creation date and no expected-arrival date for the Pending state 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_quantity for 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 closed when Σ received == Σ expected across 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 existing consolidation_event remains the default.
  • sales_channel_config.auto_receive toggle — 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):

  1. Validate
    • Load import_batch_item; reject if is_consolidation_output = true per D8 with a 422 + friendly message.
    • Check quantity > 0.
    • Compute expected = quantity_ordered + Σ batch_adjustment.quantity_delta and would_be_total = Σ existing receipts + quantity.
    • If would_be_total > expected:
      • If force = true (per D7): insert a batch_adjustment(quantity_delta = would_be_total − expected, reason = quantity_correction, notes = "Auto: supplier overship", source = "system_overship") BEFORE the receipt. Set overage_adjustment_id on the workflow output. Compensation: delete the adjustment.
      • Else: reject with 422 "Would over-receive by N units; pass force=true to record an overship adjustment automatically."
  2. Snapshot cost — read current effective_cost_per_unit_sgd from the item.
  3. Insert import_batch_receipt row with the snapshot. Compensation: delete the row.
  4. Increment inventory_level.stocked_quantity via Medusa's inventory module API (NOT raw SQL — medusa.inventoryService.adjustInventory() or equivalent). Compensation: decrement back.
  5. Update import_batch_item.quantity_received += quantity. Compensation: subtract back.
  6. Check completion — sum receipts vs. ordered across all batch items.
  7. If Σ received == 0 for every item: no transition (defensive — shouldn't happen post-step-5).
  8. If Σ received > 0 AND Σ received < Σ expected on this item: transition batch → partially_received (if not already there). Compensation: revert to previous status.
  9. If Σ received == Σ expected on this item AND all other items are also complete: transition batch → arrived. Compensation: revert.
  10. If a sales_channel_config.auto_close_on_full_receipt flag is set (deferred — not in this follow-up): transition arrived → closed.
  11. 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_quantity summed 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_allocation freezes 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/unit to 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.00 via batch_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.60 of 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).
  • 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.