PRD: Points Earning
| Module | Loyalty (EXT-01) | PRD ID | PRD-PTS-001 |
| Status | Planned | Owner | Loyalty squad |
| Date | 2026-03-23 | Version | v0.1 |
| Packages | @nx/sale · @nx/core | URD | PTS |
TL;DR
Lets a merchant turn every paid, customer-linked order into loyalty points automatically - the customer's balance grows the moment payment completes, using a per-merchant conversion rate. Each award is recorded once per order as an auditable point transaction, so repeat customers see their points accrue without any manual entry and merchants get a verifiable point ledger.
1. Context & Problem
The Loyalty module turns completed purchases into points so repeat customers return more often. Before points earning, paid orders left no loyalty trace: there was no point ledger, no balance accrual, and no link from an order to the points it generated. Customers could not be rewarded for spend, and merchants had no foundation on which to build redemption, tiers, or rewards.
Points earning is the first slice (feature PTS, phase P1): a customer-linked order that completes payment adds points to that customer's balance automatically, once, using a per-merchant conversion rate. It builds on the existing customer identity (which holds the balance) and the order/payment flow (which signals completion), and is the prerequisite for every later loyalty capability.
2. Goals & Non-Goals
Goals
- Award points automatically when a customer-linked order completes payment, with no manual entry.
- Make the award idempotent per order so a redelivered or duplicate payment event never double-awards.
- Read the conversion rate from per-merchant configuration, falling back to a default when unset.
- Record each award as a
PointTransaction(typeEARN) and atomically increment the customer's point balance. - Expose a read-only point-transaction listing API (find / findById / findOne / count), merchant-scoped.
Non-Goals
- Redemption, tiers, and the rewards catalog - owned by feature
RDM(phase P2, URD-RDM). - Cross-merchant / franchise-wide point pooling (URD Non-Goal).
- Marketing message delivery on earn (owned by Marketing).
- Awarding points to anonymous sales (constraint C-01 - only identified customers accrue).
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Coverage | 100% of paid, customer-linked orders award points (where the rate is positive) |
| Idempotency | Zero double-awards - at most one EARN transaction per order |
| Accuracy | pointBalance equals the sum of the customer's EARN transactions |
| Latency | Award completes within the payment-confirmation path without delaying order completion |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Customer | Earn points automatically on purchases and see the balance grow |
| Owner | Set the earning rate and trust that every paid order accrues correctly |
| Manager | View member point activity and balances |
Core scenarios: a customer-linked order completes payment → the system reads the merchant's conversion rate → computes points from the order total → records one EARN transaction and increments the customer balance, once per order.
5. User Stories
- As a customer, I want points added to my balance automatically when my order is paid, so that I am rewarded for spending without doing anything.
- As an owner, I want the earning rate configured per merchant, so that I control how spend converts to points.
- As an owner, I want a duplicate payment event to never double-award, so that the point ledger stays trustworthy.
- As a manager, I want to list a customer's point transactions, so that I can review how their balance was earned.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Award points when a customer-linked order completes payment; the same logic runs on either payment-completion path (order-payment and check-payment) | URD-PTS-001 |
| FR-2 | Earning is idempotent per order - short-circuit if the order has already been awarded | URD-PTS-002 |
| FR-3 | Conversion rate read from per-merchant configuration (POINT_CONVERSION_RATE); fall back to a default when unset | URD-PTS-003 |
| FR-4 | points = floor(orderTotal / conversionRate); a non-positive rate or zero points is skipped (logged, no award) | URD-PTS-001 |
| FR-5 | Within one transaction: insert a PointTransaction (type EARN, with points, conversionRate, saleOrderId) and increment the customer's pointBalance; roll back on error | URD-PTS-001..002 |
| FR-6 | Read-only point-transaction listing API (find / findById / findOne / count), merchant-scoped | URD-PTS-001 |
Full requirement text and acceptance criteria live in the Loyalty URD. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Data integrity | The PointTransaction insert and the pointBalance increment are written in one transaction - no balance change without a matching ledger entry |
| Idempotency | At most one EARN award per order, enforced by an existence check on saleOrderId |
| Tenancy & authz | All operations scoped per merchant (x-merchant-id); the listing API is gated by sale permissions (JWT / Basic auth) |
| Precision | Monetary / point math uses float(value, 4); points are floored to whole units |
| Performance | Award runs inside the payment-confirmation path without delaying order completion |
| i18n | User-facing labels/statuses are bilingual ({ en, vi }) |
8. UX & Flows
Points earning has no dedicated UI - it runs server-side on payment completion. The earned balance surfaces in the customer profile (apps/client); point transactions are available through the read-only listing API.
9. Data & Domain
| Entity | Role |
|---|---|
PointTransaction | Append-only ledger entry - type EARN, points, conversionRate, saleOrderId, merchant-scoped |
Customer.pointBalance | The running balance incremented atomically with each award |
Configuration (POINT_CONVERSION_RATE) | Per-merchant rate row (group SYSTEM, principal = merchant, status ACTIVATED); default when absent |
Conceptual only - full schema and invariants in the developer customer-points docs and the sale domain model.
10. Dependencies & Assumptions
Depends on
- Customer identity (Customer) - holds the
pointBalancethe award increments. - Orders / payment (Orders) - order completion on the payment path is the earning trigger.
- Per-merchant configuration (
@nx/core) - suppliesPOINT_CONVERSION_RATE.
Assumptions
- The order is linked to an identified customer (anonymous sales earn nothing, C-01).
- A conversion rate is configured, or the default applies.
- Payment-completion webhooks fire reliably for each paid order.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Duplicate / redelivered payment events could double-award | Idempotent per order - existence check on saleOrderId before awarding (C-02) |
| Award and balance increment could diverge on partial failure | Both written in one transaction; rolled back together on error |
| Misconfigured (non-positive) rate could award garbage | Non-positive rate or zero points is skipped and logged, never awarded |
| Refund / cancellation after earn | Open: define the clawback / reversal path for earned points |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 (foundation) - see URD feature catalog |
| Rollout | All merchants; no feature flag |
| Migration | None (new PointTransaction entity; conversion rate from configuration) |
| Launch criteria | Paid customer-linked order awards points once; balance matches EARN ledger; duplicate event does not double-award; rate read per merchant |
| Monitoring | Award volume per merchant, double-award detection (orders with >1 EARN), balance-vs-ledger consistency checks |
13. FAQ
When exactly are points awarded? When a customer-linked order completes payment - the same logic runs whether confirmation arrives via the order-payment or the check-payment path.
Can the same order award points twice? No - earning is idempotent per order. If an EARN transaction already exists for the order, the award is skipped.
Where does the earning rate come from? From the per-merchant POINT_CONVERSION_RATE configuration; a default applies when none is set.
How are points calculated? points = floor(orderTotal / conversionRate). A non-positive rate or a zero result is skipped (logged, no award).
Do anonymous sales earn points? No - only identified customers accrue (constraint C-01).
Can I redeem points yet? Not in this increment. Redemption, tiers, and the rewards catalog are the separate RDM feature (phase P2, URD-RDM).
References
- URD: Loyalty - Points Earning
- Related PRD: Redemption & Tiers (
RDM, planned) - see URD-RDM - Module: Loyalty - overview + traceability
- Developer: @nx/sale customer-points