Update backend to use firebase-admin SDK for FCM

This commit is contained in:
Hamza-Ayed
2026-07-19 16:21:43 +03:00
parent 978a7489e6
commit 959e2fa758
76 changed files with 4693 additions and 116 deletions
@@ -0,0 +1,116 @@
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';
/**
* بوابة CliQ — الدفع الإلكتروني الأردني (BOK + بنوك الأردن).
*
* تدفق العمل:
* 1. `POST /api/v1/payment` — إنشاء عملية دفع.
* 2. يُرجع `paymentUrl` — يُفتح في المتصفح/iframe.
* 3. webhook موقَّع يؤكد الدفع.
*
* الاعتمادات تُقرأ من `tenant.settings.payments.cliq` أولاً، ثم env vars.
*/
@Injectable()
export class CliqAdapter implements PaymentAdapter {
readonly name = 'cliq';
private readonly logger = new Logger('CliQ');
constructor(private readonly config: ConfigService) {}
private get baseUrl(): string {
return this.config.get<string>('cliq.baseUrl') ?? 'https://api.cliq.jo';
}
private creds(tenant: Tenant) {
const t = tenant?.settings?.payments?.cliq ?? {};
return {
merchantId: t.merchantId ?? this.config.get<string>('cliq.merchantId') ?? '',
apiKey: t.apiKey ?? this.config.get<string>('cliq.apiKey') ?? '',
secretKey: t.secretKey ?? this.config.get<string>('cliq.secretKey') ?? '',
terminalId: t.terminalId ?? this.config.get<string>('cliq.terminalId') ?? '',
};
}
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
const creds = this.creds(tenant);
if (!creds.merchantId || !creds.apiKey) {
this.logger.warn('CliQ credentials not configured — falling back to invoice');
return {
mode: 'invoice',
reference: `CLIQ-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`,
instructions: 'CliQ credentials not configured',
};
}
try {
const res = await fetch(`${this.baseUrl}/api/v1/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${creds.apiKey}`,
},
body: JSON.stringify({
merchant_id: creds.merchantId,
terminal_id: creds.terminalId,
amount: ctx.amount,
currency: ctx.currency,
order_id: ctx.paymentId,
customer_phone: ctx.userPhone ?? '',
callback_url: `${this.config.get<string>('app.baseUrl') ?? 'https://api.tripz.app'}/webhooks/payments/cliq`,
}),
});
if (!res.ok) {
const body = await res.text();
this.logger.error(`CliQ charge failed: ${res.status} ${body}`);
throw new Error(`CliQ API ${res.status}`);
}
const data = await res.json();
return {
mode: 'redirect',
redirectUrl: data.paymentUrl ?? data.redirect_url ?? data.url,
providerRef: data.payment_id ?? data.id,
};
} catch (e: any) {
this.logger.error(`CliQ 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-cliq-signature'] ?? headers['x-signature'] ?? '';
if (!signature || !creds.secretKey) return false;
// HMAC-SHA256 على الجسم الخام — مطابق لنمط PayMob.
const { createHmac, timingSafeEqual } = require('crypto');
const expected = createHmac('sha256', creds.secretKey)
.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.payment_status;
if (!status) return null;
return {
providerRef: payload.payment_id ?? payload.id ?? payload.transaction_id ?? '',
success: status === 'success' || status === 'completed' || status === 'paid',
amount: payload.amount ? Number(payload.amount) : undefined,
};
}
}
@@ -2,6 +2,8 @@ import { InvoiceAdapter } from './invoice.adapter';
import { PaymentGatewayRegistry } from './payment-gateway.registry';
import { CashAdapter } from './cash.adapter';
import { PaymobAdapter } from './paymob.adapter';
import { SyriatelAdapter } from './syriatel.adapter';
import { MtnAdapter } from './mtn.adapter';
describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)', () => {
it('يولّد مرجعاً ويعرض حساب الاستلام المضبوط للمستأجر', async () => {
@@ -45,7 +47,12 @@ describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)
describe('PaymentGatewayRegistry — يربط الكتالوج بمحوّله', () => {
const config = { get: () => undefined } as any;
const registry = () => new PaymentGatewayRegistry(new CashAdapter(), new PaymobAdapter(config));
const registry = () => new PaymentGatewayRegistry(
new CashAdapter(),
new PaymobAdapter(config),
new SyriatelAdapter(config),
new MtnAdapter(config),
);
it('يعرف كل مزوّدي الكتالوج', () => {
const r = registry();
@@ -0,0 +1,133 @@
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,
};
}
}
@@ -3,6 +3,8 @@ import { PaymentAdapter } from './payment-adapter.interface';
import { CashAdapter } from './cash.adapter';
import { PaymobAdapter } from './paymob.adapter';
import { InvoiceAdapter } from './invoice.adapter';
import { SyriatelAdapter } from './syriatel.adapter';
import { MtnAdapter } from './mtn.adapter';
/**
* يربط اسم المزوّد (من كتالوج `payment-methods.ts`) بمحوّله (docs/07).
@@ -13,11 +15,18 @@ import { InvoiceAdapter } from './invoice.adapter';
export class PaymentGatewayRegistry {
private readonly adapters = new Map<string, PaymentAdapter>();
constructor(cash: CashAdapter, paymob: PaymobAdapter) {
constructor(
cash: CashAdapter,
paymob: PaymobAdapter,
syriatel: SyriatelAdapter,
mtn: MtnAdapter,
) {
this.adapters.set(cash.name, cash);
this.adapters.set(paymob.name, paymob);
// بلا API فعلية اليوم — محوّل مشترك واحد لكل منها (invoice.adapter.ts).
for (const provider of ['cliq', 'shamcash', 'mtn', 'syriatel', 'zaincash']) {
this.adapters.set(syriatel.name, syriatel);
this.adapters.set(mtn.name, mtn);
// CliQ وشام كاش وزين كاش — فواتير (بلا API فعلية)
for (const provider of ['cliq', 'shamcash', 'zaincash']) {
this.adapters.set(provider, new InvoiceAdapter(provider));
}
}
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { CashAdapter } from './cash.adapter';
import { PaymobAdapter } from './paymob.adapter';
import { SyriatelAdapter } from './syriatel.adapter';
import { MtnAdapter } from './mtn.adapter';
import { PaymentGatewayRegistry } from './payment-gateway.registry';
@Module({
providers: [CashAdapter, PaymobAdapter, PaymentGatewayRegistry],
providers: [CashAdapter, PaymobAdapter, SyriatelAdapter, MtnAdapter, PaymentGatewayRegistry],
exports: [PaymentGatewayRegistry],
})
export class PaymentGatewaysModule {}
@@ -0,0 +1,115 @@
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';
/**
* بوابة سيرياتيل للدفع الإلكتروني — SyriaTel e-payment.
*
* تدفق العمل:
* 1. `POST /api/payment/init` — بدء عملية الدفع.
* 2. يُرجع `payment_link` — يُفتح في المتصفح.
* 3. webhook يؤكد النجاح/الفشل.
*
* الاعتمادات تُقرأ من `tenant.settings.payments.syriatel` أولاً، ثم env vars.
*/
@Injectable()
export class SyriatelAdapter implements PaymentAdapter {
readonly name = 'syriatel';
private readonly logger = new Logger('SyriaTel');
constructor(private readonly config: ConfigService) {}
private get baseUrl(): string {
return this.config.get<string>('syriatel.baseUrl') ?? 'https://e-payment.syriatel.com/api';
}
private creds(tenant: Tenant) {
const t = tenant?.settings?.payments?.syriatel ?? {};
return {
merchantCode: t.merchantCode ?? this.config.get<string>('syriatel.merchantCode') ?? '',
apiKey: t.apiKey ?? this.config.get<string>('syriatel.apiKey') ?? '',
secretKey: t.secretKey ?? this.config.get<string>('syriatel.secretKey') ?? '',
serviceId: t.serviceId ?? this.config.get<string>('syriatel.serviceId') ?? '',
};
}
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
const creds = this.creds(tenant);
if (!creds.merchantCode || !creds.apiKey) {
this.logger.warn('SyriaTel credentials not configured — falling back to invoice');
return {
mode: 'invoice',
reference: `SYR-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`,
instructions: 'SyriaTel credentials not configured',
};
}
try {
const res = await fetch(`${this.baseUrl}/payment/init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${creds.apiKey}`,
},
body: JSON.stringify({
merchant_code: creds.merchantCode,
service_id: creds.serviceId,
amount: ctx.amount,
currency: ctx.currency,
order_id: ctx.paymentId,
customer_phone: ctx.userPhone ?? '',
return_url: `${this.config.get<string>('app.baseUrl') ?? 'https://api.tripz.app'}/webhooks/payments/syriatel`,
}),
});
if (!res.ok) {
const body = await res.text();
this.logger.error(`SyriaTel charge failed: ${res.status} ${body}`);
throw new Error(`SyriaTel API ${res.status}`);
}
const data = await res.json();
return {
mode: 'redirect',
redirectUrl: data.payment_link ?? data.redirect_url ?? data.url,
providerRef: data.payment_id ?? data.id,
};
} catch (e: any) {
this.logger.error(`SyriaTel 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-syriatel-signature'] ?? headers['x-signature'] ?? '';
if (!signature || !creds.secretKey) return false;
const { createHmac, timingSafeEqual } = require('crypto');
const expected = createHmac('sha256', creds.secretKey)
.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.payment_status;
if (!status) return null;
return {
providerRef: payload.payment_id ?? payload.id ?? payload.transaction_id ?? '',
success: status === 'success' || status === 'completed' || status === 'paid',
amount: payload.amount ? Number(payload.amount) : undefined,
};
}
}