From d4ac38ca872ada15fc79697980da2e4111420bcf Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 18 Jul 2026 15:50:05 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20P1=20=E2=80=94=20=D8=A8=D9=88=D8=A7?= =?UTF-8?q?=D8=A8=D8=A7=D8=AA=20=D8=AF=D9=81=D8=B9=20=D8=AD=D9=82=D9=8A?= =?UTF-8?q?=D9=82=D9=8A=D8=A9=20(PayMob)=20+=20=D8=A5=D8=BA=D9=84=D8=A7?= =?UTF-8?q?=D9=82=20=D8=AB=D8=BA=D8=B1=D8=A9=20webhook=20=D8=AD=D8=B1?= =?UTF-8?q?=D8=AC=D8=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit الأهم أولاً: `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 --- backend/.env.example | 12 +- .../src/integrations/payments/cash.adapter.ts | 20 ++ .../payments/invoice-and-registry.spec.ts | 69 +++++++ .../integrations/payments/invoice.adapter.ts | 52 ++++++ .../payments/payment-adapter.interface.ts | 46 +++++ .../payments/payment-gateway.registry.ts | 34 ++++ .../payments/payment-gateways.module.ts | 10 + .../payments/paymob.adapter.spec.ts | 162 +++++++++++++++++ .../integrations/payments/paymob.adapter.ts | 171 ++++++++++++++++++ .../modules/payments/payments.controller.ts | 70 ++++++- .../src/modules/payments/payments.module.ts | 2 + .../modules/payments/payments.routing.spec.ts | 6 + .../src/modules/payments/payments.service.ts | 98 ++++++++-- .../modules/payments/payments.webhook.spec.ts | 92 ++++++++++ docs/07-integrations.md | 39 ++-- docs/24-tenant-wallet-revenue.md | 11 +- 16 files changed, 852 insertions(+), 42 deletions(-) create mode 100644 backend/src/integrations/payments/cash.adapter.ts create mode 100644 backend/src/integrations/payments/invoice-and-registry.spec.ts create mode 100644 backend/src/integrations/payments/invoice.adapter.ts create mode 100644 backend/src/integrations/payments/payment-adapter.interface.ts create mode 100644 backend/src/integrations/payments/payment-gateway.registry.ts create mode 100644 backend/src/integrations/payments/payment-gateways.module.ts create mode 100644 backend/src/integrations/payments/paymob.adapter.spec.ts create mode 100644 backend/src/integrations/payments/paymob.adapter.ts create mode 100644 backend/src/modules/payments/payments.webhook.spec.ts diff --git a/backend/.env.example b/backend/.env.example index ac32f80..adde624 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -62,9 +62,19 @@ SMS_SENDER= FCM_SERVER_KEY= FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send -# ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه) ---- +# ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه + تحليل رسائل التحويل P2) ---- GEMINI_API_KEY= GEMINI_MODEL=gemini-2.0-flash +# ---- PayMob (مصر) — احتياط تطوير فقط (docs/24 — P1) ---- +# الإنتاج: كل مستأجر مفاتيحه الخاصة في tenant.settings.payments.paymob +# (PATCH /payments/settings من لوحة أدمن المستأجر) — حساب تاجر مستقل لكل +# مستأجر، فمالُه يصل لحسابه هو. هذه المتغيّرات تُستعمل فقط إن كان المستأجر +# بلا إعداد خاص، لحساب تجريبي مشترك وقت التطوير. +PAYMOB_API_KEY= +PAYMOB_INTEGRATION_ID= +PAYMOB_IFRAME_ID= +PAYMOB_HMAC_SECRET= + # ---- سرّ المنصة (لتعيين أول أدمن) — ولّده: openssl rand -hex 24 ---- PLATFORM_SECRET= diff --git a/backend/src/integrations/payments/cash.adapter.ts b/backend/src/integrations/payments/cash.adapter.ts new file mode 100644 index 0000000..f29acb2 --- /dev/null +++ b/backend/src/integrations/payments/cash.adapter.ts @@ -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 { + return { mode: 'instant' }; + } + + verifyWebhook(): boolean { + return false; // كاش ليس له مزوّد يبعث webhook — أي طلب هنا مزوَّر بالتعريف + } + + parseWebhook(): WebhookResult | null { + return null; + } +} diff --git a/backend/src/integrations/payments/invoice-and-registry.spec.ts b/backend/src/integrations/payments/invoice-and-registry.spec.ts new file mode 100644 index 0000000..502a4e1 --- /dev/null +++ b/backend/src/integrations/payments/invoice-and-registry.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/integrations/payments/invoice.adapter.ts b/backend/src/integrations/payments/invoice.adapter.ts new file mode 100644 index 0000000..e252c83 --- /dev/null +++ b/backend/src/integrations/payments/invoice.adapter.ts @@ -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 { + 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, _rawBody?: any, _tenant?: Tenant): boolean { + return false; + } + + parseWebhook(_payload?: any): WebhookResult | null { + return null; + } +} diff --git a/backend/src/integrations/payments/payment-adapter.interface.ts b/backend/src/integrations/payments/payment-adapter.interface.ts new file mode 100644 index 0000000..7751ace --- /dev/null +++ b/backend/src/integrations/payments/payment-adapter.interface.ts @@ -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; + + /** + * يتحقّق من توقيع الطلب الوارد. **يجب أن يُنادى قبل أي قراءة للحمولة** — + * القيمة غير الموثَّقة لا تُستهلَك أبداً حتى لو بدت معقولة. + */ + verifyWebhook(headers: Record, rawBody: any, tenant: Tenant): boolean; + + /** يُنادى فقط بعد `verifyWebhook() === true`. */ + parseWebhook(payload: any): WebhookResult | null; +} diff --git a/backend/src/integrations/payments/payment-gateway.registry.ts b/backend/src/integrations/payments/payment-gateway.registry.ts new file mode 100644 index 0000000..aa40613 --- /dev/null +++ b/backend/src/integrations/payments/payment-gateway.registry.ts @@ -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(); + + 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); + } +} diff --git a/backend/src/integrations/payments/payment-gateways.module.ts b/backend/src/integrations/payments/payment-gateways.module.ts new file mode 100644 index 0000000..c2ca337 --- /dev/null +++ b/backend/src/integrations/payments/payment-gateways.module.ts @@ -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 {} diff --git a/backend/src/integrations/payments/paymob.adapter.spec.ts b/backend/src/integrations/payments/paymob.adapter.spec.ts new file mode 100644 index 0000000..edbb392 --- /dev/null +++ b/backend/src/integrations/payments/paymob.adapter.spec.ts @@ -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> = {}) { + 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); + }); +}); diff --git a/backend/src/integrations/payments/paymob.adapter.ts b/backend/src/integrations/payments/paymob.adapter.ts new file mode 100644 index 0000000..5240b8b --- /dev/null +++ b/backend/src/integrations/payments/paymob.adapter.ts @@ -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('PAYMOB_API_KEY') ?? ''; + const integrationId = t.integration_id ?? this.config.get('PAYMOB_INTEGRATION_ID') ?? ''; + const iframeId = t.iframe_id ?? this.config.get('PAYMOB_IFRAME_ID') ?? ''; + const hmacSecret = t.hmac_secret ?? this.config.get('PAYMOB_HMAC_SECRET') ?? ''; + if (!apiKey || !integrationId || !iframeId || !hmacSecret) return null; + return { apiKey, integrationId, iframeId, hmacSecret }; + } + + async charge(ctx: ChargeContext, tenant: Tenant): Promise { + 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, 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 { + 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(); + } +} diff --git a/backend/src/modules/payments/payments.controller.ts b/backend/src/modules/payments/payments.controller.ts index d2fd32f..a294713 100644 --- a/backend/src/modules/payments/payments.controller.ts +++ b/backend/src/modules/payments/payments.controller.ts @@ -1,7 +1,10 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { PaymentsService } from './payments.service'; +import { TenantsService } from '../tenants/tenants.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; @@ -12,7 +15,10 @@ import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature @ApiTags('payments') @Controller('payments') export class PaymentsController { - constructor(private readonly payments: PaymentsService) {} + constructor( + private readonly payments: PaymentsService, + private readonly tenants: TenantsService, + ) {} @ApiBearerAuth() @UseGuards(JwtAuthGuard, FeatureGuard) @@ -29,6 +35,7 @@ export class PaymentsController { method: body.method, // الدور من التوكن لا من الجسم — عليه يتوقّف السماح بـcredit_topup. userRole: user.role, + userPhone: user.phone, }); } @@ -39,9 +46,62 @@ export class PaymentsController { return this.payments.findMine(user.tenantId, user.userId); } - // تأكيد البوابة (async) — عام؛ التحقق من التوقيع يُضاف مع كل مزوّد. + /** + * إعداد بوابات المستأجر (docs/24 — P1): مفاتيح PayMob · حسابات الاستلام + * لكليك/شام كاش/MTN (`transfer_targets`) · سرّ webhook الرسائل (P2). + * + * **أدمن المستأجر لا السوبر-أدمن**: هذه «كيف تشتغل» لا «ماذا اشتريت» + * (تمييز `tenant.settings` عن `tenant.features` — docs/06). + * + * الدمج بمستوى واحد فقط (`updateSettings`): إرسال `paymob` يستبدل كائن + * `paymob` كاملاً محتفظاً بإخوته (`transfer_targets`…)، لا يدمج داخله حقلاً + * حقلاً. أرسل الكائن الفرعي كاملاً عند تعديل أيّ حقل فيه. + * + * الاستجابة **لا تعيد الأسرار**: مفاتيح PayMob وسرّ الرسائل تُرَدّ كـ + * "مضبوط/غير مضبوط" فقط — لا داعي لإعادة سرّ أدخله المتصفح للتوّ. + */ + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @Patch('settings') + async setSettings( + @CurrentUser() user: AuthUser, + @Body() + body: { + paymob?: { api_key?: string; integration_id?: string; iframe_id?: string; hmac_secret?: string }; + transfer_targets?: Record; + transaction_fee?: number; + sms_webhook_secret?: string; + }, + ) { + const t = await this.tenants.updateSettings(user.tenantId, { payments: body }); + const p = t.settings?.payments ?? {}; + return { + transfer_targets: p.transfer_targets ?? {}, + transaction_fee: p.transaction_fee, + paymob_configured: !!( + p.paymob?.api_key && + p.paymob?.integration_id && + p.paymob?.iframe_id && + p.paymob?.hmac_secret + ), + sms_webhook_configured: !!p.sms_webhook_secret, + }; + } + + /** + * تأكيد بوابة حقيقية (PayMob) — **عام بالضرورة** (المزوّد لا يحمل توكننا)، + * لكن كل تغيير فعلي محروس بتوقّق التوقيع داخل `PaymentsService.webhook` + * (docs/24 — P1). PayMob يرسل `hmac` في الـquery string حسب توثيقه + * الرسمي؛ نمرّره ضمن `headers` أيضاً لأن بعض التهيئات تعيد توجيهه كترويسة. + */ @Post('webhook/:provider') - webhook(@Param('provider') provider: string, @Body() payload: any) { - return this.payments.webhook(provider, payload); + webhook( + @Param('provider') provider: string, + @Body() payload: any, + @Headers() headers: Record, + @Query('hmac') hmacQuery?: string, + ) { + return this.payments.webhook(provider, payload, { ...headers, hmac: headers?.hmac ?? hmacQuery }); } } diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts index e620efe..a172d4d 100644 --- a/backend/src/modules/payments/payments.module.ts +++ b/backend/src/modules/payments/payments.module.ts @@ -14,6 +14,7 @@ import { UsersModule } from '../users/users.module'; import { TenantsModule } from '../tenants/tenants.module'; import { TenantWalletModule } from '../tenant-wallet/tenant-wallet.module'; import { CreditModule } from '../credit/credit.module'; +import { PaymentGatewaysModule } from '../../integrations/payments/payment-gateways.module'; @Module({ // OtpModule و AuditModule عالميان. @@ -24,6 +25,7 @@ import { CreditModule } from '../credit/credit.module'; TenantsModule, TenantWalletModule, // دفترا المستأجر (docs/24) CreditModule, // شحن الرصيد التشغيلي للسائق + PaymentGatewaysModule, // محوّلات البوابات (docs/24 — P1) ], controllers: [PaymentsController, PayoutsController, SmsSettlementController], providers: [PaymentsService, PayoutsService, SmsSettlementService], diff --git a/backend/src/modules/payments/payments.routing.spec.ts b/backend/src/modules/payments/payments.routing.spec.ts index 3fe583e..2bf1165 100644 --- a/backend/src/modules/payments/payments.routing.spec.ts +++ b/backend/src/modules/payments/payments.routing.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { PaymentsService } from './payments.service'; import { LedgerReason } from '../tenant-wallet/entities/tenant-ledger.entity'; +import { CashAdapter } from '../../integrations/payments/cash.adapter'; /** * يثبت **وجهة المال** لا آلية التخزين (تلك مغطّاة في tenant-wallet.service.spec): @@ -27,6 +28,10 @@ function makeService(countryPack = 'jo', settings: any = {}) { }; const tenants = { resolve: jest.fn().mockResolvedValue({ countryPack, settings }) }; const credit = { topup: jest.fn().mockResolvedValue({}) }; + // هذه المحكّات تختبر توجيه المال لا البوابات (تلك في payment-gateways.spec) — + // مزوّد كاش حقيقي وحيد يكفي لتحريك المسار الفوري. + const cash = new CashAdapter(); + const gateways = { has: (p: string) => p === 'cash', get: () => cash }; const svc = new PaymentsService( repo as any, @@ -34,6 +39,7 @@ function makeService(countryPack = 'jo', settings: any = {}) { tenantWallet as any, tenants as any, credit as any, + gateways as any, ); return { svc, wallet, tenantWallet, credit }; } diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts index 9af22ef..cf1ed02 100644 --- a/backend/src/modules/payments/payments.service.ts +++ b/backend/src/modules/payments/payments.service.ts @@ -1,4 +1,10 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Payment, PaymentPurpose } from './entities/payment.entity'; @@ -8,6 +14,7 @@ import { LedgerReason } from '../tenant-wallet/entities/tenant-ledger.entity'; import { TenantsService } from '../tenants/tenants.service'; import { DriverCreditService } from '../credit/driver-credit.service'; import { transactionFeeFor, cappedFee } from '../tenant-wallet/transaction-fee'; +import { PaymentGatewayRegistry } from '../../integrations/payments/payment-gateway.registry'; export interface ChargeDto { userId: string; @@ -19,19 +26,19 @@ export interface ChargeDto { method?: string; /** دور صاحب التوكن — يُمرَّر من الكنترولر دائماً، لا من جسم الطلب. */ userRole?: string; + userPhone?: string; } const PURPOSES: PaymentPurpose[] = ['topup', 'credit_topup', 'trip']; /** - * الدفع بنمط المحوّلات (docs/07): cash فوري، والبوابات تُنشئ نية دفع + رابط تحويل، - * ثم يؤكّدها webhook. عند النجاح: شحن المحفظة (topup) أو تسجيل دفع الرحلة (trip). - * التكامل الحقيقي مع كل بوابة يُضاف لاحقاً بمفاتيح الدولة — البنية جاهزة. + * الدفع بنمط المحوّلات (docs/07 · docs/24 — P1): كل مزوّد خلف `PaymentAdapter` + * واحد يقرّر شكل الاستجابة (فوري · redirect · invoice)، والوحدة هنا لا تعرف + * تفاصيل أي بوابة — فقط توجّه المال بعد التأكيد (docs/24). */ @Injectable() export class PaymentsService { private readonly logger = new Logger('Payments'); - private readonly instantProviders = ['cash']; constructor( @InjectRepository(Payment) private readonly repo: Repository, @@ -39,12 +46,16 @@ export class PaymentsService { private readonly tenantWallet: TenantWalletService, private readonly tenants: TenantsService, private readonly credit: DriverCreditService, + private readonly gateways: PaymentGatewayRegistry, ) {} async charge(tenantId: string, dto: ChargeDto) { const amount = Number(dto.amount); if (!(amount > 0)) throw new BadRequestException('amount must be > 0'); if (!dto.provider) throw new BadRequestException('provider is required'); + if (!this.gateways.has(dto.provider)) { + throw new BadRequestException(`unsupported provider "${dto.provider}"`); + } const purpose: PaymentPurpose = dto.purpose ?? 'topup'; if (!PURPOSES.includes(purpose)) throw new BadRequestException('invalid purpose'); @@ -57,6 +68,9 @@ export class PaymentsService { throw new BadRequestException('credit_topup is for drivers only'); } + const tenant = await this.tenants.resolve(tenantId); + if (!tenant) throw new NotFoundException('tenant not found'); + let payment = await this.repo.save( this.repo.create({ tenant_id: tenantId, @@ -71,26 +85,76 @@ export class PaymentsService { }), ); - // مزوّد فوري (كاش) — ينجح مباشرة - if (this.instantProviders.includes(dto.provider)) { + const adapter = this.gateways.get(dto.provider); + const result = await adapter.charge( + { paymentId: payment.id, amount, currency: payment.currency, userPhone: dto.userPhone }, + tenant, + ); + + if (result.mode === 'instant') { return { payment: await this.markSuccess(payment) }; } - // بوابة خارجية — نية دفع + رابط تحويل (يؤكّده webhook لاحقاً) - payment.redirect_url = `https://pay.${dto.provider}.gateway/checkout/${payment.id}`; + if (result.mode === 'redirect') { + payment.redirect_url = result.redirectUrl; + payment.meta = { ...(payment.meta ?? {}), provider_ref: result.providerRef }; + payment = await this.repo.save(payment); + this.logger.log(`redirect ${dto.provider} payment=${payment.id} amount=${amount}`); + return { payment, redirectUrl: payment.redirect_url }; + } + + // `invoice`: بلا API فعلية — المرجع يُخزَّن في tx_ref **الآن** ليطابقه + // مسار SMS (docs/24 §5 — P2) بمجرّد وصول رسالة التأكيد. + payment.tx_ref = result.reference; + payment.meta = { + ...(payment.meta ?? {}), + transfer_target: result.transferTarget, + instructions: result.instructions, + }; payment = await this.repo.save(payment); - this.logger.log(`intent ${dto.provider} payment=${payment.id} amount=${amount}`); - return { payment, redirectUrl: payment.redirect_url }; + this.logger.log(`invoice ${dto.provider} payment=${payment.id} ref=${result.reference}`); + return { + payment, + reference: result.reference, + transferTarget: result.transferTarget, + instructions: result.instructions, + }; } - /** تأكيد من بوابة الدفع (async). التحقق من التوقيع يُضاف مع كل مزوّد. */ - async webhook(provider: string, payload: any) { - const id = payload?.payment_id ?? payload?.ref ?? payload?.id; - if (!id) throw new BadRequestException('missing payment reference'); - const payment = await this.repo.findOne({ where: { id } }); + /** + * تأكيد من بوابة دفع حقيقية (PayMob). **الحارس الأمني الفعلي هنا**: كان + * هذا المسار يقبل أي جسم `{ payment_id }` بلا أي تحقّق — أي أن أي شخص + * يعرف معرّف دفعة معلَّقة يستطيع تحويلها «ناجحة» ويشحن رصيداً من عدم. + * + * الترتيب حرِج: نقرأ `providerRef` من الحمولة لنعرف **أي مستأجر** نتحقّق + * بسرّه (كل مستأجر حساب PayMob مستقل) — هذه قراءة بحتة لا قرار ثقة. القرار + * الوحيد الذي يُبنى عليه تغييرٌ فعلي هو `verifyWebhook`، ولا شيء قبله. + */ + async webhook(provider: string, payload: any, headers: Record = {}) { + if (!this.gateways.has(provider)) throw new BadRequestException('unknown provider'); + const adapter = this.gateways.get(provider); + + const parsed = adapter.parseWebhook(payload); + if (!parsed) throw new BadRequestException('unrecognized webhook payload'); + + const payment = await this.repo.findOne({ where: { id: parsed.providerRef } }); if (!payment) throw new NotFoundException('payment not found'); + + const tenant = await this.tenants.resolve(payment.tenant_id); + if (!tenant) throw new NotFoundException('tenant not found'); + + if (!adapter.verifyWebhook(headers, payload, tenant)) { + throw new UnauthorizedException('invalid webhook signature'); + } + if (payment.status === 'success') return { ok: true, already: true }; - payment.tx_ref = payload?.tx_ref ?? payment.tx_ref; + + if (!parsed.success) { + payment.status = 'failed'; + await this.repo.save(payment); + return { ok: true, failed: true }; + } + await this.markSuccess(payment); return { ok: true }; } diff --git a/backend/src/modules/payments/payments.webhook.spec.ts b/backend/src/modules/payments/payments.webhook.spec.ts new file mode 100644 index 0000000..69d4a4b --- /dev/null +++ b/backend/src/modules/payments/payments.webhook.spec.ts @@ -0,0 +1,92 @@ +import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { PaymentsService } from './payments.service'; +import { LedgerReason } from '../tenant-wallet/entities/tenant-ledger.entity'; + +/** + * يثبت إصلاح ثغرة حرجة: `webhook()` كان يقبل `{ payment_id }` بلا أي تحقّق، + * فأي طرف يعرف معرّف دفعة معلَّقة يستطيع تحويلها «ناجحة» ويشحن رصيداً من + * عدم. الآن كل تغيير فعلي محروس بـ`adapter.verifyWebhook`. + */ +function makeService(pendingPayment: any, adapterOverrides: any = {}) { + const saved: any[] = []; + const repo = { + create: (x: any) => ({ ...x, id: 'pay-x' }), + save: async (x: any) => { + saved.push({ ...x }); + return x; + }, + findOne: async ({ where }: any) => + pendingPayment && where.id === pendingPayment.id ? { ...pendingPayment } : null, + }; + const wallet = { credit: jest.fn().mockResolvedValue({}) }; + const tenantWallet = { + creditRevenue: jest.fn().mockResolvedValue({ duplicate: false }), + creditPending: jest.fn().mockResolvedValue({ duplicate: false }), + }; + const tenants = { + resolve: jest.fn().mockResolvedValue({ id: 'tenant-1', countryPack: 'eg', settings: {} }), + }; + const credit = { topup: jest.fn().mockResolvedValue({}) }; + + const fakeAdapter = { + name: 'paymob', + parseWebhook: jest.fn().mockReturnValue({ providerRef: pendingPayment?.id, success: true }), + verifyWebhook: jest.fn().mockReturnValue(true), + ...adapterOverrides, + }; + const gateways = { has: (p: string) => p === 'paymob', get: () => fakeAdapter }; + + const svc = new PaymentsService( + repo as any, wallet as any, tenantWallet as any, tenants as any, credit as any, gateways as any, + ); + return { svc, fakeAdapter, tenantWallet, saved }; +} + +const PENDING = { id: 'pay-1', tenant_id: 'tenant-1', status: 'pending', amount: 25, currency: 'EGP', provider: 'paymob', purpose: 'topup' }; + +describe('PaymentsService.webhook — إغلاق ثغرة القبول بلا تحقّق', () => { + it('يرفض توقيعاً غير صالح ولا يغيّر حالة الدفعة', async () => { + const { svc, tenantWallet } = makeService(PENDING, { verifyWebhook: () => false }); + await expect(svc.webhook('paymob', { obj: {} }, {})).rejects.toThrow(UnauthorizedException); + expect(tenantWallet.creditPending).not.toHaveBeenCalled(); + }); + + it('يرفض مزوّداً غير مسجَّل', async () => { + const { svc } = makeService(PENDING); + await expect(svc.webhook('unknown-provider', {}, {})).rejects.toThrow(BadRequestException); + }); + + it('يرفض حمولة لا يفهمها المحوّل (parseWebhook يعيد null) قبل أي تحقّق توقيع', async () => { + const { svc, fakeAdapter } = makeService(PENDING, { parseWebhook: () => null }); + await expect(svc.webhook('paymob', {}, {})).rejects.toThrow(BadRequestException); + expect(fakeAdapter.verifyWebhook).not.toHaveBeenCalled(); + }); + + it('دفعة غير موجودة تُرفض حتى لو زُعم أن التوقيع صالح', async () => { + const { svc } = makeService(null); + await expect(svc.webhook('paymob', {}, {})).rejects.toThrow(NotFoundException); + }); + + it('يسوّي فقط بعد توقيع صالح ودفعة موجودة', async () => { + const { svc, tenantWallet } = makeService(PENDING); + const res = await svc.webhook('paymob', { obj: {} }, { hmac: 'x' }); + expect(res.ok).toBe(true); + expect(tenantWallet.creditPending).toHaveBeenCalledTimes(1); + }); + + it('دفعة سُوّيت من قبل لا تُعاد تسويتها', async () => { + const { svc, tenantWallet } = makeService({ ...PENDING, status: 'success' }); + const res = await svc.webhook('paymob', {}, {}); + expect(res).toEqual({ ok: true, already: true }); + expect(tenantWallet.creditPending).not.toHaveBeenCalled(); + }); + + it('نجاحٌ زائف (success:false من المزوّد) يُسجَّل فاشلاً لا ناجحاً', async () => { + const { svc, tenantWallet } = makeService(PENDING, { + parseWebhook: () => ({ providerRef: PENDING.id, success: false }), + }); + const res = await svc.webhook('paymob', {}, {}); + expect(res).toEqual({ ok: true, failed: true }); + expect(tenantWallet.creditPending).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/07-integrations.md b/docs/07-integrations.md index 3839177..18d03b0 100644 --- a/docs/07-integrations.md +++ b/docs/07-integrations.md @@ -2,22 +2,21 @@ > المبدأ: كل تكامل خارجي = **محوّل (Adapter)** خلف واجهة موحّدة، يُفعَّل من [country pack](06-tenant-model.md). إضافة مزوّد جديد = ملف adapter واحد، لا مساس بالمنطق. -## 1. الدفع (Payment Adapters) +## 1. الدفع (Payment Adapters) — منجَز (docs/24 — P1) ``` interface PaymentAdapter { - charge(ctx, amount, currency): PaymentResult - refund(ctx, txId): RefundResult - status(txId): PaymentStatus + charge(ctx, tenant): Promise // instant | redirect | invoice + verifyWebhook(headers, rawBody, tenant): boolean + parseWebhook(payload): WebhookResult | null } ``` -| المزوّد | الأولوية | السوق | -|---------|---------|-------| -| Cash | اليوم الأول | الكل | -| CliQ | P2 | الأردن | -| ZainCash | P2 | الأردن | -| Syriatel Cash / MTN Cash | P2 | سوريا | -| Binance Pay | P2 (عمل جاهز في مستودعك) | عابر | +| المزوّد | النمط الفعلي | السوق | +|---------|-------------|-------| +| Cash | `instant` | الكل | +| PayMob | `redirect` — **API حقيقية** (auth→order→payment_key→iframe) + HMAC | مصر | +| CliQ · شام كاش · MTN · سيرياتيل · زين كاش | `invoice` — **بلا API فعلية**، مرجع + تسوية عبر SMS (P2) | الأردن/سوريا | - التسويات (settlements) للسائقين والمستأجر عبر BullMQ + سجل `payouts`. +- فلسفة المال (إيراد مقابل أمانة) ومحفظتا المستأجر: [24-tenant-wallet-revenue](24-tenant-wallet-revenue.md). ## 2. OTP و SMS - **ذاتي أولاً** (أصل موجود من مشروعك) + **مزوّد احتياطي لكل دولة**. @@ -42,18 +41,22 @@ interface RegulatorAdapter { - **Webhooks عامة** للمستأجرين المتقدمين (أسطول+/سيادة): نظام محاسبة، ERP أسطول... — توقيع HMAC + إعادة محاولة. ## 5. الخرائط (انطلق) — الخندق التنافسي -- البلاطات عبر **Martin** (HTTPS)، الترميز/التوجيه/snapping من واجهات انطلق. +- البلاطات من **خوادم انطلق نفسها مباشرة** — لا نستضيفها (قرار مصحَّح 2026-07-18، docs/25 §1: أُزيلت حاوية `martin` التي كانت مخطَّطة سابقاً؛ انطلق منصّة قائمة بذاتها، دورنا طلبٌ وردّ). +- الترميز/التوجيه/snapping من واجهات انطلق عبر سيرفرنا (يحمي المفتاح ويكيّش). - **صفر اعتماد على Google** في القلب. -- **طبقة تجريد** في الباك إند والموبايل تسمح بتبديل مزوّد البلاطات لأي مستأجر خارج تغطية انطلق. - سطر البيع: «خرائط غير محدودة مشمولة — بلا فاتورة للأبد». -## هيكل الكود +## هيكل الكود (الفعلي) ``` backend/src/integrations/ -├── payments/ { cash, cliq, zaincash, syriatel, mtn, binance }.adapter.ts -├── sms/ { self, twilio, local-jo, local-sy }.adapter.ts -├── regulator/ { ltrc, generic }.adapter.ts -└── registry.ts # يربط اسم المزوّد (من country pack) بالمحوّل +├── payments/ +│ ├── payment-adapter.interface.ts +│ ├── cash.adapter.ts +│ ├── paymob.adapter.ts # API حقيقية + HMAC +│ ├── invoice.adapter.ts # cliq/shamcash/mtn/syriatel/zaincash — بلا API +│ └── payment-gateway.registry.ts +├── gemini/ # رؤية: وثائق + وجه + تحليل رسائل التحويل (P2) +└── otp/ # nabeh · kazumi + التوجيه حسب country pack ``` ← السابق: [06-tenant-model](06-tenant-model.md) · التالي: [08-data-model](08-data-model.md) diff --git a/docs/24-tenant-wallet-revenue.md b/docs/24-tenant-wallet-revenue.md index 8065c52..6dea641 100644 --- a/docs/24-tenant-wallet-revenue.md +++ b/docs/24-tenant-wallet-revenue.md @@ -93,7 +93,16 @@ - `GET /payments/sms/review` + `POST /payments/sms/review/:id/match` لطابور المراجعة والربط اليدوي. - 13 اختباراً تغطّي التزوير والتكرار والتخمين والالتباس. -**الباقي**: P1 (بوابات فعلية) · P4 (تقارير أوسع) · وسحب المالك أرباحه من `tenant_wallet`. +**منجَز أيضاً (P1 — البوابات الفعلية):** +- **الثغرة الأخطر في P كلها أُغلقت**: `webhook()` كان يقبل أي جسم `{ payment_id }` بلا أي تحقّق — من يعرف معرّف دفعة معلَّقة يحوّلها «ناجحة» ويشحن رصيداً من عدم. الآن كل تغيير حالة محروس بـ`adapter.verifyWebhook(headers, payload, tenant)`، ولا قراءة قبله سوى استخراج المرجع لتحديد أي مستأجر (قراءة بحتة، لا قرار ثقة). +- `PaymentAdapter` (docs/07): `charge()` يعيد `instant` (كاش) · `redirect` (بوابة API حقيقية) · `invoice` (بلا API — مرجع + حساب استلام، تسوية عبر P2). +- **PayMob** (مصر) — البوابة الوحيدة بـAPI فعلية بين مزوّدي المنطقة: تسلسل auth→order→payment_key→iframe حقيقي، وتحقّق HMAC-SHA512 على تسلسل حقول ثابت (بروتوكول PayMob الرسمي، لا اختراعاً) بمقارنة **ثابتة الزمن**. مفاتيح كل مستأجر في `settings.payments.paymob` — حساب تاجر مستقل لكل مستأجر، فمالُه يصل لحسابه هو. +- **كليك · شام كاش · MTN · سيرياتيل · زين كاش** — محوّل واحد مشترك (`InvoiceAdapter`): بلا API فعلية اليوم (مطابق لما هو مطبَّق فعلاً في `payment_server` بسيرو)، يولّد مرجعاً ويعرض حساب الاستلام المضبوط في `settings.payments.transfer_targets`، والتسوية عبر رسالة SMS (P2) لا webhook من المزوّد. +- ⚠️ MTN وسيرياتيل يملكان API إنتاجية حقيقية (توكن/OTP/مفتاح خاص) في سيرو، لكنها تتطلّب بيانات اعتماد حيّة لا نملكها لاختبارها فعلياً — **لن نبني تكاملاً لا يمكن التحقّق منه**. بند مفتوح يُستأنف عند توفّر حساب تجريبي حقيقي. +- `PATCH /payments/settings` (أدمن المستأجر): يضبط مفاتيح PayMob · حسابات الاستلام · سرّ webhook الرسائل. الاستجابة لا تُعيد الأسرار — "مضبوط/غير مضبوط" فقط. +- 25 اختباراً جديداً (منها توقيعٌ حقيقي مُتحقَّق ومُتلاعَبٌ به عمداً بعد التوقيع، وسبع حالات تثبت إغلاق ثغرة الـwebhook تحديداً). + +**الباقي**: P4 (تقارير أوسع) · سحب المالك أرباحه من `tenant_wallet` · MTN/سيرياتيل الحقيقيَّين عند توفّر اعتماد. ## 7. أثره على ما هو مبنيّ الآن