architecture/payment-architecture.md

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.depositEnabledInitiatePaymentCommand
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 ≤ amount
  • refundedAmount ≤ capturedAmount
  • refundedAmount + unreleased ≤ capturedAmount
  • Cannot void CAPTURED (must refund)
  • Cannot refund AUTHORIZED (must void)
  • providerRef.transactionId required after AUTHORIZED
  • amount.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 = true per (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 → VOID
  • status === AUTHORIZED + ngoài cancellation window → FORFEIT (capture → keep as cancellation fee)
  • status === CAPTURED + trong cancellation window → FULL_REFUND
  • status === 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)

  1. Mapper: external DTO ↔ domain model. Domain không bao giờ thấy Bambora JSON shape.
  2. Error translation: Bambora error codes → domain errors (ProviderError, InvalidCredentialsError, InsufficientFundsError).
  3. Idempotency: forward idempotency key trong headers provider yêu cầu (Stripe: Idempotency-Key, Bambora: X-Request-ID).
  4. Signature verify: HMAC-SHA256 của raw body với secret key provider.
  5. 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
  • idempotencyKey unique: enforce at DB level, race-safe
  • (provider, providerTransactionId) unique: reconciliation không tạo duplicate
  • (provider, providerEventId) unique trong WebhookInbox: dedup at insert time
  • keyVersion trong config: support key rotation (decrypt old ciphertext với old key, re-encrypt với new)
  • PaymentEvent vs DomainEventOutbox tá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_KEY env 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
  • CredentialsCipherPort interface → 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 OWNER role (không phải STAFF) cho refund/void
  • Config credentials: chỉ OWNER xem được (masked), STAFF khô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, …)

  1. Create infrastructure/providers/<name>/<name>.adapter.ts implement PaymentProviderPort
  2. Add enum value to PaymentProvider + Prisma migration
  3. Register trong ProviderRegistry
  4. Add webhook route: /webhooks/payments/<name>/:tenantId
  5. Add adapter-specific credentials shape (documented in adapter)
  6. Frontend settings UI: render provider-specific form fields
  7. Tests: contract tests against sandbox

Zero change: domain layer, application layer, Booking context.

11.2 Add new PaymentIntent (e.g., giftCard, subscription)

  1. Add enum value to PaymentIntent
  2. Add policy if behavior differs
  3. Application listener cho context raise intent mới
  4. UI cho admin thấy intent type

11.3 Upgrade to KMS

  1. Implement KmsCipher implements CredentialsCipherPort
  2. DI swap in module registration
  3. Migration script: decrypt với env key → encrypt với KMS key → update keyVersion

11.4 Upgrade to manual capture mode default

  1. Add tenant.settings.captureMode: 'auto' | 'manual'
  2. Command handler respect tenant setting khi initiate
  3. Saga (PaymentLifecycleSaga) subscribe booking lifecycle → auto trigger capture
  4. Expiry cron job activate
  5. 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:

  1. Payment.replay(events: DomainEvent[]): Payment static factory
  2. Repository option: load latest snapshot + replay events after snapshot
  3. 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 tests
  • FakePaymentProvider — for application tests, deterministic
  • FakeClock — controlled time for expiry tests
  • FakeCipher — no real encryption trong tests