Skip to content

Tenant API v1 — Purchase Orders ​

Base path: /api/tenant/v1

Middleware: auth:tenant-api, tenant.user, not.suspended, verified, module:purchase-orders, plus permission middleware / policies.

Requires the Vendors module (hard module_dependencies row) — Marketplace blocks installing Purchase Orders on a workspace that doesn't already have Vendors, since every purchase order requires a vendor.

Assignee scoping: without purchase-orders.assign (and not superadmin), list/stats/view/update/send/receive/cancel only include purchase orders where assigned_to is the current user.

Stats ​

GET /purchase-orders/stats ​

Same filters as list (minus pagination/sort). Response:

json
{
  "total_purchase_orders": 0,
  "my_purchase_orders": 0,
  "draft": 0,
  "sent": 0,
  "partially_received": 0,
  "received": 0,
  "cancelled": 0,
  "scope": "org | mine"
}

Purchase Orders CRUD ​

GET /purchase-orders ​

Query: search (matches title or number), status (draft|sent|partially_received|received|cancelled), vendor_id, assigned_to (unassigned or user id), my_purchase_orders, trashed (true|only), sort, direction, page, per_page.

List items include status, currency, subtotal/tax_total/total, order_date, expected_date, vendor ref, assignee/creator refs, and latest_note.

POST /purchase-orders ​

Body: vendor_id (required, must belong to the tenant and the Vendors module must be entitled), title (required), notes, currency (3-letter, default USD), order_date, expected_date (dates), assigned_to, lines (array of { description, quantity, unit_price, tax_rate, sort_order, product_id? }). product_id is optional and must reference an entitled, tenant-scoped Product when supplied.

subtotal, tax_total, and total are computed server-side from lines — do not send them. Status always starts at draft; number is auto-generated (PO-00001, configurable via the purchase_orders_number_prefix tenant setting).

GET /purchase-orders/{id} ​

Includes vendor, assignee, creator, lines, notes, and timeline activities. Embedded notes and timeline/domain activities are newest-first (created_at DESC, then id DESC).

PUT /purchase-orders/{id} ​

Partial update of draft purchase orders only — including replacing the full lines set (recalculates totals). Non-draft purchase orders return 422 on status (Only draft purchase orders can be edited.). Assignment after send uses POST /purchase-orders/{id}/assign.

DELETE /purchase-orders/{id} ​

Soft delete. Permission: purchase-orders.delete.

POST /purchase-orders/{id}/restore ​

Permission: purchase-orders.restore.

DELETE /purchase-orders/{id}/force ​

Permanently delete a soft-deleted purchase order. Permission: purchase-orders.force.delete (owner/superadmin only by default).

Actions ​

POST /purchase-orders/{id}/assign ​

{ "assigned_to": number|null }

Permission: purchase-orders.assign.

POST /purchase-orders/{id}/send ​

Transitions draft → sent. Backfills order_date to today if unset. Permission: purchase-orders.send (assignee-scoped unless the actor has purchase-orders.assign or is superadmin). After send, PDF download and email vendor are available (separate endpoints).

GET /purchase-orders/{id}/pdf ​

Download branded purchase order PDF. Permission: purchase-orders.view (assignee-scoped). Available after Send.

POST /purchase-orders/{id}/email ​

Email the purchase order to the vendor with optional PDF attachment. Body matches billing document email (to, cc, subject, message, attach_pdf). Permission: purchase-orders.send. Throttled (billing-document-email). Sent (and later non-draft) statuses as implemented by the service.

POST /purchase-orders/{id}/receive ​

json
{
  "status": "partially_received"|"received",
  "warehouse_id": 2,
  "lines": [
    { "id": 10, "quantity": 2.5 },
    { "id": 11, "quantity": 2, "serials": ["SN-1", "SN-2"] },
    { "id": 12, "quantity": 5, "lot": "BATCH-A", "lot_expires_at": "2027-06-01" }
  ]
}

Transitions sent → partially_received|received or partially_received → received. Any other status value is rejected with a 422 before the state machine is even evaluated. warehouse_id is optional for receipt posting. Optional lines maps line id → quantity received this call (must be > 0 and not exceed remaining), with optional serials[] / lot / lot_expires_at for serial/lot-tracked products. When lines is present, header status is derived from remaining quantities after updating quantity_received. When lines is omitted: partially_received is acknowledgement-only; received completes remaining quantities on all lines — except when any remaining line is serial/lot tracked (422 requiring per-line serials/lots). Permission: purchase-orders.receive (assignee-scoped unless the actor has purchase-orders.assign or is superadmin). When Products and Inventory are entitled, stock-in posts for the receive deltas on linked track_stock product lines (including serial/lot metadata), to warehouse_id or the default warehouse. When Accounting is entitled, the same deltas post one journal on the receipt: tracked lines Dr 1200 / Cr 2000, other lines Dr 6000 / Cr 2000, at quantity × unit_price (tax_rate is not posted). A purchase order that already has an expense skips that journal. Without Inventory, Accounting still stores a receipt (warehouse_id null) so the journal can be posted once. Line resources expose quantity_received, quantity_remaining, and product track_serials / track_lots.

POST /purchase-orders/{id}/cancel ​

Transitions draft|sent|partially_received → cancelled. Permission: purchase-orders.cancel (assignee-scoped unless the actor has purchase-orders.assign or is superadmin).

POST /purchase-orders/{id}/status ​

{ "status": "draft"|"sent"|"partially_received"|"received"|"cancelled" }

Authorization depends on the target status:

  • sent → purchase-orders.send
  • partially_received / received → purchase-orders.receive (same service path as /receive, including inventory posting when entitled; no warehouse_id on this body — uses the default warehouse)
  • cancelled → purchase-orders.cancel
  • other → purchase-orders.update

Rejects disallowed transitions with a 422 validation error on status. Records a status_changed timeline entry.

POST /purchase-orders/{id}/notes ​

{ "body": string }

Permission: purchase-orders.update.

GET /purchase-orders/{id}/timeline ​

Domain timeline entries (created, updated, assigned, status_changed, note_added, deleted, restored, converted).

Convert to expense (soft dependency on Expenses) ​

POST /purchase-orders/{id}/convert ​

Permission: purchase-orders.convert.

Creates a draft Expense from the purchase order and returns it (201). Requires the Expenses module to be entitled on the workspace — this is a soft, call-time check (no module_dependencies row), so Purchase Orders keeps working with Expenses uninstalled and this single endpoint returns a 422 until Expenses is installed:

json
{
  "message": "The Expenses module is not available for this workspace.",
  "errors": { "purchase_order": ["The Expenses module is not available for this workspace."] }
}

Other failure cases (also 422):

  • The purchase order's status is not sent, partially_received, or received (draft and cancelled are not convertible).
  • The purchase order has already been converted (one-time — checked by an existing Expense row with this purchase_order_id, including soft-deleted ones).
  • A receipt for this purchase order already has journal_entry_id (the receipt is on the ledger as accounts payable).

Field mapping onto the new expense: title ← PO title, amount ← PO total, tax_amount ← PO tax_total, currency/vendor_id/assigned_to/notes ← copied from the PO, category_id defaults to the seeded Other expense category, expense_date defaults to today. The conversion is recorded as a converted timeline entry on the purchase order and the created expense's id is exposed on the purchase order resource as converted_expense_id once loaded.

Official documentation for the EloSync SaaS Platform.