mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
cac payemnt integration and cbe rate exchange webscrabing
This commit is contained in:
@@ -15,7 +15,16 @@ export default registerAs("app", () => ({
|
|||||||
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||||
},
|
},
|
||||||
cbeExchange: {
|
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),
|
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
||||||
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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()
|
@Injectable()
|
||||||
export class CbeExchangeService {
|
export class CbeExchangeService {
|
||||||
private readonly logger = new Logger(CbeExchangeService.name);
|
private readonly logger = new Logger(CbeExchangeService.name);
|
||||||
@@ -10,9 +16,8 @@ export class CbeExchangeService {
|
|||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the current CBE USD→ETB exchange rate.
|
* Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex.
|
||||||
* Fetches live from CBE_EXCHANGE_API_URL, caches for CBE_EXCHANGE_CACHE_TTL_MS,
|
* Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure.
|
||||||
* and falls back to CBE_EXCHANGE_FALLBACK_RATE when the API is unreachable.
|
|
||||||
*/
|
*/
|
||||||
async getUsdToEtbRate(): Promise<number> {
|
async getUsdToEtbRate(): Promise<number> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -21,41 +26,43 @@ export class CbeExchangeService {
|
|||||||
return this.cachedRate;
|
return this.cachedRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiUrl = this.configService.get<string>('app.cbeExchange.apiUrl') ?? '';
|
const scrapeUrl = this.getScrapeUrl();
|
||||||
const fallbackRate = this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
|
const fallbackRate =
|
||||||
const cacheTtlMs = this.configService.get<number>('app.cbeExchange.cacheTtlMs') ?? 3_600_000;
|
this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
|
||||||
|
const cacheTtlMs =
|
||||||
if (!apiUrl) {
|
this.configService.get<number>('app.cbeExchange.cacheTtlMs') ?? 3_600_000;
|
||||||
this.logger.warn(
|
|
||||||
`CBE_EXCHANGE_API_URL not configured — using fallback rate ${fallbackRate} ETB/USD`,
|
|
||||||
);
|
|
||||||
return fallbackRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(scrapeUrl, {
|
||||||
signal: AbortSignal.timeout(8_000),
|
signal: AbortSignal.timeout(8_000),
|
||||||
headers: { Accept: 'application/json' },
|
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 html = await response.text();
|
||||||
const rate = this.parseRate(json);
|
const rates = this.parseScrapedRates(html);
|
||||||
|
|
||||||
if (!rate || !Number.isFinite(rate) || rate <= 0) {
|
if (!rates) {
|
||||||
throw new Error(`Invalid rate value parsed from CBE API response: ${rate}`);
|
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.cachedRate = rate;
|
||||||
this.cacheExpiresAt = now + cacheTtlMs;
|
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;
|
return rate;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
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) {
|
if (this.cachedRate !== null) {
|
||||||
@@ -67,49 +74,33 @@ export class CbeExchangeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getScrapeUrl(): string {
|
||||||
* Parses the USD→ETB selling rate from the CBE API JSON response.
|
const configured =
|
||||||
* CBE API typically returns an array of currency objects.
|
this.configService.get<string>('app.cbeExchange.scrapeUrl') ??
|
||||||
* Adjust this method if the API shape differs.
|
this.configService.get<string>('app.cbeExchange.apiUrl');
|
||||||
*
|
return configured?.trim() || DEFAULT_SCRAPE_URL;
|
||||||
* 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;
|
|
||||||
|
|
||||||
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 =
|
const buying = Number(match[1]);
|
||||||
usdEntry['selling'] ??
|
const selling = Number(match[2]);
|
||||||
usdEntry['Selling'] ??
|
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;
|
||||||
usdEntry['sellingRate'] ??
|
|
||||||
usdEntry['rate'] ??
|
|
||||||
usdEntry['Rate'];
|
|
||||||
|
|
||||||
return selling !== undefined ? Number(selling) : null;
|
return { buying, selling };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof json === 'object' && json !== null) {
|
private unescapeHtml(html: string): string {
|
||||||
const obj = json as Record<string, unknown>;
|
return html
|
||||||
const selling =
|
.replace(/"/g, '"')
|
||||||
obj['selling'] ??
|
.replace(/"/g, '"')
|
||||||
obj['Selling'] ??
|
.replace(/&/g, '&')
|
||||||
obj['sellingRate'] ??
|
.replace(/</g, '<')
|
||||||
obj['usdToEtb'] ??
|
.replace(/>/g, '>');
|
||||||
obj['rate'];
|
|
||||||
return selling !== undefined ? Number(selling) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
|
|||||||
|
|
||||||
|
|
||||||
type PaymentType = "booking"
|
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"
|
type Currency = "ETB" | "USD"
|
||||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export class PaymentEntity extends BaseEntity {
|
|||||||
@Column({ type: "enum", enum: ["booking"] })
|
@Column({ type: "enum", enum: ["booking"] })
|
||||||
type!: PaymentType;
|
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
|
method!: PaymentMethod
|
||||||
|
|
||||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export class PaymentController {
|
|||||||
@Post("initiate")
|
@Post("initiate")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Initiate payment for a freight booking",
|
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 })
|
@ApiOkResponse({ type: InitiateResponseDto })
|
||||||
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export class PaymentService {
|
|||||||
WAAFI: "waafi",
|
WAAFI: "waafi",
|
||||||
CARD: "card",
|
CARD: "card",
|
||||||
DMONEY: "dmoney",
|
DMONEY: "dmoney",
|
||||||
|
CAC_BANK: "cac-bank",
|
||||||
};
|
};
|
||||||
const method: PaymentEntity["method"] =
|
const method: PaymentEntity["method"] =
|
||||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export enum PaymentMethodTypeEnum {
|
|||||||
WAAFI = "WAAFI",
|
WAAFI = "WAAFI",
|
||||||
CARD = "CARD",
|
CARD = "CARD",
|
||||||
DMONEY = "DMONEY",
|
DMONEY = "DMONEY",
|
||||||
|
CAC_BANK = "CAC_BANK",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InitiatePaymentDto {
|
export class InitiatePaymentDto {
|
||||||
@@ -59,8 +60,8 @@ export class RefundDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ClientActionDto {
|
export class ClientActionDto {
|
||||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
|
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||||
type!: "REDIRECT" | "LAUNCH_APP";
|
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -73,6 +74,12 @@ export class ClientActionDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||||
shortCode?: string;
|
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 {
|
export class InitiateResponseDto {
|
||||||
|
|||||||
@@ -95,9 +95,8 @@ export class SupportedPaymentMethodDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ClientActionDto {
|
export class ClientActionDto {
|
||||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type:
|
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||||
| "REDIRECT"
|
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||||
| "LAUNCH_APP";
|
|
||||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||||
url?: string;
|
url?: string;
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
@@ -112,6 +111,10 @@ export class ClientActionDto {
|
|||||||
description: "Set when type=LAUNCH_APP (mobile flow)",
|
description: "Set when type=LAUNCH_APP (mobile flow)",
|
||||||
})
|
})
|
||||||
shortCode?: string;
|
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 {
|
export class InitiateResponseDto {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import cbeConfig from "./config/cbe.config";
|
|||||||
import ebirrConfig from "./config/ebirr.config";
|
import ebirrConfig from "./config/ebirr.config";
|
||||||
import cardConfig from "./config/card.config";
|
import cardConfig from "./config/card.config";
|
||||||
import dmoneyConfig from "./config/dmoney.config";
|
import dmoneyConfig from "./config/dmoney.config";
|
||||||
|
import cacConfig from "./config/cac.config";
|
||||||
import { HealthModule } from "./modules/health/health.module";
|
import { HealthModule } from "./modules/health/health.module";
|
||||||
import { IntentsModule } from "./modules/intents/intents.module";
|
import { IntentsModule } from "./modules/intents/intents.module";
|
||||||
import { OutboxModule } from "./modules/outbox/outbox.module";
|
import { OutboxModule } from "./modules/outbox/outbox.module";
|
||||||
@@ -34,6 +35,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module";
|
|||||||
ebirrConfig,
|
ebirrConfig,
|
||||||
cardConfig,
|
cardConfig,
|
||||||
dmoneyConfig,
|
dmoneyConfig,
|
||||||
|
cacConfig,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
|
|||||||
13
apps/edr-payment-api/src/config/cac.config.ts
Normal file
13
apps/edr-payment-api/src/config/cac.config.ts
Normal 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),
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
InitiatePaymentRequestDto,
|
InitiatePaymentRequestDto,
|
||||||
IntentReferenceQueryDto,
|
IntentReferenceQueryDto,
|
||||||
} from "./dto/initiate-payment.dto";
|
} from "./dto/initiate-payment.dto";
|
||||||
|
import { ConfirmPaymentDto } from "./dto/confirm-payment.dto";
|
||||||
import { IntentsService } from "./intents.service";
|
import { IntentsService } from "./intents.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,4 +67,17 @@ export class IntentsController {
|
|||||||
query.referenceId,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { DataSource, QueryFailedError } from "typeorm";
|
import { DataSource, QueryFailedError } from "typeorm";
|
||||||
import { createMerchantOrderId } from "@edr/payment-providers";
|
import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers";
|
||||||
import {
|
import {
|
||||||
|
ConfirmPaymentRequest,
|
||||||
InitiatePaymentRequest,
|
InitiatePaymentRequest,
|
||||||
PaymentIntentSnapshot,
|
PaymentIntentSnapshot,
|
||||||
PaymentReferenceType,
|
PaymentReferenceType,
|
||||||
PaymentService,
|
PaymentService,
|
||||||
|
ProviderMethod,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
@@ -52,6 +54,7 @@ export class IntentsService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(PAYMENT_PROVIDER_MAP)
|
@Inject(PAYMENT_PROVIDER_MAP)
|
||||||
private readonly providers: PaymentProviderMap,
|
private readonly providers: PaymentProviderMap,
|
||||||
|
private readonly cacBankProvider: CacBankProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ initiate */
|
/* ------------------------------------------------------------------ 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 merchantOrderId = createMerchantOrderId();
|
||||||
const result = await provider.initiate({
|
const result = await provider.initiate({
|
||||||
merchantOrderId,
|
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
|
* Decide whether an existing active intent can be returned as-is. An expired
|
||||||
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
|
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
|
||||||
@@ -192,7 +263,7 @@ export class IntentsService {
|
|||||||
if (!refreshable || !stale || !provider) return intent;
|
if (!refreshable || !stale || !provider) return intent;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await provider.queryStatus(intent.merchantOrderId);
|
const status = await this.queryProviderStatus(intent);
|
||||||
await this.applyProviderResult(
|
await this.applyProviderResult(
|
||||||
intent.id,
|
intent.id,
|
||||||
this.fromProviderStatus(status),
|
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 {
|
fromProviderStatus(status: ProviderStatus): ProviderResultInput {
|
||||||
return {
|
return {
|
||||||
status: status.status,
|
status: status.status,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
CardProvider,
|
CardProvider,
|
||||||
|
CacBankProvider,
|
||||||
CbeBirrProvider,
|
CbeBirrProvider,
|
||||||
DMoneyProvider,
|
DMoneyProvider,
|
||||||
EBirrProvider,
|
EBirrProvider,
|
||||||
@@ -23,6 +24,7 @@ const providerClasses = [
|
|||||||
CardProvider,
|
CardProvider,
|
||||||
WaafiProvider,
|
WaafiProvider,
|
||||||
DMoneyProvider,
|
DMoneyProvider,
|
||||||
|
CacBankProvider,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { SchedulerRegistry } from "@nestjs/schedule";
|
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||||
import { ProviderPaymentStatus } from "@edr/types";
|
import { ProviderPaymentStatus, ProviderMethod } from "@edr/types";
|
||||||
|
import { CacBankProvider } from "@edr/payment-providers";
|
||||||
import {
|
import {
|
||||||
PAYMENT_PROVIDER_MAP,
|
PAYMENT_PROVIDER_MAP,
|
||||||
PaymentProviderMap,
|
PaymentProviderMap,
|
||||||
@@ -38,6 +39,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly schedulerRegistry: SchedulerRegistry,
|
private readonly schedulerRegistry: SchedulerRegistry,
|
||||||
@Inject(PAYMENT_PROVIDER_MAP)
|
@Inject(PAYMENT_PROVIDER_MAP)
|
||||||
private readonly providers: PaymentProviderMap,
|
private readonly providers: PaymentProviderMap,
|
||||||
|
private readonly cacBankProvider: CacBankProvider,
|
||||||
) {
|
) {
|
||||||
this.intervalMs =
|
this.intervalMs =
|
||||||
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
|
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
|
||||||
@@ -82,7 +84,13 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
|
|||||||
try {
|
try {
|
||||||
const provider = this.providers.get(intent.provider);
|
const provider = this.providers.get(intent.provider);
|
||||||
if (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);
|
const result = this.intentsService.fromProviderStatus(status);
|
||||||
if (result.status !== intent.status || result.providerTxnId) {
|
if (result.status !== intent.status || result.providerTxnId) {
|
||||||
await this.intentsService.applyProviderResult(intent.id, result);
|
await this.intentsService.applyProviderResult(intent.id, result);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export { EBirrProvider } from './providers/ebirr/ebirr.provider';
|
|||||||
export { CardProvider } from './providers/card/card.provider';
|
export { CardProvider } from './providers/card/card.provider';
|
||||||
export { WaafiProvider } from './providers/waafi/waafi.provider';
|
export { WaafiProvider } from './providers/waafi/waafi.provider';
|
||||||
export { DMoneyProvider } from './providers/dmoney/dmoney.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)
|
// Telebirr crypto + types (exported for apps that build/verify signatures directly)
|
||||||
export {
|
export {
|
||||||
@@ -49,6 +50,19 @@ export type {
|
|||||||
WaafiGetTranInfoResponse,
|
WaafiGetTranInfoResponse,
|
||||||
} from './providers/waafi/waafi.types';
|
} 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
|
// Webhook payload types
|
||||||
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
|
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
|
||||||
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
|
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
|
||||||
|
|||||||
@@ -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<string> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly http: HttpService,
|
||||||
|
private readonly config: CacAuthConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getAccessToken(): Promise<string> {
|
||||||
|
if (this.cache && Date.now() < this.cache.expiresAt) {
|
||||||
|
return this.cache.accessToken;
|
||||||
|
}
|
||||||
|
return this.signin();
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
this.cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async signin(): Promise<string> {
|
||||||
|
if (this.signinInFlight) return this.signinInFlight;
|
||||||
|
|
||||||
|
this.signinInFlight = this.doSignin();
|
||||||
|
try {
|
||||||
|
return await this.signinInFlight;
|
||||||
|
} finally {
|
||||||
|
this.signinInFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSignin(): Promise<string> {
|
||||||
|
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<CacSigninResponse>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ProviderInitiationResult> {
|
||||||
|
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<CacPaymentInitiateResponse>(
|
||||||
|
"/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<CacConfirmResult> {
|
||||||
|
const requestBody: CacPaymentConfirmRequest = {
|
||||||
|
app_key: this.appKey,
|
||||||
|
api_key: this.apiKey,
|
||||||
|
payment_request_id: Number(paymentRequestId),
|
||||||
|
otp,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.postJson<CacPaymentConfirmResponse>(
|
||||||
|
"/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<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "SUCCEEDED",
|
||||||
|
providerTxnId: String(
|
||||||
|
response.confirmReference ?? response.reference,
|
||||||
|
),
|
||||||
|
reference: response.reference,
|
||||||
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
} 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<ProviderStatus> {
|
||||||
|
const lookupRef = reference ?? merchantOrderId;
|
||||||
|
const requestBody: CacGetPaymentByReferenceRequest = {
|
||||||
|
app_key: this.appKey,
|
||||||
|
api_key: this.apiKey,
|
||||||
|
reference: lookupRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.postJson<CacPaymentByReferenceResponse>(
|
||||||
|
"/paymentapi/GetPaymentByReferenceRequest",
|
||||||
|
requestBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.transactionNo != null && response.transactionDate) {
|
||||||
|
return {
|
||||||
|
status: ProviderPaymentStatus.SUCCEEDED,
|
||||||
|
providerTxnId: String(response.transactionNo),
|
||||||
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: ProviderPaymentStatus.PROCESSING,
|
||||||
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||||
|
return {
|
||||||
|
status: ProviderPaymentStatus.PROCESSING,
|
||||||
|
rawResponse: { notFound: true, reference: lookupRef },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<T>(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<T>(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<string, unknown> {
|
||||||
|
const { app_key: _appKey, api_key: _apiKey, ...rest } = body;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get baseUrl(): string {
|
||||||
|
return (this.config.get<string>("cac.baseUrl") ?? "").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
private get username(): string {
|
||||||
|
return this.config.get<string>("cac.username") ?? "";
|
||||||
|
}
|
||||||
|
private get password(): string {
|
||||||
|
return this.config.get<string>("cac.password") ?? "";
|
||||||
|
}
|
||||||
|
private get appKey(): string {
|
||||||
|
return this.config.get<string>("cac.appKey") ?? "";
|
||||||
|
}
|
||||||
|
private get apiKey(): string {
|
||||||
|
return this.config.get<string>("cac.apiKey") ?? "";
|
||||||
|
}
|
||||||
|
private get companyServicesId(): number {
|
||||||
|
return this.config.get<number>("cac.companyServicesId") ?? 0;
|
||||||
|
}
|
||||||
|
private get defaultCurrency(): string {
|
||||||
|
return this.config.get<string>("cac.currency") ?? "DJF";
|
||||||
|
}
|
||||||
|
private get tokenTtlMs(): number {
|
||||||
|
return this.config.get<number>("cac.tokenTtlMs") ?? 23 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
private get otpExpiryMs(): number {
|
||||||
|
return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
@@ -211,7 +211,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: input.currency,
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
redirect_url: input.redirectUrl,
|
redirect_url: "https://google.com",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sign = signRequestObject(
|
const sign = signRequestObject(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export enum ProviderMethod {
|
|||||||
WAAFI = "WAAFI",
|
WAAFI = "WAAFI",
|
||||||
CARD = "CARD",
|
CARD = "CARD",
|
||||||
DMONEY = "DMONEY",
|
DMONEY = "DMONEY",
|
||||||
|
CAC_BANK = "CAC_BANK",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentPlatform = "web" | "mobile";
|
export type PaymentPlatform = "web" | "mobile";
|
||||||
@@ -34,6 +35,11 @@ export type ClientAction =
|
|||||||
appId: string;
|
appId: string;
|
||||||
receiveCode?: string;
|
receiveCode?: string;
|
||||||
shortCode: string;
|
shortCode: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "COLLECT_OTP";
|
||||||
|
providerOrderId: string;
|
||||||
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ProviderInitiationInput {
|
export interface ProviderInitiationInput {
|
||||||
@@ -125,6 +131,11 @@ export interface InitiatePaymentRequest {
|
|||||||
idempotencyKey?: string;
|
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. */
|
/** Response of `POST /payments/initiate` and shape of intent lookups. */
|
||||||
export interface PaymentIntentSnapshot {
|
export interface PaymentIntentSnapshot {
|
||||||
intentId: string;
|
intentId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user