134 lines
4.8 KiB
TypeScript
134 lines
4.8 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { Tenant } from '../../database/entities/tenant.entity';
|
|
import {
|
|
PaymentAdapter,
|
|
ChargeContext,
|
|
ChargeResult,
|
|
WebhookResult,
|
|
} from './payment-adapter.interface';
|
|
|
|
/**
|
|
* بوابة MTN للدفع الإلكتروني — MTN Mobile Money / MTN Payment Gateway.
|
|
*
|
|
* تدفق العمل:
|
|
* 1. `POST /api/v1/requestToPay` — طلب دفع من المستخدم.
|
|
* 2. يُرجع `referenceId` — التطبيق يتابع الحالة عبر polling.
|
|
* 3. webhook / polling يؤكد النجاح.
|
|
*
|
|
* الاعتمادات تُقرأ من `tenant.settings.payments.mtn` أولاً، ثم env vars.
|
|
*/
|
|
@Injectable()
|
|
export class MtnAdapter implements PaymentAdapter {
|
|
readonly name = 'mtn';
|
|
private readonly logger = new Logger('MTN');
|
|
|
|
constructor(private readonly config: ConfigService) {}
|
|
|
|
private get baseUrl(): string {
|
|
return this.config.get<string>('mtn.baseUrl') ?? 'https://proxy.momoapi.mtn.com';
|
|
}
|
|
|
|
private creds(tenant: Tenant) {
|
|
const t = tenant?.settings?.payments?.mtn ?? {};
|
|
return {
|
|
apiKey: t.apiKey ?? this.config.get<string>('mtn.apiKey') ?? '',
|
|
apiUser: t.apiUser ?? this.config.get<string>('mtn.apiUser') ?? '',
|
|
subscriptionKey: t.subscriptionKey ?? this.config.get<string>('mtn.subscriptionKey') ?? '',
|
|
environment: t.environment ?? this.config.get<string>('mtn.environment') ?? 'sandbox',
|
|
};
|
|
}
|
|
|
|
private async getAccessToken(creds: { apiKey: string; apiUser: string; subscriptionKey: string }): Promise<string> {
|
|
const res = await fetch(`${this.baseUrl}/collection/token/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Basic ${Buffer.from(`${creds.apiUser}:${creds.apiKey}`).toString('base64')}`,
|
|
'Ocp-Apim-Subscription-Key': creds.subscriptionKey,
|
|
},
|
|
});
|
|
if (!res.ok) throw new Error(`MTN token failed: ${res.status}`);
|
|
const data = await res.json();
|
|
return data.access_token;
|
|
}
|
|
|
|
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
|
|
const creds = this.creds(tenant);
|
|
if (!creds.apiKey || !creds.apiUser) {
|
|
this.logger.warn('MTN credentials not configured — falling back to invoice');
|
|
return {
|
|
mode: 'invoice',
|
|
reference: `MTN-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`,
|
|
instructions: 'MTN credentials not configured',
|
|
};
|
|
}
|
|
|
|
try {
|
|
const token = await this.getAccessToken(creds);
|
|
const referenceId = ctx.paymentId;
|
|
|
|
const res = await fetch(`${this.baseUrl}/collection/v1_0/requestToPay`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
'X-Reference-Id': referenceId,
|
|
'X-Target-Environment': creds.environment,
|
|
'Ocp-Apim-Subscription-Key': creds.subscriptionKey,
|
|
},
|
|
body: JSON.stringify({
|
|
amount: String(ctx.amount),
|
|
currency: ctx.currency,
|
|
externalId: ctx.paymentId,
|
|
payer: { partyIdType: 'MSISDN', partyId: ctx.userPhone ?? '' },
|
|
payerMessage: 'Tripz payment',
|
|
payeeNote: `Payment for trip ${ctx.paymentId}`,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok && res.status !== 202) {
|
|
const body = await res.text();
|
|
this.logger.error(`MTN charge failed: ${res.status} ${body}`);
|
|
throw new Error(`MTN API ${res.status}`);
|
|
}
|
|
|
|
// MTN يرجع 202 Accepted — التأكيد عبر polling أو webhook.
|
|
return {
|
|
mode: 'redirect',
|
|
redirectUrl: `${this.baseUrl}/collection/v1_0/requestToPay/${referenceId}`,
|
|
providerRef: referenceId,
|
|
};
|
|
} catch (e: any) {
|
|
this.logger.error(`MTN charge error: ${e?.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean {
|
|
const creds = this.creds(tenant);
|
|
const signature = headers['x-mtn-signature'] ?? headers['x-signature'] ?? '';
|
|
if (!signature || !creds.subscriptionKey) return false;
|
|
|
|
const { createHmac, timingSafeEqual } = require('crypto');
|
|
const expected = createHmac('sha256', creds.subscriptionKey)
|
|
.update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody))
|
|
.digest('hex');
|
|
try {
|
|
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
parseWebhook(payload: any): WebhookResult | null {
|
|
if (!payload) return null;
|
|
const status = payload.status ?? payload.financialTransaction?.status;
|
|
if (!status) return null;
|
|
return {
|
|
providerRef: payload.referenceId ?? payload.externalId ?? '',
|
|
success: status === 'SUCCESSFUL' || status === 'completed',
|
|
amount: payload.amount ? Number(payload.amount) : undefined,
|
|
};
|
|
}
|
|
}
|