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
SUBMITTEDis transient — the server action auto-advances it toMGR_REVIEWimmediately.- 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
productIdupdatesProduct.lastPrice/lastVendorIdand upserts the per-vendor price; aPRODUCT_PRICE_UPDATEDaction is logged. See Inventory and Catalogue. - Inventory increment — at approval, ordered quantities are added to
ItemInventorywhen the PO has asiteId.
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.ts → canCancel). 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).
Pelagia Portal (PPMS)
Overview
Build & Run
System
Product
- Feature Catalogue
- Pages and Navigation
- Workflows
- Purchase Orders
- Vendors and GST Lookup
- Inventory and Catalogue
- Inventory on Approval
- Notifications
- File Storage
- Design System
Planned
Quality
Ops
Engineering
Pelagia Portal (PPMS) — internal purchase-order management. Self-hosted on pms1, live at pms.pelagiamarine.com. This wiki tracks the shipped product; authoritative sources are the repo code, App/CLAUDE.md, Docs/, and CHANGELOG.md.