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 |
DEPOSITlà 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áchARRIVED→ 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
Paymentrow. Provider của booking =latest.provider(Payment cóupdatedAtlớn nhất, tức là deposit hoặc retry gần nhất). - FE cross-check
latest.providervới danh sáchpaymentConfigs(quausePaymentConfigList) — nếu không có config nàoprovider === 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
- 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
- Return page (
/b/{slug}/bookings/{id}/payment/return) — client polling 2s interval, 30s timeout, tone cards cho mỗi outcome - Cancel page (
/b/{slug}/bookings/{id}/payment/cancelled) — static "you cancelled" với CTA Retry - Email receipt: sau capture, email với invoice + payment ref (channel deferred —
EmailProviderport +LogEmailProviderdefault impl từ P1-4) - Refund / Void notification: SMS template Norwegian từ
OnPaymentNotificationListener(P1-10) - Retry payment (P1-4):
account/BookingsSectionshow yellow CTA khidepositStatus=RETRY_PENDING && status=PENDING. Click →POST /payment/retry→ redirect tới freshcheckoutUrl. Email deep-link?retry=<id>auto-fire mutation on landing.
10. Liên quan
payment-flow.md— 20 scenarios end-to-end (deposit, cancel, no-show, refund, retry, reconciliation)../architecture/payment-architecture.md— DDD layered architecture, bounded context, domain modelbooking-flow.md— Booking lifecycle + integration eventsbooking-status-flow.md— Booking state machine, transitions, role matrixstatus-matrix/05-payment-driven.md— Reverse direction: Payment events → Booking transitions +depositStatus../architecture/api-design.md— HTTP conventions, response envelope