PRD: Promotions, methods & segment rules
| Module | Pricing (CORE-14) | PRD ID | PRD-PROMO-001 |
| Status | Shipped | Owner | Pricing squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/pricing · @nx/core | URD | PROMO |
TL;DR
Lets a merchant configure a discount campaign as a single, self-contained object: a promotion carries exactly one method (fixed amount or percentage; aimed at the line items, the whole order, or shipping; spread per-item, across items, or once) and a scheme that is either a straight discount (
STANDARD) or a buy-X-get-Y deal (BUY_GET). Three rule sets segment the campaign - eligibility rules decide who/when qualifies, source rules decide what must be bought, target rules decide what gets discounted. The whole graph - promotion, method, and all three rule sets - is created and edited in one atomic aggregate with denormalized rule counts kept accurate. Applying the discount during checkout is a deliberate later increment; this PRD ships the configuration surface.
1. Context & Problem
Pricing already selects fares (FARE) and computes taxes (TAX), but a merchant had no way to express a discount campaign - "10% off drinks before 5pm", "buy 2 coffees get 1 free", "50,000₫ off orders over 300,000₫". A campaign is not a fare: it spans more than one line, it can target the order or shipping rather than an item, and it must say not just how much to discount but who qualifies, what triggers it, and what it lands on.
Without a first-class promotion object an owner cannot build these offers at all, and the data needed to apply them at checkout does not exist. This increment closes the configuration gap: it gives the owner a promotion with one method, a standard or buy-get scheme, and three distinct rule sets, all authored and edited as one atomic unit - so a campaign is always saved complete or not at all, never half-built.
2. Goals & Non-Goals
Goals
- A promotion object with a scheme (
STANDARDorBUY_GET), a code or automatic trigger, an effective window, a usage limit, and a lifecycle status. - Exactly one method per promotion declaring the mechanic:
FIXED/PERCENTAGE, a target (ITEMS/ORDER/SHIPPING), an allocation (EACH/ACROSS/ONCE), and a discounted-quantity cap. - Buy-get support: a method that declares a source minimum quantity (must buy) and a target quantity (gets discount).
- Three segment rule sets - eligibility (on the promotion), source and target (on the method) - reusing the shared rule model (attribute, operator, value, AND logic).
- One atomic aggregate create and update across promotion + method + all rule sets, with denormalized rule counts kept accurate and a shape-based rule merge on update.
- Cascade delete that removes the method and every attached rule with the promotion.
Non-Goals
- Applying promotion discounts during checkout pricing - the calculator is not yet wired into either simulation pipeline (URD-PROMO-006, still a Won't this increment).
- Fare selection and tax computation - owned by FARE and TAX.
- Multi-currency conversion of discount amounts (a currency and exchange rate are carried, not converted here).
- Order persistence, invoice issuance, stock or payment side effects.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Atomicity | A failed sub-step (missing rule field, absent method) leaves nothing persisted |
| Aggregate completeness | Every saved promotion has exactly one method; rule sets persist together with it |
| Count accuracy | Eligibility / source / target rule counts always match the rules actually stored |
| Edit integrity | An aggregate update creates, updates, and deletes rules in one transaction by entry shape |
| Code integrity | No two live promotions in a merchant share a code |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Manager | Build a discount campaign - pick the mechanic, the target, and the rules that segment it |
| Marketing staff | Author time-boxed offers (happy-hour, weekday, channel-specific) and retire them via status |
| System (future checkout) | Read a complete, valid promotion graph to apply at the point of sale (later increment) |
Core scenario: an owner creates a "Buy 2 coffees, get 1 free" promotion - scheme BUY_GET, one PERCENTAGE method valued 100 targeting ITEMS with source-min 2 and target 1, one source rule (the qualifying drink) and one target rule (the free drink), plus an eligibility rule limiting it to the dine-in channel. The promotion, its method, and all three rules are saved in one atomic call; the rule counts read back correctly. Later the owner edits the aggregate to drop the channel rule and raise the usage limit - all in one transaction.
5. User Stories
- As an owner, I create a promotion, its method, and all its rules in one request, so a campaign is never saved half-built.
- As an owner, I choose a fixed amount or a percentage and aim it at the items, the order, or shipping, so the offer matches what I am promoting.
- As an owner, I build a buy-X-get-Y deal by setting the source quantity to buy and the target quantity to discount, so classic combo offers are possible.
- As marketing staff, I segment an offer with eligibility, source, and target rules, so it fires only for the right basket on the right channel.
- As marketing staff, I retire an offer by moving its status to deactivated or expired, so it stops without being deleted.
- As an owner, I edit an existing promotion's rules - adding, changing, and removing them - in one save, so maintenance is a single atomic step.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | A promotion, its single method, and its rule sets are created together in one atomic aggregate | URD-PROMO-001 |
| FR-2 | A promotion has at most one method | URD-PROMO-003 |
| FR-3 | The method is FIXED or PERCENTAGE, carrying the discount value | URD-PROMO-008 |
| FR-4 | The method targets ITEMS, ORDER, or SHIPPING | URD-PROMO-009 |
| FR-5 | The method declares an allocation (EACH / ACROSS / ONCE) and can cap the discounted quantity | URD-PROMO-010..011 |
| FR-6 | A promotion is STANDARD or BUY_GET; a buy-get method declares source-min and target quantities | URD-PROMO-007 · URD-PROMO-012 |
| FR-7 | Three rule sets segment the campaign: eligibility (promotion), source and target (method), reusing the shared rule model | URD-PROMO-013 · URD-PROMO-004 |
| FR-8 | Eligibility / source / target rule counts are denormalized and kept accurate as rules change | URD-PROMO-014 |
| FR-9 | A promotion carries an optional unique code (no code ⇒ automatic), a stacking flag, and a tax-inclusive flag | URD-PROMO-015..016 |
| FR-10 | A promotion carries an effective window, a usage limit, and a running usage count | URD-PROMO-017 |
| FR-11 | A promotion moves through a lifecycle status, defaulting to DRAFT | URD-PROMO-018 |
| FR-12 | An aggregate update merges rules by entry shape (create / update / delete) in one transaction | URD-PROMO-019 · URD-PROMO-002 |
| FR-13 | Deleting a promotion cascades to its method and every attached rule atomically | URD-PROMO-020 |
| FR-14 | Promotions, methods, and rules are merchant-isolated and soft-deleted | URD-PROMO-005 |
Full requirement text and acceptance criteria live in the Pricing URD - PROMO. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Atomicity | The whole campaign graph - promotion, method, eligibility / source / target rules - is one all-or-nothing transaction |
| Tenancy & authz | All operations scoped per merchant (x-merchant-id), authenticated (JWT or basic) and gated by promotion / method / rule permissions |
| Validation | A rule created during an aggregate write must carry attribute, operator, and data type, or the whole write is refused |
| Consistency | Denormalized rule counts are updated inside the same transaction as the rules they count |
| Precision | Discount value, max quantity, and buy-get quantities use decimal precision (4 places) |
| i18n | Promotion name and description are bilingual ({ en, vi }) |
8. UX & Flows
The configuration surface lets the owner pick the scheme, set the code (or leave it automatic), define the effective window and usage limit, choose the method's mechanic / target / allocation, enter buy-get quantities, and attach the three rule sets. Editing reuses the same aggregate; status moves a campaign through its lifecycle without deletion.
9. Data & Domain
| Entity | Role in this feature |
|---|---|
Promotion | The campaign - scheme, code/automatic, stacking, tax-inclusive, effective window, usage limit/count, status; holds the eligibility rules |
PromotionMethod | The single mechanic - fixed/percentage value, target type, allocation, discounted-quantity cap, buy-get source-min and target quantities |
Rule | A polymorphic condition (attribute, operator, value, priority); attached to the promotion (eligibility) or the method (source / target, distinguished by context) |
Conceptual only - full schema and invariants live in the pricing domain model. A method's source and target rules are the same entity distinguished by a stored context tag; integrity is enforced in the aggregate service, not by database constraints.
10. Dependencies & Assumptions
Depends on
- Shared rule model (URD-PROMO-004, also used by FARE) - eligibility, source, and target rules reuse the same attribute / operator / value shape with AND logic.
- Commerce / Merchant - every promotion, method, and rule is merchant-scoped.
@nx/core- promotion, method, and rule models, schemes, and status constants.
Assumptions
- A promotion has exactly one method; multi-method campaigns are out of scope.
- Source and target rules are meaningful only for buy-get-style mechanics; standard promotions typically use eligibility rules alone.
- The checkout-time calculator that consumes this configuration is a separate, later increment.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Partial campaign on failure (promotion without a method, or with orphan rules) | Whole graph is one atomic transaction - any failure rolls back fully |
| Rule counts drift from the rules actually stored | Counts are denormalized and updated inside the same transaction as the rules |
| Ambiguous edit intent (add vs change vs remove a rule) | Aggregate update is shape-based: no id creates, id+fields updates, id only deletes |
| Two live promotions reuse a code | Code is unique per merchant among non-deleted promotions |
| Owner expects the discount to apply at checkout | Out of scope this increment - the calculator is not yet wired (URD-PROMO-006) |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P3 - PROMO in the URD feature catalog |
| Rollout | All merchants; no feature flag |
| Migration | None - new promotion / method / rule tables; no change to existing fare/tax data |
| Launch criteria | Aggregate create persists promotion + one method + all rule sets atomically; counts read back correctly; aggregate update merges rules by shape; delete cascades; codes are unique per merchant |
| Monitoring | Aggregate create/update error rate by reason (missing rule field, absent method, code conflict) |
13. FAQ
Does creating a promotion apply a discount at checkout? No - this increment ships the configuration surface only. The calculator that applies promotions during pricing is a separate, later increment (URD-PROMO-006).
How many methods can a promotion have? Exactly one. The mechanic (fixed/percentage, target, allocation, buy-get quantities) all lives on that single method.
What is the difference between the three rule sets? Eligibility rules (on the promotion) decide who/when qualifies; source rules (on the method) decide what must be bought; target rules (on the method) decide what gets discounted. Source and target are the same rule entity distinguished by a stored context tag.
How do I edit a campaign's rules? Send an aggregate update - a rule with no id is created, a rule with id and fields is updated, a rule with only an id is deleted, all in one transaction.
What happens to the method and rules when I delete a promotion? They are cascade-deleted with it in one transaction - no orphan method or rules are left behind.
What does a promotion with no code mean? It is automatic (auto-apply) only. A code, when present, is unique per merchant among live promotions.
References
- URD: Pricing - PROMO
- Sibling PRD: Fare & Tax Pricing Engine
- Module: Pricing - overview + capabilities
- Developer: @nx/pricing · Promotion System · @nx/core