flows/stamp-voucher-flow.md

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. 1 booking = tối đa 1 stamp / card — bất kể bao nhiêu service trong booking
  2. Eligibility = paidAmount >= minBookingValue (paidAmount = sau khi trừ discount khác, đã capture). Nếu minBookingValue = null → mọi booking đều eligible
  3. 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)
  4. 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"
  5. Listener idempotent qua processedForLoyalty CAS 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 citext hoặc lower() 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)

  1. ADD COLUMN loyalty_cards.required_stamps (nullable) → backfill từ required_visits → DROP cũ ở Phase D
  2. ADD COLUMN loyalty_cards.min_booking_value (nullable, default null = mọi booking)
  3. ADD COLUMN loyalty_cards.voucher_expiry_months (nullable)
  4. CREATE TABLE loyalty_vouchers + indices
  5. ADD COLUMN bookings.applied_voucher_id (nullable, unique, FK)

Phase B — Backend

  1. LoyaltyVoucher aggregate + repository + state machine guards
  2. VoucherCodeGenerator service (Crockford base32 + retry)
  3. OnBookingCompletedLoyaltyListener rewrite — booking-level check thay per-service
  4. New endpoints (/loyalty-vouchers/*, /public/.../vouchers/preview, customer portal)
  5. BookingService.create — accept voucherCode, atomic UPDATE + INSERT
  6. Cancellation listener — restore vs forfeit logic
  7. Cron job voucher-expiry:sweep (BullMQ repeatable, daily)

Phase C — Backfill historical

  • Old LoyaltyRedemption rows (status=CONSUMED) → giữ nguyên cho audit, không backfill thành voucher (đã consume rồi)
  • Old LoyaltyStamp rows → giữ nguyên, cycle calculation vẫn đếm đúng vì cycleNumber là field cố định

Phase D — Cleanup (sau khi đã chạy ổn 2 tuần)

  1. DROP loyalty_cards.required_visits (rename cũ)
  2. DROP loyalty_cards.service_ids (không dùng cho stamp nữa, nếu points cần thì giữ)
  3. Mark old admin endpoints POST /loyalty-cards/:id/redeem deprecated → xoá

Phase E — Frontend

  1. Admin loyalty form rewrite (đổi serviceIds picker → minBookingValue input)
  2. Admin voucher list page mới
  3. Customer dashboard — voucher card UI
  4. Booking page — apply voucher input + preview
  5. Admin BookingDrawer — apply voucher input cho phone booking
  6. 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

  1. 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
  2. Tax / accounting clarity — voucher = liability cá nhân của tenant với customer cụ thể. Transfer làm rối ledger
  3. 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
  4. 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:

  • customerNameCustomer.name từ profile
  • salonName, salonLogo, salonAddress, primaryColorBrandingResolver
  • code — voucher code
  • rewardLabel — 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 requiredStamps hoặc tăng rewardValue
  • 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_vouchers table với GROUP BY status, 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: OnBookingVoucherLifecycleListener subscribe BookingCancelled → flip RESERVED → ACTIVE. Cancel pre-complete release voucher đúng.
  • ❌ Stamp: KHÔNG có cancel listener. OnBookingCompletedLoyaltyListener chỉ 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 khi BookingCancelled → 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):

  1. Tạo OnBookingCancelledLoyaltyListener subscribe BookingCancelled:
    • Delete LoyaltyStamp rows có bookingId = X (chỉ stamps earn từ booking này, identify bằng FK)
    • Call existing LoyaltyService.clawbackPoints(bookingId, tenantCustomerId)
  2. (Optional) Cancel voucher tự động nếu vừa earn từ stamp vừa bị xoá → flip status ACTIVE → CANCELLED với reason BOOKING_REVERTED
  3. Wire vào LoyaltyModule.onModuleInit
  4. Spec test cancel post-COMPLETED edge case

loyalty-reward-types

Quyết định: UI tạm thời CHỈ expose DISCOUNT_PERCENT. FREE_SERVICEDISCOUNT_AMOUNT bị ẩn trong LoyaltyFormModalkhô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_AMOUNT show 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:

  1. Per-card maxCap field cho FREE_SERVICE (vd "free service worth up to 500 NOK")
  2. minBookingValue gate strict cho DISCOUNT_AMOUNT (block redeem nếu booking total < ngưỡng)
  3. Admin "preview the worst-case discount" UI khi tạo card
  4. Owner training docs về abuse vectors