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

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

View File

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

View File

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

View File

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

View File

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

View File

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