architecture/payment-architecture.md

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 qua PaymentProviderPort (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.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. 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:

  1. Display ambiguitycapturedAmount=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).
  2. 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.
  3. 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.
  4. 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 → CapturePayment listener (~150 LoC)
  • Bỏ AuthorizationExpiryChecker cron 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 kr−5 kr row (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 refundedAmount field 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 a refundedAmount aggregate so external consumers (FE list, reporting) see no shape change.

Invariants:

  • Status transitions valid only per state machine (§5.3)
  • capturedAmount ≤ amount
  • intent = 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.transactionId required after AUTHORIZED
  • amount.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:

  • PaymentRefundCreated on the child (carries reason, idempotencyKey, parent linkage)
  • PaymentPartiallyRefunded / PaymentRefunded on the parent after applyRefundProjection — 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 = 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>;
}

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)

  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 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
  • 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 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:

  1. Add tenant.settings.captureMode: 'instant' | 'manual' (per-tenant opt-in, default 'instant')
  2. Command handler respect tenant setting khi initiate session
  3. Re-attach OnBookingArrived → CapturePaymentCommand listener (đã có code, comment out tại deploy ADR-001)
  4. Re-enable AuthorizationExpiryChecker cron job
  5. Admin UI: capture/void buttons (đã có code)
  6. 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:

  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

13. Liên quan