User guide
Purchasing (Procure-to-Pay)
Looking for what it does rather than how to use it? Read the Purchasing overview .
Purchasing covers everything between deciding to buy something and paying the supplier for it: the supplier record, the purchase order, its approval, the goods arriving, the supplier’s invoice, and matching that invoice back to what was ordered and received.
Concepts
| Noun | What it is |
|---|---|
| Vendor | A supplier you buy from. Owned by an organization, or global (platform-owned and shared). |
| Purchase order (PO) | A commitment to buy, with line items, a ship-to, payment terms and a total. Numbered PO-YYMM-NNNN. |
| PO line item | One thing being ordered: description, SKU/MPN, quantity, unit price, tax, and receipt tracking. |
| Approval workflow | Rules choosing who signs off, selected by amount. Ordered steps, one approver each. |
| Approval | One recorded decision on one step of one document. Approve, reject, or delegate. |
| Receiving session | A physical goods receipt at a location: packing slip, serials, damage, and the stock it creates. |
| Vendor invoice (bill) | The supplier’s invoice — your accounts payable. Numbered by the vendor, not by you. |
| Match | A link from a vendor-invoice line to the PO line, catalogue product or subscription it belongs to. |
How the nouns connect
Vendor ──< PurchaseOrder ──< PurchaseOrderLineItem
│ ▲
├──< InventoryReceiving│ (receipt against the order)
└──< VendorInvoice ──< VendorInvoiceLineItem ── match ──┘
A purchase order can have many vendor invoices and many receiving sessions against it. A receiving session belongs to at most one vendor invoice.
Roles and permissions
platform administrator > MSP administrator > MSP technician > organization administrator > power user > CUSTOMER`
Everything under Purchasing requires organization administrator or above. power user and CUSTOMER cannot reach any of it.
| Action | platform administrator | MSP administrator | MSP technician | organization administrator | power user | CUSTOMER |
|---|---|---|---|---|---|---|
| View POs, bills, vendors, approvals | ● | ● | ● | ● | — | — |
| Create / edit a PO, add and edit lines | ● | ● | ● | ● | — | — |
| Submit a PO or bill for approval | ● | ● | ● | ● | — | — |
| Send a PO to the vendor, acknowledge, receive, close | ● | ● | ● | ● | — | — |
| Create a vendor | ● | ● | ● | ● | — | — |
| Approve / reject a PO or bill | ● | ● | — | ● | — | — |
| Approve or retire a vendor | ● | ● | — | ● | — | — |
| Record a payment against a bill | ● | ● | — | ● | — | — |
| Delete a draft PO or an unsubmitted bill | ● | ● | — | ● | — | — |
| Edit a global (platform) vendor or workflow | ● | — | — | — | — | — |
The bold rows are spend decisions. They are guarded by a named set of roles, not by a rank, because the role ladder cannot express “an administrator of this account”: MSP technician ranks above organization administrator, so any rank that admitted an org admin would also readmit a technician. The set is {platform administrator, MSP administrator, organization administrator} — a technician can raise and send a purchase order but never approve one, and an organization that has no MSP staff can still approve its own spend.
Every route is scoped to the caller’s account. Another tenant’s purchase order is not found, not forbidden.
Walkthrough — buy something and pay for it
Add the supplier
- Go to Purchasing → Vendors → New vendor (
/msp/purchasing/vendorsor/org/purchasing/vendors). - Fill in name, email and payment terms. Email is required later — a PO cannot be sent to a vendor with no address on file.
- Save. The vendor is created unapproved (
isApproved: false). Approval is always a separate, deliberate act, never a side effect of creation. - An administrator opens the vendor and chooses Approve.
Vendors created automatically by AP import are also unapproved for the same reason.
Raise the purchase order
- Purchasing → Orders → New order.
- Pick the vendor and, in the MSP portal, the client organization you are buying for. Both must belong to your account.
- Add line items: description, quantity, unit price, optional SKU/MPN, vendor SKU, tax and expense category. Add freight in Shipping cost — it is part of the order total.
- Save. The order is created as DRAFT with a number like
PO-2607-0001. Totals are computed server-side from the lines; you cannot type a total.
While the order is DRAFT (or PENDING_APPROVAL) you can edit its header, add lines, correct a line in place, or delete a line. Correcting a line keeps its id, so any vendor-invoice line already matched to it stays matched.
Get it approved
- Open the order and choose Submit for approval.
- Solidlio looks for an approval workflow matching this order’s total and entity type, preferring your account’s own workflows over platform-wide ones, and highest
priorityfirst.- A workflow matches → the step-1 approval row is created in the same transaction as the status change, assigned to the configured approver.
- No workflow matches → the order moves to PENDING_APPROVAL with no step in flight, and a human approves it directly. It is never auto-approved.
- The matched step routes to nobody (a role nobody holds any more, a deleted user) → the submission is refused with 409 and the order stays DRAFT. Fix the workflow first.
- The approver opens Purchasing → Approvals, and chooses Approve or Reject. Rejecting requires a reason.
Only the assigned approver can decide an approval that is part of a workflow. Being an administrator in the right tenant is not enough — otherwise the approver you configured would be decorative. If the approver is unavailable, they (or an MSP admin) can delegate: the original row is marked SKIPPED and the new one records who it came from.
When a workflow approval is in flight, the direct approve/reject buttons on the order refuse with 409 and point you at the approval queue.
Send it to the vendor
- With the order APPROVED, choose Send to vendor.
- Solidlio emails the PO to the vendor’s address using the
purchase_order_senttemplate, branded as the tenant the vendor is dealing with, listing every line and the total. - The order becomes SENT only if the email was accepted. If the vendor has no email on file you get a 400 and the order stays APPROVED; if the email service is unreachable you get a 502 and it stays APPROVED. Retry the action.
The email asks the vendor to confirm by replying. When they do, record it with Acknowledge (optionally with the vendor’s own order number and a revised delivery date) — the order becomes ACKNOWLEDGED.
Receive the goods
Two paths, deliberately different:
Lightweight acknowledgement — the boxes arrived, nothing to book into stock. On the order, choose Receive, enter quantities per line (or receive everything outstanding). Partial receipts accumulate; over-receipt is refused with the outstanding quantity quoted. The order becomes PARTIALLY_RECEIVED, or RECEIVED when every line is satisfied. Quantities are Decimal(12,4) and all arithmetic runs on integers scaled by 10,000, so repeated partial receipts cannot drift off the ordered quantity.
Full receiving session — stock has to go into a location. Purchasing → Receiving → New session: pick the location, the purchase order and optionally the supplier’s invoice; add received lines with serial numbers, MAC addresses and damaged counts; convert a line into a hardware asset; then Complete. Completing creates inventory items and re-derives the order’s status.
Both paths write the same order state. The lightweight route owns
quantityReceived/quantityPendingon the PO lines; the session route re-derives the PO header status from those quantities. Nothing forces an order between them, so a session completed against an order whose lines were never received leaves the order PARTIALLYRECEIVED with lines still fully pending. See _Limits.
Enter the supplier’s bill
A bill starts at PENDING_REVIEW (manual) or PENDING_EXTRACTION (imported). Review it, correct the lines, then mark it reviewed to move it to PENDING_MATCHING.
Match the bill to the order
Choose Run matching. Solidlio walks a waterfall per line:
- PO reference — if the bill carries your PO number, find that order (in states APPROVED, SENT, ACKNOWLEDGED or PARTIALLY_RECEIVED) and match the line by SKU, then by MPN.
- SKU / MPN — match to a catalogue product.
- Subscription — match to an active subscription by product SKU, then by description similarity.
- No match — the line is flagged for manual review.
A PO match also computes variance. If the invoiced quantity is above the ordered quantity, or the invoiced unit price is more than 1% above the PO unit price, the line is flagged requiresReview and its confidence is lowered (1.0 → 0.8 for a SKU match, 0.95 → 0.75 for an MPN match). The reason names what differed: “quantity mismatch”, “price above PO”.
Anything the waterfall misses you can match by hand, or accept AI suggestions (only suggestions at 0.7 confidence or above are applied). The entity you match to must belong to your own account.
The invoice’s matched/unmatched counts recompute after every matching action, so the header always agrees with the badges on the lines.
Approve and pay
- Submit the bill for approval — the same engine as purchase orders, with its own workflows (
entityType: VENDOR_INVOICE). Unmatched lines produce a warning, not a block. - An administrator Approves it. The decision is written as a
VendorInvoiceApprovalrow in the same transaction as the status change, stamped with the approved amount. - Record payment. Partial payments are first class: pay less than the balance and the bill stays APPROVED with a reduced
balanceDue. It becomes PAID only when the balance reaches exactly zero. All arithmetic is done in integer cents; overpayment is refused with the outstanding balance quoted.
Rejecting a bill records rejectedAt, rejectedBy and rejectionReason and writes an approval row. Voiding requires a reason. Both append to the internal notes rather than replacing them, so earlier payment and receiving history survives.
Lifecycle reference
Purchase order
| From | Action | To | Who |
|---|---|---|---|
| — | Create | DRAFT | organization administrator+ |
DRAFT | Submit | PENDING_APPROVAL | organization administrator+ |
PENDING_APPROVAL | Approve | APPROVED | Spend approver |
PENDING_APPROVAL | Reject | CANCELLED | Spend approver |
APPROVED | Send | SENT (only if the email sent) | organization administrator+ |
SENT | Acknowledge | ACKNOWLEDGED | organization administrator+ |
APPROVED/SENT/ACKNOWLEDGED/PARTIALLY_RECEIVED | Receive (partial) | PARTIALLY_RECEIVED | organization administrator+ |
| the same | Receive (all) | RECEIVED | organization administrator+ |
RECEIVED / PARTIALLY_RECEIVED | Close | CLOSED | organization administrator+ |
anything except RECEIVED / CLOSED | Cancel | CANCELLED | organization administrator+ |
DRAFT only | Delete | gone | Spend approver |
Editing the header or lines is allowed in DRAFT and PENDING_APPROVAL only. Status is never settable through the update route — it belongs to the actions above, each of which records who took it.
Deleting is refused if the order has vendor invoices or receiving sessions.
Vendor invoice
PENDING_EXTRACTION → EXTRACTION_FAILED → PENDING_REVIEW → PENDING_MATCHING → PENDING_APPROVAL → APPROVED → PAID, with REJECTED and VOIDED as terminal branches.
Deletion is only available before the bill has been submitted for approval, and is refused if it has payments, rebill links or a receiving session. Everything else is voided, not erased.
Configuration
Approval workflows
Purchasing → Approval Rules (/msp/purchasing/approval-workflows, /org/purchasing/approval-workflows).
| Setting | What it does | Unset |
|---|---|---|
| Entity type | PURCHASE_ORDER or VENDOR_INVOICE. A workflow governs one or the other. | Required |
triggerConditions.minAmount / maxAmount | The document total range this workflow claims. | Matches any amount |
priority | Higher wins when several workflows match. | 0 |
| Steps | Ordered, must start at 1 and be contiguous. | At least one required |
approverType | SPECIFIC_USER, ROLE or GROUP. | Required |
escalationTimeoutHours | Surfaced as a deadline on the pending-approvals list. | No deadline shown |
autoApproveUnderAmount | Stored on the step. | — |
MANAGER and BUDGET_OWNER approver types are refused at save time. The schema models no manager relationship and no budget owner, so a step using them would route to nobody; accepting it would produce a workflow the UI shows as active that approves nothing. A ROLE or GROUP step that currently resolves to zero people is also refused when a document is submitted against it.
Global (platform) workflows are visible to every tenant and can only be changed by a platform administrator. Create your own — account workflows are matched first.
Vendor payment terms
DUE_ON_RECEIPT, NET_7, NET_15, NET_30, NET_45, NET_60, NET_90, CUSTOM. A bill with no explicit due date derives one from its terms (CUSTOM falls back to 30 days). A PO inherits the vendor’s terms unless you override them.
Plan tiers
Purchasing is not plan-gated. There is no feature flag for purchase orders, vendors, accounts payable, matching, receiving or approval workflows in ServiceTier.features, and no gate middleware on any of the routes. Every tier, including Free, gets the whole capability.
Two adjacent limits do apply: creating a hardware asset from a receiving line counts against the plan’s asset limit, and AI-assisted extraction consumes AI credits.
Troubleshooting
| Message | Cause |
|---|---|
| “This vendor has no email address on file — add one before sending the purchase order” | The PO stays APPROVED. Add the address and send again. |
| “This purchase order is in a multi-step approval workflow — approve it from the approval queue” | Use Purchasing → Approvals. The configured approver decides that step. |
| “This approval is assigned to another approver. Only the assigned approver can decide it; it can be reassigned via delegate.” | You are not the approver for this step. Delegate it if it is stuck. |
Approval workflow step "X" resolves to no approver | The step’s role or group has no members. Fix the workflow, then resubmit. |
approverType MANAGER cannot be resolved by this system | Use SPECIFIC_USER, ROLE or GROUP. |
| “Cannot receive 5 on line 2 — only 3 remain outstanding” | Over-receipt. Receive what is outstanding, or correct the order. |
| “This purchase order has already been fully received” | Nothing left to receive. |
| “Only draft orders can be submitted for approval” | The order has already been submitted, sent or cancelled. |
| “Only approved orders can be sent” | Get it approved first. |
| “Only orders that have been sent to the vendor can be acknowledged” | Acknowledge follows Send. |
| “A SENT purchase order cannot be deleted — cancel it instead” | The vendor has seen it. Cancel preserves the trail. |
| “This purchase order has vendor invoices linked to it and cannot be deleted” | The order was acted on. Cancel it. |
| “organizationId must name an organization inside your account” | You named another tenant’s organization. |
| “Vendor not found” | The vendor does not exist, or belongs to another tenant. |
| “This is a shared platform vendor… can only be edited by a platform administrator” | Global vendors are shared. Create your own record. |
| “This is a shared platform approval workflow… create your own workflow instead” | Same, for workflows. Account workflows take precedence. |
| “Invoice must be APPROVED before a payment can be recorded” | Approve the bill first. |
| “Cannot update paid or voided invoice” | Terminal states are locked. |
| “A APPROVED invoice cannot be deleted — void it instead” | Approved bills are financial records. |
| “The purchase order line being matched does not belong to this account” | You cannot match across tenants. |
| “Only an invoice awaiting review can be marked reviewed” | The bill is already past review. |
Limits and known behaviour
- Purchase orders are created in CAD. The create route hard-codes the currency. Vendor invoices accept any ISO currency and default to CAD.
- No distributor ordering. TD Synnex and Ingram Micro integrations are catalogue, price and availability sync only — a one-way pull. A purchase order is emailed to the vendor; it is not transmitted to a distributor’s API.
- Demand and supply are separate.
requisitionIdis inert. It is stored from the request body and read by nothing; there is no requisition model.- The two receiving paths are not sequenced. Completing a receiving session re-derives the purchase order’s status from its line quantities but does not write them; the PO
/receiveroute owns those. Complete a session against an order whose lines were never received and the order shows PARTIALLY_RECEIVED with every line still fully pending. - Damaged quantities are recorded but not deducted.
quantityDamagedon a receiving line is stored and never read, so damaged units still become in-stock inventory. ReceivingLineItem.inventoryItemIdis never written. The link is stored in the other direction, on the inventory item.- No approver notification. Creating an approval does not send an email or an in-app notification; approvers find work in Purchasing → Approvals.
- Escalation is not automatic.
escalationTimeoutHoursproduces a displayed deadline on the pending list. No job reassigns or escalates an overdue approval. autoApproveUnderAmountis stored on a workflow step and is not applied by the approval engine.- Subscription matching is fuzzy. Step 3 scans up to 100 active subscriptions and falls back to a case-insensitive substring match of the product name in the line description, flagged for review at 0.7 confidence.
Vendor.glpiSupplierIdis unique per organization (@@unique([organizationId, glpiSupplierId])), not globally. GLPI supplier ids are unique only within one GLPI instance, so a global unique meant two tenants syncing their own GLPI collided on the same integer — and awhere: { glpiSupplierId }lookup could resolve to another tenant’s vendor.- Duplicate columns remain in the schema.
VendorInvoicecarries bothpoReferenceandpurchaseOrderReference, andVendorInvoiceLineItemcarries bothmatchedPoLineIdandmatchedPurchaseOrderLineId. The code now reads and writes both consistently; collapsing them needs a migration.