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)on-payment-state-projection.listener.ts(depositStatus projection, all 8 events)on-payment-failed-retry-notification.listener.ts(P1-4 retry SMS + email)
stateDiagram-v2
[*] --> PENDING: Booking created
PENDING --> CONFIRMED: PaymentAuthorized
PENDING --> PENDING: PaymentFailed PERMANENT<br/>(failedCount < 3)
PENDING --> CANCELLED: PaymentFailed PERMANENT<br/>(failedCount >= 3)<br/>reason=PAYMENT_RETRY_EXHAUSTED
PENDING --> PENDING: PaymentFailed TRANSIENT<br/>(never auto-cancels)
PENDING --> CANCELLED: PaymentExpired
CONFIRMED --> CANCELLED: PaymentExpired<br/>reason=AUTHORIZATION_EXPIRED
CONFIRMED --> CONFIRMED: PaymentFailed<br/>(top-up retry, prior auth stands)
CONFIRMED --> ARRIVED: staff marks arrived
ARRIVED --> COMPLETED: capture on complete
COMPLETED --> [*]
CANCELLED --> [*]
flowchart LR
subgraph "Payment events → depositStatus projection"
PInit[PaymentInitiated] --> Pending2[depositStatus=PENDING]
PAuth[PaymentAuthorized] --> Auth2[depositStatus=AUTHORIZED]
PCap[PaymentCaptured] --> Paid[depositStatus=PAID]
PRef[PaymentRefunded] --> Ref2[depositStatus=REFUNDED]
PPRef[PaymentPartiallyRefunded] --> PR2[depositStatus=PARTIALLY_REFUNDED]
PVoid[PaymentVoided] --> V2[depositStatus=VOIDED]
PFail[PaymentFailed<br/>any kind] --> RP[depositStatus=RETRY_PENDING]
PExp[PaymentExpired] --> Exp2[depositStatus=EXPIRED]
end
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
- Case
autoConfirm=true && depositEnabled=true— P1-5 shipped 2026-04-24 giải quyết bằng cách chặn combo ở tenant settings (mãTENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICT) thay vì thêm listener edge-case. Settings UI cũng disable mỗi switch khi switch đối lập đang on.
3. PaymentFailed / PaymentExpired → CANCELLED (or HELD for retry)
Listener logic (on-payment-settled-negative.listener.ts)
Three branches keyed by event.eventType + payload.failureKind:
PaymentFailed (PERMANENT) — card declined, fraud, expired card:
- booking PENDING + failed-count < PAYMENT_RETRY_CAP (3) → keep PENDING (customer retries)
- booking PENDING + failed-count >= cap → cancel SYSTEM reason=PAYMENT_RETRY_EXHAUSTED
- booking CONFIRMED (top-up retry race) → skip (prior auth must stand)
PaymentFailed (TRANSIENT) — provider 5xx / timeout (reserved, not yet emitted):
- booking PENDING → keep PENDING regardless of count (provider-health, not customer-card)
- booking CONFIRMED → skip
PaymentExpired (auth-hold TTL lapsed, P1-11):
- booking PENDING → cancel SYSTEM
- booking CONFIRMED → cancel SYSTEM reason=AUTHORIZATION_EXPIRED
- ARRIVED / IN_PROGRESS / COMPLETED / CANCELLED → skip (admin already acted)
PAYMENT_RETRY_CAP = 3 is exported from the listener so the retry endpoint
- projection can share the constant.
Real-world
| Case | Outcome |
|---|---|
| Customer abandon Bambora checkout (INITIATED → EXPIRED via cron) | Booking PENDING → CANCELLED, slot free |
| Card declined (1st attempt) | Payment PERMANENT → booking stays PENDING + RETRY_PENDING; SMS/email nudge fires |
| Card declined 3 times in a row | 3rd failure trips the cap → cancel reason=PAYMENT_RETRY_EXHAUSTED |
| Provider outage (TRANSIENT) | Booking stays PENDING; no auto-cancel; customer can retry once provider recovers |
| Booking đã CONFIRMED rồi sau đó retry payment fail | Listener skip (status != PENDING). OK. |
| Customer retry sau khi PERMANENT_RETRY_EXHAUSTED đã cancel | Booking terminal → must book again. Retry endpoint rejects with 422 BOOKING_NOT_RETRY_ELIGIBLE. |
Checklist
- Listener wired
- Subscribes Failed + Expired
- Idempotent + cross-tenant
- Unit test
- Distinguish provider-transient vs permanent failure (P1-4)
- Retry payment endpoint + UI (P1-4)
- Real SMTP/SendGrid
EmailProviderimpl (currentlyLogEmailProvideronly) - Guest retry via magic-link (P1-4 follow-up — currently CustomerAuth only)
- Server-side automatic TRANSIENT retry without customer click (P1-4 follow-up)
4. PaymentCaptured → depositStatus = PAID
Expected (per booking-flow.md §Events Payment publish)
Payment publish PaymentCaptured →
Booking listener: UPDATE booking.depositStatus = 'PAID'
Reality (P1-9 shipped 2026-04-24)
OnPaymentStateProjectionListener (in core/booking/) subscribes all 8 Payment events (Initiated/Authorized/Captured/PartiallyRefunded/Refunded/Voided/Failed/Expired) và map sang Booking.depositStatus per table dưới. Migration 20260424085654 thêm Booking.depositStatus TEXT + backfill từ DISTINCT ON latest DEPOSIT-intent Payment per booking. Update qua updateMany với WHERE id, tenantId, NOT depositStatus — cross-tenant guard + skip-if-unchanged optimization, idempotent với outbox redelivery.
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
- P1-9 listener shipped 2026-04-24 —
OnPaymentStateProjectionListener8 events → 8 states + 13 unit tests -
depositStatusstring type: chưa enum hóa trong schema → Prisma enum migration (P2-8, deferp1-9-deposit-status-enum) - Admin UI badge deposit status shipped —
PaymentStatusBadgeshared component dùng trên BookingList + BookingPaymentSummary (Track C4.1+C4.2)
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 (P1-10 shipped 2026-04-24)
OnPaymentNotificationListener (in core/booking/) subscribes PaymentRefunded / PaymentPartiallyRefunded / PaymentVoided → enqueue NotificationService SMS với template Norwegian (salon name + booking date + amount via toLocaleString('nb-NO')). Phone resolution: linked Customer.phone → fallback booking snapshot customerPhone → silent drop với debug log nếu cả 2 null. Cross-tenant safe via findFirst({ where: { id, tenantId } }). Queue failure swallowed (fire-and-forget).
Checklist
- Payment command emit event
- P1-9 shipped —
depositStatusprojection update tự động viaOnPaymentStateProjectionListener - P1-10 shipped — refund / partial-refund / void SMS notification + 13 unit tests
- Admin UI timeline reflect deposit status badge (Track C4.1)
- Email channel cho refund (defer
p1-4-smtp-vendor—LogEmailProviderdefault, cần production SMTP)
6. PaymentVoided
Expected
- Update
booking.depositStatus = VOIDED - Không đổi booking.status (void thường xảy ra khi booking đã CANCELLED)
Reality (P1-9 + P1-10 shipped 2026-04-24)
Tương tự Refunded — OnPaymentStateProjectionListener map PaymentVoided → depositStatus = VOIDED; OnPaymentNotificationListener gửi SMS "Auth-hold released" template Norwegian.
Checklist
- Listener update depositStatus (P1-9)
- Admin UI reflect via
PaymentStatusBadge - Customer notification (P1-10)
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
- autoConfirm + depositEnabled combo blocked at settings save time (P1-5, shipped 2026-04-24) —
TENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICTon the API + disabled toggle on the UI
Negative path
-
PaymentFailed/Expired→CANCELLEDlistener - Idempotent + cross-tenant
- Unit test
- Distinguish transient vs permanent provider error (P1-4, shipped 2026-04-25) —
PaymentFailureKindenum + listener split + retry-cap (3) + retry endpoint - Retry payment flow (P1-4) —
POST /public/tenants/:slug/bookings/:id/payment/retry(CustomerAuth)
Projection (depositStatus)
-
OnPaymentStateProjectionListener— shipped 2026-04-24 (P1-9) — subscribes all 8 Payment events, writes string mapping from §4, cross-tenant guarded, idempotent -
RETRY_PENDINGprojection on PaymentFailed (P1-4, shipped 2026-04-25) - Enum-hóa
depositStatusfield (P2-8)
Notifications
- Refund + partial refund + void customer SMS (P1-10, shipped 2026-04-24) —
OnPaymentNotificationListener+ 3BOOKING_REFUNDED/BOOKING_PARTIALLY_REFUNDED/BOOKING_VOIDEDtemplates (nb-NO, money formatted) - PaymentFailed retry nudge (P1-4, shipped 2026-04-25) —
OnPaymentFailedRetryNotificationListenerfans out SMS + email with deep-link/account/bookings?retry=<id>. Email channel via newEmailProviderport (LOGimpl ships; SMTP/SendGrid pluggable). - NoShow notification (P2-7)
Admin UI
- Timeline events hiển thị payment lifecycle (phụ thuộc P1-9)
- Badge
depositStatustrên booking card
→ progress/gaps-and-plan.md cho plan.