PRD: Customer loyalty points seam (earn)
| Module | Customer (CORE-09) | PRD ID | PRD-PNT-001 |
| Status | Shipped | Owner | Sale squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/sale · @nx/core | URD | PNT |
TL;DR
Closes the loop between a sale and the buyer's loyalty balance. When an order completes payment and a customer is attached, the system earns that customer points - computed from the order total against a per-merchant conversion rate - records the award as an immutable ledger entry, and increments the customer's running point balance in one atomic step. The award is idempotent per order: a redelivered payment event never double-credits. The conversion rate is a spend-per-point factor (how much order value equals one point), so points are floored to whole numbers. This PRD covers earn only - point redemption (burn) is a deliberate non-goal and is not built.
1. Context & Problem
The Customer module already keeps customer profiles (CUS) and attaches a customer to an order at checkout, and the Sale module already drives an order to a fully-paid state. What was missing was the seam between them: nothing turned a completed payment into loyalty value for the buyer.
Without that seam a merchant cannot reward repeat business. There is no automatic point earning, no per-customer balance to read, and no auditable record of why a balance moved. A naive earn path would also risk double-crediting when a payment webhook is redelivered, or crediting against an unconfigured or zero rate. This increment adds the earn seam on top of the existing profile and payment primitives: one award, computed once, recorded once, per paid order.
2. Goals & Non-Goals
Goals
- Earn points automatically when an order completes payment and a customer is attached (URD-PNT-001).
- Compute the award from the order total and a per-merchant conversion rate (spend-per-point), floored to whole points (URD-PNT-002).
- Track a running point balance per customer, incremented atomically (URD-PNT-003).
- Idempotent per order - award at most once even if the payment event is redelivered (URD-PNT-004).
- Award nothing when no customer is attached, the rate is unset, or the rate / computed points are not positive (URD-PNT-005).
- Record every award as an immutable ledger entry linking customer, order, merchant, point count, and the rate applied (URD-PNT-006).
- Expose a merchant-scoped, read-only ledger so staff can browse a customer's point history (URD-PNT-007).
Non-Goals
- Point redemption (burn) - spending points on an order. Not built; the ledger records only earn entries and the balance only ever increases. Tracked as an excluded scope item (URD - Non-Goals).
- Loyalty tiers (Bronze / Silver / Gold) and tier-based earn multipliers.
- Manual point adjustments or external write access to the ledger - entries are written only by the earn seam.
- Expiry of earned points, and point statements / notifications to the customer.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Earn coverage | Every fully-paid order with an attached customer and a configured rate produces exactly one award |
| No double-credit | A redelivered payment event never adds a second award for the same order |
| Balance integrity | A customer's balance equals the sum of their ledger entries' points |
| Audit completeness | No balance increment exists without a matching immutable ledger entry |
| Safe defaults | Orders with no customer, no rate, or a non-positive rate award nothing - never an error |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Manager | Configure the conversion rate; read a customer's balance and point history |
| Cashier | Attach a customer at checkout so the sale earns them points on payment |
| Customer | Accrue a point balance from purchases, auditable entry by entry |
Core scenario: a cashier attaches a customer to an order and takes payment. When the order becomes fully paid, the seam reads the merchant's conversion rate, computes the points from the order total, writes one ledger entry, and increments the customer's balance - all atomically. If the same payment event is redelivered, the seam detects the order already earned and does nothing.
5. User Stories
- As an owner, I want a customer to earn points automatically when their order is paid, so loyalty needs no manual step.
- As an owner, I set a single per-merchant conversion rate, so earning is consistent across every sale.
- As an owner, I want each award recorded as an immutable entry, so I can always explain a balance.
- As an owner, I want a redelivered payment event to never double-credit, so balances stay trustworthy.
- As a cashier, I attach the customer at checkout and do nothing else, so points just follow the sale.
- As an owner, I browse a customer's point history in one read-only list, so I can audit how their balance grew.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | When an order completes payment and a customer is attached, earn that customer points | URD-PNT-001 |
| FR-2 | Compute the award as floor(order total ÷ per-merchant conversion rate) - the rate is spend-per-point | URD-PNT-002 |
| FR-3 | Maintain a running point balance per customer, incremented by each award | URD-PNT-003 |
| FR-4 | Award at most once per order; a redelivered payment event is a no-op (idempotent by order) | URD-PNT-004 |
| FR-5 | Award nothing when no customer is attached, the rate is unset, or rate / computed points ≤ 0 | URD-PNT-005 |
| FR-6 | Record every award as an immutable ledger entry: type, points, applied rate, links to customer / order / merchant | URD-PNT-006 |
| FR-7 | The ledger entry and the balance increment are written in one atomic transaction | URD-PNT-003 · URD-PNT-006 |
| FR-8 | Provide a merchant-scoped, read-only ledger to browse a customer's point history | URD-PNT-007 |
Full requirement text and acceptance criteria live in the Customer URD - PNT. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Idempotency | At most one award per order; the seam checks for an existing entry before crediting |
| Atomicity | Ledger entry + balance increment commit together; a partial failure rolls back fully |
| Data integrity | No balance increment without a matching immutable ledger entry; entries are append-only |
| Tenancy & authz | The ledger read API is scoped per merchant (x-merchant-id) and gated by point-transaction read permissions |
| Precision | Order total and rate use decimal precision (4 places); points are floored to whole numbers |
| Resilience | Missing customer, missing rate, or a non-positive rate / point result is a safe no-op, not an error |
| i18n | User-facing labels are bilingual ({ en, vi }) |
8. UX & Flows
The earn path has no dedicated UI - it runs on payment completion. The owner-facing surfaces are the per-merchant conversion-rate setting and the read-only point ledger for a customer.
9. Data & Domain
| Entity | Role in the seam |
|---|---|
PointTransaction | An immutable ledger entry per award - type (EARN), points, applied conversion rate, links to customer / order / merchant. One per order |
Customer | Carries the running pointBalance, incremented atomically with each award |
Configuration | Holds the per-merchant point conversion rate read at award time |
SaleOrder | The paid order whose total drives the award |
Conceptual only - full schema and invariants live in the Sale - Customer Points developer docs. Relations are soft references; integrity is enforced in the seam, not by database constraints.
10. Dependencies & Assumptions
Depends on
- Customer profiles (CUS) - the award credits the customer attached to the order.
- Order payment completion (Orders) - the seam fires when an order becomes fully paid.
- Per-merchant configuration - the conversion rate is read from the merchant's point-conversion setting; a sensible default applies when the setting exists without an explicit value.
@nx/core- the point-transaction ledger, customer balance, order, and configuration models.
Assumptions
- A customer is attached to the order at checkout; otherwise the seam awards nothing.
- The merchant has configured a positive conversion rate; an unset or non-positive rate yields no award.
- The payment seam may redeliver its completion event, so the earn path must be idempotent.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Redelivered payment event double-credits | Award is idempotent by order - an existing entry short-circuits the seam |
| Ledger entry and balance diverge on partial failure | Both are written in one atomic transaction; partial failure rolls back fully |
| Merchant has not configured a rate | Safe no-op - no award, no error; logged for visibility |
| Rate is zero or negative | Safe no-op - guarded before any write |
| Small orders earn zero points | Expected - points are floored; a sub-one-point total awards nothing |
| Customers expect to spend points | Out of scope - redemption (burn) is a deliberate non-goal in this increment |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P2 - PNT in the URD feature catalog |
| Rollout | All merchants; no feature flag. Earning is active once a merchant sets a conversion rate |
| Migration | None - the seam runs on the existing payment-completion path; balance column already present |
| Launch criteria | A fully-paid order with a customer and a configured rate earns floor(total ÷ rate) points; the award writes one immutable ledger entry and increments the balance atomically; a redelivered event awards nothing; no customer / no rate / non-positive rate awards nothing |
| Monitoring | Awards per merchant, balance-vs-ledger consistency checks, no-op reasons (no customer, no rate, non-positive), idempotent-skip count |
13. FAQ
How are points computed? Points = floor(order total ÷ conversion rate). The rate is a spend-per-point factor - how much order value equals one point - so a rate of 1000 earns one point per 1000 of order total.
What happens if a payment event is redelivered? Nothing is credited twice. The seam checks whether the order already earned and, if so, does nothing.
What if the merchant hasn't set a rate? No points are awarded - it is a safe no-op, not an error. The same holds when no customer is attached or the rate is not positive.
Can a customer spend their points? Not yet. This increment ships earn only; redemption (burn) is a deliberate non-goal - the ledger records only earn entries and the balance only increases.
Is every balance change auditable? Yes - each award writes an immutable ledger entry (points, applied rate, customer / order / merchant) in the same transaction as the balance increment, and the ledger is browsable read-only per merchant.
Can staff edit the ledger? No - entries are written only by the earn seam. The ledger API is read-only and merchant-scoped.
References
- URD: Customer - PNT
- Related PRDs: Customer profiles · Business customers & groups
- Module: Customer - overview + traceability
- Developer: @nx/sale - Customer Points · @nx/core