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.ts — create(), 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 +
forceOverlapoverride 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
BookingCreatedhiệ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
-
BookingCreatedemit 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 +
PaymentFailedevent - Admin UI hiển thị
depositStatus = PAYMENT_FAILEDvớ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
paymentRedirectUrldo 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_FAILEDkhi 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 /bookingsadmin flow -
POST /public/tenants/:slug/bookingspublic flow -
POST /bookings/walk-inwalk-in flow - Tất cả guards G1–G8
-
autoConfirmbranch -
BookingCreatedoutbox + 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_PERSONcho phone booking (P1-3)
→ Xem gaps-and-plan.md cho ưu tiên + plan.