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 = 0case — 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)