Server-owned promotion stamping and pricing discounts
This Dealops 2 server PR makes promotions trusted, priced, and booked into quote summaries without letting client-supplied stamps affect TCV, ACV, or ARR.
Three promotion services: stamping, discount computation, and summary booking.
The server now derives appliedPromotion from trusted inputs instead of accepting it from the client.
PricingEngineSummary.calculateSummary runs a promotion pass after the response literal is built.
Promotion concessions reduce TCV, total contract revenue, and first-year ACV.
Quotes without promotion stamps return byte-identical summaries.
ARR, profit, margin, and negotiated dealDiscount stay gross by design.
Validate the net/gross contract, the stacking rule for deal-volume promotions, and the amendment re-booking path.
selectedPromotionIdsappliedPromotion stamp1. Why this exists
Promotions can be authored, stored, selected, and included in approval attributes.
- The model exists.
- The quote can carry selected IDs.
- No server pricing pass applies the discount yet.
The server becomes the source of truth for promotion eligibility and concession booking.
- Client stamps are stripped or overwritten.
- Discounts are computed from engine-valued revenue.
- TCV, ACV, and ARR stop contradicting each other.
2/4. It sits between model/storage work and the upcoming admin UI, quote badges, persisted toggle, and Pylon startup-discount scenarios.
2. What changes
selectedPromotionIds is the only client-writable promotion state.
Server resolves live spec, segment, renewal, and stacking gates.
Stamped lines are grouped by promotion and valued per contract year.
Promotion concession nets only the agreed output fields.
Headline TCV is re-netted after amendment override recomputes it.
Files grouped by responsibility
PricingQuoteService.ts stamps promotions during quote creation / seeded quote pricing.trpc/router/pricingQuote/update.ts stamps promotions on normal recompute saves and on refreshOutput:false writes.pricingEngineSummary.ts computes promotion discounts after the base response literal is built and applies the concession.computeAmendmentContext.ts re-applies the concession when amendment normalization overwrites headline TCV.stampQuotePromotions.ts derives line stamps from selected IDs and live specs.computePromotionDiscounts.ts calculates year and month discount totals.applyPromotionConcession.ts books the discount into the output summary.packages/types/v2/pricingQuoteInput.ts adds apiProductInputSchema that omits appliedPromotion.stampQuotePromotions.test.ts, computePromotionDiscounts.test.ts, and applyPromotionConcession.test.ts pin the behavioral contract.Before / after at the API boundary
A caller could send product lines that contained appliedPromotion.
That created room for stale or forged stamps to persist alongside selectedPromotionIds.
External quote input uses apiProductInputSchema, which omits appliedPromotion.
Zod strips the field; server write paths re-derive the real stamp.
3. How it works
Inputs: selected IDs, live PromotionSpec, product list, quote use case, renewal flag.
Output: effective selection plus per-line appliedPromotion snapshots.
Inputs: stamped lines, contract term, currency, engine-provided yearly revenue.
Output: promotionDiscounts with total, per-year, per-promotion, and internal per-month allocation.
Inputs: gross pricing summary plus computed discounts.
Output: net TCV / ACV fields with gross recurring run-rate fields preserved.
3.1 Server-owned stamping
input.selectedPromotionIds is human-authored state; products[].appliedPromotion is derived state.
| Gate | What happens | Why it matters |
|---|---|---|
| Live spec | Dropped if the org no longer has the promotion spec. | Prevents deleted or missing promotions from pricing. |
| Segment | Dropped when quote use case does not match displayTargeting.segments. |
Prevents stale client state from bypassing presentation-only targeting. |
| Renewal | Dropped unless carryIntoRenewal is true. |
Startup discount renewals re-price at full price by default. |
| Stacking | Only the first deal-volume promotion survives. | A deal-volume promotion already covers the whole recurring base. |
const promotionStamping = await stampQuoteInputPromotions({
organizationId,
opportunityV2Id,
products: finalMergedInput.products ?? [],
selectedPromotionIds: finalMergedInput.selectedPromotionIds,
pricingSpecData: summaryInputUpdate.pricingSpecData,
useCase: finalMergedInput.useCase,
});
3.2 Pricing-summary promotion pass
The pass runs inside PricingEngineSummary.calculateSummary immediately after the response literal is built.
The calculator is kept pure by injecting engine valuation callbacks instead of reaching into PricingEngineService directly.
const promotionDiscounts = computePromotionDiscounts({
products: pricingQuoteInput.products,
subscriptionTerms: pricingQuoteInput.subscriptionTerms,
currency: pricingQuoteInput.targetCurrency || 'USD',
getYearlyRevenue: (product, yearIndex) =>
pricingEngineService.getRevenue(
{ type: 'product', id: product.id, productSpecId: product.productSpecId ?? product.id },
{ type: 'absolute', timePeriod: 'year_idx', idx: yearIndex },
).value,
getActiveMonths: promotionActiveMonths,
isRecurring: (product) =>
pricingSpecData.productSpecs[product.productSpecId ?? product.id]
?.calculationSpec?.contributesToRecurringRevenue !== false,
});
3.3 Discount math invariants
The ramped percent applies to the sum of eligible revenue, then clamps to the year cap.
This makes a flat cap behave like a true promotion cap, not a per-line cap.
The pass uses year_idx, not month lookup, for the eligible base.
That prevents Pylon-style dated lines from being double-counted across years.
Deal-volume promotions include recurring lines only.
One-time fees such as implementation and services are excluded from the base.
Discounts are allocated to months as integer cents using largest remainder.
TCV segments reconcile exactly instead of drifting by pennies.
3.4 What becomes net vs gross
| Field family | After promotion pass | Reason |
|---|---|---|
tcv.all, tcv.year1, tcv.year2, tcv.segments[].tcv |
Net | They represent what the customer pays over the contract and per slice. |
tcvAllValue, revenue.totalContract |
Net | They mirror total contract value. |
netNewAcv |
Net | Promotion reduces first-year contract value. |
netNewArr, arrAtScale, revenue.annual, revenue.monthly |
Gross | They are recurring run-rate metrics; the promotion concession expires with the term. |
dealDiscount, product discount fields, profit, margin |
Gross | Negotiated rep concession stays separate from programmatic promotion concession. |
applyPromotionConcession exits if summary.promotionDiscounts already exists, so repeated application does not double-discount.
if (summary.promotionDiscounts) return summary;
if (discounts.total.value <= 0) return summary;
summary.tcv.all = net(summary.tcv.all, discounts.total.value);
summary.netNewAcv = net(summary.netNewAcv, discountForYear(1));
summary.promotionDiscounts = output;
3.5 Amendment override repair
applyAmendmentSummaryOverride recomputes tcv.all from gross prior TCV plus change value.
This PR calls reapplyPromotionConcessionToTcvAll immediately after that overwrite, keeping the headline aligned with the already-booked promotion discount.
4. What it doesn't change
- No client UI is added here; admin UI and quote badges are part
3/4. - No new REST route is introduced; touched quote write paths are existing Dealops 2 server / tRPC paths.
- No Prisma migration is included in this PR.
- No line price is rewritten; promotions are booked as a summary-level concession.
- No ARR run-rate, profit, margin, or negotiated deal discount field is netted by the promotion.
- No promotion pass runs for unstamped quotes; the summary remains byte-identical.
5. Tests and coverage shape
Covers forged stamp removal, renewal carry gate, segment mismatch, no-spec stripping, no-op fast path, and stacking selection.
Covers caps, year two at full price, repeat-last schedules, dated lines, deal-volume basis, exclusivity, and cent allocation.
Covers net fields, gross fields, idempotency, zero floor, and amendment re-application.
6. Risks / rollback / open questions
tcv, netNewAcv, and promotionDiscounts.
- Mitigation: quotes without promotion stamps short-circuit to byte-identical output.
- Rollback: remove the promotion pass call in
pricingEngineSummary.tsand the stamping calls in quote creation/update paths; storedselectedPromotionIdscan remain inert. - Reviewer question: confirm that approval consumers expect
dealDiscountto remain gross and will readpromotionDiscountsseparately.