flows/status-matrix/06-loyalty-coupling.md

06 · Loyalty Coupling

Loyalty redemption lifecycle gắn với booking status. Source of truth: ../loyalty-flow.md.


1. Track state (2026-04-24)

Phase Status Deliverable
L1 [x] Schema + migration + backfill
L2 [x] computeLoyaltyDiscount helper + 36 tests
L3 [x] Reserve on booking create, DDD layering
L4 [~] Lifecycle listeners (CONSUMED on COMPLETED, rollback on CANCELLED/NO_SHOW) — pre-L3 listener auto-stamp/auto-earn exists; RESERVED→CONSUMED flip cần verify
L5 [ ] Public API + customer UI redemption picker
L6 [ ] Admin UX (view redemption, apply hộ khách) + E2E

2. State transitions for LoyaltyRedemption (VISIT_BASED)

[none] ──reserveInTx (BookingCreated)──▶ RESERVED
RESERVED ──onBookingCompleted──▶ CONSUMED (+ redeemedAt)  ← L4 pending
RESERVED ──onBookingCancelled (pre-capture)──▶ CANCELLED  ← L4 pending
RESERVED ──onBookingNoShow──▶ FORFEITED hoặc CANCELLED    ← L4 pending
RESERVED ──onBookingCancelled (post-capture)──▶ FORFEITED ← L4 pending

Terminal states: CONSUMED, CANCELLED, FORFEITED.

3. POINTS_BASED flow (không có LoyaltyRedemption row)

BookingCreated + redemption:
  LoyaltyPointTransaction{type: REDEEM, points: -N, bookingId: id}
  booking.discountAmount = computed

BookingCompleted:
  (no-op — points đã burn ở create)
  Còn autoEarn: tạo LoyaltyPointTransaction{type: EARN, points: +M}

BookingCancelled (pre-capture — customer cancel in window):
  Tạo LoyaltyPointTransaction{type: CLAWBACK / ADJUST, points: +N, bookingId: id}
  → restore balance

BookingCancelled (post-capture, out-of-window):
  Không clawback — points burn lost (forfeit, mirror payment policy)

BookingNoShow:
  Không clawback — forfeit

4. Matrix: Booking status × loyalty action

Booking transition VISIT_BASED (stamp card) POINTS_BASED (points ledger) Implemented?
(create + redemption) RESERVED row + booking.appliedRedemptionId REDEEM transaction (−N) [x] L3
COMPLETED RESERVED → CONSUMED (+ redeemedAt) no-op (đã burn) [~] L4 — verify
COMPLETED (non-redemption) autoStamp: tạo LoyaltyStamp cho cycle autoEarn: EARN transaction +M [x] pre-L3 listener
CANCELLED pre-capture (in-window customer) RESERVED → CANCELLED + restore stamps (re-insert rows) CLAWBACK transaction +N [ ] L4
CANCELLED post-capture (out-of-window / salon cancel với refund) RESERVED → FORFEITED (no restore) no clawback [ ] L4
NO_SHOW RESERVED → FORFEITED no clawback [ ] L4 (cần add listener)
Walk-in (IN_PROGRESS direct) autoStamp chạy khi COMPLETED autoEarn chạy khi COMPLETED [~] Verify — walk-in không emit BookingCreated, nhưng autoStamp listener chỉ cần BookingCompleted nên OK

5. Loyalty read-side: customer portal

UX Status
Customer xem stamp card progress + next reward [ ] L5
Apply redemption khi book [ ] L5
History redemption (RESERVED + CONSUMED + CANCELLED) [ ] L5
Push notification khi đủ stamp [ ] L5 future

6. Loyalty admin UX

UX Status
List cards (CRUD) [x]
Manual stamp (customer quên mang thẻ) [x] loyaltyService.manualStamp
Manual redeem / adjust points [x]
Tạo booking hộ khách + áp redemption [ ] L6
Display discountAmount + appliedRedemptionId trong booking drawer [ ] L6
Audit: ai đã áp redemption [~] có audit log chưa verify
Cancel redemption thủ công (sau khi bookingcancelled post-capture) [ ] L6

7. Real-world cases

# Case Flow hiện tại Gap
1 Khách book + redeem free service L3 reserve → booking PENDING → pay deposit (trên payable = 0 nếu full discount) → CONFIRMED OK, nhưng nếu payable = 0 thì depositAmount = 0 → không init Payment. Booking stuck PENDING nếu autoConfirm=false. P2-9
2 Khách redeem xong, service hoàn thành L4 flip RESERVED → CONSUMED [~] cần verify
3 Khách redeem xong, hủy trong window L4 clawback (stamps re-insert / points credit) [ ] L4 pending
4 Khách redeem xong, hủy out-of-window L4 forfeit (stamp mất, points mất) [ ] L4 pending
5 Khách redeem xong, no-show Forfeit [ ] L4 listener cho NoShow
6 Staff tạo booking hộ + áp redemption L6 chưa build — admin không có UI [ ] L6
7 Regular customer quên mang thẻ, staff manual stamp loyaltyService.manualStamp OK
8 Tenant đổi reward config giữa chừng discountAmount snapshot trong Booking — OK không retroactive OK (L3 invariant)
9 Customer có 2 stamp card cùng lúc Chọn 1 khi book (dto.redemption.cardId) [ ] L5 UI picker
10 Points card minRedemption + pointsToRedeem L2/L3 validate trong preflight OK

8. Events emitted by Loyalty (for future)

L4 sẽ emit (hiện chưa):

  • LoyaltyRedemptionConsumed {redemptionId, bookingId, redeemedAt}
  • LoyaltyRedemptionCancelled {redemptionId, bookingId, cancelledAt, refundedStamps/Points}
  • LoyaltyRedemptionForfeited {redemptionId, bookingId, forfeitedAt}
  • LoyaltyStampEarned {tenantCustomerId, cardId, stampCount}
  • LoyaltyPointsEarned {tenantCustomerId, cardId, points}

Subscribers tiềm năng:

  • Notification (customer nhận "bạn đã có stamp mới")
  • Stats (dashboard loyalty metrics)

→ Gap P2-10: event contract + notification.


9. Checklist loyalty coupling

L1–L3 (shipped)

  • Schema
  • Pure helpers (discount calc, policy)
  • Reserve intra-tx
  • Guest reject
  • DDD layering (domain / application / infrastructure)

L4 (partial / pending)

  • Pre-L3 auto-stamp / auto-earn on BookingCompleted
  • [~] RESERVED → CONSUMED flip (verify)
  • CANCELLED rollback (stamps restore / points clawback)
  • NO_SHOW forfeit listener
  • Events emit cho cross-context

L5 (customer)

  • GET rewards list public endpoint
  • UI picker khi book
  • History page

L6 (admin)

  • Staff tạo booking + áp redemption
  • Booking drawer show discount + redemption
  • Cancel redemption manual

Edge

  • payableTotal = 0 case — booking không có Payment (P2-9)
  • Walk-in không emit BookingCreated → verify autoStamp vẫn chạy trên BookingCompleted
  • Tenant đổi card isActive=false sau khi RESERVED — xử lý orphan RESERVED (verify)

gaps-and-plan.md.