import { BadRequestException, Inject, Injectable, Logger, NotFoundException, } from "@nestjs/common"; import { DataSource, QueryFailedError } from "typeorm"; import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers"; import { ConfirmPaymentRequest, InitiatePaymentRequest, PaymentIntentSnapshot, PaymentReferenceType, PaymentService, ProviderMethod, ProviderPaymentStatus, ProviderStatus, } from "@edr/types"; import { PAYMENT_PROVIDER_MAP, PaymentProviderMap, } from "../providers/providers.module"; import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity"; import { buildOutboxRow } from "../outbox/payment-event.factory"; import { PaymentIntent, TERMINAL_INTENT_STATUSES, } from "./entities/payment-intent.entity"; import { IntentsRepository } from "./intents.repository"; const PG_UNIQUE_VIOLATION = "23505"; /** Don't hit the provider again if the intent was refreshed this recently. */ const REFRESH_MIN_AGE_MS = 5_000; /** Result of a provider signal (webhook or status query) applied to the state machine. */ export interface ProviderResultInput { status: ProviderPaymentStatus; providerTxnId?: string; paidAt?: Date; confirmedAmountMinor?: number; failureCode?: string; failureMessage?: string; } @Injectable() export class IntentsService { private readonly logger = new Logger(IntentsService.name); constructor( private readonly intentsRepository: IntentsRepository, // DataSource is used only for the finalize transaction (intent update + outbox insert // must commit atomically); routine access still goes through the custom repository. private readonly dataSource: DataSource, @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, private readonly cacBankProvider: CacBankProvider, ) {} /* ------------------------------------------------------------------ initiate */ async initiate( request: InitiatePaymentRequest, ): Promise { if (request.idempotencyKey) { const byKey = await this.intentsRepository.findByIdempotencyKey( request.service, request.idempotencyKey, ); if (byKey) return this.toSnapshot(byKey); } const existing = await this.intentsRepository.findActiveByReference( request.service, request.referenceType, request.referenceId, ); if (existing) { // Payer switched method (e.g. Waafi → Telebirr) on an uncharged session: retire the // open intent and fall through to open a fresh one for the new provider. Only safe // while REQUIRES_ACTION — PROCESSING/SUCCEEDED intents may have money in flight, so // they keep the reuse path (the switch is silently refused until they resolve). const switchingProvider = existing.provider !== request.provider && existing.status === ProviderPaymentStatus.REQUIRES_ACTION; if (switchingProvider) { await this.intentsRepository.update(existing.id, { status: ProviderPaymentStatus.CANCELLED, failureCode: "METHOD_CHANGED", failureMessage: `Payer switched from ${existing.provider} to ${request.provider}`, }); this.logger.log( `intent ${existing.id} retired (METHOD_CHANGED ${existing.provider} → ${request.provider}) for ` + `${request.service}/${request.referenceType}/${request.referenceId}`, ); } else { const reusable = await this.reuseOrRetire(existing); if (reusable) return this.toSnapshot(reusable); } } const provider = this.providers.get(request.provider); if (!provider) { throw new BadRequestException( `Unsupported payment provider: ${request.provider}`, ); } if ( request.provider === ProviderMethod.CAC_BANK && !request.payerAccount?.trim() ) { throw new BadRequestException( "payerAccount (customer mobile number) is required for CAC_BANK", ); } const merchantOrderId = createMerchantOrderId(); const result = await provider.initiate({ merchantOrderId, orderRef: request.orderRef ?? request.referenceId, amountMinor: request.amountMinor, currency: request.currency, platform: request.platform, payerAccount: request.payerAccount, returnUrl: request.returnUrl, redirectUrl: request.returnUrl, failureUrl: request.failureUrl, }); try { const intent = await this.intentsRepository.create({ service: request.service, referenceType: request.referenceType, referenceId: request.referenceId, merchantOrderId, provider: request.provider, providerOrderId: result.providerOrderId, amountMinor: request.amountMinor, currency: request.currency, status: ProviderPaymentStatus.REQUIRES_ACTION, clientAction: result.clientAction, idempotencyKey: request.idempotencyKey ?? null, expiresAt: result.expiresAt, rawInitiation: result.rawInitiation, }); this.logger.log( `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`, ); return this.toSnapshot(intent); } catch (err) { if ( err instanceof QueryFailedError && (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION ) { const winner = await this.intentsRepository.findActiveByReference( request.service, request.referenceType, request.referenceId, ); if (winner) return this.toSnapshot(winner); } throw err; } } /* ------------------------------------------------------------------ confirm (OTP providers) */ async confirm( intentId: string, request: ConfirmPaymentRequest, ): Promise { const intent = await this.intentsRepository.findById(intentId); if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.provider !== ProviderMethod.CAC_BANK) { throw new BadRequestException( `Confirm is not supported for provider: ${intent.provider}`, ); } if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) { throw new BadRequestException( `Intent is not awaiting confirmation (status=${intent.status})`, ); } if (!intent.providerOrderId) { throw new BadRequestException("Intent has no provider order id"); } const confirmResult = await this.cacBankProvider.confirmPayment( intent.providerOrderId, request.otp, ); if (confirmResult.reference) { await this.intentsRepository.update(intent.id, { rawInitiation: { ...(intent.rawInitiation ?? {}), reference: confirmResult.reference, confirmResponse: confirmResult.rawResponse, }, }); } if (confirmResult.status === "SUCCEEDED") { await this.applyProviderResult(intent.id, { status: ProviderPaymentStatus.SUCCEEDED, providerTxnId: confirmResult.providerTxnId, paidAt: new Date(), }); } else { await this.applyProviderResult(intent.id, { status: ProviderPaymentStatus.FAILED, failureCode: confirmResult.failureCode, failureMessage: confirmResult.failureMessage, }); } const updated = await this.intentsRepository.findById(intent.id); if (!updated) throw new NotFoundException("PaymentIntent not found"); return this.toSnapshot(updated); } /** * Decide whether an existing active intent can be returned as-is. An expired * REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid) * so a fresh provider session can be opened. */ private async reuseOrRetire( intent: PaymentIntent, ): Promise { const expired = intent.status === ProviderPaymentStatus.REQUIRES_ACTION && intent.expiresAt != null && intent.expiresAt.getTime() < Date.now(); if (!expired) return intent; await this.intentsRepository.update(intent.id, { status: ProviderPaymentStatus.CANCELLED, failureCode: "EXPIRED", failureMessage: "Provider session expired before the payer acted", }); return null; } /* ------------------------------------------------------------------ lookups */ async getIntent(id: string): Promise { const intent = await this.intentsRepository.findById(id); if (!intent) throw new NotFoundException("PaymentIntent not found"); return this.toSnapshot(await this.refreshIfStale(intent)); } async getIntentByReference( service: PaymentService, referenceType: PaymentReferenceType, referenceId: string, ): Promise { const intent = await this.intentsRepository.findActiveByReference( service, referenceType, referenceId, ); if (!intent) throw new NotFoundException("PaymentIntent not found"); return this.toSnapshot(await this.refreshIfStale(intent)); } /** * Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the * provider for the truth and run the answer through the state machine. The browser * redirect never confirms payment — this query (or a webhook) does. */ private async refreshIfStale(intent: PaymentIntent): Promise { const refreshable = intent.status === ProviderPaymentStatus.REQUIRES_ACTION || intent.status === ProviderPaymentStatus.PROCESSING; const stale = intent.updatedAt.getTime() < Date.now() - REFRESH_MIN_AGE_MS; const provider = this.providers.get(intent.provider); if (!refreshable || !stale || !provider) return intent; try { const status = await this.queryProviderStatus(intent); await this.applyProviderResult( intent.id, this.fromProviderStatus(status), ); return (await this.intentsRepository.findById(intent.id)) ?? intent; } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.warn( `queryStatus failed for intent ${intent.id}: ${message}; returning cached`, ); return intent; } } private async queryProviderStatus( intent: PaymentIntent, ): Promise { const provider = this.providers.get(intent.provider); if (!provider) { throw new Error(`Unknown provider: ${intent.provider}`); } if (intent.provider === ProviderMethod.CAC_BANK) { const reference = (intent.rawInitiation as { reference?: string }) ?.reference; return this.cacBankProvider.queryStatus( intent.merchantOrderId, reference, ); } return provider.queryStatus(intent.merchantOrderId); } fromProviderStatus(status: ProviderStatus): ProviderResultInput { return { status: status.status, providerTxnId: status.providerTxnId, failureCode: status.failureCode, failureMessage: status.failureMessage, }; } /* ------------------------------------------------------------------ state machine */ /** * Advance the intent state machine with a verified provider signal. Terminal states are * absorbing; a terminal transition writes the notification_outbox row IN THE SAME * TRANSACTION as the intent update (transactional outbox — architecture.md §8). */ async applyProviderResult( intentId: string, result: ProviderResultInput, ): Promise<{ alreadyTerminal: boolean }> { return this.dataSource.transaction(async (manager) => { const intent = await manager .getRepository(PaymentIntent) .createQueryBuilder("intent") .setLock("pessimistic_write") .where("intent.id = :intentId", { intentId }) .getOne(); if (!intent) throw new NotFoundException("PaymentIntent not found"); if ( (TERMINAL_INTENT_STATUSES as readonly ProviderPaymentStatus[]).includes( intent.status, ) ) { return { alreadyTerminal: true }; } if (result.status === ProviderPaymentStatus.SUCCEEDED) { const paidAt = result.paidAt ?? new Date(); intent.status = ProviderPaymentStatus.SUCCEEDED; intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; intent.paidAt = paidAt; intent.confirmedAmountMinor = result.confirmedAmountMinor ?? intent.confirmedAmountMinor; intent.failureCode = null; intent.failureMessage = null; await manager.save(intent); await manager.getRepository(NotificationOutbox).save( buildOutboxRow(intent, { eventType: "payment.succeeded", providerTxnId: intent.providerTxnId ?? undefined, paidAt, }), ); if ( result.confirmedAmountMinor != null && result.confirmedAmountMinor !== intent.amountMinor ) { this.logger.error( `intent ${intent.id} amount mismatch: asserted=${intent.amountMinor} confirmed=${result.confirmedAmountMinor}`, ); } this.logger.log( `intent ${intent.id} SUCCEEDED (txn=${intent.providerTxnId ?? "n/a"})`, ); return { alreadyTerminal: false }; } if ( result.status === ProviderPaymentStatus.FAILED || result.status === ProviderPaymentStatus.CANCELLED ) { intent.status = result.status; intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; intent.failureCode = result.failureCode ?? null; intent.failureMessage = result.failureMessage ?? null; await manager.save(intent); await manager.getRepository(NotificationOutbox).save( buildOutboxRow(intent, { eventType: "payment.failed", failureCode: result.failureCode, failureMessage: result.failureMessage, }), ); this.logger.log( `intent ${intent.id} ${result.status} (${result.failureCode ?? "n/a"})`, ); return { alreadyTerminal: false }; } // Non-terminal: REQUIRES_ACTION may move to PROCESSING; never the reverse. if ( result.status === ProviderPaymentStatus.PROCESSING && intent.status === ProviderPaymentStatus.REQUIRES_ACTION ) { intent.status = ProviderPaymentStatus.PROCESSING; } intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; await manager.save(intent); return { alreadyTerminal: false }; }); } /** Expire an abandoned intent (reconciliation sweep) — CANCELLED + payment.failed event. */ async expireIntent(intentId: string): Promise { await this.applyProviderResult(intentId, { status: ProviderPaymentStatus.CANCELLED, failureCode: "EXPIRED", failureMessage: "Payment session expired before completion", }); } /* ------------------------------------------------------------------ mapping */ toSnapshot(intent: PaymentIntent): PaymentIntentSnapshot { return { intentId: intent.id, service: intent.service, referenceType: intent.referenceType, referenceId: intent.referenceId, merchantOrderId: intent.merchantOrderId, provider: intent.provider, status: intent.status, amountMinor: intent.amountMinor, currency: intent.currency, clientAction: intent.clientAction ?? undefined, providerTxnId: intent.providerTxnId ?? undefined, paidAt: intent.paidAt?.toISOString(), failureCode: intent.failureCode ?? undefined, failureMessage: intent.failureMessage ?? undefined, expiresAt: intent.expiresAt?.toISOString(), }; } }