PPactDocs
Sales

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:

code
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 & pathPurpose
GET /v1/quotesList with q, status, account_id, deal_id filters
POST /v1/quotesCreate 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}/statusTransition status
PUT /v1/quotes/{id}/line-itemsAtomic bulk-replace of the line set
POST /v1/quotes/from-deal/{deal_id}Seed a quote from a deal
POST /v1/quotes/{id}/ai-generateAI-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.