flows/status-matrix/03-cancel.md

03 · Cancel Transitions

* → CANCELLED. File lớn nhất vì tổ hợp cao: starting booking status × payment status × performer × cancellation window.

Code ref:

  • booking.service.ts:updateStatus — transition + window guard + event emit
  • cancellation-refund-policy.ts:decideCancellationRefund — payment decision matrix
  • payment-integration.service.ts:onBookingCancelled — command dispatch

1. Performer mapping

cancelledBy trong BookingCancelledPayload derive từ performer role:

const cancelledBy = performer?.role === 'CUSTOMER' ? 'CUSTOMER' : 'SALON';
Performer role (backend) cancelledBy trong event Decision branch policy
CUSTOMER CUSTOMER window check + decision theo policy customer branch
STAFF SALON decision theo salon branch (luôn VOID/FULL_REFUND)
OWNER / ADMIN SALON idem STAFF
SYSTEM (negative listener) SALON idem — tuy nhiên khi SYSTEM cancel do PaymentExpired/Failed thì payment đã terminal → NOT_APPLICABLE

Quan sát: hiện STAFF = SALON trong policy → staff cancel hộ khách (kể cả khách chủ động yêu cầu) luôn được full refund. Có thể bug — xem P2-1.


2. State machine allowed transitions

Từ booking-status.constants.ts:

PENDING      → CANCELLED ✅
CONFIRMED    → CANCELLED ✅
ARRIVED      → CANCELLED ✅
IN_PROGRESS  → CANCELLED ❌ (chỉ → COMPLETED)
COMPLETED    → CANCELLED ❌ (terminal)
CANCELLED    → CANCELLED ❌ (terminal)
NO_SHOW      → CANCELLED ❌ (terminal)

OWNER/ADMIN + SYSTEM bypass state machine qua isAdminTransition → có thể force IN_PROGRESS → CANCELLED. Nhưng Payment policy không xử lý nhất quán cho state này (xem §7).


3. Cancellation window guard

booking.service.ts:804:

if (newStatus === CANCELLED && !isSystem) {
  validateCancellationWindow(settings, booking.startTime);
  // throws BOOKING_CANCELLATION_TOO_LATE nếu hoursUntilStart < cancellationHours
}

Hành vi hiện tại:

  • CUSTOMER out-of-window → 422 block ✅ (đúng spec)
  • STAFF out-of-window → 422 block ⚠️ (docs ghi STAFF "in-window only", trừ khi có escalation path — hiện không có)
  • OWNER/ADMIN out-of-window → 422 block ❌ trái docs (booking-status-flow.md role matrix cho OWNER force override)
  • SYSTEM → skip ✅

Gap P0-1: OWNER/ADMIN phải bypass + endpoint nhận { force, reason }.


4. Payment decision matrix (decideCancellationRefund)

4a. cancelledBy = CUSTOMER

Payment status Within window Out of window
INITIATED VOID VOID (khách chưa trả)
AUTHORIZED VOID (release hold) FORFEIT (capture full as fee)
CAPTURED FULL_REFUND NO_ACTION (salon keep as fee)
PARTIALLY_REFUNDED FULL_REFUND outstanding NO_ACTION
REFUNDED / VOIDED / FAILED / EXPIRED NOT_APPLICABLE idem

4b. cancelledBy = SALON (consumer protection — always refund)

Payment status Decision (window irrelevant)
INITIATED VOID
AUTHORIZED VOID
CAPTURED FULL_REFUND
PARTIALLY_REFUNDED FULL_REFUND outstanding
REFUNDED / VOIDED / FAILED / EXPIRED NOT_APPLICABLE

4c. Defensive branch (code payment-integration.service.ts:144)

Nếu decision = FULL_REFUND nhưng capturedAmount - refundedAmount <= 0 → đổi thành VOID (ví dụ payment AUTHORIZED nhưng chưa capture lại đi vào FULL_REFUND branch do logic sai).


5. Full matrix: cancel × booking state × payment state × performer

Legend: [V] VOID, [R] FULL_REFUND, [F] FORFEIT (capture), [N] NO_ACTION, [x] NOT_APPLICABLE, [—] transition blocked.

5a. From PENDING

Payment CUSTOMER in-win CUSTOMER out STAFF OWNER SYSTEM
(none — depositEnabled=false) no-op no-op (N/A — listener chỉ fire với payment fail)
INITIATED V V V V (N/A — payment đã FAILED/EXPIRED trước khi vào đây)
AUTHORIZED (hiếm — nên đã CONFIRMED qua listener, nhưng race) V F V V (N/A)
FAILED / EXPIRED x x x x x (do listener cancel, payment đã terminal)

5b. From CONFIRMED

Payment CUSTOMER in-win CUSTOMER out STAFF OWNER SYSTEM
(none) no-op [—] blocked (window guard) no-op in-win / [—] out [—] blocked bug — cần P0-1
INITIATED V [—] V V (needs P0-1)
AUTHORIZED V F V V (needs P0-1)
CAPTURED R [—] (but N if policy) R R (needs P0-1)
PARTIALLY_REFUNDED R outstanding [—] R R (needs P0-1)

5c. From ARRIVED

Booking đã ARRIVED → Payment đã CAPTURED (primary capture point). hoursUntilStart thường âm → out-of-window.

Payment CUSTOMER STAFF OWNER SYSTEM
CAPTURED [—] blocked (luôn out-of-window) hoặc N R R (needs P0-1)
AUTHORIZED (hiếm) F V V (needs P0-1)
PARTIALLY_REFUNDED [—] hoặc N R outstanding R (needs P0-1)

5d. From IN_PROGRESS

State machine block cho STAFF/CUSTOMER. Chỉ OWNER/ADMIN force được qua isAdminTransition.

Payment CUSTOMER STAFF OWNER force
CAPTURED R (needs P0-1) + UX "refund amount"

→ Real-world: staff làm hỏng dịch vụ giữa chừng, OWNER phải cancel + refund. Hiện có thể force nhưng UI chưa có path, chắc phải làm partial refund thủ công qua Payment UI rồi force status. Gap P2-5.

5e. From COMPLETED / CANCELLED / NO_SHOW

Terminal. Cancel không áp dụng.

  • Refund: OWNER vẫn có thể refund qua Payment UI (POST /payments/:id/refund) mà không đụng booking status.

6. Events emitted

BookingCancelled {
  bookingId, tenantId, cancelledAt,
  bookingStartTime,                     // để policy tính hoursUntilStart
  cancelledBy: 'CUSTOMER' | 'SALON',
  cancellationWindowHours: settings.cancellationHours,
  reason?,                              // TODO: chưa chạy qua request body
  idempotencyKey: `bk-${bookingId}-cancelled`,
}

Subscribers:

  • PaymentIntegrationService.onBookingCancelled — payment command
  • LoyaltyL4.onBookingCancelled (pending) — restore stamps / clawback points nếu RESERVED

Implementation

  • Event emit
  • Payload includes window hours
  • [!] reason field chưa được nhận từ endpoint (controller không đọc body) — P0-1
  • [!] cancelledBy không phân biệt STAFF-cancel-for-customer vs STAFF-salon-decision — P2-1

7. Customer-facing cancel

Endpoint Status
POST /public/tenants/:slug/bookings/:id/cancel [x] — @CustomerAuth(), 403 guest/owner-mismatch, 404 tenant-mismatch, window guard via updateStatus
Admin POST /bookings/:id/status/CANCELLED [x] — chỉ OWNER/STAFF/ADMIN

P1-1 resolved 2026-04-24. Frontend: account/BookingsSection exposes a "Cancel" CTA (ConfirmDialog) on PENDING/CONFIRMED/ARRIVED rows. Out-of-window UX (P1-2) still open.


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

# Case Flow hiện tại Gap
1 Khách hủy sớm (>24h) CUSTOMER cancel → VOID/FULL_REFUND OK
2 Khách hủy muộn (<24h) 422 block, khách không có đường trong app P1-1 customer endpoint + P1-2 "contact salon" CTA
3 Khách gọi điện hủy muộn → staff cancel hộ STAFF cancel → treated as SALON → FULL_REFUND luôn P2-1 phân biệt STAFF-for-customer
4 Staff ốm đột xuất → OWNER cancel gấp [—] blocked by window guard P0-1
5 Owner cancel vì lý do hợp pháp (đóng cửa) [—] blocked by window guard P0-1
6 Khách check-in rồi đổi ý OWNER force cancel → FULL_REFUND Needs P0-1; UX chưa rõ "refund amount"
7 Service hỏng giữa chừng OWNER force IN_PROGRESS → CANCELLED → partial refund manual P2-5 UX
8 Payment stuck INITIATED, customer abandon checkout Expiry sweep (Flow 11) → markExpired → listener cancel PENDING OK
9 Payment FAILED do card declined Negative listener cancel PENDING booking OK, nhưng customer có muốn retry không? — P1-4
10 Tenant bật depositEnabled rồi tắt giữa chừng Booking cũ còn Payment INITIATED — listener vẫn cancel được (payment FAILED sẽ emit) [~] cần verify trong test
11 Customer đã refund 50% rồi muốn hủy nốt Nếu trong window → FULL_REFUND outstanding (50% còn) → OK OK
12 Admin double-cancel (race / retry) Idempotent: isValidTransition(CANCELLED, CANCELLED) = false → throw. Frontend nên handle. [~] FE hiện có show toast error nhưng không silent-ignore

9. Reschedule (alternative to cancel)

Hiện booking update time qua PATCH /bookings/:id — payment không phản ứng. Trong spec Fresha/Booksy: reschedule = đổi startTime, giữ payment hold nguyên.

Scenario Hiện tại Expected
Customer within window đổi giờ PATCH success (nếu business hours pass) OK — payment hold giữ nguyên ✅
Customer out-of-window đổi giờ PATCH success (không có guard) [?] Có nên gate bằng cancellationHours không? Fresha cho phép đổi đến 1h trước.
Admin đổi resource giữa ARRIVED PATCH với forceOverlap OK

→ Gap P1-7: reschedule guard rules + UX "đổi lịch thay vì hủy" khi out-of-window.


10. Checklist cancel

State machine

  • PENDING/CONFIRMED/ARRIVED → CANCELLED (staff path)
  • Admin/OWNER force bypass isValidTransition
  • Terminal states block cancel

Guards

  • Cancellation window cho CUSTOMER
  • [!] Cancellation window block OWNER/ADMIN (P0-1)
  • [!] { force, reason } body params missing (P0-1)

Events

  • BookingCancelled emit
  • cancelledBy + cancellationWindowHours payload
  • [!] reason không được persist (không đọc từ body)
  • Phân biệt STAFF-for-customer vs SALON (P2-1)

Payment integration

  • decideCancellationRefund all combos
  • VOID / FULL_REFUND / FORFEIT / NO_ACTION dispatch
  • Defensive fallback FULL_REFUND → VOID khi outstanding=0
  • Idempotency key per decision

Customer UX

  • Public POST /public/.../bookings/:id/cancel endpoint (P1-1, shipped 2026-04-24)
  • "Contact salon" CTA khi out-of-window (P1-2)
  • Preview "Refund amount" trước confirm cancel (P1-8)

Admin UX

  • Force cancel với reason modal (P0-1)
  • Audit log CONFIRMED_FORCE_CANCEL với reason (P0-1)
  • Partial refund workflow khi force cancel IN_PROGRESS (P2-5)

gaps-and-plan.md tổng hợp priority.