mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
cac payemnt integration and cbe rate exchange webscrabing
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
redirect_url: input.redirectUrl,
|
||||
redirect_url: "https://google.com",
|
||||
},
|
||||
};
|
||||
const sign = signRequestObject(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user