diff --git a/apps/edr-freight-api/src/config/dmoney.config.ts b/apps/edr-freight-api/src/config/dmoney.config.ts new file mode 100644 index 000000000..7922b4aae --- /dev/null +++ b/apps/edr-freight-api/src/config/dmoney.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("dmoney", () => ({ + baseUrl: process.env.DMONEY_BASE_URL ?? "", + appId: process.env.DMONEY_APP_ID ?? "", + appSecret: process.env.DMONEY_APP_SECRET ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "" +})); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 4a5bd44be..1111a69f2 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -74,8 +74,6 @@ export class PaymentService { reason: `Payment for booking`, }); - // const formatterd = this.formatIntentResponse(payment); - return { redirectUrl: `${this.configService.get("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}` } diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index b7089cae4..ef308a44a 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -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'); diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts new file mode 100644 index 000000000..a4aae8ca3 --- /dev/null +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -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 { + 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( + `${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 { + const token = await this.authenticate(); + const timestamp = new Date().toISOString(); + const signature = this.signRequest({ + merchantId: this.merchantId, + merchantOrderId, + timestamp, + }); + + const response = await this.postJson( + `${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, + }; + } + + verifyWebhookSignature(payload: Record): 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 { + const response = await this.postJson( + `${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 { + 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(url: string, body: unknown, token?: string): Promise { + const headers: Record = { + '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(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 { + const { signature: _signature, ...rest } = body; + return rest; + } + + private get baseUrl(): string { + return this.config.get('dmoney.baseUrl') ?? ''; + } + private get merchantId(): string { + return this.config.get('dmoney.merchantId') ?? ''; + } + private get appSecret(): string { + return this.config.get('dmoney.appSecret') ?? ''; + } + private get secretKey(): string { + return this.config.get('dmoney.secretKey') ?? ''; + } + private get notifyUrl(): string { + return this.config.get('dmoney.notifyUrl') ?? ''; + } + private get returnUrl(): string { + return this.config.get('dmoney.returnUrl') ?? ''; + } +} diff --git a/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts b/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts new file mode 100644 index 000000000..3a8362066 --- /dev/null +++ b/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts @@ -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; +} diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index b7938aae7..66a6dead3 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -22,6 +22,7 @@ export enum ProviderMethod { EBIRR = "EBIRR", WAAFI = "WAAFI", CARD = "CARD", + DMONEY = "DMONEY", } export type PaymentPlatform = "web" | "mobile";