flows/status-matrix/05-payment-driven.md

05 · Payment-Driven Transitions

Reverse direction: Payment events → Booking status transitions hoặc depositStatus updates. Tất cả chạy qua EventBus + outbox, performer = SYSTEM.

Code ref:

  • on-payment-authorized.listener.ts
  • on-payment-settled-negative.listener.ts (subscribes Failed + Expired)
  • (TBD) on-payment-captured.listener.ts — có thể chưa có trong core/booking, cần verify

1. Listeners overview

Event source (Payment) Listener (Booking ctx) Booking action Performer
PaymentAuthorized OnPaymentAuthorizedListener PENDING → CONFIRMED SYSTEM
PaymentFailed OnPaymentSettledNegativeListener PENDING → CANCELLED SYSTEM
PaymentExpired idem (subscribes cả 2) PENDING → CANCELLED SYSTEM
PaymentCaptured TBD — docs hứa update depositStatus, code listener chưa verify
PaymentVoided TBD update depositStatus=VOIDED
PaymentRefunded / PaymentPartiallyRefunded TBD update depositStatus=REFUNDED/PARTIALLY_REFUNDED
PaymentInitiated — (audit only)

2. PaymentAuthorized → CONFIRMED

Listener logic (on-payment-authorized.listener.ts)

1. Fetch booking by payload.bookingId
2. Skip if not found
3. Refuse if tenant mismatch (cross-tenant safety)
4. Skip if booking.status !== PENDING (idempotent redelivery)
5. Call bookingService.updateStatus(id, tenantId, CONFIRMED, { role: 'SYSTEM' })
6. Catch INVALID_STATUS_TRANSITION → log + return (race với admin)
7. Re-throw unexpected errors → outbox retry

Guards + invariants

  • Booking found
  • Cross-tenant refuse
  • Idempotent (status check)
  • Race swallow INVALID_STATUS_TRANSITION
  • SYSTEM role → bypass cancellation window

Real-world

Case Outcome
Customer trả deposit lần đầu booking CONFIRMED, email khách "xác nhận"
Webhook redelivery (idempotent) skip sau lần đầu
Admin vừa cancel PENDING cùng lúc webhook về Race: nếu admin trước → listener skip (status CANCELLED). Nếu webhook trước → admin cancel từ CONFIRMED (tới với CANCELLED direct).
Cross-tenant attack (forged bookingId) Listener compare tenantId từ event vs booking row → reject
Booking đã CONFIRMED từ autoConfirm=true Listener skip

Checklist

  • Listener wired trong booking.module.ts
  • Idempotency
  • Cross-tenant safety
  • Unit test (spec file exists)
  • Race handling
  • Listener cho case autoConfirm=true pre-CONFIRMED booking — bổ sung logic "update depositStatus=AUTHORIZED even if status != PENDING"? (P1-5)

3. PaymentFailed / PaymentExpired → CANCELLED

Listener logic (on-payment-settled-negative.listener.ts)

Same skeleton như Authorized listener, khác:

  • Target status: CANCELLED
  • Chỉ fire khi booking.status = PENDING
  • Explicit comment: CONFIRMED booking không bị wipe (trước đó đã có successful auth).

Real-world

Case Outcome
Customer abandon Bambora checkout (INITIATED → EXPIRED via cron) Booking PENDING → CANCELLED, slot free
Card declined Payment FAILED → Booking cancel
Provider outage, markFailed('PROVIDER_UNAVAILABLE') Booking cancel
Booking đã CONFIRMED rồi sau đó retry payment fail Listener skip (status != PENDING). OK.
Customer retry sau cancel Không flow — booking đã CANCELLED terminal. Phải book lại. Gap P1-4.

Checklist

  • Listener wired
  • Subscribes Failed + Expired
  • Idempotent + cross-tenant
  • Unit test
  • Distinguish provider-transient vs permanent failure (P1-4): provider outage → giữ booking + surface "retry" thay vì cancel
  • Retry payment endpoint + UI (P1-4)

4. PaymentCaptureddepositStatus = PAID

Expected (per booking-flow.md §Events Payment publish)

Payment publish PaymentCaptured →
  Booking listener: UPDATE booking.depositStatus = 'PAID'

Reality (cần verify)

booking-flow.md §Events Payment publish list 5 events (Captured, Authorized, Refunded, Voided, Failed) với booking reaction. Nhưng trong booking.module.ts chỉ thấy 2 listener (Authorized + SettledNegative). Không có listener nào update depositStatus.

Gap P1-9: depositStatus projection listener chưa có. Booking row không reflect payment state → admin UI không hiển thị đúng "Deposit PAID / AUTHORIZED / etc".

Expected behavior

Payment event booking.depositStatus
PaymentInitiated PENDING
PaymentAuthorized AUTHORIZED
PaymentCaptured PAID
PaymentVoided VOIDED
PaymentRefunded REFUNDED
PaymentPartiallyRefunded PARTIALLY_REFUNDED
PaymentFailed PAYMENT_FAILED
PaymentExpired EXPIRED

→ Cần viết listener OnPaymentStateProjectionListener subscribe tất cả, update booking.depositStatus (read-only projection, không drive logic).

Checklist

  • [~] docs promise
  • Listener chưa implement (P1-9)
  • depositStatus string type: chưa enum hóa trong schema (dễ inconsistent) → Prisma enum migration (P2-8)
  • Admin UI hiển thị badge deposit status (phụ thuộc P1-9)

5. PaymentRefunded / PartiallyRefunded

Expected

  • Update booking.depositStatus
  • Notification customer: "Hoàn tiền X NOK, ETA 5–10 ngày"
  • Không đổi booking.status (có thể refund sau COMPLETED)

Reality

Notification enqueue trong booking.service cho CONFIRMED/CANCELLED, không có cho refund. Customer không được biết refund.

→ Gap P1-10 notification sau refund.

Checklist

  • [~] Payment command emit event
  • Booking listener update depositStatus (P1-9)
  • Notification customer (P1-10)
  • Admin UI timeline hiển thị refund entry (phụ thuộc depositStatus projection)

6. PaymentVoided

Expected

  • Update booking.depositStatus = VOIDED
  • Không đổi booking.status (void thường xảy ra khi booking đã CANCELLED)

Reality

Tương tự Refunded — chưa có listener projection.

Checklist

  • Listener update depositStatus
  • Admin UI reflect

7. Double-direction coupling summary

┌─────────────────┬──────────────────┬────────────────────────────┐
│ Booking event   │ Payment reaction │ Back-reaction to Booking   │
├─────────────────┼──────────────────┼────────────────────────────┤
│ BookingCreated  │ initiate()       │ (via PaymentAuthorized →   │
│                 │                  │  PENDING→CONFIRMED)        │
│ BookingConfirmed│ (no-op)          │ —                          │
│ BookingArrived  │ capture()        │ → depositStatus=PAID       │
│                 │                  │   (needs P1-9)             │
│ BookingCompleted│ capture fallback │ → depositStatus=PAID       │
│ BookingCancelled│ VOID/REFUND/etc  │ → depositStatus update     │
│ BookingNoShow   │ capture=forfeit  │ → depositStatus=PAID       │
└─────────────────┴──────────────────┴────────────────────────────┘

8. Race / ordering edge cases

Xem 07-edge-cases.md chi tiết. Tóm tắt:

  • Webhook trước createSession return — Flow 9 payment-flow.md
  • Admin cancel concurrent với Authorized listener — race swallow INVALID_STATUS_TRANSITION
  • Outbox retry sau listener throw — exponential backoff, dead-letter sau 10 attempts

9. Checklist tổng hợp

Authorize path

  • PaymentAuthorizedCONFIRMED listener
  • Idempotent + cross-tenant + race swallow
  • Unit test
  • Handle autoConfirm=true case (P1-5)

Negative path

  • PaymentFailed/ExpiredCANCELLED listener
  • Idempotent + cross-tenant
  • Unit test
  • Distinguish transient vs permanent provider error (P1-4)
  • Retry payment flow (P1-4)

Projection (depositStatus)

  • OnPaymentStateProjectionListener — chưa có, P1-9
  • Enum-hóa depositStatus field (P2-8)

Notifications

  • Refund customer email (P1-10)
  • Void customer email (P2-7)

Admin UI

  • Timeline events hiển thị payment lifecycle (phụ thuộc P1-9)
  • Badge depositStatus trên booking card

gaps-and-plan.md cho plan.