flows/payment-flow.md

Payment Flow & Scenarios

Companion doc của ../architecture/payment-architecture.md. Đọc architecture trước.

Mô tả end-to-end flows cho mọi scenarios của Payment Context: happy path, cancellation, no-show, failure, reconciliation, admin ops.

Notation: dùng ASCII sequence diagrams cho flow chi tiết, Mermaid cho state / topology. Participants viết tắt:

  • C = Customer browser
  • FE = Frontend (Next.js)
  • API = Backend (NestJS)
  • PC = Payment Context
  • BC = Booking Context
  • LC = Loyalty Context
  • OB = Outbox worker
  • PR = Provider (Bambora)
  • DB = Database

0. Concepts & State Machines

0.1 PaymentIntent

Intent phân loại mục đích của một Payment aggregate. Một booking có thể sinh nhiều Payment qua các intent khác nhau trong đời sống của nó.

Intent Tạo bởi captureMode Chiếm tiền lúc nào
DEPOSIT onBookingCreated nếu depositEnabled MANUAL (hold) Authorize ngay, capture khi BookingArrived (primary) hoặc BookingCompleted (fallback)
FULL_PAYMENT (reserved, chưa dùng — tương lai full-prepay flow) AUTO Authorize + capture đồng thời
REMAINING_PAYMENT Admin bấm "Krev resterende" (Track E1 QR) AUTO Authorize + capture đồng thời

DEPOSIT là intent duy nhất chạy MANUAL. Lý do: muốn giữ khả năng VOID trước khi booking thực sự được consume. Sau khi khách ARRIVED → capture → không còn đường void free.

0.2 Payment State Machine

stateDiagram-v2
    [*] --> INITIATED: initiate()
    INITIATED --> AUTHORIZED: authorize()\n(webhook, MANUAL)
    INITIATED --> CAPTURED: capture()\n(webhook, AUTO)
    INITIATED --> FAILED: markFailed()
    INITIATED --> EXPIRED: markExpired()\n(customer abandoned)
    AUTHORIZED --> CAPTURED: capture()\n(BookingArrived/Completed)
    AUTHORIZED --> VOIDED: void()\n(cancel in window)
    AUTHORIZED --> EXPIRED: markExpired()\n(7-day hold lapsed)
    AUTHORIZED --> FAILED: capture error
    CAPTURED --> PARTIALLY_REFUNDED: refund(partial)
    CAPTURED --> REFUNDED: refund(full)
    PARTIALLY_REFUNDED --> REFUNDED: refund(rest)
    CAPTURED --> [*]
    REFUNDED --> [*]
    PARTIALLY_REFUNDED --> [*]
    VOIDED --> [*]
    FAILED --> [*]
    EXPIRED --> [*]

Terminal states: CAPTURED, PARTIALLY_REFUNDED, REFUNDED, VOIDED, FAILED, EXPIRED. Không có transition nào rời khỏi terminal (trừ partial→full refund).

0.3 Booking ↔ Payment Coupling

sequenceDiagram
    participant BC as Booking Context
    participant OB as Outbox
    participant PC as Payment Context
    participant LC as Loyalty

    Note over BC: Create (with optional redemption)
    BC->>LC: reserve redemption (L3 tx)
    BC->>BC: discountAmount snapshot
    BC->>OB: BookingCreated<br/>(rawTotal, discountAmount,<br/>captureMode=MANUAL for DEPOSIT)
    OB-->>PC: event
    PC->>PC: initiate(payableTotal × depositPct)

    Note over BC: Lifecycle
    BC->>OB: BookingConfirmed (PENDING→CONFIRMED by PaymentAuthorized listener or admin)
    BC->>OB: BookingArrived (ARRIVED = customer checked in)
    OB-->>PC: capture MANUAL + AUTHORIZED
    BC->>OB: BookingCompleted
    OB-->>PC: capture (fallback if Arrived skipped)
    OB-->>LC: consume redemption

    Note over BC: Or cancel
    BC->>OB: BookingCancelled
    OB-->>PC: decide refund/void/forfeit
    OB-->>LC: cancel redemption (pre-capture only)

Chu trình booking chuẩn: PENDING → CONFIRMED → ARRIVED → IN_PROGRESS → COMPLETED. Payment bám vào 3 trigger: Created, Arrived, Completed. Cancelled / NoShow là terminal alternate.

0.4 payableTotal vì sao quan trọng

rawTotal       = Σ BookingItem.price
discountAmount = computeLoyaltyDiscount(...)   // 0 nếu không redeem
payableTotal   = rawTotal − discountAmount

DEPOSIT:   Payment.amount = payableTotal × depositPercent / 100
                                 (hoặc fixed, clamped to payableTotal)
REMAINING: Payment.amount = payableTotal − Σ (captured − refunded)

Deposit tính trên payableTotal (không phải rawTotal) → fair cho khách + match Stripe/Booksy. Loyalty discount được snapshot trên Booking.discountAmount tại lúc create, không re-derived khi Payment gọi lại.


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; only 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)

Đây 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 with exact 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', now)
  save (status FAILED)
  publish PaymentFailed

[outbox] → OnPaymentFailedListener (BC)
  UPDATE booking.depositStatus = PAYMENT_FAILED
  Booking stays PENDING but UI shows "Payment failed, please try again"

FE polls GET /bookings/:id → sees depositStatus=PAYMENT_FAILED
FE shows "Retry payment" button
  → POST /payments/retry { bookingId } → InitiatePaymentCommand (new idempotencyKey)

Customer can retry sau khi provider lên lại. Previous FAILED payment record stays for audit.


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 with 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
    else:
      no-op (CONFIRMED đã trả đủ deposit; 1 lần retry khác fail không wipe booking)

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 with 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 with 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).


Flow 14: Customer Retry after FAILED (new idempotency key)

Previous attempt: Payment(id=P1, status=FAILED)
Customer clicks "Retry" on FE

FE → POST /bookings/:bookingId/pay/retry → API

InitiatePaymentHandler:
  lookup existing payments for booking → found P1 (FAILED)
  policy: create NEW Payment P2 với new idempotencyKey (not retry P1)
  P1 remains for audit
  P2 goes through Flow 1 from start

Never retry same idempotencyKey — provider would return cached original response (still failed). Always new key for new attempt.


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)
  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 with depositEnabled:
  OnBookingCreatedListener → lookup active config → NOT FOUND
  throw ProviderNotConfiguredError
  Payment.markFailed('NO_ACTIVE_PROVIDER', ..., now)
  save
  BC receives PaymentFailed → booking stays PENDING with 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 18: Remaining Payment via In-Salon QR (Track E1)

Owner collects the balance (total − already captured) after the service ends. Instead of sending a link via SMS/email, the admin UI renders a QR code that the customer scans on the spot — common UX in Nordic salons where everyone already has Vipps/BankID on their phone.

Preconditions:

  • Booking status ∈ {ARRIVED, IN_PROGRESS, COMPLETED} (guarded by InitiateRemainingPaymentHandler)
  • remaining = booking.total − Σ capturedAmount + Σ refundedAmount > 0
  • Tenant has an active PaymentConfig (same provider as 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 after 2 s, 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: if the owner closes and reopens, the handler finds the existing unexpired INITIATED REMAINING_PAYMENT row and returns the same redirectUrl — no Payment zombie stack.
  • Close ≠ void: the customer may still be scanning. Owner voids explicitly from the Payment detail drawer when needed (customer walked away without paying).
  • No SMS fallback in E1: deferred. When shipped, it just embeds the same redirectUrl in a message.

Error paths:

  • PAYMENT_BOOKING_NOT_FOUND — stale bookingId or cross-tenant, 404 visible in modal.
  • PAYMENT_INVALID_BOOKING_STATE — booking is PENDING/CONFIRMED/CANCELLED/NO_SHOW; modal blocks with toast.
  • PAYMENT_NO_REMAINING_AMOUNT — already fully paid; CTA is hidden client-side but this is the server-side backstop.
  • PAYMENT_REMAINING_AMOUNT_EXCEEDED — owner edited the amount above remaining; Zod catches it client-side.
  • Polling sees FAILED / EXPIRED — FE shows the respective message without closing, owner can reopen.

Flow 19: Loyalty Discount Applied to Booking (Track L, scaffolding L1–L2 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
  • L5 ⏳ public API + customer UI
  • L6 ⏳ admin UX + E2E

Flow 20: Lead-Time Cap (maxBookingDaysInAdvance, Track D3)

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  │ Warning nếu cap > 7 days vì Bambora auth hold │
│                │ chỉ 7 ngày — deposit sẽ EXPIRED trước khi     │
│                │ booking diễn ra                               │
└────────────────┴────────────────────────────────────────────────┘

Tương tác với Payment: nếu cap > 7, booking sau ngày 7 sẽ dính Flow 11 (Auth Expiry Sweep) → booking CANCELLED tự động. Settings form cảnh báo rõ; tenant muốn cap dài phải chấp nhận rủi ro refund-forgetting hoặc wait for POS integration.


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.


State Transition Table (Payment)

From Event To Command Notes
initiate() INITIATED InitiatePaymentCommand Created, awaiting provider
INITIATED authorize() AUTHORIZED webhook processing Manual mode: stop here till capture
INITIATED capture() CAPTURED webhook processing Auto mode: skip AUTHORIZED
INITIATED markFailed() FAILED webhook / error Retry allowed (new Payment)
AUTHORIZED capture(amount) CAPTURED CapturePaymentCommand Manual capture
AUTHORIZED void() VOIDED VoidPaymentCommand Cancellation in window
AUTHORIZED markExpired() EXPIRED cron Authorization TTL
AUTHORIZED markFailed() FAILED capture error
CAPTURED refund(partial) PARTIALLY_REFUNDED RefundPaymentCommand Partial
CAPTURED refund(full) REFUNDED RefundPaymentCommand Full
PARTIALLY_REFUNDED refund(rest) REFUNDED RefundPaymentCommand
CAPTURED/PARTIALLY_REFUNDED/REFUNDED/VOIDED/FAILED/EXPIRED * * Terminal, no transitions

Cross-Context Event Reference

Event Producer Subscribers Payload (key fields)
BookingCreated Booking Payment (initiate deposit) bookingId, tenantId, rawTotal, discountAmount, payableTotal, startTime, depositAmount, captureMode (MANUAL for DEPOSIT), customer snapshot, returnUrl, cancelUrl, webhookUrl
BookingConfirmed Booking Notification bookingId, confirmedAt, confirmedBy
BookingArrived Booking Payment (capture if MANUAL + AUTHORIZED, primary) bookingId, arrivedAt
BookingCancelled Booking Payment (decide refund/void/forfeit), Loyalty (restore if pre-capture), Notification bookingId, cancelledAt, cancelledBy (CUSTOMER/SALON/SYSTEM), reason, cancellationWindowHours
BookingMarkedNoShow Booking Payment (capture as fee if AUTH; no-op if AUTO+CAPTURED), Stats bookingId, markedAt, markedBy
BookingCompleted Booking Payment (capture fallback), Loyalty (consume redemption + auto-earn), TenantCustomer (metrics) bookingId, completedAt, payableTotal
PaymentInitiated Payment — (audit only) paymentId, bookingId, intent, amount, currency
PaymentAuthorized Payment Booking (PENDING→CONFIRMED) paymentId, bookingId, providerRef, authorizedAt, expiresAt
PaymentCaptured Payment Booking, Notification, Stats paymentId, bookingId, capturedAmount, capturedAt
PaymentVoided Payment Booking, Notification paymentId, bookingId, voidedAt, reason
PaymentRefunded Payment Booking, Notification paymentId, bookingId, refundedAmount, reason
PaymentPartiallyRefunded Payment Booking paymentId, bookingId, refundedAmount, remainingAmount
PaymentFailed Payment Booking (PENDING→CANCELLED via OnPaymentSettledNegativeListener), Notification paymentId, bookingId, failureCode, failureMessage, failedAt
PaymentExpired Payment Booking (PENDING→CANCELLED via same listener), Notification paymentId, bookingId, expiredAt

Error Codes (Domain → i18n keys)

Follow error-codes convention:

Code Message key HTTP Thrown when
PAYMENT_NOT_FOUND errors.payment.notFound 404 Query by id fails
PAYMENT_INVALID_STATE errors.payment.invalidState 409 State transition violates machine
PAYMENT_AMOUNT_EXCEEDED errors.payment.amountExceeded 422 Refund > captured, capture > authorized
PAYMENT_PROVIDER_NOT_CONFIGURED errors.payment.providerNotConfigured 400 No active config for tenant
PAYMENT_PROVIDER_ERROR errors.payment.providerError 502 Provider rejects or errors
PAYMENT_PROVIDER_UNAVAILABLE errors.payment.providerUnavailable 503 Timeout, 5xx after retry
PAYMENT_WEBHOOK_INVALID_SIGNATURE — (server only) 401 HMAC mismatch
PAYMENT_IDEMPOTENCY_CONFLICT errors.payment.idempotencyConflict 409 Same key, different params
PAYMENT_CURRENCY_MISMATCH errors.payment.currencyMismatch 422 Money ops with different currencies
PAYMENT_AUTHORIZATION_EXPIRED errors.payment.authExpired 410 Operating on expired authorization
PAYMENT_REFUND_NOT_SUPPORTED errors.payment.refundNotSupported 422 Provider doesn't support (rare)
PAYMENT_BOOKING_NOT_FOUND errors.PAYMENT_BOOKING_NOT_FOUND 404 Booking missing or cross-tenant on remaining payment init
PAYMENT_INVALID_BOOKING_STATE errors.PAYMENT_INVALID_BOOKING_STATE 409 Booking status not in {ARRIVED, IN_PROGRESS, COMPLETED} when initiating remaining
PAYMENT_NO_REMAINING_AMOUNT errors.PAYMENT_NO_REMAINING_AMOUNT 409 remaining ≤ 0 (booking already paid in full)
PAYMENT_REMAINING_AMOUNT_EXCEEDED errors.PAYMENT_REMAINING_AMOUNT_EXCEEDED 422 command.amount > remaining
BOOKING_TOO_FAR_IN_ADVANCE errors.BOOKING_TOO_FAR_IN_ADVANCE 422 daysAhead > maxBookingDaysInAdvance on create/update (Track D3)
LOYALTY_NO_ITEMS errors.LOYALTY_NO_ITEMS 400 Booking has 0 items when computing discount
LOYALTY_NO_APPLICABLE_ITEMS errors.LOYALTY_NO_APPLICABLE_ITEMS 422 Reward applicableServiceIds matches nothing in cart
LOYALTY_INVALID_REWARD_VALUE errors.LOYALTY_INVALID_REWARD_VALUE 422 rewardValue < 0 (guard against malformed config)
LOYALTY_SERVICE_PICK_REQUIRED errors.LOYALTY_SERVICE_PICK_REQUIRED 400 FREE_SERVICE with >1 eligible item but no selectedServiceItemId
LOYALTY_PICKED_ITEM_NOT_FOUND errors.LOYALTY_PICKED_ITEM_NOT_FOUND 400 selectedServiceItemId not in cart
LOYALTY_PICKED_ITEM_NOT_ELIGIBLE errors.LOYALTY_PICKED_ITEM_NOT_ELIGIBLE 400 Picked item's service not in reward scope
LOYALTY_REDEMPTION_GUEST_NOT_ALLOWED errors.LOYALTY_REDEMPTION_GUEST_NOT_ALLOWED 401 Guest booking cannot redeem (L3)
LOYALTY_REDEMPTION_NOT_AVAILABLE errors.LOYALTY_REDEMPTION_NOT_AVAILABLE 409 Stamp not yet full / points below minRedemption (L3)

Admin UI Pages (planned)

Page Path Purpose
Payment config list /admin/settings?tab=payments Enable/config providers
Payment config form (modal) Enter credentials, test connection
Payment list /admin/payments Search, filter by status, tenant-wide
Payment detail /admin/payments/:id Full lifecycle, events timeline, refund button
Booking payment panel Inside booking detail Inline refund, status, receipt
Webhook log /admin/payments/webhooks Debug, retry, dead letter review
Reconciliation report /admin/payments/reconciliation Discrepancies, orphans

Customer-Facing UI Flow

  1. Booking flow — nếu tenant depositEnabled:
    • Trước submit: show "Depositum: 200 NOK" + link terms
    • After submit → redirect to Bambora
  2. Success page: confirmation with booking ID + receipt download
  3. Cancel page: if user cancels on Bambora → show "Booking pending, pay later from email link"
  4. Email receipt: after capture, email with invoice + payment ref
  5. Refund notification: email khi refund processed + ETA 5-10 days