Deal-level pricing rules for attribute-priced orgs

PR #7360 adds a second list-price layer that applies org-wide deal conditions after attribute pricing, gated entirely behind attributePricingEnabled.

Author: @mileszim PR: #7360 Status: open Stack: 8 / 16 Base: miles/attribute-pricing-07-catalog Head: miles/attribute-pricing-08-deal-rules Files: 43 Diff: +4261 / -72

What it adds

Deal-level pricing rules can adjust a line’s list price based on quote, opportunity, pricebook, and SKU fields.

They run after attribute price composition and are stored/published as pricingRulesSpec.

What it protects

Every path is gated by attributePricingEnabled. V1/V2 orgs and V3 orgs without attribute pricing continue through the same pricing path as before.

Reviewer focus

Check the list-price resolution order, the validation/source typing, and the publish/draft behavior for DealRule rows.

Attribute price layer
Deal rules layer
DealRule table
pricingRulesSpec
v3Admin routes
Pricing engine
Diff note: the supplied diff is truncated. This explainer covers the PR description and the visible diff, especially the engine, assembly, validation, routes, and e2e tests.

1. Why this exists

Before

Attribute price layer could price the SKU from product attributes.

  • Product/base rules handled catalog-specific list-price logic.
  • Deal-level conditions like payment terms, subscription length, deal type, or pricebook had no second pricing layer.
  • Traces explained the attribute price, but not a deal-wide adjustment on top.
After

Deal rules layer adjusts the list price after the attribute layer.

  • Rules scope to all products or product ids.
  • Conditions can read validated quote, opportunity, pricebook, and SKU fields.
  • Trace output shows both layers, including skipped deal rules.
LIST_PRICEOnly lookup type adjusted
2Pricing layers
3New catalog-test cases
0Change for disabled orgs

2. What changes

Resolution pipeline

Admin save v3Admin.saveDealRules validates the whole authored list and loaded versions.
DealRule rows Live and draft rows store rule content, position, keys, scope, and version.
Assembled spec assembleV3PricingSpec compiles rows into pricingRulesSpec.
Engine lookup On LIST_PRICE, the engine resolves layer 1 then layer 2.
Trace The explanation tree includes attribute price, deal pricing rules, or skipped fallback.

Major file groups

Area Files Change
Engine evaluationEngine.ts, VariablesController.ts, pricingEngineService.ts, pricingEngineSummary.ts Adds deal-rule layer to LIST_PRICE lookup, forwards opportunityType, and carries the attribute-pricing flag into engine attrs.
Pure rule helpers dealops4/pricing/attributePrice/dealRules.ts, dealContext.ts Builds deal context, qualifies keys like quote.subscriptionTerm, scopes rules, converts price rules into composer rules, and keeps add-product collection separate.
Validation dealops4/pricingRules/validate.ts, check.ts, listDealRuleFields.ts Defines source typing for deal terms, SKU attributes, opportunity.dealType, and pricebook.pricebookId; rejects invalid keys before publish.
Routes v3Admin/dealRules.ts, _router.ts, publishDrafts.ts, rawSpec.ts Adds getDealRules and saveDealRules, publishes deal-rule drafts by target, and blocks raw specs that attempt to write pricingRulesSpec.
Spec plumbing catalogProduct/assemble.ts, pricingSpec/types.ts, converter.ts, v2Converter.ts, packages/types/v2/pricingSpecData.ts Carries pricingRulesSpec through V3/V2 schemas and converters, but assembles it from DealRule rows rather than trusting stale blob content.
Tests dealRules.engine.test.ts, dealRules.conformance.test.ts, validate.test.ts, catalog-test/tests.ts Adds conformance, engine, validation, round-trip, route, publish, conflict, and catalog e2e coverage.

Before / after at the price level

Before: one composed layer
base list price:        40,000
attribute rule:       + 12%
resolved list price:    44,800

trace:
list_price_pick
└─ Attribute price
After: deal layer on top
base list price:        40,000
attribute rule:       + 12%
attribute price:        44,800
deal rule:             - 2%
resolved list price:    43,904

trace:
list_price_pick
├─ Attribute price
└─ Deal pricing rules

3. How it works

Engine gate

VariablesController resolves attributePricingEnabledFor(orgId) once and passes attributePricing: true only for enabled orgs.

If the flag is absent, lookup uses the old matching path.

Spec gate

usablePricingRulesSpec() schema-checks stored JSON once per object via WeakMap.

Invalid specs are logged and ignored, not allowed to break pricing.

Lookup gate

Layer 2 only runs when selectors.type === "LIST_PRICE" and the selected item is a DataPoint.

COGS, percent rows, and non-list-price lookups are left alone.

Engine sequence

  1. Resolve the normal selected row with current lookup behavior.
  2. If the row is an attribute-price base row, compose Attribute price layer.
  3. Find active, in-scope Deal rules layer rules for the product via dealLayerFor(spec, productId).
  4. Build a deal context from quote terms, opportunity data/type, and pricebook id.
  5. Resolve deal values against the published key table and run the same attribute-price composer again.
  6. Return the adjusted row value only if the composed price actually changes.
LIST_PRICE row
  → layer 1: composeAttributePrice(product attributes)
  → layer 2: composeAttributePrice(deal context + sku values)
  → final row.value

Condition sources

Entity Examples Typing / validation rule
quote paymentTerms, subscriptionTerm, aliases like quoteBillingFrequency Must be a supported deal term: select, multiselect, number, or checkbox. subscriptionTerm has a fallback from quote subscription length.
opportunity dealType Uses the same pure deal-type normalization as approvals; falls back to opportunityType when CRM data has no type.
pricebook pricebookId No fixed allowed-value list in validator context; editor can supply a separate pricebook picker.
sku supportLevel, seats Reads line pick first, then SKU attribute. Computed and string attributes are not valid deal-rule sources.

Admin save and publish semantics

Optimistic concurrency

The save sends every loaded rule id + version.

If another admin changed a loaded rule, saveDealRules returns CONFLICT and writes nothing.

Draft boundary

Staged deal rules do not reach quoting until publishDrafts includes dealRules.

Catalog-only publish leaves deal-rule drafts pending.

Raw spec boundary

The raw pricing-spec editor refuses pricingRulesSpec.

Deal rules are sourced from the DealRule table, not hand-edited blob JSON.

4. What it doesn’t change

5. Test coverage

Catalog e2e
  • Case 13: deal rule adjusts attribute price and trace shows both layers.
  • Case 15: stale Deal Rules page save is refused with CONFLICT.
  • Case 16: staged rule reaches quoting only through publishDrafts target dealRules.
Pure / conformance
  • dealRules.conformance.test.ts covers DEAL and DCTX corpus cases.
  • attributePricePurity.spec.ts keeps pricing isolate imports pinned.
  • composer.test.ts covers keepBaseWhenNothingApplies.
Validation / spec plumbing
  • validate.test.ts checks every error/warning class.
  • listDealRuleFields.test.ts proves listed fields validate.
  • specRoundTrip.test.ts proves V2/V3 schemas and converters preserve pricingRulesSpec.

6. Risks / rollback / open questions

Primary risks to review:

Rollback shape: disable attributePricingEnabled for an org to bypass the entire new pricing path, or remove/unpublish deal-rule rows so assembled specs omit pricingRulesSpec.