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('mtn.baseUrl') ?? 'https://proxy.momoapi.mtn.com'; } private creds(tenant: Tenant) { const t = tenant?.settings?.payments?.mtn ?? {}; return { apiKey: t.apiKey ?? this.config.get('mtn.apiKey') ?? '', apiUser: t.apiUser ?? this.config.get('mtn.apiUser') ?? '', subscriptionKey: t.subscriptionKey ?? this.config.get('mtn.subscriptionKey') ?? '', environment: t.environment ?? this.config.get('mtn.environment') ?? 'sandbox', }; } private async getAccessToken(creds: { apiKey: string; apiUser: string; subscriptionKey: string }): Promise { 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 { 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, 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, }; } }