Building a CPQ Engine for B2B: Rules, Pricing, Approvals
DEV Community

Building a CPQ Engine for B2B: Rules, Pricing, Approvals

Most B2B quoting problems are not sales problems. They are data problems hiding in a spreadsheet: an option combination engineering cannot build, a discount nobody approved, a price list that changed between the quote and the order.

A configure-price-quote (CPQ) engine fixes that by turning product rules, pricing and approvals into versioned data that the system enforces, instead of tribal knowledge in a sales rep's Excel file.

I run Loop Origin, a software studio, and as a Principal Software Engineer I have spent nine years building order, pricing and ERP modules in Laravel, Vue, Nuxt and PostgreSQL. I wrote this with help from AI tools and checked every query and code sample myself. This guide covers the three engines inside CPQ, the data model, a pricing pipeline in Laravel, approval rules and the failure modes I see in production.

What is a CPQ engine?

Direct answer: A CPQ engine is the part of a B2B system that validates which product options can be combined (configure), calculates the price from price books, tiers and contract terms (price), and produces a versioned, approved document the customer can accept (quote). Build one when your products have real option rules and approval chains.

Three engines, not one

CPQ projects go wrong when teams treat it as "a quote PDF generator". In practice there are three separate engines with different change rates:

  • Configuration rules change when engineering or the catalog changes. "Motor size L requires a reinforced frame." "Outdoor enclosure excludes the standard fan."
  • Pricing changes when finance changes price books, contracts or currency rates.
  • Approvals change when the business changes who can sign off on what.

Keep them separate in code and in data. When a rep asks "why is this quote blocked?", the answer should point at exactly one of the three.

The data model

The core idea: rules and prices are data, and every quote version freezes a snapshot of what it used.

CREATE TABLE option_rules (
  id bigserial PRIMARY KEY,
  product_id bigint NOT NULL,
  rule_type text NOT NULL CHECK (rule_type IN ('requires', 'excludes', 'limits_qty')),
  if_option text NOT NULL,
  then_option text,
  max_qty int,
  message text NOT NULL,
  active_from date NOT NULL,
  active_to date
);
CREATE TABLE price_book_entries (
  price_book_id bigint NOT NULL,
  sku text NOT NULL,
  min_qty int NOT NULL DEFAULT 1,
  unit_price numeric(14,4) NOT NULL,
  currency char(3) NOT NULL,
  PRIMARY KEY (price_book_id, sku, min_qty)
);
CREATE TABLE quote_versions (
  id bigserial PRIMARY KEY,
  quote_id bigint NOT NULL,
  version int NOT NULL,
  status text NOT NULL DEFAULT 'draft',
  price_book_id bigint NOT NULL,
  snapshot jsonb NOT NULL, -- lines, rules and prices actually used
  totals_hash text, -- set when submitted for approval
  approved_by bigint,
  approved_at timestamptz,
  UNIQUE (quote_id, version)
);

Two decisions matter here. First, numeric, never floats, for money. Second, a quote is a chain of immutable versions. Editing an approved quote creates version 2 with status draft; it never mutates version 1.

If you already keep an append-only audit log, the approval events belong there too, as in immutable audit trails in Postgres.

Configuration rules as data

Rules stay simple if you limit them to a few types and evaluate them in one place:

final class ConfigurationValidator {
  /** @return list<string> violation messages */
  public function validate(int $productId, array $selected, CarbonImmutable $on): array {
    $rules = OptionRule::activeFor($productId, $on)->get();
    $errors = [];
    foreach ($rules as $r) {
      if (!array_key_exists($r->if_option, $selected)) {
        continue;
      }
      $ok = match ($r->rule_type) {
        'requires' => isset($selected[$r->then_option]),
        'excludes' => !isset($selected[$r->then_option]),
        'limits_qty' => $selected[$r->if_option] <= $r->max_qty,
      };
      if (!$ok) {
        $errors[] = $r->message;
      }
    }
    return $errors;
  }
}

Run the same validator in the Vue/Nuxt configurator (through an API call) and again on submit. The UI check is for speed; the server check is the one that counts.

The pricing pipeline

Price as an ordered list of small stages. Each stage reads the line and writes an adjustment with a reason, so the final number is explainable line by line.

final class PricingPipeline {
  public function __construct(private array $stages = [
    new ListPriceStage(), // price book entry for the qty tier
    new OptionPriceStage(), // add-on options
    new ContractPriceStage(), // customer-specific overrides
    new VolumeDiscountStage(), // tier breaks across the whole quote
    new ManualDiscountStage(), // rep discount, capped by role
    new RoundingStage(), // currency rounding, last
  ]) {}
  public function price(QuoteDraft $q): PricedQuote {
    $priced = PricedQuote::from($q);
    foreach ($this->stages as $stage) {
      $priced = $stage->apply($priced); // records adjustments + reasons
    }
    return $priced;
  }
}

Order matters and should be documented: does a contract price replace the list price before or after volume breaks? Decide once with finance, write it into the pipeline and test it.

Contract and tier pricing for dealers follows the same logic I described in B2B dealer portals: tier pricing and credit limits.

AI-generated image.

Approvals that cannot be bypassed

Approval rules are thresholds stored as data:

Condition Approver Why
Discount above the rep's cap Sales manager Protects list price
Margin below the floor for the product family Finance Protects profit
Non-standard payment terms Finance Cash-flow risk
Custom option outside the catalog Engineering Can we build it?

When a version is submitted, compute a hash of its lines and totals and store it in totals_hash. Approval is recorded against that hash. Before the PDF is sent or the quote converts to an order, recompute the hash; if it differs, the approval is void. This single check stops the most common bypass: editing a line after the manager clicked approve.

Converting an accepted quote into an ERP sales order is a cross-system write, so publish it through an outbox rather than calling the ERP inline. The pattern is in transactional outbox in Laravel.

Build, buy or extend?

Option Fits when Rule complexity ERP integration Main ongoing cost driver
Spreadsheet + PDF template Few products, one approver Low, unenforced Manual re-keying Errors and rep time
CPQ add-on to your CRM Your CRM is already the system of record Medium to high Through CRM connectors Per-seat licenses and admin skills Custom CPQ module
Rules tied to your ERP, catalog or engineering data As high as you need Direct, same database or API Your team's maintenance

I do not recommend custom by default. If your sales team lives in a CRM that has a mature CPQ add-on and your rules are standard, buy it. Custom wins when the configuration rules depend on data that only your ERP or engineering system has, or when per-seat licensing does not fit how many dealers and reps need to quote.

Failure modes I see in production

Price book changes mid-quote. Without a snapshot, reopening an old quote silently re-prices it. Freeze prices per version and show "price book updated" as an explicit action.

Rule explosion. Hundreds of pairwise excludes rules that nobody can reason about. Group options into families and write rules per family.

Discount stacking. Contract price plus volume break plus rep discount ends below cost. The margin floor check must run on the final price, after every stage.

Rounding drift. Rounding each line and the total separately makes the PDF disagree with the ERP by a few cents. Round once per line in the last stage, then sum.

Approvals by email. "Approved, go ahead" in an inbox is not an audit trail.

Rollout checklist

  • [ ] Rules, price books and approval thresholds live in tables, with effective dates
  • [ ] Money uses numeric and a documented rounding rule
  • [ ] Server-side validation runs on every submit, not only in the UI
  • [ ] Every pricing adjustment stores a reason the rep can see
  • [ ] Quote versions are immutable; edits create a new version
  • [ ] Approvals bind to a totals hash and are re-checked before sending and ordering
  • [ ] Margin floor runs after all discounts
  • [ ] Quote-to-order uses an outbox, with idempotent order creation
  • [ ] Run in parallel with the old spreadsheet for a few weeks and compare totals

Frequently asked questions

What is the difference between CPQ and a quoting tool?

A quoting tool formats a document. CPQ also validates the configuration, calculates the price from rules and enforces approvals before the document exists.

Should configuration rules live in code or in the database?

In the database, with a small set of rule types evaluated by code. Product managers can then change rules without a deployment, and tests cover the evaluator once.

How do we handle multiple currencies?

Keep a price book per currency where you can. If you convert, store the rate and its timestamp in the quote snapshot so the version is reproducible.

Can dealers use the same CPQ as internal reps?

Yes, with role-based limits: dealers see their contract price book and lower discount caps, and their quotes follow the same approval rules.

How do quotes sync with the ERP?

Push accepted quotes as sales orders through an outbox and an idempotency key, and pull price books and stock from the ERP on a schedule.

How long does a custom CPQ module take to build?

It depends on how many product families and rules you have and how clean the catalog data is. Estimate after a short discovery of the real rules, not before.

Follow & Connect

LinkedIn: Loop Origin

X: Muhammad Ali Mughal (@AliMughal887)

GitHub: Loop Origin (@LoopOrigin)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.