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 emitcancellation-refund-policy.ts:decideCancellationRefund— payment decision matrixpayment-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.mdrole 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 commandLoyaltyL4.onBookingCancelled(pending) — restore stamps / clawback points nếu RESERVED
Implementation
- Event emit
- Payload includes window hours
- [!]
reasonfield chưa được nhận từ endpoint (controller không đọc body) — P0-1 - [!]
cancelledBykhô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
-
BookingCancelledemit -
cancelledBy+cancellationWindowHourspayload - [!]
reasonkhông được persist (không đọc từ body) - Phân biệt STAFF-for-customer vs SALON (P2-1)
Payment integration
-
decideCancellationRefundall 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/cancelendpoint (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_CANCELvới reason (P0-1) - Partial refund workflow khi force cancel IN_PROGRESS (P2-5)
→ gaps-and-plan.md tổng hợp priority.