Payment — Scenarios (End-to-End Flows)
Đọc
payment-fundamentals.mdtrướ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:
- Authorize — khách điền card xong, webhook "authorized" đến →
Payment.authorize()→PaymentAuthorizedevent → listener flip booking PENDING → CONFIRMED. - Capture on Arrived (primary) — staff bấm Arrived trên admin UI →
BookingArrivedevent →PaymentIntegrationService.onBookingArrived→ CapturePaymentCommand nếu MANUAL + AUTHORIZED. - Capture on Completed (fallback) — nếu state machine skip Arrived (CONFIRMED → IN_PROGRESS → COMPLETED trực tiếp) →
BookingCompletedvẫ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 invariant — Payment.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
redirectUrltrong 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}→ mappingMANUAL_CASH/MANUAL_TERMINAL(Prisma enum +ProviderKey).recordedByUserIdlấ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ửiPAYMENT_RECEIVEDemail 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
- Stamp card: progress complete (đủ
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.ADJUSThoặ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 ✅
computeLoyaltyDiscountpure 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 ofexpiresAt), (b)booking.status ∈ {CONFIRMED, ARRIVED, IN_PROGRESS, COMPLETED}(cho customer self-pay từ PENDING). - Admin
POST /admin/payments/remaining→false(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
payment-fundamentals.md— Concepts, state machine, event/error reference../architecture/payment-architecture.md— DDD layered architecturebooking-flow.md— Booking lifecycle + integration eventsbooking-status-flow.md— Booking state machinestatus-matrix/05-payment-driven.md— Reverse direction: Payment events → Booking transitions../architecture/api-design.md— HTTP conventions