PRD: Promotion setup & lifecycle
| Module | Campaign (EXT-03) | PRD ID | PRD-PRM-001 |
| Status | Shipped | Owner | Campaign squad |
| Date | 2026-03-23 | Version | v1.0 |
| Packages | @nx/pricing · @nx/core · apps/client | URD | PRM |
TL;DR
Lets a merchant set up a promotion as a first-class pricing concept - its discount method (percentage or fixed), eligibility/source/target rules, validity window, and usage limit - in a single operation, then drive it through a clear
DRAFT → ACTIVATED → DEACTIVATED/EXPIRED/ARCHIVEDlifecycle. Promotions are scoped per merchant, so each storefront owns and controls its own offers from draft to retirement.
1. Context & Problem
Merchants want to drive sales with targeted discounts, but the pricing package only carries fare-based primitives (Fare, FareRule). There is no promotion entity, no discount method, and no lifecycle to move a promotion from a draft into a live offer - so a merchant has no way to author a structured discount, define who/what it applies to, cap its usage, or schedule when it is valid.
This increment introduces promotions as a first-class pricing concept. The rule primitive is generalized so the same eligibility/source/target condition is shared by both fares and promotions, and a Promotion + PromotionMethod pair gives the merchant full CRUD, a validity window, a usage limit, and a status-driven lifecycle. Promotions are scoped per merchant so each storefront owns its own catalog of offers.
2. Goals & Non-Goals
Goals
- A
Promotion+PromotionMethodpair as first-class pricing entities, created with method and rules in one aggregate operation. - A discount method on the promotion:
FIXED/PERCENTAGE, with a target type and an allocation strategy. - A reusable
Ruleprimitive for eligibility, source, and target rules - shared with fares. - A status-driven lifecycle defaulting to
DRAFT, coveringDRAFT → ACTIVATED → DEACTIVATED / EXPIRED / ARCHIVED. - A validity window (time-base fields) and a usage limit per promotion.
- Promotion types
STANDARDandBUY_GET. - Per-merchant scoping (
merchantIdonPromotion).
Non-Goals
- Auto-apply / manual coupon at checkout - owned by Discount Application (
APP, P2, URD-APP-001…003). - Loyalty point earning/redemption - owned by Loyalty.
- Sending promotional messages - owned by Marketing.
- Tax calculation - owned by the Pricing tax engine.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Atomicity | 100% of promotion creates persist promotion + method + rules together, or none on error |
| Lifecycle integrity | Every promotion sits in exactly one valid status; only valid transitions are accepted |
| Tenancy isolation | A merchant only ever sees and mutates its own promotions (merchantId scoped) |
| Setup coverage | Merchants can configure percentage and fixed offers with eligibility/target rules without engineering help |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner | Create, activate, and archive any promotion for the merchant |
| Manager | Author and manage promotions - method, rules, validity, usage limit |
Core scenarios: create a promotion with its method and rules in one step → set validity window and usage limit → activate it → later deactivate, let it expire, or archive it.
5. User Stories
- As a manager, I want to create a promotion with its discount method and rules in one step, so the offer is fully defined in a single operation.
- As a manager, I want to choose a
FIXEDorPERCENTAGEmethod with a target type and allocation, so the discount behaves the way the campaign needs. - As a manager, I want to define eligibility, source, and target rules, so the promotion only qualifies the right customers and products.
- As a manager, I want to set a validity window and a usage limit, so the offer runs for a fixed period and cannot be over-redeemed.
- As an owner, I want to move a promotion through its lifecycle (activate, deactivate, expire, archive), so I control when an offer is live.
- As an owner, I want promotions scoped to my merchant, so my offers never leak across storefronts.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Create a promotion with method and rules in one aggregate operation (all or none on error) | URD-PRM-001 |
| FR-2 | Lifecycle DRAFT → ACTIVATED → DEACTIVATED / EXPIRED / ARCHIVED, defaulting to DRAFT (IGNIS Statuses) | URD-PRM-002 |
| FR-3 | Discount method FIXED / PERCENTAGE, with target type and allocation strategy | URD-PRM-001 |
| FR-4 | Define eligibility, source, and target rules via the shared Rule primitive | URD-PRM-003 |
| FR-5 | Promotion types STANDARD and BUY_GET | URD-PRM-001 |
| FR-6 | Validity-window (time-base) fields on the promotion | URD-PRM-002 |
| FR-7 | Enforce a usageLimit per promotion | URD-PRM-004 |
| FR-8 | Per-merchant scoping (merchantId on Promotion, (merchantId, status) index) | URD-PRM-001 |
Full requirement text and acceptance criteria live in the Campaign URD. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Data integrity | Promotion, method, and rules are created together transactionally - partial failures don't leave a half-written promotion |
| Lifecycle validity | status defaults to DRAFT; transitions map directly to IGNIS built-in Statuses (no custom enum) |
| Tenancy & authz | All operations scoped per merchant; gated by pricing/campaign permissions |
| Reuse | The Rule primitive is shared between fares and promotions - one rule model, two consumers |
| Cascade | Deleting a promotion cascades to its method and rules |
| i18n | User-facing labels/statuses are bilingual ({ en, vi }) |
8. UX & Flows
Key screens (in apps/client): the promotion list view, plus promotion create/edit for the method, rules, validity window, and usage limit.
9. Data & Domain
| Entity | Role |
|---|---|
Promotion | The promotion document - type, status, validity window, usageLimit, merchantId |
PromotionMethod | The discount method - FIXED/PERCENTAGE, target type, allocation strategy |
Rule | Shared eligibility/source/target condition primitive (reused by fares and promotions) |
Conceptual only - full schema and invariants in the pricing domain model and the promotion docs.
10. Dependencies & Assumptions
Depends on
Ruleprimitive (@nx/pricing/@nx/core) - generalized fromFareRuleso promotions and fares share one rule model.- Products (Products) - target rules reference products and categories.
- Customer (Customer) - eligibility rules reference customer segments.
Assumptions
- A promotion has a valid method before it can activate (constraint C-01).
- Each merchant owns its own promotions;
merchantIdis always present.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Promotion/method/rule writes could diverge on partial failure | Created transactionally as one aggregate - all or none |
Renaming FareRule to Rule could break fare consumers | Shared primitive verified against both fare and promotion usage |
| Promotions configured but not yet applied at checkout | Accepted - discount application is the separate APP feature (P2); compute service disabled here |
| Activating a promotion without a method | Guarded by constraint C-01 (method required before activation) |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 (foundation) - see URD feature catalog |
| Rollout | All merchants; no feature flag |
| Migration | New entities (Promotion, PromotionMethod); FareRule → Rule rename; merchantId added to Promotion |
| Launch criteria | Aggregate create persists promotion + method + rules; lifecycle transitions verified; per-merchant isolation verified; client list renders |
| Monitoring | Promotion count per merchant, create error rate, lifecycle transition errors |
13. FAQ
Are a promotion's method and rules created separately? No - promotion, method, and rules are created together in one aggregate operation; on error none are persisted.
What discount methods are supported? FIXED and PERCENTAGE, each with a target type and an allocation strategy.
What is the lifecycle? A promotion starts in DRAFT and moves through ACTIVATED → DEACTIVATED / EXPIRED / ARCHIVED, mapped to IGNIS built-in Statuses.
Does activating a promotion apply discounts at checkout? Not yet - setup and lifecycle are live, but auto-apply/coupon at checkout is the separate Discount Application feature (APP, P2). The compute service is disabled here.
Can a promotion be shared across merchants? No - promotions are scoped per merchant via merchantId; a merchant only sees and mutates its own.
References
- URD: Campaign - Promotion Setup & Lifecycle
- Related PRD: Discount Application (
APP, planned) - Module: Campaign - overview + traceability
- Developer: @nx/pricing · Promotions · domain model