Stamp Voucher Flow
Thiết kế lại cơ chế stamp card theo mô hình booking-level eligibility + voucher code redemption. Đơn giản hơn model hiện tại (per-service stamp + RESERVED redemption), khớp với cách Booksy / Vagaro xử lý reward.
Doc này thay thế phần stamp trong
loyalty-flow.md. Phần Points-based vẫn giữ theo flow cũ.
1. Vì sao redesign
Vấn đề của model hiện tại
| Vấn đề | Mô tả |
|---|---|
| Stamp lệch với booking đa-service | OnBookingCompletedLoyaltyListener chỉ check theo booking.serviceId (first item) → card "stamp khi cắt tóc" miss khi service ở vị trí thứ 2 |
| Không có "voucher code" | Reward chỉ là discount tự động khi đặt booking — khách không "cầm" được, không gửi cho người khác, không dùng khi đặt qua điện thoại |
| Cycle counter dễ lệch | redemptionCount đếm cả RESERVED + CANCELLED → nếu L4 không restore đúng, customer mất stamp vĩnh viễn |
| L5 customer UI phức tạp | Apply reward = chọn card + chọn item ăn discount + preview math — flow dài, không match mental model "tôi có 1 phiếu, dùng cho lần này" |
Approach mới
1 booking đủ điều kiện = 1 stamp. Đủ N stamp = sinh 1 voucher code. Voucher code = giảm tối đa = booking total tại lần áp dụng.
| Khía cạnh | Model cũ | Model mới |
|---|---|---|
| Stamp granularity | Per service trong items[] | Per booking |
| Eligibility | serviceIds whitelist |
minBookingValue (paidAmount >= threshold) |
| Reward delivery | Discount auto-compute khi book | Voucher code, customer cầm + áp dụng khi muốn |
| Customer UX | Phức tạp (chọn card + item) | Nhập code hoặc click "Apply" |
| Phone booking | Không hỗ trợ | Staff nhập code thay khách |
| Code generation | Không có | STAMP-XXXX-XXXX (Crockford base32) |
2. Domain model
Entities
erDiagram
LoyaltyCard ||--o{ LoyaltyStamp : "stamps belong to"
LoyaltyCard ||--o{ LoyaltyVoucher : "vouchers issued from"
TenantCustomer ||--o{ LoyaltyStamp : "earned by"
TenantCustomer ||--o{ LoyaltyVoucher : "owned by"
Booking ||--o| LoyaltyStamp : "earned from"
Booking ||--o| LoyaltyVoucher : "redeemed against (1:1)"
LoyaltyCard {
uuid id PK
uuid tenantId FK
string name
enum type "VISIT_BASED"
bool isActive
int requiredStamps "vd 10"
int minBookingValue "øre, vd 20000 = 200 NOK"
enum rewardType "FREE_SERVICE | DISCOUNT_PERCENT | DISCOUNT_AMOUNT"
int rewardValue
int voucherExpiryMonths "null = không hết hạn"
}
LoyaltyStamp {
uuid id PK
uuid loyaltyCardId FK
uuid tenantCustomerId FK
uuid bookingId FK
int stampNumber "thứ tự trong cycle"
int cycleNumber
timestamp createdAt
}
LoyaltyVoucher {
uuid id PK
uuid tenantId FK
uuid loyaltyCardId FK
uuid tenantCustomerId FK
string code UK "STAMP-XXXX-XXXX, unique per tenant"
enum rewardType "snapshot từ card lúc issue"
int rewardValue "snapshot"
enum status "ACTIVE | RESERVED | REDEEMED | EXPIRED | CANCELLED"
timestamp issuedAt
timestamp expiresAt "null nếu card.voucherExpiryMonths null"
uuid issuedFromCycleNumber "audit: cycle nào sinh ra"
uuid reservedBookingId FK "khi customer apply lúc đặt"
timestamp reservedAt
uuid redeemedBookingId FK "khi booking COMPLETED"
timestamp redeemedAt
int discountApplied "øre — số tiền thực sự giảm (sau cap)"
}
Schema changes
LoyaltyCard — thêm field, giữ tương thích:
model LoyaltyCard {
id String @id @default(uuid())
tenantId String @map("tenant_id")
name String
type LoyaltyCardType
isActive Boolean @default(true) @map("is_active")
- requiredVisits Int? @map("required_visits")
+ requiredStamps Int? @map("required_stamps") // renamed for clarity
+ minBookingValue Int? @map("min_booking_value") // øre, optional (null = mọi booking)
+ voucherExpiryMonths Int? @map("voucher_expiry_months")
rewardType RewardType? @map("reward_type")
rewardValue Int? @map("reward_value")
// ... points-based fields giữ nguyên
- serviceIds String[] @default([]) @map("service_ids") // bỏ — eligibility theo booking value
}
LoyaltyVoucher — model mới hoàn toàn:
enum LoyaltyVoucherStatus {
ACTIVE // có thể dùng
RESERVED // đã apply vào booking PENDING/CONFIRMED, chờ COMPLETED
REDEEMED // booking COMPLETED, voucher đã spend
EXPIRED // quá expiresAt, cron job set
CANCELLED // owner thu hồi thủ công
}
model LoyaltyVoucher {
id String @id @default(uuid())
tenantId String @map("tenant_id")
loyaltyCardId String @map("loyalty_card_id")
tenantCustomerId String @map("tenant_customer_id")
code String // STAMP-XXXX-XXXX
rewardType RewardType @map("reward_type")
rewardValue Int @map("reward_value")
status LoyaltyVoucherStatus @default(ACTIVE)
issuedAt DateTime @default(now()) @map("issued_at")
expiresAt DateTime? @map("expires_at")
issuedFromCycleNumber Int @map("issued_from_cycle_number")
reservedBookingId String? @map("reserved_booking_id")
reservedAt DateTime? @map("reserved_at")
redeemedBookingId String? @map("redeemed_booking_id")
redeemedAt DateTime? @map("redeemed_at")
discountApplied Int? @map("discount_applied") // øre, set khi REDEEMED
cancelledAt DateTime? @map("cancelled_at")
cancelledReason String? @map("cancelled_reason")
tenant Tenant @relation(fields: [tenantId], references: [id])
loyaltyCard LoyaltyCard @relation(fields: [loyaltyCardId], references: [id])
tenantCustomer TenantCustomer @relation(fields: [tenantCustomerId], references: [id])
reservedBooking Booking? @relation("VoucherReservedBooking", fields: [reservedBookingId], references: [id])
redeemedBooking Booking? @relation("VoucherRedeemedBooking", fields: [redeemedBookingId], references: [id])
@@unique([tenantId, code]) // code unique per tenant
@@index([tenantCustomerId, status]) // customer dashboard query
@@index([tenantId, status, expiresAt]) // expiry sweep
@@map("loyalty_vouchers")
}
Booking — thêm 1 FK:
model Booking {
+ appliedVoucherId String? @unique @map("applied_voucher_id")
+ appliedVoucher LoyaltyVoucher? @relation(...)
// giữ discountAmount field từ L1 — voucher discount cũng dùng field này
}
3. Eligibility rules — earn stamp
flowchart TD
Start([BookingCompleted event]) --> HasCustomer{customerId có?}
HasCustomer -->|No, guest| End1([Bỏ qua])
HasCustomer -->|Yes| ClaimFlag{processedForLoyalty<br/>CAS claim}
ClaimFlag -->|Mất race| End2([Bỏ qua — đã xử lý])
ClaimFlag -->|Win| LoadCards[Load active VISIT_BASED cards<br/>của tenant]
LoadCards --> EachCard{Cho mỗi card}
EachCard --> CheckMin{paidAmount >=<br/>minBookingValue?}
CheckMin -->|No| EachCard
CheckMin -->|Yes| ComputeCycle[currentCycle =<br/>countVouchersIssued + 1]
ComputeCycle --> StampNumber[stampNumber =<br/>countStampsInCycle + 1]
StampNumber --> InsertStamp[INSERT LoyaltyStamp]
InsertStamp --> CheckThreshold{stampNumber ==<br/>requiredStamps?}
CheckThreshold -->|No| EachCard
CheckThreshold -->|Yes| GenCode[Sinh code STAMP-XXXX-XXXX<br/>Crockford base32, retry-on-collision]
GenCode --> InsertVoucher[INSERT LoyaltyVoucher<br/>status=ACTIVE<br/>expiresAt = now + voucherExpiryMonths]
InsertVoucher --> EmitEvent[Emit VoucherIssued event<br/>→ SMS/email customer]
EmitEvent --> EachCard
EachCard -->|Hết card| Done([Done])
Quy tắc
- 1 booking = tối đa 1 stamp / card — bất kể bao nhiêu service trong booking
- Eligibility =
paidAmount >= minBookingValue(paidAmount = sau khi trừ discount khác, đã capture). NếuminBookingValue = null→ mọi booking đều eligible - Cycle =
countVouchersIssued + 1— không đếm theo redemption nữa, đếm theo voucher đã issue (rõ ràng hơn, không bị nhập nhằng RESERVED status) - Stamp thứ N (N == requiredStamps) → atomically INSERT voucher trong cùng tx với stamp đó. Tránh case "stamp 10 đã insert nhưng voucher fail → khách không có voucher"
- Listener idempotent qua
processedForLoyaltyCAS flag — giữ nguyên cơ chế cũ
4. Voucher lifecycle state machine
stateDiagram-v2
[*] --> ACTIVE: Stamp thứ N issued<br/>(auto-issue)
ACTIVE --> RESERVED: Customer apply code<br/>vào booking PENDING/CONFIRMED
ACTIVE --> EXPIRED: cron sweep<br/>(expiresAt < now)
ACTIVE --> CANCELLED: Owner thu hồi<br/>(audit reason required)
RESERVED --> REDEEMED: BookingCompleted
RESERVED --> ACTIVE: BookingCancelled<br/>pre-capture (release)
RESERVED --> CANCELLED: BookingCancelled<br/>post-capture (forfeit)
REDEEMED --> [*]
EXPIRED --> [*]
CANCELLED --> [*]
Transition guards
| Từ | Tới | Điều kiện | Side effect |
|---|---|---|---|
| ACTIVE | RESERVED | Voucher chưa expire + thuộc đúng customer + booking thuộc đúng tenant | discountApplied chưa set, chỉ ghi reservedBookingId |
| ACTIVE | EXPIRED | expiresAt < now() (cron daily) |
Emit VoucherExpired event → email "your voucher expired" (P2) |
| ACTIVE | CANCELLED | Owner action với reason | Audit log |
| RESERVED | REDEEMED | BookingCompleted event |
Set discountApplied = min(rewardValue_in_øre, booking.total), redeemedBookingId, redeemedAt |
| RESERVED | ACTIVE | BookingCancelled + pre-capture (refund full / void) |
Clear reservedBookingId/At |
| RESERVED | CANCELLED | BookingCancelled + post-capture (forfeit) |
Mirror payment forfeit policy |
5. End-to-end flows
5a. Earn stamp + auto-issue voucher
sequenceDiagram
actor Customer
participant Admin as Admin/Staff
participant BC as Booking Context
participant EvBus as Event Bus
participant LC as Loyalty Listener
participant DB as Database
participant Notif as SMS/Email
Customer->>BC: Book service (200 NOK)
Note over BC: ... booking flow ...
Admin->>BC: Mark booking COMPLETED
BC->>DB: tx { booking.status=COMPLETED, outbox: BookingCompleted }
BC-->>EvBus: emit BookingCompleted
EvBus->>LC: handle(BookingCompleted)
LC->>DB: SELECT booking (paidAmount=20000, customerId)
LC->>DB: CAS claim processedForLoyalty
LC->>DB: SELECT active VISIT_BASED cards
loop Cho mỗi card
LC->>LC: paidAmount(20000) >= minBookingValue(20000) ? OK
LC->>DB: cycleNum = COUNT(vouchers issued from card+customer) + 1
LC->>DB: stampNum = COUNT(stamps in current cycle) + 1
LC->>DB: INSERT stamp (stampNum=10, cycleNum=1)
alt stampNum == requiredStamps (10)
LC->>LC: generateCode() → STAMP-AB12-CD34
LC->>DB: INSERT voucher (code, status=ACTIVE, expiresAt=now+12mo)
LC-->>EvBus: emit VoucherIssued
end
end
EvBus->>Notif: VoucherIssued → SMS
Notif->>Customer: 🎉 Bạn vừa nhận voucher STAMP-AB12-CD34<br/>Giảm 200 NOK cho lần đặt sau
5b. Customer apply voucher khi đặt online
sequenceDiagram
actor Customer
participant FE as Booking Page
participant BC as Booking Context
participant LC as Loyalty Service
participant DB as Database
Customer->>FE: Chọn service (250 NOK)
Customer->>FE: Click "Áp dụng voucher"
Customer->>FE: Nhập STAMP-AB12-CD34
FE->>BC: GET /public/.../vouchers/preview<br/>{code, items}
BC->>LC: previewVoucher(tenantId, customerId, code, rawTotal)
LC->>DB: SELECT voucher WHERE code=AB12CD34 AND tenant=X<br/>AND tenantCustomer=Y AND status=ACTIVE
LC->>LC: validate: chưa expire?
LC->>LC: discountApplied = min(rewardValue_in_øre, rawTotal)
LC-->>BC: { voucherId, discountApplied: 20000, payable: 5000 }
BC-->>FE: { discount: 200 NOK, payable: 50 NOK }
FE->>FE: Hiện preview "Giảm 200 NOK · Còn 50 NOK"
Customer->>FE: Confirm + Pay
FE->>BC: POST /public/.../bookings { voucherCode, items, ... }
BC->>DB: tx {
Note over BC,DB: Reserve voucher + tạo booking atomically
BC->>DB: UPDATE voucher SET status=RESERVED, reservedBookingId=...<br/>WHERE code=X AND status=ACTIVE
BC->>DB: (count=0 → throw VOUCHER_ALREADY_USED)
BC->>DB: INSERT booking (discountAmount=20000, appliedVoucherId)
BC->>DB: outbox: BookingCreated (totalAmount=5000)
BC->>DB: }
BC-->>FE: 201 { bookingId, requiresPayment: true, payable: 50 NOK }
Note over FE,Customer: ... PSP flow with payable=50 NOK ...
Note over BC: BookingCompleted → voucher RESERVED → REDEEMED (set discountApplied + redeemedAt)
5c. Staff nhập voucher khi khách đặt qua điện thoại
sequenceDiagram
actor Staff
actor Phone as Customer (phone)
participant Drawer as BookingDrawer
participant BC as Booking Context
participant LC as Loyalty Service
Phone->>Staff: "Tôi muốn đặt cắt tóc, có voucher STAMP-AB12-CD34"
Staff->>Drawer: Mở booking drawer + chọn service + chọn customer
Staff->>Drawer: Click "Áp dụng voucher"
Staff->>Drawer: Nhập code
Drawer->>BC: GET /vouchers/preview { code, customerId, rawTotal }
BC->>LC: previewVoucher(...)
LC-->>BC: { discountApplied: 20000, payable: ... }
BC-->>Drawer: Preview
Drawer->>Staff: Hiện "Giảm 200 NOK · Khách còn trả 50 NOK"
Staff->>Drawer: Confirm
Drawer->>BC: POST /bookings { voucherCode, source=PHONE, ... }
Note over BC: source=PHONE → paymentMode=IN_PERSON<br/>(không trigger PSP, khách trả tại quầy)
BC-->>Drawer: 201 { bookingId }
5d. Cancel booking đã reserve voucher
flowchart TD
Cancel[BookingCancelled event] --> HasVoucher{appliedVoucherId<br/>có không?}
HasVoucher -->|No| End1([No-op])
HasVoucher -->|Yes| LoadVoucher[SELECT voucher]
LoadVoucher --> CheckPayment{Payment\nstate?}
CheckPayment -->|No payment OR<br/>Voided/Refunded| Restore[UPDATE voucher<br/>status=ACTIVE<br/>clear reservedBookingId/At]
CheckPayment -->|Captured + no full refund| Forfeit[UPDATE voucher<br/>status=CANCELLED<br/>cancelledReason=BOOKING_FORFEIT]
Restore --> EmitRestore[Emit VoucherRestored<br/>→ SMS 'Voucher còn dùng được']
Forfeit --> EmitForfeit[Emit VoucherForfeited<br/>→ no notification]
Logic:
- Pre-capture cancel → khách chưa mất tiền → voucher trả về ACTIVE để dùng lần sau
- Post-capture cancel (salon giữ tiền theo cancellation policy) → voucher coi như đã consume, không restore (tránh double-dip)
6. Code generation
Format
STAMP-XXXX-XXXX
^^^^ ^^^^
│ └─ 4 ký tự Crockford base32
└─── 4 ký tự Crockford base32
Crockford base32 alphabet: 0123456789ABCDEFGHJKMNPQRSTVWXYZ (32 ký tự, không có I O U L 1 để tránh nhầm lẫn).
8 ký tự = 32^8 = ~1.1 nghìn tỷ combo / tenant.
Algorithm
function generateVoucherCode(): string {
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const bytes = crypto.randomBytes(8);
let code = '';
for (let i = 0; i < 8; i++) {
code += ALPHABET[bytes[i] % 32];
}
return `STAMP-${code.slice(0, 4)}-${code.slice(4, 8)}`;
}
async function issueVoucher(tx, payload) {
const MAX_RETRIES = 5;
for (let i = 0; i < MAX_RETRIES; i++) {
const code = generateVoucherCode();
try {
return await tx.loyaltyVoucher.create({ data: { ...payload, code } });
} catch (err) {
if (isUniqueViolation(err) && i < MAX_RETRIES - 1) continue;
throw err;
}
}
}
Collision probability với 1M voucher/tenant = ~1/1100. Retry tối đa 5 lần đủ an toàn.
Validation khi nhập
- Strip whitespace + uppercase
- Cho phép cả với hoặc không có dấu
-(STAMP-AB12-CD34==STAMPAB12CD34) - Reject nếu chứa ký tự ngoài alphabet
- DB lookup case-insensitive (PostgreSQL
citexthoặclower()index)
7. API surface
Admin (existing controller, mở rộng)
| Method | Path | Body | Mô tả |
|---|---|---|---|
| POST | /loyalty-cards |
{ name, type=VISIT_BASED, requiredStamps, minBookingValue?, voucherExpiryMonths?, rewardType, rewardValue } |
Tạo card |
| GET | /loyalty-vouchers |
query: status?, customerId?, page?, limit? |
List vouchers tenant đã issue |
| GET | /loyalty-vouchers/:id |
— | Detail (audit timeline) |
| POST | /loyalty-vouchers/:id/cancel |
{ reason } |
Owner thu hồi voucher |
| GET | /customers/:tenantCustomerId/loyalty |
— | Stamp progress + voucher list của 1 khách |
Customer portal
| Method | Path | Mô tả |
|---|---|---|
| GET | /customer/loyalty/vouchers |
List voucher ACTIVE/RESERVED của customer (cross-tenant) |
| GET | /customer/loyalty/stamps/:tenantSlug |
Stamp progress per salon |
Public booking (apply voucher)
| Method | Path | Body | Mô tả |
|---|---|---|---|
| POST | /public/tenants/:slug/vouchers/preview |
{ code, items: [{serviceId,...}] } |
Preview discount, không reserve |
| POST | /public/tenants/:slug/bookings |
{ voucherCode?, ... } |
Tạo booking + atomic reserve voucher |
Error codes
LOYALTY_VOUCHER_NOT_FOUND — code không tồn tại trong tenant này
LOYALTY_VOUCHER_NOT_OWNED — voucher của customer khác
LOYALTY_VOUCHER_EXPIRED — quá expiresAt
LOYALTY_VOUCHER_ALREADY_USED — status REDEEMED hoặc CANCELLED
LOYALTY_VOUCHER_RESERVED_OTHER — đang RESERVED cho booking khác
LOYALTY_VOUCHER_GUEST_NOT_ALLOWED — guest booking không thể apply (cần customerId)
8. UI mockups
8a. Admin — card form
┌─────────────────────────────────────────────────────┐
│ Stamp card │
├─────────────────────────────────────────────────────┤
│ Tên [ Loyal customer 10x ] │
│ Active [✓] │
│ │
│ ── Earning ─────────────────────────────────────── │
│ Số stamp cần [ 10 ] │
│ Giá trị booking [ 200 ] NOK │
│ tối thiểu (đặt 0 nếu mọi booking) │
│ │
│ ── Reward ──────────────────────────────────────── │
│ Loại reward [ Discount fixed ▼ ] │
│ Giá trị [ 200 ] NOK │
│ Voucher hết hạn [ 12 ] tháng │
│ (để trống = không hết hạn) │
│ │
│ [ Cancel ] [ Save ] │
└─────────────────────────────────────────────────────┘
8b. Customer — voucher list trong dashboard
┌─────────────────────────────────────────────────────┐
│ Voucher của bạn │
├─────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ✨ Beauty Salon Oslo │ │
│ │ STAMP-AB12-CD34 [📋] │ │
│ │ Giảm 200 NOK · Hết hạn 31/12/2026 │ │
│ │ [ Đặt với voucher ]│ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ 🔒 Hair Studio │ │
│ │ STAMP-XY99-ZZ12 · Đang giữ │ │
│ │ Đang dùng cho booking #B-1234 │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
8c. Booking page — apply voucher input
┌────────────────────────────────────────┐
│ Tóm tắt │
├────────────────────────────────────────┤
│ Cắt tóc nam 250 NOK │
│ │
│ ▼ Áp dụng voucher │
│ ┌────────────────────────┐ [ Áp dụng ]│
│ │ STAMP-AB12-CD34 │ │
│ └────────────────────────┘ │
│ │
│ ✓ Voucher hợp lệ │
│ Giảm −200 NOK │
│ ──────────────────────────── │
│ Tổng 50 NOK │
│ │
│ [ Tiếp tục thanh toán 50 NOK ] │
└────────────────────────────────────────┘
8d. Stamp progress dashboard
┌─────────────────────────────────────────────────────┐
│ Tiến độ stamp — Beauty Salon Oslo │
├─────────────────────────────────────────────────────┤
│ │
│ Đặt 10 lần (tối thiểu 200 NOK) → Giảm 200 NOK │
│ │
│ ●●●●●●●○○○ │
│ 7/10 stamp │
│ │
│ Còn 3 booking nữa để nhận voucher 🎁 │
│ │
│ Cycle hiện tại: 2 (đã nhận 1 voucher trước đó) │
└─────────────────────────────────────────────────────┘
9. Migration plan từ model cũ
Phase A — Schema (additive, online-safe)
- ADD COLUMN
loyalty_cards.required_stamps(nullable) → backfill từrequired_visits→ DROP cũ ở Phase D - ADD COLUMN
loyalty_cards.min_booking_value(nullable, default null = mọi booking) - ADD COLUMN
loyalty_cards.voucher_expiry_months(nullable) - CREATE TABLE
loyalty_vouchers+ indices - ADD COLUMN
bookings.applied_voucher_id(nullable, unique, FK)
Phase B — Backend
LoyaltyVoucheraggregate + repository + state machine guardsVoucherCodeGeneratorservice (Crockford base32 + retry)OnBookingCompletedLoyaltyListenerrewrite — booking-level check thay per-service- New endpoints (
/loyalty-vouchers/*,/public/.../vouchers/preview, customer portal) BookingService.create— acceptvoucherCode, atomic UPDATE + INSERT- Cancellation listener — restore vs forfeit logic
- Cron job
voucher-expiry:sweep(BullMQ repeatable, daily)
Phase C — Backfill historical
- Old
LoyaltyRedemptionrows (status=CONSUMED) → giữ nguyên cho audit, không backfill thành voucher (đã consume rồi) - Old
LoyaltyStamprows → giữ nguyên, cycle calculation vẫn đếm đúng vìcycleNumberlà field cố định
Phase D — Cleanup (sau khi đã chạy ổn 2 tuần)
- DROP
loyalty_cards.required_visits(rename cũ) - DROP
loyalty_cards.service_ids(không dùng cho stamp nữa, nếu points cần thì giữ) - Mark old admin endpoints
POST /loyalty-cards/:id/redeemdeprecated → xoá
Phase E — Frontend
- Admin loyalty form rewrite (đổi
serviceIdspicker →minBookingValueinput) - Admin voucher list page mới
- Customer dashboard — voucher card UI
- Booking page — apply voucher input + preview
- Admin BookingDrawer — apply voucher input cho phone booking
- Invoice page — voucher row trong payment ledger
Phase F — Tests + E2E
- Unit: VoucherCodeGenerator, state machine, eligibility check
- Integration: end-to-end issue → apply → redeem flow
- E2E Playwright: customer apply voucher khi book + admin nhập code khi tạo phone booking
- Regression test: multi-service booking → 1 stamp duy nhất (không phải 3)
10. Edge cases + decisions
| Câu hỏi | Quyết định |
|---|---|
| Booking có voucher + bị refund 1 phần — voucher xử lý sao? | Refund 1 phần KHÔNG động voucher (vì voucher discount đã apply trước, refund chỉ giảm phần khách trả). Voucher giữ REDEEMED. |
| Customer book với voucher 200 NOK cho booking 50 NOK | Voucher giữ status REDEEMED, discountApplied=5000 (cap), phần thừa 150 NOK không trả lại (không gen voucher mới) |
| 2 voucher trên 1 booking | KHÔNG cho phép — bookings.applied_voucher_id UNIQUE. UI ẩn "Apply" sau khi đã có voucher |
| Voucher từ tenant A có dùng được ở tenant B? | KHÔNG — voucher scope tenant qua tenantId. Lookup luôn filter WHERE tenant=? |
| Customer copy code gửi bạn — bạn dùng được không? | KHÔNG — voucher gắn tenantCustomerId. Lookup filter WHERE tenantCustomer=?. Nếu nhập đúng code nhưng không phải owner → LOYALTY_VOUCHER_NOT_OWNED |
Đếm stamp khi cycle 1 đang dở mà card bị admin đổi requiredStamps từ 10 → 5 |
Voucher snapshot rewardType+rewardValue lúc issue, nhưng số stamp thì compare với card hiện tại. Cycle 1 đã có 7 stamp → next stamp sinh voucher (vì 8 > 5). Acceptable. |
| Voucher EXPIRED có refund được không? | Không — expired là expired. UI hiển thị "Voucher hết hạn ngày X" cho customer thấy lý do |
| Owner thu hồi voucher sau khi customer đã RESERVED? | Cho phép — UPDATE voucher status=CANCELLED + clear reservedBookingId. Booking không bị huỷ, nhưng discountAmount cần re-compute (booking về full price). Cần audit reason + email customer. Edge case rủi ro — đề xuất phase 2 |
11. Mapping qua model cũ (cho reviewer)
| Khái niệm cũ | Khái niệm mới |
|---|---|
LoyaltyRedemption (status RESERVED/CONSUMED/CANCELLED) |
LoyaltyVoucher (status ACTIVE/RESERVED/REDEEMED/EXPIRED/CANCELLED) |
redeemStampCard admin endpoint |
Auto-issue khi stamp đủ → bỏ endpoint cũ |
Booking.appliedRedemptionId |
Booking.appliedVoucherId |
LoyaltyCard.serviceIds (whitelist) |
LoyaltyCard.minBookingValue (threshold) |
LoyaltyCard.requiredVisits |
LoyaltyCard.requiredStamps (rename) |
computeLoyaltyDiscount (chọn item ăn discount) |
min(rewardValue_in_øre, booking.total) (cap đơn giản) |
LoyaltyRedemptionService.preflight + reserveInTx |
VoucherService.preview + reserveInTx (giữ pattern, đơn giản hoá) |
12. Voucher transferability — industry research
Quyết định: KHÔNG cho transfer giữa customer ở MVP.
Practice của các platform khác
| Platform | Loyalty voucher transfer? | Gift card transfer? |
|---|---|---|
| Booksy | ❌ Không | ✅ Có (gift card là sản phẩm riêng) |
| Vagaro | ❌ Không | ✅ Có |
| Mindbody | ❌ Không | ✅ Có |
| Treatwell | ❌ Không | ✅ Có |
| Square Loyalty | ❌ Không | ✅ Có |
| Starbucks Rewards | ❌ Không | ✅ Có |
| Sephora Beauty Insider | ❌ Không | ✅ Có |
Vì sao không cho transfer
- Anti-fraud — chống thị trường ngầm bán voucher (Reddit, Facebook Marketplace). Loyalty là phần thưởng cho sự gắn bó, không phải tài sản giao dịch
- Tax / accounting clarity — voucher = liability cá nhân của tenant với customer cụ thể. Transfer làm rối ledger
- Mental model rõ — "tôi đặt 10 lần ở salon X → tôi được giảm". Bạn tôi đặt 0 lần thì không liên quan
- Fraud detection — pattern "1 customer cứ liên tục mua tài khoản" dễ bị flag khi voucher = personal account
Khi nào nên cho transfer?
- Gift card — concept khác hẳn. Customer mua gift card cho người khác. Đây là phase 3+ (post-MVP), cần stripe/vipps integration để charge
- Family plan — Mindbody có "shared family points" nhưng chỉ trên enterprise tier. Phức tạp, không phù hợp salon nhỏ
Implementation note
Không cần làm gì thêm trong schema — tenantCustomerId FK + lookup filter WHERE tenantCustomer = ? đã đủ block transfer. Nếu customer A copy code gửi customer B, B nhập sẽ nhận LOYALTY_VOUCHER_NOT_OWNED.
13. Customer deletion — voucher disposition
Quyết định: Soft-cancel ACTIVE/RESERVED vouchers, giữ REDEEMED nguyên cho audit.
Industry practice — GDPR right-to-be-forgotten
| Platform | Cách xử lý |
|---|---|
| Booksy | Anonymize PII, vouchers cancelled, financial records giữ 7 năm |
| Stripe Customers | Customer deleted → coupons remain on Stripe side, customer ref nulled |
| Square | Account deleted → loyalty zeroed, transaction history retained |
Tại sao không hard-delete
Norway luật (Bokføringsloven §13) yêu cầu giữ chứng từ kế toán 5 năm. Voucher đã REDEEMED = chứng từ giảm giá → cần giữ. Voucher chưa redeemed = không phát sinh giao dịch tài chính → có thể CANCEL nhưng vẫn nên giữ row cho audit.
GDPR (Art. 17 right to erasure) cho phép giữ data nếu cần để comply với "legal obligation" (Art. 17(3)(b)) hoặc "establishment of legal claims" (Art. 17(3)(e)). Tax/accounting fits both.
→ Anonymize PII (name, phone, email) trên Customer row + TenantCustomer.deletedAt set → voucher rows giữ nguyên data nội bộ (id, code, amount, dates) nhưng customer-side query không reach được.
Implementation
// Khi DELETE /customers/:id (admin tenant-scope) hoặc customer self-delete:
async function onCustomerSoftDeleted(tenantCustomerId: string) {
await prisma.$transaction(async (tx) => {
// 1. Cancel ACTIVE vouchers — không còn customer để dùng
await tx.loyaltyVoucher.updateMany({
where: {
tenantCustomerId,
status: { in: ['ACTIVE', 'RESERVED'] },
},
data: {
status: 'CANCELLED',
cancelledAt: new Date(),
cancelledReason: 'CUSTOMER_DELETED',
},
});
// 2. RESERVED voucher đang gắn booking PENDING → cũng release booking về full price
// (booking-side cancellation listener sẽ handle nếu booking bị cancel cùng lúc)
// 3. REDEEMED vouchers → giữ nguyên (kế toán)
});
}
Edge cases
| Tình huống | Xử lý |
|---|---|
| Customer xoá account, có voucher RESERVED cho booking tương lai | Voucher CANCELLED + booking re-priced (về full). Khách đã uỷ quyền xoá → không còn dispute |
| Customer xoá account, có voucher REDEEMED tháng trước | Voucher giữ REDEEMED. Báo cáo tài chính tenant vẫn đúng |
| Customer un-delete (restore) trong 30 ngày | Vouchers CANCELLED do CUSTOMER_DELETED không tự restore. Owner có thể issue lại thủ công nếu muốn (admin endpoint phase 2) |
| Tenant xoá customer thay vì customer self-delete | Cùng flow — TenantCustomer.deletedAt là trigger duy nhất |
14. Notification templates
4 events × 2 channels (SMS + email) × 2 locales (nb-NO + en).
Tái dụng pattern BrandedTransactionalEmails đã ship 2026-04-28 + NotificationProcessor BullMQ queue.
Event taxonomy
| Event | Trigger | SMS? | Email? | Khi nào fire |
|---|---|---|---|---|
VoucherIssued |
Stamp đủ → voucher tạo | ✅ | ✅ | Ngay sau INSERT voucher (cùng listener handle BookingCompleted) |
VoucherExpiring |
Cron daily | ❌ | ✅ | 7 ngày trước expiresAt, 1 lần |
VoucherExpired |
Cron daily | ❌ | ✅ | Sau khi sweep flip status=EXPIRED |
VoucherCancelled |
Owner action | ❌ | ✅ | Ngay sau CANCEL endpoint |
(SMS không gửi cho expiring/expired/cancelled vì tránh spam — chỉ event tích cực mới đáng bằng SMS)
14a. VoucherIssued
SMS — nb-NO:
Gratulerer! Du har tjent et bonusbevis hos {salonName}. Kode: {code}
Rabatt: {rewardLabel}. Gyldig til {expiresAtShort}.
Bruk på {bookingUrl}
SMS — en:
Congrats! You've earned a reward voucher at {salonName}. Code: {code}
Discount: {rewardLabel}. Valid until {expiresAtShort}.
Use at {bookingUrl}
Email subject — nb-NO: 🎁 Du har tjent et bonusbevis hos {salonName}
Email subject — en: 🎁 You've earned a reward voucher at {salonName}
Email body (HTML template, branded với tenant logo + primary color):
┌─────────────────────────────────────────────┐
│ [salon logo] │
├─────────────────────────────────────────────┤
│ │
│ 🎉 Gratulerer, {customerName}! │
│ │
│ Du har samlet 10 stempler hos │
│ {salonName} og har nå tjent et bonusbevis. │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ STAMP-AB12-CD34 [Kopier] │ │
│ │ │ │
│ │ Rabatt: 200 kr │ │
│ │ Gyldig til: 31. desember 2026 │ │
│ └────────────────────────────────────────┘ │
│ │
│ [ Bestill med bevis ] │
│ │
│ Bevisset er personlig og kan ikke │
│ overføres. Bruk det neste gang du │
│ bestiller hos oss. │
│ │
│ Takk for at du er en fast kunde 💕 │
│ │
├─────────────────────────────────────────────┤
│ {salonName} · {salonAddress} │
│ Avbestill notifikasjoner │
└─────────────────────────────────────────────┘
Template variables:
customerName—Customer.nametừ profilesalonName,salonLogo,salonAddress,primaryColor—BrandingResolvercode— voucher coderewardLabel— i18n format: "200 kr" (DISCOUNT_AMOUNT) / "20% av" (DISCOUNT_PERCENT) / "1 gratis service" (FREE_SERVICE)expiresAtShort— formatted theo locale (vd "31. des. 2026" / "Dec 31, 2026"). NULL → text "Uten utløp"bookingUrl—{publicWebUrl}/b/{slug}/book?voucher={code}(auto-pre-fill voucher input)
14b. VoucherExpiring (7 ngày trước)
Email subject — nb-NO: ⏰ Bonusbeviset ditt utløper om 7 dager
Email subject — en: ⏰ Your reward voucher expires in 7 days
Email body:
┌─────────────────────────────────────────────┐
│ [salon logo] │
├─────────────────────────────────────────────┤
│ │
│ Hei {customerName}, │
│ │
│ Bonusbeviset ditt hos {salonName} │
│ utløper om 7 dager. │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ STAMP-AB12-CD34 │ │
│ │ Rabatt: 200 kr │ │
│ │ Utløper: 31. desember 2026 │ │
│ └────────────────────────────────────────┘ │
│ │
│ [ Bestill nå ] │
│ │
│ Etter utløpsdatoen kan beviset │
│ ikke lenger brukes. │
│ │
└─────────────────────────────────────────────┘
Cron job: voucher-reminder:sweep (BullMQ repeatable, daily 09:00 tenant timezone) — query WHERE status=ACTIVE AND expiresAt BETWEEN now()+7d AND now()+8d AND remindedAt IS NULL. Set remindedAt để không gửi nhiều lần (cần thêm 1 column).
14c. VoucherExpired
Email subject — nb-NO: Bonusbeviset ditt har utløpt
Email subject — en: Your reward voucher has expired
Hei {customerName},
Bonusbeviset ditt {code} hos {salonName}
har utløpt {expiredAt}.
Den gode nyheten? Du kan begynne å samle
stempler igjen for et nytt bevis!
[ Bestill time ]
Tone: nhẹ nhàng, mời call-to-action đặt tiếp để start cycle mới (giữ retention).
14d. VoucherCancelled (owner thu hồi)
Email subject — nb-NO: Viktig melding om bonusbeviset ditt
Email subject — en: Important update about your voucher
Hei {customerName},
Bonusbeviset {code} hos {salonName} har blitt
trukket tilbake av salongen.
Grunn: {ownerProvidedReason}
Hvis du har spørsmål, vennligst kontakt
{salonName}:
- Telefon: {salonPhone}
- E-post: {salonEmail}
Vi beklager ulempen.
Tone formal — case này hiếm + sensitive, không CTA, dẫn customer về liên lạc trực tiếp với salon.
Schema bổ sung
model LoyaltyVoucher {
...
+ remindedAt DateTime? @map("reminded_at") // 7-day expiry reminder sent
}
File layout
booking-api/src/core/loyalty/notifications/
├── voucher-issued.template.ts
├── voucher-expiring.template.ts
├── voucher-expired.template.ts
├── voucher-cancelled.template.ts
├── voucher-issued.template.spec.ts (snapshot tests, en + nb)
└── on-voucher-event.listener.ts (subscribes 4 events, dispatches via NotificationProcessor)
i18n keys gộp dưới voucherEmails.* namespace, mirror bookingEmails.* structure đã có.
15. Admin analytics — voucher dashboard
Vì sao cần
Owner cần biết:
- Liability outstanding — bao nhiêu tiền sẽ phải giảm trong tương lai (ACTIVE + RESERVED vouchers ×
min(rewardValue, avgBookingValue)) - Program ROI — issue vs redemption rate. Nếu 1000 voucher issue mà chỉ 50 redeem → program không hấp dẫn, owner cân nhắc giảm
requiredStampshoặc tăngrewardValue - Customer retention proxy — bao nhiêu khách đang có voucher = bao nhiêu khách "đang nuôi" để quay lại
Endpoint
GET /admin/loyalty-cards/:cardId/analytics?from&to
Response:
{
card: { id, name, requiredStamps, ... },
period: { from: ISO, to: ISO },
metrics: {
vouchersIssued: 247, // count
vouchersRedeemed: 89, // count
vouchersExpired: 12,
vouchersCancelled: 3,
vouchersActive: 143, // status=ACTIVE
vouchersReserved: 0, // status=RESERVED
redemptionRate: 0.36, // 89/247
expiryRate: 0.05, // 12/247
avgDaysToRedeem: 23.4, // (redeemedAt - issuedAt) trên REDEEMED
totalDiscountGiven: 1780000, // SUM(discountApplied) on REDEEMED, øre
outstandingLiability: 2860000, // ACTIVE+RESERVED count × avg discount øre
},
trend: [
{ month: '2026-01', issued: 12, redeemed: 4 },
{ month: '2026-02', issued: 18, redeemed: 7 },
// ...
],
topCustomers: [
{ tenantCustomerId, customerName, vouchersEarned: 5, vouchersRedeemed: 4 },
// top 10 by vouchersEarned
],
}
UI
Trang /admin/loyalty/[cardId]/analytics:
┌─────────────────────────────────────────────────────────────┐
│ Loyal customer 10x · Analytics [30 days▼]│
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ISSUED │ │ REDEEMED │ │ ACTIVE │ │
│ │ │ │ │ │ │ │
│ │ 247 │ │ 89 │ │ 143 │ │
│ │ +12% vs M-1 │ │ 36% conv. │ │ Outstanding │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ OUTSTANDING LIABILITY │ │
│ │ │ │
│ │ 28 600 NOK │ │
│ │ (143 active vouchers × ~200 NOK avg) │ │
│ │ │ │
│ │ Worst case nếu mọi voucher ACTIVE đều redeem │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ISSUE vs REDEEM TREND │ │
│ │ │ │
│ │ ▁▂▃▅▆▇█▇▆▅▃▂ │ │
│ │ ░░░░▒▒▓▓▒▒░░░ │ │
│ │ Jan Feb Mar Apr May │ │
│ │ ── Issued ── Redeemed │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ TOP REDEEMERS │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ Anna Berg 5 earned · 4 redeemed │ │
│ │ Magnus Olsen 4 earned · 3 redeemed │ │
│ │ Kari Hansen 4 earned · 2 redeemed │ │
│ │ ... (top 10) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Implementation note
- Aggregate query qua
loyalty_voucherstable với GROUP BYstatus, không cần warehouse riêng - Tính
outstandingLiability=COUNT(WHERE status IN (ACTIVE, RESERVED)) × AVG(rewardValue)— không chính xác 100% (cap = booking total có thể giảm con số thực tế) nhưng đủ "worst case" cho owner planning - Trend chart 12 tháng: tái dụng
combo trend chartđã có cho superadmin (Phase 1.1) — pure SVG, no library - Top redeemers: query top 10, không cần pagination
- Cache 5 phút (Redis) — analytics không cần real-time, giảm query load
File layout
booking-api/src/core/loyalty/analytics/
├── voucher-analytics.service.ts
├── voucher-analytics.service.spec.ts
└── voucher-analytics.controller.ts (mount under existing LoyaltyController)
booking-web/src/app/admin/(dashboard)/loyalty/[cardId]/analytics/
├── page.tsx
└── AnalyticsContent.tsx
16. Cron jobs summary
| Job name | Schedule | Mục đích |
|---|---|---|
voucher-expiry:sweep |
Daily 02:00 UTC | Find ACTIVE vouchers with expiresAt < now() → flip EXPIRED + emit event |
voucher-reminder:sweep |
Daily 09:00 (per-tenant timezone) | Find ACTIVE vouchers expiring trong 7 ngày, remindedAt IS NULL → send email + set remindedAt |
Cả 2 đều BullMQ repeatable, register ở LoyaltyModule.onModuleInit.
17. Open items (đã align)
- ✅ Voucher transfer: KHÔNG (mục 12)
- ✅ Customer xoá account: soft-cancel ACTIVE/RESERVED, giữ REDEEMED (mục 13)
- ✅ Notification templates: 4 events × 2 channels × 2 locales (mục 14)
- ✅ Admin analytics: dashboard per-card với liability + trend + top redeemers (mục 15)
- ✅ Service whitelist cho stamp card: KHÔNG — owner muốn restrict thì tạo card riêng
Sẵn sàng để scaffold backend Phase A + B.
18. Deferred items
loyalty-cancel-revert
Vấn đề: Khi 1 booking đã COMPLETED bị admin force-cancel (lùi status), stamps + points đã earn từ booking đó KHÔNG được revert tự động. Có thể gây leak nhỏ — customer giữ stamp/voucher cho service họ không thực sự nhận.
Trạng thái hiện tại:
- ✅ Voucher:
OnBookingVoucherLifecycleListenersubscribeBookingCancelled→ flipRESERVED → ACTIVE. Cancel pre-complete release voucher đúng. - ❌ Stamp: KHÔNG có cancel listener.
OnBookingCompletedLoyaltyListenerchỉ INSERT stamp ở COMPLETED. Cancel post-complete → stamp leak. - 🟡 Point:
LoyaltyService.clawbackPoints(bookingId, tcId)đã code + test cover. Nhưng không có listener nào CALL khiBookingCancelled→ dead method.
Khi nào leak xảy ra (probability):
- Cancel pre-complete (PENDING/CONFIRMED/ARRIVED): không leak (chưa earn).
- Admin force-cancel post-COMPLETED: leak. Phổ biến với admin tool "lùi status" — edge case nhưng thật.
- Khi voucher đã được auto-issue từ N stamps complete, sau đó admin cancel 1 trong N booking đó: vouhcer thực ra "không xứng đáng" — không có path nào cancel voucher tự động.
Khi nào ship fix:
- Trước production launch loyalty (Phase 4 sweep) HOẶC
- Khi admin tool "force-cancel completed booking" được expose
Scope fix (~30 phút):
- Tạo
OnBookingCancelledLoyaltyListenersubscribeBookingCancelled:- Delete
LoyaltyStamprows cóbookingId = X(chỉ stamps earn từ booking này, identify bằng FK) - Call existing
LoyaltyService.clawbackPoints(bookingId, tenantCustomerId)
- Delete
- (Optional) Cancel voucher tự động nếu vừa earn từ stamp vừa bị xoá → flip status
ACTIVE → CANCELLEDvới reasonBOOKING_REVERTED - Wire vào
LoyaltyModule.onModuleInit - Spec test cancel post-COMPLETED edge case
loyalty-reward-types
Quyết định: UI tạm thời CHỈ expose DISCOUNT_PERCENT. FREE_SERVICE và DISCOUNT_AMOUNT bị ẩn trong LoyaltyFormModal — không xoá, comment block + legacy option fallback giữ nguyên backend.
Lý do:
FREE_SERVICE— giảm 100% booking tiếp theo: dễ abuse trên service giá cao, khó dispute giá-tại-thời-điểm-redeem.DISCOUNT_AMOUNT— fixed amount risk vượt total trên service nhỏ → confusing "0 kr" display.DISCOUNT_PERCENT— predictable, cap by booking total, an toàn cho MVP.
Trạng thái:
- Backend (
VoucherIssuanceService, listeners, domain) vẫn handle 3 rewardType — existing cards trên DB giữ nguyên hoạt động. - FE form: chỉ render PERCENT cho card mới + edit. Legacy card có
FREE_SERVICE/DISCOUNT_AMOUNTshow option với suffix(legacy)để admin không bị "kẹt UI" khi mở card cũ. - Seed demo (
prisma/seed-loyalty-demo.ts) vẫn tạo full 3 rewardType để FE test mọi branch hiển thị.
Re-enable conditions:
- Per-card
maxCapfield choFREE_SERVICE(vd "free service worth up to 500 NOK") minBookingValuegate strict choDISCOUNT_AMOUNT(block redeem nếu booking total < ngưỡng)- Admin "preview the worst-case discount" UI khi tạo card
- Owner training docs về abuse vectors