Files
tripz-llc/backend/src/integrations/payments/mtn.adapter.ts
T

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