4 PO Lifecycle
Hardik edited this page 2026-06-21 12:23:09 +05:30

PO Lifecycle (State Machine)

Every purchase-order status change is enforced by a single module, lib/po-state-machine.ts. No transition happens outside it, so the graph is guaranteed in one place, and each transition is recorded as a POAction audit row. The state machine also declares the roles allowed to perform each action, whether a note is required, and which email side-effects fire.

Canonical flow

DRAFT → SUBMITTED → MGR_REVIEW → MGR_APPROVED → SENT_FOR_PAYMENT → PAID_DELIVERED → CLOSED
                       ↓↑                              ↕                  ↕
              EDITS_REQUESTED / REJECTED        PARTIALLY_PAID    PARTIALLY_CLOSED
              / VENDOR_ID_PENDING
  • SUBMITTED is transient — the server action auto-advances it to MGR_REVIEW immediately.
  • Partial payments (PARTIALLY_PAID) and partial receipts (PARTIALLY_CLOSED) loop until the full amount/quantity is settled.
  • Imported POs are created directly in CLOSED (historical record, bypassing approval). See Purchase Orders.
  • Terminal states: REJECTED, CLOSED.

Transition table

Exactly as encoded in TRANSITIONS in lib/po-state-machine.ts:

From Action To Allowed roles Note? Email side-effects
DRAFT submit SUBMITTED TECHNICAL, MANNING, MANAGER, SUPERUSER Manager
SUBMITTED (auto) MGR_REVIEW system
MGR_REVIEW approve MGR_APPROVED MANAGER, SUPERUSER Submitter + Accounts
MGR_REVIEW approve_with_note MGR_APPROVED MANAGER, SUPERUSER Submitter + Accounts
MGR_REVIEW reject REJECTED MANAGER, SUPERUSER Submitter
MGR_REVIEW request_edits EDITS_REQUESTED MANAGER, SUPERUSER Submitter
MGR_REVIEW request_vendor_id VENDOR_ID_PENDING MANAGER, SUPERUSER Submitter
VENDOR_ID_PENDING provide_vendor_id MGR_REVIEW TECHNICAL, MANNING, ACCOUNTS, MANAGER, SUPERUSER Manager
EDITS_REQUESTED submit SUBMITTED TECHNICAL, MANNING, MANAGER, SUPERUSER Manager
MGR_APPROVED process_payment SENT_FOR_PAYMENT ACCOUNTS, SUPERUSER Submitter + Manager
SENT_FOR_PAYMENT mark_paid PAID_DELIVERED ACCOUNTS, SUPERUSER, MANAGER Submitter + Manager
SENT_FOR_PAYMENT mark_partial_payment PARTIALLY_PAID ACCOUNTS, SUPERUSER, MANAGER
PARTIALLY_PAID mark_paid PAID_DELIVERED ACCOUNTS, SUPERUSER, MANAGER
PARTIALLY_PAID mark_partial_payment PARTIALLY_PAID ACCOUNTS, SUPERUSER, MANAGER
PARTIALLY_PAID confirm_receipt CLOSED TECHNICAL, MANNING, SUPERUSER, MANAGER
PARTIALLY_PAID confirm_partial_receipt PARTIALLY_PAID TECHNICAL, MANNING, SUPERUSER, MANAGER
PAID_DELIVERED confirm_receipt CLOSED TECHNICAL, MANNING, SUPERUSER, MANAGER Manager + Accounts
PAID_DELIVERED confirm_partial_receipt PARTIALLY_CLOSED TECHNICAL, MANNING, SUPERUSER, MANAGER
PARTIALLY_CLOSED confirm_receipt CLOSED TECHNICAL, MANNING, SUPERUSER, MANAGER Manager + Accounts
PARTIALLY_CLOSED confirm_partial_receipt PARTIALLY_CLOSED TECHNICAL, MANNING, SUPERUSER, MANAGER

Note the shipped product is broader than the original spec: SUPERUSER and MANAGER can also create/submit and process payments, and partial payment/receipt loops exist. The table above is the authoritative encoding.

Module API

getTransition(from, action)            // → Transition | null
canPerformAction(from, action, role)   // → boolean (status + role gate)
getAvailableActions(status, role)      // → POAction[]  (drives which buttons render)
requiresNote(from, action)             // → boolean

getAvailableActions is what the UI uses to decide which action buttons to show for the current PO status and the signed-in user's role.

Side-effects

Side-effects are declared per transition (EMAIL_MANAGER, EMAIL_SUBMITTER, EMAIL_ACCOUNTS, EMAIL_SUBMITTER_AND_MANAGER) and dispatched via lib/notifier.ts — never directly from UI handlers. See Notifications for the event→recipient matrix and templates.

Two non-email side-effects worth calling out, applied in the server actions:

  • Product price auto-update — on payment confirmation, each line item with a productId updates Product.lastPrice/lastVendorId and upserts the per-vendor price; a PRODUCT_PRICE_UPDATED action is logged. See Inventory and Catalogue.
  • Inventory increment — at approval, ordered quantities are added to ItemInventory when the PO has a siteId.

Status badges

Each status renders a colour-coded pill (components/po/po-status-badge.tsx):

Status Colour intent
DRAFT neutral grey
SUBMITTED / MGR_REVIEW blue (in-progress)
VENDOR_ID_PENDING orange/warning
EDITS_REQUESTED yellow/warning
MGR_APPROVED teal
SENT_FOR_PAYMENT purple
PARTIALLY_PAID purple-adjacent
PAID_DELIVERED blue-green
PARTIALLY_CLOSED green-adjacent
CLOSED green/success
REJECTED red/danger
CANCELLED red/danger (greyed row in history)

Cancellation & supersede

Orthogonal to the lifecycle above: a MANAGER or SUPERUSER can cancel a PO from any state (permission cancel_po; lib/po-state-machine.tscanCancel). The UI requires typing the word cancel and a mandatory reason in a confirmation modal (components/po/cancel-po-controls.tsx); the action (cancelPo) sets a terminal CANCELLED status with cancelledAt/cancellationReason, logs a CANCELLED audit row, and notifies the submitter + Accounts.

A cancelled PO's value drops out of every spend tracker/graph automatically — those filter on POST_APPROVAL_STATUSES or explicit whitelists, none of which include CANCELLED. Cancelled POs stay visible (greyed in history) and exportable, but the PDF/XLSX export carries a diagonal CANCELLED watermark.

A cancelled PO may optionally be superseded by the existing PO that replaces it (supersedePo, by PO number; self-referential supersededById). No vessel/account/vendor match is enforced, the link can be added any time, and the replacement shows the reciprocal "supersedes" link.

Approval-time inventory increments are not reversed on cancel yet — deferred (see Tech Debt; inventory is feature-flagged off).