PRD: Loyalty points at order
| Module | Orders (CORE-07) | PRD ID | PRD-PNT-001 |
| Status | Shipped | Owner | Orders squad |
| Date | 2026-03-23 | Version | v1.0 |
| Packages | @nx/sale · @nx/core | URD | PNT |
TL;DR
Lets a merchant reward repeat customers automatically: when a customer-linked order is fully paid, the customer earns loyalty points computed from the order total against a per-merchant conversion rate. Each award is recorded once per order in a read-only ledger and rolled into a per-customer balance - turning every completed sale into measurable loyalty without any manual cashier step.
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 KICKO targets.
This feature closes the gap: a customer-linked, fully-paid order automatically earns the customer loyalty points, driven off the payment-success path so the cashier does nothing extra. Each award is idempotent per order and maintained as both an immutable ledger entry and 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 sale-order and sale-check payment-success flows.
- Compute the award from the order total against a per-merchant
POINT_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 insert in a single transaction.
- Record each award as a read-only
PointTransaction(EARN) carrying the order, points, and conversion rate.
Non-Goals
- Point redemption / spending - only the
EARNtransaction 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 POINT_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 scenarios: a cashier attaches a customer to an order → takes full payment (on the order or its final check) → the system computes floor(orderTotal / conversionRate) points, records one EARN ledger entry, and increments the customer's balance - once, even if the payment event is replayed.
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 sale-order and sale-check payment-success paths | URD-PNT-001 |
| FR-2 | Compute points as floor(orderTotal / conversionRate); skip when the result is ≤ 0 | URD-PNT-001 |
| FR-3 | Read the per-merchant POINT_CONVERSION_RATE configuration; skip when no config 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 PointTransaction (EARN) carrying saleOrderId, points, and conversionRate | URD-PNT-001 |
| FR-6 | Increment the customer's point balance and insert the ledger entry in a single transaction; roll back on error | URD-PNT-003 |
| FR-7 | Expose the ledger read-only (find / findById / findOne / count), merchant-scoped; 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 insert and balance increment are written in one transaction - 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 write-only via the payment-success path and read-only via the API; entries are not edited by clients |
| Tenancy & authz | All operations scoped per merchant (x-merchant-id); ledger reads gated by PointTransaction* permissions |
| Precision | Order total read with float(value, 4); points are whole numbers |
| i18n | User-facing labels/statuses are bilingual ({ en, vi }) |
8. UX & Flows
The award path has no dedicated UI - it runs server-side off payment success. The read-only ledger and per-customer balance are consumed by Orders/Customer surfaces in apps/client.
9. Data & Domain
| Entity | Role |
|---|---|
PointTransaction | Immutable ledger entry (type EARN) - saleOrderId, points, conversionRate, customer & merchant scope |
Customer.pointBalance | Running per-customer per-merchant balance, incremented atomically with each award |
Configuration (POINT_CONVERSION_RATE) | Per-merchant conversion rate (SYSTEM group, MERCHANT principal, ACTIVATED); default 1000 |
SaleOrder | 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.
Configuration(@nx/core) - supplies the per-merchant conversion rate.
Assumptions
- The order carries a settled total at payment-success time.
- A merchant that wants points configures
POINT_CONVERSION_RATE; absent that, the award is skipped (default1000applies only when the config row exists without a value).
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Duplicate awards from replayed payment events | Idempotency short-circuit keyed on saleOrderId |
| Ledger and balance could diverge on partial failure | Both written in one transaction; rollback 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 PointTransaction table (sale schema) and customer.point_balance column |
| 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 sale-order or the sale-check payment-success path.
How many points does an order earn? floor(orderTotal / conversionRate), where the conversion rate is the merchant's POINT_CONVERSION_RATE (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 transaction type exists. Redemption, tiers, and expiry are future work.
Can a client write to the ledger? No - the ledger API 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 - overview + traceability
- Developer: @nx/sale · domain model