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.
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.
Every path is gated by attributePricingEnabled. V1/V2 orgs and V3 orgs without attribute pricing continue through the same pricing path as before.
Check the list-price resolution order, the validation/source typing, and the publish/draft behavior for DealRule rows.
1. Why this exists
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.
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.
2. What changes
Resolution pipeline
v3Admin.saveDealRules validates the whole authored list and loaded versions.
assembleV3PricingSpec compiles rows into pricingRulesSpec.
LIST_PRICE, the engine resolves layer 1 then layer 2.
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
base list price: 40,000
attribute rule: + 12%
resolved list price: 44,800
trace:
list_price_pick
└─ Attribute price
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
VariablesController resolves attributePricingEnabledFor(orgId) once and passes attributePricing: true only for enabled orgs.
If the flag is absent, lookup uses the old matching path.
usablePricingRulesSpec() schema-checks stored JSON once per object via WeakMap.
Invalid specs are logged and ignored, not allowed to break pricing.
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
- Resolve the normal selected row with current lookup behavior.
- If the row is an attribute-price base row, compose Attribute price layer.
- Find active, in-scope Deal rules layer rules for the product via
dealLayerFor(spec, productId). - Build a deal context from quote terms, opportunity data/type, and pricebook id.
- Resolve deal values against the published key table and run the same attribute-price composer again.
- 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
The save sends every loaded rule id + version.
If another admin changed a loaded rule, saveDealRules returns CONFLICT and writes nothing.
Staged deal rules do not reach quoting until publishDrafts includes dealRules.
Catalog-only publish leaves deal-rule drafts pending.
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
- No behavior change for orgs without
attributePricingEnabled. - No change to V1/V2 org pricing paths.
- No REST surface; the new admin surface is Dealops 2/3 tRPC under
v3Admin. - No migration in this PR;
DealRuletable work comes from the prior stack PR. - No direct raw-spec ownership of deal rules; raw spec publish/editor paths reject or ignore that field.
- No deal-rule adjustment for non-
LIST_PRICElookups such as COGS. - No adjustment of non-price list-price rows; percent/count-style rows are left unchanged and traced as skipped.
- No add-product execution in the engine.
collectAddedProductsexists for a later quote-save reactor path.
5. Test coverage
- 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
publishDraftstargetdealRules.
dealRules.conformance.test.tscovers DEAL and DCTX corpus cases.attributePricePurity.spec.tskeeps pricing isolate imports pinned.composer.test.tscoverskeepBaseWhenNothingApplies.
validate.test.tschecks every error/warning class.listDealRuleFields.test.tsproves listed fields validate.specRoundTrip.test.tsproves V2/V3 schemas and converters preservepricingRulesSpec.
6. Risks / rollback / open questions
- Validation strictness: save/publish now reject rules whose keys do not match the org’s deal terms, attributes, products, or converter behavior.
- Trace shape: list-price traces now have a second child when deal rules apply, and an internal fallback node when they are skipped.
- Spec assembly:
pricingRulesSpecin an old blob is intentionally ignored when no live rows exist. - Truncated diff: route internals and some lower-file details were not fully visible in the prompt; reviewers should inspect those files directly.
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.