cac payemnt integration and cbe rate exchange webscrabing

This commit is contained in:
marshal
2026-06-15 14:54:20 +03:00
parent e3c66bfa5e
commit 0aed9824ae
20 changed files with 680 additions and 76 deletions

View File

@@ -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),
},

View File

@@ -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<number> {
const now = Date.now();
@@ -21,41 +26,43 @@ export class CbeExchangeService {
return this.cachedRate;
}
const apiUrl = this.configService.get<string>('app.cbeExchange.apiUrl') ?? '';
const fallbackRate = this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
const cacheTtlMs = this.configService.get<number>('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<number>('app.cbeExchange.fallbackRate') ?? 130;
const cacheTtlMs =
this.configService.get<number>('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<string, unknown>)['currency'] === 'USD' ||
(entry as Record<string, unknown>)['Currency'] === 'USD'
),
) as Record<string, unknown> | undefined;
private getScrapeUrl(): string {
const configured =
this.configService.get<string>('app.cbeExchange.scrapeUrl') ??
this.configService.get<string>('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<string, unknown>;
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(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
}

View File

@@ -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"] })

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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),
}));

View File

@@ -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;
}

View File

@@ -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<PaymentIntentSnapshot> {
return this.intentsService.confirm(id, dto);
}
}

View File

@@ -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<PaymentIntentSnapshot> {
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<ProviderStatus> {
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,

View File

@@ -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,
];
/**

View File

@@ -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<number>("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);