Loyalty Flow
End-to-end loyalty redemption flow, DDD-conformant layering, and how the Loyalty context integrates with Booking + Payment.
Context: Loyalty is a bounded context. It owns loyalty cards, stamps, points ledger, and redemptions. Booking/Payment never read or write loyalty tables directly — all access goes through the application service exposed by the Loyalty module.
Track state (2026-04-21):
- L1 — schema + backfill ✅
- L2 —
computeLoyaltyDiscountpure helper ✅ - L3 — reserve on booking create, DDD layering ✅ ← you are here
- L4 — lifecycle listeners (CONSUMED on COMPLETED, CANCELLED rollback) — pending
- L5 — public API + customer UI — pending
- L6 — admin UX + E2E — pending
1. Bounded context + layering
src/core/loyalty/
├── domain/ ← pure, no Prisma, no NestJS runtime deps
│ ├── compute-loyalty-discount.ts — maps reward × items → discount øre
│ ├── redemption-policy.ts — rules: guest chặn, card active, stamps/points đủ
│ └── *.spec.ts — 36 tests, 0 DB
├── application/ ← use cases + ports
│ ├── reserve-redemption.command.ts — command DTO
│ ├── loyalty-redemption.service.ts — preflight() + reserveInTx()
│ └── ports/
│ └── loyalty-redemption.repository.port.ts — DI token + interface
├── infrastructure/ ← Prisma adapter
│ └── prisma-loyalty-redemption.repository.ts — implements the port
├── loyalty.service.ts — admin CRUD (cards, manual stamp/redeem)
├── loyalty.controller.ts — admin HTTP
├── loyalty.module.ts — wires DI, exports LoyaltyRedemptionService
└── on-booking-completed.listener.ts — auto-stamp + auto-earn on COMPLETED (pre-L3)
Layer rules:
| Layer | May import from |
|---|---|
domain/ |
Nothing outside domain + @prisma/client for types only (enums like RewardType) |
application/ |
domain/, application/ports/, NestJS framework |
infrastructure/ |
application/ports/, @prisma/client, PrismaService |
| Other bounded contexts | Only application/ — never infrastructure/ or domain/ internals |
This matches the Payment context (src/core/payment/{domain,application,infrastructure}).
2. Reserve redemption on booking create (L3)
Players
- BookingService.create — orchestrator, owns the single
$transaction - LoyaltyRedemptionService — exposes
preflight()(read) +reserveInTx(tx,…)(write) - LoyaltyRedemptionRepositoryPort — DI boundary, Prisma hidden behind it
- buildBookingCreatedPayload — pure helper, takes optional
discountAmount
Sequence
Client → POST /public/tenants/:slug/bookings {
items: [...], customerId, redemption: { cardId, pointsToRedeem?, selectedServiceItemIndex? }
}
│
▼
BookingService.create
│
├─ [1] normalize + resolve items, validate business hours + mode + lead time
│
├─ [2] if dto.redemption:
│ guest? → throw LOYALTY_GUEST_NOT_ALLOWED
│ loyaltyRedemption.preflight(cmd) ─┐
│ │
│ ▼
│ LoyaltyRedemptionService
│ ├─ repo.loadStampContext() OR loadPointsContext()
│ ├─ assertStampRedeemable() / assertPointsRedeemable()
│ ├─ computeLoyaltyDiscount() / pointsToDiscountAmount()
│ └─ return { discountAmount, cycleNumber, card, … }
│
├─ [3] compute payableTotal = rawTotal - discountAmount
│ compute depositAmount on payableTotal
│ resolve initial status (PENDING vs CONFIRMED)
│
├─ [4] prisma.$transaction(async tx => {
│ const booking = await tx.booking.create({
│ discountAmount: nullable, ← from preflight
│ ...
│ });
│
│ if redemption:
│ res = await loyaltyRedemption.reserveInTx(tx, booking.id, cmd, preflight)
│ │
│ ▼
│ LoyaltyRedemptionService.reserveInTx
│ ├─ repo.ensureTenantCustomerInTx() ← upsert bridge row
│ ├─ if VISIT_BASED: repo.createReservedStampRedemptionInTx()
│ │ → return { redemptionId }
│ └─ if POINTS_BASED: repo.createPointsRedeemTransactionInTx()
│ → return { redemptionId: null }
│
│ if res.redemptionId:
│ await tx.booking.update({ appliedRedemptionId: res.redemptionId })
│
│ await tx.domainEventOutbox.createMany([BookingCreated({
│ totalAmount: payableTotal,
│ originalAmount, discountAmount, ← present only when discount > 0
│ depositAmount, ← computed on payableTotal
│ captureMode: intent === DEPOSIT ? MANUAL : AUTO,
│ })])
│ })
│
└─ [5] enqueue publish job (after commit) → PaymentIntegrationService listens
Key invariants
- Atomicity: booking row + redemption row + outbox row commit or roll back together. No "booked but reward not reserved" state.
- Guest reject: guest bookings with
redemptionnever hit the DB — rejected pre-preflight. - TOCTOU: preflight reads live outside the tx. Within the tx,
ensureTenantCustomerInTxusesupsert(idempotent); stamp row creation usesstatus=RESERVED. Single-customer race window is negligible in a monolith. - Deposit on payable:
buildBookingCreatedPayloadreceivesdiscountAmountand computes the deposit % on the post-discount total. Customer never pre-pays a portion they won't owe. - Backward-compat:
BookingCreatedPayload.originalAmountanddiscountAmountfields are optional — consumers ignoring them still get the correcttotalAmount(payable).
3. Data model cheat-sheet
VISIT_BASED flow
- Customer earns
LoyaltyStamprows per completed booking, scoped to(cardId, tenantCustomerId, cycleNumber). - When
stamps in current cycle >= card.requiredVisits→ redeemable. - Reserving creates a
LoyaltyRedemptionrow withstatus=RESERVED. Current cycle increments (redemptionCount + 1) so future stamps go to the new cycle. - L4 will flip
RESERVED → CONSUMEDon BookingCompleted,RESERVED → CANCELLEDon BookingCancelled.
POINTS_BASED flow
- Customer earns
LoyaltyPointTransaction{type=EARN, points=+N}per completed booking. - Balance = sum of all transactions for
(cardId, tenantCustomerId). - Reserving creates
LoyaltyPointTransaction{type=REDEEM, points=-N}— noLoyaltyRedemptionrow. - L4 rollback: on pre-capture cancel, create matching
CLAWBACKtransaction. Post-capture cancel forfeits the burn (mirrors Payment refund policy).
Why no redemption row for points: points are a ledger, not a single-shot reward. Multiple partial burns per booking would create noise if each required its own redemption row. The ledger already has timestamps, reason, and booking FK.
4. Error codes
All errors throw BadRequestException (→ 400) or NotFoundException (→ 404).
| Code | When |
|---|---|
LOYALTY_GUEST_NOT_ALLOWED |
dto.redemption set but no customerId |
LOYALTY_CARD_NOT_FOUND |
Card ID not found for tenant |
LOYALTY_CARD_INACTIVE |
Card found but isActive = false |
LOYALTY_CARD_MISCONFIGURED |
Card missing requiredVisits or rewardType |
NOT_VISIT_BASED_CARD |
Called stamp path on a POINTS_BASED card |
NOT_POINTS_BASED_CARD |
Called points path on a VISIT_BASED card |
STAMPS_NOT_COMPLETE |
Fewer stamps than requiredVisits in current cycle |
INSUFFICIENT_POINTS |
pointsToRedeem > balance |
BELOW_MIN_REDEMPTION |
pointsToRedeem < card.minRedemption |
REDEEM_RATE_NOT_SET |
POINTS_BASED card missing redeemRate |
LOYALTY_INVALID_POINTS |
pointsToRedeem <= 0 |
LOYALTY_NO_ITEMS |
Booking has no items |
LOYALTY_NO_APPLICABLE_ITEMS |
Reward scoped to services, none in the booking |
LOYALTY_SERVICE_PICK_REQUIRED |
FREE_SERVICE + multi-eligible item + no index picked |
LOYALTY_PICKED_ITEM_NOT_FOUND |
selectedServiceItemIndex out of range |
LOYALTY_PICKED_ITEM_NOT_ELIGIBLE |
Picked index maps to an item not in applicableServiceIds |
LOYALTY_INVALID_REWARD_VALUE |
reward.value < 0 |
i18n: add matching keys under errors.loyalty.* in both nb.json + en.json when surfacing in the customer UI (L5).
5. Reference: BookingCreatedPayload shape
interface BookingCreatedPayload {
bookingId: string;
tenantId: string;
startTime: string;
totalAmount: number; // = payableTotal = rawTotal - discountAmount
currency: string;
depositAmount: number; // computed on payableTotal
idempotencyKey: string;
intent: 'DEPOSIT' | 'FULL_PAYMENT';
captureMode: 'AUTO' | 'MANUAL';
returnUrl: string;
cancelUrl: string;
webhookUrl: string;
originalAmount?: number; // present only when discountAmount > 0
discountAmount?: number; // present only when > 0
customer?: { email?: string; phone?: string; name?: string };
metadata?: Record<string, unknown>;
}
6. Testing strategy
| Layer | Test type | Count (L3) | What it covers |
|---|---|---|---|
domain/compute-loyalty-discount.spec |
pure unit | 17 | Reward × items → discount math |
domain/redemption-policy.spec |
pure unit | 19 | Guard assertions, no Prisma |
application/loyalty-redemption.service.spec |
Nest TestingModule + mocked port | 13 | preflight + reserveInTx branches |
booking.service.spec (new cases) |
mocked Prisma | 5 | Wire-up: guest reject, VISIT_BASED row link, POINTS_BASED no link, deposit on payable |
booking-outbox.e2e-spec |
real Prisma + EventBus | 5 | End-to-end outbox payload shape |
Run: yarn test --testPathPatterns=loyalty (96 unit) + yarn test:e2e --testPathPatterns=booking-outbox.
7. What's next (L4+)
- L4 listeners: subscribe
BookingCompleted→ flip RESERVED→CONSUMED +redeemedAt=now. SubscribeBookingCancelled→ RESERVED→CANCELLED + CLAWBACK for points,cancelledAt=now. Idempotent viaLoyaltyRedemption.statuscheck (same CAS pattern asprocessedForLoyaltyon Booking). - L5 public API:
GET /public/tenants/:slug/customer/rewards(auth-gated). Public booking DTO already acceptsredemption— just gate guest + surface the card list. - L6 admin: display
discountAmount+appliedRedemptionIdin booking drawer + list.
All of L4–L6 call LoyaltyRedemptionService — no direct Prisma access, no duplication of the policy rules. Adding a new channel (POS walk-in, gift cards, etc.) is a new command + handler on the same service.