Promotion model, V3 storage, routes, and approval hooks
PR #7061 lays the server-side foundation for the startup discount: a richer promotion schema, versioned storage in the V3 spec store, tRPC access, and approval attributes.
A real rampedDiscount promotion model for “50% off year one, capped, then full price” deals.
Promotions move from local/client storage toward immutable OrgSpecVersion rows with specType: 'promotionSpec'.
New Dealops 2 tRPC routes expose promotionSpecs.get/list/create/update; reps can read, admins can write.
No promotion pricing pass lands here. This PR declares the model and summary output shape; PR 2 applies money.
1. Why this exists
The promo-engine shell from PR #5907 did not have enough shape for the startup-discount ask.
- No per-contract-year schedule.
- No flat dollar cap.
- No enforced active window.
- No server-authoritative stacking rule.
- No durable, versioned home for org promotion catalogs.
The startup discount needs to express a very specific commercial rule:
Year 1: 50% off, capped at a flat dollar amount
Year 2+: full price
Basis: total deal volume, not a separate promo tier
Renewal: do not carry by default
@dealops/types/v2, routes are tRPC in apps/server, and persistence uses the Dealops 3 V3 spec-versioning store.
2. What changes
packages/types/v2/promotionSpec.ts gains the durable schema and pure helpers.
rampedDiscountPer-year percentOff, optional maxDiscountAmount, beyondSchedule, and basis.scheduleEnforced lifecycle: inactive, scheduled, active, expired.carryIntoRenewalDefaults false, so startup discount does not seed renewals unless explicitly allowed.resolvePromotionSelectionSingle predicate for live window, segment gate, renewal gate, and stacking.promotionSpec becomes a SpecType enum value and an optional pointer in GlobalSpecPointers.
ALTER TYPE "SpecType" ADD VALUE 'promotionSpec';
There is no dedicated promotions table.
New router registered at promotionSpecs.
getreturns current live version ornull.listreturns published versions newest first.createpublishes version 1.updatepublishes the next version with compare-and-set.
Five system attributes let V3 custom approval rules reference promotion selection and discount state.
appliedPromotionId
hasPromotion
selectedPromotionIds
quoteHasPromotion
promotionDiscountAmount
Before / after shape
- Promotion actions were legacy display-style grants:
freeCredits,percentDiscount,dollarCredit. - Schedule fields existed as display metadata, not enforcement.
- Conditions were flat lists of atomic product rules.
- Promotion authoring had no V3 immutable-version contract.
rampedDiscountcan model year-by-year discount schedules and caps.getPromotionLifecycleStatuscontrols live / not-live behavior.anyOfandallOfencode OR groups of AND rows.- Promotion catalogs publish as live V3 spec versions.
Meaningful file groups
packages/types/v2/promotionSpec.ts, promotionConditionForm.ts, pricingQuoteOutput.ts, package.jsonpackages/prisma/.../migration.sql, spec-versioning.prisma, dealops3/promotions/promotionSpecStore.ts, dealops3/versioning/*trpc/router/promotionSpec/*, trpc/router/_app.ts, dealops2/services/spec/SpecService.tsdealops3/attributes/system.json, approvals/contextBuilder.ts, approvals/runtimeContext.tsPromotionBuilderDrawer.tsx ignores rampedDiscount for legacy action controls; PromotionsPage.tsx initializes carryIntoRenewal: false.3. How it works
Promotion schema: from drawer form to stored expression
formToConditions / conditionsToForm
anyOf / allOf condition tree
pricingSpecData
- Form → conditions → form is identity for builder-expressible conditions.
- Conditions → form → conditions preserves product matching semantics.
- Unrepresentable conditions, such as tag rules or mixed-type nested expressions, return
nullso callers can render them read-only instead of rewriting them.
Promotion publish path
promotionSpec: null; callers treat that as “no promotions,” not as an error.
- Takes an
Organizationrow lock. - Rejects saves while a
promotionSpecdraft exists. - Validates with zod before writing.
- Uses compare-and-set via
expectedLatestId. - Returns the existing version for no-op saves.
- Dispatches platform events after commit.
- Resolve through
Organization.currentGlobalVersionId. - Read
OrgGlobalVersion.specs.promotionSpec. - Load the matching immutable
OrgSpecVersion. - Return versions with string version numbers for client compatibility.
Selection and stacking
resolvePromotionSelection walks the seller’s selected IDs in order and only keeps promotions that survive every gate.
| Gate | Drop reason | Rule |
|---|---|---|
| Exists in spec | unknown |
Deleted or missing promotion IDs are ignored with a reason. |
| Lifecycle | notLive |
Admin toggle must be active and today must be inside the schedule window. |
| Segment | segmentMismatch |
Segment-gated promotions require a matching quote use case. |
| Renewal | notCarriedIntoRenewal |
Renewal quotes drop promotions unless carryIntoRenewal is true. |
| Stacking | stackingConflict |
dealVolume conflicts with everything; matchingLines conflicts only on catalog overlap. |
Approval data path
selectedPromotionIds and per-line stamps
promotionDiscountAmount from summary
promotionDiscountAmount is only present when pricingEngineSummary.promotionDiscounts.total exists. A gt 0 rule stays quiet on promo-less quotes because no synthetic zero is injected.
Pricing-summary output contract
pricingEngineSummaryOutputSchema now allows an optional promotionDiscounts block.
| Block | Purpose |
|---|---|
total |
Total promotion discount across the contract. |
byYear |
Eligible amount and discount amount per 1-based contract year. |
byPromotion |
Per-promotion amount, covered quote-line IDs, and year breakdown. |
The doc comment explicitly separates fields intended to be net of promotion discounts from fields that stay gross.
4. What it doesn't change
- No promotion discount is applied to pricing math yet; that is PR 2.
- No rebuilt promotions admin UI lands here; PR 3 replaces the current shell.
- No quote badges, persisted seller toggle, or knowledge concept land here.
- No Pylon enablement or startup-discount e2e scenarios land here.
- No promotion table is introduced; storage is JSON body in the V3 spec store.
- No cloning of promotions between organizations;
syncConfigurationdeliberately skipspromotionSpec. - No shared database was touched by migration generation, per the PR description.
5. Risks / rollback / open questions
ALTER TYPE "SpecType" ADD VALUE 'promotionSpec'. Rollback for enum additions is not a normal down migration; operational rollback should disable callers or revert application usage rather than assuming the enum value can be removed cleanly.
Stale admin tabs get tRPC CONFLICT. This is intentional and tested, but the eventual UI must surface a reload / reapply path.
Publishing refuses while a promotionSpec draft is pending, preventing a live save from swallowing staged changes.
This PR is first in a four-PR stack. Reviewers should not expect visible seller-facing pricing behavior until PR 2 and PR 3 land.
- 34 Mocha unit tests: schema, lifecycle edges, discount math with caps, selection resolver, and condition codec round-trip.
- 8 tRPC e2e tests: versioning, no-op save, admin gate, org scoping, stale-tab conflict.
- 1 Jest spec: approval runtime context presence / absence for
promotionDiscountAmount.