PRD: Promotion setup & lifecycle
| Module | Campaign | PRD ID | PRD-PRM-001 |
| Status | Ready to dev | FEAT | PRM |
| Epic | — | Plane | BANA-1492 |
| Date | 2026-03-23 | Version | v1.0 |
| Packages | @nx/pricing | URD | PRM |
| Surface | Client · Owner/Mgr | ||
| Owner | Phát Nguyễn | ||
TL;DR
A merchant builds a promotion in one operation - discount method (percentage or fixed), eligibility/source/target conditions, validity window, usage limit - then runs it through a clear
DRAFT → ACTIVATED → DEACTIVATED/EXPIRED/ARCHIVEDlifecycle. Each promotion stays inside its own merchant, so every storefront owns its offers end to end.
1. Context & Problem
Merchants want targeted discounts to drive sales, but pricing only knew fares and their conditions - no promotion concept, no discount method, no lifecycle. A merchant had no way to author a structured discount, say who and what it applies to, cap its usage, or schedule when it runs.
This increment makes promotions a first-class pricing concept. The condition is generalized so fares and promotions share one eligibility/source/target model; pairing a promotion with a discount method gives the merchant full create/edit/remove control, a validity window, a usage limit, and a status-driven lifecycle. Each promotion stays inside the merchant's own merchant, so every storefront owns its own catalogue of offers.
2. Goals & Non-Goals
Goals
- A promotion paired with its discount method as first-class pricing concepts, created with method and conditions in one atomic operation.
- A discount method on the promotion:
FIXED/PERCENTAGE, with a target type and an allocation strategy. - A reusable condition for eligibility, source, and target rules - shared with fares.
- A status-driven lifecycle defaulting to
DRAFT, coveringDRAFT → ACTIVATED → DEACTIVATED / EXPIRED / ARCHIVED. - A validity window and a usage-limit field per promotion (the limit is checked/incremented at apply-time, which ships with
APP). - Promotion types
STANDARDandBUY_GET. - Per-merchant scoping - each promotion belongs to one merchant.
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 + conditions together, or none on error |
| Lifecycle integrity | Every promotion sits in exactly one status from the standard lifecycle set, defaulting to DRAFT (ordered-transition validation is not yet enforced - status changes go through a generic update) |
| Tenancy isolation | A merchant only ever sees and mutates promotions in its own merchant |
| Setup coverage | Merchants can configure percentage and fixed offers with eligibility/target conditions 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, conditions, validity, usage limit |
Core scenarios: create a promotion with its method and conditions 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 conditions 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 conditions, 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 confined to my own merchant, so my offers never leak across storefronts.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Create a promotion with method and conditions in one atomic operation (all or none on error) | URD-PRM-001 |
| FR-2 | Lifecycle statuses DRAFT → ACTIVATED → DEACTIVATED / EXPIRED / ARCHIVED, defaulting to DRAFT (status field built; ordered-transition guard not yet implemented for promotions) | 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 conditions via the shared condition | URD-PRM-003 |
| FR-5 | Promotion types STANDARD and BUY_GET | URD-PRM-001 |
| FR-6 | Validity-window fields on the promotion | URD-PRM-002 |
| FR-7 | Usage-limit field per promotion, with limit-check and atomic-increment helpers; runtime enforcement happens at apply-time and is not yet live | URD-PRM-004 |
| FR-8 | Per-merchant scoping - each promotion belongs to one merchant, with lookups by merchant and status | 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 conditions are created together atomically - partial failures don't leave a half-written promotion |
| Lifecycle validity | Status defaults to DRAFT and uses the standard lifecycle statuses (no custom states); an ordered-transition state machine is not yet in place (unlike fare sets, which guard transitions) |
| Tenancy & authz | All operations confined to the user's own merchant; gated by promotion permissions |
| Reuse | The condition is shared between fares and promotions - one condition model, two consumers |
| Cascade | Removing a promotion cascades to its method and conditions |
| i18n | User-facing labels/statuses are bilingual (English + Vietnamese) |
8. UX & Flows
Key screens (in the merchant admin app): the promotion list view, plus promotion create/edit for the method, conditions, validity window, and usage limit.
9. Data & Domain
| Concept | Role |
|---|---|
| Promotion | The promotion document - type, status, validity window, usage limit, owning merchant |
| Discount method | The discount method - FIXED/PERCENTAGE, target type, allocation strategy |
| Condition | Shared eligibility/source/target condition (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
- Shared condition - generalized from the fare condition so promotions and fares share one condition model.
- Products (Products) - target conditions reference products and categories.
- Customer (Customer) - eligibility conditions reference customer segments.
Assumptions
- A promotion is expected to have a valid method before it activates (constraint C-01); this is not yet enforced in code - activation is a plain status update with no method guard.
- Each merchant owns its own promotions; every promotion is tied to a merchant.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Promotion/method/condition writes could diverge on partial failure | Created atomically as one unit - all or none |
| Generalizing the fare condition into a shared condition could break fare consumers | Shared condition verified against both fare and promotion usage |
| Promotions configured but not yet applied at checkout | Accepted - discount application is the separate APP feature (P2); discount computation is out of scope here |
| Activating a promotion without a method | Constraint C-01 (method required before activation) is specified but not yet enforced - no activation guard exists today; to be added with the apply-time path |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 (foundation) - see URD feature catalog |
| Rollout | All merchants; no feature flag |
| Migration | New promotion and discount-method records; the fare condition generalized into a shared condition; per-merchant ownership added to promotions |
| Launch criteria | Atomic create persists promotion + method + conditions; status defaults to DRAFT and status updates apply; 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 conditions created separately? No - promotion, method, and conditions are created together in one atomic 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.
Does activating a promotion apply discounts at checkout? Not yet - setup and the lifecycle status field are live, but auto-apply/coupon at checkout is the separate Discount Application feature (APP, P2). Discount computation is present in the codebase but not enabled, so computing discounts, enforcing usage limits, and guarding activation by method are all out of scope here.
Can a promotion be shared across merchants? No - each promotion is confined to one merchant; a merchant only sees and mutates its own.
References
- URD: Campaign - Promotion Setup & Lifecycle
- Related PRD: Discount Application (
APP, planned) - Module: Campaign - URD
- Developer: @nx/pricing · Promotions · domain model