Status: 🟡 Live spine, dormant edges, one dead status. Creating POs, itemized
lines, committed-cost rollup, the release → vendor-approval → amendment lifecycle,
per-line receiving, and bid→PO / CO→PO are all 🟢 live against real data. The
e-signature approval leg is real code that stays ⚪ dormant until a per-org
Documenso credential is set. Two status values are effectively unreachable in the
product:received/closedhave no wired UI control, andbilledis dead
code — never assigned anywhere. And POs ❌ never sync to QuickBooks by design.
Audience: engineers + finance-literate operators (PM / office). This is the deep
reference for how a Job commits cost to a vendor or sub before that cost becomes
an actual Bill. Companion pages: Job costing & the financial loop
(where committed cost lands in the margin engine), Reports & Finance
(the hub this lives under), and Bid management (covered
below — awarding a bid spawns a PO).
Legend: 🟢 live · 🟡 WIP / config-gated · ⚪ dormant / credential-gated · ❌ not built.
House terms: a unit of work is a Job (jobs); the person you build for is a
Client (clients). A Vendor is a supplier you buy materials from; a Sub
is a subcontractor crew. Never "Project", never "Customer".
Procurement is not a standalone hub — it's the Purchase Orders folder of the Job
Command Center plus a company-wide roll-up list.
purchase_orders.command_center() (app/services/purchase_orders.py:480) intotemplates/job_command_center.html, reached at/portal/jobs/{job_id}?view=command&folder=po. The old standalone/portal/jobs/{id}/procurement route is RETIRED and now 302-redirects into theapp/routers/procurement.py:105)./portal/purchase-orders (app/routers/procurement.py:117,purchase_orders.org_dashboard() → app/services/purchase_orders.py:564)./po/{token}app/routers/procurement.py:832). The vendor opens this from their email.The whole builder-facing surface is gated two ways: role (pm or office only)
and entitlement — require_feature("procurement") (app/routers/procurement.py:37).
Orgs without the procurement feature never see it.
All of procurement is hub-owned — OpenProject has no vendor entity and its Budgets
API is a read-only stub, so none of this round-trips to OP (app/models/cost.py:1).
| Model | Table | Purpose |
|---|---|---|
PurchaseOrder |
purchase_orders |
A committed (not-yet-billed) cost to a vendor/sub (app/models/cost.py:125) |
POLineItem |
purchase_order_lines |
One itemized line — qty × unit price, with its own cost code + category (app/models/cost.py:223) |
POVersion |
purchase_order_versions |
Amendment snapshot: original total / delta / new total (app/models/cost.py:280) |
Vendor |
vendors |
A supplier — the contact-name field OpenProject lacks (app/models/cost.py:88) |
Bill |
bills |
The actual AP cost a PO eventually becomes (app/models/cost.py:310) |
BillLineItem |
bill_line_items |
A bill line, optionally matched back to a POLineItem (app/models/cost.py:414) |
CostCode |
cost_codes |
The org's chart-of-cost-codes library (app/models/cost.py:43) |
Money is stored in cents on the line items. PurchaseOrder.amount_cents is a
recomputed cache (recalc_total(), app/services/purchase_orders.py:71) so
existing job-costing reads keep working — the source of truth is
computed_total_cents = sum(active line amounts) (app/models/cost.py:196).
A PO is created as a draft from the command center's PO editor. The form posts
parallel line arrays (line_desc[], line_qty[], line_price[], line_code[],
line_cat[]) parsed by _lines_from_form() (app/routers/procurement.py:72), then
create_po() builds it (app/routers/procurement.py:419 →
app/services/purchase_orders.py:121).
Key facts:
PO-0001, PO-0002… via next_number()app/services/purchase_orders.py:62). It counts existing rows, so it is notVendor (materials) or a Sub (crew) — vendor_id andsub_id are both nullable FKs; the approval path branches on which is setapp/models/cost.py:131)._vendor_for_name() (app/services/purchase_orders.py:348). There is noapp/routers/procurement.py:448).schedule_item_id,app/models/cost.py:159) so its delivery date paints a marker on the Job's Ganttschedule_viz.job_procurement_gantt, called fromapp/services/purchase_orders.py:553).A PO's value counts as committed cost — money promised but not yet actualized —
while its status is in PO_COMMITTED_STATUSES (app/models/base.py:158):
released, sub_approved, draft_amended, received, sent
draft is excluded (not yet a commitment) and billed is deliberately excluded
to avoid double-counting once the cost flows to actuals via a Bill. The command
center's committed KPI is sum(amount for open POs) (app/services/purchase_orders.py:508);
the same rolls up org-wide in org_dashboard() (app/services/purchase_orders.py:585).
This committed figure is what Job costing folds into projected
cost alongside Bills (actuals) and labor.
PurchaseOrder.is_open is the property that answers "does this count?"
(app/models/cost.py:200).
Lines were promoted out of a legacy JSON blob (PurchaseOrder.lines) into real
POLineItem rows so we can do per-line partial receiving, per-line billing, and
amendments that append lines while preserving originals (app/models/cost.py:223).
Old rows are lazily migrated on read by ensure_line_items()
(app/services/purchase_orders.py:76) — idempotent.
Each line carries its own cost_code and category (app/models/cost.py:239),
independent of the PO header. This is what lets a single PO attribute to multiple
cost-code rows in job costing. amount_cents = round(quantity × unit_price_cents)
is a computed property (app/models/cost.py:258) — the line total is never stored,
always derived.
While a PO is still a draft or draft_amended, editing replaces all lines
wholesale via replace_lines() (app/services/purchase_orders.py:94); once approved,
the line editor is locked (app/routers/procurement.py:516). New lines can still
arrive after approval, but only through an amendment (below).
Vendor (app/models/cost.py:88) carries: name, contact_name, email, phone,
address, trade, plus:
business_number and is_subcontractor for CRA contract-paymentapp/models/cost.py:100). A sub's payments are reportable; this flag letsrating (1–5) + rating_note (app/models/cost.py:103),Depth caveat (🟡): vendors are created implicitly, not managed. Every creation
path is a get-or-create by name — from the PO form (_vendor_for_name,
app/services/purchase_orders.py:348), from an awarded bid, from bill capture
(_get_or_create_vendor, app/routers/cost.py:95). There is no central "Vendors"
CRUD screen for editing contact details, dedup, or reviewing the rating/scorecard in
one place. The delivery-performance capture fields exist on the PO
(expected_delivery, delivered_at, quality_ok — app/models/cost.py:153) but are
not aggregated into a vendor scorecard view.
The canonical status enum is POStatus (app/models/base.py:134). The intended
flow:
draft → released → sub_approved → (draft_amended → sub_approved)* → received → closed
│
└→ declined (terminal)
STATUS_META maps each status to a UI label + tone (app/services/purchase_orders.py:31);
the command-center stepper renders draft → released → approved → received → closed
(templates/job_command_center.html:838).
| Transition | Trigger | Sets status | Code |
|---|---|---|---|
| draft → released | "Release for approval" button | released |
release() app/services/purchase_orders.py:147 |
| released → sub_approved | Vendor e-signs, or guarded manual approve | sub_approved |
mark_approved() app/services/purchase_orders.py:173 |
| → draft_amended | A change order appends lines | draft_amended |
amend_po_from_co() app/services/purchase_orders.py:324 |
| → declined | Vendor or office declines | declined |
decline() app/services/purchase_orders.py:185 |
| → received / closed | manual set_status route |
received/closed |
app/routers/procurement.py:612 |
| → billed | nothing — never assigned | — | (dead) |
Three sharp edges to know:
received and closed have no wired UI. The set_status routeapp/routers/procurement.py:612) is the only thing that can set these values,sub_approved forever, even when fully delivered. The "Received" step lightsreceived_pct >= 100templates/job_command_center.html:837), decoupled from the actual status enum.delivered_at but never changesstatus.billed is dead code. It's defined in the enum (app/models/base.py:149) andSTATUS_META (app/services/purchase_orders.py:38), but a repo-widebilled. The Bill phasesent is a legacy alias for released and reads identically everywhereapp/models/base.py:152).Releasing a PO mints a public_token and (if Documenso is configured) opens an e-sign
request (app/services/purchase_orders.py:147; esign_service.request_po_signature).
The vendor opens /po/{token} and signature is approval — the public approve
handler refuses to approve without opening a live signing session
(app/routers/procurement.py:874; no more silent click-through). Signing policy is
dual (vendor + our rep counter-sign) or vendor_only (app/models/cost.py:173),
chosen when sending (app/routers/procurement.py:645).
Guarded manual approval is the escape hatch for pen-and-paper vendors or a
Documenso outage: /po/{po_id}/approve requires a written reason, voids any
in-flight signature to avoid an "approved-while-pending" collision, and is fully
audit-logged (app/routers/procurement.py:542). See
Documents & e-sign for the Documenso plumbing.
Receiving is per-line and partial. The command-center "Receive items" panel posts
recv_<line_id> = received_quantity for each line
(templates/job_command_center.html:957) to /po/{po_id}/receive
(app/routers/procurement.py:587), which calls receive_lines()
(app/services/purchase_orders.py:195).
received_quantity; received_fraction and fully_received areapp/models/cost.py:270).PurchaseOrder.received_pct is value-weighted — the share of PO dollars received,app/models/cost.py:205).received_pct >= 100, receiving stamps delivered_at (once)app/services/purchase_orders.py:200) — but, as noted above, does not flip thereceived.is_late flags a PO past its expected_delivery that isn't fully receivedapp/models/cost.py:215) — this drives the late-delivery KPI and the red GanttThe RFQ side is bid management — a BidRequest on a Job with a public link; subs
submit Bids (company + amount + optional line items + quote PDFs); the office
compares and awards one (app/models/bid.py). Bids render inside the same command
center (app/services/purchase_orders.py:526).
Awarding a bid spawns a draft PO. The award route
(app/routers/jobextras.py:383) flips the bid to awarded, then calls
create_po_from_bid() (app/services/purchase_orders.py:208), which:
BidRequest.cost_code and category onto the PO so committed costapp/models/bid.py);PurchaseOrder.source_bid_id andBid.purchase_order_id (app/models/cost.py:163).The award is best-effort tolerant: if PO creation hiccups, the award still succeeds
(app/routers/jobextras.py:410). The office then reviews the draft PO and releases it
normally.
Change orders also spawn / amend POs (Phase D). A CO can create a brand-new draft
PO (create_po_from_co(), app/services/purchase_orders.py:258) or amend an existing
one — amend_po_from_co() (app/services/purchase_orders.py:284) appends the CO's
builder-cost lines (not the marked-up client price), bumps current_version,
snapshots a POVersion, flips to draft_amended, and re-opens signing. Amendments are
idempotent (guarded by co.applied_to_po) and never reduce a total below what's already
billed (app/services/purchase_orders.py:321).
A PO is a commitment; a Bill is the actual cost that lands against it. The
link is Bill.po_id (app/models/cost.py:320), and — more precisely — each
BillLineItem can match a specific POLineItem via po_line_item_id
(app/models/cost.py:433).
Matching drives a billed-to-date rollup: bills.py sums matched bill lines into
POLineItem.billed_cents (app/services/bills.py:71) so it can flag over-billing
when a line's bills exceed its PO value (app/services/bills.py:90). This is
idempotent — it survives re-approve / edit / unmatch.
Crucially, billing does not change PO status (the billed status is dead, per
above). A fully-billed PO still reads sub_approved; the money simply flows to actuals
through the Bill. Once approved/paid, a Bill feeds job-costing actuals — see
Job costing.
Purchase orders never sync to QuickBooks Online. The QBO sync layer
(app/services/quickbooks_sync.py) pushes exactly four things and there is no
push_purchase_order:
push_invoice() (app/services/quickbooks_sync.py:537)push_bill() (app/services/quickbooks_sync.py:642)app/services/quickbooks_sync.py:355, :448)_ensure_customer :102, _ensure_vendor :118)This is correct by design: a PO is a commitment, not a booked transaction. QBO
records the actual expense when the Bill is pushed — pushing the PO too would
double-count. A vendor only appears in QBO once you actually bill against it. See
Job costing for the full accounting-sync picture.
file:line)app/routers/procurement.py — all PO routes (create, lifecycle, receive, e-sign, PDF, public portal)app/services/purchase_orders.py — the PO engine (create, release, approve, receive, bid→PO, CO→amend, read models)app/models/cost.py — PurchaseOrder (:125), POLineItem (:223), POVersion (:280), Vendor (:88), Bill (:310)app/models/base.py:134 — POStatus enum; :158 — PO_COMMITTED_STATUSESapp/models/bid.py — BidRequest / Bid (RFQ side)app/services/bills.py — PO-line matching + over-billing detectionapp/services/quickbooks_sync.py — QBO push (invoices/bills/payroll only; no PO)templates/job_command_center.html — the PO folder UI