SIGNALS Documentation
API Reference

Opportunities

Create, update, and query rental quotes and orders through the opportunities API.

Opportunities API

CRUD and lifecycle endpoints for opportunities — the event-sourced quote/order documents that drive the rental lifecycle.

Opportunities use a two-axis model: a state (Draft, Quotation, Order) and a per-state status (e.g. Quotation/Reserved, Order/On Hire). Reads hit a read-optimised projection; every write is recorded as an event so full history is preserved.

Endpoints

Method URL Description
GET /api/v1/opportunities List opportunities
GET /api/v1/opportunities/{id} Show an opportunity
GET /api/v1/opportunities/{id}/assets List every line's asset assignments (flat)
GET /api/v1/opportunities/{id}/availability Per-line availability picture
GET /api/v1/opportunities/{id}/activity Scoped audit timeline
GET /api/v1/opportunities/{id}/available_actions Legal actions for the current state
POST /api/v1/opportunities Create an opportunity (as a Draft)
PUT /api/v1/opportunities/{id} Update an opportunity's header fields
DELETE /api/v1/opportunities/{id} Delete (soft-delete) an opportunity
POST /api/v1/opportunities/{id}/restore Restore a soft-deleted opportunity
POST /api/v1/opportunities/{id}/clone Clone an opportunity into a new Draft
POST /api/v1/opportunities/{id}/convert_to_quotation Convert a Draft into a Quotation
POST /api/v1/opportunities/{id}/convert_to_order Convert a Quotation into an Order
POST /api/v1/opportunities/{id}/change_status Move to another status within the current state
POST /api/v1/opportunities/{id}/reinstate Reinstate a closed opportunity
POST /api/v1/opportunities/{id}/reopen Re-open a completed order
POST /api/v1/opportunities/{id}/revert_to_quotation Revert an Order back to a Quotation
POST /api/v1/opportunities/{id}/revert_to_draft Revert a Quotation back to a Draft
POST /api/v1/opportunities/{id}/lock_locks Apply FX/tax locks (freeze rates)
POST /api/v1/opportunities/{id}/unlock_locks Release FX/tax locks for re-pricing
POST /api/v1/opportunities/{id}/participants Add a participant
PATCH /api/v1/opportunities/{id}/participants/{participant} Update a participant's role or mute flag
DELETE /api/v1/opportunities/{id}/participants/{participant} Remove a participant
POST /api/v1/opportunities/{id}/items Add a line item
PATCH /api/v1/opportunities/{id}/items/{item} Update a line item
DELETE /api/v1/opportunities/{id}/items/{item} Remove a line item
PATCH /api/v1/opportunities/{id}/items/tree Restructure the line-item tree (reorder/nest)
PATCH /api/v1/opportunities/{id}/items/{item}/fulfilment Dispatch/return/adjust a bulk line
POST /api/v1/opportunities/{id}/quick_allocate Batch-allocate serialised assets
POST /api/v1/opportunities/{id}/quick_prepare Batch-prepare allocated assets
POST /api/v1/opportunities/{id}/quick_book_out Batch-dispatch serialised assets
POST /api/v1/opportunities/{id}/quick_check_in Batch-return serialised assets
POST /api/v1/opportunities/{id}/costs Add an ad-hoc cost
PATCH /api/v1/opportunities/{id}/costs/{cost} Update a cost
DELETE /api/v1/opportunities/{id}/costs/{cost} Remove a cost
POST /api/v1/opportunities/{id}/deal_price Set a manual deal-total override
DELETE /api/v1/opportunities/{id}/deal_price Clear the deal-total override

Authentication

Requires a Sanctum bearer token with opportunities:read (GET) or opportunities:write (POST/PUT/DELETE) ability, alongside the matching opportunities.view / opportunities.create / opportunities.edit / opportunities.delete permission.

Opportunity Field Reference

All opportunity responses include the following fields under the opportunity key.

Identity & Header

Field Type Description
id integer Opportunity identifier
subject string Opportunity subject / title
number string|null Zero-padded RMS number (e.g. "0000000042")
reference string|null External reference / PO number
description string|null Internal description
external_description string|null Customer-facing description
member_id integer|null Owning member (customer) id
venue_id integer|null Venue member id
store_id integer|null Home store id
owned_by integer|null Owner (staff member) id
source_opportunity_id integer|null Source opportunity id when this was cloned from another
rating integer|null Customer satisfaction rating (1–5)
tag_list string[] Tag labels attached to the opportunity
custom_fields object Custom field values as a flat key/value map ({} when none)

State & Status

Field Type Description
state integer RMS state integer: 0 Draft, 1 Quotation, 2 Order
state_label string Human-readable state label (e.g. "Quotation")
state_name string Alias of state_label (RMS compatibility)
status integer Per-state status integer (see the Two-Axis table below)
status_label string Human-readable status label (e.g. "Reserved")
status_name string Alias of status_label (RMS compatibility)
availability_phase string Demand phase implied by the current status (e.g. "reserved", "on_hire")

Dates

Field Type Description
starts_at string|null Hire start (ISO 8601 UTC)
ends_at string|null Hire end (ISO 8601 UTC)
charge_starts_at string|null Chargeable period start
charge_ends_at string|null Chargeable period end
ordered_at string|null Timestamp when the opportunity was converted to an order
quote_invalid_at string|null Date/time after which the quote expires
prep_starts_at / prep_ends_at string|null Warehouse preparation window
load_starts_at / load_ends_at string|null Loading window
deliver_starts_at / deliver_ends_at string|null Delivery window
setup_starts_at / setup_ends_at string|null Setup/rigging window
show_starts_at / show_ends_at string|null Show/event window
takedown_starts_at / takedown_ends_at string|null Takedown/de-rig window
collect_starts_at / collect_ends_at string|null Collection window
unload_starts_at / unload_ends_at string|null Unloading window
deprep_starts_at / deprep_ends_at string|null De-preparation window

Fulfilment Flags

Field Type Description
use_chargeable_days boolean Whether chargeable days override the duration calculation
chargeable_days string|null Override chargeable day count (decimal string)
open_ended_rental boolean Whether the hire has no fixed return date
customer_collecting boolean Customer collects from store (affects delivery demand)
customer_returning boolean Customer returns to store (affects return demand)
invoiced boolean Whether the opportunity has been invoiced
has_shortage boolean Whether any line item currently has a shortage

Delivery & Collection Addresses

Field Type Description
delivery_address_id integer|null Member address id for delivery
collection_address_id integer|null Member address id for collection
delivery_instructions string|null Free-text delivery instructions
collection_instructions string|null Free-text collection instructions

Include delivery_address and collection_address via ?include=delivery_address,collection_address to embed the full address object (street, city, postcode, country, etc.).

Pricing & Totals

All money values are returned as decimal strings (e.g. "125.50").

Field Type Description
currency_code string|null ISO 4217 currency code (e.g. "GBP")
exchange_rate string Exchange rate snapshot at creation or last unlock (decimal string)
exchange_rate_locked boolean Whether the exchange rate is locked
tax_locked boolean Whether the tax snapshot is locked
pricing_locked boolean Computed: true when either exchange_rate_locked or tax_locked is set
prices_include_tax boolean Whether prices are tax-inclusive
charge_total string Gross headline total (net when no deal override)
deal_total string|null Manual deal-total override when set
rental_charge_total string Total for rental lines
sale_charge_total string Total for sale lines
service_charge_total string Total for service, labour, and surcharge lines
sub_rental_charge_total string Total for sub-rental lines
transit_charge_total string Total for delivery costs
loss_damage_charge_total string Total for loss/damage costs
charge_excluding_tax_total string Net total (all lines and costs, tax excluded)
tax_total string Total tax
charge_including_tax_total string Gross total including tax

Quote Version Summary

Field Type Description
active_version_id integer Id of the currently active quote version
version_count integer Total number of versions
has_alternatives boolean Whether there is more than one non-superseded version

Lazy Includes

These fields are null unless the relationship is eager-loaded via ?include=:

Include key Field Description
member member {id, name} reference for the owning member
venue venue {id, name} reference for the venue member
store store {id, name} reference for the store
owner owner {id, name} reference for the owning user/member
delivery_address delivery_address Full address object
collection_address collection_address Full address object
items items Array of line item objects
costs costs Array of cost objects
versions versions Array of quote version objects
participants participants Array of participant objects

Response Meta Block

Every show/write response includes a top-level meta block alongside opportunity:

{
    "opportunity": { ... },
    "meta": {
        "can_edit": true,
        "can_destroy": false
    }
}
Field Type Description
can_edit boolean Whether the authenticated user can update this opportunity (respects policy + closed state)
can_destroy boolean Whether the authenticated user can delete (soft-delete) this opportunity

Two-Axis State Model

State state Statuses (status)
Draft 0 0 Open
Quotation 1 0 Provisional, 1 Reserved, 2 Lost, 3 Dead, 4 Postponed
Order 2 0 Active, 1 Dispatched, 2 On Hire, 3 Returned, 4 Checked, 5 Complete, 6 Cancelled

Each response includes both the raw RMS integers (state, status) and human-readable labels (state_label, status_label), plus the availability_phase the current status places on the availability engine.

List Opportunities

GET /api/v1/opportunities

Returns opportunities under the opportunities key with a meta block (total, per_page, page).

Filters

Ransack-compatible q[field_predicate]=value parameters:

Parameter Description
q[subject_cont]=stage Subject contains
q[state_eq]=1 Filter by state (0 Draft, 1 Quotation, 2 Order)
q[status_eq]=0 Filter by per-state status
q[member_id_eq]=42 Filter by member
q[store_id_eq]=3 Filter by store
q[reference_eq]=PO-123 Filter by reference
q[invoiced_eq]=false Filter by invoiced flag
q[starts_at_gteq]=2026-01-01 Starts on or after
q[cf.field_name_eq]=value Filter by custom field

Includes

Eager-load relationships with ?include=member,venue,store,owner,items,items.assets,costs,versions,versions.items.

The opportunity's custom_fields object is populated from the customFieldValues relation, which is eager-loaded by defaultcustom_fields is always present ({} when none are set), with no need to request it explicitly.

Custom Views

Apply a saved view with ?view_id=42. The view's columns project a sparse response (only those fields plus id); custom-field columns (cf.*) filter the custom_fields object. Explicit q filters take priority over the view's filters. See Custom Views API for details.

Sort

sort=created_at, sort=-starts_at, sort=subject, sort=charge_total, sort=state, sort=status (prefix with - for descending).

Pagination

Offset-based: ?page=2&per_page=20 (max per_page 100).

Show Opportunity

GET /api/v1/opportunities/{id}

Returns the opportunity under the opportunity key. Supports ?include=.

The opportunity body also exposes the version summary fields active_version_id, version_count, and has_alternatives, the tag_list array, and (when included) a versions collection — so the Show page can sideload the quote versions in one call with ?include=versions,versions.items.

Opportunity Assets

GET /api/v1/opportunities/{id}/assets

A flat, paginated list of every per-asset assignment across all of the opportunity's line items (the opportunity_item_assets rows), returned under the assets key with a meta block. Use this for the Show page's assets tab without loading the full items payload. Each row matches the asset shape used elsewhere (id, opportunity_item_id, stock_level_id, status + status_label, lifecycle timestamps).

Requires opportunities.view / opportunities:read.

Filters

Ransack-compatible q[field_predicate]=value:

Parameter Description
q[status_eq]=2 Filter by assignment status (0 Allocated … 5 Finalised)
q[opportunity_item_id_eq]=10 Filter to a single line
q[stock_level_id_eq]=55 Filter by assigned stock level

Sort

sort=status, sort=-dispatched_at, sort=allocated_at, sort=created_at (prefix with - for descending). Defaults to oldest-first.

Opportunity Availability

GET /api/v1/opportunities/{id}/availability

Returns the per-line availability picture under the availability key: for each product-backed line, how many units are free over the line's own window at its own store with the line's own demand excluded, plus its shortage shortfall. Lines that reference no product (services, ad-hoc lines) are omitted. Computed live from demands (no snapshot dependency), so it always reflects current state.

Each entry: opportunity_item_id, product_id, store_id, requested_quantity, available_for_item, shortage_quantity, has_shortage, from, to.

Requires opportunities.view / opportunities:read.

Opportunity Activity

GET /api/v1/opportunities/{id}/activity

A paginated, newest-first read of the audit trail (action_logs) scoped to this opportunity, returned under the activity key with a meta block. Saves the caller from knowing the underlying model FQCN that the global /api/v1/actions endpoint filters on. Each entry matches the action-log shape (action, user_id, user_name, old_values, new_values, ip_address, created_at).

Gated like the global action-log endpoint: requires action-log.view / action-log:read.

Create Opportunity

POST /api/v1/opportunities

Creates the opportunity as a Draft / Open. A zero-padded RMS number (e.g. "0000000042") is auto-generated from a per-store running sequence and returned in the response. Money is supplied as a decimal string or minor-unit integer and returned as a decimal string.

Identity & Header

Field Type Required Description
subject string Yes The opportunity subject (max 255)
member_id integer No Owning member — must be an Organisation member (not archived)
venue_id integer No Venue member
owned_by integer No Owner member
store_id integer No Store
reference string No External reference / PO number (max 255)
description string No Internal description
external_description string No Customer-facing description
currency string No ISO-4217 currency code (default GBP)
prices_include_tax boolean No Whether entered prices are tax-inclusive (set at create time only)
charge_total numeric No Header charge total (int = minor units, decimal string/float = major units)
rating integer No Sales priority rating 0–5
tag_list string[] No Tag labels
custom_fields object No Custom field values as a flat key/value map

Hire & Charge Dates

Field Type Required Description
starts_at date No Hire start
ends_at date No Hire end (on/after starts_at)
charge_starts_at date No Chargeable period start
charge_ends_at date No Chargeable period end (on/after charge_starts_at)

Event Logistics Dates

All fields are optional ISO 8601 date strings. Each phase is a _starts_at / _ends_at pair.

Field pair Description
prep_starts_at / prep_ends_at Warehouse preparation window
load_starts_at / load_ends_at Loading window
deliver_starts_at / deliver_ends_at Delivery window
setup_starts_at / setup_ends_at Setup/rigging window
show_starts_at / show_ends_at Show/event window
takedown_starts_at / takedown_ends_at Takedown/de-rig window
collect_starts_at / collect_ends_at Collection window
unload_starts_at / unload_ends_at Unloading window
deprep_starts_at / deprep_ends_at De-preparation window
ordered_at Timestamp when the opportunity was converted to an order
quote_invalid_at Date/time after which the quote expires

Fulfilment Flags

Field Type Required Description
use_chargeable_days boolean No Whether chargeable_days overrides the duration calculation (default false)
chargeable_days numeric string No Override chargeable day count
open_ended_rental boolean No Whether the hire has no fixed return date (default false)
customer_collecting boolean No Customer collects from store (default false)
customer_returning boolean No Customer returns to store (default false)

Delivery & Collection

Field Type Required Description
delivery_address_id integer No Member address id for delivery (must belong to member_id)
collection_address_id integer No Member address id for collection (must belong to member_id)
delivery_instructions string No Free-text delivery instructions
collection_instructions string No Free-text collection instructions

Returns 201 with the created opportunity under the opportunity key.

Update Opportunity

PUT /api/v1/opportunities/{id}

Partial update of editable header fields. All fields are optional — omitted fields are left unchanged. A closed/terminal opportunity (Complete, Cancelled, Lost, Dead) cannot be edited and yields a 422.

For nullable clearable fields (venue_id, reference, description, external_description, delivery_instructions, collection_instructions, delivery_address_id, collection_address_id, chargeable_days, rating, tag_list) an explicit null clears the value; omitting the key entirely leaves it unchanged.

Identity & Header

Field Type Description
subject string Opportunity subject (max 255)
member_id integer|null Owning member — must be an Organisation member
venue_id integer|null Venue member (null clears)
owned_by integer|null Owner member
store_id integer|null Store
reference string|null External reference / PO number (null clears)
description string|null Internal description (null clears)
external_description string|null Customer-facing description (null clears)
rating integer|null Sales priority rating 0–5 (null clears)
tag_list string[]|null Replacement tag list (null clears all tags)
custom_fields object Custom field values to update

Hire & Charge Dates

Field Type Description
starts_at date|null Hire start
ends_at date|null Hire end (on/after starts_at)
charge_starts_at date|null Chargeable period start
charge_ends_at date|null Chargeable period end

Event Logistics Dates

All fields are optional ISO 8601 date strings. Providing a value sets or moves the date; omitting the key leaves it unchanged. Dates in this group are not clearable via null — use the Create path or contact the store's team if a date must be removed.

Field pair Description
prep_starts_at / prep_ends_at Warehouse preparation window
load_starts_at / load_ends_at Loading window
deliver_starts_at / deliver_ends_at Delivery window
setup_starts_at / setup_ends_at Setup/rigging window
show_starts_at / show_ends_at Show/event window
takedown_starts_at / takedown_ends_at Takedown/de-rig window
collect_starts_at / collect_ends_at Collection window
unload_starts_at / unload_ends_at Unloading window
deprep_starts_at / deprep_ends_at De-preparation window
ordered_at Timestamp when converted to an order
quote_invalid_at Quote expiry date/time

Fulfilment Flags

Field Type Description
use_chargeable_days boolean Toggle chargeable-days override
chargeable_days numeric string|null Override chargeable day count (null clears)
open_ended_rental boolean Toggle open-ended rental
customer_collecting boolean Toggle customer-collects flag
customer_returning boolean Toggle customer-returns flag
invoiced boolean Mark the opportunity as invoiced

Delivery & Collection

Field Type Description
delivery_address_id integer|null Member address id for delivery (null clears)
collection_address_id integer|null Member address id for collection (null clears)
delivery_instructions string|null Free-text delivery instructions (null clears)
collection_instructions string|null Free-text collection instructions (null clears)

Delete Opportunity

DELETE /api/v1/opportunities/{id}

Soft-deletes the opportunity. The deletion is recorded as an event so history is preserved; the row drops out of list and availability reads. Returns 204.

Convert to Quotation

POST /api/v1/opportunities/{id}/convert_to_quotation

Transitions a Draft opportunity to a Quotation / Provisional. Returns the updated opportunity under the opportunity key. An invalid transition (not currently a Draft) yields a 422.

Convert to Order

POST /api/v1/opportunities/{id}/convert_to_order

Transitions a Quotation opportunity to an Order / Active. Returns the updated opportunity. An invalid transition yields a 422.

Change Status

POST /api/v1/opportunities/{id}/change_status

Moves the opportunity to a different status within its current state.

Field Type Required Description
status integer Yes The per-state status integer (see the table above)

Returns the updated opportunity under the opportunity key.

Error Cases

Status Condition
401 No valid Sanctum token
403 Token lacks opportunities:read/opportunities:write, or the user lacks the permission
404 Opportunity not found (or soft-deleted)
422 Validation failure, or an invalid state/status transition

Available Actions

GET /api/v1/opportunities/{id}/available_actions

Returns the set of legal lifecycle actions for the current state/status combination, resolving each action's feasibility given the current state, the actor's permissions, and system checks (shortage gate, lock state, dispatch state). The UI uses this to show/hide action buttons without needing to reproduce the guard logic.

Requires opportunities.view / opportunities:read.

Response

{
    "available_actions": [
        {
            "action": "convert_to_order",
            "label": "Convert to Order",
            "available": true,
            "disabled": false,
            "reason": null,
            "reason_code": null
        },
        {
            "action": "revert_to_draft",
            "label": "Revert to Draft",
            "available": false,
            "disabled": true,
            "reason": "Only an open, provisional quotation can be reverted to a draft.",
            "reason_code": "invalid_state"
        }
    ]
}

Each entry has action (the endpoint slug), label, available (boolean — the actor has permission and the state allows it), disabled, reason (human-readable explanation when not available), and reason_code (machine-readable, e.g. invalid_state, nothing_to_unlock, dispatched, permission).

Restore Opportunity

POST /api/v1/opportunities/{id}/restore

Restores a soft-deleted opportunity. The {id} must identify a soft-deleted record (use trashed=true on the list or the Delete/Restore flow from the UI). Returns the restored opportunity under the opportunity key (200 OK).

Requires opportunities.delete / opportunities:write.

Clone Opportunity

POST /api/v1/opportunities/{id}/clone

Creates a copy of the opportunity as a new Draft, including its line items, costs, and custom fields. The clone gets a fresh RMS number and is otherwise independent of the source; its source_opportunity_id points back to the original. Returns the new opportunity under the opportunity key (201 Created).

Requires opportunities.create / opportunities:write.

Field Type Required Description
subject string No Subject for the clone (defaults to "Copy of [original subject]")

Reinstate

POST /api/v1/opportunities/{id}/reinstate

Reinstates a Lost, Dead, Postponed, or Cancelled opportunity back to its default open status (Quotation/Provisional or Order/Active). Returns the updated opportunity. A 422 is returned for any other state/status.

Requires opportunities.edit / opportunities:write.

Revert to Quotation

POST /api/v1/opportunities/{id}/revert_to_quotation

Reverts an open Order (Active) back to a Quotation / Provisional. The opportunity must not have any dispatched assets — if assets have been dispatched, the transition is rejected (422) until they are returned and all quantity demand is unwound.

Requires opportunities.edit / opportunities:write.

Revert to Draft

POST /api/v1/opportunities/{id}/revert_to_draft

Reverts an open, provisional Quotation back to a Draft / Open. Only allowed while the quotation is in the Provisional (open) status — a Reserved or closed quotation cannot be reverted. Returns the updated opportunity.

Requires opportunities.edit / opportunities:write.

Reopen

POST /api/v1/opportunities/{id}/reopen

Re-opens a Completed order back to Order / Active. Complete is the only terminal order status that can be re-opened (Cancelled orders use reinstate). Returns the updated opportunity.

Requires opportunities.edit / opportunities:write.

Unlock Locks

POST /api/v1/opportunities/{id}/unlock_locks

Releases any FX rate lock and/or tax lock currently in place on an order. Once unlocked, the next write (line edit, cost add, deal-price change) will re-snapshot the current exchange rates and tax rules. Returns the updated opportunity.

Requires the opportunities.unlock_rates permission and opportunities:write ability. Returns 422 when neither lock is set.

Lock Rates

POST /api/v1/opportunities/{id}/lock_locks

Applies FX and tax locks on an opportunity — snapshots the current exchange rate and freezes the tax treatment so later writes re-price the agreed net basis at the frozen rate rather than re-deriving FX/tax. This is the inverse of unlock_locks.

Field Type Required Description
reason string No Free-text note recorded with the lock event

Requires the opportunities.unlock_rates permission and opportunities:write ability. Returns 422 when locks are already active. Returns the updated opportunity.

Restructure Items Tree

PATCH /api/v1/opportunities/{id}/items/tree

Reorders and/or re-nests the line-item tree in one operation. The body carries the full item set in display pre-order (top-to-bottom), each node carrying its target tree depth; the server rebuilds every item's materialised path from order + depth.

Field Type Required Description
nodes array Yes Ordered [{id, depth}] — every item in final display pre-order, depth 1-based
prune_orphans boolean No When true, items omitted from nodes are removed (used by the local-first editor sync). Default false

Requires opportunities.edit / opportunities:write. Returns the updated opportunity.

Opportunity Participants

Participants are members (contacts or staff) attached to an opportunity in a named role — for example, a site contact, a crew chief, or an on-site supervisor. They are RMS-compatible (participants[] in the field set) and returned via ?include=participants.

List (via include)

GET /api/v1/opportunities/{id}?include=participants

Participants are returned in the participants array on the opportunity. Each entry:

Field Type Description
id integer Participant record id
opportunity_id integer Parent opportunity id
member_id integer The member record attached
role string|null Named role (free text, e.g. "Site Contact")
mute boolean Whether notifications are suppressed for this participant
created_at / updated_at string ISO 8601 UTC timestamps
member object|null {id, name} reference when include=participants.member is also requested

Add Participant

POST /api/v1/opportunities/{id}/participants
Field Type Required Description
member_id integer Yes The member to attach (must exist)
role string No Named role label
mute boolean No Suppress notifications (default false)

Returns the new participant under the participant key (201 Created).

Requires opportunities.edit / opportunities:write.

Update Participant

PATCH /api/v1/opportunities/{id}/participants/{participant}
Field Type Description
role string|null New role (set null to clear)
mute boolean Updated notification-mute flag

Returns the updated participant under the participant key. The {participant} must belong to the opportunity (404 otherwise).

Remove Participant

DELETE /api/v1/opportunities/{id}/participants/{participant}

Removes the participant. Returns 204 No Content. The {participant} must belong to the opportunity.

Opportunity Items

Line items are the priced rows of an opportunity. Every write below flows through the event-sourced lifecycle: the rate engine resolves the per-unit price and duration-aware subtotal, the line discount is applied to the net (before tax), and the tax engine computes line-level tax. The opportunity's totals (charge_excluding_tax_total, tax_total, charge_including_tax_total, the per-type rental/sale/service totals, and the headline charge_total) are recomputed and rolled up automatically.

Every item endpoint returns the parent opportunity (under the opportunity key) with its refreshed totals — include ?include=items (or items.assets) when reading to see the line rows themselves.

Optional lines (is_optional = true) still claim availability but are excluded from all charge totals.

Each serialised line also reports its bulk-line fulfilment progress via dispatched_quantity and returned_quantity (decimal strings) — the quantities physically booked out and checked back in — so the UI can show dispatch progress without loading the asset rows.

A quantity reduction is rejected (422) when the new quantity would drop below the number of serialised assets already allocated to the line, or below the line's dispatched_quantity. Deallocate or return units first.

Add Line Item

POST /api/v1/opportunities/{id}/items
Field Type Required Description
name string Yes Display name of the line
item_id integer No Catalogue item id (product) the line refers to
item_type string No Polymorphic type (Product / FQCN) — pairs with item_id
description string No Line description
quantity numeric No Quantity (default 1)
transaction_type integer No 0 Rental, 1 Sale, 2 Service, 3 Sub-rental
charge_period integer No 0 Hour, 1 Day, 2 Week, 3 Month, 4 Fixed
starts_at / ends_at datetime No Per-item hire window (inherits the opportunity's dates when null)
is_optional boolean No Exclude from charge totals (default false)
unit_price money No Manual unit-price override (int = minor units, decimal string/float = major units against currency). Omit to price from the rate engine
currency string No Currency scale for unit_price (default base currency)
discount_percent numeric No Line discount percentage (applied before tax)
sort_order integer No Display ordering
notes string No Free-form notes
custom_fields object No Inline line-item custom-field map

Returns the opportunity with refreshed totals under the opportunity key (201 Created).

Update Line Item

PATCH /api/v1/opportunities/{id}/items/{item}

Accepts any subset of the fields below; each provided field dispatches its own lifecycle event in turn (quantity → price → discount → dates → optional → substitution).

Field Type Description
quantity numeric New quantity (resyncs availability demand)
unit_price money Manual unit-price override; send null to clear it and revert to rate pricing
currency string Currency scale for unit_price
discount_percent numeric Line discount percentage; send null to clear
starts_at / ends_at datetime Per-item hire window (resyncs availability demand)
is_optional boolean Toggle whether the line counts toward totals
item_id / item_type integer / string Substitute the catalogue reference (re-prices, resyncs demand)
name string New display name (with a substitution)

Returns the opportunity with refreshed totals.

Remove Line Item

DELETE /api/v1/opportunities/{id}/items/{item}

Releases the line's availability demand, removes the row, and rolls the totals back down. Returns the opportunity with refreshed totals (200 OK).

Asset Allocation

Serialised line items track which specific physical assets (stock levels) are committed to them. Each allocation pins one asset to a line, increments the stock level's allocated quantity, and transitions the line's availability demand from a single quantity-based demand to one asset-specific demand per allocated unit (the quantity-based demand shrinks by the number allocated). Deallocation reverses this.

Allocation is permitted while the opportunity is open (a Reserved quotation or an Order). The asset must belong to the line's product, be serialised, and be free for the line's window — otherwise a 422 is returned. Allocations carry no body beyond the asset id; the asset assignment is returned under the singular asset key.

Allocate Asset

POST /api/v1/opportunities/{id}/items/{item}/assets

Body: stock_level_id (required). Fires AssetAllocated. Returns the new assignment (201 Created).

Update Asset

PATCH /api/v1/opportunities/{id}/items/{item}/assets/{asset}

Mutates an existing assignment. The action field (required) selects the operation:

action Effect Extra body
prepare Allocated → Prepared (picked/packed)
revert Prepared → Allocated
set_container Nest the asset inside a kit/case container_stock_level_id
clear_container Remove from its container
substitute Swap the physical asset, preserving status new_stock_level_id, optional reason
dispatch Allocated/Prepared → Dispatched (order only) optional dispatched_by, vehicle_id, notes, dispatched_at
on_hire Dispatched → On Hire
return Dispatched/On Hire → Checked In optional received_by, return_store_id, returned_at
check Checked In → Finalised (condition assessment) condition (0=Good, 1=Damaged, 2=Missing), optional checked_by, damage_notes, checked_at
revert_status Step the asset back to an earlier status revert_to, optional reason

An invalid status transition (e.g. preparing an already-prepared asset, dispatching an unallocated asset, or dispatching on a quote rather than an order) yields a 422. Returns the updated assignment (200 OK).

The fulfilment actions auto-promote the parent opportunity's aggregate status — see Dispatch & Return.

Deallocate Asset

DELETE /api/v1/opportunities/{id}/items/{item}/assets/{asset}

Optional body: reason. Fires AssetDeallocated — removes the assignment row, decrements the stock level's allocated quantity, and reverts the freed unit to a quantity-based demand. Allowed only while the asset is Allocated or Prepared; otherwise 422. Returns 204 No Content.

Quick Allocate (batch)

POST /api/v1/opportunities/{id}/quick_allocate

Body: allocations — a non-empty array of {opportunity_item_id, stock_level_id} pairs. Every allocation fires inside a single atomic commit, so a failure on any one (asset unavailable, wrong product, or allocating beyond the line's quantity) rolls back the whole batch. All line items must belong to the opportunity. Returns the opportunity with its items + assets (200 OK).

Quick Prepare (batch)

POST /api/v1/opportunities/{id}/quick_prepare

Body: asset_ids — a non-empty array of asset-assignment ids, plus optional prepared_at. Every asset is prepared (Allocated → Prepared) inside a single atomic commit, so a failure on any one (an asset not in the Allocated status) rolls back the whole batch. All assets must belong to the opportunity. Returns the opportunity with its items + assets (200 OK).

Dispatch & Return

Once an opportunity is an order, its line items move through the fulfilment cycle — out of the warehouse on dispatch, back on return, then condition-checked. Serialised lines track this per physical asset (via the asset actions above); bulk (non-serialised) lines track it as aggregate quantities on the line.

Aggregate auto-promotion

Every dispatch/return/check re-derives the opportunity's order sub-status from the state of all its items (the "lowest common denominator") and, when it changes, fires a distinct OpportunityStatusPromoted event — a real, audited status change with its own timestamp, not a silent column write:

Condition across all items Order status
Nothing dispatched yet Active
Some allocated assets still undispatched Dispatched
Everything out, nothing returned On Hire
Some returned but not yet checked Returned
Everything dispatched is checked Checked

A dispatch before the line's planned start pulls that asset's availability demand start back to the actual dispatch time; a return moves the demand end to the actual return time (with turnaround applied off the real return) and releases availability immediately on scan — there is no finalisation gate.

Bulk-line fulfilment

PATCH /api/v1/opportunities/{id}/items/{item}/fulfilment

Dispatch, return, or adjust a non-serialised line. The action field (required) selects the operation:

action Effect Extra body
dispatch Record a (partial) dispatch (order only) quantity, optional dispatched_by
return Record a (partial) return quantity, optional received_by, condition
adjust Change the requested quantity mid-cycle new_quantity, optional reason

Partial dispatch/return is first-class: 60 m of a 100 m cable line can go out now and 40 m later. Over-dispatch (beyond the requested quantity), over-return (beyond what is out), or adjusting below the dispatched quantity each yield a 422. The line's effective demand is quantity - returned_quantity. Returns the updated line (200 OK).

Quick Book-Out (batch)

POST /api/v1/opportunities/{id}/quick_book_out

Body: asset_ids — a non-empty array of asset-assignment ids, plus optional dispatched_by, vehicle_id, dispatched_at. Every asset is dispatched inside a single atomic commit (a failure on any one rolls back the batch), and the order's aggregate status promotes once consistently. All assets must belong to the opportunity. Returns the opportunity with its items + assets (200 OK).

Quick Check-In (batch)

POST /api/v1/opportunities/{id}/quick_check_in

Body: asset_ids (required array), optional received_by, return_store_id, returned_at, and finalise (boolean). Each asset is returned in one atomic commit; when finalise is true each return is immediately condition-checked (Good), clearing the check-in queue in a single pass. Returns the opportunity with its items + assets (200 OK).

Opportunity Costs

Costs are ad-hoc charges that sit alongside the priced line items — delivery, crew labour, surcharges, insurance, loss/damage recovery, etc. Unlike line items they are not priced by the rate engine: each carries its own amount (per unit). Costs are taxed exactly like line items (inclusive/exclusive, line-level rounding) and rolled into the opportunity totals.

Each cost's net is routed into an RMS category bucket by cost_type:

cost_type Value Total bucket
Delivery 0 transit_charge_total
Labour 1 service_charge_total
Surcharge 2 service_charge_total
Insurance 3 service_charge_total
Loss / Damage 4 loss_damage_charge_total
Miscellaneous 5 service_charge_total

Regardless of type, every non-optional cost also feeds charge_excluding_tax_total, tax_total, charge_including_tax_total, and the headline charge_total. Optional costs (is_optional = true) are excluded from all totals. Every cost endpoint returns the parent opportunity (under the opportunity key) with its refreshed totals; include ?include=costs when reading to see the cost rows.

Add Cost

POST /api/v1/opportunities/{id}/costs
Field Type Required Description
description string Yes Cost description
cost_type integer No 0 Delivery, 1 Labour, 2 Surcharge, 3 Insurance, 4 Loss/Damage, 5 Misc (default 5)
transaction_type integer No 0 Rental, 1 Sale, 2 Service, 3 Sub-rental (default 2 Service)
amount money No Per-unit charge (int = minor units, decimal string/float = major units against currency)
currency string No Currency scale for amount (default base currency)
quantity numeric No Quantity (default 1)
is_optional boolean No Exclude from charge totals (default false)
sort_order integer No Display ordering
notes string No Free-form notes

Returns the opportunity with refreshed totals under the opportunity key (201 Created).

Update Cost

PATCH /api/v1/opportunities/{id}/costs/{cost}

Accepts any subset of the fields below; omitted fields are left untouched.

Field Type Description
description string New description
cost_type integer New cost type
transaction_type integer New transaction type
amount money New per-unit charge
currency string Currency scale for amount
quantity numeric New quantity
is_optional boolean Toggle whether the cost counts toward totals
sort_order integer Display ordering
notes string Free-form notes

Returns the opportunity with refreshed totals.

Remove Cost

DELETE /api/v1/opportunities/{id}/costs/{cost}

Removes the cost row and rolls the totals back down. Returns the opportunity with refreshed totals (200 OK).

Set Deal Price

POST /api/v1/opportunities/{id}/deal_price
Field Type Required Description
deal_total money Yes Manual deal total (int = minor units, decimal string/float = major units)
currency string No Currency scale for deal_total

Replaces the engine-computed headline charge_total with the manual override. The per-type and tax totals continue to reflect the line items. Returns the opportunity.

Rejected (422) on a locked order — like a line price override or discount, setting a deal price changes the effective charge and is blocked while the order's FX/tax locks are in place. Release the locks (POST /api/v1/opportunities/{id}/unlock_locks) first.

Clear Deal Price

DELETE /api/v1/opportunities/{id}/deal_price

Clears the manual override, reverting charge_total to the engine-computed gross total. Returns the opportunity. Also rejected (422) on a locked order.

Quote Versions

A quotation can carry multiple versions — sequential revisions (each new revision supersedes its parent) and parallel alternatives (concurrent options the customer chooses between). Exactly one version is active at any time: the opportunity's totals, line-item scope, and availability demand all follow the active version. Versions are a sub-resource of an opportunity — there is no top-level versions endpoint. They reuse the opportunities:read / opportunities:write abilities and the opportunities.view / opportunities.edit permissions.

A version's status is one of 0 Draft, 1 Sent, 2 Accepted, 3 Declined, 4 Superseded. Its version_type is 0 Revision or 1 Alternative. Money totals are NET (tax-exclusive) decimal strings.

List Versions

GET /api/v1/opportunities/{id}/versions

Returns the opportunity's versions (oldest first) in a versions collection. Add ?include=items to embed each version's line items.

Show Version

GET /api/v1/opportunities/{id}/versions/{version}

Returns a single version. Add ?include=items to embed its line items.

Create Version

POST /api/v1/opportunities/{id}/versions
Field Type Required Description
version_type int No 0 Revision (default), 1 Alternative
label string No Display label for the version
source_version_id int No Version whose items are cloned (defaults to the active version)
notes string No Free-text notes

Clones the source version's line items into the new version, which becomes active. A revision supersedes its parent; an alternative coexists. Valid only while the opportunity is a Quotation and within the opportunities.max_versions (20) and opportunities.max_alternatives (5) caps. Returns the new version (201 Created).

Activate Version

POST /api/v1/opportunities/{id}/versions/{version}/activate

Makes the version active. The opportunity's totals and item scope switch to it, and availability demand swaps from the previously-active version's items to this one's.

Send / Accept / Decline Version

POST /api/v1/opportunities/{id}/versions/{version}/send
POST /api/v1/opportunities/{id}/versions/{version}/accept
POST /api/v1/opportunities/{id}/versions/{version}/decline

Advance the version through its customer workflow. send (Draft → Sent) stamps sent_at; accept (Draft/Sent → Accepted) stamps accepted_at; decline (Draft/Sent → Declined) stamps declined_at. An accepted version takes priority when the quotation is converted to an order (an accepted version is confirmed over the active one, and every other version is superseded).

Rename Version

PATCH /api/v1/opportunities/{id}/versions/{version}
Field Type Required Description
label string|null Yes New label (null clears it)

Diff Versions

GET /api/v1/opportunities/{id}/versions/{from}/diff/{to}

Returns a diff of the item-level content delta between two versions of the same opportunity: added (in target, not source), removed (in source, not target), changed (quantity / unit price / discount differ), and net_change (signed total delta). Lines are matched by product. Both versions must belong to the opportunity.

Delete Version

DELETE /api/v1/opportunities/{id}/versions/{version}

Removes a version and its line items (204 No Content). The active version and the only remaining version cannot be deleted, and deletion is allowed only while the opportunity is a Quotation.

Error Cases

Status Condition
401 No valid Sanctum token
403 Token lacks opportunities:write, or the user lacks the opportunities.edit permission
404 Opportunity not found, or the line item / cost / version does not belong to the opportunity
422 Validation failure, a write against a closed/terminal opportunity, a version cap breach, or an invalid version transition (e.g. sending an already-sent version, deleting the active or only version)