04 · No-Show Transition
CONFIRMED/ARRIVED → NO_SHOW. Khi khách không đến và staff đã đợi qua thời gian grace.
Code ref: booking.service.ts status switch case NO_SHOW + payment-integration.service.ts:onBookingNoShow.
1. State machine
PENDING → NO_SHOW ❌
CONFIRMED → NO_SHOW ✅
ARRIVED → NO_SHOW ✅ (edge case: khách check-in rồi biến mất?)
IN_PROGRESS → NO_SHOW ❌
COMPLETED → NO_SHOW ❌
OWNER/ADMIN bypass qua isAdminTransition nhưng no-show từ PENDING hoặc IN_PROGRESS không có business meaning thực.
2. Performer matrix
| Performer | Allowed? | Ghi chú |
|---|---|---|
| CUSTOMER | — | — |
| STAFF | ✅ | primary path |
| OWNER / ADMIN | ✅ | + force |
| SYSTEM | ✅ (hiện chưa có listener auto-no-show) | nice-to-have cron: auto mark no-show sau startTime + grace |
3. Guards
| # | Guard | Code? | Docs? | Status |
|---|---|---|---|---|
| N1 | isValidTransition(CONFIRMED/ARRIVED, NO_SHOW) |
✅ | ✅ | [x] |
| N2 | Grace period — now() > startTime + graceMinutes (docs default 15min) |
❌ | ✅ | [ ] Chưa implement. Staff có thể mark no-show ngay trước giờ booking. |
| N3 | Không có outstanding payment command đang pending | ❌ | ❌ | [ ] — |
4. Events + side effects
Event
BookingMarkedNoShow {
bookingId, tenantId, markedAt,
idempotencyKey: `bk-${bookingId}-noshow`,
}
Docs payment-flow.md §ref table ghi thêm markedBy, nhưng code hiện không có field đó trong payload.
Payment (onBookingNoShow)
for (payment of findByBookingId) {
if (payment.status !== AUTHORIZED) continue;
dispatch CapturePaymentCommand (forfeit = capture full)
}
| Payment status | Action |
|---|---|
| INITIATED | no-op (nothing to capture) |
| AUTHORIZED | capture full — forfeit deposit |
| CAPTURED (auto mode) | no-op — salon đã giữ tiền |
| PARTIALLY_REFUNDED / REFUNDED | no-op |
| VOIDED / FAILED / EXPIRED | no-op |
Loyalty
Hiện không có listener onBookingMarkedNoShow trong Loyalty context.
→ Nếu booking có appliedRedemptionId = RESERVED và rơi vào NO_SHOW:
- Stamps: không restore (forfeit, khớp payment policy)
- Points: không clawback (ledger burn lost)
- Redemption row: stuck ở RESERVED forever →
listByCustomerquery có thể return orphan
→ Gap P1-6: Loyalty L4 listener cho NO_SHOW path (status RESERVED → FORFEITED hoặc CANCELLED).
Stats
Docs ghi "customer no-show counter++". Code chưa verify:
grep -rn "noShow\|no_show" booking-api/src/core/customer* 2>/dev/null
→ Gap: Stats counter nếu chưa có → P2-6.
Notification
| Recipient | Event |
|---|---|
| Customer | Email/SMS "Bạn đã vắng mặt, deposit đã bị giữ lại làm phí" (nice-to-have) |
| Staff | — |
→ Chưa implement trong enqueueNotification switch (booking.service.ts:858 chỉ cho CONFIRMED/CANCELLED).
5. Real-world cases
| # | Case | Hiện tại | Expected | Gap |
|---|---|---|---|---|
| 1 | Khách không đến sau 15 phút | Staff bấm tay "No show" | Auto mark + email khách | [ ] Auto cron (nice-to-have) |
| 2 | Khách đến trễ 10 phút, staff đã bấm NoShow | [!] Không có grace guard → staff đã bấm quá sớm | Grace period enforce | N2 guard cần add |
| 3 | Khách check-in rồi đi ra cửa hàng khác → biến mất | Staff bấm NoShow từ ARRIVED | VOID/FORFEIT payment tuỳ state | [~] Payment đã CAPTURED ở ARRIVED → onBookingNoShow skip (status !== AUTHORIZED). Salon giữ tiền. OK. |
| 4 | Auto-confirmed booking, khách never pay, never show | Booking CONFIRMED (đã bị listener confirm từ PaymentAuthorized? Không — payment không authorize), cần staff bấm NoShow hoặc cron | [x] Sẽ bị auto-cancel qua expiry listener khi Payment EXPIRED (nếu depositEnabled) | — |
| 5 | Khách có lịch sử no-show nhiều lần | Không có counter → salon không thấy trend | [ ] Stats counter + display (P2-6) | |
| 6 | Loyalty redemption RESERVED + khách no-show | Orphan RESERVED | [!] L4 cần cancel + forfeit (P1-6) | |
| 7 | Staff lỡ tay bấm NoShow | Không undo được (terminal) | [ ] OWNER force NO_SHOW → CANCELLED (nếu sau đó khách gọi giải thích) — cần force path P0-1 |
6. Edge cases
6a. Auto-capture mode (FULL_PAYMENT intent future)
Khi captureMode=AUTO, Payment ở CAPTURED ngay sau khách trả. Staff mark NoShow → no-op (đã có tiền). OK.
6b. Khách cancel sau khi bị mark NoShow
Terminal — không transition được. Payment đã forfeit (capture). Customer khiếu nại → OWNER phải:
- Refund manual qua Payment UI (
POST /payments/:id/refund), KHÔNG đổi booking status. - Log reason trong audit.
6c. Race: auto no-show cron vs staff mark COMPLETED
Không áp dụng hiện tại vì chưa có auto cron. Nếu add sau:
- Cron check
CONFIRMED + now > startTime + graceMinutes + X→ mark NoShow - Staff đang click "Arrived" cùng lúc → race
- Giải pháp: cron dùng
isValidTransition(CONFIRMED, NO_SHOW)— nếu status đã ARRIVED/IN_PROGRESS thì throw → swallow → skip
7. Checklist no-show
State machine
- CONFIRMED/ARRIVED → NO_SHOW (staff)
- OWNER force bypass
- Terminal
Guards
- N2 grace period — 15 phút sau startTime mới cho bấm
- N3 outstanding payment check (nếu cần)
Events
-
BookingMarkedNoShowemit - [~] Payload thiếu
markedByso với docs
Payment integration
- Capture AUTHORIZED (forfeit)
- Skip các status khác
Loyalty integration
- L4 listener cho NO_SHOW — restore / forfeit redemption RESERVED (P1-6)
Stats
- Customer no-show counter increment (P2-6)
- Display counter trong customer detail UI
Notification
- Email khách "bạn đã vắng mặt, deposit bị giữ" (P2-7)
Automation
- Cron auto-mark NoShow sau
startTime + grace + 30min(P3 nice-to-have)
Admin UX
- "Undo NoShow" path (OWNER force NO_SHOW → CANCELLED + refund) — P0-1 force flag + P2-5 UX
→ gaps-and-plan.md cho priorities.