flows/status-matrix/01-create.md

01 · Booking Creation

Mọi đường booking được sinh ra. Kết quả là booking row với status ban đầu ∈ {PENDING, CONFIRMED, IN_PROGRESS} + Payment + Loyalty side effects.

Code ref: booking-api/src/core/booking/booking.service.tscreate(), walkIn().

flowchart TD
  Admin[Admin / Staff create<br/>POST /bookings] --> Derive{source?}
  Public[Public customer<br/>POST /public/.../bookings] --> Derive
  Walkin[Walk-in<br/>POST /bookings/walk-in] -->|paymentMode=IN_PERSON| EmitWalkin

  Derive -->|PHONE| StampInPerson[paymentMode=IN_PERSON]
  Derive -->|ONLINE / ADMIN| Default[paymentMode=undefined]

  StampInPerson --> AutoConf{autoConfirm?}
  Default --> AutoConf
  AutoConf -->|true| Confirmed[status=CONFIRMED]
  AutoConf -->|false| Pending[status=PENDING]

  Confirmed --> EmitOnline[Emit BookingCreated]
  Pending --> EmitOnline
  EmitWalkin[Emit BookingCreated<br/>status=IN_PROGRESS] --> Listener[OnBookingCreated]
  EmitOnline --> Listener

  Listener -->|paymentMode=IN_PERSON| Skip[Skip PSP init]
  Listener -->|depositAmount=0| Skip
  Listener -->|else| Init[Initiate Payment via Bambora<br/>checkoutUrl in metadata]

1. Đường tạo booking

# Path Endpoint Initial status Performer
A Admin / staff tạo hộ POST /bookings PENDING (autoConfirm=false) / CONFIRMED (autoConfirm=true) STAFF / OWNER / ADMIN
B Public customer book POST /public/tenants/:slug/bookings như A CUSTOMER (auth) / guest
C Walk-in POST /bookings/walk-in IN_PROGRESS STAFF / OWNER / ADMIN

2. Guards (pre-conditions)

Mọi path cùng chạy qua BookingService.create (ngoại trừ walk-in có nhánh riêng). Guards chung:

# Guard Code ref Reject code Path áp dụng
G1 validateBusinessHours(settings, startTime, endTime) booking-settings.helper.ts OUTSIDE_BUSINESS_HOURS A, B, C
G2 validateBookingMode(settings, items)assigned_only yêu cầu resourceId idem BOOKING_MODE_ASSIGNED_ONLY A, B
G3 validateResourceSkill(resourceId, serviceId) booking.service.ts RESOURCE_MISSING_SKILL A, B, C (per-item)
G4 checkConflict(resource × time) nếu !shouldSkipConflict idem RESOURCE_CONFLICT A, B, C
G5 validateBookingLeadTime(settings, startTime) — cap maxBookingDaysInAdvance idem BOOKING_TOO_FAR_IN_ADVANCE A, B
G6 validateWalkInEnabled(settings) idem WALK_IN_DISABLED C
G7 Loyalty guest reject — nếu dto.redemption + không có customerId loyalty-redemption.service.ts LOYALTY_GUEST_NOT_ALLOWED B
G8 Loyalty preflight — card active, đủ stamps / points idem 10+ LOYALTY_* codes, xem loyalty-flow.md §4 A (nếu admin áp redemption), B

Implementation

  • G1 businessHours enforce ở API + UI
  • G2 bookingMode enforce ở API + UI
  • G3 resource skill check
  • G4 conflict check + forceOverlap override cho OWNER/ADMIN
  • G5 lead-time cap (D3)
  • G6 walkIn toggle
  • G7 guest loyalty reject
  • G8 loyalty preflight (L3)

3. Initial status decision (resolveInitialStatus)

settings.autoConfirm === true          → CONFIRMED
settings.autoConfirm === false         → PENDING
walk-in                                → IN_PROGRESS (bypass autoConfirm)

P1-5 shipped 2026-04-24: validateSettingsCombination block trực tiếp combo depositEnabled=true && autoConfirm=true (mã TENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICT) tại TenantService.create/update + onboarding step. Settings UI cũng disable mỗi switch khi switch đối lập đang on. Trước fix: booking xuất phát đã là CONFIRMED trước khi Payment init → khi PaymentAuthorized đến, listener thấy status != PENDING → skip → booking CONFIRMED nhưng không bao giờ bị cancel nếu payment fail. Xem progress/gaps-and-plan.md.

Implementation

  • autoConfirm branch
  • Walk-in direct IN_PROGRESS
  • [!] autoConfirm=true + depositEnabled=true không có guard ngăn mix — rủi ro "confirmed không deposit".

4. Events emitted

Sau khi commit transaction, BookingCreated enqueue vào domain_event_outbox:

BookingCreated {
  bookingId, tenantId, startTime,
  totalAmount: payableTotal,            // = rawTotal - discountAmount (loyalty)
  originalAmount?, discountAmount?,     // chỉ có khi discount > 0
  depositAmount,                        // computed on payableTotal
  currency, idempotencyKey,
  intent: 'DEPOSIT' | 'FULL_PAYMENT',
  captureMode: 'MANUAL' | 'AUTO',       // DEPOSIT = MANUAL, FULL = AUTO
  returnUrl, cancelUrl, webhookUrl,
  customer?: { email, phone, name },
  metadata?,
}
  • Walk-in: KHÔNG emit BookingCreated hiện tại (status IN_PROGRESS, không cần deposit flow, không loyalty reserve). Cần xác nhận — gap P2-3.

Implementation

  • BookingCreated emit với full payload (A, B)
  • [~] Walk-in không emit → mất cơ hội auto-earn stamp trên BookingCompleted (vì listener L4 vẫn fire nếu booking reach COMPLETED, nhưng reservation không tồn tại)

5. Payment side effect (post-commit)

PaymentIntegrationService.onBookingCreated:

if depositAmount <= 0: return (no-op)
else: dispatch InitiatePaymentCommand({
  intent: DEPOSIT | FULL_PAYMENT,
  captureMode: MANUAL | AUTO,
  amount: depositAmount, currency, urls, customer, idempotencyKey
})

→ Payment row created, provider.createSession() gọi Bambora, trả về redirectUrl vào Payment.metadata + enqueue PaymentInitiated event.

Tình huống Initial payment status Next event
depositEnabled + autoConfirm=false INITIATED Customer pay → webhook → AUTHORIZED → listener → booking CONFIRMED
depositEnabled + autoConfirm=true INITIATED Customer pay → AUTHORIZED (booking đã CONFIRMED, listener skip). depositStatus update
depositEnabled=false (no payment) Booking stays PENDING chờ manual confirm
Provider not configured FAILED ngay depositStatus = PAYMENT_FAILED, booking PENDING → listener nào xử lý? (xem edge case 07)

Implementation

  • depositAmount > 0 → Payment init
  • depositAmount = 0 → no-op
  • Provider unavailable → markFailed + PaymentFailed event
  • Admin UI hiển thị depositStatus = PAYMENT_FAILED với CTA retry (Flow 14 payment-flow.md) — chưa wire FE

6. Loyalty side effect (intra-transaction)

Nếu dto.redemption present:

preflight (đọc ngoài tx) → reserveInTx (ghi trong tx):
  VISIT_BASED:  LoyaltyRedemption{ status: RESERVED, cycleNumber }
                booking.appliedRedemptionId = redemption.id
                booking.discountAmount = preflight.discountAmount
  POINTS_BASED: LoyaltyPointTransaction{ type: REDEEM, points: -N, bookingId }
                booking.discountAmount = preflight.discountAmount
                (no redemption row)

Implementation

  • VISIT_BASED reserve + link
  • POINTS_BASED debit ledger
  • Guest reject ngay preflight
  • Atomic commit booking + redemption + outbox
  • Admin UI tạo booking hộ khách + áp redemption — chưa build (L6)

7. Notification side effect

fire-and-forget qua NotificationQueue:

  • BOOKING_CREATED → customer email/SMS nếu có customerPhone/Email.
  • Nội dung: confirmation với deposit info nếu requiresPayment = true + payment link.

Implementation

  • Notification enqueue trong booking.service.ts
  • [~] Deposit link trong notification — payload chưa gắn paymentRedirectUrl do Payment init async, chỉ có sau outbox flush. Customer nhận mail "booking tạo" rồi một lúc sau mới có link pay.
  • Email template cho case PAYMENT_FAILED khi customer cần retry — chưa có

8. Real-world cases (salon thật)

# Case Path + settings Flow hiện tại Gap
1 Khách book online, tenant bật deposit 30% B + autoConfirm=false + depositEnabled=true PENDING → Bambora redirect → pay → AUTHORIZED → CONFIRMED OK
2 Khách book online, tenant không bật deposit B + depositEnabled=false + autoConfirm=true CONFIRMED ngay OK, trust-based
3 Staff tạo hộ phone booking A + source=PHONE paymentMode=IN_PERSON flagged → onBookingCreated skips PSP init; salon collects at counter (P1-3 shipped 2026-04-24, P1-5 also blocks the autoConfirm+deposit combo at settings level) OK
4 Walk-in kiểu giữ chỗ (khách xin đứng đợi) C IN_PROGRESS ngay OK cho walk-in, nhưng "giữ chỗ tối nay" phải đi path A
5 Regular/VIP khách, OWNER skip deposit A + depositEnabled=true OWNER buộc phải tắt depositEnabled trong settings hoặc chấp nhận customer pay Cần VIP flag per-customer (P2-2)
6 Customer redeem loyalty card B + loyalty Apply discount → payableTotal giảm → deposit % × payable OK (L3)
7 Provider Bambora tạm outage bất kỳ Payment FAILED ngay → booking stays PENDING → OnPaymentSettledNegative cancel sau retry cycle Retry UI chưa có (P1-4)
8 Guest (chưa login) redeem B guest Reject LOYALTY_GUEST_NOT_ALLOWED OK, spec-compliant
9 Booking với maxBookingDaysInAdvance > 7 B + cap=14 Cho qua nhưng Payment hold Bambora 7 ngày → auth expire → auto cancel Settings warning có, nhưng UX có thể cải thiện (P2-4)

9. Checklist tổng hợp path create

  • POST /bookings admin flow
  • POST /public/tenants/:slug/bookings public flow
  • POST /bookings/walk-in walk-in flow
  • Tất cả guards G1–G8
  • autoConfirm branch
  • BookingCreated outbox + payload
  • Loyalty reserve intra-tx
  • Payment init async
  • Notification enqueue
  • [~] Walk-in không emit BookingCreated (ảnh hưởng loyalty auto-earn path)
  • [!] autoConfirm=true + depositEnabled=true: booking CONFIRMED trước khi payment verify → payment-failed listener skip
  • Admin tạo booking + áp loyalty redemption UI (L6)
  • Retry payment UI cho FAILED / EXPIRED — P1-4 shipped 2026-04-25 (TRANSIENT vs PERMANENT classification, 3-attempt cap, customer retry endpoint + email/SMS nudge với deep-link)
  • VIP per-customer flag để skip deposit (P2-2)
  • paymentMode: IN_PERSON cho phone booking (P1-3, shipped 2026-04-24 — derive từ source=PHONE trong BookingService.create, listener đã skip init từ P0-3)

→ Xem progress/gaps-and-plan.md cho ưu tiên + plan.