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)
  • 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=trueP1-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 EmailProvider impl (currently LogEmailProvider only)
  • 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. PaymentCaptureddepositStatus = 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-24OnPaymentStateProjectionListener 8 events → 8 states + 13 unit tests
  • depositStatus string type: chưa enum hóa trong schema → Prisma enum migration (P2-8, defer p1-9-deposit-status-enum)
  • Admin UI badge deposit status shippedPaymentStatusBadge shared 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 shippeddepositStatus projection update tự động via OnPaymentStateProjectionListener
  • 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-vendorLogEmailProvider default, 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 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
  • autoConfirm + depositEnabled combo blocked at settings save time (P1-5, shipped 2026-04-24) — TENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICT on the API + disabled toggle on the UI

Negative path

  • PaymentFailed/ExpiredCANCELLED listener
  • Idempotent + cross-tenant
  • Unit test
  • Distinguish transient vs permanent provider error (P1-4, shipped 2026-04-25) — PaymentFailureKind enum + 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_PENDING projection on PaymentFailed (P1-4, shipped 2026-04-25)
  • Enum-hóa depositStatus field (P2-8)

Notifications

  • Refund + partial refund + void customer SMS (P1-10, shipped 2026-04-24) — OnPaymentNotificationListener + 3 BOOKING_REFUNDED/BOOKING_PARTIALLY_REFUNDED/BOOKING_VOIDED templates (nb-NO, money formatted)
  • PaymentFailed retry nudge (P1-4, shipped 2026-04-25) — OnPaymentFailedRetryNotificationListener fans out SMS + email with deep-link /account/bookings?retry=<id>. Email channel via new EmailProvider port (LOG impl ships; SMTP/SendGrid pluggable).
  • NoShow notification (P2-7)

Admin UI

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

progress/gaps-and-plan.md cho plan.