DOMAIN_MODEL — payment-gateway-service
Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS
Strategic anchors: 10 Payments Architecture §5 PaymentPort · 02 Enterprise Architecture §3 · standards/NAMING · standards/ERROR_CODES
The domain model is pure TypeScript. No NestJS decorators, no Drizzle imports, no pg, no fetch, no vendor SDK type. The CI dependency-graph guard fails any PR introducing those imports under src/domain/. The model below is the source of truth for invariants, value objects, state machines, and the PaymentPort contract; the SQL projection (DATA_MODEL.md) and wire DTOs (API_CONTRACTS.md) are downstream of this.
1. Value objects
import { Branded } from '@ghasi/domain-primitives';
export type TenantId = Branded<string, 'TenantId'>; // tnt_…
export type PropertyId = Branded<string, 'PropertyId'>; // ppt_…
export type GuestId = Branded<string, 'GuestId'>; // gst_…
export type ReservationId = Branded<string, 'ReservationId'>; // rsv_…
export type FolioId = Branded<string, 'FolioId'>; // fol_… (reference)
export type UserId = Branded<string, 'UserId'>; // usr_…
export type DeviceId = Branded<string, 'DeviceId'>; // dev_…
export type PaymentId = Branded<string, 'PaymentId'>; // pay_…
export type AuthorizationId = Branded<string, 'AuthorizationId'>; // auth_…
export type CaptureId = Branded<string, 'CaptureId'>; // cap_…
export type RefundId = Branded<string, 'RefundId'>; // rfd_…
export type VoidId = Branded<string, 'VoidId'>; // vd_…
export type PaymentMethodId = Branded<string, 'PaymentMethodId'>; // pm_…
export type WebhookId = Branded<string, 'WebhookId'>; // whk_…
export type ReconciliationId = Branded<string, 'ReconciliationId'>;// rec_…
export type ChargebackId = Branded<string, 'ChargebackId'>; // cbk_…
export type IdempotencyKey = Branded<string, 'IdempotencyKey'>; // ULID, 26 chars
export type ProcessorRef = Branded<string, 'ProcessorRef'>; // vendor's id, opaque
export type ExternalEventId = Branded<string, 'ExternalEventId'>; // dedupe key for webhooks
// Money — bigint micro-units; closed under same currency only
export interface Money {
readonly amountMicro: bigint; // 12_500_000n = 12.50; never floats
readonly currency: CurrencyCode;
}
export type CurrencyCode =
| 'AFN' | 'IRR' | 'TJS'
| 'USD' | 'EUR' | 'AED' | 'INR' | 'PKR' | 'SAR'
| 'GBP' | 'KES' | 'CNY';
export const Money = {
zero: (c: CurrencyCode): Money => ({ amountMicro: 0n, currency: c }),
add: (a: Money, b: Money): Money => {
if (a.currency !== b.currency) throw new CurrencyMismatchError(a.currency, b.currency);
return { amountMicro: a.amountMicro + b.amountMicro, currency: a.currency };
},
sub: (a: Money, b: Money): Money => {
if (a.currency !== b.currency) throw new CurrencyMismatchError(a.currency, b.currency);
return { amountMicro: a.amountMicro - b.amountMicro, currency: a.currency };
},
gte: (a: Money, b: Money): boolean =>
a.currency === b.currency && a.amountMicro >= b.amountMicro,
isZero:(a: Money): boolean => a.amountMicro === 0n,
};
// FX context — captured at authorize time; immutable
export interface FxContext {
readonly base: CurrencyCode; // guest-quote currency
readonly quote: CurrencyCode; // tenant-settle currency
readonly rate: number; // 1 base = rate * quote
readonly source: 'ecb' | 'openexchangerates' | 'tenant_pinned' | 'manual_override';
readonly provider: string;
readonly quotedAt: ISODate;
readonly snapshotId: string;
}
// Payment method kinds
export type PaymentMethodKind =
| 'cash_on_arrival'
| 'paypal'
| 'card' // generic card (Stripe, Adyen, …)
| 'mfs' // mobile-financial-services (HesabPay, M-Pesa, …)
| 'apple_pay'
| 'google_pay'
| 'crypto_stablecoin';
export type ProcessorName =
| 'stripe' | 'paypal' | 'hesabpay' | 'cash'
| 'adyen' | 'razorpay' | 'mpesa' | 'apple_pay' | 'google_pay' | 'stablecoin';
// Refund reasons (closed enum)
export type RefundReason =
| 'cancellation_within_policy'
| 'cancellation_goodwill'
| 'overcharge_correction'
| 'service_failure'
| 'duplicate_charge'
| 'fraud_chargeback'
| 'no_show_partial';
// Chargeback reasons (mapped from processor codes)
export type ChargebackReason =
| 'fraud' | 'duplicate' | 'product_not_received'
| 'product_unacceptable' | 'subscription_cancelled'
| 'credit_not_processed' | 'general' | 'unknown';
export type ISODate = Branded<string, 'ISODate'>; // RFC 3339 UTC
Money is the only monetary type the domain knows. Never floats, never display strings, never combined-string "$100.50". Cross-currency math is illegal except via an explicit FxContext.
2. The PaymentPort interface
This is the canonical declaration; the file lives at src/application/ports/payment.port.ts and is the only symbol the rest of the platform imports for payment operations.
export interface AuthorizeInput {
tenantId: TenantId;
propertyId: PropertyId;
reservationId: ReservationId;
guestId: GuestId;
amount: Money;
method: {
kind: PaymentMethodKind;
paymentMethodId?: PaymentMethodId; // existing tokenized method
processorRef?: ProcessorRef; // raw processor token (fresh hosted-fields capture)
metadata?: Record<string, string>;
};
fxContext?: FxContext;
capture: 'manual' | 'automatic';
description?: string;
idempotencyKey: IdempotencyKey;
initiatedBy: { type: 'guest' | 'staff' | 'system'; id: UserId | 'system' };
}
export interface AuthorizeResult {
paymentId: PaymentId;
authorizationId: AuthorizationId;
status: 'authorized' | 'pending' | 'requires_action' | 'failed';
requiresAction?: {
type: '3ds_redirect' | 'mfs_otp' | 'webhook_async';
url?: string;
ref?: string;
expiresAt?: ISODate;
};
expiresAt?: ISODate; // authorization validity window
processor: ProcessorName;
warnings?: Array<{ code: string; detail: string }>;
}
export interface CaptureResult {
paymentId: PaymentId;
captureId: CaptureId;
status: 'captured' | 'pending' | 'failed';
capturedAt?: ISODate;
amount: Money;
}
export interface RefundResult {
refundId: RefundId;
paymentId: PaymentId;
status: 'refunded' | 'pending' | 'failed';
amount: Money;
reason: RefundReason;
refundedAt?: ISODate;
}
export interface Transaction {
paymentId: PaymentId;
tenantId: TenantId;
reservationId: ReservationId;
amount: Money;
status: TransactionStatus;
method: PaymentMethodKind;
processor: ProcessorName;
fxContext?: FxContext;
authorization?: { id: AuthorizationId; expiresAt: ISODate };
captures: Array<{ id: CaptureId; amount: Money; capturedAt: ISODate }>;
refunds: Array<{ id: RefundId; amount: Money; reason: RefundReason; refundedAt: ISODate }>;
events: Array<{ at: ISODate; type: string; processorRef?: ProcessorRef }>;
createdAt: ISODate;
updatedAt: ISODate;
version: number; // OCC
}
export type TransactionStatus =
| 'pending'
| 'authorized'
| 'requires_action'
| 'captured'
| 'partially_refunded'
| 'refunded'
| 'voided'
| 'failed'
| 'pending_cash';
export interface ReconciliationReport {
reconciliationId: ReconciliationId;
date: ISODate;
processor: ProcessorName;
matched: { count: number; total: Money };
unmatched: { count: number; total: Money; entries: ReconciliationDelta[] };
refundsMatched: { count: number; total: Money };
fees: Money;
net: Money;
source: { reportId: string; ingestedAt: ISODate };
}
export interface ReconciliationDelta {
side: 'platform_only' | 'processor_only';
paymentId?: PaymentId;
processorRef?: ProcessorRef;
amount: Money;
reason: string;
}
export type PaymentError =
| { code: 'MELMASTOON.PAYMENT.GATEWAY_TIMEOUT'; retriable: true; processor: ProcessorName }
| { code: 'MELMASTOON.PAYMENT.DECLINED'; retriable: false; processor: ProcessorName; declineCode?: string }
| { code: 'MELMASTOON.PAYMENT.INSUFFICIENT_FUNDS'; retriable: false; processor: ProcessorName }
| { code: 'MELMASTOON.PAYMENT.INTENT_NOT_FOUND'; retriable: false }
| { code: 'MELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALID'; retriable: false; processor: ProcessorName }
| { code: 'MELMASTOON.PAYMENT.CASH_RECONCILIATION_PENDING'; retriable: true };
export interface PaymentPort {
authorize(input: AuthorizeInput): Promise<AuthorizeResult>;
capture(authorizationId: AuthorizationId, amount: Money | undefined, idempotencyKey: IdempotencyKey): Promise<CaptureResult>;
refund(paymentId: PaymentId, amount: Money, reason: RefundReason, idempotencyKey: IdempotencyKey): Promise<RefundResult>;
void(authorizationId: AuthorizationId, idempotencyKey: IdempotencyKey): Promise<void>;
getTransaction(id: PaymentId): Promise<Transaction>;
reconcileBatch(date: ISODate): Promise<ReconciliationReport>;
tokenize(input: TokenizeInput): Promise<TokenizeResult>;
describeAdapter(): AdapterDescriptor;
}
export interface TokenizeInput {
tenantId: TenantId;
guestId: GuestId;
method: PaymentMethodKind;
returnUrl?: string;
idempotencyKey: IdempotencyKey;
}
export interface TokenizeResult {
sessionId: string;
clientSecret: string; // ephemeral; consumed by hosted-fields client
expiresAt: ISODate;
}
export interface AdapterDescriptor {
processor: ProcessorName;
methods: PaymentMethodKind[];
capabilities: {
partialCapture: boolean;
partialRefund: boolean;
voidWindow: boolean;
voidWindowSeconds?: number;
threeDSecure: boolean;
asyncConfirm: boolean;
multiCapture: boolean;
};
currencies: CurrencyCode[];
}
2.1 Invariants enforced by the port
Moneyis always{ amountMicro: bigint, currency: CurrencyCode }. Floats are illegal in the domain.- No method takes raw card data. Card data flows browser/app → processor (Stripe Elements / PayPal hosted fields). The platform receives only
processorRef. - Every mutating method takes
idempotencyKey. Replays return the same result; they do not double-charge. - The
cashadapter implements the same port. It returns syntheticprocessor: 'cash'records and never calls a network. - Adapter errors normalize to
PaymentError; vendor SDK exception types do not leak past the adapter boundary. capture(amount=undefined)captures the full authorized amount.refund(amount)must satisfyΣ refunds + amount ≤ Σ capturesfor the samepaymentId; else throwMELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE.
3. Aggregates
3.1 Transaction (root)
export interface Transaction {
id: PaymentId;
tenantId: TenantId;
propertyId: PropertyId;
reservationId: ReservationId;
guestId: GuestId;
status: TransactionStatus; // §4 state machine
method: PaymentMethodKind;
processor: ProcessorName;
paymentMethodId?: PaymentMethodId; // present if a saved method was used
amount: Money; // requested amount in guest-quote currency
fxContext?: FxContext; // present for cross-currency
settledAmount?: Money; // in tenant-settle currency, post-FX
authorization?: AuthorizationRef;
captures: CaptureRef[]; // 0..N
refunds: RefundRef[]; // 0..N
void?: VoidRef;
description?: string;
initiatedBy: { type: 'guest' | 'staff' | 'system'; id: UserId | 'system' };
channel: 'tenant_booking' | 'consumer_meta' | 'walk_in' | 'phone_by_staff' | 'system_recovery';
events: TransactionEvent[]; // append-only audit
pendingWebhook?: { externalEventId: ExternalEventId; expectedBy: ISODate };
createdAt: ISODate;
updatedAt: ISODate;
version: number; // OCC
idempotencyKey: IdempotencyKey; // the key that created this transaction
}
export interface AuthorizationRef { id: AuthorizationId; processorRef: ProcessorRef; expiresAt: ISODate; }
export interface CaptureRef { id: CaptureId; processorRef: ProcessorRef; amount: Money; capturedAt: ISODate; }
export interface RefundRef { id: RefundId; processorRef: ProcessorRef; amount: Money; reason: RefundReason; refundedAt: ISODate; }
export interface VoidRef { id: VoidId; processorRef: ProcessorRef; voidedAt: ISODate; }
export interface TransactionEvent {
id: string;
at: ISODate;
type: 'created' | 'authorized' | 'captured' | 'refunded' | 'voided' | 'failed' | 'webhook_received';
processorRef?: ProcessorRef;
detail?: Record<string, unknown>;
}
3.2 PaymentMethod
export interface PaymentMethod {
id: PaymentMethodId;
tenantId: TenantId;
guestId: GuestId;
kind: PaymentMethodKind;
processor: ProcessorName;
processorToken: ProcessorRef; // encrypted at rest (KMS envelope)
display: { // returned by processor; non-PCI
brand?: 'visa' | 'mastercard' | 'amex' | 'discover' | 'unionpay' | 'jcb' | 'other';
last4?: string; // 4 digits
expMonth?: number; // 1..12
expYear?: number; // 4-digit
holderName?: string;
walletKind?: 'apple_pay' | 'google_pay';
msisdn?: string; // for MFS
};
status: 'active' | 'expired' | 'detached';
lastUsedAt?: ISODate;
createdAt: ISODate;
}
The processorToken is never logged, never returned to clients, and never exits the service except via the adapter for that vendor.
3.3 Webhook
export interface Webhook {
id: WebhookId;
tenantId: TenantId | null; // null until routed
processor: ProcessorName;
externalEventId: ExternalEventId; // unique per processor
signatureValid: boolean;
receivedAt: ISODate;
rawPayloadRef: string; // GCS object name (encrypted)
headers: Record<string, string>;
status: 'received' | 'processing' | 'processed' | 'duplicate_dropped' | 'failed' | 'dlq';
attempts: number;
processedAt?: ISODate;
error?: PaymentError;
appliedTo?: PaymentId;
}
3.4 Reconciliation, Chargeback, AdapterHealth
export interface Reconciliation {
id: ReconciliationId;
tenantId: TenantId;
processor: ProcessorName;
date: ISODate;
matched: { count: number; total: Money };
unmatched: { count: number; total: Money; entries: ReconciliationDelta[] };
fees: Money;
net: Money;
source: { reportId: string; ingestedAt: ISODate; reportUri: string };
status: 'in_progress' | 'completed' | 'failed';
completedAt?: ISODate;
}
export interface Chargeback {
id: ChargebackId;
tenantId: TenantId;
paymentId: PaymentId;
reservationId: ReservationId;
processor: ProcessorName;
processorRef: ProcessorRef;
reason: ChargebackReason;
amount: Money;
status: 'open' | 'evidence_required' | 'evidence_submitted' | 'won' | 'lost' | 'expired';
evidenceDeadline: ISODate;
evidence?: {
submittedAt: ISODate;
bundleRef: string; // GCS object
narrative: string;
aiAssisted: boolean;
aiProvenanceId?: string;
};
outcomeAt?: ISODate;
feeAmount?: Money;
}
export interface AdapterHealth {
processor: ProcessorName;
env: 'sandbox' | 'production';
windowSeconds: number;
windowStart: ISODate;
successCount: number;
errorCount: number;
p99LatencyMs: number;
circuitState: 'closed' | 'open' | 'half_open';
openedAt?: ISODate;
lastSuccessAt?: ISODate;
lastErrorAt?: ISODate;
}
4. State machines
4.1 Transaction.status
┌────────────┐
│ pending │ (intent persisted, adapter not yet called)
└─────┬──────┘
┌─────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌──────────────┐
│ authorized │ │requires_action│ │ failed │ (terminal)
└─────┬──────┘ └─────┬────────┘ └──────────────┘
│ │ guest 3DS / OTP / async webhook
│ ▼
│ ┌────────────┐
│ │ authorized │
│ └─────┬──────┘
│ │
▼ ▼
┌──────────────────────┐
│ pending_cash (cash) │ cash variant: stays here until front-desk capture
└─────────┬────────────┘
│
▼
┌────────────┐ ┌──────────┐
│ captured │ ◀──── void ──▶│ voided │ (terminal)
└─────┬──────┘ └──────────┘
│ refund < captured
▼
┌────────────────────┐
│ partially_refunded │
└─────────┬──────────┘
│ refund total = captured
▼
┌────────────┐
│ refunded │ (terminal)
└────────────┘
Legal transitions are enforced by Transaction.transitionTo(status); illegal transitions throw InvalidStateTransitionError with code: 'MELMASTOON.PAYMENT.INVALID_STATE_TRANSITION'.
4.2 Webhook.status
received → processing → { processed | duplicate_dropped }
↓ on error (retriable)
retry (exponential backoff, max 5)
↓ exhausted
dlq → manual ops triage → replayed or buried
4.3 Chargeback.status
open → evidence_required → evidence_submitted → { won | lost }
↑
expired (no submission by deadline) ──┘
5. Domain events (names + payload sketches)
Event subjects follow NAMING §events. Full payloads + JSON schemas live in EVENT_SCHEMAS.md. Names emitted from this aggregate root:
| Subject | When |
|---|---|
melmastoon.payment.transaction.created.v1 | Intent persisted, before adapter call |
melmastoon.payment.transaction.authorized.v1 | Adapter returned authorized |
melmastoon.payment.transaction.captured.v1 | Capture (full or partial) succeeded |
melmastoon.payment.transaction.refunded.v1 | Refund (full or partial) succeeded |
melmastoon.payment.transaction.voided.v1 | Authorization voided |
melmastoon.payment.transaction.failed.v1 | Terminal failure |
melmastoon.payment.method.tokenized.v1 | Hosted-fields confirmation; method attached |
melmastoon.payment.method.detached.v1 | Method detached; processor token deletion enqueued |
melmastoon.payment.webhook.received.v1 | Signed webhook persisted to inbox |
melmastoon.payment.webhook.processed.v1 | Webhook applied to state |
melmastoon.payment.webhook.duplicate_dropped.v1 | Webhook deduped via externalEventId |
melmastoon.payment.reconciliation.completed.v1 | Daily reconciliation ran cleanly |
melmastoon.payment.reconciliation.discrepancy_found.v1 | Mismatch detected |
melmastoon.payment.chargeback.received.v1 | Dispute opened |
melmastoon.payment.chargeback.evidence_submitted.v1 | Evidence submitted to processor |
melmastoon.payment.chargeback.won.v1 | Outcome: won |
melmastoon.payment.chargeback.lost.v1 | Outcome: lost |
melmastoon.payment.adapter.health_changed.v1 | Circuit-breaker state change |
6. Domain errors
Domain errors map 1:1 to ERROR_CODES. All extend DomainError.
export class GatewayTimeoutError extends DomainError { code = 'MELMASTOON.PAYMENT.GATEWAY_TIMEOUT'; }
export class PaymentDeclinedError extends DomainError { code = 'MELMASTOON.PAYMENT.DECLINED'; }
export class InsufficientFundsError extends DomainError { code = 'MELMASTOON.PAYMENT.INSUFFICIENT_FUNDS'; }
export class IntentNotFoundError extends DomainError { code = 'MELMASTOON.PAYMENT.INTENT_NOT_FOUND'; }
export class WebhookSignatureError extends DomainError { code = 'MELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALID'; }
export class CashReconciliationPending extends DomainError { code = 'MELMASTOON.PAYMENT.CASH_RECONCILIATION_PENDING'; }
export class CrossTenantReferenceError extends DomainError { code = 'MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE'; }
export class InvalidStateTransitionError extends DomainError { code = 'MELMASTOON.PAYMENT.INVALID_STATE_TRANSITION'; }
export class RefundExceedsBalanceError extends DomainError { code = 'MELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE'; }
export class CurrencyMismatchError extends DomainError { code = 'MELMASTOON.PRICING.CURRENCY_MISMATCH'; }
export class PanExposureBlocked extends DomainError { code = 'MELMASTOON.PAYMENT.PAN_EXPOSURE_BLOCKED'; }
PanExposureBlocked is raised by the redaction layer when a PAN regex matches in a log payload, in a request body, or in a webhook payload destined for storage. It is treated as a Sev-1.
7. Tenant-scoped invariants
- Every aggregate root carries
tenantId. Every cross-aggregate reference passestenantIdto the constructor; mismatch throwsCrossTenantReferenceError. - Repository methods take
tenantIdas an explicit parameter; no implicit ALS-only repositories. - Schema-per-tenant means the connection's
search_pathis set totenant_<uuid>_paymentsbefore any query; the domain assumes (and integration tests prove) this is enforced at the adapter layer.
8. Where the model is exercised
| Use case | Domain methods involved |
|---|---|
| AuthorizePayment | Transaction.create, Transaction.transitionTo('authorized'), FxContext.snapshot |
| CapturePayment | Transaction.transitionTo('captured' | 'partially_refunded'), Capture.add |
| RefundPayment | Transaction.transitionTo('partially_refunded' | 'refunded'), Refund.add, balance check |
| VoidPayment | Transaction.transitionTo('voided'), Void.add |
| TokenizePaymentMethod | PaymentMethod.create, PaymentMethod.attach |
| ProcessWebhook | Webhook.markProcessed / markDuplicate, Transaction.applyWebhookFact |
| RunDailyReconciliation | Reconciliation.compose, ReconciliationDelta.classify |
| RecordCashPayment | Transaction.create(method='cash_on_arrival'), Capture.add(operatorId) |
| RecordChargeback | Chargeback.open, Transaction.attachChargeback |
| SubmitChargebackEvidence | Chargeback.submitEvidence(bundleRef, narrative, aiProvenance?) |