Plain-English summary
A Purchase Order (PO) is the formal record of what was ordered from which supplier, at what price. POs move through a configurable status pipeline — by default Draft → Pending Approval → Approved → Sent to Supplier → Partially Billed → Billed → Closed, with Cancelled as the off-ramp — and only the transitions configured in the pipeline are allowed. Receiving goods and billing are separate follow-on documents: from a PO you "Receive items" (creating a Purchase Receive that books stock into a location) and "Create Bill" (creating the payable). A PO itself is never hard-deleted — you cancel it by status.
When you'd use this
- Placing a weekly produce order with a supplier
- Tracking outstanding POs to chase late deliveries (delivery-date field + assignment)
- Receiving goods into stock against what was ordered
- Converting a PO into a supplier bill for payment
- Reconciling supplier invoices against received goods
How to access
- Admin backoffice (canonical):
/expenses/purchase-orders(list),/new(create),/{id}(details),/{id}/edit. [code: foodops-admin-app/apps/admin/src/App.tsx:874-906] - Receives:
/expenses/purchase-receipts(+/new?purchaseOrderId=...from a PO's "Receive items"). [code: App.tsx:952-982; PurchaseOrderDetailsPage.tsx:201-203] - API:
/api/v1/purchase-orderson the expenses service. [code: hubits-api-expenses/Hubits.Expenses.Api/Controllers/PurchaseOrdersController.cs:20]
The legacy back-office is retired at the July-10 launch — the Purchases section in the admin app (
/expenses/*) is the only purchasing surface.
Walkthrough
Verified against
hubits-api-expenses@55102c3+foodops-admin-app@f6ffd0b(new stack; monolith retired).
Creating a PO
- Expenses → Purchase Orders → New. Select the supplier, add line items (product variant + quantity + price) and review totals. [code: PurchaseOrderFormPage.tsx; PurchaseLineItemsEditor.tsx]
- Submit —
POST /api/v1/purchase-orders(requires the PurchaseOrders.Create role). The server fills sensible defaults the form doesn't send: transaction currency (supplier default → MVR), a default tax type, payment term (org default → shortest active), and resolves each line's ProductId/unit from the variant. [code: PurchaseOrdersController.cs:381-444] - The new PO starts in its pipeline's initial status (default pipeline: Draft).
Approving and sending
- On a Draft PO the preview tab shows an approval banner with Mark as Approved (when the pipeline requires approval). [code: PurchaseOrderPreviewTab.tsx:29-50]
- Status changes go through
POST /{id}/change-status; the server validates that the target status belongs to the PO's pipeline AND that a transition from the current status is configured — otherwise "Transition from 'X' to 'Y' is not allowed." TheGET /{id}/statusesendpoint returns only the current + reachable statuses, each carrying flags (IsFinal, IsCompleted, IsCancelled, IsApprovalRequired, IsInvoicingAllowed, IsRecievingAllowed) and colors. [code: PurchaseOrdersController.cs:927-1060] - Email the PO to the supplier (
POST /{id}/email), print it (GET /{id}/print), switch the print template (PUT /{id}/print-template), and regenerate the rendered document (generate-document-content). [code: PurchaseOrdersController.cs:1065-1145]
Receiving goods (separate document)
- From the PO details header menu, Receive items →
/expenses/purchase-receipts/new?purchaseOrderId=...(disabled when the PO is cancelled, or once every line item on the order has been fully received). [code: PurchaseOrderHeaderActions.tsx:60-67,121-127; PurchaseOrderDetailsPage.tsx:201-203] - Pick the location (required), enter actual received quantities per line (may differ from ordered). Saving creates a Purchase Receive that generates the StockTransaction rows booking stock in — the same wire format the old POS detail view reads. [code: hubits-api-expenses/Hubits.Expenses.Api/Controllers/PurchaseReceivesController.cs:410-470]
- The PO's line items track
ReceivedQuantity, and its details list attached receives with their statuses. Receiving is allowed in the pipeline statuses flaggedIsRecievingAllowed(default pipeline: Sent to Supplier, Partially Billed, Billed). [code: PurchaseOrdersController.cs:188-215,356-368; Services/PurchaseOrders/PurchaseOrderPipelineHelper.cs]
Billing and closing
- Create Bill from the PO details →
/expenses/bills/new?purchaseOrderId=...pre-links the payable to the PO; billing drives the Partially Billed / Billed statuses. [code: PurchaseOrderDetailsPage.tsx:204-206] - Closed is the default pipeline's completed terminal; Cancelled is reached via the Cancel dialog (status change, not deletion). [code: PurchaseOrderPipelineHelper.cs; CancelPurchaseOrderDialog.tsx]
Collaboration on the PO record
- Assign a user to the PO (
POST /{id}/assign) and set/update the expected delivery date (PUT /{id}/delivery-date). [code: PurchaseOrdersController.cs:740-790] - Comments (add/edit/delete) and file attachments (upload/download/soft-delete) live on the record; operation logs and history give the audit trail. [code: PurchaseOrdersController.cs:1212-1317]
- Export/import endpoints support bulk PO flows. [code: PurchaseOrdersController.cs:837-889]
Key concepts
- Status pipeline — each PO belongs to a
PurchaseOrderPipelinewith statuses and explicit allowed transitions; the default seeded pipeline is Draft → Pending Approval → Approved → Sent to Supplier → Partially Billed → Billed → Closed (+ Cancelled). Pipelines are org-configurable, so a given org's statuses may differ. [code: PurchaseOrderPipelineHelper.cs:73-180] - Status flags drive the UI —
IsRecievingAllowedgates receiving,IsInvoicingAllowedgates billing,IsApprovalRequireddrives the approval banner,IsFinal/IsCompleted/IsCancelledmark terminals. [code: PurchaseOrdersController.cs:1040-1056] - Receive ≠ close — receiving creates a Purchase Receive document and stock transactions; it does not automatically close the PO. (An earlier KB note claimed Foodops "collapses receive-into-PO" — that is no longer what the code shows; receives are a separate first-class document with their own list page, statuses, comments and files.)
- No hard delete — cancel via change-status; comments can be edited/deleted and files soft-deleted, but the PO record persists. [code: PurchaseOrdersController.cs:927,1232-1304]
- Server-side defaulting — currency, tax type, payment term and per-line product/unit resolution happen API-side so the lightweight admin form stays valid. [code: PurchaseOrdersController.cs:393-444]
- Warehouse → stock location resolution — the receive form's warehouse picker resolves to the canonical StockLocation server-side before stock is booked. [code: PurchaseReceivesController.cs:428-440]
Common questions
Q: What statuses can a PO be in? A: Whatever its pipeline defines. Default: Draft, Pending Approval, Approved, Sent to Supplier, Partially Billed, Billed, Closed, Cancelled. The status dropdown only offers transitions configured from the current status. [code: PurchaseOrdersController.cs:1004-1060]
Q: Why can't I move my PO straight from Draft to Billed? A: The pipeline only allows configured transitions — "Transition from 'Draft' to 'Billed' is not allowed" means there is no direct edge; walk the configured path. [code: PurchaseOrdersController.cs:963-971]
Q: How do I receive a partial delivery? A: Receive items → enter the actual quantities received. Each receive is its own document; received quantity accumulates on the PO lines, and you can receive again later for the balance. [code: PurchaseReceivesController.cs:410; PurchaseOrdersController.cs:215-216]
Q: Does receiving update my stock? A: Yes — the receive generates StockTransaction(+items) rows into the selected location; that is the moment inventory moves, not PO creation. [code: PurchaseReceivesController.cs:464-468]
Q: How do I turn the PO into something I pay? A: Create Bill from the PO details — the bill is the payable; payment is recorded against the bill (see Suppliers & Purchase Orders for the payment side).
Q: Can I delete a PO created in error? A: No hard delete — cancel it (status change). The record and its audit trail persist. [code: PurchaseOrdersController.cs:927]
Q: Can I edit a PO after sending it?
A: The edit endpoint (PUT /{id}) exists; pipeline status flags and org policy determine what's sensible. Edit before billing/receiving where possible. [code: PurchaseOrdersController.cs:564]
Q: How do I send the PO to the supplier?
A: Email it from the record (POST /{id}/email) — sent emails are listed on the record (GET /{id}/emails) — or print/download via the print-template pipeline. [code: PurchaseOrdersController.cs:1117-1145,1258]
Q: Who is responsible for a PO? A: Use Assign to put a user on it, and the delivery-date field to track when it's expected — both surfaced as header actions. [code: PurchaseOrderHeaderActions.tsx; PurchaseOrdersController.cs:740-790]
Q: Can I attach the supplier's quote or delivery note? A: Yes — file attachments upload onto the PO record (and onto receives), with download and delete. [code: PurchaseOrdersController.cs:1267-1314]
Q: Is there OCR for supplier invoices? A: An AI controller exists in the expenses API surface, but an end-to-end invoice-OCR flow matching user story US-PUR-003 was not verified this pass — treat as unconfirmed.
Q: What's different from Shops purchase orders?
A: Both now share the same expenses service; the old claim that Foodops merges receiving into the PO while Shops keeps it separate is outdated — Foodops also has separate Purchase Receives (/expenses/purchase-receipts).
Edge cases and known issues
- PO stuck pending approval — if the approver is unavailable there is no built-in delegation; an admin must change status directly (the change-status role is
PurchaseOrders.Create). (User story US-PUR-EDGE-004 tracks this gap.) - Receive with no PO — the receive form supports
purchaseOrderIdbeing absent (ad-hoc receives); lines then need manual product selection. [code: PurchaseReceivesController.cs:417request.PurchaseOrderId?] - Location must resolve — "The selected location could not be resolved to a stock location" means the picked warehouse has no StockLocation; fix warehouse setup first. [code: PurchaseReceivesController.cs:436-440]
- Document generation failures — PO/receipt PDF rendering surfaced errors historically (
ba7d8c5, 2026-04-23 fixed surfacing;1b7ccc1fixed receipts not generating). If a print fails, regenerate viagenerate-document-content. - Editing a Purchase Receive's warehouse — fixed in code — earlier versions of the admin-app detail mapper mis-resolved (or hardcoded null for) the header
warehouseIdon load, so re-saving an edited receive risked posting stock to the wrong/default location.normaliseDetailnow deriveswarehouseIdfrom the receive's own line-itemstockLocation.warehouseobject (firstWarehouse?.id ?? firstWarehouse?.Id ?? null) instead of defaulting or reading the wrong field, so an edited receive should carry the original warehouse correctly. Re-confirm with a live edit-and-resave before fully retiring this note. [code: foodops-admin-app/apps/admin/src/services/purchaseReceiveService.ts —warehouseId: firstWarehouse?.id ?? firstWarehouse?.Id ?? null] - Monolith retired — the legacy
Haafai.ExpensesPO screens are gone at launch;/expenses/*in the admin app is the only purchasing surface.
Related
- Suppliers & Purchase Orders — supplier directory + the payment side; previously the canonical PO walkthrough
- Inventory — where received stock lands
- Ingredients — the raw materials most kitchen POs order
What changed (2026-04-15 → 2026-05-04)
1b7ccc1(2026-04-13, just before window) fixed PO receipt not getting generated — landed in main during the watermark windowba7d8c5(2026-04-23) surfaced document generation errors on the PO/receipt pathSee Suppliers & Purchase Orders for the supplier-side walkthrough
2026-08-15 — codebase: hubits-api-expenses e505b62..aba2ff2. Most of this range reconfirms material already captured by the 2026-08-12/08-14/08-15 notes — BillsController's GET /v1/bills/list-summary, ChangeStatus's pre-approval validation for a missing ProductVariant / missing default Accounts Payable account, the PurchaseOrdersController Assign rewrite (UserId→AssignedUserId, delegated to domain AssignUser with same-user no-op), GetOperationLogs' User/Remarks/Details projection, GetById's AssignedUserName/AssignedDate projection, and PurchaseOrderHelper.GetByIdForUpdate eager-loading AssignedUser — no new information there, and nothing contradicts this entry. Genuinely new: PurchaseReceivesController plus a brand-new ProcurementTolerances.cs service introduce an org-configurable discrepancy/exception system layered on top of the three-way-match fields the 2026-08-15 note flagged as backend-only and unconsumed. Two org settings (Expenses:PriceTolerancePercent, Expenses:QuantityTolerancePercent — missing/unparsable reads as 0, i.e. strict) feed one canonical ExceptionPredicate: a receive is an exception when any line has RejectedQty > 0, OR its received qty falls short of the paired PO line's ordered qty beyond the qty tolerance (same-unit only), OR its invoice unit price differs from the PO line's cost price beyond the price tolerance (same-currency, same-unit only). GetPurchaseReceives gains a virtual 'Exceptions' tab (query.List=Exceptions — not a real PurchaseReceiveListFilter enum value) that runs this predicate over the full unpaginated set and repaginates so counts stay correct; GetListSummary keeps a byte-equivalent inlined copy of the same predicate (EF can't compose the stored expression inside a projection) so the Exceptions tab's count agrees with its list. The plain list's existing HasDiscrepancy flag — previously RejectedQty > 0 only, itself never documented in this entry — is widened the same way, so a receive can now surface as an exception purely on a price or quantity mismatch with zero rejected units. This is real new user-facing surface worth a 'Reconciling discrepancies (Exceptions tab)' note under 'Receiving goods' and Key concepts once the admin-app UI consuming the Exceptions tab/tolerances is confirmed live — not checked this pass, this diff is backend-only. The 'fix(bills): eager-load ProductVariant' and 'feat(idempotency)' commit subjects in this range produced no diff hunks intersecting this entry's cited surface beyond the already-covered BillsController.ChangeStatus validation, so nothing further to confirm from them here. (auto-applied by the truth pipeline; adversarially gated)
2026-08-15 — codebase: hubits-api-expenses e505b62..532eb69. Most of this range reconfirms, not newly introduces, content already captured by the 2026-08-12 and 2026-08-14 notes (PurchaseOrdersController's Assign rewrite UserId→AssignedUserId with domain AssignUser delegation/same-user no-op, GetOperationLogs' User/Remarks/Details projection, GetById's AssignedUserName/AssignedDate projection, PurchaseOrderHelper.GetByIdForUpdate eager-loading AssignedUser, and BillsController's GET /v1/bills/list-summary + ChangeStatus pre-approval validation for missing ProductVariant/Accounts Payable account) — no new information there, and none of it contradicts this entry. Genuinely new: PurchaseReceivesController.GetById now returns a three-way-match data set on each receive line item — the invoice-side figures entered on the receive itself (InvoiceQuantity, InvoiceUnitPrice, InvoiceUnitId + a resolved InvoiceUnitName looked up via a new Units dictionary fetch) alongside the paired PO line's figures (PoCostPrice — null whenever the receive has no linked PO line OR its currency differs from the PO's, since the client is never shown the PO's currency for a same-currency comparison guard; PoUnitId; PoOrderedQty), plus a bare receive.SupplierInvoiceNumber field. This is previously-unexposed data supporting exactly the 'Reconciling supplier invoices against received goods' use case this entry already lists under 'When you'd use this' but never details in the Walkthrough or Key concepts. Worth a new 'Three-way match (invoice vs. receive vs. PO)' note once the admin-app UI consuming these fields is confirmed live — not checked this pass; the fields exist API-side but whether any receive-detail screen renders them is unverified. Also a pure extract-method refactor in PurchaseReceivesController.Create (line-item/view-model construction pulled into BuildCreateLineItem/BuildCreateViewModel helpers) with no visible behavior change. Nothing here contradicts this entry's documented routes, statuses, receive/bill flows, or Q&A. (auto-applied by the truth pipeline; adversarially gated)
2026-08-14 — codebase: hubits-api-expenses e505b62..27a6656. This range is a superset of the 2026-08-12-noted e505b62..b8b7a25 window — the Assign rewrite (UserId→AssignedUserId, delegated to domain AssignUser with org-membership check/operation-log/notification, same-user no-op), GetOperationLogs' new User/Remarks/Details projection, GetById's new AssignedUserName/AssignedDate projection, and BillsController's GET /v1/bills/list-summary + ChangeStatus pre-approval validation (missing ProductVariant / missing default Accounts Payable account → 400 instead of NRE) were already captured in that note and are only reconfirmed here, not new. Genuinely new beyond the b8b7a25 pin: PurchaseReceivesController.ConvertQuantitiesToBaseUnitsAsync's signature changes from void to (bool Ok, string Error). Previously, if a receive line's chosen unit differed from the product variant's base unit and no ProductVariantUnitConversionFactor was configured for that pairing, the code silently fell back to treating the entered quantity as already being in the base unit — a comment on the old code candidly notes 'the legacy domain helper would error here ... treat the qty as already-base' — meaning a receive could silently book the wrong stock quantity with no signal to the user. Both receive endpoints that call it (create via CreateLegacyHandBuilt, and Update) now surface a 400 'Line '{Description}': {error}' via the new PurchaseReceiveUnitConversion.ApplyBaseConversion helper instead of mis-converting silently. This entry's 'Receiving goods (separate document)' walkthrough and Edge cases section (which documents the sibling 'Location must resolve' error) don't currently mention unit conversion at all, so nothing is contradicted, but this is real user-facing behavior worth folding in on the next full pass — including whether the receive form's Unit selector can even present a no-conversion-factor unit today, which wasn't checked live this pass. Also minor and non-user-facing: PurchaseOrderHelper.GetByIdForUpdate now eager-loads AssignedUser, supporting the Assign fix's FullName read for its operation-log detail. (auto-applied by the truth pipeline; adversarially gated) (auto-applied by the truth pipeline; adversarially gated)
2026-08-12 — codebase: hubits-api-expenses e505b62..b8b7a25. Three PO changes worth folding in on the next full pass, none contradicting documented claims. (1) POST /{id}/assign (documented under 'Collaboration on the PO record' and the 'Who is responsible for a PO?' Q&A) is substantially rewritten: the request field is renamed UserId→AssignedUserId and is now required (400 'An assigned user is required.' if blank); assignment is delegated to the domain PurchaseOrder.AssignUser(...) method instead of setting AssignedUserId/AssignedDate directly, which verifies the assignee belongs to the PO's organisation, writes a proper operation-log entry, and sends the assignee a notification; re-assigning to the same user is now a no-op 200 success instead of surfacing the domain's 'Already assigned to the same user' failure. This aligns the backend with the admin-app's 2026-08-11-noted Assign dialog rework (raw-GUID textbox → searchable user picker). (2) GetOperationLogs now projects User (OperationLog.Username), Remarks, and Details (ExtraDetails) alongside the existing Date/StatusName — previously only date and status name were returned, so a rejection reason or handover note had nowhere to surface on the History tab. (3) GetById now projects AssignedUserName (AssignedUser?.FullName) and AssignedDate — the details rail's 'Assigned user' row, previously fed only an unprojected id, was permanently blank and should now show a name. Separately (Bills side, also in this entry's source_repos glob but deferred to suppliers.md for payment detail): BillsController gains GET /v1/bills/list-summary (per-tab row counts) and ChangeStatus now pre-validates a bill before approval, rejecting with a specific 400 if a line item has no ProductVariant selected or the org has no default Accounts Payable account, instead of an unhandled NRE — worth a forward-pointer once the bill-approval UI is verified live. Nothing here changes the entry's documented routes, statuses, receive/bill flows, or existing Q&A text. (auto-applied by the truth pipeline; adversarially gated)
2026-08-11 — codebase: foodops-admin-app c6ee722..4e51283 ('feat(purchase-orders): inline validation, discard guard, real empty state'). None of these hunks contradict this entry's documented claims (Assign, line-item entry, submit flow, receive/bill routes, server-side defaulting are all unchanged), but four are worth folding in on the next full pass. (1) AssignPurchaseOrderUserDialog.tsx: the Assign-a-user flow documented under 'Collaboration on the PO record' and the 'Who is responsible for a PO?' Q&A previously required typing a raw user GUID into a free-text box (a code comment confirms it was 'in practice ... unusable'); it now calls a new expensesLookupService.assignableUsers() lookup and renders a proper searchable Select of org users, falling back to an empty selection with a 'No users available.' message if the lookup fails. (2) PurchaseOrderForm.tsx (the shared create/edit form behind 'New'/'{id}/edit') gains real client-side inline validation: Supplier, Warehouse, Order Date, Payment Terms and Print Template are now required fields checked on Save, each reporting its own message anchored next to the field plus a single toast ('N required fields still need an answer.'), replacing whatever server-side-only failure previously surfaced. It also adds a dirty-tracking discard guard (touchedRef/baselineRef/isDirty) that ignores async default-hydration (payment term, print template, supplier address auto-fill) so only real user edits trigger an unsaved-changes prompt, plus an inline 'New Supplier' creation dialog and an address-edit dialog reachable from the form. (3) PurchaseLineItemsEditor.tsx: a newly-added line now defaults to quantity 1 instead of 0 ('a freshly picked line should already contribute to the totals'); the previously-disabled, non-functional 'New Product (TBD)' button is removed entirely; the empty line-items table replaces the old DataTables-style 'No data available in table' text with a real empty state (icon, 'No items on this order yet', and an 'Add an item' button that focuses the product search box); number inputs (quantity/cost/discount) now blur on mouse-wheel to stop accidental scroll-driven value changes. None of this changes the entry's documented routes, statuses, server defaulting, or receive/bill flows, so no existing text is contradicted — worth adding a 'the New form validates required fields inline before submit (supplier, warehouse, order date, payment terms, print template) and warns on navigating away with unsaved changes' note to the Walkthrough's 'Creating a PO' section, and updating the Assign Q&A to say the user is now picked from a proper list rather than typed as a GUID, on the next full re-verification pass. (auto-applied by the truth pipeline; adversarially gated)
2026-08-11 — codebase: foodops-admin-app c6ee722..79b3952 ('feat(purchase-orders): inline validation, discard guard, real empty state'). PurchaseOrderForm.tsx (rendered by PurchaseOrderFormPage for both create and edit, per the 2026-07-12 note) gains client-side inline validation: a REQUIRED_FIELDS list (supplier, warehouseId, orderDate, paymentTermId, printTemplateId) is checked on Save, missing fields get an inline message plus scroll-and-focus via focusFieldAnchor, and a single toast reports the count ('N required fields still need an answer.' — confirmed by the new PurchaseOrderForm.test.tsx). The form also now tracks unsaved changes (touchedRef/baselineRef/isDirty) to drive a discard-confirmation guard (discardOpen state) instead of closing/navigating away silently, and gained an inline 'New Supplier' creation affordance (SupplierFormDialog/newSupplierOpen) and an address-edit dialog (AddressEditDialog/addressOpen) — full wiring not visible in this truncated diff hunk, worth confirming live. Separately, PurchaseLineItemsEditor.tsx replaced the DataTables-style 'No data available in table' empty row with a designed empty state ('No items on this order yet' + an 'Add an item' button that focuses the product search), reversed the newly-added line's default quantity from 0 back to 1 (0 was only introduced 2026-07-12), swapped the disabled 'New Product (TBD)' button for keyboard-nav hint text, and added scroll-wheel-blur guards on the quantity/cost/discount number inputs to stop accidental scroll-edits. None of this contradicts the entry's documented walkthrough, routes, statuses, or Q&A — the entry doesn't currently describe form validation, the discard guard, default line quantity, or the empty-item state — but it's real new user-facing behavior worth folding into 'Creating a PO' (required-field validation + discard guard) and Key concepts (default quantity=1 on a newly added line) on the next full re-verification pass. (auto-applied by the truth pipeline; adversarially gated)
2026-08-11 — codebase: foodops-admin-app c6ee722..cb5f1c2 ('feat(purchase-orders): inline validation, discard guard, real empty state'). PurchaseOrderForm.tsx gains client-side required-field validation not previously documented: a REQUIRED_FIELDS list (supplier, warehouseId, orderDate, paymentTermId, printTemplateId) is checked on Save; missing fields render an inline message under the field, scroll+focus the first offender, flag the Settings tab with an indicator when its own error (printTemplateId) is out of view, and surface a single toast ('N required fields still need an answer.', confirmed by a new test asserting the exact 4-field count with only orderDate prefilled). This reveals that selecting a warehouse is a required part of creating a PO ('Select the warehouse that will receive the goods.') — a field this entry's 'Creating a PO' walkthrough never mentions alongside supplier/line-items. The form also now tracks dirty state (touchedRef/baselineRef comparison) feeding a discardOpen guard, implying an unsaved-changes confirmation on navigate-away (exact trigger UI not visible in this diff — worth a live check). Separately in PurchaseLineItemsEditor.tsx: a freshly added line's default quantity reverts to 1 (was changed to 0 per the 2026-07-12 verification note — now reverted back), the empty line-items state is redesigned from plain 'No data available in table' text to an icon + 'No items on this order yet' message + an 'Add an item' button that focuses the product search, the long-disabled 'New Product' button is removed entirely, the search placeholder changes to 'Search products by name or code, or scan a barcode' with an aria-label and a keyboard hint, and number inputs (quantity/cost/discount) get onWheel blur-guards to stop accidental scroll-wheel edits. Several PO dialogs (Assign/Cancel/ChangeStatus/Email/UpdateDeliveryDate) also drop a shadow-sm class from their remarks textareas — purely cosmetic. None of this contradicts the entry's existing Walkthrough or Q&A text (which doesn't claim an exhaustive required-field list or a specific default line quantity), so no edit is needed, but the required-warehouse-field, the validation/toast UX, and the discard guard are worth folding into 'Creating a PO' and a new Q&A on the next full re-verification pass. (auto-applied by the truth pipeline; adversarially gated)
2026-08-06 — codebase: foodops-admin-app 47ff627..14d95d1 ('feat(reports): add shared org-timezone-aware date presets' + 'feat(reports): add the quick date-range chip row'). None of these hunks contradict this entry's documented walkthrough, routes, statuses, or Q&A — all are list-page/UI mechanics outside this entry's scope. (1) PurchaseOrdersPage.tsx's inline DATE_PRESETS/isoDate/presetRange (used by the 'Today/Yesterday/This week/This month/Last 30d' filter chips above the PO list) are extracted into a shared apps/admin/src/utils/datePresets.ts and now take an orgTz argument — presetRange(key, orgTz) instead of presetRange(key) — so the quick-filter chips compute 'today'/'this week' boundaries in the org's configured timezone rather than the viewer's browser-local time, consistent with the org-tz display rework already noted on 2026-07-05/07-17. This filter row isn't part of the entry's documented content (no Walkthrough/Q&A references PO-list search/filter mechanics), so nothing is contradicted. (2) PurchaseOrderDetailsPage.tsx and PurchaseReceiveDetailsPage.tsx both replace manual useState/useEffect data-loading with @tanstack/react-query's useQuery (queryKey ['expenses','purchase-order'|'purchase-receive', id]); the background poll for generated-document-content now writes into the query cache via queryClient.setQueryData instead of calling setDetail directly. This is an internal data-fetching refactor with explicitly preserved semantics (a code comment notes isPending is deliberately not gated on id to reproduce the old loading=true behavior) — no change to any documented endpoint, status flow, or button behavior. (3) PurchaseOrderKpiStrip.tsx's spend-tile hero row is now hidden below the md breakpoint (was always visible) — a responsive-design change, not documented in this entry. (4) PurchaseOrdersPage.tsx and PurchaseReceivesPage.tsx column-hiding switches from cellClassName/headerClassName='hidden md:flex'/'hidden lg:flex' to a hideBelow:'md'|'lg' prop — same visual effect, cosmetic API rename in the shared list-column config, not a behavior change. Also several hero-band JSX blocks (PurchaseOrderDetailsPage, PurchaseReceiveDetailsPage) switch from a fixed flex-row header to flex-col on mobile / flex-row from sm: up — responsive layout polish only. Nothing here rises to a documented-claim change; worth folding the org-tz-aware quick-filter chips into a future 'Filtering the PO list' subsection if this entry's scope is ever extended to cover list/search UI. (auto-applied by the truth pipeline; adversarially gated)
2026-07-17 — codebase: foodops-admin-app 8ebb1d2..31fa044. Two non-contradicting changes touch this entry's cited surface. (1) PurchaseOrderForm.tsx (the shared create/edit form rendered by PurchaseOrderFormPage) now imports useOrgTimeZone/formatYmdOrgTz and uses them for BOTH the default orderDate on a brand-new PO (previously
formatYmd(new Date().toISOString()), i.e. the viewer's browser-local today; now the org's configured-timezone today) and for hydrating an existing PO's saved orderDate/expectedDeliveryDate into the edit form (previously browser-local formatYmd). This extends the 2026-07-05-noted org-tz display rework (which covered list/detail rendering) to the create/edit form's date fields themselves — a small but real behavior change for orgs whose configured timezone differs from a given admin's browser timezone (the default Order Date on a new PO, and the pre-filled dates on edit, now reflect org tz rather than the viewer's). (2) PurchaseOrdersPage.tsx and PurchaseReceivesPage.tsx replace ad-hoc local page/pageSize state and an inline page-size Select with shared usePagination/usePagedRows hooks (storageKey 'purchase-orders' / 'purchase-receives', implying page-size now persists per list via localStorage across visits) and a shared Pagination component. This is an internal list-UI refactor — no documented route, workflow, or Q&A claim in this entry references pagination mechanics, so nothing is contradicted. Neither change affects the entry's documented walkthrough, status pipeline, receive/bill flows, or Q&A; worth folding the org-tz-default-order-date nuance into the entry on the next full re-verification pass if the form's date-picker behavior is ever documented in more detail. (auto-applied by the truth pipeline; adversarially gated)2026-07-13 — codebase: hubits-api-expenses f182af9..a23943b. New hunk beyond what the 2026-07-12 note covered: BillsController.GetById's JournalEntries block is restructured from one row per BillJournalEntry (entry-level Debit/Credit totals, Account and Description hardcoded null) to a SelectMany flattening each posted JournalEntry's AccountingJournalItems into one row per journal line, filtered to JournalStatusId == Posted (a code comment notes edits mark the original entry Deleted, avoiding doubled lines), with real Account (ChartOfAccount.Name) and Description populated per line. Net effect: the Bill detail page's Journal tab (once/if surfaced in admin-app) would show an actual double-entry account breakdown (e.g. debit Inventory/Expense, credit Payables/Tax) instead of just entry totals with blank Account/Description. This entry only documents Bills as far as 'Create Bill' links a payable to the PO and defers payment/accounting detail to suppliers.md, so nothing here is contradicted — no edit needed. Worth flagging to whichever entry documents Bill detail/accounting (or a future Bills-focused entry) once the Journal tab UI is verified live. Re-confirms, without new information, the two items already covered by prior notes: PurchaseOrdersController.Search's null-supplierId fix (2026-07-10 note) and BillsController's new GET /{id}/statuses endpoint for the bill approve workflow (2026-07-12 note). (auto-applied by the truth pipeline; adversarially gated)
2026-07-12 — codebase: foodops-admin-app 4136a33..8ebb1d2 replaces the modal-only PurchaseOrderFormDialog with a shared PurchaseOrderForm component that takes a
presentation: 'page' | 'modal'prop. PurchaseOrderFormPage now renders it inline as a full page (presentation="page", with HideChromeBar) instead of auto-opening a dialog and navigating back on close; PurchaseOrdersPage's 'New Purchase Order' button (both toolbar and empty-state) now doesnavigate('/expenses/purchase-orders/new')and the list page's inline<PurchaseOrderFormDialog>usage was removed entirely. Net effect: creating/editing a PO from/expenses/purchase-orders/newand/{id}/editis now a dedicated full-page form, not a modal overlay on the list. This doesn't contradict the entry's 'How to access' routes or Walkthrough steps (neither specifies dialog vs. page), so no edit is required, but worth folding a 'renders as a full page, not a modal' note into 'How to access' on the next full pass. The remaining diff hunks in this range (PurchaseReceiveDetailsPage/PurchaseReceiveActionBar consolidation, purchaseReceiveService.ts warehouseId derivation, PurchaseLineItemsEditor.tsx Tax % select) reconfirm — not newly introduce — changes already captured by the 2026-07-11 verification note and the entry's 'fixed in code' Known Issue; PurchaseLineItemsEditor also now defaults a newly-added line's quantity to 0 (was 1) and adds select-on-focus to the Quantity/Rate inputs, both minor UI polish with no documented-claim impact. (auto-applied by the truth pipeline; adversarially gated)2026-07-12 — codebase: hubits-api-expenses f182af9..5fc983c. BillsController gains a new GET /v1/bills/{id}/statuses endpoint ('feat(bills): add GET /v1/bills/{id}/statuses for the approve workflow') that mirrors the already-documented PurchaseOrdersController GET /{id}/statuses pattern: it loads the bill's BillPipeline, computes the set of statuses reachable from the bill's current BillStatusId via BillStatusTransitions (plus the current status itself), and returns each with its flags (IsApproved, IsApprovalRequired, IsEditable, IsPaid, IsVoided, IsFinalState, RequiresConfirmation) and display color, flagging IsCurrent. This gives the admin UI what it needs to drive a bill approve/send-for-approval workflow analogous to the PO's Mark-as-Approved flow already documented in 'Approving and sending'. This entry's walkthrough only takes the bill as far as 'Create Bill' (linking a payable to the PO) and defers the payment side to suppliers.md, so no existing text is contradicted, but the new endpoint is worth a forward-pointer once the bill-approval UI is verified live — e.g. in the 'How do I turn the PO into something I pay?' Q&A or a new 'Approving a bill' subsection. Also re-confirms the previously-noted Search fix (PurchaseOrdersController.Search now short-circuits to empty only when both q is blank AND supplierId is null, with a null-safe (q ?? string.Empty).Trim()) already covered by the 2026-07-10 verification note — no new information there, included only because it falls within this diff's range. The other two commits in this range ('Fixed line items doubled - bill', 'ai key added') and the warehouses-test commit produced no diff hunks intersecting this entry's cited surface, so nothing to confirm or flag from them here. (auto-applied by the truth pipeline; adversarially gated) (auto-applied by the truth pipeline; adversarially gated)
2026-07-11 — codebase: foodops-admin-app 4136a33..82ff18d touches the Purchase Receive detail/edit surface further, beyond the warehouse-drop fix (see edits). (1) PurchaseReceiveDetailsPage.tsx collapses PurchaseReceiveStatusActions + PurchaseReceiveQuickActions into a single PurchaseReceiveActionBar (props: detail, onChanged, onEdit, onCreateBill). (2) The Create Bill action's cross-link logic changed: previously PurchaseReceiveCrossLinks built the bill URL conditionally on purchaseOrderId/supplierId; now PurchaseReceiveActionBar always navigates to
/expenses/bills/new?purchaseReceiveId={detail.id}— a Purchase Receive can now be billed directly via its own id, not only gated through its parent PO or supplier. Worth folding into the 'Billing and closing' section and the 'How do I turn the PO into something I pay?' Q&A, which currently only document Create Bill from the PO details page. (3) PurchaseLineItemsEditor.tsx (PO line-item editor) replaces the free-typed tax-amount Input with a Tax % Select populated from expensesLookupService.taxTypes(), auto-computing taxAmount = netTotal × rate on selection/hydration — admins now pick a configured org tax rate per line instead of typing a raw tax amount. Worth a note in Key concepts / the 'What's different from Shops purchase orders?' area on the next full re-verification pass. None of (1)-(3) contradict existing walkthrough or Q&A text. (auto-applied by the truth pipeline; adversarially gated)2026-07-10 — codebase: hubits-api-expenses f182af9..0ac9199 ('fix(search): improve query validation to handle null supplierId'). PurchaseOrdersController.Search previously required a non-blank
qtext query — passing onlysupplierIdwith no search text returned an empty result set even though the endpoint acceptssupplierIdas a filter parameter. It now short-circuits to empty only when BOTHqis blank ANDsupplierIdis null, and null-safes the query normalization ((q ?? string.Empty).Trim()...) so a nullqno longer risks a NullReferenceException when a supplierId-only search is issued. Net effect: PO search/typeahead can now be filtered by supplier alone with no text entered — previously this silently returned nothing. This endpoint isn't part of this entry's documented walkthrough or Q&A (only Create/Update/change-status/statuses/email/print/assign/delivery-date/comments/files/export-import are covered), so no body edit is needed, but worth folding a 'filter POs by supplier' note into the entry if a Search/Filter section is ever added. The other two commits in this range ('Fixed line items doubled - bill', 'ai key added') touch no diff hunks intersecting this entry's cited surface (BillsController.cs not in the diff), so nothing to confirm or flag from them here — see the Bills-focused entry if one exists. (auto-applied by the truth pipeline; adversarially gated) (auto-applied by the truth pipeline; adversarially gated)2026-07-05 — codebase: hubits-api-expenses 22c34df → 8852799 ('Fixed unable to save edited purchase order'). PurchaseOrderHelper.Update() previously loaded the PO via the lean GetById() (no line-item include, no tracking) before mutating PurchaseOrderLineItems and calling SaveChangesAsync — a persistence bug analogous to the Purchase Receive edit bug fixed 2026-07-02. It now uses a new GetByIdForUpdate() that eagerly includes PurchaseOrderLineItems + their operation logs, so line-item edits actually persist. The fix also codifies a real business rule not previously documented: removing a line item from an existing PO is now blocked server-side if that line has ReceivedQuantity > 0 or is referenced by a Bill, returning 'Cannot remove line item '{Description}' because it has already been received or billed.' — with a DbUpdateException/SQL-error-547 catch as a backstop for any FK case not covered by the explicit check, surfacing as a validation message instead of a raw SQL error. This doesn't contradict the entry's existing 'Edit before billing/receiving where possible' Q&A guidance — it gives that guidance a concrete, user-visible error message and confirms edits now save correctly. A no-op OrderDate UTC-conversion call (UserHelper.GetLocalToUtcTime) was also dropped from Update(), unrelated to documented behavior. Worth folding the specific line-item-removal error message into the 'Can I edit a PO after sending it?' Q&A on the next full re-verification pass. (auto-applied by the truth pipeline; adversarially gated)
2026-07-05 — codebase: foodops-admin-app 8ec1ab1..6642e57. Fixed a PO-edit bug in PurchaseOrderFormDialog.tsx where the edit dialog's Payment Term and Price List fields were hardcoded to initialize as empty strings on every open, regardless of the PO's saved values; the dialog now seeds them from
detail.paymentTermId/detail.priceListId(commit: 'fixed payment term and price list not auto populating in po edit'). This is a distinct, now-resolved bug in the PO edit flow — no entry text claimed this issue existed, so no edit is needed, but it's worth noting since it's the same class of edit-form-state bug as the documented Purchase-Receive warehouse-drop known issue (that one remains unfixed and untouched by this diff). Separately, this range also reworked date rendering across the PO and Receive list/detail pages (PurchaseOrderDetailsRail, PurchaseOrderDetailsPage, PurchaseOrdersPage, ReceivesAndBillsSection, PurchaseReceiveDetailsPage, PurchaseReceivesPage) from local-browserformatDate/new Date()to org-timezone-awareformatDateOrgTz/parseUtc/formatDateTimeOrgTz— order date, delivery date, created/modified, received date, and stock-transaction timestamps now display in the org's configured timezone rather than the viewer's browser timezone. Purely a display-correctness fix; no documented workflow, endpoint, or status-pipeline claim is affected. (auto-applied by the truth pipeline; adversarially gated)2026-07-02 — codebase: hubits-api-expenses moved 55102c3e40 → e1e592a with three related fixes, none contradicting documented claims. (1) PO creation (PurchaseOrdersController.Create) now looks up the organization's base CurrencyId and uses it as a fallback when CallContext.CurrencyId is unset, ahead of the existing supplier-default/MVR resolution — commit subject 'fix(unable to create po): set default currency ID based on organization if not specified' implies PO creation could previously fail or mis-default when neither request nor supplier supplied a currency; this adds an org-level layer to the documented 'server fills sensible defaults ... transaction currency (supplier default → MVR)' chain, worth folding in once the full resolved precedence is confirmed. (2) PurchaseOrderHelper.GetPurchaseReceiveById switched from AsNoTracking() to a tracked AsSplitQuery() load including the full item/stock-transaction/operation-log graph, fixing a bug ('Fixed unable to save edited purchase recieve') where edits made via StockHelper.UpdatePurchaseReceive were silently never persisted because the change tracker wasn't attached. This is a distinct backend defect from the entry's documented known issue (admin-app purchaseReceiveService.ts hardcoding warehouseId/stockLocationId to null on load) — the diff does not touch that frontend file, so the known issue should NOT be marked resolved on this evidence; flag for a live re-check of whether editing a Purchase Receive now saves correctly and whether the warehouse-drop symptom is still reproducible. (3) PurchaseReceivesController now calls a new ExpensesCacheInvalidator.InvalidateInventoryStockAsync after receive create, status-change (Received), and update — an internal stock-cache-consistency fix, not a user-facing behavior change. (auto-applied by the truth pipeline; adversarially gated)
Info: Curated 2026-05-04 · codebase Verified_by:
codebase· Last verified: 2026-05-04 Glob retargeted from non-existentHaafai.Purchasing/**to the realHaafai.Expenses/Views/PurchaseOrders/**family in haafai.app.foodops monolith. Removed bogushaafai.app.pos: src/purchasing/**glob (never matched). Anchorba7d8c5is the freshest substantive commit touchingHaafai.Expenses/**since the watermark.
Note: Re-pointed after backend microservice split 2026-06-03 · codebase Verified_by:
codebase· Last verified: 2026-06-03 ·hubits-api-expenses:5b89882+haafai.app.foodops:bf7c9d2The 2026-05-02 split extracted the PO/bills/suppliers API intohaafai/hubits-api-expenses(prodmain). Added it as the now-primary backing repo with globHubits.Expenses.Api/{Controllers/PurchaseOrdersController.cs,Services/PurchaseOrders/**,ViewModels/PurchaseOrders/**}(paths verified viagh api ...?ref=main; PO service helpers and AddEdit view-models confirmed present). The monolithHaafai.Expenses/**PO paths still exist onmain(split is additive/in-progress — monolith purchasing not yet removed), so the monolith glob is retained and consolidated toHaafai.Expenses/{Views,ViewModels,Services}/PurchaseOrders/**, re-pinned to prod HEADbf7c9d2. Did NOT addfoodops.api(legacy) orhubits.microservices.
Note: Re-verified 2026-06-03 · codebase (
hubits-api-expenses@5b89882,haafai.app.foodops@bf7c9d2) Stub — verify-and-note.PurchaseOrdersControlleron prodmain(HEAD5b89882, unchanged from pin) confirms POs support edit ([HttpPut]:564) and change-status (:927) with no top-level hard-delete (cancel via status). New since the prior note: commit3b378e5(2026-05-15) added comment edit/delete (UpdateEntityComment:1232,DeleteEntityComment:1245) and attached-file soft-delete (DeleteEntityFile:1304) on the PO record. No PO-form or core-flow change — Foodops still collapses receive-into-PO. Documented in detail on the canonical Suppliers & Purchase Orders; fold into this stub when upgraded tostable.
2026-06-11 — codebase: promoted stub → draft against
hubits-api-expenses@b75e8ee+foodops-admin-app@77ec95f(+ monolithf06cdd9). Full lifecycle documented from controller + admin-app source: pipeline-driven statuses with transition validation (change-status/statuses:927/:1004), default pipeline Draft→Pending Approval→Approved→Sent to Supplier→Partially Billed→Billed→Closed (+Cancelled) with per-status receiving/invoicing flags, assignment + delivery-date, email/print-template/document-content, comments + files, export/import. CORRECTION vs prior notes: Foodops does NOT collapse receive-into-PO anymore — Purchase Receives are a separate document/page (/expenses/purchase-receipts,PurchaseReceivesController) that books StockTransactions into a resolved StockLocation; the PO tracks accumulated ReceivedQuantity. Added the receives controller + admin PO/receive pages to source_repos (paths verified onmain). Invoice-OCR flow left unconfirmed. Confidencemedium; UI pending Playwright walk.2026-06-29 — codebase: NEW-STACK re-verification for July-10 launch (legacy
haafai.app.foodopsmonolith fully retired). Removed theHaafai.Expenses/{Views,ViewModels,Services}/PurchaseOrders/**source_repos glob and the "Legacy back-office" access note; re-pinned tohubits-api-expenses@55102c3e40(main) +foodops-admin-app@f6ffd0b19d(main). Re-confirmed on the new stack: pipeline-driven statuses with transition validation (PurchaseOrdersController.change-status/statuses), Receive items and Create Bill are separate first-class documents (PurchaseReceivesController+BillsControllerboth present on main; admin pagesPurchaseReceivesPage/PurchaseReceiveDetailsPage/BillsPageexist), receive booksStockTransactionrows into a resolved StockLocation. KNOWN ISSUE added (N2): editing a Purchase Receive drops the header warehouse —purchaseReceiveService.ts:112-114mapswarehouseId/stockLocationIdtonullon load → re-save can mis-post stock. Pipeline statuses, server-side defaulting, assign/delivery-date, comments/files all unchanged.
Additional verification events are appended here by the weekly KB sync and by manual re-verification passes. See _meta/failure-log.md for the global failure index.