feat: P1 — بوابات دفع حقيقية (PayMob) + إغلاق ثغرة webhook حرجة
الأهم أولاً: `PaymentsService.webhook()` كان يقبل أي جسم `{ payment_id }`
بلا أي تحقّق توقيع — من يعرف معرّف دفعة معلَّقة كان يستطيع تحويلها «ناجحة»
ويشحن رصيداً من عدم (محفظة راكب أو رصيد سائق تشغيلي). الآن كل تغيير حالة
محروس بـ`adapter.verifyWebhook(headers, payload, tenant)`، ولا شيء يُقرأ من
الحمولة قبل ذلك كقرار ثقة — قراءة المرجع لتحديد المستأجر ليست قراراً.
البنية (docs/07 · docs/24 — P1):
- `PaymentAdapter`: charge() يعيد instant (كاش) · redirect (بوابة API حقيقية)
· invoice (بلا API، تسوية عبر P2).
- PayMob (مصر): تسلسل auth→order→payment_key→iframe حقيقي عبر fetch،
وتحقّق HMAC-SHA512 على تسلسل حقول ثابت (بروتوكول PayMob الرسمي بالضبط)
بمقارنة ثابتة الزمن. مفاتيح كل مستأجر مستقلة — حساب تاجر خاص به.
- كليق/شام كاش/MTN/سيرياتيل/زين كاش: محوّل مشترك واحد لأن سلوكها متطابق
فعلياً في سيرو (`create_*_invoice.php` تُنشئ فاتورة فقط، لا نداء بوابة
حيّاً) — مرجع + حساب استلام معروض، والتسوية عبر رسالة SMS لا webhook.
MTN/سيرياتيل الحقيقيَّين (توكن+OTP) موثَّقان كبند مفتوح: لا نبني تكاملاً
لا نملك اعتماداً حيّاً للتحقّق منه.
- `PATCH /payments/settings` لأدمن المستأجر: مفاتيح PayMob · حسابات
الاستلام · سرّ webhook الرسائل — الاستجابة لا تُعيد الأسرار.
تنظيف: إزالة الإشارات المتبقّية لحاوية `martin` من docs/07 (أُزيلت فعلياً
سابقاً)، وتحديث هيكل الكود الموثَّق ليطابق ما هو مبنيّ فعلاً.
25 اختباراً جديداً (226 إجمالاً) — منها توقيع PayMob محسوب فعلياً ومُتحقَّق،
وتلاعبٌ بالحمولة بعد التوقيع يُرفض، وسبع حالات تثبت إغلاق ثغرة الـwebhook.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
da035e46a4
commit
d4ac38ca87
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PaymentAdapter, ChargeResult, WebhookResult } from './payment-adapter.interface';
|
||||
|
||||
/** كاش — لا مزوّد خارجي، ينجح فوراً عند الاستلام اليدوي. لا webhook له إطلاقاً. */
|
||||
@Injectable()
|
||||
export class CashAdapter implements PaymentAdapter {
|
||||
readonly name = 'cash';
|
||||
|
||||
async charge(): Promise<ChargeResult> {
|
||||
return { mode: 'instant' };
|
||||
}
|
||||
|
||||
verifyWebhook(): boolean {
|
||||
return false; // كاش ليس له مزوّد يبعث webhook — أي طلب هنا مزوَّر بالتعريف
|
||||
}
|
||||
|
||||
parseWebhook(): WebhookResult | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { InvoiceAdapter } from './invoice.adapter';
|
||||
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||
import { CashAdapter } from './cash.adapter';
|
||||
import { PaymobAdapter } from './paymob.adapter';
|
||||
|
||||
describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)', () => {
|
||||
it('يولّد مرجعاً ويعرض حساب الاستلام المضبوط للمستأجر', async () => {
|
||||
const adapter = new InvoiceAdapter('cliq');
|
||||
const tenant = { settings: { payments: { transfer_targets: { cliq: '+962790000000' } } } } as any;
|
||||
const result = await adapter.charge({ paymentId: 'abcdef12', amount: 25, currency: 'JOD' }, tenant);
|
||||
|
||||
expect(result.mode).toBe('invoice');
|
||||
if (result.mode === 'invoice') {
|
||||
expect(result.reference.startsWith('CLIQ-')).toBe(true);
|
||||
expect(result.transferTarget).toBe('+962790000000');
|
||||
expect(result.instructions).toContain('+962790000000');
|
||||
}
|
||||
});
|
||||
|
||||
it('بلا حساب استلام مضبوط يوضّح ذلك بدل رابط فارغ', async () => {
|
||||
const adapter = new InvoiceAdapter('mtn');
|
||||
const result = await adapter.charge({ paymentId: 'x', amount: 10, currency: 'SYP' }, { settings: {} } as any);
|
||||
if (result.mode === 'invoice') {
|
||||
expect(result.transferTarget).toBeUndefined();
|
||||
expect(result.instructions).toContain('لم يُضبط');
|
||||
}
|
||||
});
|
||||
|
||||
it('لا webhook حقيقياً — التسوية عبر SMS فقط', () => {
|
||||
const adapter = new InvoiceAdapter('shamcash');
|
||||
expect(adapter.verifyWebhook({}, {}, {} as any)).toBe(false);
|
||||
expect(adapter.parseWebhook({})).toBeNull();
|
||||
});
|
||||
|
||||
it('كل نداء يولّد مرجعاً مختلفاً — لا تصادم بين فاتورتين', async () => {
|
||||
const adapter = new InvoiceAdapter('cliq');
|
||||
const tenant = { settings: {} } as any;
|
||||
const a = await adapter.charge({ paymentId: 'p1', amount: 5, currency: 'JOD' }, tenant);
|
||||
const b = await adapter.charge({ paymentId: 'p2', amount: 5, currency: 'JOD' }, tenant);
|
||||
if (a.mode === 'invoice' && b.mode === 'invoice') {
|
||||
expect(a.reference).not.toBe(b.reference);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaymentGatewayRegistry — يربط الكتالوج بمحوّله', () => {
|
||||
const config = { get: () => undefined } as any;
|
||||
const registry = () => new PaymentGatewayRegistry(new CashAdapter(), new PaymobAdapter(config));
|
||||
|
||||
it('يعرف كل مزوّدي الكتالوج', () => {
|
||||
const r = registry();
|
||||
for (const p of ['cash', 'paymob', 'cliq', 'shamcash', 'mtn', 'syriatel', 'zaincash']) {
|
||||
expect(r.has(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('يرفض مزوّداً مخترعاً', () => {
|
||||
const r = registry();
|
||||
expect(r.has('made_up_gateway')).toBe(false);
|
||||
expect(() => r.get('made_up_gateway')).toThrow();
|
||||
});
|
||||
|
||||
it('cliq وmtn محوّلان مستقلّان لا نفس الكائن — مراجعهما لا تختلط', () => {
|
||||
const r = registry();
|
||||
expect(r.get('cliq')).not.toBe(r.get('mtn'));
|
||||
expect(r.get('cliq').name).toBe('cliq');
|
||||
expect(r.get('mtn').name).toBe('mtn');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import {
|
||||
PaymentAdapter,
|
||||
ChargeContext,
|
||||
ChargeResult,
|
||||
WebhookResult,
|
||||
} from './payment-adapter.interface';
|
||||
|
||||
/**
|
||||
* كليك · شام كاش · MTN · سيرياتيل · زين كاش — **بلا API فعلية اليوم**
|
||||
* (مطابق لما أكّده المالك ولما هو مطبَّق فعلاً في `payment_server` لدى سيرو:
|
||||
* `create_cliq_invoice.php` / `create_mtn_invoice.php` تُنشئ فاتورة فقط،
|
||||
* لا نداء حيّاً لبوابة تحويل).
|
||||
*
|
||||
* المحوّل الواحد يخدم كل هذه المزوّدات لأن سلوكها متطابق حرفياً: يولّد مرجع
|
||||
* فاتورة ويعرض للمستخدم أين يحوّل (رقم/حساب المستأجر المسجَّل عند ذاك
|
||||
* المزوّد)، والتسوية تصل **لاحقاً عبر رسالة SMS** (docs/24 §5 — P2) لا عبر
|
||||
* webhook من المزوّد — فلا `verifyWebhook` هنا يعيد `true` أبداً.
|
||||
*
|
||||
* ⚠️ MTN وسيرياتيل يملكان API حقيقية موثَّقة في سيرو (توكن + OTP داخل
|
||||
* التطبيق) لكنها تتطلّب بيانات اعتماد إنتاجية حيّة (terminal ID · مفتاح
|
||||
* خاص · حساب تاجر) لا نملكها هنا لاختبارها فعلياً. البنية جاهزة لاستقبالها
|
||||
* (`docs/24` بند مفتوح) — لا نبني تكاملاً لا يمكن التحقّق منه.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvoiceAdapter implements PaymentAdapter {
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
|
||||
const targets = tenant?.settings?.payments?.transfer_targets ?? {};
|
||||
const reference = `${this.name.toUpperCase()}-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`;
|
||||
|
||||
return {
|
||||
mode: 'invoice',
|
||||
reference,
|
||||
transferTarget: targets[this.name] ?? undefined,
|
||||
instructions: targets[this.name]
|
||||
? `حوّل ${ctx.amount} ${ctx.currency} إلى ${targets[this.name]} عبر ${this.name}، واذكر المرجع ${reference}`
|
||||
: `حوّل ${ctx.amount} ${ctx.currency} عبر ${this.name} — لم يُضبط حساب استلام لهذا المستأجر بعد`,
|
||||
};
|
||||
}
|
||||
|
||||
/** لا webhook حقيقياً من هذه البوابات — التسوية عبر SMS فقط (P2). */
|
||||
verifyWebhook(_headers?: Record<string, any>, _rawBody?: any, _tenant?: Tenant): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
parseWebhook(_payload?: any): WebhookResult | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
|
||||
/** ما يحتاجه المحوّل لبدء عملية دفع. المبلغ بالوحدة الكبرى (دينار/جنيه لا قروش). */
|
||||
export interface ChargeContext {
|
||||
paymentId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
userPhone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* نتيجة بدء الدفع — ثلاثة أنماط لا نمط واحد، لأن بوابات المنطقة تختلف جذرياً:
|
||||
*
|
||||
* - `instant`: ينجح فوراً (كاش) — لا انتظار.
|
||||
* - `redirect`: بوابة حقيقية بواجهة دفع (PayMob) — رابط iframe يفتحه التطبيق،
|
||||
* وتأكيد لاحق عبر webhook **موقَّع** من المزوّد.
|
||||
* - `invoice`: **لا API فعلية** (كليك/شام كاش/MTN كما هي اليوم) — نولّد مرجعاً
|
||||
* ونعرض للمستخدم أين يحوّل، والتأكيد عبر **رسالة SMS** (docs/24 §5 — P2)
|
||||
* لا عبر webhook من المزوّد نفسه.
|
||||
*/
|
||||
export type ChargeResult =
|
||||
| { mode: 'instant' }
|
||||
| { mode: 'redirect'; redirectUrl: string; providerRef?: string }
|
||||
| { mode: 'invoice'; reference: string; transferTarget?: string; instructions?: string };
|
||||
|
||||
/** نتيجة تحليل حمولة webhook بعد التحقق من توقيعها. */
|
||||
export interface WebhookResult {
|
||||
providerRef: string;
|
||||
success: boolean;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface PaymentAdapter {
|
||||
readonly name: string;
|
||||
|
||||
charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult>;
|
||||
|
||||
/**
|
||||
* يتحقّق من توقيع الطلب الوارد. **يجب أن يُنادى قبل أي قراءة للحمولة** —
|
||||
* القيمة غير الموثَّقة لا تُستهلَك أبداً حتى لو بدت معقولة.
|
||||
*/
|
||||
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean;
|
||||
|
||||
/** يُنادى فقط بعد `verifyWebhook() === true`. */
|
||||
parseWebhook(payload: any): WebhookResult | null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PaymentAdapter } from './payment-adapter.interface';
|
||||
import { CashAdapter } from './cash.adapter';
|
||||
import { PaymobAdapter } from './paymob.adapter';
|
||||
import { InvoiceAdapter } from './invoice.adapter';
|
||||
|
||||
/**
|
||||
* يربط اسم المزوّد (من كتالوج `payment-methods.ts`) بمحوّله (docs/07).
|
||||
* إضافة مزوّد جديد بواجهة API فعلية = محوّل جديد + سطر هنا، بلا مساس بمنطق
|
||||
* الدفع في `PaymentsService`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentGatewayRegistry {
|
||||
private readonly adapters = new Map<string, PaymentAdapter>();
|
||||
|
||||
constructor(cash: CashAdapter, paymob: PaymobAdapter) {
|
||||
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(provider, new InvoiceAdapter(provider));
|
||||
}
|
||||
}
|
||||
|
||||
get(provider: string): PaymentAdapter {
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!adapter) throw new NotFoundException(`no payment adapter for provider "${provider}"`);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
has(provider: string): boolean {
|
||||
return this.adapters.has(provider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CashAdapter } from './cash.adapter';
|
||||
import { PaymobAdapter } from './paymob.adapter';
|
||||
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||
|
||||
@Module({
|
||||
providers: [CashAdapter, PaymobAdapter, PaymentGatewayRegistry],
|
||||
exports: [PaymentGatewayRegistry],
|
||||
})
|
||||
export class PaymentGatewaysModule {}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { createHmac } from 'crypto';
|
||||
import { PaymobAdapter } from './paymob.adapter';
|
||||
|
||||
const CREDS = {
|
||||
api_key: 'test-api-key',
|
||||
integration_id: '12345',
|
||||
iframe_id: '837992',
|
||||
hmac_secret: 'super-secret-hmac-key',
|
||||
};
|
||||
|
||||
function tenant(overrides: any = {}) {
|
||||
return { settings: { payments: { paymob: CREDS, ...overrides } } } as any;
|
||||
}
|
||||
|
||||
function configStub() {
|
||||
return { get: () => undefined } as any; // بلا احتياط env — الاعتماد كله على إعداد المستأجر
|
||||
}
|
||||
|
||||
/** يبني حمولة webhook صالحة موقَّعة فعلياً — تُستعمل لإثبات القبول والتلاعب معاً. */
|
||||
function signedPayload(overrides: Partial<Record<string, any>> = {}) {
|
||||
const obj = {
|
||||
amount_cents: 2500,
|
||||
created_at: '2026-07-18T10:00:00Z',
|
||||
currency: 'EGP',
|
||||
error_occured: false,
|
||||
has_parent_transaction: false,
|
||||
id: 999,
|
||||
integration_id: CREDS.integration_id,
|
||||
is_3d_secure: false,
|
||||
is_auth: false,
|
||||
is_capture: false,
|
||||
is_refunded: false,
|
||||
is_standalone_payment: true,
|
||||
is_voided: false,
|
||||
order: { id: 111, merchant_order_id: 'payment-uuid-1' },
|
||||
owner: 55,
|
||||
pending: false,
|
||||
source_data: { pan: '1234', sub_type: 'MASTERCARD', type: 'card' },
|
||||
success: true,
|
||||
...overrides,
|
||||
};
|
||||
const norm = (v: any) => (v === true ? 'true' : v === false ? 'false' : v == null ? '' : String(v));
|
||||
const fields = [
|
||||
norm(obj.amount_cents), norm(obj.created_at), norm(obj.currency), norm(obj.error_occured),
|
||||
norm(obj.has_parent_transaction), norm(obj.id), norm(obj.integration_id), norm(obj.is_3d_secure),
|
||||
norm(obj.is_auth), norm(obj.is_capture), norm(obj.is_refunded), norm(obj.is_standalone_payment),
|
||||
norm(obj.is_voided), norm(obj.order?.id), norm(obj.owner), norm(obj.pending),
|
||||
norm(obj.source_data?.pan), norm(obj.source_data?.sub_type), norm(obj.source_data?.type),
|
||||
norm(obj.success),
|
||||
];
|
||||
const hmac = createHmac('sha512', CREDS.hmac_secret).update(fields.join('')).digest('hex');
|
||||
return { payload: { obj }, hmac };
|
||||
}
|
||||
|
||||
describe('PaymobAdapter.verifyWebhook — توقيع حقيقي (docs/24 — P1)', () => {
|
||||
it('يقبل توقيعاً صحيحاً محسوباً بنفس خوارزمية PayMob', () => {
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const { payload, hmac } = signedPayload();
|
||||
expect(adapter.verifyWebhook({ hmac }, payload, tenant())).toBe(true);
|
||||
});
|
||||
|
||||
it('يرفض حمولة مُتلاعَباً بها بعد التوقيع — أهمّ اختبار هنا', () => {
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const { payload, hmac } = signedPayload();
|
||||
// مهاجمٌ اعترض webhook حقيقياً وغيّر المبلغ بعد أن أُخذ التوقيع لمبلغ آخر.
|
||||
payload.obj.amount_cents = 999999;
|
||||
expect(adapter.verifyWebhook({ hmac }, payload, tenant())).toBe(false);
|
||||
});
|
||||
|
||||
it('يرفض توقيعاً بسرّ خاطئ', () => {
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const { payload } = signedPayload();
|
||||
const wrongHmac = createHmac('sha512', 'wrong-secret').update('x').digest('hex');
|
||||
expect(adapter.verifyWebhook({ hmac: wrongHmac }, payload, tenant())).toBe(false);
|
||||
});
|
||||
|
||||
it('يرفض بلا توقيع إطلاقاً', () => {
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const { payload } = signedPayload();
|
||||
expect(adapter.verifyWebhook({}, payload, tenant())).toBe(false);
|
||||
});
|
||||
|
||||
it('يقرأ hmac من الـquery كما من الترويسة (توثيق PayMob الرسمي)', () => {
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const { payload, hmac } = signedPayload();
|
||||
expect(adapter.verifyWebhook({}, { ...payload, hmac }, tenant())).toBe(true);
|
||||
});
|
||||
|
||||
it('مستأجر بلا إعداد PayMob = رفض دائم، لا سقوط لسرّ افتراضي', () => {
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const { payload, hmac } = signedPayload();
|
||||
expect(adapter.verifyWebhook({ hmac }, payload, { settings: {} } as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('parseWebhook يستخرج merchant_order_id كمرجع لا معرّف PayMob الداخلي', () => {
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const { payload } = signedPayload();
|
||||
const parsed = adapter.parseWebhook(payload);
|
||||
expect(parsed).toEqual({ providerRef: 'payment-uuid-1', success: true, amount: 25 });
|
||||
});
|
||||
|
||||
it('is_voided يقلب النجاح رغم success:true — استرجاع لا يُعامَل كدفع ناجح', () => {
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const { payload } = signedPayload({ is_voided: true });
|
||||
expect(adapter.parseWebhook(payload)?.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaymobAdapter.charge — تسلسل auth → order → payment_key', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('يبني رابط iframe من التسلسل الثلاثي', async () => {
|
||||
const calls: string[] = [];
|
||||
global.fetch = jest.fn(async (url: any) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).includes('/auth/tokens')) {
|
||||
return { ok: true, json: async () => ({ token: 'AUTH123' }) } as any;
|
||||
}
|
||||
if (String(url).includes('/ecommerce/orders')) {
|
||||
return { ok: true, json: async () => ({ id: 4242 }) } as any;
|
||||
}
|
||||
if (String(url).includes('/payment_keys')) {
|
||||
return { ok: true, json: async () => ({ token: 'PAYKEY456' }) } as any;
|
||||
}
|
||||
throw new Error('unexpected url ' + url);
|
||||
}) as any;
|
||||
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
const result = await adapter.charge(
|
||||
{ paymentId: 'pay-1', amount: 25, currency: 'EGP' },
|
||||
tenant(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: 'redirect',
|
||||
redirectUrl: expect.stringContaining('PAYKEY456'),
|
||||
providerRef: '4242',
|
||||
});
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('مستأجر بلا مفاتيح PayMob يُرفض قبل أي نداء شبكة', async () => {
|
||||
global.fetch = jest.fn();
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
await expect(
|
||||
adapter.charge({ paymentId: 'p', amount: 10, currency: 'EGP' }, { settings: {} } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('فشل استدعاء PayMob يفشل بوضوح لا بصمت', async () => {
|
||||
global.fetch = jest.fn(async () => ({ ok: false, status: 401, text: async () => 'unauthorized' }) as any);
|
||||
const adapter = new PaymobAdapter(configStub());
|
||||
await expect(
|
||||
adapter.charge({ paymentId: 'p', amount: 10, currency: 'EGP' }, tenant()),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import { PaymentAdapter, ChargeContext, ChargeResult, WebhookResult } from './payment-adapter.interface';
|
||||
|
||||
interface PaymobCreds {
|
||||
apiKey: string;
|
||||
integrationId: string;
|
||||
iframeId: string;
|
||||
hmacSecret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PayMob (مصر) — البوابة الحقيقية الوحيدة بواجهة API فعلية بين مزوّدي المنطقة
|
||||
* (الباقي بلا API — انظر `invoice.adapter.ts`). البروتوكول (auth → order →
|
||||
* payment key → iframe) والتحقّق من الـHMAC منقولان من `payment_server` في
|
||||
* سيرو، وهما توثيق PayMob الرسمي نفسه — لا نخترعهما.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymobAdapter implements PaymentAdapter {
|
||||
readonly name = 'paymob';
|
||||
private readonly logger = new Logger('PaymobAdapter');
|
||||
private readonly base = 'https://accept.paymob.com/api';
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
/**
|
||||
* مفاتيح PayMob **لكل مستأجر أولاً** — كل مستأجر حساب تاجر مستقل عنده،
|
||||
* فمالُه يصل لحسابه هو لا لحساب المنصة. القيم في env احتياطٌ للتطوير
|
||||
* فقط (حساب تجريبي مشترك)، لا افتراضاً للإنتاج.
|
||||
*/
|
||||
private creds(tenant: Tenant): PaymobCreds | null {
|
||||
const t = tenant?.settings?.payments?.paymob ?? {};
|
||||
const apiKey = t.api_key ?? this.config.get<string>('PAYMOB_API_KEY') ?? '';
|
||||
const integrationId = t.integration_id ?? this.config.get<string>('PAYMOB_INTEGRATION_ID') ?? '';
|
||||
const iframeId = t.iframe_id ?? this.config.get<string>('PAYMOB_IFRAME_ID') ?? '';
|
||||
const hmacSecret = t.hmac_secret ?? this.config.get<string>('PAYMOB_HMAC_SECRET') ?? '';
|
||||
if (!apiKey || !integrationId || !iframeId || !hmacSecret) return null;
|
||||
return { apiKey, integrationId, iframeId, hmacSecret };
|
||||
}
|
||||
|
||||
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
|
||||
const creds = this.creds(tenant);
|
||||
if (!creds) {
|
||||
throw new BadRequestException('paymob is not configured for this tenant');
|
||||
}
|
||||
|
||||
// 1) رمز مصادقة
|
||||
const authRes = await this.post(`${this.base}/auth/tokens`, { api_key: creds.apiKey });
|
||||
const authToken = authRes?.token;
|
||||
if (!authToken) throw new BadRequestException('paymob auth failed');
|
||||
|
||||
// 2) طلب — amount_cents بوحدة القرش/السنت، ونربطه بمعرّف دفعتنا حتى
|
||||
// يعود إلينا كمرجع عند الـwebhook (merchant_order_id).
|
||||
const amountCents = Math.round(ctx.amount * 100);
|
||||
const orderRes = await this.post(`${this.base}/ecommerce/orders`, {
|
||||
auth_token: authToken,
|
||||
delivery_needed: false,
|
||||
amount_cents: amountCents,
|
||||
currency: ctx.currency,
|
||||
merchant_order_id: ctx.paymentId,
|
||||
items: [],
|
||||
});
|
||||
const orderId = orderRes?.id;
|
||||
if (!orderId) throw new BadRequestException('paymob order creation failed');
|
||||
|
||||
// 3) مفتاح دفع
|
||||
const keyRes = await this.post(`${this.base}/acceptance/payment_keys`, {
|
||||
auth_token: authToken,
|
||||
amount_cents: amountCents,
|
||||
expiration: 3600,
|
||||
order_id: orderId,
|
||||
billing_data: {
|
||||
first_name: 'NA',
|
||||
last_name: 'NA',
|
||||
email: 'na@tripz.app',
|
||||
phone_number: ctx.userPhone ?? 'NA',
|
||||
country: 'EG',
|
||||
city: 'NA',
|
||||
state: 'NA',
|
||||
street: 'NA',
|
||||
building: 'NA',
|
||||
apartment: 'NA',
|
||||
floor: 'NA',
|
||||
postal_code: 'NA',
|
||||
},
|
||||
currency: ctx.currency,
|
||||
integration_id: creds.integrationId,
|
||||
});
|
||||
const paymentToken = keyRes?.token;
|
||||
if (!paymentToken) throw new BadRequestException('paymob payment key failed');
|
||||
|
||||
const redirectUrl = `${this.base}/acceptance/iframes/${creds.iframeId}?payment_token=${paymentToken}`;
|
||||
return { mode: 'redirect', redirectUrl, providerRef: String(orderId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* توقيع PayMob (HMAC-SHA512) على تسلسل حقول ثابت الترتيب — **ليس** على
|
||||
* الجسم الخام. هذا هو بروتوكول PayMob الموثَّق رسمياً، لا اختراعاً منّا.
|
||||
* `timingSafeEqual` بدل مقارنة نصّية: مقارنة عادية تُسرّب طول التطابق
|
||||
* الصحيح زمنياً وتُمكّن استخراج التوقيع حرفاً حرفاً.
|
||||
*/
|
||||
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean {
|
||||
const creds = this.creds(tenant);
|
||||
if (!creds) return false;
|
||||
|
||||
const received = String(headers?.hmac ?? rawBody?.hmac ?? '').trim();
|
||||
if (!received) return false;
|
||||
|
||||
const obj = rawBody?.obj;
|
||||
if (!obj) return false;
|
||||
|
||||
const norm = (v: any) => (v === true ? 'true' : v === false ? 'false' : v == null ? '' : String(v));
|
||||
const fields = [
|
||||
norm(obj.amount_cents),
|
||||
norm(obj.created_at),
|
||||
norm(obj.currency),
|
||||
norm(obj.error_occured),
|
||||
norm(obj.has_parent_transaction),
|
||||
norm(obj.id),
|
||||
norm(obj.integration_id),
|
||||
norm(obj.is_3d_secure),
|
||||
norm(obj.is_auth),
|
||||
norm(obj.is_capture),
|
||||
norm(obj.is_refunded),
|
||||
norm(obj.is_standalone_payment),
|
||||
norm(obj.is_voided),
|
||||
norm(obj.order?.id),
|
||||
norm(obj.owner),
|
||||
norm(obj.pending),
|
||||
norm(obj.source_data?.pan),
|
||||
norm(obj.source_data?.sub_type),
|
||||
norm(obj.source_data?.type),
|
||||
norm(obj.success),
|
||||
];
|
||||
const calculated = createHmac('sha512', creds.hmacSecret).update(fields.join('')).digest('hex');
|
||||
|
||||
const a = Buffer.from(calculated);
|
||||
const b = Buffer.from(received);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
parseWebhook(payload: any): WebhookResult | null {
|
||||
const obj = payload?.obj;
|
||||
if (!obj) return null;
|
||||
// merchant_order_id = paymentId عندنا (مررناه عند إنشاء الطلب) — هو
|
||||
// المرجع الذي نطابق به، لا معرّف PayMob الداخلي.
|
||||
const providerRef = obj?.order?.merchant_order_id ?? null;
|
||||
if (!providerRef) return null;
|
||||
return {
|
||||
providerRef,
|
||||
success: obj?.success === true && obj?.is_voided !== true && obj?.is_refunded !== true,
|
||||
amount: typeof obj?.amount_cents === 'number' ? obj.amount_cents / 100 : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async post(url: string, body: any): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
this.logger.error(`PayMob ${url} → ${res.status}: ${text.slice(0, 300)}`);
|
||||
throw new BadRequestException('paymob request failed');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user