Measured purchase lines
Purchase invoice lines now record the packages received, the unit the supplier cost applies to and the real measured quantity. The contract adds a measurement block, persists line discounts, resolves the cost from the supplier offer and duplicates a purchase from an authorised source.
Purchase invoices now keep the physical side of a purchase apart from the billed
side. A line can state how many packages arrived, which unit the supplier cost
is quoted in and the real measured quantity; the server derives the billed
quantity, the conversion factor to the base unit and the frozen unit cost. A
line without the new block keeps its previous quantity × unit_price
semantics.
New request fields
POST /v1/purchase_invoices and PUT /v1/purchase_invoices/{purchase_invoice}
accept these additive fields. Omitting all of them is still a valid request.
| Field | Meaning |
|---|---|
source_purchase_invoice_id | UUID of a purchase invoice of your company whose historical lines you want to duplicate. Create only. The key needs purchase_invoices:read on top of purchase_invoices:write, otherwise 403 insufficient_scope; an unknown or foreign source returns 404 purchase_invoice_not_found. |
lines[].purchase_measurement | Closed object with package_quantity (decimal string or null) and cost_basis (offer_unit, base_unit or presentation_unit). Omitted or null keeps the legacy contract. The derived read fields (billing_*, unit_cost) are rejected on write, even when copied from a previous read. |
lines[].discount_percent | Percentage discount, 0–100 with up to four decimals, applied before tax. The API used to accept it and drop it silently; it is now persisted and returned. |
lines[].source_line_index | Zero-based ordinal of the line in the authorised source document. It keeps a duplicated line matched to its historical measures and frozen cost. Never an internal key. |
lines[].price_source | manual or supplier_offer. New on the public API; it changes how unit_price is read (see below). |
metadata.purchase_source_review | Reserved key: a compact JSON string of at most 500 characters with the OCR reconciliation state (pending, reconciled or accepted_difference), source and calculated totals, difference and reason. Metadata stays flat; nested objects are rejected. |
Example: three 2 kg boxes bought at a per-kilo offer
The supplier quotes 7.40 per kg. Three boxes of 2 kg are billed as 6 kg; the server derives the same gross amount, 44.40, whichever basis you choose.
{
"external_invoice_number": "F-2026-0918",
"issued_on": "2026-09-18",
"supplier_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a10",
"lines": [
{
"product_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"presentation_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a02",
"supplier_offer_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a03",
"quantity": "6.0000",
"unit_price": "7.4000",
"price_source": "supplier_offer",
"tax_rate": 10,
"purchase_measurement": { "package_quantity": "3.0000", "cost_basis": "offer_unit" }
}
]
}The same purchase priced per box uses "quantity": "3.0000",
"unit_price": "14.8000" and "cost_basis": "presentation_unit" with a manual
cost. A weighed presentation (variable measure) also requires
confirmed_base_quantity, even when an offer applies.
How the cost is resolved
Two behaviours change for catalog lines (those carrying product_id) on the
public API and MCP, matching the web application:
unit_priceomitted means "cost not confirmed", not zero. The server proposes the cost of the applicable supplier offer. When the supplier has no active offer for the selected product or variant, the line is rejected with422 parameter_invalid_valueandparam: unit_price. Previously that silence was stored as0.00and the stock entered valued at zero. Manual lines withoutproduct_idstill requireunit_price.price_source: "supplier_offer"is no longer decorative. Sent together withunit_price, the amount is read as the echo of the cost the server resolved and the offer cost is frozen again. To impose a cost, sendprice_source: "manual"or omit it: the amount you send is respected.
The supplier_offer cost applies to the purchase unit of the offer. A purchase
of variable measure without confirmed_base_quantity is rejected even with an
offer. Historical documents are not recalculated.
New response fields
PurchaseInvoiceLine, returned by list, show, create and update:
| Field | Meaning |
|---|---|
purchase_measurement | Derived block or null: package_quantity, billing_quantity, billing_unit_code, billing_conversion_factor, unit_cost and cost_basis. |
confirmed_base_quantity | Real measured quantity in the base unit as a decimal string; null when the line does not require one. |
discount_percent | Applied before tax. subtotal is already net of it, so unit_price × quantity does not equal subtotal on a discounted line. |
source_line_index | Zero-based ordinal of the line within its document, in stable read order. |
quantity, unit_price | The type is now number | string. A measured line returns 4-decimal strings so no precision is lost; a legacy line keeps the number it has always published. Parse both. |
Errors
Measurement errors are 422 with code: parameter_invalid_value and
subcode: catalog_line_assembly_invalid; param points at the field to fix.
param | Cause |
|---|---|
purchase_measurement.cost_basis | The basis is invalid, the offer or presentation it requires is missing, or the purchase unit and the base unit belong to different families. |
purchase_measurement.package_quantity | Packages must be positive, with at most four decimals. |
quantity | The billed quantity does not match the packages or the confirmed measure. |
confirmed_base_quantity | Required on a variable-measure presentation, or the measure cannot be expressed with four decimals in the billing unit. |
unit_price | No cost to apply: the supplier has no active offer for the selection, or a legacy conversion would lose precision. |
A stored measurement block that fails validation is reported with
subcode: catalog_line_snapshot_invalid. See Errors.
MCP
create_purchase_invoice and update_purchase_invoice publish the same line
schema, including purchase_measurement, discount_percent, price_source,
source_line_index and source_purchase_invoice_id, and apply the same
additional purchase_invoices:read rule when duplicating. get_purchase_invoice
returns the new line fields. See the MCP tool catalog.
Read Product catalog for supplier offers and Amounts and dates for the per-line purchase fields.
Updated endpoints4
| Endpoint | Description |
|---|---|
POST/v1/purchase_invoices | Create a purchase invoice |
PUT/v1/purchase_invoices/{purchase_invoice} | Update a purchase invoice |
GET/v1/purchase_invoices/{purchase_invoice} | Retrieve a purchase invoice |
GET/v1/purchase_invoices | List all purchase invoices |