flows/payment-fundamentals.md

Payment — Fundamentals & Reference

BẮT BUỘC đọc trước payment-flow.md (20 scenarios) và ../architecture/payment-architecture.md (DDD layered architecture).

Doc này tập trung vào concepts (intents, state machine, coupling, payable total) + reference tables (state transition, cross-context event catalog, error codes, admin/customer UI map). Mọi flow chi tiết end-to-end sequence diagram nằm ở payment-flow.md.

Notation chung dùng xuyên suố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

1. Payment Intent

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.


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


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.


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.


5. State Transition Table

From Event To Command Notes
initiate() INITIATED InitiatePaymentCommand Created, awaiting provider
INITIATED authorize() AUTHORIZED webhook processing Manual mode: dừng đây tới khi 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 sweep Authorization TTL (Bambora 7d)
AUTHORIZED markFailed() FAILED capture error
CAPTURED refund(partial) PARTIALLY_REFUNDED RefundPaymentCommand Partial refund
CAPTURED refund(full) REFUNDED RefundPaymentCommand Full refund
PARTIALLY_REFUNDED refund(rest) REFUNDED RefundPaymentCommand
CAPTURED/PARTIALLY_REFUNDED/REFUNDED/VOIDED/FAILED/EXPIRED * * Terminal, no transitions

6. 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 — P1-4: cap 3 retries), Notification paymentId, bookingId, failureCode, failureMessage, kind (TRANSIENT | PERMANENT), failedAt
PaymentExpired Payment Booking (PENDING→CANCELLED, CONFIRMED→CANCELLED via P1-11 listener), Notification paymentId, bookingId, expiredAt

7. Error Codes (Domain → i18n)

Quy ước chung tại api-design.md §error-codes.

Payment domain

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_NO_ACTIVE_CONFIG errors.PAYMENT_NO_ACTIVE_CONFIG 409 No active TenantPaymentConfig for tenant. Thrown by InitiatePaymentHandler.selectConfig whenever a flow tries to charge but the salon has no provider with isActive=true. Filter chain note: also handled by HttpExceptionFilterGlobal as a defence-in-depth — PaymentDomainErrorFilter is module-scoped via APP_FILTER, but the global filter (registered via useGlobalFilters in main.ts) can win on filter resolution and would otherwise wrap this as INTERNAL_ERROR. The global filter has the same PAYMENT_DOMAIN_STATUS map as the dedicated filter so the response shape stays identical regardless of which one catches.
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 với 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 Legacy — no longer thrown. Status guard removed 2026-05-07 (Track E1.1) so owners can collect / record manual payments from any booking state (deposit pre-service, top-up post-COMPLETED, cancellation/no-show fee). Code kept in the filter map for backward compat with old serialised events.
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 → Payment cross-context

Code Message key HTTP Thrown when
BOOKING_TOO_FAR_IN_ADVANCE errors.BOOKING_TOO_FAR_IN_ADVANCE 422 daysAhead > maxBookingDaysInAdvance (Track D3)
BOOKING_DEPOSIT_REQUIRED errors.BOOKING_DEPOSIT_REQUIRED 422 Confirm khi depositEnabled + Payment chưa AUTHORIZED/CAPTURED (P0-2). OWNER/ADMIN có thể force=true + audit reason
BOOKING_CANCELLATION_TOO_LATE errors.BOOKING_CANCELLATION_TOO_LATE 422 Cancel out-of-window. P1-1 trả error này → FE mở OutOfWindowDialog (P1-2)

Loyalty discount

Code Message key HTTP Thrown when
LOYALTY_NO_ITEMS errors.LOYALTY_NO_ITEMS 400 Booking has 0 items khi compute discount
LOYALTY_NO_APPLICABLE_ITEMS errors.LOYALTY_NO_APPLICABLE_ITEMS 422 Reward applicableServiceIds matches nothing
LOYALTY_INVALID_REWARD_VALUE errors.LOYALTY_INVALID_REWARD_VALUE 422 rewardValue < 0 (guard cho config malformed)
LOYALTY_SERVICE_PICK_REQUIRED errors.LOYALTY_SERVICE_PICK_REQUIRED 400 FREE_SERVICE với >1 eligible item nhưng không selectedServiceItemId
LOYALTY_PICKED_ITEM_NOT_FOUND errors.LOYALTY_PICKED_ITEM_NOT_FOUND 400 selectedServiceItemId không có trong cart
LOYALTY_PICKED_ITEM_NOT_ELIGIBLE errors.LOYALTY_PICKED_ITEM_NOT_ELIGIBLE 400 Picked item's service không trong 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 chưa full / points dưới minRedemption (L3)

Tenant settings combo

Code Message key HTTP Thrown when
TENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICT errors.TENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICT 422 autoConfirm=true && depositEnabled=true (P1-5) — silent foot-gun trước fix
TENANT_SETTINGS_DEPOSIT_LEAD_TIME_CONFLICT errors.TENANT_SETTINGS_DEPOSIT_LEAD_TIME_CONFLICT 422 depositEnabled=true && maxBookingDaysInAdvance > 7 (P1-11) — Bambora auth hold cap

8. Admin UI Pages (planned + shipped)

Page Path Trạng thái Mục đích
Payment config list /admin/settings?tab=payment ✅ Shipped (Track D1) Enable/config providers, health-check
Payment config drawer (modal) ✅ Shipped Enter credentials (Bambora 4 fields), test connection
Payment list /admin/payments ✅ Shipped (Track D2) Search, filter status/provider/bookingId/date, paginated
Payment detail drawer inline ✅ Shipped (Track D2) Full lifecycle, events timeline, refund/void/capture buttons
Booking payment panel trong booking detail ✅ Shipped (Track C4.1) Total / Deposit+status badge / Paid / Remaining, failure banner
Collect remaining QR inline modal ✅ Shipped (Track E1) Krev resterende 3-step state machine (input → QR → success)
Webhook log /admin/payments/webhooks ❌ Chưa (P2-12) Debug, retry dead letter
Reconciliation report /admin/payments/reconciliation ❌ Chưa (P2-13) Discrepancies, orphans

8b. Provider-active guard (FE)

Owner có thể vô hiệu hoá payment provider giữa 2 booking — ví dụ tắt Bambora trong /admin/settings?tab=payment. Flow tiếp theo cần biết booking đó đang dùng provider nào và provider đó còn active không trước khi cho phép thao tác PSP, nếu không owner click "Collect remaining" sẽ trúng API 409 PAYMENT_NO_ACTIVE_CONFIG mà không có hint actionable.

Quy tắc

  • Mỗi booking có thể có ≥ 1 Payment row. Provider của booking = latest.provider (Payment có updatedAt lớn nhất, tức là deposit hoặc retry gần nhất).
  • FE cross-check latest.provider với danh sách paymentConfigs (qua usePaymentConfigList) — nếu không có config nào provider === latest.provider && isActive === true → block thao tác PSP-bound.
  • KHÔNG fallback sang provider khác đang active. Customer expectation là "cùng provider thread", và contract của salon với PSP cũng theo từng provider.

Áp dụng tại BookingPaymentSummary (admin booking drawer)

State UI Action
remaining > 0 (any booking status) Nút "Collect remaining · X kr" enabled (brand-500) Owner click → CollectRemainingModal mở. Status guard ở handler đã removed 2026-05-07 — owner thu tiền ở mọi state booking (PENDING, CONFIRMED, ARRIVED, IN_PROGRESS, COMPLETED, CANCELLED, NO_SHOW).
remaining === 0 Nút không render

CollectRemainingModal tự gate option QR theo qrEnabled (booking provider có active không) — nếu PSP inactive, modal vẫn mở với 2 lựa chọn manual (Cash / Terminal); QR slot hiển thị "Online payment is not configured for this salon." Không còn warning bar inline ở booking drawer (gây nhiễu cho cash-only salons).

Tại sao bỏ guard FE

  • Owner cần top-up bill từ mọi booking state: thêm dịch vụ trên booking COMPLETED, ghi nhận cancellation fee trên CANCELLED, deposit pre-service trên PENDING. Guard cũ {ARRIVED, IN_PROGRESS, COMPLETED} chặn các use case hợp pháp.
  • Booking-state validation thuộc booking context; payment context chỉ ghi nhận tiền. Phân tách trách nhiệm rõ ràng hơn.
  • Idempotency-by-intent ở backend vẫn đảm bảo "click nhiều lần không sinh nhiều URL".

9. Customer-Facing UI Flow

  1. Booking flow — nếu tenant depositEnabled:
    • Trước submit: show "Depositum: 200 NOK" + link terms (Track C4.3 amber notice)
    • After submit → redirect to Bambora hosted checkout
  2. Return page (/b/{slug}/bookings/{id}/payment/return) — client polling 2s interval, 30s timeout, tone cards cho mỗi outcome
  3. Cancel page (/b/{slug}/bookings/{id}/payment/cancelled) — static "you cancelled" với CTA Retry
  4. Email receipt: sau capture, email với invoice + payment ref (channel deferred — EmailProvider port + LogEmailProvider default impl từ P1-4)
  5. Refund / Void notification: SMS template Norwegian từ OnPaymentNotificationListener (P1-10)
  6. Retry payment (P1-4): account/BookingsSection show yellow CTA khi depositStatus=RETRY_PENDING && status=PENDING. Click → POST /payment/retry → redirect tới fresh checkoutUrl. Email deep-link ?retry=<id> auto-fire mutation on landing.

10. Liên quan