flows/payment-flow.md

Payment — Scenarios (End-to-End Flows)

Đọc payment-fundamentals.md trước (intents, state machine, coupling, payable total, event/error reference). Doc này tập trung vào 20 scenarios end-to-end.

Notation tóm tắt (chi tiết ở fundamentals): C = Customer, FE = Next.js, API = NestJS, PC = Payment Context, BC = Booking Context, LC = Loyalty, OB = Outbox, PR = Provider (Bambora), DB = Database.


Flow 1: Initiate Deposit (Happy Path, Auto Capture)

Customer book appointment, tenant có depositEnabled = true, depositType = percentage, depositValue = 20.

C          FE         API/BC        API/PC       DB              PR
│          │            │             │           │               │
│─book──▶ │            │             │           │               │
│          │─POST /bookings ─▶      │             │               │
│          │            │─ create booking (status=PENDING)─▶ DB   │
│          │            │             │          ◀──────         │
│          │            │─ publish BookingCreated event ─▶ OutboxDB │
│          │            │             │          ◀──────         │
│          │            │─ respond { bookingId, requiresPayment }│
│          │◀──────────│             │           │               │
│          │            │             │           │               │
│          │            │  [OutboxPublisher async] │               │
│          │            │─ poll → BookingCreated → EventBus ─▶ OnBookingCreatedListener │
│          │            │             │             │             │
│          │            │             │    [PC] ResolveFeePolicy: deposit = bookingTotal × 20% │
│          │            │             │    CreatePaymentConfigLookup(tenantId, active) │
│          │            │             │    DecryptCredentials │   │
│          │            │             │    Payment.initiate() → INITIATED │
│          │            │             │    provider.createSession({amount, captureMode:AUTO, urls, idempotencyKey}) │
│          │            │             │             │             │
│          │            │             │──────── POST /sessions ─────▶│
│          │            │             │             │             │ verify auth
│          │            │             │             │             │ return { sessionId, redirectUrl, transactionId }
│          │            │             │◀──────── 200 JSON ──────────│
│          │            │             │             │             │
│          │            │             │    Payment.authorize(providerRef, at, expiresAt) (pending webhook confirm) │
│          │            │             │    SAVE: payments row + payment_events + domain_event_outbox │
│          │            │             │    → TX commit                │
│          │            │             │             │             │
│          │ [Poll FE for paymentRedirectUrl via useQuery OR WS push] │
│          │◀── {redirectUrl} ──     │             │             │
│◀─ redirect ─│            │             │             │             │
│            │            │             │             │             │
│─ browse to PR hosted checkout ─────────────────────────────────▶│
│            │            │             │             │             │ show card form
│─ enter card ────────────────────────────────────────────────▶│
│            │            │             │             │             │ authorize + capture (auto mode)
│◀──────── redirect to successUrl ──────────────────────────────│
│            │            │             │             │             │
│─ GET successUrl on FE ─▶│            │             │             │
│            │            │             │             │             │
│            │ [concurrently] PR→API webhook POST /webhooks/payments/bambora/:tenantId │
│            │            │             │             │             │
│            │            │─ signature verify ─▶ ok                 │
│            │            │─ INSERT payment_webhook_inbox (UNIQUE on provider_event_id) │
│            │            │    → if conflict: skip (dedup)          │
│            │            │─ return 200 immediately                 │
│            │            │             │             │             │
│            │  [WebhookInboxProcessor async]  │             │             │
│            │            │─ load payment aggregate ─▶ DB         │
│            │            │             │◀── Payment loaded         │
│            │            │─ apply event: capture(amount, at)      │
│            │            │   status: INITIATED → CAPTURED          │
│            │            │   push PaymentCaptured event            │
│            │            │─ SAVE aggregate: UPDATE + INSERT events + outbox → TX commit │
│            │            │─ UPDATE inbox SET processed_at          │
│            │            │             │             │             │
│            │    [OutboxPublisher] → PaymentCaptured → EventBus    │
│            │            │             │─ OnPaymentCapturedListener (in BC) │
│            │            │             │   UPDATE booking.depositStatus = PAID │
│            │            │             │   publish BookingDepositPaid │
│            │            │             │             │             │
│            │ FE polls GET /bookings/:id or WS → depositStatus=PAID │
│            │◀─ status PAID ─│         │             │             │
│◀─ show "Confirmed" ─│     │             │             │             │

Key points:

  • Provider call happen before sending redirect URL to FE (user cần URL từ Bambora)
  • Payment status temporarily INITIATED after createSession; chỉ CAPTURED (or AUTHORIZED) sau webhook verified
  • Customer redirect chỉ UX; source of truth là webhook
  • Webhook idempotent qua DB unique constraint

Flow 2: Manual Capture — Deposit (DEPOSIT intent, Track D3)

⚠️ DEPRECATED as default (ADR-001, 2026-04-26 — xem architecture/payment-architecture.md §3.1). Default deposit flow giờ là instant capture (Flow 1). Manual capture path vẫn còn trong domain + adapter làm escape hatch cho future "no-show protection" hoặc "premium deposit > 500 NOK" use cases (xem §11.4 architecture). Nội dung dưới giữ nguyên làm reference cho re-enable path.

Đây từng là flow chính của MVP: tenant bật deposit → session MANUAL, Bambora chỉ authorize, capture khi khách thật sự đến.

Trigger sequence:

  1. Authorize — khách điền card xong, webhook "authorized" đến → Payment.authorize()PaymentAuthorized event → listener flip booking PENDING → CONFIRMED.
  2. Capture on Arrived (primary) — staff bấm Arrived trên admin UI → BookingArrived event → PaymentIntegrationService.onBookingArrived → CapturePaymentCommand nếu MANUAL + AUTHORIZED.
  3. Capture on Completed (fallback) — nếu state machine skip Arrived (CONFIRMED → IN_PROGRESS → COMPLETED trực tiếp) → BookingCompleted vẫn capture.
[... same as Flow 1 until provider.createSession ...]

API/PC → PR: POST /checkout/sessions { captureMode: MANUAL,
                                        ❗ instantcaptureamount OMITTED }
# Bambora quirk: presence of the field (bất kể value) = capture on auth.
# Chỉ set khi AUTO.

PR: authorize only (7-day hold on card)
PR → GET webhook /webhooks/payments/bambora/:tenantId (MD5 verify)

ProcessWebhookInboxService:
  Payment.authorize(providerRef, authorizedAt, expiresAt=auth+7d)
    → status AUTHORIZED + PaymentAuthorized event
  save aggregate

[outbox] PaymentAuthorized → BC
  OnPaymentAuthorizedListener → booking PENDING → CONFIRMED
  publish BookingConfirmed

[Staff clicks Arrived on admin UI]
BC: Booking.updateStatus(ARRIVED) → publish BookingArrived
[outbox] BookingArrived → PC.onBookingArrived
  if Payment.status === AUTHORIZED && captureMode === MANUAL:
    dispatch CapturePaymentCommand(paymentId, full amount)
    provider.capture(transactionId, amount, idempotencyKey)
    Payment.capture(amount, now) → status CAPTURED + PaymentCaptured event
    save

[If Arrived skipped — state machine allows CONFIRMED → IN_PROGRESS directly]
BC: Booking → COMPLETED → publish BookingCompleted
[outbox] BookingCompleted → PC.onBookingCompleted
  fallback capture với same guard (idempotent: already-CAPTURED → no-op)

Lịch sử: capture ban đầu chạy trên BookingConfirmed, nhưng PaymentAuthorized listener flip PENDING → CONFIRMED trong vài giây sau khi khách authorize → cửa sổ Void còn lại ~0. Dời sang BookingArrived (Track D3, 2026-04-21) → match industry (Booksy / Timely / Vagaro / Phorest).


Flow 3: Customer Cancels in Window → Full Refund (auto mode)

Tenant có cancellationHours = 24. Customer cancels 30h trước booking.

C ─ cancel ─▶ FE ─ POST /bookings/:id/cancel ─▶ API/BC

API/BC:
  Booking.cancel(at, reason) → status CANCELLED
  publish BookingCancelled event
  save
  respond 200

[async] OutboxPublisher → BookingCancelled → OnBookingCancelledListener (in PC)

OnBookingCancelledListener:
  load Payment by bookingId
  decision = CancellationRefundPolicy.decide({
    booking: {startTime, cancelledAt},
    tenant: {cancellationHours: 24},
    payment: {status: CAPTURED, capturedAmount: 200 NOK}
  })
  → decision = FULL_REFUND

  dispatch RefundPaymentCommand(paymentId, amount=200 NOK, reason="customer cancellation in window")

RefundHandler:
  load Payment
  decrypt credentials
  provider.refund({transactionId, amount, idempotencyKey})
    → PR return { refundId, status: 'processed' }
  Payment.refund(amount, reason, now) → status REFUNDED
  save

[outbox] → PaymentRefunded → OnPaymentRefundedListener (in BC)
  UPDATE booking.depositStatus = REFUNDED
  send notification email customer

Provider timing: Bambora refund lên card 5-10 business days. Chúng ta return ngay trạng thái "REFUNDED" sau API ack; nếu PR gửi webhook "refund.completed" thì update timestamp bổ sung.


Flow 4: Customer Cancels Out of Window → Forfeit (auto mode, no action)

Tenant có cancellationHours = 24. Customer cancels 10h trước booking.

[... BookingCancelled published ...]

OnBookingCancelledListener:
  decision = CancellationRefundPolicy.decide({...})
  → decision = NO_ACTION (out of window, salon keeps deposit as cancellation fee)

  NO command dispatched. Payment stays CAPTURED.
  Log: {action: 'NO_ACTION', reason: 'out of cancellation window'}

BC updates booking.cancellationFee = 200 NOK
       booking.depositStatus = FORFEIT (informational)

Flow 5: Customer No-Show (auto mode)

Staff marks booking as NO_SHOW từ admin UI.

Staff → AdminUI → POST /bookings/:id/status/NO_SHOW ─▶ API/BC

API/BC:
  Booking.markNoShow(at) → status NO_SHOW
  publish BookingMarkedNoShow
  save

[outbox] → OnBookingMarkedNoShowListener (PC)

OnBookingMarkedNoShowListener:
  load Payment
  if status === CAPTURED (auto mode):
    NO_ACTION, payment stays as is (salon keeps as no-show fee)
    log "no-show, deposit retained"
  if status === AUTHORIZED (manual mode):
    dispatch CapturePaymentCommand (forfeit = capture full)

Flow 6: Salon Cancels (Full Refund Required)

Owner cancel booking (staff sick, emergency).

Owner → AdminUI → POST /bookings/:id/cancel { bySalon: true, reason: "..." } ─▶ API/BC

API/BC:
  Booking.cancelBySalon(at, reason) → status CANCELLED_BY_SALON
  publish BookingCancelledBySalon event
  save

[outbox] → OnBookingCancelledBySalonListener (PC)

OnBookingCancelledBySalonListener:
  decision = FULL_REFUND (always when salon cancels, consumer protection)
  if status === CAPTURED → RefundPaymentCommand(full amount)
  if status === AUTHORIZED → VoidPaymentCommand
  [state transitions as before]

Flow 7: Provider Webhook Signature Failure (Attack or Config Drift)

Attacker → POST /webhooks/payments/bambora/:tenantId (forged payload, invalid signature)

WebhookController:
  load tenant config → decrypt secret
  compute HMAC(raw body, secret)
  compare with header signature → MISMATCH

  log.warn({ event: 'webhook_signature_fail', tenantId, ip, pathPayload... })
  emit metric payment_webhook_verify_fail_total
  return 401

[If repeated: alert triggers security team review]

Không có write, không process, không ack — attacker không thu thập info.


Flow 8: Provider Outage during createSession

API/PC → provider.createSession → Bambora returns 503 / timeout

BamboraAdapter retry strategy:
  attempt 1: wait 500ms
  attempt 2: wait 1s
  attempt 3: wait 2s
  all fail → throw ProviderUnavailableError

InitiatePaymentHandler catches:
  Payment.markFailed('PROVIDER_UNAVAILABLE', 'Bambora service unavailable',
                     kind: TRANSIENT, now)
  save (status FAILED)
  publish PaymentFailed { kind: TRANSIENT }

[outbox] → OnPaymentSettledNegativeListener (BC, P1-4)
  TRANSIENT → never cancel booking (provider-health, not customer-card)
  → booking.depositStatus = RETRY_PENDING

Customer can retry sau khi provider lên lại. P1-4: TRANSIENT failures không bao giờ cancel booking; PERMANENT failures count toward 3-attempt cap (PAYMENT_RETRY_CAP).


Flow 9: Webhook Arrives Before provider.createSession Returns (Race)

Rare: provider webhook faster than API response.

API/PC sends createSession → PR
PR processes payment, webhook posts to API
WebhookController receives: INSERT inbox OK, process
  load Payment by providerTransactionId → NOT FOUND (not saved yet!)
  strategy: retry process with backoff
  or: store webhook, apply after Payment record exists

solution:
  WebhookInboxProcessor marks webhook as "pending_payment_record"
  retries every 5s, up to 5 min
  after timeout, alerts ops

Alternative solution (preferred): write Payment với status INITIATED + providerTransactionId
  BEFORE calling provider? No, we don't have transactionId yet.
  → accept race, rely on retry logic

Flow 10: Admin Partial Refund

Salon refunds partial amount (e.g., service 50% complete).

Owner → AdminUI → booking detail → "Refund 50%" button
FE → POST /payments/:paymentId/refund { amount: 10000, reason: "..." } → API

API PaymentController:
  authz check: role === OWNER
  dispatch RefundPaymentCommand(paymentId, amount=10000, reason)

RefundHandler:
  load Payment (status CAPTURED, capturedAmount=20000)
  validate: amount <= capturedAmount - refundedAmount ✓
  provider.refund(transactionId, 10000, idempotencyKey)
  Payment.refund(amount, reason, now) → status PARTIALLY_REFUNDED (refundedAmount=10000)
  save

[outbox] → PaymentPartiallyRefunded → BC
  booking.depositStatus = PARTIALLY_REFUNDED (metadata: refunded 10000 / 20000)

Flow 11: Authorization Expiry Sweep (BullMQ repeatable 15 min)

Cron repeatable payment-expiry:sweep dọn các Payment stuck quá expiresAt (Bambora auth hold = 7 ngày). Áp dụng cho cả INITIATED (khách abandon) lẫn AUTHORIZED (hold lapse).

[BullMQ job fires every 15 min → AuthorizationExpiryService.sweepAndExpire]

batch = payments.findExpirable(now, PAYMENT_EXPIRY_SWEEP_BATCH_SIZE)
         // status ∈ {INITIATED, AUTHORIZED} AND expiresAt ≤ now

FOR EACH payment:
  if status === AUTHORIZED && has transactionId && provider.capabilities.supportsVoid:
    try provider.void(transactionId, reason=AUTHORIZATION_EXPIRED,
                      idempotencyKey=`expire-${paymentId}-${uuid}`)
    on error: log + metric payment_expiry_void_failed_total{provider_key}
               (best-effort — authorization already lapsed, hold sẽ tự release)

  payment.markExpired(now)   // status → EXPIRED + PaymentExpired event
                              // payload: { bookingId, expiredAt }
  payments.save(payment)     // dual-write row + outbox in tx
  metric payment_expiry_swept_total{provider_key, from_status}

[outbox] PaymentExpired → EventBus
  → OnPaymentSettledNegativeListener (in BC, subscribes Failed + Expired)
    load booking
    if booking.status === PENDING:
      bookingService.updateStatus(CANCELLED, role: SYSTEM)
        // slot mở lại cho khách khác
    if booking.status === CONFIRMED (P1-11):
      bookingService.updateStatus(CANCELLED, role: SYSTEM,
                                  { reason: 'AUTHORIZATION_EXPIRED' })
        // hold đã lapsed, salon không có tiền — auto cancel
    else:
      no-op (ARRIVED/IN_PROGRESS/COMPLETED — admin đã act)

Contract invariantPayment.markExpired PHẢI gắn bookingId vào payload. Listener tìm booking qua payload.bookingId; mất field này → silent no-op. Locked bởi test ở authorization-expiry.service.spec.ts (2026-04-21).

Per-payment isolation — một payment throw không abort batch (try/catch + metric skipped). Idempotent: re-run không double-void (findExpirable filter non-terminal).


Flow 12: Credentials Rotation (Admin Config Update)

Owner rotate Bambora API secret.

Owner → AdminUI → Settings → Payments → Bambora → "Update credentials"
FE → PATCH /admin/payment-configs/bambora { apiKey: "new_key", merchantNumber: "..." }

API PaymentConfigController:
  authz: OWNER
  load PaymentConfig(tenantId, BAMBORA)
  config.rotateCredentials(newCredentials, cipher)
    → re-encrypt với current master key
    → keyVersion stays same (master key unchanged)
    → if master key rotated: new credentials use new version
  save

  [immediate health check]
  provider.healthCheck(newCredentials) → ok / fail
  save lastHealthCheckAt, lastHealthCheckStatus

  return 200 { health: 'ok' } or 422 { health: 'fail', reason: '...' }

Flow 13: Reconciliation Job (Daily Sanity Check)

Cron daily ReconciliationJob: fetch provider's payment list, compare with our DB.

FOR each tenant với active config:
  decrypt credentials
  provider.listPayments({since: yesterday}) → provider snapshot

  FOR each external record:
    find local Payment by providerTransactionId
    if not found: alert "orphaned_provider_payment" (investigate)
    if status mismatch:
      log discrepancy
      if provider says CAPTURED but local INITIATED: fetchStatus + apply
      if provider says REFUNDED but local CAPTURED: apply refund event

Handle webhook loss / missed events. Runs quiet time (3 AM local). Status: chưa verify implemented (P2-13).


Flow 14: Customer Retry after FAILED (P1-4 cap, retry endpoint)

Previous attempt: Payment(P1, status=FAILED, kind=PERMANENT) — failedCount=1
Customer clicks "Retry payment" (yellow CTA on /account/bookings)

FE → POST /public/tenants/:slug/bookings/:id/payment/retry → API
     (CustomerAuth, validate booking PENDING + depositStatus=RETRY_PENDING + ownership + failedCount < cap)

InitiatePaymentHandler (mint fresh):
  idempotencyKey = `bk-${bookingId}-retry-${attempt}` // never reuse P1 key
  reuse original Money + intent + captureMode (no settings re-derivation)
  metadata = { retryOf: P1.id, attempt }
  → P2 INITIATED, fresh checkoutUrl
  freshest Payment by updatedAt → FE redirect to it

Customer redirect to Bambora → fresh card form
  if SUCCESS → P2 AUTHORIZED → booking PENDING → CONFIRMED (Flow 2)
  if FAIL again (PERMANENT) → failedCount++=2 → still under cap → keep PENDING + RETRY_PENDING
  if FAIL 3rd time → cap reached → OnPaymentSettledNegativeListener cancel booking
                     reason='PAYMENT_RETRY_EXHAUSTED'

Email deep-link `/account/bookings?retry=<id>` from `OnPaymentFailedRetryNotificationListener`
  auto-fire mutation on landing, strip `?retry=` so refresh không churn (useRef dedup)

Never retry same idempotencyKey — provider would return cached original response (still failed). Always new key for new attempt. TRANSIENT failures never count toward cap (provider-health, not customer-card).


Flow 15: Outbox Publisher Fail + Retry

OutboxPublisher polls → finds row (unpublished)
eventBus.publish(event) → listener throws (DB down in listener context)
catch: UPDATE outbox SET attempts = attempts + 1, last_error = '...'
publishedAt still NULL

Next poll:
  WHERE published_at IS NULL AND attempts < 10 AND
        (last_attempt_at IS NULL OR last_attempt_at < now() - backoff)
  pick up same row, retry

Backoff schedule:
  attempt 1: immediate
  attempt 2: 30s after
  attempt 3: 2min
  attempt 4: 10min
  attempt 5-9: 1h each
  attempt 10: abandon → alert

After 10 attempts: row moves to "dead letter" state (visible trong admin ops UI — P2-12 chưa ship)
  ops can manually retrigger or mark resolved

Flow 16: Tenant Disables Payment (temporarily)

Owner toggles isActive = false cho Bambora config.

Owner → Settings → Payments → Bambora → toggle off
FE → PATCH /admin/payment-configs/bambora { isActive: false }

API:
  config.deactivate()
  save

Next booking với depositEnabled:
  OnBookingCreatedListener → lookup active config → NOT FOUND
  throw ProviderNotConfiguredError
  Payment.markFailed('NO_ACTIVE_PROVIDER', ..., now)
  save
  BC receives PaymentFailed → booking stays PENDING với notice
  FE shows "Deposit unavailable, please contact salon"

Alternative: if depositEnabled=true + no provider → BLOCK booking creation at BC validation?
Currently: allow booking without deposit, owner pay manually later.
Configurable per tenant.

Flow 17: End-to-End Happy Path — Deposit + Arrive + Remaining + Loyalty (Condensed)

Reference cho scenario phổ biến nhất của MVP: khách login, redeem stamp card (FREE_SERVICE), book 1 dịch vụ có deposit 30%, đến salon đúng giờ, trả nốt.

 1. [Tenant setup]     Owner configs Bambora (isActive), deposit 30%, loyalty stamp card active.
 2. [Lead-time]        maxBookingDaysInAdvance = 14. Front+back enforce.
 3. [Customer login]   Google OAuth → customer JWT → TenantCustomer row resolved.
 4. [Customer books]   POST /public/tenants/:slug/bookings {
                         items: [...], redemption: { cardId, selectedServiceItemId? }
                       }
 5. [Booking tx]       validate redemption → computeLoyaltyDiscount → reset stamps
                       → LoyaltyRedemption(RESERVED) → booking {discountAmount,
                       appliedRedemptionId, payableTotal = rawTotal − discount}
                       → publish BookingCreated (captureMode=MANUAL, amount = payableTotal×30%)
 6. [Outbox]           BookingCreated → PC.InitiatePayment → PR.createSession(MANUAL)
                       → Payment.INITIATED + providerRef, redirectUrl in metadata
 7. [Customer pays]    Redirect to Bambora hosted page, card → authorize (hold 7d)
 8. [Webhook]          Bambora GET /webhooks/payments/bambora/:tenantId (MD5 verify)
                       → inbox insert (dedup) → ProcessWebhookInboxService
                       → Payment.authorize → AUTHORIZED + PaymentAuthorized event
 9. [Outbox]           PaymentAuthorized → BC.OnPaymentAuthorizedListener
                       → booking PENDING → CONFIRMED → publish BookingConfirmed
10. [Polling lands]    FE `/bookings/:id/payment/return` sees AUTHORIZED → "Deposit secured"
11. [Customer arrives] Staff clicks Arrived → BookingArrived
12. [Outbox]           BookingArrived → PC.onBookingArrived → CapturePaymentCommand
                       → provider.capture → Payment.CAPTURED + PaymentCaptured event
13. [Owner charges     Admin "Krev resterende" → POST /admin/payments/remaining
    remainder via QR]  → InitiateRemainingPaymentCommand (AUTO)
                       remaining = payableTotal − captured = payableTotal × 70%
                       → new Payment(REMAINING_PAYMENT, AUTO) → redirectUrl
14. [QR scan + pay]    Customer phone → Bambora → authorize + auto capture
                       → webhook → Payment.CAPTURED (second Payment row)
15. [Completed]        Staff marks Completed → BookingCompleted
16. [Outbox]           BookingCompleted →
                       - PC.onBookingCompleted: idempotent capture no-op (đã CAPTURED)
                       - LC.OnBookingCompletedListener:
                           LoyaltyRedemption RESERVED → CONSUMED, redeemedAt=now
                           autoStamp + autoEarnPoints chạy trên payableTotal
                       - TenantCustomer listener: visitCount++, lastVisit, totalSpent
17. [Audit]            payment_events + booking_audit_logs + loyalty_stamps + outbox

Đơn giản hơn — full prepay (khi triển khai FULL_PAYMENT intent sau này): bỏ bước 13–14, Payment chuyển thẳng AUTO capture ở bước 8, không có hold 7 ngày → không dính Flow 11.


Flow 18: Remaining Payment via In-Salon QR (Track E1)

Owner collects the balance (total − already captured) sau khi service kết thúc. Thay vì gửi link via SMS/email, admin UI render QR code customer scan tại chỗ — UX phổ biến ở Nordic salons (ai cũng có Vipps/BankID trên phone).

Preconditions:

  • Booking tồn tại + thuộc tenant (handler validate qua BookingLookupPort.getSummary). Không còn status guard — owner thu tiền ở mọi state: pre-service (PENDING/CONFIRMED), trong service (ARRIVED/IN_PROGRESS), post-service (COMPLETED), cancellation/no-show fee (CANCELLED/NO_SHOW). Booking-state validation thuộc booking context; payment context chỉ ghi nhận tiền.
  • remaining = booking.total − Σ capturedAmount + Σ refundedAmount > 0
  • Tenant có active PaymentConfig (cùng provider với deposit)
Owner      FE-admin       API/PC                DB                    Customer phone      PR
 │            │              │                    │                         │                │
 │ click      │              │                    │                         │                │
 │ "Collect   │              │                    │                         │                │
 │  remaining"│              │                    │                         │                │
 │──────────▶│              │                    │                         │                │
 │            │── POST /admin/payments/remaining ─▶                         │                │
 │            │              │ InitiateRemainingPaymentHandler              │                │
 │            │              │  ├─ bookings.getSummary (tenant-scoped)  ──▶ │                │
 │            │              │  │  ◀── { status, total, currency, urls }   │                │
 │            │              │  ├─ payments.findByBookingId          ──▶    │                │
 │            │              │  │  ◀── [deposit payment list]              │                │
 │            │              │  ├─ compute remaining = total − paid          │                │
 │            │              │  ├─ idempotency-by-intent: reuse if unexpired │                │
 │            │              │  │          INITIATED REMAINING_PAYMENT      │                │
 │            │              │  └─ delegate to InitiatePaymentHandler       │                │
 │            │              │        ├─ PR.createSession (AUTO capture)  ──┼──────────────▶ │
 │            │              │        │                                     │                │
 │            │              │        │  ◀── { providerSessionId, redirectUrl, expiresAt }   │
 │            │              │        └─ Payment row + outbox      ──▶      │                │
 │            │  ◀──── { paymentId, redirectUrl, amount, expiresAt } ─── 200 OK              │
 │            │              │                    │                         │                │
 │            │ render QR (qrcode.react, 240 px)                            │                │
 │            │ start polling GET /admin/payments/:id every 2 s             │                │
 │            │              │                    │                         │                │
 │            │              │                    │     (customer scans)   ◀│                │
 │            │              │                    │                         │── browser ──▶ PR (Bambora Checkout)
 │            │              │                    │                         │   (Vipps / BankID / kort)
 │            │              │                    │                         │  ◀── AUTHORIZED + auto CAPTURE
 │            │              │                    │                              ── GET webhook ─▶ API /api/webhooks/payments/bambora/{tenantId}
 │            │              │ ProcessWebhookInboxService → Payment.capture  │                │
 │            │              │        → event CAPTURED + outbox              │                │
 │            │              │                    │                         │                │
 │            │◀── poll tick: { status: CAPTURED, capturedAmount: 800_00, ... } ──            │
 │            │ step = 'success'                                            │                │
 │            │ auto-close modal sau 2s, invalidate bookings + payments queries
 │            │              │                    │                         │                │

Frontend step machine:

INPUT ── submit ──▶ QR ── poll.status ∈ {CAPTURED, AUTHORIZED} ──▶ SUCCESS ── 2s timeout ──▶ CLOSE
  │                  │   ── poll.status ∈ {FAILED, EXPIRED}   ──▶ (error banner in QR step)
  │                  │
  │                  └── owner clicks Close ─────────────────────▶ CLOSE (Payment remains INITIATED; reuse next open)
  │
  └── owner clicks Cancel ─▶ CLOSE (no Payment created yet)

Key decisions:

  • captureMode = AUTO for REMAINING_PAYMENT (no hold/capture split; money arrives once).
  • Idempotency-by-intent: nếu owner đóng + reopen, handler tìm thấy unexpired INITIATED REMAINING_PAYMENT row → return same redirectUrl → no Payment zombie stack.
  • Close ≠ void: customer có thể vẫn đang scan. Owner void explicit từ Payment detail drawer khi cần (customer walked away without paying).
  • No SMS fallback in E1: deferred. Khi shipped, embed same redirectUrl trong message.

Error paths:

  • PAYMENT_BOOKING_NOT_FOUND — stale bookingId hoặc cross-tenant, 404 visible trong modal.
  • PAYMENT_INVALID_BOOKING_STATE — legacy code, no longer thrown after the status guard was removed.
  • PAYMENT_NO_REMAINING_AMOUNT — đã pay full; CTA hide client-side nhưng đây là server-side backstop.
  • PAYMENT_REMAINING_AMOUNT_EXCEEDED — owner edit amount above remaining; Zod catch client-side.
  • Polling thấy FAILED / EXPIRED — FE show message, không close, owner có thể reopen.

Flow 18.1: Manual Payment Record (cash drawer / standalone terminal)

Salon thu tiền bên ngoài PSP — khách trả cash tại quầy, hoặc quẹt qua máy POS rời (Verifone, Bambora POS) — admin vẫn cần ghi nhận vào ledger để booking phản ánh đúng số tiền đã thu. Endpoint POST /admin/payments/manual skip toàn bộ provider session: tạo Payment row, transition INITIATED → CAPTURED ngay trong cùng 1 domain transaction, attribute người ghi nhận qua metadata.recordedByUserId.

Preconditions:

  • Booking tồn tại + thuộc tenant. Không có status guard (cùng lý do với QR flow).
  • remaining = total − earmarked > 0 (earmarked formula trùng QR flow).
  • method ∈ {CASH, TERMINAL} → mapping MANUAL_CASH / MANUAL_TERMINAL (Prisma enum + ProviderKey).
  • recordedByUserId lấy từ RequestUser.userId (JWT) — staff/owner đang đăng nhập.
Owner         FE-admin                 API/PC                                   DB
 │              │                       │                                        │
 │ pick "Cash"  │                       │                                        │
 │ in modal     │                       │                                        │
 │────────────▶│ ConfirmDialog          │                                        │
 │   ◀── confirm or cancel              │                                        │
 │              │── POST /admin/payments/manual { bookingId, method, amount,     │
 │              │     idempotencyKey, note? } ───────▶                           │
 │              │                       │ RecordManualPaymentHandler             │
 │              │                       │  ├─ guard: isManualProvider, intent    │
 │              │                       │  ├─ findByIdempotencyKey ──▶ replay-safe│
 │              │                       │  ├─ bookings.getSummary ──▶ tenant scope│
 │              │                       │  ├─ findByBookingId ──▶ remaining math │
 │              │                       │  ├─ Payment.initiate (synthetic        │
 │              │                       │  │      transactionId = `manual-${id}`)│
 │              │                       │  ├─ Payment.capture (status=CAPTURED)  │
 │              │                       │  └─ payments.save ───────────▶ DB      │
 │              │  ◀──── { paymentId, amount, alreadyExists } ─── 201 Created    │
 │              │ invalidate booking + payment caches                            │
 │              │ toast "Recorded {amount}"                                      │
 │              │ close modal                                                    │

Listeners:

  • OnPaymentEmailListener (Captured) — gửi PAYMENT_RECEIVED email cho customer (method = MANUAL_CASH / MANUAL_TERMINAL).
  • OnPaymentAdminNotificationListener (Captured) — admin notification record được tạo bình thường.

Refund: chạy qua RefundPaymentHandler như bất kỳ row CAPTURED nào. Hệ thống ghi nhận refund record; staff phải tự trả lại tiền mặt / void terminal transaction ngoài hệ thống.

Lý do tách khỏi InitiateRemainingPaymentHandler: thân handler khác hẳn (no PSP, no session, no idempotency-by-intent QR-style) và forceNewSession semantics không có ý nghĩa ở đây. Tách thành command/handler riêng giữ guard nhẹ và tránh if (manual) skip(...) rải rác.

PaymentConfig không chấp nhận MANUAL_CASH / MANUAL_TERMINAL (PaymentConfig.create throws). Manual providers không có credentials — không cần per-tenant config.


Flow 19: Loyalty Discount Applied to Booking (Track L, scaffolding L1–L3 shipped)

Khách login + redeem reward → discountAmount giảm Payment.amount ngay tại lúc onBookingCreated listener chạy. Snapshot trên Booking nên tenant đổi reward policy về sau không retroactively rewrite lịch sử.

Preconditions:

  • Khách authenticated (có customerId + TenantCustomer). Guest không redeem được — block tại L3 BookingService.create.
  • Reward khả dụng:
    • Stamp card: progress complete (đủ requiredVisits)
    • Points card: balance ≥ minRedemption (nếu có) và ≥ pointsToRedeem
Customer      FE             API/BC               API/LC            API/PC            DB
  │            │                │                   │                  │                │
  │ pick reward│                │                   │                  │                │
  │  on book   │                │                   │                  │                │
  │──────────▶│                │                   │                  │                │
  │            │─ POST /bookings { redemption: {                       │                │
  │            │     cardId, selectedServiceItemId?, pointsToRedeem? }}│
  │            │─────────────────▶│                  │                  │                │
  │            │                BookingService.create tx:              │                │
  │            │                ├─ validate redeemability ────────────▶│                │
  │            │                │                   │◀── reward spec    │                │
  │            │                ├─ computeLoyaltyDiscount(reward, items, rawTotal, picked)
  │            │                │     → { discountAmount, freeServiceItemId?, eligibleSubtotal }
  │            │                ├─ stamp: reset cycle  /  points: debit ledger
  │            │                ├─ create LoyaltyRedemption(status=RESERVED, bookingId)
  │            │                ├─ set booking.appliedRedemptionId + discountAmount
  │            │                ├─ publish BookingCreated {             │                │
  │            │                │    rawTotal, discountAmount,          │                │
  │            │                │    payableTotal = rawTotal − discount,│                │
  │            │                │    captureMode: DEPOSIT→MANUAL        │                │
  │            │                │ }                                     │                │
  │            │                └─ tx commit                            │                │
  │            │◀── { bookingId, requiresPayment: true, paymentPollUrl }
  │            │                                                       │                │
  │            │  [outbox] BookingCreated → PC.onBookingCreated         │                │
  │            │                                                       ▶ InitiatePaymentHandler
  │            │                                                       │  depositAmount = payableTotal × depositPct/100
  │            │                                                       │  Payment.initiate({amount: depositAmount, …})
  │            │                                                       │  provider.createSession(MANUAL)
  │            │                                                       │  …
  │            │◀── poll → redirectUrl → redirect to Bambora
  │            │                                                       │                │
  │ [service ends, booking → COMPLETED]                                │                │
  │            │                BC: publish BookingCompleted            │                │
  │            │  [outbox] → PC.onBookingCompleted → capture (fallback)
  │            │  [outbox] → LC.OnBookingCompletedListener              │                │
  │            │               if booking.appliedRedemptionId:          │                │
  │            │                 LoyaltyRedemption.status RESERVED → CONSUMED
  │            │                 redeemedAt = now                       │                │

Cancel trước capture → LC listener cho BookingCancelled khôi phục:

  • Stamp: re-insert stamp rows cho cycle (reset ngược)
  • Points: reverse transaction (REDEEM → credit back với PointTransactionType.ADJUST hoặc dedicated REVERSE)
  • Redemption.status RESERVED → CANCELLED + cancelledAt

Cancel sau capture → forfeit (match payment forfeit policy): status=CANCELLED nhưng KHÔNG restore stamp/points. Owner có thể refund manual nếu muốn.

Phased rollout:

  • L1 ✅ schema groundwork (migration + enum + backfill)
  • L2 ✅ computeLoyaltyDiscount pure helper (19 tests)
  • L3 ✅ BookingService.create transactional wiring
  • L4 ⏳ lifecycle listeners (P1-6)
  • L5 ⏳ public API + customer UI
  • L6 ⏳ admin UX + E2E

Flow 20: Lead-Time Cap (maxBookingDaysInAdvance, Track D3 + P1-11 hard cap)

Tenant cấu hình cap N ngày (default 30, range 1–365) — giới hạn cả public và admin. Admin không được override (salon không tự book hộ khách ra quá xa, match industry).

┌────────────────┬────────────────────────────────────────────────┐
│ Layer          │ Enforcement                                   │
├────────────────┼────────────────────────────────────────────────┤
│ Public FE      │ DateStrip clamp length = min(28, cap + 1)     │
│ (BookingPage)  │ handleSubmit pre-validates daysAhead ≤ cap    │
│                │ → setSubmitError early, no API round-trip     │
├────────────────┼────────────────────────────────────────────────┤
│ Admin FE       │ DateField max = today + cap days              │
│ (BookingDrawer)│                                               │
├────────────────┼────────────────────────────────────────────────┤
│ API (both)     │ validateBookingLeadTime(settings, startTime)   │
│                │ `daysAhead > cap` → throw                     │
│                │ BOOKING_TOO_FAR_IN_ADVANCE (422)              │
├────────────────┼────────────────────────────────────────────────┤
│ Settings form  │ P1-11 HARD CAP: depositEnabled=true ⇒ cap≤7   │
│ (P1-11)        │ → TENANT_SETTINGS_DEPOSIT_LEAD_TIME_CONFLICT  │
│                │ Bambora auth hold = 7 days; deposit > 7d sẽ   │
│                │ EXPIRED trước booking → P1-11 auto-cancel     │
└────────────────┴────────────────────────────────────────────────┘

Tương tác với Payment: trước P1-11, nếu cap > 7, booking sau ngày 7 sẽ dính Flow 11 (Auth Expiry Sweep) → silent CONFIRMED-but-no-money. P1-11 đóng path này 2 hướng: (a) settings hard cap chặn combo tại write-time, (b) OnPaymentSettledNegativeListener cũng cancel CONFIRMED khi PaymentExpired (reason=AUTHORIZATION_EXPIRED).


Flow 21: Stable Invoice Page + Always-Fresh PSP Session (Track E2, 2026-04-26)

Mục đích: Tách "URL khách giữ" khỏi "PSP session expiry". Bambora session sống ~15-30 phút; nếu cache URL vào QR/SMS thì khách click sau khi expire sẽ thấy "Sesjonen har utløpt".

URL stable: /b/:slug/bookings/:id/invoice — trang public không bao giờ đổi cho 1 booking.

Lazy session: mỗi click "Pay" trên invoice page mint session mới qua POST /public/.../bookings/:id/checkout-session {intent}.

sequenceDiagram
    participant Customer as 👤 Customer
    participant Invoice as /invoice page
    participant API as Public API
    participant CmdBus as CommandBus
    participant Bambora

    Note over Customer,Bambora: 1. Mở invoice (từ QR admin hoặc SMS)
    Customer->>Invoice: GET /b/:slug/bookings/:id/invoice
    Invoice->>API: GET .../payments (plural)
    API-->>Invoice: payments[] newest-first
    Invoice->>Invoice: deriveNextPayment(status, total, payments)<br/>→ {intent, amount}
    Invoice-->>Customer: BookingTicket + Ledger + "Pay X kr"

    Note over Customer,Bambora: 2. Click Pay → fresh session
    Customer->>Invoice: Click "Pay X kr"
    Invoice->>API: POST .../checkout-session {intent}
    alt intent=DEPOSIT
        API->>CmdBus: InitiatePaymentCommand<br/>(args derived từ row mới nhất + booking summary,<br/> randomUUID idempotencyKey)
    else intent=REMAINING_PAYMENT
        API->>CmdBus: InitiateRemainingPaymentCommand<br/>(forceNewSession=true,<br/> bypass dedup-by-intent + status guards)
    end
    CmdBus->>Bambora: createSession()
    Bambora-->>CmdBus: {checkoutUrl, expiresAt}
    CmdBus-->>API: {paymentId, checkoutUrl}
    API-->>Invoice: {checkoutUrl}
    Invoice->>Customer: window.location.href = checkoutUrl

    Note over Customer,Bambora: 3. Customer pay → return tới invoice
    Customer->>Bambora: Pay
    Bambora-->>Invoice: returnUrl<br/>(/payment/return → redirect /invoice?from=payment-return)
    Invoice->>Invoice: Banner "Verifying" → poll /payments<br/> → flip → Banner "Payment received"

deriveNextPayment rules (xử lý 3 edge case khó từ user feedback):

Booking status Deposit settled? → Pay button
PENDING / CONFIRMED No DEPOSIT, amount = original deposit row's amount (NOT total)
PENDING / CONFIRMED Yes (early-pay path) REMAINING_PAYMENT, amount = outstanding
ARRIVED / IN_PROGRESS / COMPLETED No (owner skipped deposit) REMAINING_PAYMENT, amount = full total
ARRIVED+ Yes REMAINING_PAYMENT, amount = outstanding
Any Outstanding = 0 Button hidden (paid in full)

Edge case 3: owner toggle COMPLETED → PENDING sau khi nhầm → re-evaluate from scratch → DEPOSIT branch lại match (vì status pre-service + deposit chưa settled). User-expected.

forceNewSession flag (InitiateRemainingPaymentInput.forceNewSession?: boolean):

  • Public invoice page → true. Skip 2 guards: (a) "find existing alive INITIATED → return cached" (luôn mint mới vì Bambora có thể invalidate ahead of expiresAt), (b) booking.status ∈ {CONFIRMED, ARRIVED, IN_PROGRESS, COMPLETED} (cho customer self-pay từ PENDING).
  • Admin POST /admin/payments/remainingfalse (default). Giữ idempotent + status guard để 2 admin click không double-charge và không cho collect remaining cho booking PENDING/CANCELLED.

totalPaid field: PublicBookingDetailDto.totalPaid aggregate Σ (capturedAmount − refundedAmount) qua tất cả settled rows. Drives BookingTicket "Paid" line + invoice page Paid summary. Single payment.amount không đủ (sau deposit settled + remaining INITIATED tạo on top, payment.amount của latest row = remaining intent → mis-render).

Ledger collapse: vì mỗi click mint Payment row mới, multiple INITIATED rows cùng intent xếp chồng. PaymentLedger.collapseStaleInitiated(payments) chỉ render row INITIATED mới nhất per intent + tất cả settled rows.

Admin QR migration: DepositCheckoutModal + CollectRemainingModal QR/copy-bar giờ encode ${origin}/b/${slug}/bookings/${bookingId}/invoice (stable) thay vì raw Bambora URL (15-30 phút expiry). Customer scan → invoice page → Pay → fresh session.

Booking-form direct path: BookingPage form vẫn redirect thẳng Bambora qua redirectToCheckout (không qua invoice page) — customer vừa review trên form, redundant. Cancel URL từ Bambora trỏ về /payment/cancelled → "Try again" link tới /invoice (booking đã tạo, không cần re-book).


Liên quan