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 |
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.
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:
- 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 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 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 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 byInitiateRemainingPaymentHandler) 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
redirectUrlin 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
- 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
- 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
- Booking flow — nếu tenant depositEnabled:
- Trước submit: show "Depositum: 200 NOK" + link terms
- After submit → redirect to Bambora
- Success page: confirmation with booking ID + receipt download
- Cancel page: if user cancels on Bambora → show "Booking pending, pay later from email link"
- Email receipt: after capture, email with invoice + payment ref
- Refund notification: email khi refund processed + ETA 5-10 days
Related
../architecture/payment-architecture.md— Architecture, domain modelbooking-flow.md— Booking lifecycle + integration events../architecture/api-design.md— HTTP conventions