Customer Points
1. Overview
| Property | Value |
|---|---|
| ID | FEAT-SALE-POINTS |
| Status | Stable (P1 — award only; redemption is P3) |
| Owner | sale-team |
| Depends on | Customer (point balance), SaleOrder (source), Configuration (per-merchant conversion rate) |
When a sale order completes payment, sale awards loyalty points to the linked customer. Award amount = order.total × conversionRate (per-merchant, read from Configuration). Idempotent per saleOrderId to survive Kafka redelivery and webhook retries.
2. Entity Model
Field details — see Domain Model §3.8 and §3.9.
3. Operations
CustomerPointService (customer-point.service.ts — 148 lines).
| Method | Signature | Purpose |
|---|---|---|
awardPointsForOrder | { saleOrderId, customerId, merchantId, total, transaction? } | Award points; idempotent |
Idempotency check
PointTransactionRepository.existsBySaleOrderId is the dedup key.
4. REST Endpoints
| Verb | Path | Auth | Permission | Handler |
|---|---|---|---|---|
| 6× CRUD | /point-transactions | JWT/BASIC | PointTransaction.<crud> | merchant-scoped (effectively read-only — writes happen internally on payment) |
Live OpenAPI:
/v1/api/sale/doc/openapi.json.
5. Events
Inbound (internal call): triggered from SaleOrderPaymentWebhookService._handleOrderPaymentSuccess when order transitions to COMPLETED (not on PARTIAL).
Outbound: none directly. WebSocket order update (ORDER_PAYMENT_UPDATED) carries the updated Customer.pointBalance indirectly via order details.
6. Configuration
| Source | Field | Default |
|---|---|---|
Configuration table per merchant | loyalty.conversionRate (jsonb path) | 0 (no points awarded) |
| Currency assumption | order currency × conversionRate = points | implementation-defined per merchant |
7. Edge Cases
| Case | Behavior |
|---|---|
customerId is null on order | Skip — points need a customer |
conversionRate is 0 or unset | Skip — no points to award |
Order goes COMPLETED then CANCELLED (refund) | Refund flow inserts negative PointTransaction (P3 — TBD) |
Order goes PARTIAL first, then COMPLETED | Award only fires on COMPLETED (idempotent — single award per order) |