working on dmoney

This commit is contained in:
Eyosiyas
2026-06-08 16:29:20 +03:00
parent a1940c4901
commit 3a66242f8b
6 changed files with 278 additions and 2 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 {
@@ -45,6 +46,7 @@ export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
export type { WaafiWebhookPayload } 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,252 @@
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.authenticate();
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

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