flows/booking-flow.md

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. get quá 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 pattern payment-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)

  1. BookingService.create() → qua resolveItems() (batch fetch services + resources, build snapshot)
  2. BookingService.update() với items mới → cùng resolveItems()
  3. BookingService.walkIn() → fetch service + resource trực tiếp, build snapshot inline

Read sites — MUST use snapshot

  • EmailBookingPayloadBuilder.build() — confirmation/cancel/completion email
  • CustomerPortalService "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ả null cho service/resource bị xoá. UI fallback hiển thị serviceName / resourceName từ 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

  1. Settings data PHẢI đầy đủ trong DB — KHÔNG fallback, KHÔNG default ở runtime
  2. Tenant create flow (service/seed) PHẢI include đầy đủ settings từ getDefaultSettings()
  3. Mỗi setting mới thêm PHẢI có: API enforcement + UI enforcement + docs update
  4. Business hours so sánh bằng local time (salon timezone), KHÔNG dùng UTC

Settings Enforcement Status

✅ Enforced

  • businessHours — API + UI
  • allowDoubleBooking — API
  • autoConfirm — API
  • allowStaffSelection — API (public payload + combination guard) + UI (stepper skip + settings toggle). Chỉ hợp lệ cùng bookingMode: allow_unassigned.
  • currency — UI (display)

⚠️ Needs Implementation

  • bookingMode — API phải reject unassigned khi assigned_only; UI phải required resourceId
  • walkInEnabled — API phải check trước tạo walk-in; UI phải ẩn walk-in button
  • cancellationHours — API phải check trước cancel; UI phải show deadline
  • posEnabled — 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

  1. Booking service raise event: eventBus.publish(new BookingCreated({...}))
  2. Event writes vào DomainEventOutbox cùng transaction với booking row (xem architecture §8)
  3. OutboxPublisher worker poll → dispatch via EventBus
  4. 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.