Update backend to use firebase-admin SDK for FCM
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user