Skip to content

Purchase Orders — Developer Guide

Mirror of the Estimates developer guide (assignee scope, notes, domain timeline, hard module dependency, first-class lines child table, status machine) and the optional related-record pickers — swapped for a single required vendor_id. A soft convert-to-expense action was added in Phase 4 Milestone 3 (see Expenses developer guide). Prefer copying those patterns over inventing new ones.

Backend layout

PiecePath
Modelsapp/Models/PurchaseOrder.php, PurchaseOrderLine, PurchaseOrderNote, PurchaseOrderActivity
EnumsPurchaseOrderStatusEnum, PurchaseOrderActivityTypeEnum
Serviceapp/Services/Tenant/PurchaseOrderService.php (+ ScopesToAssignee, RetriesOnDuplicateNumber)
Controllerapp/Http/Controllers/Tenant/Api/V1/PurchaseOrderController.php
Requestsapp/Http/Requests/Tenant/Api/V1/PurchaseOrder/*
Resourcesapp/Http/Resources/Tenant/Api/V1/PurchaseOrder/*
Policyapp/Policies/PurchaseOrderPolicy.php
Eventsapp/Events/PurchaseOrder*.php
Subscriberapp/Listeners/PurchaseOrderEventSubscriber.php (audit + assignment notification)
Notificationsapp/Notifications/Tenant/PurchaseOrder/PurchaseOrderAssignedNotification.php
Link ruleapp/Rules/LinkableVendor.phpvendor_id is required, tenant-scoped, and validates the Vendors module is entitled
Dependency migrationdatabase/migrations/2026_08_01_120006_add_purchase_orders_vendors_dependency.php (mirrors estimates → invoices)
ConvertPurchaseOrderService::convertToExpense() (soft Expenses entitlement check, no hard dependency) — see Expenses developer guide
Convert permission migrationdatabase/migrations/2026_08_01_130005_add_purchase_orders_convert_permission.php
FactoriesPurchaseOrderFactory, PurchaseOrderLineFactory, PurchaseOrderNoteFactory, PurchaseOrderActivityFactory
Teststests/Feature/Tenant/PurchaseOrder/PurchaseOrderTest.php, tests/Feature/Central/Module/PurchaseOrdersModuleDependencyTest.php

Domain notes

  • Hard dependency: Purchase Orders declares a required module_dependencies row on Vendors — Marketplace install is blocked until Vendors is entitled, same pattern as Estimates → Invoices.
  • Status machine lives on PurchaseOrderStatusEnum::allowedTransitions() / canTransitionTo(): draft → sent|cancelled, sent → partially_received|received|cancelled, partially_received → received|cancelled, received/cancelled are terminal. PurchaseOrderService::transitionStatus() throws ValidationException (422, status field) for disallowed transitions.
  • send() backfills order_date to today if it wasn't already set, then transitions draft → sent.
  • receive() only accepts a target status of partially_received or received — any other value throws a 422 validation error before even checking the state machine.
  • Content updates (PUT) and line sync are draft-only via PurchaseOrder::isEditable() (status === draft). Assignment remains available after send via POST …/assign.
  • POST …/status route middleware requires purchase-orders.update; the controller then re-checks the specific gate per target status (sentsend, partially_received/receivedreceive, cancelledcancel, otherwise update) before delegating to PurchaseOrderService::changeStatus(). Receive statuses (partially_received / received) are routed through receive() so /status and /receive share the same inventory posting and atomicity guarantees (mirrors invoices routing void through void()).
  • send / receive / cancel policies are assignee-scoped (same as view / update) unless the actor has purchase-orders.assign or is superadmin.
  • Lines are a first-class child table (purchase_order_lines), not embedded JSON — each row is { description, quantity, unit_price, tax_rate, sort_order, product_id? }. product_id is nullable and, when supplied, is tenant/entitlement validated by LinkableProduct. subtotal/tax_total/total are recomputed server-side from lines on create/update, same as Estimates/Invoices/Quotations.
  • Assignee scoping via ScopesToAssignee with purchase-orders.assign.
  • purchase-orders.force.delete is not granted to any default role — owner/superadmin only.
  • vendor_id is required (unlike Estimates' optional contact/company) and validated via LinkableVendor — must exist, belong to the tenant, and the Vendors module must be entitled.
  • Auto-numbering: PurchaseOrderService::nextNumber() reads the purchase_orders_number_prefix tenant setting (default PO-), then zero-pads a running count to 5 digits — same pattern as Estimates/Invoices/Payments. Editable under Settings → General → Document number prefixes (PUT /settings). purchase_orders has a unique(tenant_id, number) DB index; create() retries up to 3 times via the shared RetriesOnDuplicateNumber trait on a duplicate-key collision.
  • Receiving bridgepartially_received remains acknowledgement-only. On received, when Products and Inventory are entitled, StockService::postPurchaseOrderReceipt() posts stock-in once for linked track_stock product lines, using the optional receive warehouse_id or the default warehouse. receive() runs status transition + stock post in one DB transaction with lockForUpdate() on the purchase order (and again inside receipt posting) so concurrent receives cannot double-post and a failed stock post rolls the status back.
  • Convert to expense is soft, one-way, one-time: PurchaseOrderService::convertToExpense() checks EntitlementService::hasModule($tenant, 'expenses') at call time (not a hard module_dependencies row), rejects if an Expense already references this purchase_order_id (withTrashed() check), and only allows sent/partially_received/received source statuses via PurchaseOrder::isConvertible(). PurchaseOrder::convertedExpense() (hasOne) and ListPurchaseOrderResource.converted_expense_id let the frontend hide the action once used.

Permissions

purchase-orders.view | create | update | delete | restore | force.delete | assign | send | receive | cancel | convert

Routes use module:purchase-orders then can:purchase-orders.* / policies.

Catalog: slug purchase-orders, category purchasing, is_default_included = false, is_billable = false, sort_order = 20. Registered via DefaultModuleRegistrar migration (migrate-only), with a follow-up migration inserting the module_dependencies row on vendors.

API (tenant)

Base: /api/tenant/v1 — full reference tenant-v1-purchase-orders.md.

Frontend

SPA mirrors Estimates (table + create/edit page, record page) under the existing AppLayout — do not invent a parallel shell.

PiecePath
Pagesrc/pages/purchase-orders/ (purchase-orders-page.tsx, purchase-order-form-dialog.tsx, purchase-order-detail-sheet.tsx)
Detail sheetOverview (vendor, totals, dates, assignee, related converted expense), line items, notes, timeline — actions: assign, add note, send, mark partially received, mark received, cancel, convert to expense (soft), edit (draft only), delete
Form dialogTitle, required vendor picker (SearchableSelect backed by vendorService.list()), currency, order date, expected date, notes, and a line-items editor (useFieldArray) with live subtotal/tax/total preview
ServicepurchaseOrderService in src/api/services.ts
TypesPurchaseOrder* in src/types/api.ts
Query keysQUERY_KEYS.purchaseOrders / purchaseOrder(id) / purchaseOrderTimeline(id) / purchaseOrderStats
PermissionsPERMISSIONS.purchaseOrders.* (maps to purchase-orders.* permission strings)
NavPurchasing sidebar group, after Vendors — permission: PERMISSIONS.purchaseOrders.view, module: 'purchase-orders'
RoutetenantRoutes.purchaseOrders = '/purchase-orders', lazy-loaded in App.tsx behind RequireAccess module="purchase-orders"
Notificationssrc/notifications/modules/purchase-orders.tspurchase-order.assigned/purchase-orders?purchase_order={id}
Playwrighte2e/pages/purchase-orders.page.ts, e2e/tests/purchase-orders/, npm run test:e2e:purchase-orders

Tests

bash
php artisan test --compact tests/Feature/Tenant/PurchaseOrder/PurchaseOrderTest.php tests/Feature/Central/Module/PurchaseOrdersModuleDependencyTest.php
npm run typecheck && npm run lint && npm run build
npm run test:e2e:purchase-orders

Logging

  • Spatie LogsActivity on PurchaseOrder (log name purchase-orders)
  • Domain purchase_order_activities timeline
  • PlatformAuditService via PurchaseOrderEventSubscriber

Intentional differences from Estimates

EstimatesPurchase Orders
Optional contact_id/company_id/opportunity_id/quotation_idSingle required vendor_id
Hard-depends on InvoicesHard-depends on Vendors
convert() → draft CustomerInvoice (hard dependency)convertToExpense() → draft Expense (soft entitlement check, no hard dependency)
Statuses: draft → sent → accepted|rejected|expiredStatuses: draft → sent → partially_received|received|cancelled
accept / send / convert actionssend / receive / cancel / convert actions

Ask EloSync

Ask EloSync Purchase Order tools (get_purchase_order, confirmed status/assign/note writes) are registered in AIToolRegistry and confirmed via PendingAiActionService. Status auth mirrors HTTP POST …/status (Sent→send, PartiallyReceived/Received→receive, Cancelled→cancel, else update) via PurchaseOrderAiSupport::authorizeStatusChange and changeStatus. Get payload includes assigned_to (user id) and assignee_name. See AI tools and AI Purchase Order triage production readiness.

Deferred

  • Per-line partial receiving
  • Dashboard widgets for Purchase Orders
  • Communication template placeholders for Purchase Orders

Official documentation for the EloSync SaaS Platform.