Loyalty Points Program
Overview
The loyalty module implements a points-based reward system for FlowPOS businesses. Customers earn points on purchases and redeem them as discounts on future transactions.
Core concept: Rules → Wallet → Transactions (ledger)
Architecture
The module follows hexagonal architecture (ports & adapters):
loyalty/
├── loyalty.module.ts # NestJS module (DI wiring)
├── domain/
│ └── loyalty-repository.domain.ts # Repository interface (port) + DI token
├── application/
│ ├── loyalty.service.ts # Core use cases (enroll, earn, redeem, reverse, adjust)
│ ├── loyalty-rule.service.ts # Program/rule configuration
│ └── events/
│ ├── on-sale-completed.handler.ts # Earn points when retail sale completes
│ ├── on-sale-refunded.handler.ts # Reverse points on customer return
│ ├── on-bill-paid.handler.ts # Earn points when restaurant bill is paid
│ └── on-bill-refunded.handler.ts # Reverse points on bill refund
├── infrastructure/
│ └── loyalty.repository.ts # Kysely implementation (adapter)
└── interfaces/
├── loyalty.controller.ts # HTTP endpoints
├── dtos/ # Request validation
└── query/ # Query parameter validation
Dependency injection: The repository is registered via LOYALTY_REPOSITORY Symbol token, following the project-wide pattern ({ provide: LOYALTY_REPOSITORY, useClass: LoyaltyRepository }).
Domain Concepts
| Concept | Description |
|---|---|
| Program | One per business. Holds name, active flag, inline enrollment toggle |
| Rule | Earning/redemption parameters. Versioned — new config creates a new rule and deactivates the old one |
| Account | One per customer per business. Holds points_balance |
| Transaction | Ledger entry (earn, redeem, adjust, reverse). Immutable audit trail with rate_snapshot |
Key Business Rules
- Earn rate: Points =
floor(netAmount * earnRate * multiplier). Multiplier applies only within a configured date window - Redemption rate: Discount =
pointsToRedeem / redemptionRate. Capped bymaxDiscountPerTxif set - Minimum balance: Redemption blocked if balance <
minBalanceToRedeem - Negative balance: Refund reversals can push balance below zero, blocking future redemptions until balance is restored
- Suspended accounts: No earning or redeeming
- Event-driven: Points are earned/reversed automatically via EventEmitter2 handlers — never blocks the parent sale/order flow
Stacking Order
Points are earned on totalAmount (already net of any applied discounts). Loyalty discounts create a discount_application record via the discounts module.
Database Tables
| Table | Purpose |
|---|---|
loyalty_program | Program config per business (unique on business_id) |
loyalty_rule | Versioned earn/redeem parameters (one active per program) |
loyalty_account | Customer wallet (unique on business_id + customer_id) |
loyalty_transaction | Immutable ledger entries |
API Endpoints
All endpoints require authentication (Bearer token) and the LOYALTY_ENABLED feature flag.
| Method | Path | Description |
|---|---|---|
| GET | /loyalty/programs/me | Get program config + active rule |
| PUT | /loyalty/programs/me | Create/update program and rule |
| GET | /loyalty/accounts/customer/:customerId | Lookup account by customer |
| POST | /loyalty/accounts/enroll | Enroll customer (direct or inline) |
| POST | /loyalty/accounts/:accountId/redeem-preview | Preview redemption (no side effects) |
| POST | /loyalty/accounts/:accountId/redeem | Redeem points → discount |
| GET | /loyalty/accounts/:accountId/transactions | Paginated transaction history |
| POST | /loyalty/accounts/:accountId/adjust | Manual admin adjustment |
Enrollment
Two paths:
- Direct: Provide
customer_idof an existing customer - Inline: Provide
phone+name— auto-creates customer record (must be enabled viaallow_inline_enrollment)
Redemption Flow
- Client calls
POST /redeem-previewwith desired points → gets discount value, capping info - Client calls
POST /redeem→ debits points, creates discount_application, returns discount to apply
Event-Driven Earning
| Event | Source | Action |
|---|---|---|
sale.updated (→ completed) | Sales module | Earn points on sale total |
customer-return.created | Customer returns | Reverse proportional points |
order.bill.paid | Restaurant module | Earn points on bill amount |
order.bill.refunded | Restaurant module | Reverse proportional points |
Event handlers silently skip if: no loyalty account, program inactive, account suspended, or feature flag disabled.
Design Decisions
- Rate snapshot: Every transaction stores the earn/redemption rates at the time of the event, enabling historical accuracy even after rule changes
- Negative balance allowed on reversal: A refund can push balance below zero rather than failing silently — this ensures accounting integrity and blocks redemption until resolved
- Row-level locking: Balance mutations use
SELECT ... FOR UPDATEto prevent double-spend in concurrent POS scenarios - PostgreSQL serialization errors: The redeem endpoint catches codes
40001/40P01and returns a 409, letting the client retry - Inline enrollment: Designed for POS speed — cashiers can enroll customers without leaving the sale flow
Bruno API Collection
Available at api-client/flowpos/collections/loyalty/ with requests for all endpoints.