flows/loyalty-flow.md

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 — computeLoyaltyDiscount pure 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 redemption never hit the DB — rejected pre-preflight.
  • TOCTOU: preflight reads live outside the tx. Within the tx, ensureTenantCustomerInTx uses upsert (idempotent); stamp row creation uses status=RESERVED. Single-customer race window is negligible in a monolith.
  • Deposit on payable: buildBookingCreatedPayload receives discountAmount and computes the deposit % on the post-discount total. Customer never pre-pays a portion they won't owe.
  • Backward-compat: BookingCreatedPayload.originalAmount and discountAmount fields are optional — consumers ignoring them still get the correct totalAmount (payable).

3. Data model cheat-sheet

VISIT_BASED flow

  • Customer earns LoyaltyStamp rows per completed booking, scoped to (cardId, tenantCustomerId, cycleNumber).
  • When stamps in current cycle >= card.requiredVisits → redeemable.
  • Reserving creates a LoyaltyRedemption row with status=RESERVED. Current cycle increments (redemptionCount + 1) so future stamps go to the new cycle.
  • L4 will flip RESERVED → CONSUMED on BookingCompleted, RESERVED → CANCELLED on 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} — no LoyaltyRedemption row.
  • L4 rollback: on pre-capture cancel, create matching CLAWBACK transaction. 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. Subscribe BookingCancelled → RESERVED→CANCELLED + CLAWBACK for points, cancelledAt=now. Idempotent via LoyaltyRedemption.status check (same CAS pattern as processedForLoyalty on Booking).
  • L5 public API: GET /public/tenants/:slug/customer/rewards (auth-gated). Public booking DTO already accepts redemption — just gate guest + surface the card list.
  • L6 admin: display discountAmount + appliedRedemptionId in 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.