mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
cac payemnt integration and cbe rate exchange webscrabing
This commit is contained in:
@@ -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(/"/g, '"')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user