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().


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)

Vấn đề chưa rõ ràng: nếu depositEnabled=true + autoConfirm=true, 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 gap P1-5 trong 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 + autoConfirm=true + depositEnabled=true CONFIRMED ngay, Payment INITIATED stuck (khách không ở trước máy) Gap: booking CONFIRMED không tiền. Cần paymentMode: IN_PERSON flag (P1-3)
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)
  • VIP per-customer flag để skip deposit (P2-2)
  • paymentMode: IN_PERSON cho phone booking (P1-3)

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