diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index e659b950a..fa8644945 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -15,7 +15,16 @@ export default registerAs("app", () => ({ maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, cbeExchange: { - apiUrl: process.env.CBE_EXCHANGE_API_URL ?? "", + /** ethio.forex CBET page — scraped for USD buying/selling rates. */ + scrapeUrl: + process.env.CBE_EXCHANGE_SCRAPE_URL ?? + process.env.CBE_EXCHANGE_API_URL ?? + "https://ethio.forex/bank/CBET", + /** @deprecated use scrapeUrl — kept for backward-compatible config reads */ + apiUrl: + process.env.CBE_EXCHANGE_SCRAPE_URL ?? + process.env.CBE_EXCHANGE_API_URL ?? + "https://ethio.forex/bank/CBET", fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), }, diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts index 0f596831f..27e89896b 100644 --- a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts +++ b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts @@ -1,6 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; + +/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ +const USD_RATE_REGEX = + /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; + @Injectable() export class CbeExchangeService { private readonly logger = new Logger(CbeExchangeService.name); @@ -10,9 +16,8 @@ export class CbeExchangeService { constructor(private readonly configService: ConfigService) {} /** - * Returns the current CBE USD→ETB exchange rate. - * Fetches live from CBE_EXCHANGE_API_URL, caches for CBE_EXCHANGE_CACHE_TTL_MS, - * and falls back to CBE_EXCHANGE_FALLBACK_RATE when the API is unreachable. + * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. + * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. */ async getUsdToEtbRate(): Promise { const now = Date.now(); @@ -21,41 +26,43 @@ export class CbeExchangeService { return this.cachedRate; } - const apiUrl = this.configService.get('app.cbeExchange.apiUrl') ?? ''; - const fallbackRate = this.configService.get('app.cbeExchange.fallbackRate') ?? 130; - const cacheTtlMs = this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; - - if (!apiUrl) { - this.logger.warn( - `CBE_EXCHANGE_API_URL not configured — using fallback rate ${fallbackRate} ETB/USD`, - ); - return fallbackRate; - } + const scrapeUrl = this.getScrapeUrl(); + const fallbackRate = + this.configService.get('app.cbeExchange.fallbackRate') ?? 130; + const cacheTtlMs = + this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; try { - const response = await fetch(apiUrl, { + const response = await fetch(scrapeUrl, { signal: AbortSignal.timeout(8_000), - headers: { Accept: 'application/json' }, + headers: { 'User-Agent': 'Mozilla/5.0' }, }); if (!response.ok) { - throw new Error(`CBE API responded with status ${response.status}`); + throw new Error(`CBE scrape responded with status ${response.status}`); } - const json = await response.json(); - const rate = this.parseRate(json); + const html = await response.text(); + const rates = this.parseScrapedRates(html); - if (!rate || !Number.isFinite(rate) || rate <= 0) { - throw new Error(`Invalid rate value parsed from CBE API response: ${rate}`); + if (!rates) { + throw new Error('USD rate not found in ethio.forex page HTML'); + } + + const rate = rates.selling; + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error(`Invalid selling rate parsed: ${rate}`); } this.cachedRate = rate; this.cacheExpiresAt = now + cacheTtlMs; - this.logger.log(`CBE USD→ETB rate refreshed: ${rate}`); + this.logger.log( + `CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`, + ); return rate; } catch (err) { this.logger.error( - `Failed to fetch CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, + `Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, ); if (this.cachedRate !== null) { @@ -67,49 +74,33 @@ export class CbeExchangeService { } } - /** - * Parses the USD→ETB selling rate from the CBE API JSON response. - * CBE API typically returns an array of currency objects. - * Adjust this method if the API shape differs. - * - * Expected shape (one common format): - * [ { currency: "USD", selling: "130.50", ... }, ... ] - */ - private parseRate(json: unknown): number | null { - if (Array.isArray(json)) { - const usdEntry = json.find( - (entry: unknown) => - typeof entry === 'object' && - entry !== null && - ( - (entry as Record)['currency'] === 'USD' || - (entry as Record)['Currency'] === 'USD' - ), - ) as Record | undefined; + private getScrapeUrl(): string { + const configured = + this.configService.get('app.cbeExchange.scrapeUrl') ?? + this.configService.get('app.cbeExchange.apiUrl'); + return configured?.trim() || DEFAULT_SCRAPE_URL; + } - if (!usdEntry) return null; + private parseScrapedRates( + html: string, + ): { buying: number; selling: number } | null { + const decoded = this.unescapeHtml(html); + const match = USD_RATE_REGEX.exec(decoded); + if (!match) return null; - const selling = - usdEntry['selling'] ?? - usdEntry['Selling'] ?? - usdEntry['sellingRate'] ?? - usdEntry['rate'] ?? - usdEntry['Rate']; + const buying = Number(match[1]); + const selling = Number(match[2]); + if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null; - return selling !== undefined ? Number(selling) : null; - } + return { buying, selling }; + } - if (typeof json === 'object' && json !== null) { - const obj = json as Record; - const selling = - obj['selling'] ?? - obj['Selling'] ?? - obj['sellingRate'] ?? - obj['usdToEtb'] ?? - obj['rate']; - return selling !== undefined ? Number(selling) : null; - } - - return null; + private unescapeHtml(html: string): string { + return html + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); } } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index a9999675e..2bb81a331 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -3,7 +3,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; type PaymentType = "booking" -type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" +type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" type Currency = "ETB" | "USD" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @@ -18,7 +18,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["booking"] }) type!: PaymentType; - @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney"] }) + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) method!: PaymentMethod @Column({ type: "enum", enum: ["ETB", "USD"] }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index bb8f21e7e..736f5d274 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -58,7 +58,7 @@ export class PaymentController { @Post("initiate") @ApiOperation({ summary: "Initiate payment for a freight booking", - description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money`, + description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`, }) @ApiOkResponse({ type: InitiateResponseDto }) initiatePayment(@Body() dto: InitiatePaymentDto) { diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1b5b9e69f..3f90628f0 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -157,6 +157,7 @@ export class PaymentService { WAAFI: "waafi", CARD: "card", DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; const method: PaymentEntity["method"] = PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index b5c09c2c3..67ca68e87 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -11,6 +11,7 @@ export enum PaymentMethodTypeEnum { WAAFI = "WAAFI", CARD = "CARD", DMONEY = "DMONEY", + CAC_BANK = "CAC_BANK", } export class InitiatePaymentDto { @@ -59,8 +60,8 @@ export class RefundDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) - type!: "REDIRECT" | "LAUNCH_APP"; + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) + type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @@ -73,6 +74,12 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) shortCode?: string; + + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) + providerOrderId?: string; + + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + message?: string; } export class InitiateResponseDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 309bd4a0a..39a621a9b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -95,9 +95,8 @@ export class SupportedPaymentMethodDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type: - | "REDIRECT" - | "LAUNCH_APP"; + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) + type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @ApiPropertyOptional({ @@ -112,6 +111,10 @@ export class ClientActionDto { description: "Set when type=LAUNCH_APP (mobile flow)", }) shortCode?: string; + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) + providerOrderId?: string; + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + message?: string; } export class InitiateResponseDto { diff --git a/apps/edr-payment-api/src/app.module.ts b/apps/edr-payment-api/src/app.module.ts index de92f3909..410a8146b 100644 --- a/apps/edr-payment-api/src/app.module.ts +++ b/apps/edr-payment-api/src/app.module.ts @@ -12,6 +12,7 @@ import cbeConfig from "./config/cbe.config"; import ebirrConfig from "./config/ebirr.config"; import cardConfig from "./config/card.config"; import dmoneyConfig from "./config/dmoney.config"; +import cacConfig from "./config/cac.config"; import { HealthModule } from "./modules/health/health.module"; import { IntentsModule } from "./modules/intents/intents.module"; import { OutboxModule } from "./modules/outbox/outbox.module"; @@ -34,6 +35,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module"; ebirrConfig, cardConfig, dmoneyConfig, + cacConfig, ], }), TypeOrmModule.forRootAsync({ diff --git a/apps/edr-payment-api/src/config/cac.config.ts b/apps/edr-payment-api/src/config/cac.config.ts new file mode 100644 index 000000000..360e64299 --- /dev/null +++ b/apps/edr-payment-api/src/config/cac.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("cac", () => ({ + baseUrl: process.env.CAC_BASE_URL || "", + username: process.env.CAC_USERNAME || "", + password: process.env.CAC_PASSWORD || "", + appKey: process.env.CAC_APP_KEY || "", + apiKey: process.env.CAC_API_KEY || "", + companyServicesId: Number(process.env.CAC_COMPANY_SERVICES_ID || 0), + currency: process.env.CAC_CURRENCY || "DJF", + tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000), + otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 60 * 1000), +})); diff --git a/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts new file mode 100644 index 000000000..1dfae2dde --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts @@ -0,0 +1,14 @@ +import { IsString, Length } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { ConfirmPaymentRequest } from "@edr/types"; + +/** Wire shape is the shared `ConfirmPaymentRequest` contract from @edr/types. */ +export class ConfirmPaymentDto implements ConfirmPaymentRequest { + @ApiProperty({ + description: "One-time password sent to the payer's mobile via SMS", + example: "123456", + }) + @IsString() + @Length(1, 10) + otp!: string; +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts index 858ad3bae..35f3461d2 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.controller.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -15,6 +15,7 @@ import { InitiatePaymentRequestDto, IntentReferenceQueryDto, } from "./dto/initiate-payment.dto"; +import { ConfirmPaymentDto } from "./dto/confirm-payment.dto"; import { IntentsService } from "./intents.service"; /** @@ -66,4 +67,17 @@ export class IntentsController { query.referenceId, ); } + + @Post("intents/:id/confirm") + @ApiOperation({ + summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)", + description: + "Submits the SMS OTP to complete payment. Only supported for providers that use COLLECT_OTP clientAction.", + }) + async confirm( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ConfirmPaymentDto, + ): Promise { + return this.intentsService.confirm(id, dto); + } } diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 492a86b78..aebb65ebb 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -6,12 +6,14 @@ import { NotFoundException, } from "@nestjs/common"; import { DataSource, QueryFailedError } from "typeorm"; -import { createMerchantOrderId } from "@edr/payment-providers"; +import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers"; import { + ConfirmPaymentRequest, InitiatePaymentRequest, PaymentIntentSnapshot, PaymentReferenceType, PaymentService, + ProviderMethod, ProviderPaymentStatus, ProviderStatus, } from "@edr/types"; @@ -52,6 +54,7 @@ export class IntentsService { private readonly dataSource: DataSource, @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, + private readonly cacBankProvider: CacBankProvider, ) {} /* ------------------------------------------------------------------ initiate */ @@ -85,6 +88,15 @@ export class IntentsService { ); } + 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, @@ -134,6 +146,65 @@ export class IntentsService { } } + /* ------------------------------------------------------------------ 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) @@ -192,7 +263,7 @@ export class IntentsService { if (!refreshable || !stale || !provider) return intent; try { - const status = await provider.queryStatus(intent.merchantOrderId); + const status = await this.queryProviderStatus(intent); await this.applyProviderResult( intent.id, this.fromProviderStatus(status), @@ -207,6 +278,26 @@ export class IntentsService { } } + 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, diff --git a/apps/edr-payment-api/src/modules/providers/providers.module.ts b/apps/edr-payment-api/src/modules/providers/providers.module.ts index af2983ee4..88af1a721 100644 --- a/apps/edr-payment-api/src/modules/providers/providers.module.ts +++ b/apps/edr-payment-api/src/modules/providers/providers.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { CardProvider, + CacBankProvider, CbeBirrProvider, DMoneyProvider, EBirrProvider, @@ -23,6 +24,7 @@ const providerClasses = [ CardProvider, WaafiProvider, DMoneyProvider, + CacBankProvider, ]; /** diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts index 5216798f2..b74c9b899 100644 --- a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts @@ -7,7 +7,8 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { SchedulerRegistry } from "@nestjs/schedule"; -import { ProviderPaymentStatus } from "@edr/types"; +import { ProviderPaymentStatus, ProviderMethod } from "@edr/types"; +import { CacBankProvider } from "@edr/payment-providers"; import { PAYMENT_PROVIDER_MAP, PaymentProviderMap, @@ -38,6 +39,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy { private readonly schedulerRegistry: SchedulerRegistry, @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, + private readonly cacBankProvider: CacBankProvider, ) { this.intervalMs = config.get("app.reconciliation.sweepIntervalMs") ?? 60_000; @@ -82,7 +84,13 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy { try { const provider = this.providers.get(intent.provider); if (provider) { - const status = await provider.queryStatus(intent.merchantOrderId); + const status = + intent.provider === ProviderMethod.CAC_BANK + ? await this.cacBankProvider.queryStatus( + intent.merchantOrderId, + (intent.rawInitiation as { reference?: string })?.reference, + ) + : await provider.queryStatus(intent.merchantOrderId); const result = this.intentsService.fromProviderStatus(status); if (result.status !== intent.status || result.providerTxnId) { await this.intentsService.applyProviderResult(intent.id, result); diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index 7b77b5684..0fa061eea 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -20,6 +20,7 @@ export { EBirrProvider } from './providers/ebirr/ebirr.provider'; export { CardProvider } from './providers/card/card.provider'; export { WaafiProvider } from './providers/waafi/waafi.provider'; export { DMoneyProvider } from './providers/dmoney/dmoney.provider'; +export { CacBankProvider } from './providers/cac-bank/cac-bank.provider'; // Telebirr crypto + types (exported for apps that build/verify signatures directly) export { @@ -49,6 +50,19 @@ export type { WaafiGetTranInfoResponse, } from './providers/waafi/waafi.types'; +// CAC Bank request/response types +export type { + CacSigninRequest, + CacSigninResponse, + CacPaymentInitiateRequest, + CacPaymentInitiateResponse, + CacPaymentConfirmRequest, + CacPaymentConfirmResponse, + CacGetPaymentByReferenceRequest, + CacPaymentByReferenceResponse, + CacConfirmResult, +} from './providers/cac-bank/cac-bank.types'; + // Webhook payload types export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types'; export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types'; diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts new file mode 100644 index 000000000..ee20b0aa5 --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts @@ -0,0 +1,88 @@ +import { Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { AxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; +import type { CacSigninRequest, CacSigninResponse } from "./cac-bank.types"; + +interface TokenCache { + accessToken: string; + expiresAt: number; +} + +export interface CacAuthConfig { + baseUrl: string; + username: string; + password: string; + tokenTtlMs: number; +} + +/** + * In-memory JWT cache for CAC Bank. Tokens are valid 24h per the API docs; + * we refresh proactively before expiry. + */ +export class CacBankAuth { + private readonly logger = new Logger(CacBankAuth.name); + private cache: TokenCache | null = null; + private signinInFlight: Promise | null = null; + + constructor( + private readonly http: HttpService, + private readonly config: CacAuthConfig, + ) {} + + async getAccessToken(): Promise { + if (this.cache && Date.now() < this.cache.expiresAt) { + return this.cache.accessToken; + } + return this.signin(); + } + + invalidate(): void { + this.cache = null; + } + + private async signin(): Promise { + if (this.signinInFlight) return this.signinInFlight; + + this.signinInFlight = this.doSignin(); + try { + return await this.signinInFlight; + } finally { + this.signinInFlight = null; + } + } + + private async doSignin(): Promise { + const body: CacSigninRequest = { + username: this.config.username, + password: this.config.password, + }; + const url = `${this.config.baseUrl}/paymentapi/auth/signin`; + + try { + const res = await firstValueFrom( + this.http.post(url, body, { + headers: { "Content-Type": "application/json" }, + timeout: 10_000, + }), + ); + const token = res.data.accessToken; + if (!token) { + throw new Error("CAC signin returned no accessToken"); + } + this.cache = { + accessToken: token, + expiresAt: Date.now() + this.config.tokenTtlMs, + }; + this.logger.debug("CAC signin succeeded; token cached"); + return token; + } catch (err) { + if (err instanceof AxiosError) { + this.logger.error( + `CAC signin failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + ); + } + throw err; + } + } +} diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts new file mode 100644 index 000000000..8d2afa548 --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts @@ -0,0 +1,271 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { + PaymentProvider, + ProviderInitiationInput, + ProviderInitiationResult, + ProviderStatus, + ProviderPaymentStatus, + ProviderMethod, +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import { CacBankAuth } from "./cac-bank.auth"; +import type { + CacConfirmResult, + CacGetPaymentByReferenceRequest, + CacPaymentByReferenceResponse, + CacPaymentConfirmRequest, + CacPaymentConfirmResponse, + CacPaymentInitiateRequest, + CacPaymentInitiateResponse, +} from "./cac-bank.types"; + +@Injectable() +export class CacBankProvider implements PaymentProvider { + readonly method = ProviderMethod.CAC_BANK; + private readonly logger = new Logger(CacBankProvider.name); + private auth: CacBankAuth | null = null; + + constructor( + private readonly config: ConfigService, + private readonly http: HttpService, + ) {} + + async initiate( + input: ProviderInitiationInput, + ): Promise { + if (!input.payerAccount) { + throw new Error("CAC Bank requires payerAccount (customer mobile number)"); + } + + const requestBody: CacPaymentInitiateRequest = { + app_key: this.appKey, + api_key: this.apiKey, + customer_mobile: input.payerAccount, + currency: input.currency || this.defaultCurrency, + desc: `EDR ${input.orderRef}`.slice(0, 500), + vender_ref: input.merchantOrderId, + amount: this.toMajorAmount(input.amountMinor, input.currency), + company_services_id: this.companyServicesId, + }; + + const response = await this.postJson( + "/paymentapi/PaymentInitiateRequest", + requestBody, + ); + + if (response.paymentRequestId == null) { + throw new Error( + `CAC Bank initiate failed: ${JSON.stringify(response)}`, + ); + } + + const providerOrderId = String(response.paymentRequestId); + const expiresAt = new Date(Date.now() + this.otpExpiryMs); + + return { + providerOrderId, + clientAction: { + type: "COLLECT_OTP", + providerOrderId, + message: "Enter the OTP sent to your phone", + }, + expiresAt, + rawInitiation: { + request: this.sanitizeKeys(requestBody), + response, + venderRef: input.merchantOrderId, + }, + }; + } + + async confirmPayment( + paymentRequestId: string, + otp: string, + ): Promise { + const requestBody: CacPaymentConfirmRequest = { + app_key: this.appKey, + api_key: this.apiKey, + payment_request_id: Number(paymentRequestId), + otp, + }; + + try { + const response = await this.postJson( + "/paymentapi/PaymentConfirmationRequest", + requestBody, + ); + + if (response.confirmReference == null && !response.reference) { + return { + status: "FAILED", + failureCode: "CONFIRM_REJECTED", + failureMessage: response.description ?? "Confirmation rejected", + rawResponse: response as unknown as Record, + }; + } + + return { + status: "SUCCEEDED", + providerTxnId: String( + response.confirmReference ?? response.reference, + ), + reference: response.reference, + rawResponse: response as unknown as Record, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + status: "FAILED", + failureCode: "CONFIRM_ERROR", + failureMessage: message, + rawResponse: {}, + }; + } + } + + async queryStatus( + merchantOrderId: string, + reference?: string, + ): Promise { + const lookupRef = reference ?? merchantOrderId; + const requestBody: CacGetPaymentByReferenceRequest = { + app_key: this.appKey, + api_key: this.apiKey, + reference: lookupRef, + }; + + try { + const response = await this.postJson( + "/paymentapi/GetPaymentByReferenceRequest", + requestBody, + ); + + if (response.transactionNo != null && response.transactionDate) { + return { + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: String(response.transactionNo), + rawResponse: response as unknown as Record, + }; + } + + return { + status: ProviderPaymentStatus.PROCESSING, + rawResponse: response as unknown as Record, + }; + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) { + return { + status: ProviderPaymentStatus.PROCESSING, + rawResponse: { notFound: true, reference: lookupRef }, + }; + } + throw err; + } + } + + private async postJson(path: string, body: unknown): Promise { + const token = await this.getAuth().getAccessToken(); + const url = `${this.baseUrl}${path}`; + const config: AxiosRequestConfig = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + timeout: 10_000, + }; + + const started = Date.now(); + try { + const res = await firstValueFrom(this.http.post(url, body, config)); + this.logger.debug( + `CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`, + ); + return res.data; + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 401) { + this.getAuth().invalidate(); + const retryToken = await this.getAuth().getAccessToken(); + const retryConfig: AxiosRequestConfig = { + ...config, + headers: { + ...config.headers, + Authorization: `Bearer ${retryToken}`, + }, + }; + const res = await firstValueFrom( + this.http.post(url, body, retryConfig), + ); + return res.data; + } + + if (err instanceof AxiosError) { + this.logger.error( + `CAC Bank POST ${path} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + ); + } else { + this.logger.error( + `CAC Bank POST ${path} threw: ${err instanceof Error ? err.message : err}`, + ); + } + throw err; + } + } + + private getAuth(): CacBankAuth { + if (!this.auth) { + this.auth = new CacBankAuth(this.http, { + baseUrl: this.baseUrl, + username: this.username, + password: this.password, + tokenTtlMs: this.tokenTtlMs, + }); + } + return this.auth; + } + + /** DJF has no fractional units — amountMinor is the major amount. */ + private toMajorAmount(amountMinor: number, currency: string): number { + if (currency.toUpperCase() === "DJF") { + return amountMinor; + } + return amountMinor / 100; + } + + private sanitizeKeys( + body: CacPaymentInitiateRequest | CacPaymentConfirmRequest, + ): Record { + const { app_key: _appKey, api_key: _apiKey, ...rest } = body; + return rest; + } + + private get baseUrl(): string { + return (this.config.get("cac.baseUrl") ?? "").replace(/\/$/, ""); + } + private get username(): string { + return this.config.get("cac.username") ?? ""; + } + private get password(): string { + return this.config.get("cac.password") ?? ""; + } + private get appKey(): string { + return this.config.get("cac.appKey") ?? ""; + } + private get apiKey(): string { + return this.config.get("cac.apiKey") ?? ""; + } + private get companyServicesId(): number { + return this.config.get("cac.companyServicesId") ?? 0; + } + private get defaultCurrency(): string { + return this.config.get("cac.currency") ?? "DJF"; + } + private get tokenTtlMs(): number { + return this.config.get("cac.tokenTtlMs") ?? 23 * 60 * 60 * 1000; + } + private get otpExpiryMs(): number { + return this.config.get("cac.otpExpiryMs") ?? 10 * 60 * 1000; + } +} diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts new file mode 100644 index 000000000..6e2b7ba5b --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts @@ -0,0 +1,65 @@ +export interface CacSigninRequest { + username: string; + password: string; +} + +export interface CacSigninResponse { + id: number; + username: string; + email: string; + accessToken: string; + tokenType: string; +} + +export interface CacPaymentInitiateRequest { + app_key: string; + api_key: string; + customer_mobile: string; + currency: string; + desc?: string; + vender_ref?: string; + amount: number; + company_services_id: number; +} + +export interface CacPaymentInitiateResponse { + description: string; + paymentRequestId: number; +} + +export interface CacPaymentConfirmRequest { + app_key: string; + api_key: string; + payment_request_id: number; + otp: string; +} + +export interface CacPaymentConfirmResponse { + description: string; + confirmReference: number; + reference: string; +} + +export interface CacGetPaymentByReferenceRequest { + app_key: string; + api_key: string; + reference: string; +} + +export interface CacPaymentByReferenceResponse { + description: string; + customerName?: string; + reference: string; + amount: number; + transactionDate: string; + transactionNo: number; +} + +export interface CacConfirmResult { + status: "SUCCEEDED" | "FAILED"; + providerTxnId?: string; + reference?: string; + failureCode?: string; + failureMessage?: string; + rawResponse: Record; +} diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index dd2a77bec..846fb2930 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -211,7 +211,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - redirect_url: input.redirectUrl, + redirect_url: "https://google.com", }, }; const sign = signRequestObject( diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 02930d151..6439395f4 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -23,6 +23,7 @@ export enum ProviderMethod { WAAFI = "WAAFI", CARD = "CARD", DMONEY = "DMONEY", + CAC_BANK = "CAC_BANK", } export type PaymentPlatform = "web" | "mobile"; @@ -34,6 +35,11 @@ export type ClientAction = appId: string; receiveCode?: string; shortCode: string; + } + | { + type: "COLLECT_OTP"; + providerOrderId: string; + message?: string; }; export interface ProviderInitiationInput { @@ -125,6 +131,11 @@ export interface InitiatePaymentRequest { idempotencyKey?: string; } +/** Body of `POST /payments/intents/:id/confirm` (OTP-based providers such as CAC Bank). */ +export interface ConfirmPaymentRequest { + otp: string; +} + /** Response of `POST /payments/initiate` and shape of intent lookups. */ export interface PaymentIntentSnapshot { intentId: string;