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.tson-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=truepre-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. PaymentCaptured → depositStatus = 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)
-
depositStatusstring 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
createSessionreturn — 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
-
PaymentAuthorized→CONFIRMEDlistener - Idempotent + cross-tenant + race swallow
- Unit test
- Handle autoConfirm=true case (P1-5)
Negative path
-
PaymentFailed/Expired→CANCELLEDlistener - 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
depositStatusfield (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
depositStatustrên booking card
→ gaps-and-plan.md cho plan.