Saltar al contenido principal

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

ConceptDescription
ProgramOne per business. Holds name, active flag, inline enrollment toggle
RuleEarning/redemption parameters. Versioned — new config creates a new rule and deactivates the old one
AccountOne per customer per business. Holds points_balance
TransactionLedger 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 by maxDiscountPerTx if 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

TablePurpose
loyalty_programProgram config per business (unique on business_id)
loyalty_ruleVersioned earn/redeem parameters (one active per program)
loyalty_accountCustomer wallet (unique on business_id + customer_id)
loyalty_transactionImmutable ledger entries

API Endpoints

All endpoints require authentication (Bearer token) and the LOYALTY_ENABLED feature flag.

MethodPathDescription
GET/loyalty/programs/meGet program config + active rule
PUT/loyalty/programs/meCreate/update program and rule
GET/loyalty/accounts/customer/:customerIdLookup account by customer
POST/loyalty/accounts/enrollEnroll customer (direct or inline)
POST/loyalty/accounts/:accountId/redeem-previewPreview redemption (no side effects)
POST/loyalty/accounts/:accountId/redeemRedeem points → discount
GET/loyalty/accounts/:accountId/transactionsPaginated transaction history
POST/loyalty/accounts/:accountId/adjustManual admin adjustment

Enrollment

Two paths:

  1. Direct: Provide customer_id of an existing customer
  2. Inline: Provide phone + name — auto-creates customer record (must be enabled via allow_inline_enrollment)

Redemption Flow

  1. Client calls POST /redeem-preview with desired points → gets discount value, capping info
  2. Client calls POST /redeem → debits points, creates discount_application, returns discount to apply

Event-Driven Earning

EventSourceAction
sale.updated (→ completed)Sales moduleEarn points on sale total
customer-return.createdCustomer returnsReverse proportional points
order.bill.paidRestaurant moduleEarn points on bill amount
order.bill.refundedRestaurant moduleReverse proportional points

Event handlers silently skip if: no loyalty account, program inactive, account suspended, or feature flag disabled.


Design Decisions

  1. Rate snapshot: Every transaction stores the earn/redemption rates at the time of the event, enabling historical accuracy even after rule changes
  2. 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
  3. Row-level locking: Balance mutations use SELECT ... FOR UPDATE to prevent double-spend in concurrent POS scenarios
  4. PostgreSQL serialization errors: The redeem endpoint catches codes 40001/40P01 and returns a 409, letting the client retry
  5. 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.