Skip to main content

APPLICATION_LOGIC — payment-gateway-service

Sibling: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL

Strategic anchors: 10 Payments Architecture · 04 Event-Driven Architecture · standards/SERVICE_TEMPLATE

The application layer composes domain objects with ports (interfaces declared here, implemented in infrastructure/). Use cases are thin: they validate input, load aggregate state, call domain methods, persist via the outbox, and publish events. They never embed vendor SDKs, never touch SQL, never read environment variables.


1. Ports

// Adapter for one vendor — every vendor implements PaymentPort (DOMAIN_MODEL §2)
export interface PaymentPort { /* see DOMAIN_MODEL §2 */ }

// Repositories (one per aggregate; tenant-scoped)
export interface TransactionRepository {
save(tx: Transaction): Promise<void>; // upsert + outbox
findById(tenantId: TenantId, id: PaymentId): Promise<Transaction | null>;
findByIdempotencyKey(tenantId: TenantId, key: IdempotencyKey): Promise<Transaction | null>;
findByReservation(tenantId: TenantId, rsv: ReservationId): Promise<Transaction[]>;
listForReconciliation(tenantId: TenantId, processor: ProcessorName, date: ISODate): Promise<Transaction[]>;
}

export interface PaymentMethodRepository {
save(pm: PaymentMethod): Promise<void>;
findById(tenantId: TenantId, id: PaymentMethodId): Promise<PaymentMethod | null>;
listByGuest(tenantId: TenantId, guestId: GuestId): Promise<PaymentMethod[]>;
detach(tenantId: TenantId, id: PaymentMethodId): Promise<void>;
}

export interface WebhookRepository {
insertIfNew(w: Webhook): Promise<{ inserted: boolean; existing?: Webhook }>;
markProcessing(id: WebhookId): Promise<void>;
markProcessed(id: WebhookId, appliedTo?: PaymentId): Promise<void>;
markFailed(id: WebhookId, error: PaymentError): Promise<void>;
enqueueDlq(id: WebhookId): Promise<void>;
drainPending(processor: ProcessorName, batchSize: number): Promise<Webhook[]>;
}

export interface ReconciliationRepository {
save(rec: Reconciliation): Promise<void>;
findByDate(tenantId: TenantId, processor: ProcessorName, date: ISODate): Promise<Reconciliation | null>;
}

export interface ChargebackRepository {
save(cb: Chargeback): Promise<void>;
findById(tenantId: TenantId, id: ChargebackId): Promise<Chargeback | null>;
listOpen(tenantId: TenantId): Promise<Chargeback[]>;
}

export interface IdempotencyKeyStore {
// Central across schemas; key = `<tenantId>:<requestId>` ensures cross-tenant uniqueness
reserve(key: IdempotencyKey, requestHash: string, ttlSeconds: number): Promise<{ acquired: boolean; existingResultRef?: string }>;
attachResult(key: IdempotencyKey, resultRef: string): Promise<void>;
loadResult(key: IdempotencyKey): Promise<string | null>;
}

export interface AdapterRegistry {
for(tenantId: TenantId, method: PaymentMethodKind, currency: CurrencyCode, amount: Money): Promise<PaymentPort>;
describe(processor: ProcessorName): AdapterDescriptor;
health(processor: ProcessorName): AdapterHealth;
recordResult(processor: ProcessorName, ok: boolean, latencyMs: number): void;
}

export interface ExchangeRatePort {
getRate(from: CurrencyCode, to: CurrencyCode, at?: ISODate): Promise<FxContext>;
getBatch(from: CurrencyCode, to: CurrencyCode[], at?: ISODate): Promise<Record<CurrencyCode, FxContext>>;
}

export interface SecretResolver {
// Resolves Secret Manager resource names → secret material
resolve(resource: string): Promise<string>;
rotate(resource: string): Promise<void>;
}

export interface AuditLogger {
// Append-only, audit-service-bound trail of payment operations
record(event: { kind: string; tenantId: TenantId; actor: string; payload: Record<string, unknown> }): Promise<void>;
}

export interface AIClient {
// Routed via ai-orchestrator-service; never direct LLM calls (see AI_INTEGRATION.md)
scoreFraud(input: FraudScoreInput): Promise<FraudScoreResult>;
draftDisputeNarrative(input: DisputeDraftInput): Promise<DisputeDraftResult>;
}

export interface EventPublisher {
publish<T>(envelope: EventEnvelope<T>): Promise<void>; // through outbox
}

export interface Clock { now(): ISODate; }

The payment.port.ts is the only port the platform's other services know about. Everything else is internal.


2. Use cases

Each use case is implemented as a class with a single execute(input) method. All accept a RequestContext (tenantId, userId, traceId, requestId, idempotencyKey).

2.1 AuthorizePayment use case

export class AuthorizePaymentUseCase {
constructor(
private txs: TransactionRepository,
private adapters: AdapterRegistry,
private fx: ExchangeRatePort,
private idem: IdempotencyKeyStore,
private events: EventPublisher,
private audit: AuditLogger,
private ai: AIClient,
private clock: Clock,
) {}

async execute(cmd: AuthorizePaymentCommand): Promise<AuthorizeResult> {
// 1. Idempotency: dedupe at the use case boundary
const reservation = await this.idem.reserve(cmd.idempotencyKey, hash(cmd), 86_400);
if (!reservation.acquired) return loadFromResult(reservation.existingResultRef!);

// 2. Load adapter (tenant precedence, circuit-breaker aware)
const adapter = await this.adapters.for(cmd.tenantId, cmd.method.kind, cmd.amount.currency, cmd.amount);

// 3. FX snapshot if cross-currency
const tenantSettleCurrency = await this.tenantSettleCurrency(cmd.tenantId);
const fxContext = cmd.amount.currency !== tenantSettleCurrency
? await this.fx.getRate(cmd.amount.currency, tenantSettleCurrency)
: undefined;

// 4. Persist Transaction(pending) + outbox transaction.created.v1 (atomic)
const tx = Transaction.create({
...cmd, processor: adapter.describe().processor, fxContext, createdAt: this.clock.now(),
});
await this.txs.save(tx); // outbox written in same SQL TX

// 5. Call adapter
const t0 = Date.now();
let result: AuthorizeResult;
try {
result = await adapter.authorize({ ...cmd, fxContext });
this.adapters.recordResult(tx.processor, true, Date.now() - t0);
} catch (e) {
this.adapters.recordResult(tx.processor, false, Date.now() - t0);
tx.transitionTo('failed', { reason: errorCode(e) });
await this.txs.save(tx); // outbox transaction.failed.v1
throw e;
}

// 6. Apply result; transition; persist; outbox events
tx.applyAuthorization(result);
await this.txs.save(tx); // outbox transaction.authorized.v1

// 7. Async fraud score (non-blocking; HITL if high risk)
this.ai.scoreFraud(toFraudInput(tx)).catch(() => { /* swallowed; logged */ });

// 8. Audit + idempotency commit
await this.audit.record({ kind: 'payment.authorize', tenantId: cmd.tenantId, actor: cmd.initiatedBy.id, payload: { paymentId: tx.id } });
await this.idem.attachResult(cmd.idempotencyKey, tx.id);

return result;
}
}

Failure paths — adapter timeout → MELMASTOON.PAYMENT.GATEWAY_TIMEOUT (retriable; saga retries with same key); decline → MELMASTOON.PAYMENT.DECLINED (non-retriable; saga compensates by releasing inventory). The transaction stays persisted in failed state for audit.

2.2 CapturePayment use case

export class CapturePaymentUseCase {
async execute(cmd: CapturePaymentCommand): Promise<CaptureResult> {
const reserved = await this.idem.reserve(cmd.idempotencyKey, hash(cmd), 86_400);
if (!reserved.acquired) return loadCapture(reserved.existingResultRef!);

const tx = await this.txs.findById(cmd.tenantId, cmd.paymentId);
if (!tx) throw new IntentNotFoundError();
tx.assertCanCapture(cmd.amount); // domain invariant
const adapter = this.adapters.bound(tx.processor);

const result = await adapter.capture(tx.authorization!.id, cmd.amount, cmd.idempotencyKey);
tx.applyCapture(result);
await this.txs.save(tx); // outbox transaction.captured.v1

await this.idem.attachResult(cmd.idempotencyKey, result.captureId);
return result;
}
}

Cash variant: when tx.processor === 'cash', the adapter performs no network call and persists a Capture row with operatorId and cashDrawerShiftId from the request context. Available offline on the desktop (the request enters via the desktop sync push channel; see SYNC_CONTRACT).

2.3 RefundPayment use case

export class RefundPaymentUseCase {
async execute(cmd: RefundPaymentCommand): Promise<RefundResult> {
const reserved = await this.idem.reserve(cmd.idempotencyKey, hash(cmd), 86_400);
if (!reserved.acquired) return loadRefund(reserved.existingResultRef!);

const tx = await this.txs.findById(cmd.tenantId, cmd.paymentId);
if (!tx) throw new IntentNotFoundError();
tx.assertCanRefund(cmd.amount); // throws RefundExceedsBalanceError if Σ refunds + amount > Σ captures

const adapter = this.adapters.bound(tx.processor);
const result = await adapter.refund(tx.id, cmd.amount, cmd.reason, cmd.idempotencyKey);
tx.applyRefund(result);
await this.txs.save(tx); // outbox transaction.refunded.v1

await this.idem.attachResult(cmd.idempotencyKey, result.refundId);
return result;
}
}

Special case: partial refund when capture is not yet settled. Some processors (PayPal sandbox, certain MFS rails) return pending for refunds against unsettled captures. The use case persists status: 'pending' and waits for the matching webhook to flip partially_refunded / refunded. The reservation-side caller sees transaction.refunded.v1 only after the webhook resolution.

2.4 VoidPayment use case

export class VoidPaymentUseCase {
async execute(cmd: VoidPaymentCommand): Promise<void> {
const tx = await this.txs.findById(cmd.tenantId, cmd.paymentId);
if (!tx) throw new IntentNotFoundError();
tx.assertCanVoid(this.clock.now()); // void window per adapter capability

const adapter = this.adapters.bound(tx.processor);
await adapter.void(tx.authorization!.id, cmd.idempotencyKey);
tx.applyVoid({ voidedAt: this.clock.now() });
await this.txs.save(tx); // outbox transaction.voided.v1
}
}

2.5 TokenizePaymentMethod use case

export class TokenizePaymentMethodUseCase {
// Two-step: (1) create hosted-fields session; (2) on hosted-fields confirm webhook, attach.
async createSession(cmd: CreateTokenizationSessionCommand): Promise<TokenizeResult> {
const adapter = await this.adapters.for(cmd.tenantId, cmd.method, /*ccy*/'USD', Money.zero('USD'));
return adapter.tokenize({ ...cmd });
}

async finalize(cmd: FinalizeTokenizationCommand): Promise<PaymentMethod> {
// Triggered by webhook: setup_intent.succeeded (Stripe), VAULT.CREDIT-CARD.CREATED (PayPal), etc.
const display = await this.adapters.bound(cmd.processor).fetchDisplayDetails(cmd.processorToken);
const pm = PaymentMethod.create({
tenantId: cmd.tenantId, guestId: cmd.guestId, kind: cmd.method, processor: cmd.processor,
processorToken: encrypt(cmd.processorToken), display, status: 'active',
});
await this.pms.save(pm); // outbox method.tokenized.v1
return pm;
}
}

2.6 ProcessWebhook use case (per vendor; same shape)

export class ProcessWebhookUseCase {
async receive(req: ReceiveWebhookCommand): Promise<void> {
// Receiver path: < 50ms, no business logic
const signatureValid = this.verifySignature(req); // throws WebhookSignatureError → 401
const w = Webhook.fromRaw({ ...req, signatureValid });
const { inserted } = await this.webhooks.insertIfNew(w);
if (!inserted) {
await this.events.publish(envelope('webhook.duplicate_dropped.v1', { id: w.externalEventId }));
return;
}
await this.events.publish(envelope('webhook.received.v1', { id: w.id }));
// Dispatcher worker drains; receiver returns 202.
}

async dispatch(w: Webhook): Promise<void> {
await this.webhooks.markProcessing(w.id);
try {
const fact = this.parseFact(w); // vendor-specific
const tx = await this.txs.findByProcessorRef(w.tenantId!, w.processor, fact.processorRef);
if (!tx) {
// Webhook arrived before our intent persisted (race). Re-enqueue with backoff.
throw new RetryableError('intent_not_yet_persisted');
}
tx.applyWebhookFact(fact);
await this.txs.save(tx); // outbox transaction.<verb>.v1
await this.webhooks.markProcessed(w.id, tx.id);
await this.events.publish(envelope('webhook.processed.v1', { id: w.id, paymentId: tx.id }));
} catch (e) {
if (w.attempts >= 5) await this.webhooks.enqueueDlq(w.id);
else throw e; // backoff retry by worker
}
}
}

The receiver and dispatcher run in separate processes (see DEPLOYMENT_TOPOLOGY). The receiver has zero downstream dependencies beyond webhook_inbox so a processor outage of webhook.processed.v1 consumers cannot cause webhook 5xx back to the processor.

2.7 RunDailyReconciliation use case

export class RunDailyReconciliationUseCase {
async execute(cmd: { tenantId: TenantId; processor: ProcessorName; date: ISODate }): Promise<Reconciliation> {
const adapter = this.adapters.bound(cmd.processor);
const report = await adapter.reconcileBatch(cmd.date); // adapter fetches settlement
const platformTxs = await this.txs.listForReconciliation(cmd.tenantId, cmd.processor, cmd.date);
const reconciled = Reconciliation.compose({ report, platformTxs, tenantId: cmd.tenantId });
await this.recs.save(reconciled); // outbox reconciliation.completed.v1

for (const delta of reconciled.unmatched.entries) {
await this.events.publish(envelope('reconciliation.discrepancy_found.v1', { ...delta, tenantId: cmd.tenantId }));
}
return reconciled;
}
}

A scheduled Cloud Run job invokes this per (tenant, processor) at 02:00 local property time. The job is itself idempotent: re-running for a date already completed is a no-op (the reconciliations table has (tenant_id, processor, date) unique).

2.8 RecordCashPayment use case

export class RecordCashPaymentUseCase {
async execute(cmd: RecordCashPaymentCommand): Promise<{ paymentId: PaymentId; captureId: CaptureId }> {
// Cash adapter; zero network. Available offline via desktop sync push.
const tx = Transaction.create({
tenantId: cmd.tenantId, propertyId: cmd.propertyId, reservationId: cmd.reservationId, guestId: cmd.guestId,
method: 'cash_on_arrival', processor: 'cash', amount: cmd.amount, fxContext: cmd.fxContext,
initiatedBy: cmd.initiatedBy, channel: 'walk_in',
idempotencyKey: cmd.idempotencyKey, createdAt: this.clock.now(),
});
tx.transitionTo('authorized');
const cap = tx.applyCashCapture({ operatorId: cmd.operatorId, shiftId: cmd.cashDrawerShiftId, receiptPhotoMediaId: cmd.receiptPhotoMediaId });
await this.txs.save(tx); // outbox transaction.created + .authorized + .captured
return { paymentId: tx.id, captureId: cap.id };
}
}

The desktop pushes these via /sync/v1/push while online; while offline, they queue locally and sync on reconnect. Server is the conflict-resolution authority (SYNC_CONTRACT).

2.9 RecordChargeback use case

export class RecordChargebackUseCase {
async execute(cmd: RecordChargebackCommand): Promise<Chargeback> {
const tx = await this.txs.findByProcessorRef(cmd.tenantId, cmd.processor, cmd.processorRef);
if (!tx) throw new IntentNotFoundError();
if (tx.processor === 'cash') {
// Chargeback for cash payment is structurally impossible → fraud signal
await this.audit.record({ kind: 'payment.chargeback.impossible_for_cash', tenantId: cmd.tenantId, actor: 'webhook', payload: { processorRef: cmd.processorRef } });
throw new DomainError('MELMASTOON.PAYMENT.CHARGEBACK_IMPOSSIBLE_FOR_CASH');
}
const cb = Chargeback.open({ ...cmd, paymentId: tx.id });
await this.cbs.save(cb); // outbox chargeback.received.v1
return cb;
}
}

2.10 SubmitChargebackEvidence use case

export class SubmitChargebackEvidenceUseCase {
async execute(cmd: SubmitChargebackEvidenceCommand): Promise<void> {
const cb = await this.cbs.findById(cmd.tenantId, cmd.chargebackId);
if (!cb) throw new DomainError('MELMASTOON.PAYMENT.CHARGEBACK_NOT_FOUND');

// AI-assisted draft (HITL) — never auto-submit; staff must approve
let aiProvenance;
if (cmd.aiAssist) {
const draft = await this.ai.draftDisputeNarrative({ chargeback: cb, bookingRecord: cmd.bookingContext });
aiProvenance = draft.provenance; // require HITL gate
}

const adapter = this.adapters.bound(cb.processor);
await adapter.submitDisputeEvidence(cb.processorRef, cmd.bundleRef, cmd.narrative);
cb.markSubmitted({ bundleRef: cmd.bundleRef, narrative: cmd.narrative, aiProvenance });
await this.cbs.save(cb); // outbox chargeback.evidence_submitted.v1
}
}

3. Saga participation

payment-gateway-service participates in the booking saga as a reactor — it does not orchestrate. The orchestrator is reservation-service (reservation-service/SERVICE_OVERVIEW §6).

Consumed eventAction
reservation.held.v1Invoke AuthorizePaymentUseCase with the rate-plan-driven amount + method.
reservation.confirmed.v1Invoke CapturePaymentUseCase (or, for cash, no-op until front desk records receipt).
reservation.cancelled.v1Invoke RefundPaymentUseCase (caller has computed amount + reason via the policy DSL) or VoidPaymentUseCase if not yet captured.
reservation.no_show.v1If card_guarantee: capture first-night charge from held card.

All consumed handlers are idempotent on the consumed event's id (inbox dedupe); replays produce zero side effects.


4. Compensation paths

FailureCompensation
AuthorizePayment fails after FX snapshot persistedTransaction stays in failed; saga emits inventory release; FX snapshot is preserved for audit.
CapturePayment fails post-authorizeVoidPaymentUseCase invoked to release the authorization; saga compensates by cancelling the reservation.
RefundPayment fails at processorRefund persists status: 'pending'; retry worker invokes again with same idempotency key; if 5 attempts exhaust, finance on-call alerted; the folio refund line still posts (we are the discrepancy, not billing).
Cash receipt recorded offline; reservation later cancelled before syncWhen the desktop reconnects, server detects the cash receipt for a now-cancelled reservation; opens a manager-review task in the desktop; refund is recorded via RecordCashPayment with reason: 'cancellation_within_policy'.

5. Concurrency and idempotency

  • Optimistic concurrencyTransaction carries version; every save() checks WHERE version = :expected RETURNING version + 1. Conflict → re-load + re-apply or surface MELMASTOON.GENERAL.PRECONDITION_FAILED.
  • Idempotency — every mutating use case takes IdempotencyKey. The store reserves the key with the request hash; replays with the same key + same hash return the previous result; replays with a different hash return MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED.
  • Outbox — domain events are written transactionally with the aggregate. The relay publishes to Pub/Sub at-least-once; consumers dedupe on eventId via inbox.

6. Application-layer cross-cutting

  • All use cases run inside the per-tenant request context (tenantId from JWT → middleware → search_path set on connection).
  • All use cases emit OpenTelemetry spans with tenant.id, payment.id, payment.processor, payment.method.kind, payment.amount.currency, idempotency.key.
  • All use cases hand off to the AuditLogger for the canonical action (payment.authorize, payment.capture, payment.refund, payment.void, payment.method.tokenize, payment.method.detach, payment.cash.record, payment.chargeback.evidence_submit).
  • All use cases are protected by the RateLimiter per (tenant, principal, action) with sliding-window buckets.