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.

Author: @mehulshinde PR: dealops#7061 Issue: DEA-7480 Stack: 1/4 Status: open Files: 27 Diff: +2280 / -52 Head: mehul/dea-7480-1-model-storage-approvals

What it adds

A real rampedDiscount promotion model for “50% off year one, capped, then full price” deals.

1new spec type
Where it lives

Promotions move from local/client storage toward immutable OrgSpecVersion rows with specType: 'promotionSpec'.

V3spec store
What can call it

New Dealops 2 tRPC routes expose promotionSpecs.get/list/create/update; reps can read, admins can write.

4routes
What is deferred

No promotion pricing pass lands here. This PR declares the model and summary output shape; PR 2 applies money.

0runtime pricing changes
Promotion model
V3 spec store
tRPC routes
Approval attributes
Pricing-summary contract
Tests

1. Why this exists

Current shell gap

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.
Target contract

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
Scope call: this is Dealops 2 + Dealops 3 infrastructure. Types live under @dealops/types/v2, routes are tRPC in apps/server, and persistence uses the Dealops 3 V3 spec-versioning store.
1. Model + storage This PR: schema, V3 storage, routes, approvals.
2. Pricing pass Server-owned stamping and promotion discount computation.
3. UI surfaces Admin UI, quote badges, persisted toggle, knowledge concept.
4. Enablement Pylon startup-discount e2e scenarios.

2. What changes

Promotion model

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.
V3 spec storage

promotionSpec becomes a SpecType enum value and an optional pointer in GlobalSpecPointers.

ALTER TYPE "SpecType" ADD VALUE 'promotionSpec';

There is no dedicated promotions table.

API surface

New router registered at promotionSpecs.

read: authed write: admin org-scoped tRPC
  • get returns current live version or null.
  • list returns published versions newest first.
  • create publishes version 1.
  • update publishes the next version with compare-and-set.
Approvals

Five system attributes let V3 custom approval rules reference promotion selection and discount state.

appliedPromotionId hasPromotion selectedPromotionIds quoteHasPromotion promotionDiscountAmount

Before / after shape

Before
  • 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.
After
  • rampedDiscount can model year-by-year discount schedules and caps.
  • getPromotionLifecycleStatus controls live / not-live behavior.
  • anyOf and allOf encode OR groups of AND rows.
  • Promotion catalogs publish as live V3 spec versions.

Meaningful file groups

Shared types
packages/types/v2/promotionSpec.ts, promotionConditionForm.ts, pricingQuoteOutput.ts, package.json
Persistence
packages/prisma/.../migration.sql, spec-versioning.prisma, dealops3/promotions/promotionSpecStore.ts, dealops3/versioning/*
tRPC
trpc/router/promotionSpec/*, trpc/router/_app.ts, dealops2/services/spec/SpecService.ts
Approvals
dealops3/attributes/system.json, approvals/contextBuilder.ts, approvals/runtimeContext.ts
Compat UI edits
PromotionBuilderDrawer.tsx ignores rampedDiscount for legacy action controls; PromotionsPage.tsx initializes carryIntoRenewal: false.
Tests
Mocha unit coverage for model / selection / codec, tRPC e2e for storage semantics, and a Jest spec for approval runtime context.

3. How it works

Promotion schema: from drawer form to stored expression

Builder form OR groups of AND rows
Codec formToConditions / conditionsToForm
Stored model anyOf / allOf condition tree
Evaluator Product/category matching against pricingSpecData
Codec contract

Promotion publish path

Write semantics
  • Takes an Organization row lock.
  • Rejects saves while a promotionSpec draft 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.
Read semantics
  • 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

One predicate before stamping

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

Quote input selectedPromotionIds and per-line stamps
Context builder Quote and line-level computed fields
Runtime context promotionDiscountAmount from summary
Approval rules Custom V3 rules can route promo deals
Important absence behavior: 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

Declared now, populated later

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

Explicit non-goals in this PR

5. Risks / rollback / open questions

Migration risk: this adds a Postgres enum value with 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.
Conflict behavior

Stale admin tabs get tRPC CONFLICT. This is intentional and tested, but the eventual UI must surface a reload / reapply path.

Draft interaction

Publishing refuses while a promotionSpec draft is pending, preventing a live save from swallowing staged changes.

Stack dependency

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.

Test coverage called out by the PR