Quotes
Build, version, send, and e-sign CPQ quotes and proposals with server-computed totals, a recipient portal, and an accepted-quote-to-order handoff.
Quotes
Pact's quoting surface is a full CPQ + proposal + native e-signature stack. Reps
build a quote from line items, send it to the buyer, watch it get viewed and
signed on a public recipient page, and — on acceptance — an order row is created
automatically. The operator API lives under /v1/quotes and is gated by the
sales module.
Live
Quotes are backed by real, enforced tables: quotes, quote_line_items,
quote_signatures, quote_versions, proposal_templates, quote_events, and
quote_comments. Every row is tenant-scoped. The lifecycle state machine
(core.cpq.lifecycle) and totals engine (core.cpq.totals) are the source of
truth for what the recipient sees.
The quote object
A quotes row carries the header fields — name, exec_summary, terms,
currency, owner_user_id, and back-pointers to account_id and deal_id —
plus a set of server-computed money columns: subtotal_cents, discount_cents,
tax_cents, and total_cents. You never post totals; the server recomputes them
from the line set on every write, so the header can never show a figure that
disagrees with the lines.
Each line in quote_line_items has a position (for drag-to-reorder),
quantity, unit_price_cents, a percentage discount_pct or fixed
discount_cents, and the is_optional / is_selected flags that let a buyer
toggle optional add-ons on the public page.
Lifecycle
Quote status is a strict state machine enforced in core.cpq.lifecycle. The
statuses are draft, sent, viewed, accepted, declined, expired, and
revoked. Allowed transitions:
draft → sent
sent → viewed | accepted | declined | expired | revoked
viewed → accepted | declined | expired | revoked
accepted / declined / expired / revoked → (terminal)
An out-of-band transition (e.g. draft → accepted) raises
InvalidTransitionError and is rejected at the route. Expiry is evaluated
against the quote's expires_at timestamp via is_expired.
Operator endpoints
| Method & path | Purpose |
|---|---|
GET /v1/quotes | List with q, status, account_id, deal_id filters |
POST /v1/quotes | Create a draft |
GET /v1/quotes/{id} | Fetch header + line items |
PATCH /v1/quotes/{id} | Partial update of header fields |
DELETE /v1/quotes/{id} | Delete a draft (terminal states are retained) |
POST /v1/quotes/{id}/status | Transition status |
PUT /v1/quotes/{id}/line-items | Atomic bulk-replace of the line set |
POST /v1/quotes/from-deal/{deal_id} | Seed a quote from a deal |
POST /v1/quotes/{id}/ai-generate | AI-draft the exec summary + line descriptions |
Line edits go through a single bulk PUT rather than per-line CRUD: a reorder or
discount change re-persists the whole list atomically, so position can't desync
from the line set and the header aggregates are recomputed in one pass.
Why from-deal exists
Reps reach a quote from a deal record the overwhelming majority of the time.
POST /v1/quotes/from-deal/{deal_id} seeds account_id, deal_id, currency,
and owner_user_id from the deal so those fields aren't retyped.
Versioning, e-signature, and the recipient page
Every meaningful edit snapshots into quote_versions (version_num,
content_hash, snapshot_json) so history and diffs are exact. Native
e-signature writes a quote_signatures row — signature_type of typed,
drawn, or uploaded — capturing signed_at, a document_hash, and hashed
IP / user-agent for a tamper-evident record. Recipient-side interactions (opens,
comments) append to quote_events and quote_comments; the public surfaces are
served under a per-quote public_token.
Proposal structure is reusable: proposal_templates stores ordered
sections_json with variables, versioned per tenant.
Acceptance creates an order
When a quote reaches accepted, core.cpq.orders.create_order_for_quote mints
the corresponding orders row. From there, the money and fulfillment lifecycles
are owned by the Orders surface.