flows/status-matrix/gaps-and-plan.md

Gaps & Plan

Consolidated issue tracker từ 01-create.md07-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:

  1. Sửa booking.service.ts:804:
    if (newStatus === CANCELLED && !isSystem && !isAdmin) {
      validateCancellationWindow(...);
    }
    
  2. Mở rộng booking.controller.ts:116 nhận body:
    @Body() body: { reason?: string; force?: boolean }
    
  3. Khi isAdmin && out-of-window → bắt buộc reason non-empty.
  4. Audit log: thêm action STATUS_CHANGE_FORCED với reason persistent.
  5. Frontend: modal "Reason" trước confirm force cancel (shadcn Dialog + Textarea).
  6. 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 → CONFIRMEDdepositEnabled=true + Payment chưa AUTHORIZED. Booking CONFIRMED không deposit.

Fix:

  1. Thêm guard trong updateStatus cho PENDING → 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');
      }
    }
    
  2. Exception: payableTotal = 0 (full loyalty discount) → exempt guard (xem P2-9).
  3. OWNER override với { force, reason } body (dùng chung với P0-1).
  4. Audit log: CONFIRMED_WITHOUT_DEPOSIT flag.
  5. 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.depositStatus projection (nhưng cần P1-9 trước).
  • Option B (current recommendation): Booking module inject PAYMENT_REPOSITORY port — 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:

  1. POST /public/tenants/:slug/bookings/:id/cancel with @CustomerAuth() — requires a valid customer JWT, 403 for guests / mismatched owner, 404 when booking is scoped to another tenant.
  2. Performer sent to BookingService.updateStatus is { role: 'CUSTOMER', userId: customerId } so cancelledBy=CUSTOMER is emitted by buildStatusTransitionEvent and policy takes the customer branch.
  3. Cancellation-window guard kicks in automatically via the !isSystem && !isAdmin branch — customer out-of-window gets 422 BOOKING_CANCELLATION_TOO_LATE (P1-2 will add CTA).
  4. Frontend: account/BookingsSection exposes a red "Cancel" CTA on PENDING/CONFIRMED/ARRIVED rows behind a ConfirmDialog. API client teaches itself that /public/tenants/.../cancel is a customer-authed public path (refresh + retry via customer JWT).
  5. 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:

  1. Schema: Booking.paymentMode: 'ONLINE' | 'IN_PERSON' default ONLINE.
  2. Create DTO cho admin path: cho phép chọn mode.
  3. onBookingCreated: skip initiate nếu IN_PERSON.
  4. Admin UI: dropdown "Payment mode" khi staff tạo booking.
  5. "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:

  1. Payment aggregate: phân biệt FAILED_TRANSIENT vs FAILED_PERMANENT.
  2. Transient (5xx, timeout, NO_ACTIVE_PROVIDER) → booking stay PENDING + depositStatus = RETRY_PENDING.
  3. Permanent (4xx card declined) → booking CANCELLED as today.
  4. Endpoint POST /public/tenants/:slug/bookings/:id/payment/retry tạo Payment P2 với new idempotencyKey.
  5. 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:

  1. Settings UI: block toggle combination autoConfirm=true && depositEnabled=true. Force user chọn 1.
  2. Hoặc: nếu combo cho phép, listener OnPaymentSettledNegative mở rộng xử lý CONFIRMED case: 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:

  1. on-booking-completed.loyalty.listener.ts — RESERVED → CONSUMED.
  2. on-booking-cancelled.loyalty.listener.ts — phân biệt pre-capture (restore) vs post-capture (forfeit).
  3. on-booking-no-show.loyalty.listener.ts — RESERVED → FORFEITED.
  4. Clawback POINTS_BASED: create LoyaltyPointTransaction{type: CLAWBACK, points: +N}.
  5. Stamp restore VISIT_BASED: re-insert stamp rows to cycle trước.
  6. Idempotent via status check.
  7. 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:

  1. Thêm setting rescheduleHours (separate từ cancellationHours, default = same).
  2. update endpoint check guard khi startTime changes + performer = CUSTOMER.
  3. 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:

  1. Expose decideCancellationRefund as pure helper + endpoint GET /bookings/:id/cancel-preview.
  2. Return: { decision: 'VOID'|'FULL_REFUND'|...', refundAmount, waitingDays }.
  3. 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:

  1. Tạo OnPaymentStateProjectionListener subscribe tất cả Payment events.
  2. Update booking.depositStatus theo table mapping trong §4.
  3. Idempotent (re-delivery OK).
  4. 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:

  1. booking.service enqueueNotification switch mở rộng cho PaymentRefunded, PartiallyRefunded, Voided (subscribe trực tiếp Payment events hoặc qua depositStatus change).
  2. 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:

  1. Nếu depositEnabled=true → enforce maxBookingDaysInAdvance ≤ 7 (hard cap, không cho settings > 7).
  2. Hoặc: mở rộng OnPaymentSettledNegativeListener xử 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"
  3. 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.

Sprint 1 (1 tuần) — unblock real salon

  1. P0-1 + P0-2 + P0-3 (combined PR) — force cancel + deposit guard + walk-in event
  2. P1-1 customer cancel endpoint
  3. P1-2 out-of-window dialog
  4. P1-9 depositStatus projection (enabler cho P1-5, P1-10)

Sprint 2 (1 tuần) — stability + UX

  1. P1-3 paymentMode IN_PERSON
  2. P1-4 payment retry
  3. P1-5 autoConfirm+deposit conflict block
  4. P1-11 7-day lead-time enforcement
  5. P1-10 refund notifications
  6. P1-8 cancel preview

Sprint 3 (1 tuần) — loyalty L4

  1. P1-6 L4 listeners (COMPLETED + CANCELLED + NO_SHOW)
  2. P1-7 reschedule guard

Post-MVP (ad-hoc)

  • P2 items theo priority của business.

Tracking

Mỗi item resolved → update:

  1. File gốc: đổi [!]/[ ] thành [x].
  2. File này: check box của item.
  3. Git commit message: fix(status-matrix): resolve P0-1 force cancel out-of-window.
  4. Re-run npx gitnexus analyze sau merge để refresh index.