Gaps & Plan
Consolidated issue tracker từ
01-create.md→07-edge-cases.md. Ưu tiên theo impact + effort. Mỗi item link ngược về file gốc.
Legend
- P0 — Blocker cho production real-world. Salon thật sẽ gặp trong tuần đầu vận hành.
- P1 — Important trước launch công khai. Gap UX hoặc safety.
- P2 — Nice-to-have / post-MVP.
- P3 — Future / out-of-scope.
Checkbox:
[x]done + merged[~]in-progress / partial[ ]not started
P0 — Production blockers
[x] P0-1 · OWNER / ADMIN force cancel out-of-window
File: 03-cancel.md §3, §5, §7 · Real-world: case #4, #5, #6.
Vấn đề: booking.service.ts:804 block cả OWNER/ADMIN bằng !isSystem guard. Salon không cancel được khi emergency (staff ốm, khách gọi xin hủy sát giờ).
Fix:
- Sửa
booking.service.ts:804:if (newStatus === CANCELLED && !isSystem && !isAdmin) { validateCancellationWindow(...); } - Mở rộng
booking.controller.ts:116nhận body:@Body() body: { reason?: string; force?: boolean } - Khi
isAdmin && out-of-window→ bắt buộcreasonnon-empty. - Audit log: thêm action
STATUS_CHANGE_FORCEDvớireasonpersistent. - Frontend: modal "Reason" trước confirm force cancel (shadcn Dialog + Textarea).
- Test: unit test policy branch + e2e OWNER cancel out-of-window pass.
Effort: ~1 ngày. 1 API + 1 FE modal + tests.
[x] P0-2 · Deposit-required guard cho manual confirm
File: 02-happy-path.md §T1 · Real-world: case #3, #4 trong §8 01-create.md.
Vấn đề: STAFF có thể PENDING → CONFIRMED dù depositEnabled=true + Payment chưa AUTHORIZED. Booking CONFIRMED không deposit.
Fix:
- Thêm guard trong
updateStatuschoPENDING → CONFIRMED:if (newStatus === CONFIRMED && !isSystem) { if (tenant.settings.depositEnabled && !isAdmin) { const payments = await paymentRepo.findByBookingId(id, tenantId); const hasValid = payments.some(p => p.status === AUTHORIZED || p.status === CAPTURED); if (!hasValid) throw UnprocessableEntityException('BOOKING_DEPOSIT_REQUIRED'); } } - Exception:
payableTotal = 0(full loyalty discount) → exempt guard (xem P2-9). - OWNER override với
{ force, reason }body (dùng chung với P0-1). - Audit log:
CONFIRMED_WITHOUT_DEPOSITflag. - Test: STAFF blocked, OWNER force pass, loyalty-free case pass.
Effort: ~1 ngày. Gắn với P0-1 chung 1 PR.
Lưu ý: Cross-context dependency — Booking gọi PaymentRepo. Hoặc:
- Option A (simpler): check
booking.depositStatusprojection (nhưng cần P1-9 trước). - Option B (current recommendation): Booking module inject
PAYMENT_REPOSITORYport — OK về layering vì Payment đã expose port.
[x] P0-3 · Walk-in không emit BookingCreated
File: 01-create.md §4, §8 · Related: 06-loyalty-coupling.md case 1.
Vấn đề: Walk-in skip create event → loyalty không reserve redemption (nếu có), payment không init (OK cho walk-in vì pay tại salon), analytics miss events.
Decision cần: walk-in có nên emit BookingCreated không?
- Pro: uniform event stream, loyalty auto-earn trên BookingCompleted vẫn chạy
- Con: emit sẽ trigger Payment.initiate nếu depositEnabled → không phù hợp walk-in
Fix: emit BookingCreated với flag paymentMode: IN_PERSON (chung với P1-3):
- Payment listener: skip initiate khi
paymentMode = IN_PERSON. - Loyalty listener: vẫn chạy cho redemption + autoStamp.
Effort: ~0.5 ngày. Gắn với P1-3 chung PR.
P1 — Important before launch
[x] P1-1 · Customer self-cancel endpoint (public)
File: 03-cancel.md §7 · Real-world: case #1, #2. Shipped: 2026-04-24.
Shipped:
POST /public/tenants/:slug/bookings/:id/cancelwith@CustomerAuth()— requires a valid customer JWT, 403 for guests / mismatched owner, 404 when booking is scoped to another tenant.- Performer sent to
BookingService.updateStatusis{ role: 'CUSTOMER', userId: customerId }socancelledBy=CUSTOMERis emitted bybuildStatusTransitionEventand policy takes the customer branch. - Cancellation-window guard kicks in automatically via the
!isSystem && !isAdminbranch — customer out-of-window gets422 BOOKING_CANCELLATION_TOO_LATE(P1-2 will add CTA). - Frontend:
account/BookingsSectionexposes a red "Cancel" CTA on PENDING/CONFIRMED/ARRIVED rows behind aConfirmDialog. API client teaches itself that/public/tenants/.../cancelis a customer-authed public path (refresh + retry via customer JWT). - Tests: 6 new unit tests on
PublicBookingController(happy path, guest booking, owner mismatch, tenant mismatch, window-guard passthrough, slug not found).
[ ] P1-2 · "Contact salon" CTA khi out-of-window
File: 03-cancel.md §7, §8 case #2.
Vấn đề: customer out-of-window nhận 422. UX cụt. Cần CTA "Liên hệ salon" + auto-compose message với booking info.
Fix: FE component OutOfWindowDialog với tel:/mailto:/in-app message, link tới OWNER.
Effort: ~0.5 ngày FE only.
[ ] P1-3 · paymentMode: IN_PERSON cho phone/walk-in booking
File: 01-create.md case #3 · Combined với P0-3.
Vấn đề: Staff tạo phone booking cho khách trả mặt → depositEnabled bắt Payment init, nhưng khách không online.
Fix:
- Schema:
Booking.paymentMode: 'ONLINE' | 'IN_PERSON'defaultONLINE. - Create DTO cho admin path: cho phép chọn mode.
onBookingCreated: skip initiate nếuIN_PERSON.- Admin UI: dropdown "Payment mode" khi staff tạo booking.
- "Krev resterende" QR flow (Flow 18) vẫn dùng được khi muốn collect sau.
Effort: ~1.5 ngày. Schema + API + UI + tests.
[ ] P1-4 · Distinguish transient vs permanent Payment failure + retry UI
File: 05-payment-driven.md §3 · 07-edge-cases.md §3a.
Vấn đề: Provider outage 30s destroy booking (markFailed → cancel). Customer không có đường retry.
Fix:
- Payment aggregate: phân biệt
FAILED_TRANSIENTvsFAILED_PERMANENT. - Transient (5xx, timeout, NO_ACTIVE_PROVIDER) → booking stay PENDING +
depositStatus = RETRY_PENDING. - Permanent (4xx card declined) → booking CANCELLED as today.
- Endpoint
POST /public/tenants/:slug/bookings/:id/payment/retrytạo Payment P2 với new idempotencyKey. - FE: nếu
depositStatus=RETRY_PENDING→ show "Retry payment" button.
Effort: ~2 ngày. Payment domain change + listener split + UI.
[ ] P1-5 · autoConfirm=true + depositEnabled=true guard
File: 01-create.md §3, 02-happy-path.md §T1 real-world case #4.
Vấn đề: Nếu 2 setting cùng bật, booking CONFIRMED ngay trước khi payment verify. Nếu payment sau đó FAIL, OnPaymentSettledNegativeListener skip (status != PENDING) → booking CONFIRMED không deposit.
Fix:
- Settings UI: block toggle combination
autoConfirm=true && depositEnabled=true. Force user chọn 1. - Hoặc: nếu combo cho phép, listener
OnPaymentSettledNegativemở rộng xử lýCONFIRMEDcase: downgrade back to PENDING hoặc CANCELLED (risky).
Recommendation: option 1 (block combo). Simpler, deterministic.
Effort: ~0.5 ngày settings validation.
[ ] P1-6 · Loyalty L4 lifecycle listeners
File: 06-loyalty-coupling.md §4, 04-no-show.md case #6.
Vấn đề: RESERVED redemption stuck forever nếu booking COMPLETED/CANCELLED/NO_SHOW vì L4 chưa ship.
Fix:
on-booking-completed.loyalty.listener.ts— RESERVED → CONSUMED.on-booking-cancelled.loyalty.listener.ts— phân biệt pre-capture (restore) vs post-capture (forfeit).on-booking-no-show.loyalty.listener.ts— RESERVED → FORFEITED.- Clawback POINTS_BASED: create
LoyaltyPointTransaction{type: CLAWBACK, points: +N}. - Stamp restore VISIT_BASED: re-insert stamp rows to cycle trước.
- Idempotent via status check.
- Migration dọn data orphan RESERVED cho booking terminal trước L4.
Effort: ~2 ngày. Listeners + migration + unit/e2e tests.
[ ] P1-7 · Reschedule rules + UX
File: 03-cancel.md §9.
Vấn đề: Update booking startTime không có guard khi out-of-window. Spec Fresha: cho đổi đến X giờ trước.
Fix:
- Thêm setting
rescheduleHours(separate từcancellationHours, default = same). updateendpoint check guard khistartTimechanges + performer = CUSTOMER.- FE: prefer "Reschedule" CTA trong out-of-window dialog (P1-2) thay vì chỉ "Contact salon".
Effort: ~1 ngày.
[ ] P1-8 · Cancel preview: hiển thị refund amount
File: 03-cancel.md §10 admin UX.
Vấn đề: Admin/customer không biết sẽ refund bao nhiêu trước khi confirm cancel.
Fix:
- Expose
decideCancellationRefundas pure helper + endpointGET /bookings/:id/cancel-preview. - Return:
{ decision: 'VOID'|'FULL_REFUND'|...', refundAmount, waitingDays }. - FE ConfirmDialog dùng preview trước khi mutation cancel.
Effort: ~1 ngày.
[ ] P1-9 · depositStatus projection listener
File: 05-payment-driven.md §4, §5, §6.
Vấn đề: Booking.depositStatus không reflect Payment state. Admin UI không hiển thị đúng.
Fix:
- Tạo
OnPaymentStateProjectionListenersubscribe tất cả Payment events. - Update
booking.depositStatustheo table mapping trong §4. - Idempotent (re-delivery OK).
- Migration backfill existing bookings với status từ latest Payment event.
Effort: ~1 ngày. Listener + migration + tests.
[ ] P1-10 · Notification customer khi refund / void
File: 05-payment-driven.md §5, §6.
Vấn đề: Customer không biết khi refund được xử lý.
Fix:
booking.serviceenqueueNotificationswitch mở rộng cho PaymentRefunded, PartiallyRefunded, Voided (subscribe trực tiếp Payment events hoặc quadepositStatuschange).- Email template: "Hoàn tiền X NOK, ETA 5-10 ngày".
Effort: ~0.5 ngày.
[ ] P1-11 · PaymentExpired cho booking CONFIRMED (lead-time > 7d)
File: 07-edge-cases.md §2b.
Vấn đề: Bambora auth hold 7 ngày. Booking đặt trước > 7 ngày → hold expire → booking CONFIRMED không có tiền.
Fix:
- Nếu
depositEnabled=true→ enforcemaxBookingDaysInAdvance ≤ 7(hard cap, không cho settings > 7). - Hoặc: mở rộng
OnPaymentSettledNegativeListenerxử lý CONFIRMED case:- Flag
depositStatus=EXPIRED - Notification admin "Deposit hold expired, yêu cầu khách re-pay"
- FE booking drawer show warning + button "Send re-auth link"
- Flag
- Re-auth flow: tạo Payment mới với intent DEPOSIT, gửi link khách.
Recommendation: combine — hard cap 7d + fallback handling (option 2) cho edge cases tenant misconfig.
Effort: ~1.5 ngày.
P2 — Post-MVP nice-to-have
[ ] P2-1 · Phân biệt STAFF-cancel-for-customer vs SALON-cancel
File: 03-cancel.md §1, case #3.
Hiện tất cả non-CUSTOMER = 'SALON' → always full refund. Case staff cancel hộ khách muộn nên theo CUSTOMER policy (forfeit).
Fix: add body onBehalfOf: 'CUSTOMER' | 'SALON' trong cancel request. STAFF default = ON_BEHALF_OF_CUSTOMER, OWNER default = SALON.
[ ] P2-2 · VIP per-customer skip deposit
File: 01-create.md case #5.
Thêm TenantCustomer.trustLevel: 'NORMAL' | 'VIP'. Create booking với customerId VIP → depositAmount = 0 regardless of settings.
[ ] P2-3 · Walk-in emit BookingCreated with IN_PERSON flag
Đã cover trong P0-3 + P1-3.
[ ] P2-4 · UX cảnh báo lead-time > auth hold
File: 07-edge-cases.md §2b.
Settings form đã có warning. UX book customer: nếu chọn date > 7 ngày + depositEnabled → hiển thị inline "Deposit hold chỉ 7 ngày, nếu không có update từ salon sẽ cần re-auth."
[ ] P2-5 · Force cancel IN_PROGRESS với partial refund UX
File: 03-cancel.md case #7.
Khi OWNER force IN_PROGRESS → CANCELLED: hỏi "refund bao nhiêu?" + gọi RefundPaymentCommand với amount.
[ ] P2-6 · Customer no-show counter + display
File: 04-no-show.md §4.
Schema: TenantCustomer.noShowCount. Listener OnBookingMarkedNoShow increment. UI: badge "3 no-shows trong 6 tháng" trong customer detail + booking drawer để staff warning.
[ ] P2-7 · Notification email cho NoShow + void
File: 04-no-show.md §4, 05-payment-driven.md §6.
Templates nb/en cho NoShow apology + void confirmation.
[ ] P2-8 · Enum hóa depositStatus
File: 05-payment-driven.md §4.
Thay string bằng Prisma enum. Migration backfill. Tight coupling Booking schema với Payment lifecycle → cần cân nhắc.
[ ] P2-9 · payableTotal = 0 case
File: 06-loyalty-coupling.md case #1, 07-edge-cases.md §5a.
Khi loyalty discount = 100% → không có payment → booking stuck PENDING nếu autoConfirm=false.
Fix: onBookingCreated nếu depositAmount = 0 + depositEnabled=true → auto-publish BookingConfirmed via SYSTEM role.
[ ] P2-10 · Loyalty event contract + notification
File: 06-loyalty-coupling.md §8.
LoyaltyRedemptionConsumed + StampEarned + PointsEarned → notification "Bạn đã có stamp mới / +N điểm".
[ ] P2-11 · Cross-aggregate outbox ordering
File: 07-edge-cases.md §1a.
Nghiên cứu: cần guarantee ordering giữa PaymentAuthorized + BookingCancelled cho cùng booking? Hiện mỗi aggregate có ordering riêng. Risk assessment.
[ ] P2-12 · Admin ops UI: outbox dead letter + webhook retry
File: 07-edge-cases.md §2d, §7a.
/admin/ops/outbox + /admin/payments/webhooks. List + retrigger manual.
[ ] P2-13 · Reconciliation cron verify shipped
File: 07-edge-cases.md §3c.
Audit code: ReconciliationJob có chưa? Nếu chưa → implement (Flow 13 payment-flow.md).
[ ] P2-14 · Toggle depositEnabled=false migration
File: 07-edge-cases.md §4a.
Dialog cảnh báo N booking pending + lựa chọn "cancel all" / "keep and skip deposit".
[ ] P2-15 · Grandfather cancellationHours change
File: 07-edge-cases.md §4b.
Snapshot cancellationHours lên booking tại create. updateStatus dùng snapshot thay vì live settings.
[ ] P2-16 · Update booking items sau Payment captured
File: 07-edge-cases.md §5b.
Policy: block edit items sau capture, hoặc auto-refund difference. Decision cần.
[ ] P2-17 · Optimistic locking booking edits
File: 07-edge-cases.md §6a.
If-Match: updatedAt header.
[ ] P2-18 · Outbox retention job
File: 07-edge-cases.md §7b.
Cron xóa published_at < now() - 30d.
[ ] P2-19 · Manual payment reconcile endpoint
File: 07-edge-cases.md §7d.
PATCH /admin/payments/:id/reconcile để fix aggregate corrupt.
P3 — Future / out-of-scope
- Cron auto-mark NoShow sau grace (no manual click).
- Loyalty L5 customer portal UI.
- Loyalty L6 admin redemption picker.
- POS integration (gate M2 guard).
- Multi-location (Organization layer).
- SMS retry payment link.
Recommended execution order
Sprint 1 (1 tuần) — unblock real salon
- P0-1 + P0-2 + P0-3 (combined PR) — force cancel + deposit guard + walk-in event
- P1-1 customer cancel endpoint
- P1-2 out-of-window dialog
- P1-9 depositStatus projection (enabler cho P1-5, P1-10)
Sprint 2 (1 tuần) — stability + UX
- P1-3 paymentMode IN_PERSON
- P1-4 payment retry
- P1-5 autoConfirm+deposit conflict block
- P1-11 7-day lead-time enforcement
- P1-10 refund notifications
- P1-8 cancel preview
Sprint 3 (1 tuần) — loyalty L4
- P1-6 L4 listeners (COMPLETED + CANCELLED + NO_SHOW)
- P1-7 reschedule guard
Post-MVP (ad-hoc)
- P2 items theo priority của business.
Tracking
Mỗi item resolved → update:
- File gốc: đổi
[!]/[ ]thành[x]. - File này: check box của item.
- Git commit message:
fix(status-matrix): resolve P0-1 force cancel out-of-window. - Re-run
npx gitnexus analyzesau merge để refresh index.