Payment Architecture
BẮT BUỘC đọc trước khi viết code liên quan payment, deposit, refund, hoặc integration với booking.
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. Scope & Out-of-Scope
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/Worldline, future = Stripe, Vipps, Nets, Adyen
- Authorize + Capture (auto & manual modes)
- Void, Refund (full & partial)
- Webhook verification + idempotent processing
- Audit trail đầy đủ (compliance Finanstilsynet / PSD2)
Out of scope (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
┌──────────────────────────┐ ┌──────────────────────────┐
│ Booking Context │ │ Payment Context │
│ (existing, CRUD-style) │ │ (new, DDD) │
│ │ │ │
│ Booking │ │ Payment │
│ BookingItem │ │ PaymentConfig │
│ BookingAuditLog │ │ PaymentEvent │
│ │ │ WebhookInbox │
│ │──event──▶│ │
│ │◀─event───│ │
└──────────────────────────┘ └──────────────────────────┘
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.
Events 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. Decision Log
| # | Decision | Rationale |
|---|---|---|
| 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. |
4. Layer Architecture (Hexagonal)
┌──────────────────────────────────────────────────────────────────┐
│ INTERFACE LAYER (HTTP / NestJS) │
│ • PaymentController (admin REST) │
│ • PublicPaymentController (customer-facing) │
│ • WebhookController (POST /webhooks/payments/:provider) │
│ • DTOs │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER (CQRS commands/queries/listeners) │
│ Commands: │
│ • InitiatePaymentCommand • CapturePaymentCommand │
│ • VoidPaymentCommand • RefundPaymentCommand │
│ • ProcessWebhookCommand │
│ Queries: │
│ • GetPaymentQuery • ListPaymentsQuery │
│ • PaymentStatsQuery │
│ Integration Listeners (cross-context): │
│ • OnBookingCreatedListener • OnBookingCancelledListener │
│ • OnBookingNoShowListener • OnBookingConfirmedListener │
│ Sagas: │
│ • PaymentLifecycleSaga (authorize→capture/void flow) │
│ • RefundPolicyEvaluator │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER (pure TypeScript, zero framework dependency) │
│ Aggregates: │
│ • Payment (root) • PaymentConfig (root) │
│ Value Objects: │
│ • Money • PaymentId │
│ • ProviderRef • IdempotencyKey │
│ • PaymentIntent • CaptureMode │
│ Domain Events: │
│ • PaymentInitiated • PaymentAuthorized │
│ • PaymentCaptured • PaymentVoided │
│ • PaymentRefunded • PaymentFailed │
│ • PaymentExpired │
│ Policies: │
│ • CancellationRefundPolicy • AuthorizationExpiryPolicy │
│ • FeeCalculationPolicy │
│ Ports (outgoing interfaces): │
│ • PaymentProviderPort • PaymentRepositoryPort │
│ • PaymentConfigRepositoryPort │
│ • CredentialsCipherPort • ClockPort │
│ • DomainEventPublisherPort │
│ Errors: │
│ • InvalidStateTransitionError │
│ • ProviderError • WebhookVerificationError │
│ • CapturedAmountExceededError │
└──────────────────────────────────────────────────────────────────┘
▲
│ implements
┌──────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER (adapters — framework code allowed) │
│ Providers: │
│ • BamboraAdapter (BamboraClient + BamboraMapper) │
│ • [future] StripeAdapter, VippsAdapter, NetsAdapter │
│ • ProviderRegistry (key → adapter resolver) │
│ Persistence (Prisma): │
│ • PrismaPaymentRepository │
│ • PrismaPaymentConfigRepository │
│ • PrismaDomainEventOutboxRepository │
│ • PrismaWebhookInboxRepository │
│ • PaymentMapper (Prisma row ↔ domain aggregate) │
│ Crypto: │
│ • AesGcmCipher (AES-256-GCM, env key) │
│ • [future] KmsCipher │
│ Time: │
│ • SystemClock │
│ Workers: │
│ • OutboxPublisher (poll domain event outbox → EventBus) │
│ • WebhookInboxProcessor (process inbox → domain) │
│ • AuthorizationExpiryChecker (cron, detect expired AUTH) │
└──────────────────────────────────────────────────────────────────┘
Dependency rule: arrows 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 | CANCELLATION_FEE | NO_SHOW_FEE)
captureMode : CaptureMode (AUTO | MANUAL)
amount : Money (authorized amount)
capturedAmount : Money (actually captured)
refundedAmount : Money (cumulative refunded)
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)
Invariants:
- Status transitions valid only per state machine (§5.3)
capturedAmount ≤ amountrefundedAmount ≤ capturedAmountrefundedAmount + unreleased ≤ capturedAmount- Cannot void CAPTURED (must refund)
- Cannot refund AUTHORIZED (must void)
providerRef.transactionIdrequired after AUTHORIZEDamount.currency === capturedAmount.currency === refundedAmount.currency
Behaviors (public methods):
static initiate(...): Payment
authorize(providerRef, at, expiresAt?): void
capture(amount, at): void
void(at, reason?): void
refund(amount, reason, at): void
markFailed(code, message, at): void
markExpired(at): void
Mỗi behavior emit 1 domain event, push vào _pendingEvents[], chỉ publish khi aggregate commit success.
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>;
}
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 STRIPE VIPPS NETS ADYEN }
enum PaymentStatus { INITIATED AUTHORIZED CAPTURED PARTIALLY_REFUNDED REFUNDED VOIDED FAILED EXPIRED }
enum PaymentIntent { DEPOSIT FULL_PAYMENT CANCELLATION_FEE NO_SHOW_FEE }
enum CaptureMode { AUTO MANUAL }
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 Upgrade to manual capture mode default
- Add
tenant.settings.captureMode: 'auto' | 'manual' - Command handler respect tenant setting khi initiate
- Saga (PaymentLifecycleSaga) subscribe booking lifecycle → auto trigger capture
- Expiry cron job activate
- Admin UI: capture/void buttons
Schema đã ready — không migrate.
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. Related Docs
../flows/payment-flow.md— End-to-end flows, sequence diagrams../flows/booking-flow.md— Booking lifecycle, integration eventsapi-design.md— REST conventions, error envelope../rules/development-rules.md— Coding standards, testing, git rules