CPQ quotes, price books and a discount approval that waits for a human
Quotes, price books, discount approval
People ask this as: open source CPQ with a quote approval workflow and discount rules
A catalog, price books, composite quotes priced entirely on the server, immutable quote versions, and a versioned discount policy whose only two outcomes at the boundary are auto-approve or stop and wait for a named human. The name stops at the quote: nothing here bills, invoices or takes payment. The catalog provider is a deterministic fixture rather than Stripe or Zuora, money is integer minor units with no conversion, and unlike periods and unlike currencies are never summed into a grand total.
Where this stops
Read this before the rest of the page. Every line below is a thing this does not do.
- Nothing bills. There is no invoice, no payment, no dunning, no tax and no revenue recognition, and the phrase quote-to-cash stops at the quote.
- The catalog provider is a deterministic offline fixture. No Stripe, Zuora, ERP or CPQ adapter ships, no credential ships, and full Stripe or Zuora support is not claimed — the provider mapping table describes what a future adapter would normalize to.
- There is no quote PDF and nothing is sent to a customer. The quote exists as records and Admin screens; no document is rendered, and no notification adapter delivers anything to anyone.
- requiredApprovalKey — sales-manager, finance — is a label printed on the approval, not enforced security. The server checks that the actor is a user, never which user, because there is no authentication to ask.
- ARR, MRR and TCV are deliberately not derived, and there is no grand total. A quote persists one one-time total plus one total per currency, interval and interval count; unlike periods are never summed and there is no currency conversion.
- Metered usage, overage, proration, ramps, minimum commitments and attribute-based pricing are recorded as not quotable rather than approximated. Quoting one is a refusal, which is the point.
- Approval is proven for the quote's discount policy. There is no general policy engine over arbitrary custom objects.
No authentication, tenancy or RBAC. The server is local-development-only. An actor header is an assertion, not an identity. Do not expose it to a network. Every claim and every limitation is on one page.
What it models
The pricing model is Product to ProductVersion to Offer to PriceComponent to Tier. A Product is stable identity, a ProductVersion is an immutable commercial description, and an Offer is the sellable package — versioned as a whole, so its name, eligibility, every component and every tier are fingerprinted together and any pricing change creates a new immutable revision while the prior one is deactivated.
One Offer may carry several components mixing one-time and recurring charges, each declaring a charge type, a pricing model (flat fee, per unit, volume or graduated), a recurrence interval and, for tiered models, an ordered schedule that must be strictly increasing and end open-ended — gap-free and overlap-free by construction. One quote line is one offer and one quantity, and the quantity semantics are explicit rather than implied: a flat fee is charged once and quantity never multiplies it, a volume tier prices the entire quantity at the tier the total reaches, and a graduated schedule prices each band separately.
A quote binds one opportunity, one price book and one currency. Submitting freezes an immutable Quote Version carrying everything needed to reproduce the commercial decision after any catalog change: offer identity and revision, product version, every component definition, the complete tier schedule, the calculated band breakdown, quantity, list, discount and net per component, provider provenance, and one grouped total per currency, interval and interval count.
How a coding agent builds it
Sync the catalog from a provider, create a quote from an opportunity and a price book, add lines with an expected revision for optimistic concurrency, submit against a named policy version, then have a human decide. Every amount on every one of those calls is calculated by the server; the client sends an offer, a quantity and a basis-point discount, and nothing else about money.
Discount policies are code-first versioned definitions with declared JSON-safe config. Thresholds live in that config, never in closures, because config is part of the declared-definition fingerprint — changing a threshold without publishing a new version stops the boot rather than quietly re-deciding old quotes. Evaluate receives a deep-frozen context and returns synchronously one of auto_approve, approval_required or reject, with bounded reason, approval-key and matched-rule metadata. The decision, the policy name, the version and the fingerprint are stored on the Quote Version, so a quote from last quarter still explains itself after v2 ships.
Every record in the domain — product, price book, offer, component, tier, quote, quote line, quote version and its lines, components and totals, and the approval — is publicly read-only. They exist solely through catalog sync and the quote actions on a trusted in-process path, so no client can forge a price, a total or a version.
What the framework refuses
An agent actor calling approve on a discounted quote is refused with 403 HUMAN_APPROVAL_REQUIRED. An agent may build the catalog, the quote, the lines and the submission; only actor.type user may decide, and there is exactly one decision per version (409 ALREADY_DECIDED).
Pricing shapes the framework cannot compute exactly are never approximated as flat prices. Metered usage, overage, proration, ramp deals, minimum commitments, attribute-based pricing, tax-inclusive computation, FX and custom provider formulas are stored with quoteEligible false and a bounded reason, no component rows are invented for them, and quoting one is a stable 409 OFFER_NOT_QUOTE_ELIGIBLE.
The arithmetic refuses to be plausibly wrong. Component discounts truncate and never round up, every sum is overflow-checked and an overflow is a refusal rather than a silently wrong number, a superseded offer revision cannot be added to a new draft (409 OFFER_INACTIVE), a stale draft edit is 409 STALE_REVISION, and concurrent submits produce exactly one version because version numbers are database-monotonic.
Provider failures leave no partial catalog state: PROVIDER_FAILED, PROVIDER_TIMEOUT and PROVIDER_INVALID are stable codes with an honest failed trace, and a sync that changes nothing writes nothing — no fake audit events, no fake revisions.
What proves it
tests/commercial-e2e.test.js drives the approval boundary, the revise-and-version-2 path, concurrency, fault injection, provider failures and catalog drift; the 403 refusal for an agent actor is asserted there by line rather than by test name, which is stated rather than glossed. tests/commercial-contract.test.js pins the pricing and rounding contract and tests/admin-quotes.test.js covers the Quotes screens.
The load-bearing proof is drift: a provider changes a tier boundary and a price, new immutable offer revisions appear, every existing Quote Version stays byte-identical, and new drafts price at the new revision.
Where it stops
This is the quote half of quote-to-cash and there is no other half. An approved quote version continues into signature and an immutable Order; it does not continue into an invoice, a payment or a revenue figure, because none of those exist anywhere in the repository.
The evidence this page rests on
Claims and limitations are printed from site/claims.json word for word. Job statuses come from docs/benchmarks/jobs.json; a job with no page of its own is listed with its status rather than linked.
Claims
- C-08 Quotes price on the server from a catalog — one-time and recurring, flat, per-unit, volume and graduated tiers — and freeze into an immutable version when a discount goes for approval.
LimitCatalog sync runs against a fixture provider; no real external catalog (Stripe, Zuora, ERP) is connected. Money is integer cents with no FX — currencies are never summed.
- C-21 The same refusal holds where the money is: an agent actor asking to approve a discounted quote is refused with a 403, and only a human user actor can decide.
LimitThe assertion lives inside a composite end-to-end test rather than a test named for it, so the citation is a file and a line rather than a test name. Extracting it into a named test is tracked in docs/strategy/GO_TO_MARKET.md; until then, cite the line.
- C-16 Every mutation goes through a module service or a named workflow, and leaves an audit event and a step-level trace behind it.
LimitAudit records what the process did under an asserted actor. It is not a tamper-evident or externally attestable log, and it is not a compliance control.
Limitations
- L-01 No authentication, tenancy or RBAC. The server is local-development-only. An actor header is an assertion, not an identity. Do not expose it to a network.
- L-05 No email, calendar or marketing integrations. An in-memory notification provider contract exists. No adapter sends anything to anyone.
Jobs it covers
- JTBD-CO-01 Create a Quote from a Price Book — validated end to end
- JTBD-CO-03 Request a discount under a deterministic policy — validated end to end
- JTBD-CO-04 Obtain required commercial approval on a discount — partially supported
- JTBD-CO-02 Synchronize an external catalog (Stripe/Zuora/ERP/custom) — partially supported
- JTBD-02 Request commercial approval on a deal — validated end to end
- JTBD-03 Manage a deal through pipeline stages — validated end to end
- JTBD-CS-05 Calculate MRR, ARR and TCV from real contract data — not supported