PRD: Loyalty points at order
| Module | Sale | PRD ID | PRD-PNT-001 |
| Status | Ready to dev | FEAT | PNT |
| Epic | — | Plane | BANA-1559 |
| Date | 2026-03-23 | Version | v1.0 |
| Packages | @nx/sale | URD | PNT |
| Surface | Backend | ||
| Owner | Phát Nguyễn | ||
TL;DR
When a customer-linked order is fully paid, the customer automatically earns loyalty points from the order total at a per-merchant conversion rate. Each award fires once per order, posts to a read-only ledger, and rolls into the customer's running balance - no extra step for the cashier.
1. Context & Problem
Orders can already be linked to a customer (URD-ORD-014) and reach a terminal COMPLETED state on full payment (URD-ORD-011), but nothing accrues on that relationship - there is no way to reward repeat purchase. Merchants who want a simple "spend X, earn a point" loyalty mechanic have to track it off-system or not at all, which is unworkable for the HKD/SME retail and F&B venues BANA targets.
This PRD closes that gap: fully-paid, customer-linked orders earn points automatically off the payment-success path - one idempotent ledger entry per order, reflected in a running per-customer balance.
2. Goals & Non-Goals
Goals
- Earn points for the customer when an order is fully paid with a customer attached, hooked into both the order and the per-check payment-success paths.
- Compute the award from the order total against a per-merchant conversion rate, floored to whole points.
- Make the award idempotent per order - a replay of the same completed order awards nothing extra.
- Track a point balance per customer per merchant, incremented together with the ledger entry as a single atomic write.
- Record each award as a read-only point ledger entry (earn) carrying the order, points, and conversion rate.
Non-Goals
- Point redemption / spending - only the earn entry type exists in this increment.
- Refund / return reversal of awarded points (refund flow is itself a module Non-Goal).
- Tiers, expiry, multipliers, promotions, or campaign-driven bonus points.
- A customer-facing UI for the balance or ledger.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Coverage | Customer-linked, fully-paid orders that produce a point award (where a conversion rate is configured) |
| Idempotency | Zero duplicate awards - at most one ledger entry per order |
| Balance integrity | Customer balance always equals the sum of that customer's earn ledger entries |
| Adoption | Merchants that configure a conversion rate and accrue points |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Customer | Accumulate points automatically on every completed purchase |
| Cashier | Take payment as usual; points accrue with no extra step |
| Owner | Run a simple earn-based loyalty program scoped to each merchant |
Core scenario: cashier attaches a customer → takes full payment (order or final check) → system floors the order total by the conversion rate to whole points, records one earn ledger entry, and increments the balance - idempotently, even on replay.
5. User Stories
- As a customer, I want to earn points when my order is fully paid, so that repeat purchases build a balance I can later be rewarded for.
- As a cashier, I want points to accrue automatically on payment, so that I don't have to remember a manual loyalty step.
- As an owner, I want to set a per-merchant conversion rate, so that I control how much spend earns a point.
- As an owner, I want a given order to award points only once, so that a replayed payment event can't inflate balances.
- As an owner, I want a read-only ledger of awards, so that every point in a customer's balance is traceable to an order.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Earn points for the customer when an order becomes fully paid with a customer attached, triggered from both the order and the per-check payment-success paths | URD-PNT-001 |
| FR-2 | Compute points as the order total divided by the conversion rate, rounded down to whole points; skip when the result is ≤ 0 | URD-PNT-001 |
| FR-3 | Read the per-merchant point conversion rate setting; skip when no setting exists or the rate is non-positive; default 1000 | URD-PNT-001 |
| FR-4 | Award is idempotent per order - short-circuit if an award already exists for the order | URD-PNT-002 |
| FR-5 | Record the award as a point ledger entry (earn) carrying the order, points, and conversion rate | URD-PNT-001 |
| FR-6 | Increment the customer's point balance and record the ledger entry together as a single atomic write; roll back on error | URD-PNT-003 |
| FR-7 | Expose the ledger read-only (list, get by id, get one, count), confined to the user's own merchant; awards are never written by a client | URD-PNT-001..003 |
Full requirement text and acceptance criteria live in the Orders URD. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Data integrity | Ledger entry and balance increment are written together as a single atomic write - no balance change without a matching ledger entry, and vice versa |
| Idempotency | At most one award per order regardless of how many times the payment-success event fires |
| Immutability | The ledger is written only via the payment-success path and is read-only for clients; entries are not edited by clients |
| Tenancy & authz | All operations confined to the user's own merchant; ledger reads gated by point-ledger read permissions |
| Precision | Order total read with 4-decimal precision; points are whole numbers |
| i18n | User-facing labels/statuses are bilingual (English & Vietnamese) |
8. UX & Flows
The award path has no dedicated UI - it runs in the background off payment success. The read-only ledger and per-customer balance are consumed by Orders/Customer surfaces in the front end.
9. Data & Domain
| Entity | Role |
|---|---|
| Point ledger entry | Immutable ledger entry (earn type) - the order, points, conversion rate, customer & merchant scope |
| Customer point balance | Running per-customer per-merchant balance, incremented atomically with each award |
| Point conversion rate setting | Per-merchant conversion rate setting; default 1000 |
| Order | Source of the order total and the customer link that drives the award |
Conceptual only - full schema and invariants in the sale domain model.
10. Dependencies & Assumptions
Depends on
- Sale Order lifecycle (URD-ORD-011) - the award fires on the full-payment transition.
- Customer link (URD-ORD-014) - only customer-linked orders earn points.
- Check splitting (URD-CHK) - sale-check payment success is a second trigger path.
- Point conversion rate setting - supplies the per-merchant conversion rate.
Assumptions
- The order carries a settled total at payment-success time.
- A merchant that wants points configures the conversion rate; absent that, the award is skipped (default 1000 applies only when the setting exists without a value).
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Duplicate awards from replayed payment events | Idempotency short-circuit keyed on the order |
| Ledger and balance could diverge on partial failure | Both written together as a single atomic write; rolled back on error |
| Refund / return after points awarded | Out of scope - no reversal yet; refund is a module Non-Goal |
| No redemption path | Accepted for this increment; only earn exists, redemption is a future increment |
| Merchant without a configured rate earns nothing | By design - award is skipped on missing config or non-positive rate |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P2 - see URD feature catalog |
| Rollout | All merchants; no feature flag (inert until a merchant sets a conversion rate) |
| Migration | New point ledger entry and a customer point balance |
| Launch criteria | Full payment on a customer-linked order awards the correct floored points; replay awards nothing extra; balance equals ledger sum; reads are merchant-scoped and read-only |
| Monitoring | Award volume per merchant, idempotency-skip rate, balance-vs-ledger consistency checks |
13. FAQ
When exactly are points awarded? When a customer-linked order becomes fully paid - via either the order or the per-check payment-success path.
How many points does an order earn? The order total divided by the conversion rate, rounded down to whole points, where the conversion rate is the merchant's setting (default 1000). Sub-point remainders are dropped.
What if the merchant hasn't configured a rate? No points are awarded - the award is skipped on a missing config or a non-positive rate.
Can a replayed payment double-award? No - the award is idempotent per order; a second attempt for the same order is short-circuited.
Can customers spend or redeem points? Not in this increment - only the earn entry type exists. Redemption, tiers, and expiry are future work.
Can a client write to the ledger? No - the ledger is read-only; awards are written solely by the payment-success path.
References
- URD: Orders - Loyalty Points (PNT area)
- Related: Sale Order · Check Splitting
- Module: Orders - URD
- Developer: @nx/sale · domain model