Skip to content

PRD: Loyalty points at order

ModuleOrders (CORE-07)PRD IDPRD-PNT-001
StatusShippedOwnerOrders squad
Date2026-03-23Versionv1.0
Packages@nx/sale · @nx/coreURDPNT

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 EARN transaction 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

MetricTarget / signal
CoverageCustomer-linked, fully-paid orders that produce a point award (where a conversion rate is configured)
IdempotencyZero duplicate awards - at most one ledger entry per order
Balance integrityCustomer balance always equals the sum of that customer's EARN ledger entries
AdoptionMerchants that configure a POINT_CONVERSION_RATE and accrue points

4. Personas & Use Cases

PersonaGoal in this feature
CustomerAccumulate points automatically on every completed purchase
CashierTake payment as usual; points accrue with no extra step
OwnerRun 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

#RequirementURD ref
FR-1Earn 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 pathsURD-PNT-001
FR-2Compute points as floor(orderTotal / conversionRate); skip when the result is ≤ 0URD-PNT-001
FR-3Read the per-merchant POINT_CONVERSION_RATE configuration; skip when no config exists or the rate is non-positive; default 1000URD-PNT-001
FR-4Award is idempotent per order - short-circuit if an award already exists for the orderURD-PNT-002
FR-5Record the award as a PointTransaction (EARN) carrying saleOrderId, points, and conversionRateURD-PNT-001
FR-6Increment the customer's point balance and insert the ledger entry in a single transaction; roll back on errorURD-PNT-003
FR-7Expose the ledger read-only (find / findById / findOne / count), merchant-scoped; awards are never written by a clientURD-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

AreaRequirement
Data integrityLedger insert and balance increment are written in one transaction - no balance change without a matching ledger entry, and vice versa
IdempotencyAt most one award per order regardless of how many times the payment-success event fires
ImmutabilityThe ledger is write-only via the payment-success path and read-only via the API; entries are not edited by clients
Tenancy & authzAll operations scoped per merchant (x-merchant-id); ledger reads gated by PointTransaction* permissions
PrecisionOrder total read with float(value, 4); points are whole numbers
i18nUser-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

EntityRole
PointTransactionImmutable ledger entry (type EARN) - saleOrderId, points, conversionRate, customer & merchant scope
Customer.pointBalanceRunning 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
SaleOrderSource 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 (default 1000 applies only when the config row exists without a value).

11. Risks & Open Questions

Risk / questionMitigation / status
Duplicate awards from replayed payment eventsIdempotency short-circuit keyed on saleOrderId
Ledger and balance could diverge on partial failureBoth written in one transaction; rollback on error
Refund / return after points awardedOut of scope - no reversal yet; refund is a module Non-Goal
No redemption pathAccepted for this increment; only EARN exists, redemption is a future increment
Merchant without a configured rate earns nothingBy design - award is skipped on missing config or non-positive rate

12. Release Plan & Launch Criteria

AspectPlan
PhaseP2 - see URD feature catalog
RolloutAll merchants; no feature flag (inert until a merchant sets a conversion rate)
MigrationNew PointTransaction table (sale schema) and customer.point_balance column
Launch criteriaFull 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
MonitoringAward 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

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.