Merge branch 'alpha' into feat/telebirr-integration

This commit is contained in:
Abubeker Yasin
2026-06-10 13:22:47 +03:00
869 changed files with 97176 additions and 5351 deletions

View File

@@ -19,6 +19,7 @@ export { CbeBirrProvider } from './providers/cbe-birr/cbe-birr.provider';
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';
// Telebirr crypto + types (exported for apps that build/verify signatures directly)
export {
@@ -61,6 +62,7 @@ export type {
WaafiWebhookEvent,
WaafiWebhookStatus,
} from './webhooks/waafi-webhook.types';
export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types';
// DI token for injecting all providers as an array (future multi-provider wiring)
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');

View File

@@ -0,0 +1,266 @@
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 * as crypto from "node:crypto";
interface DMoneyAuthResponse {
token: string;
}
interface DMoneyInitiateRequest {
merchantId: string;
merchantOrderId: string;
amount: string;
currency: string;
description: string;
returnUrl: string;
notifyUrl: string;
payerPhone?: string;
timestamp: string;
signature: string;
}
interface DMoneyInitiateResponse {
success: boolean;
orderId: string;
checkoutUrl?: string;
expiresIn: number;
}
interface DMoneyQueryResponse {
success: boolean;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
currency?: string;
paidAt?: string;
payerPhone?: string;
}
@Injectable()
export class DMoneyProvider implements PaymentProvider {
readonly method = ProviderMethod.DMONEY;
private readonly logger = new Logger(DMoneyProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) { }
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
const token = await this.getFabricToken();
const amount = (input.amountMinor / 100).toFixed(2);
const timestamp = new Date().toISOString();
const requestBody: DMoneyInitiateRequest = {
merchantId: this.merchantId,
merchantOrderId: input.merchantOrderId,
amount,
currency: input.currency,
description: `EDR ${input.orderRef}`,
returnUrl: this.returnUrl,
notifyUrl: this.notifyUrl,
timestamp,
signature: this.signRequest({
merchantId: this.merchantId,
merchantOrderId: input.merchantOrderId,
amount,
timestamp,
}),
};
const response = await this.postJson<DMoneyInitiateResponse>(
`${this.baseUrl}/api/v1/payment/initiate`,
requestBody,
token,
);
if (!response.success || !response.orderId) {
throw new Error(`DMoney initiate failed: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(Date.now() + response.expiresIn * 1000);
return {
providerOrderId: response.orderId,
clientAction: response.checkoutUrl
? { type: "REDIRECT", url: response.checkoutUrl }
: {
type: "REDIRECT",
url: `${this.baseUrl}/checkout/${response.orderId}`,
},
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const token = await this.getFabricToken();
const timestamp = new Date().toISOString();
const signature = this.signRequest({
merchantId: this.merchantId,
merchantOrderId,
timestamp,
});
const response = await this.postJson<DMoneyQueryResponse>(
`${this.baseUrl}/api/v1/payment/query`,
{
merchantId: this.merchantId,
merchantOrderId,
timestamp,
signature,
},
token,
);
const mapped = this.mapStatus(response.status);
return {
status: mapped,
providerTxnId: response.transactionId,
failureCode:
mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
const { signature, ...data } = payload;
if (!signature || typeof signature !== "string") return false;
const expectedSignature = this.signRequest(data);
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
}
mapWebhookStatus(status: string): ProviderPaymentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): ProviderPaymentStatus {
switch (status?.toUpperCase()) {
case "SUCCESS":
case "COMPLETED":
return ProviderPaymentStatus.SUCCEEDED;
case "FAILED":
case "REJECTED":
case "EXPIRED":
case "CANCELLED":
return ProviderPaymentStatus.FAILED;
case "PENDING":
return ProviderPaymentStatus.REQUIRES_ACTION;
case "PROCESSING":
return ProviderPaymentStatus.PROCESSING;
default:
return ProviderPaymentStatus.PROCESSING;
}
}
private async getFabricToken(): Promise<string> {
const response = await this.postJson<DMoneyAuthResponse>(
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/token`,
{
appSecret: this.appSecret,
},
);
if (!response.token) {
throw new Error(
`DMoney authentication failed: ${JSON.stringify(response)}`,
);
}
return response.token;
}
private signRequest(data: Record<string, unknown>): string {
const sortedKeys = Object.keys(data).sort();
const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&");
return crypto
.createHmac("sha256", this.secretKey)
.update(signString)
.digest("hex");
}
private async postJson<T>(
url: string,
body: unknown,
token?: string,
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const config: AxiosRequestConfig = {
headers,
timeout: 10_000,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(
`DMoney POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`DMoney POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(
`DMoney POST ${url} threw: ${err instanceof Error ? err.message : err}`,
);
}
throw err;
}
}
private sanitize(body: DMoneyInitiateRequest): Record<string, unknown> {
const { signature: _signature, ...rest } = body;
return rest;
}
private get baseUrl(): string {
return this.config.get<string>("dmoney.baseUrl") ?? "";
}
private get merchantId(): string {
return this.config.get<string>("dmoney.merchantId") ?? "";
}
private get appSecret(): string {
return this.config.get<string>("dmoney.appSecret") ?? "";
}
private get secretKey(): string {
return this.config.get<string>("dmoney.secretKey") ?? "";
}
private get notifyUrl(): string {
return this.config.get<string>("dmoney.notifyUrl") ?? "";
}
private get returnUrl(): string {
return this.config.get<string>("dmoney.returnUrl") ?? "";
}
}

View File

@@ -64,11 +64,11 @@ export class TelebirrProvider implements PaymentProvider {
const clientAction =
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
appId: this.merchantAppId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
type: 'LAUNCH_APP' as const,
appId: this.merchantAppId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
return {
@@ -195,6 +195,7 @@ export class TelebirrProvider implements PaymentProvider {
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
redirect_url: input.redirectUrl
},
};
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);

View File

@@ -67,3 +67,4 @@ export interface QueryOrderResponse {
};
[key: string]: unknown;
}

View File

@@ -0,0 +1,13 @@
export interface DMoneyWebhookPayload {
merchantId: string;
merchantOrderId: string;
orderId: string;
status: string;
transactionId?: string;
amount?: string;
currency?: string;
paidAt?: string;
payerPhone?: string;
signature: string;
[key: string]: unknown;
}