Kiến trúc Payment
BẮT BUỘC đọc trước khi viết code liên quan payment, deposit, refund, hoặc integration với booking. Companion:
docs/flows/payment-fundamentals.md(intent + state machine + event/error reference) vàdocs/flows/payment-flow.md(20 scenarios end-to-end).
Payment Context là bounded context độc lập, được thiết kế theo DDD + Hexagonal Architecture, tách biệt hoàn toàn với Booking Context, giao tiếp qua domain events. Mục tiêu: provider-agnostic (Bambora, Stripe, Vipps, …), compliance-ready, zero-trust credentials, audit-complete.
1. Phạm vi
Trong phạm vi (in scope)
- Salon nhận deposit / full payment từ customer
- Salon là merchant of record (tiền về tài khoản salon, không qua platform)
- Provider-agnostic: MVP = Bambora Classic (Norway SMB). Worldline Direct adapter code được giữ trong
providers/worldline-direct/cho enterprise migration tương lai. Stripe, Vipps MobilePay, Nets, Adyen — roadmap post-MVP, drop-in quaPaymentProviderPort(zero domain change) - Authorize + Capture (auto & manual modes)
- Void, Refund (full & partial)
- Webhook verification + idempotent processing
- Audit trail đầy đủ (compliance Finanstilsynet / PSD2)
Ngoài phạm vi (cho đến có yêu cầu cụ thể)
- Platform subscription billing (SaaS fee) — sẽ có flow riêng
- Marketplace aggregator / split payment
- In-person POS / chip & pin
- Payment dispute (chargeback) resolution UI — chỉ log, handle manual
- Multi-currency per tenant
- Per-service deposit override
2. Bounded Context Map
flowchart LR
subgraph BC [Booking Context — CRUD-style, existing]
B[Booking]
BI[BookingItem]
BAL[BookingAuditLog]
end
subgraph PC [Payment Context — DDD, new]
P[Payment]
PCfg[PaymentConfig]
PE[PaymentEvent]
WI[WebhookInbox]
end
BC -.->|domain events| PC
PC -.->|domain events| BC
PC -->|hosted page redirect| Provider{{Bambora / Stripe / Vipps}}
Provider -->|webhook| WI
Integration pattern: Pure event-driven. Booking không biết Payment tồn tại (chỉ publish events). Payment listen + react. Khi Payment cần update Booking (e.g., deposit paid → booking.depositStatus = PAID), Payment emit event PaymentCaptured, Booking listener update.
Sự kiện qua biên context
| Direction | Event | Published by | Subscriber action |
|---|---|---|---|
| Booking → Payment | BookingCreated |
Booking | Nếu tenant.depositEnabled → InitiatePaymentCommand |
| Booking → Payment | BookingConfirmed |
Booking | Nếu payment AUTHORIZED → CapturePaymentCommand (manual mode) |
| Booking → Payment | BookingCancelled |
Booking | CancellationRefundPolicy.decide() → Void / Refund / NoAction |
| Booking → Payment | BookingMarkedNoShow |
Booking | Capture (forfeit) nếu AUTHORIZED; keep nếu CAPTURED |
| Booking → Payment | BookingCompleted |
Booking | Capture nếu AUTHORIZED (manual mode) |
| Payment → Booking | PaymentAuthorized |
Payment | Booking update depositStatus = AUTHORIZED |
| Payment → Booking | PaymentCaptured |
Payment | Booking update depositStatus = PAID |
| Payment → Booking | PaymentFailed |
Payment | Booking update depositStatus = FAILED, trigger customer notification |
| Payment → Booking | PaymentRefunded |
Payment | Booking update depositStatus = REFUNDED |
3. Nhật ký quyết định (Decision Log)
| # | Quyết định | Lý do |
|---|---|---|
| D1 | Hybrid persistence (state row + append-only PaymentEvent log) |
Query-friendly cho admin + complete audit trail. Đủ nền tảng để upgrade event sourcing sau mà không rebuild. |
| D2 | Strangler Booking (không refactor ngay) | Minimize regression risk. Publish lifecycle events đủ cho integration. Refactor Booking → DDD incremental khi touch feature mới. |
| D3 | Dual outbox: Domain Event Outbox + Webhook Inbox | Guaranteed at-least-once delivery. Crash-safe. Compliance không mất event. |
| D4 | PCI scope = hosted page only | Không touch raw card data → PCI SAQ-A (lowest burden). Provider host payment form. |
| D5 | AES-256-GCM + env master key, KMS-ready | MVP đơn giản. Port CredentialsCipherPort abstract → đổi sang AWS KMS / GCP KMS không sửa domain code. |
| D6 | Tự viết light saga orchestrator | Payment lifecycle đơn giản (authorize→capture→refund). @nestjs/cqrs saga overkill. Explicit state machine dễ debug. |
| D7 | Multi TenantPaymentConfig per tenant |
1 row/provider (Bambora, Stripe, …). Tenant có thể config nhiều provider, chọn 1 active (isActive = true). Mở rộng dễ. |
| D8 | Path-based webhook URL: /webhooks/payments/:provider/:tenantId |
Explicit tenant isolation, rate limit per tenant dễ, debug rõ ràng, không rely vào payload lookup. |
| D9 | Integer minor units (Money VO) | Consistent với rest of codebase (MoneyField, depositValue). Tránh float rounding. Standard practice payment. |
| D10 | Idempotency first-class | Mọi command có idempotencyKey. Webhook dedupe qua providerEventId. Double-submit safe. |
| D11 | Clock port (injectable time) | Testable time (authorization expiry, cancellation window), deterministic tests. |
| D12 | 1 Payment = 1 Booking (nullable bookingId cho future use) | MVP: deposit gắn 1-1 với booking. Future: có thể tách (subscription, gift card). Schema đã cho phép. |
| D13 | Default capture mode = INSTANT cho deposit (đảo từ MANUAL trước đây) | Auth-only deposit khiến capturedAmount không phản ánh "tiền đã commit", mỗi UI tự quyết AUTH-as-paid → bug class lặp lại. Auth window 5-7 ngày thua booking window 2-4 tuần → buộc re-auth. Industry (Treatwell, Vagaro, GlossGenius) đã chuyển instant từ ~2022. Manual capture vẫn còn trong domain làm escape hatch (D14 → 11.4) cho future no-show-protection flow. Xem ADR-001 §3.1. |
3.1 ADR-001: Instant capture as default for deposits
Status: Accepted (2026-04-26) — supersedes initial MVP design (manual capture default).
Context
MVP ban đầu chọn manual capture (auth-only) cho deposit theo textbook PSP guidance: "không capture trước khi deliver value". Bambora Classic adapter setup captureMode: MANUAL, deposit ở status=AUTHORIZED, capture trigger sau khi booking đến trạng thái ARRIVED hoặc COMPLETED.
Vận hành ~2 tháng phát hiện 4 vấn đề có hệ thống:
- Display ambiguity —
capturedAmount=0ở AUTHORIZED, nhưng UX coi "money committed = paid". Mỗi consumer (PaymentLedger, BookingTicket, deriveNextPayment, admin BookingList PaymentCell) tự quyết "AUTH count như CAP?" → 1 chỗ quên = bug. Bug thực tế: invoice page Pay button hiện "Pay 599 kr" thay vì "Pay 594 kr" sau khi deposit 5 kr AUTH (2026-04-26). - Auth window risk — Visa 7 ngày, Mastercard 5 ngày. Booking thường đặt 2-4 tuần trước → AUTH expire trước appointment → buộc re-auth = re-confirm với customer = UX gãy.
- Capture failure rate — 5-15% theo industry data: card thay đổi giữa AUTH và CAPTURE (mất, thay limit, đổi bank). Manual capture mode tạo thêm window failure này.
- Customer banking app confusion — "Pending charge 5 kr" 2 tuần → support ticket, vài case chargeback dù salon không sai.
Decision
Chuyển default captureMode = INSTANT cho mọi deposit MVP. Bambora adapter set instantcapture=true. Payment lifecycle rút gọn: INITIATED → CAPTURED (skip AUTHORIZED).
Payment.authorize() + capture() API vẫn giữ trong domain làm escape hatch — bật lại khi build "no-show protection" flow (auth-only, charge nếu no-show, tương lai gần).
Comparison table (criteria thắng / 2 options):
| Category | Auth-only (was) | Instant (new) |
|---|---|---|
| Customer UX (banking display, trust) | 1 / 5 | 4 / 5 |
| Owner UX (cash flow, "đã thu chưa") | 1 / 5 | 4 / 5 |
| Engineering complexity (states, LoC, bug surface) | 0 / 5 | 5 / 5 |
| Risk (auth expire, capture fail, chargeback) | 1 / 5 | 4 / 5 |
| Cost (fees, refund) | 2 / 2 | 0 / 2 |
| Provider portability (Stripe, Vipps, Adyen) | 1 / 5 | 4 / 5 |
| Total | 5 / 27 | 22 / 27 |
Cost difference (~120 NOK/năm cho 100 booking/tháng × 10% cancel rate) trivial vs engineering + bug-class savings.
Industry alignment (closest peers — beauty booking SaaS):
| System | Deposit model |
|---|---|
| Treatwell (Nordic) | Instant capture (Adyen captureDelayHours: 0) |
| Vagaro (US) | Instant capture (Stripe) |
| GlossGenius (US) | Instant capture (Stripe) |
| Phorest (IE) | Instant capture |
| Mangomint (US) | Instant capture |
| Mindbody (US) | Configurable, default instant |
| Booksy (PL/US) | Card on file (no charge until no-show) — alternative model |
| Square Appointments | Card on file — alternative model |
Auth-only đúng cho 2 case (sẽ xem xét sau, không MVP):
- Hotel/car rental security deposit — số tiền lớn (>500 NOK), AUTH như "hold collateral", VOID nếu trả tốt
- No-show protection pure — card on file, charge nếu no-show (tương đương Stripe SetupIntent + manual charge)
Beauty deposit 5-50 NOK không thuộc 2 nhóm này.
Consequences
Positive:
committedAmount(p) === capturedAmount(p) - refundedAmount(p)— 1 formula, đồng bộ mọi UI- Bỏ orchestration
OnBookingArrived → CapturePaymentlistener (~150 LoC) - Bỏ
AuthorizationExpiryCheckercron worker - Cash flow đến T+1 thay vì T+book-window+settle (có thể 30+ ngày)
- Migration sang Stripe / Vipps / Adyen tương lai = đổi 1 flag, không refactor lifecycle
Negative:
- Mỗi cancel-in-window = REFUND thật (~1 NOK fee Bambora) thay vì VOID free
- Customer card statement có cả
+5 krvà−5 krrow (transparent, nhưng 2 line thay vì 0) - Refund visible 3-5 ngày thay vì instant void
Mitigation: copy text "Deposit charged — refundable in 24h cancellation window" set expectation rõ.
Migration path: Xem §11.4 (đảo: từ "upgrade to manual" → "downgrade to manual cho specific tenant nếu cần").
4. Kiến trúc layered (Hexagonal)
flowchart TB
subgraph IL [Interface Layer — HTTP / NestJS]
AC[PaymentController<br/>admin REST]
PCT[PublicPaymentController<br/>customer]
WC[WebhookController<br/>POST /webhooks/payments/:provider]
end
subgraph AL [Application Layer — CQRS]
Cmd[Commands<br/>Initiate / Capture / Void / Refund / ProcessWebhook]
Qry[Queries<br/>GetPayment / ListPayments / PaymentStats]
Lst[Integration Listeners<br/>OnBookingCreated / Cancelled / NoShow / Confirmed]
Saga[Sagas<br/>PaymentLifecycleSaga / RefundPolicyEvaluator]
end
subgraph DL [Domain Layer — pure TypeScript, zero framework]
Agg[Aggregates<br/>Payment / PaymentConfig]
VO[Value Objects<br/>Money / PaymentId / ProviderRef / IdempotencyKey / PaymentIntent / CaptureMode]
Evt[Domain Events<br/>PaymentInitiated / Authorized / Captured / Voided / Refunded / Failed / Expired]
Pol[Policies<br/>CancellationRefund / AuthorizationExpiry / FeeCalculation]
Port[Ports<br/>PaymentProviderPort / RepositoryPort / CredentialsCipherPort / ClockPort]
end
subgraph InfL [Infrastructure Layer — adapters]
Prov[Providers<br/>BamboraAdapter + ProviderRegistry<br/>future: Stripe / Vipps / Nets]
Persist[Persistence<br/>Prisma repos + PaymentMapper]
Crypto[Crypto<br/>AesGcmCipher → future KmsCipher]
Wkr[Workers<br/>OutboxPublisher / WebhookInboxProcessor / AuthorizationExpiryChecker]
end
IL --> AL
AL --> DL
InfL -.->|implements| Port
Dependency rule: dependency point inward. Domain không import infrastructure. Application depend on domain + ports (interfaces). Infrastructure implements ports.
5. Domain Model
5.1 Aggregate: Payment
Responsibility: lifecycle của 1 payment transaction. Enforce state transitions, capture/refund invariants.
Identity: PaymentId (UUID v7 — time-ordered, better index).
State:
status : PaymentStatus (enum, state machine)
intent : PaymentIntent (DEPOSIT | FULL_PAYMENT | REMAINING_PAYMENT |
CANCELLATION_FEE | NO_SHOW_FEE | REFUND)
captureMode : CaptureMode (AUTO | MANUAL)
amount : Money (authorized amount)
capturedAmount : Money (actually captured)
parentPaymentId : string | null (self-FK to the captured charge when
intent = REFUND; null on charge rows)
providerRef : ProviderRef ({providerKey, transactionId, sessionId})
idempotencyKey : IdempotencyKey
authorizedAt : Date | null
capturedAt : Date | null
expiresAt : Date | null (authorization TTL)
failureCode : string | null
failureMessage : string | null
metadata : Record<string, unknown> (provider-specific blob;
includes `reason` on REFUND rows)
Note (2026-05-11, refund-as-row refactor): there is no
refundedAmountfield on Payment anymore. Cumulative refund total is derived by aggregating REFUND children:sum(child.capturedAmount) WHERE child.parentPaymentId = parent.id. The DTO layer still exposes arefundedAmountaggregate so external consumers (FE list, reporting) see no shape change.
Invariants:
- Status transitions valid only per state machine (§5.3)
capturedAmount ≤ amountintent = REFUND ⇔ parentPaymentId IS NOT NULL(enforced at domain factory + handler; DB allows the column to be nullable for charge rows)- Parent of a refund must be a charge (
parent.intent !== REFUND— no refund-of-refund) - Cumulative refund ≤ parent.capturedAmount (handler-side check; the domain factory throws on a single oversize child too)
- Cannot void CAPTURED (must refund)
- Cannot refund AUTHORIZED (must void)
providerRef.transactionIdrequired after AUTHORIZEDamount.currency === capturedAmount.currency(refund children inherit the parent's currency via the factory)
Behaviors (public methods):
static initiate(...): Payment // create a charge row
static createRefund({parent, amount, reason, // create a REFUND child
at, idempotencyKey,
providerRef, metadata?}): Payment
authorize(providerRef, at, expiresAt?): void
capture(amount, at): void
void(at, reason?): void
applyRefundProjection({cumulativeRefunded, // walk parent's status
refundedNow, reason, at}): void
markFailed(code, message, kind, at): void
markExpired(at): void
// Helper — aggregate over a sibling refund collection.
static aggregateRefunded(parent, refunds): Money
Mỗi behavior emit 1 domain event, push vào _pendingEvents[], chỉ
publish khi aggregate commit success. The refund flow emits:
PaymentRefundCreatedon the child (carries reason, idempotencyKey, parent linkage)PaymentPartiallyRefunded/PaymentRefundedon the parent afterapplyRefundProjection— same payload shape as before so existing listeners (booking projection, admin notification) don't need to change.
5.2 Aggregate: PaymentConfig
Responsibility: lưu credentials của 1 provider cho 1 tenant.
Identity: PaymentConfigId.
State:
tenantId : TenantId
provider : ProviderKey (BAMBORA | STRIPE | ...)
isActive : boolean
isTest : boolean (sandbox vs production)
encryptedCredentials : EncryptedBlob ({ciphertext, iv, tag})
displayName : string?
createdAt, updatedAt
Invariants:
(tenantId, provider)unique- Decryption must succeed with current master key (else → reconfigure required)
- Only 1
isActive = trueper (tenantId, provider)
Behaviors:
static create(tenantId, provider, credentials, cipher): PaymentConfig
rotateCredentials(newCredentials, cipher): void
activate(): void
deactivate(): void
5.3 State Machine (Payment)
┌──────────────┐
│ INITIATED │ ← created, chưa call provider
└──────┬───────┘
│ provider.createSession ok + webhook "authorized"
▼
┌───────────▶ AUTHORIZED ◀──────────────┐
│ │ │
void() /│ │ capture() │ auto capture mode:
cancel │ │ │ skip AUTHORIZED,
│ ▼ │ go direct INITIATED→CAPTURED
│ CAPTURED │
│ │ │
│ │ refund(partial) │
│ ▼ │
│ PARTIALLY_REFUNDED │
│ │ │
│ │ refund(remaining) │
▼ ▼ │
VOIDED REFUNDED │
│
┌─────────────────────────────────────┐ │
│ FAILED (terminal, error state) │◀────┘
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ EXPIRED (authorization TTL passed) │
└─────────────────────────────────────┘
Terminal states: VOIDED, REFUNDED, FAILED, EXPIRED. Transient states: INITIATED, AUTHORIZED, CAPTURED, PARTIALLY_REFUNDED.
5.4 Value Object: Money
class Money {
constructor(readonly amount: number, readonly currency: string) {
if (!Number.isInteger(amount)) throw new Error('amount must be integer (minor units)');
if (amount < 0) throw new Error('amount must be non-negative');
if (!/^[A-Z]{3}$/.test(currency)) throw new Error('invalid ISO-4217');
}
add(other: Money): Money // currency match required
subtract(other: Money): Money
percentage(pct: number): Money // round to nearest minor unit
greaterThan(other: Money): boolean
equals(other: Money): boolean
isZero(): boolean
static zero(currency: string): Money
}
Immutable. Arithmetic trả về instance mới.
5.5 Domain Events
Tất cả event có shape chung:
interface DomainEvent {
eventId: string; // UUID v7
eventType: string; // 'PaymentAuthorized'
aggregateId: string;
aggregateType: 'Payment' | 'PaymentConfig';
tenantId: string;
occurredAt: Date;
payload: Record<string, unknown>;
version: number; // event schema version
}
Events list: PaymentInitiated, PaymentAuthorized, PaymentCaptured, PaymentPartiallyRefunded, PaymentRefunded, PaymentVoided, PaymentFailed, PaymentExpired, PaymentConfigCreated, PaymentConfigActivated, PaymentConfigDeactivated, PaymentConfigCredentialsRotated.
5.6 Domain Policies
CancellationRefundPolicy: quyết định khi BookingCancelled event received:
input: booking.startTime, cancelledAt, tenant.cancellationHours, payment.status
output: 'VOID' | 'FULL_REFUND' | 'PARTIAL_REFUND' | 'NO_ACTION' | 'FORFEIT'
Rules:
status === AUTHORIZED+ trong cancellation window →VOIDstatus === AUTHORIZED+ ngoài cancellation window →FORFEIT(capture → keep as cancellation fee)status === CAPTURED+ trong cancellation window →FULL_REFUNDstatus === CAPTURED+ ngoài cancellation window →NO_ACTION(salon giữ)status === CAPTURED+ salon hủy →FULL_REFUND(luật consumer)
AuthorizationExpiryPolicy: Bambora hold 7 days. Nếu booking > 5 days future → warn admin, capture sớm hoặc re-authorize trước expire.
FeeCalculationPolicy: compute deposit từ booking amount + tenant settings (percentage vs fixed).
6. Provider Port (Hexagonal boundary)
interface PaymentProviderPort {
readonly key: ProviderKey;
readonly capabilities: {
supportsCaptureModes: CaptureMode[];
supportsPartialRefund: boolean;
supportsVoid: boolean;
supportedCurrencies: string[];
supportedPaymentMethods: string[]; // 'card', 'vipps', 'mobilepay'
maxAuthorizationAgeDays: number;
};
createSession(input: CreateSessionInput): Promise<CreateSessionResult>;
capture(input: CaptureInput): Promise<CaptureResult>;
void(input: VoidInput): Promise<VoidResult>;
refund(input: RefundInput): Promise<RefundResult>;
fetchStatus(input: FetchStatusInput): Promise<ProviderStatusSnapshot>; // reconciliation
verifyWebhook(input: VerifyWebhookInput): Promise<VerifiedWebhookEvent>;
healthCheck(credentials: DecryptedCredentials): Promise<HealthCheckResult>;
}
Provider Registry topology
flowchart LR
Tenant[Tenant config<br/>active provider key] --> Reg{ProviderRegistry<br/>key → adapter}
Reg -->|BAMBORA| Bambora[BamboraAdapter<br/>Classic, MD5, GET callback]
Reg -.->|WORLDLINE *enterprise*| Worldline[WorldlineDirectAdapter<br/>kept for migration]
Reg -.->|STRIPE / VIPPS / NETS| Future[Future adapters<br/>drop-in via PaymentProviderPort]
Bambora --> CredCipher[CredentialsCipherPort<br/>AES-256-GCM]
Bambora --> Http[FetchHttpClient<br/>shared infrastructure/http/]
Adapter responsibilities (anti-corruption layer)
- Mapper: external DTO ↔ domain model. Domain không bao giờ thấy Bambora JSON shape.
- Error translation: Bambora error codes → domain errors (
ProviderError,InvalidCredentialsError,InsufficientFundsError). - Idempotency: forward idempotency key trong headers provider yêu cầu (Stripe:
Idempotency-Key, Bambora:X-Request-ID). - Signature verify: HMAC-SHA256 của raw body với secret key provider.
- Retry policy: exponential backoff cho transient errors (5xx, timeout), không retry 4xx.
Thêm provider mới = thêm 1 adapter
infrastructure/providers/stripe/
├── stripe.adapter.ts ← implements PaymentProviderPort
├── stripe.client.ts
├── stripe.mapper.ts
└── stripe.webhook.ts
Register trong ProviderRegistry. Zero change domain/application.
7. Persistence Schema (Prisma)
enum PaymentProvider { BAMBORA WORLDLINE STRIPE VIPPS NETS ADYEN MANUAL_CASH MANUAL_TERMINAL }
enum PaymentStatus { INITIATED AUTHORIZED CAPTURED PARTIALLY_REFUNDED REFUNDED VOIDED FAILED EXPIRED }
enum PaymentIntent { DEPOSIT FULL_PAYMENT REMAINING_PAYMENT CANCELLATION_FEE NO_SHOW_FEE }
enum CaptureMode { AUTO MANUAL }
// MANUAL_CASH / MANUAL_TERMINAL: offline providers, no PaymentConfig, no PSP session, no webhook.
// Recorded by staff via POST /admin/payments/manual; row goes straight INITIATED → CAPTURED.
model Payment {
id String @id @default(uuid(7))
tenantId String @map("tenant_id")
bookingId String? @map("booking_id")
provider PaymentProvider
providerTransactionId String? @map("provider_transaction_id")
providerSessionId String? @map("provider_session_id")
intent PaymentIntent
captureMode CaptureMode @map("capture_mode")
status PaymentStatus
amount Int // authorized, minor units
capturedAmount Int @default(0) @map("captured_amount")
refundedAmount Int @default(0) @map("refunded_amount")
currency String @db.Char(3)
idempotencyKey String @unique @map("idempotency_key")
failureCode String? @map("failure_code")
failureMessage String? @map("failure_message") @db.Text
metadata Json @default("{}")
authorizedAt DateTime? @map("authorized_at")
capturedAt DateTime? @map("captured_at")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([provider, providerTransactionId])
@@index([tenantId, status, createdAt])
@@index([bookingId])
@@index([expiresAt]) // for expiry checker cron
@@map("payments")
}
model PaymentEvent {
id String @id @default(uuid(7))
paymentId String @map("payment_id")
tenantId String @map("tenant_id")
eventType String @map("event_type")
eventVersion Int @default(1) @map("event_version")
payload Json
occurredAt DateTime @default(now()) @map("occurred_at")
@@index([paymentId, occurredAt])
@@index([tenantId, occurredAt])
@@map("payment_events")
}
model PaymentWebhookInbox {
id String @id @default(uuid(7))
provider PaymentProvider
providerEventId String @map("provider_event_id")
tenantId String @map("tenant_id")
paymentId String? @map("payment_id")
eventType String? @map("event_type")
rawPayload Json @map("raw_payload")
signature String?
verified Boolean @default(false)
processedAt DateTime? @map("processed_at")
failureMessage String? @map("failure_message") @db.Text
receivedAt DateTime @default(now()) @map("received_at")
@@unique([provider, providerEventId])
@@index([tenantId, receivedAt])
@@index([verified, processedAt]) // worker filter
@@map("payment_webhook_inbox")
}
model DomainEventOutbox {
id String @id @default(uuid(7))
aggregateId String @map("aggregate_id")
aggregateType String @map("aggregate_type")
tenantId String @map("tenant_id")
eventType String @map("event_type")
eventVersion Int @default(1) @map("event_version")
payload Json
occurredAt DateTime @map("occurred_at")
publishedAt DateTime? @map("published_at")
attempts Int @default(0)
lastError String? @map("last_error") @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([publishedAt, occurredAt]) // worker poll unpublished
@@index([aggregateId, occurredAt])
@@map("domain_event_outbox")
}
model TenantPaymentConfig {
id String @id @default(uuid())
tenantId String @map("tenant_id")
provider PaymentProvider
isActive Boolean @default(false) @map("is_active")
isTest Boolean @default(true) @map("is_test")
encryptedCredentials String @map("encrypted_credentials") @db.Text
credentialsIv String @map("credentials_iv")
credentialsTag String @map("credentials_tag")
keyVersion Int @default(1) @map("key_version")
displayName String? @map("display_name")
lastHealthCheckAt DateTime? @map("last_health_check_at")
lastHealthCheckStatus String? @map("last_health_check_status")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([tenantId, provider])
@@index([tenantId, isActive])
@@map("tenant_payment_configs")
}
Notes on schema
- UUID v7: time-ordered → index B-tree tốt hơn UUID v4
idempotencyKeyunique: enforce at DB level, race-safe(provider, providerTransactionId)unique: reconciliation không tạo duplicate(provider, providerEventId)unique trong WebhookInbox: dedup at insert timekeyVersiontrong config: support key rotation (decrypt old ciphertext với old key, re-encrypt với new)PaymentEventvsDomainEventOutboxtách biệt: event log là business audit (giữ mãi), outbox là transient buffer (cleanup sau publish + retention window)
8. Outbox Pattern (Dual)
8.1 Domain Event Outbox
Write side (inside aggregate save transaction):
BEGIN TX
UPDATE payments SET status = 'AUTHORIZED' WHERE id = ...
INSERT INTO payment_events (...) -- audit log
INSERT INTO domain_event_outbox (...) -- for cross-context publish
COMMIT
Publish side (background worker, @Interval(5000)):
SELECT * FROM domain_event_outbox
WHERE published_at IS NULL AND attempts < 10
ORDER BY occurred_at ASC LIMIT 100
FOR EACH row:
try {
eventBus.publish(toDomainEvent(row))
UPDATE outbox SET published_at = now() WHERE id = row.id
} catch {
UPDATE outbox SET attempts = attempts + 1, last_error = ... WHERE id = row.id
-- exponential backoff via next poll
}
Retention: cleanup job xoá outbox rows published_at < now() - 30 days.
8.2 Webhook Inbox
Receive side (HTTP controller):
POST /webhooks/payments/bambora/:tenantId
1. Verify signature against tenant's Bambora secret → if fail, 401 + log
2. Parse providerEventId from payload
3. INSERT INTO payment_webhook_inbox (...) ON CONFLICT DO NOTHING
4. IF newly inserted → enqueue WebhookInboxProcessor
5. Return 200 immediately (Bambora timeout 30s)
Process side (WebhookInboxProcessor, async):
FOR each verified, unprocessed row:
Load Payment aggregate
Apply state transition (authorize / capture / fail)
Save aggregate → commits to payment_events + domain_event_outbox
UPDATE inbox SET processed_at = now() WHERE id = ...
Idempotency: (provider, providerEventId) unique → duplicate webhook INSERT fail → skip. Safe với Bambora retry storm.
9. Security & Compliance
9.1 PCI-DSS (SAQ-A scope)
- Card data NEVER touches our servers. Customer redirected to Bambora hosted page.
- Our backend only sees: payment session ID, transaction ID, amount, status, last-4 (metadata only).
- No card storage, no tokenization on our side.
- TLS 1.2+ mandatory everywhere.
9.2 Credentials at rest
TenantPaymentConfig.encryptedCredentials: AES-256-GCM- Master key: 32 bytes, hex-encoded trong
PAYMENT_ENCRYPTION_KEYenv var - IV: 12 bytes random per record
- Tag: GCM auth tag (16 bytes) → tamper detection
- Key version field để support rotation (new key → re-encrypt lazy)
- Never log decrypted credentials
CredentialsCipherPortinterface → future KMS swap dễ
9.3 Webhook security
- Signature verification bắt buộc (HMAC-SHA256 theo provider spec)
- Rate limit per tenant: 60 req/min
- Reject if payload size > 1MB
- IP allowlist (Bambora publishes IP ranges) — optional hardening
- Timestamp validation: reject nếu
timestamp> 5 min old (replay protection)
9.4 Authorization
- Admin endpoints: require
OWNERrole (không phải STAFF) cho refund/void - Config credentials: chỉ
OWNERxem được (masked),STAFFkhông
9.5 Audit log
- Mọi command (Initiate, Capture, Void, Refund) log đầy đủ: who, when, what, result, ip
- Lưu trong
payment_events(source of truth) +booking_audit_logs(cross-reference) - Retention: indefinite cho compliance (min 5 years Norway accounting law)
9.6 GDPR
- Customer PII (email, phone) trong
metadata→ delete on customer erasure request - Payment event records: legal basis "legitimate interest + legal obligation" (accounting) → giữ nguyên
- Masked access: staff chỉ thấy last-4 card, không full PAN (Bambora đảm bảo)
9.7 Compliance documentation
Salon owner cần biết:
- Thuế MVA: deposit captured = revenue recognition → khai MVA
- Refund → credit note → reverse MVA
- Accounting export: API trả về payment log theo period + status
- Invoice number: generate per booking (Norway bokføringsloven requires)
10. Observability
Logs (structured JSON)
Events to log:
- Payment lifecycle transitions (info)
- Provider API calls with latency (info)
- Webhook received / verified / processed (info)
- Idempotency hit (debug)
- Signature verification fail (warn + security alert)
- Provider error / timeout (error)
- Unexpected state transition attempt (error + alert)
Correlation ID: paymentId trong mọi log line.
Metrics (Prometheus-style)
payment_initiated_total{tenant, provider}payment_completed_total{tenant, provider, status}payment_refund_total{tenant, provider}payment_webhook_received_total{provider, verified}payment_webhook_processing_duration_seconds{provider}payment_provider_api_duration_seconds{provider, operation}payment_authorization_expiring_total{tenant}(pending within 1 day)payment_outbox_unpublished_total(should stay near 0)
Alerts
| Condition | Severity | Action |
|---|---|---|
| Webhook verify fail rate > 5% in 5min | High | Check provider secret rotation, attack |
| Outbox unpublished > 100 for > 1min | Critical | Worker down, investigate |
| Provider 5xx rate > 10% in 5min | High | Provider outage, show degraded banner |
| Authorization expiring in < 1 day, count > 0 | Medium | Admin warning, manual capture or re-auth |
| Config health check fail | Medium | Email owner, disable provider in UI |
11. Extension Points
11.1 Add new provider (Stripe, Vipps, …)
- Create
infrastructure/providers/<name>/<name>.adapter.tsimplementPaymentProviderPort - Add enum value to
PaymentProvider+ Prisma migration - Register trong
ProviderRegistry - Add webhook route:
/webhooks/payments/<name>/:tenantId - Add adapter-specific credentials shape (documented in adapter)
- Frontend settings UI: render provider-specific form fields
- Tests: contract tests against sandbox
Zero change: domain layer, application layer, Booking context.
11.2 Add new PaymentIntent (e.g., giftCard, subscription)
- Add enum value to
PaymentIntent - Add policy if behavior differs
- Application listener cho context raise intent mới
- UI cho admin thấy intent type
11.3 Upgrade to KMS
- Implement
KmsCipher implements CredentialsCipherPort - DI swap in module registration
- Migration script: decrypt với env key → encrypt với KMS key → update
keyVersion
11.4 Re-enable manual capture (no-show protection / large deposits)
Default từ ADR-001 (§3.1) là instant capture. Mở lại manual capture path khi cần — schema + domain (Payment.authorize(), capture()) đã có sẵn:
- Add
tenant.settings.captureMode: 'instant' | 'manual'(per-tenant opt-in, default'instant') - Command handler respect tenant setting khi initiate session
- Re-attach
OnBookingArrived → CapturePaymentCommandlistener (đã có code, comment out tại deploy ADR-001) - Re-enable
AuthorizationExpiryCheckercron job - Admin UI: capture/void buttons (đã có code)
- Customer copy: "Card hold 5 kr — charged at appointment"
Use case khi nào enable:
- Premium service deposit > 500 NOK (refund fee đáng kể nếu instant + cancel)
- No-show protection only (card on file, không charge gì cả) — cần thêm
intent: 'NO_SHOW_HOLD'mới - Hotel-style security deposit (nếu mở rộng sang industry khác trong future)
Schema không thay đổi. Code path không xóa, chỉ default route đổi.
11.5 Event sourcing upgrade path
Event log đã có (payment_events). Để rebuild aggregate từ events:
Payment.replay(events: DomainEvent[]): Paymentstatic factory- Repository option: load latest snapshot + replay events after snapshot
- Snapshot strategy: every N events hoặc mọi terminal state
No schema change, purely code-side.
12. Testing Strategy
| Layer | Test type | Tool |
|---|---|---|
| Domain (aggregates, VO, policies) | Pure unit (no mocks) | Jest |
| Application (commands, handlers) | Unit with in-memory repos | Jest |
| Infrastructure adapters | Integration với real sandbox (Bambora test env) | Jest + supertest |
| Cross-context (Booking ↔ Payment) | Integration test | Jest, full NestJS app |
| Webhook processing | E2E simulated with fixture payloads | Jest + signed fixtures |
| Saga flows | Scenario tests (given-when-then) | Jest |
Coverage target: 90% domain, 85% application, 70% infrastructure.
Test doubles:
InMemoryPaymentRepository— for application testsFakePaymentProvider— for application tests, deterministicFakeClock— controlled time for expiry testsFakeCipher— no real encryption trong tests
13. Liên quan
../flows/payment-fundamentals.md— Concepts, state machine, event/error reference../flows/payment-flow.md— 20 scenarios end-to-end../flows/booking-flow.md— Booking lifecycle + integration eventsrefund-as-row-refactor.md— Shipped 2026-05-11: refund flow chuyển sangPaymentIntent.REFUNDchild row +parentPaymentIdself-FK;Payment.refundedAmountcolumn đã drop, FE đọc aggregate qua DTO (sum củarefunds[].capturedAmount).api-design.md— REST conventions, error enveloperole-matrix.md— RBAC cho payment endpoints (OWNER-only refund/void/capture)../rules/development-rules.md— Coding standards, testing, git rules