Booking Flow & Settings Guide
BẮT BUỘC đọc trước khi viết code liên quan booking hoặc tenant settings.
Booking lifecycle — overview
flowchart LR
Start([Booking sources]) --> Public[/Public booking<br/>POST /public/tenants/:slug/bookings/]
Start --> Admin[/Admin create<br/>POST /bookings/]
Start --> WalkIn[/Walk-in<br/>POST /bookings/walk-in/]
Start --> Phone[/Phone booking<br/>source=PHONE/]
Public --> Resolve{Settings + skill + business hours + lead-time + bookingMode}
Admin --> Resolve
WalkIn --> Resolve
Phone --> Resolve
Resolve -->|Pass| Status{Initial status}
Resolve -->|Fail| Reject[400/422 error]
Status -->|autoConfirm + IN_PERSON| CONFIRMED
Status -->|depositEnabled| PENDING
Status -->|walk-in| IN_PROGRESS
Status -->|default| PENDING
PENDING --> Outbox[Domain Event Outbox<br/>BookingCreated]
CONFIRMED --> Outbox
IN_PROGRESS --> Outbox
Outbox -->|Async| Payment[Payment Context<br/>InitiatePayment if depositEnabled]
Outbox -->|Async| Loyalty[Loyalty Context<br/>reserve redemption if applicable]
Booking Create Flow
User fills BookingDrawer form
→ Frontend: skill validation (resource.skills vs serviceId)
→ Frontend: zod schema (items.min(1), resourceId.min(1), date required)
→ POST /bookings (BookingController.create)
→ normalizeItems(): backward-compat serviceId → items[]
→ resolveItems(): lookup services + resources, build full snapshot
├─ duration + price (existing)
├─ flatten: serviceName, serviceCurrency, resourceName
└─ JSONB: serviceSnapshot (incl. taxRate, categoryName), resourceSnapshot
(See `BookingItem` model + `booking-snapshot.types.ts` for hybrid pattern.
Receipts/invoices/customer history MUST read these snapshot columns,
never join the live Service/Resource — see Snapshot Pattern section below.)
→ calculateTimes: startTime + totalDuration → endTime
→ parseTenantSettings(tenant.settings)
→ validateBusinessHours(settings, startTime, endTime) ← MUST PASS
→ shouldSkipConflict(settings, forceOverlap, userRole)
├─ allowDoubleBooking=true → skip all conflict checks
└─ forceOverlap=true + OWNER/ADMIN → skip conflict checks
→ validateBookingMode(settings, items) ← MUST PASS
→ For each item with resourceId:
├─ validateResourceSkill(resourceId, serviceId)
└─ checkConflict(tenantId, resourceId, startTime, endTime)
→ resolveInitialStatus(settings)
├─ autoConfirm=true → CONFIRMED
└─ autoConfirm=false → PENDING
→ prisma.booking.create(...)
→ fire-and-forget notification
Booking Update Flow
User edits in BookingDrawer
→ Frontend: skill validation + zod schema
→ PATCH /bookings/:id (BookingController.update)
→ findById(): verify booking exists + tenant ownership
→ Payment guard: isPaid only for IN_PROGRESS/COMPLETED
→ parseTenantSettings(tenant.settings)
→ validateBusinessHours() if time is changing ← MUST PASS
→ shouldSkipConflict(settings)
→ If items provided: resolveItems + validateResourceSkill + checkConflict
→ If scalar only: checkConflict when resourceId/time changing
→ prisma.booking.update(...)
Walk-In Flow
POST /bookings/walk-in (OWNER/STAFF/ADMIN only)
→ Validate walkInEnabled setting ← MUST PASS
→ validateResourceSkill
→ validateBusinessHours ← MUST PASS
→ Create with status=IN_PROGRESS, startTime=now
Booking Draft / Shareable Cart (?sessionId=, V2 stepper)
Server-side cart cho luồng V2 stepper. Giữ nguyên lựa chọn dở dang qua F5 + cho phép
chia sẻ link booking (kiểu giỏ hàng e-commerce). Model BookingDraft (booking_drafts).
FE (debounced 600ms khi đổi service/staff/date/time/voucher):
chưa có sessionId → POST /public/tenants/:slug/bookings/draft → set ?sessionId=<uuid>
có sessionId → PATCH /public/tenants/:slug/bookings/draft/:id (refresh TTL)
cart rỗng → gỡ ?sessionId khỏi URL (server row để TTL dọn)
Hydrate (F5 / mở shared link), page.tsx server:
GET /public/tenants/:slug/bookings/draft/:id
→ drop service inactive, coerce resource invalid → null
precedence: sessionId > from > services > serviceId
Quy tắc (MUST):
- KHÔNG lưu PII trong draft (tên/điện thoại/email/notes). Draft chia sẻ được → người mở link tự điền thông tin ở Step 4. Chỉ lưu selection: items (service+staff), date, startTime, voucherCode.
- Slot/time KHÔNG được tin từ draft — luôn re-check ở submit (
checkConflict+BOOKING_START_TIME_IN_PAST). Draft chỉ tái tạo lựa chọn, không giữ chỗ. - TTL 7 ngày (
BOOKING_DRAFT_TTL_MS), refresh mỗi lần ghi.getquá hạn →DRAFT_EXPIRED(410). Validate service thuộc tenant + active, resource bookable-online →DRAFT_INVALID_ITEMS. - Cleanup: lazy-expiry on read + opportunistic
deleteMany(expired)on create. Cron BullMQ defer — bật khi volume tăng / khi làm abandoned-cart remarketing (copy patternpayment-expiry).
Test plan + kịch bản đầy đủ: docs/testing/v2-booking-conflict-scenarios.md.
Snapshot Pattern (BookingItem)
Mỗi BookingItem lưu một bản chụp đông cứng của Service + Resource tại thời điểm tạo booking. Nguyên tắc bất di bất dịch: receipt / invoice / customer history / confirmation email KHÔNG được join sang Service/Resource live — đọc thẳng từ snapshot columns trên BookingItem. Ngược lại, calendar / drawer / dashboard / admin form vẫn dùng live join (operator cần thấy tên hiện tại).
Lý do
- Legal/SAF-T (Norway): invoice phải reproduce chính xác giao dịch dù salon đã đổi tên service, đổi giá, đổi VAT, hay đổi currency.
- UX history: khách xem "lịch sử booking của tôi" phải thấy đúng tên/giá đã trả, không phải tên/giá hiện tại.
Hybrid storage (Stripe Invoice / Shopify Order / Mindbody Visit pattern)
BookingItem
├── Hot snapshot (flatten — index/sort/report):
│ serviceName, serviceCurrency, resourceName, duration, price
└── Cold snapshot (JSONB — receipt/audit detail):
serviceSnapshot { id, name, description, duration, price, currency,
imageKey, categoryId, categoryName, taxRate, metadata,
capturedAt }
resourceSnapshot { id, name, type, color, avatarKey, jobTitle, metadata,
capturedAt } (null nếu unassigned)
JSONB validate qua parseServiceSnapshot / parseResourceSnapshot (xem booking-api/src/core/booking/booking-snapshot.types.ts).
Capture sites (3 — TẤT CẢ phải populate)
BookingService.create()→ quaresolveItems()(batch fetch services + resources, build snapshot)BookingService.update()vớiitemsmới → cùngresolveItems()BookingService.walkIn()→ fetch service + resource trực tiếp, build snapshot inline
Read sites — MUST use snapshot
EmailBookingPayloadBuilder.build()— confirmation/cancel/completion emailCustomerPortalService"my bookings" —select { serviceName, resourceName, serviceCurrency }PublicBookingController.getPublicBooking()— confirmation page, invoice page, ticket QR (wrap{id, name}shape giữ DTO contract)- Frontend
BookingsSection.tsx(customer "my bookings"),BookingTicket.tsx(printed ticket)
Read sites — KEEP live join
Calendar, dashboard upcoming, booking drawer (admin edit), check-in client. UX wants current state.
Tương tác với soft-delete
Service/Resource có deletedAt (xem docs/architecture/soft-delete-pattern.md). Khi service hoặc resource bị soft-delete:
- Receipt / invoice / email / customer history: vẫn render bình thường (đọc từ snapshot, không join live).
- Calendar / dashboard / admin drawer: live join sẽ trả
nullcho service/resource bị xoá. UI fallback hiển thịserviceName/resourceNametừ snapshot — không cần fallback settings, snapshot đã có đủ thông tin tối thiểu để render. - Bypass cho audit view:
prisma.service.findFirst({ where: { id, deletedAt: { not: null } } }).
Status Transition Flow
POST /bookings/:id/status/:status
→ isValidTransition(current, new)
→ If cancelling: validate cancellationHours window ← MUST PASS
→ prisma.booking.update(status)
→ Notify on CONFIRMED/CANCELLED
Tenant Settings Reference
Mỗi setting PHẢI được enforce ở cả API lẫn UI. KHÔNG có dead settings.
| Setting | Type | API Enforcement | UI Enforcement |
|---|---|---|---|
businessHours |
BusinessHours[] | Block booking ngoài giờ (create + update + walkIn) | Calendar viewport, TimePicker min/max, grey-out off-hours slots |
allowDoubleBooking |
boolean | Skip conflict check (create + update) | Settings toggle |
autoConfirm |
boolean | Initial status CONFIRMED vs PENDING (create) | Settings toggle |
bookingMode |
'assigned_only' | 'allow_unassigned' | Require resourceId khi assigned_only (create) | BookingDrawer: resourceId required/optional based on mode |
allowStaffSelection |
boolean | Public payload exposes flag (sanitizeSettings); validateSettingsCombination reject false + assigned_only (TENANT_SETTINGS_STAFF_SELECTION_REQUIRES_UNASSIGNED) |
V2 stepper bỏ step "Choose professional" → 3 bước, mọi item = "Any available" (getStepOrder); Settings toggle (khoá ON khi assigned_only, auto-bật khi đổi sang assigned_only) |
walkInEnabled |
boolean | Block walk-in endpoint khi disabled | Hide/show walk-in button |
cancellationHours |
number | Block cancel khi quá gần startTime | Show warning, disable cancel button khi hết deadline |
currency |
string | Display metadata (không validate prices) | Format money display (useCurrency + useFormatMoney) |
posEnabled |
boolean | Gate POS features (future) | Show/hide POS tab (future) |
depositEnabled |
boolean | Trigger payment flow khi tạo booking (publish BookingCreated → Payment context init) |
Show deposit field in booking summary, block submit nếu provider chưa config |
depositType |
'percentage' | 'fixed' | Pass vào FeeCalculationPolicy khi init payment |
Settings UI conditional input (% vs minor units) |
depositValue |
number (int) | Compute deposit amount (percentage 0–100, fixed minor units) | Settings UI validate (≤100 nếu %) |
Rules
- Settings data PHẢI đầy đủ trong DB — KHÔNG fallback, KHÔNG default ở runtime
- Tenant create flow (service/seed) PHẢI include đầy đủ settings từ
getDefaultSettings() - Mỗi setting mới thêm PHẢI có: API enforcement + UI enforcement + docs update
- Business hours so sánh bằng local time (salon timezone), KHÔNG dùng UTC
Settings Enforcement Status
✅ Enforced
businessHours— API + UIallowDoubleBooking— APIautoConfirm— APIallowStaffSelection— API (public payload + combination guard) + UI (stepper skip + settings toggle). Chỉ hợp lệ cùngbookingMode: allow_unassigned.currency— UI (display)
⚠️ Needs Implementation
bookingMode— API phải reject unassigned khiassigned_only; UI phải required resourceIdwalkInEnabled— API phải check trước tạo walk-in; UI phải ẩn walk-in buttoncancellationHours— API phải check trước cancel; UI phải show deadlineposEnabled— Feature chưa build (keep setting, implement later)depositEnabled/depositType/depositValue— UI admin done; Payment context implementation pending (xem../architecture/payment-architecture.md)
Integration với Payment Context
Chi tiết:
../architecture/payment-architecture.md,payment-flow.md
Booking Context publish domain events tại các lifecycle moment. Payment Context listen + react độc lập. Booking KHÔNG biết Payment tồn tại, KHÔNG direct call Payment service.
Events Booking publish
| Event | Trigger point | Payload (key fields) |
|---|---|---|
BookingCreated |
Sau prisma.booking.create success |
bookingId, tenantId, totalAmount, startTime, requiresDeposit |
BookingConfirmed |
Sau status transition → CONFIRMED | bookingId, confirmedAt |
BookingCancelled |
Sau status transition → CANCELLED (by customer) | bookingId, cancelledAt, reason |
BookingCancelledBySalon |
Sau cancel with bySalon=true |
bookingId, cancelledAt, reason |
BookingMarkedNoShow |
Sau status transition → NO_SHOW | bookingId, markedAt |
BookingCompleted |
Sau status transition → COMPLETED | bookingId, completedAt |
Mechanism
- Booking service raise event:
eventBus.publish(new BookingCreated({...})) - Event writes vào
DomainEventOutboxcùng transaction với booking row (xem architecture §8) OutboxPublisherworker poll → dispatch via EventBus- Payment Context listeners process
Events Payment publish (Booking listen)
| Event | Booking reaction |
|---|---|
PaymentCaptured |
booking.depositStatus = PAID |
PaymentAuthorized |
booking.depositStatus = AUTHORIZED |
PaymentRefunded |
booking.depositStatus = REFUNDED |
PaymentVoided |
booking.depositStatus = VOIDED |
PaymentFailed |
booking.depositStatus = PAYMENT_FAILED |
Booking không block chờ Payment
Booking create response KHÔNG chờ Payment init. Flow async qua outbox. Frontend poll hoặc WebSocket push để nhận update status.
depositStatus field trên Booking
Không phải enum strict — string với values: NOT_REQUIRED | PENDING | AUTHORIZED | PAID | PARTIALLY_REFUNDED | REFUNDED | VOIDED | PAYMENT_FAILED | EXPIRED.
Booking Context chỉ read-only update dựa trên events; business logic deposit thuộc Payment Context.