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
+11
-1
@@ -62,9 +62,19 @@ SMS_SENDER=
|
|||||||
FCM_SERVER_KEY=
|
FCM_SERVER_KEY=
|
||||||
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
|
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
|
||||||
|
|
||||||
# ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه) ----
|
# ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه + تحليل رسائل التحويل P2) ----
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
GEMINI_MODEL=gemini-2.0-flash
|
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 ----
|
# ---- سرّ المنصة (لتعيين أول أدمن) — ولّده: openssl rand -hex 24 ----
|
||||||
PLATFORM_SECRET=
|
PLATFORM_SECRET=
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
|
import { TenantsService } from '../tenants/tenants.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
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 { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||||
|
|
||||||
@@ -12,7 +15,10 @@ import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature
|
|||||||
@ApiTags('payments')
|
@ApiTags('payments')
|
||||||
@Controller('payments')
|
@Controller('payments')
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private readonly payments: PaymentsService) {}
|
constructor(
|
||||||
|
private readonly payments: PaymentsService,
|
||||||
|
private readonly tenants: TenantsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, FeatureGuard)
|
@UseGuards(JwtAuthGuard, FeatureGuard)
|
||||||
@@ -29,6 +35,7 @@ export class PaymentsController {
|
|||||||
method: body.method,
|
method: body.method,
|
||||||
// الدور من التوكن لا من الجسم — عليه يتوقّف السماح بـcredit_topup.
|
// الدور من التوكن لا من الجسم — عليه يتوقّف السماح بـcredit_topup.
|
||||||
userRole: user.role,
|
userRole: user.role,
|
||||||
|
userPhone: user.phone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +46,62 @@ export class PaymentsController {
|
|||||||
return this.payments.findMine(user.tenantId, user.userId);
|
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<string, string>;
|
||||||
|
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')
|
@Post('webhook/:provider')
|
||||||
webhook(@Param('provider') provider: string, @Body() payload: any) {
|
webhook(
|
||||||
return this.payments.webhook(provider, payload);
|
@Param('provider') provider: string,
|
||||||
|
@Body() payload: any,
|
||||||
|
@Headers() headers: Record<string, any>,
|
||||||
|
@Query('hmac') hmacQuery?: string,
|
||||||
|
) {
|
||||||
|
return this.payments.webhook(provider, payload, { ...headers, hmac: headers?.hmac ?? hmacQuery });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { UsersModule } from '../users/users.module';
|
|||||||
import { TenantsModule } from '../tenants/tenants.module';
|
import { TenantsModule } from '../tenants/tenants.module';
|
||||||
import { TenantWalletModule } from '../tenant-wallet/tenant-wallet.module';
|
import { TenantWalletModule } from '../tenant-wallet/tenant-wallet.module';
|
||||||
import { CreditModule } from '../credit/credit.module';
|
import { CreditModule } from '../credit/credit.module';
|
||||||
|
import { PaymentGatewaysModule } from '../../integrations/payments/payment-gateways.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
// OtpModule و AuditModule عالميان.
|
// OtpModule و AuditModule عالميان.
|
||||||
@@ -24,6 +25,7 @@ import { CreditModule } from '../credit/credit.module';
|
|||||||
TenantsModule,
|
TenantsModule,
|
||||||
TenantWalletModule, // دفترا المستأجر (docs/24)
|
TenantWalletModule, // دفترا المستأجر (docs/24)
|
||||||
CreditModule, // شحن الرصيد التشغيلي للسائق
|
CreditModule, // شحن الرصيد التشغيلي للسائق
|
||||||
|
PaymentGatewaysModule, // محوّلات البوابات (docs/24 — P1)
|
||||||
],
|
],
|
||||||
controllers: [PaymentsController, PayoutsController, SmsSettlementController],
|
controllers: [PaymentsController, PayoutsController, SmsSettlementController],
|
||||||
providers: [PaymentsService, PayoutsService, SmsSettlementService],
|
providers: [PaymentsService, PayoutsService, SmsSettlementService],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
import { LedgerReason } from '../tenant-wallet/entities/tenant-ledger.entity';
|
import { LedgerReason } from '../tenant-wallet/entities/tenant-ledger.entity';
|
||||||
|
import { CashAdapter } from '../../integrations/payments/cash.adapter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* يثبت **وجهة المال** لا آلية التخزين (تلك مغطّاة في tenant-wallet.service.spec):
|
* يثبت **وجهة المال** لا آلية التخزين (تلك مغطّاة في tenant-wallet.service.spec):
|
||||||
@@ -27,6 +28,10 @@ function makeService(countryPack = 'jo', settings: any = {}) {
|
|||||||
};
|
};
|
||||||
const tenants = { resolve: jest.fn().mockResolvedValue({ countryPack, settings }) };
|
const tenants = { resolve: jest.fn().mockResolvedValue({ countryPack, settings }) };
|
||||||
const credit = { topup: jest.fn().mockResolvedValue({}) };
|
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(
|
const svc = new PaymentsService(
|
||||||
repo as any,
|
repo as any,
|
||||||
@@ -34,6 +39,7 @@ function makeService(countryPack = 'jo', settings: any = {}) {
|
|||||||
tenantWallet as any,
|
tenantWallet as any,
|
||||||
tenants as any,
|
tenants as any,
|
||||||
credit as any,
|
credit as any,
|
||||||
|
gateways as any,
|
||||||
);
|
);
|
||||||
return { svc, wallet, tenantWallet, credit };
|
return { svc, wallet, tenantWallet, credit };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Payment, PaymentPurpose } from './entities/payment.entity';
|
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 { TenantsService } from '../tenants/tenants.service';
|
||||||
import { DriverCreditService } from '../credit/driver-credit.service';
|
import { DriverCreditService } from '../credit/driver-credit.service';
|
||||||
import { transactionFeeFor, cappedFee } from '../tenant-wallet/transaction-fee';
|
import { transactionFeeFor, cappedFee } from '../tenant-wallet/transaction-fee';
|
||||||
|
import { PaymentGatewayRegistry } from '../../integrations/payments/payment-gateway.registry';
|
||||||
|
|
||||||
export interface ChargeDto {
|
export interface ChargeDto {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -19,19 +26,19 @@ export interface ChargeDto {
|
|||||||
method?: string;
|
method?: string;
|
||||||
/** دور صاحب التوكن — يُمرَّر من الكنترولر دائماً، لا من جسم الطلب. */
|
/** دور صاحب التوكن — يُمرَّر من الكنترولر دائماً، لا من جسم الطلب. */
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
|
userPhone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PURPOSES: PaymentPurpose[] = ['topup', 'credit_topup', 'trip'];
|
const PURPOSES: PaymentPurpose[] = ['topup', 'credit_topup', 'trip'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* الدفع بنمط المحوّلات (docs/07): cash فوري، والبوابات تُنشئ نية دفع + رابط تحويل،
|
* الدفع بنمط المحوّلات (docs/07 · docs/24 — P1): كل مزوّد خلف `PaymentAdapter`
|
||||||
* ثم يؤكّدها webhook. عند النجاح: شحن المحفظة (topup) أو تسجيل دفع الرحلة (trip).
|
* واحد يقرّر شكل الاستجابة (فوري · redirect · invoice)، والوحدة هنا لا تعرف
|
||||||
* التكامل الحقيقي مع كل بوابة يُضاف لاحقاً بمفاتيح الدولة — البنية جاهزة.
|
* تفاصيل أي بوابة — فقط توجّه المال بعد التأكيد (docs/24).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
private readonly logger = new Logger('Payments');
|
private readonly logger = new Logger('Payments');
|
||||||
private readonly instantProviders = ['cash'];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Payment) private readonly repo: Repository<Payment>,
|
@InjectRepository(Payment) private readonly repo: Repository<Payment>,
|
||||||
@@ -39,12 +46,16 @@ export class PaymentsService {
|
|||||||
private readonly tenantWallet: TenantWalletService,
|
private readonly tenantWallet: TenantWalletService,
|
||||||
private readonly tenants: TenantsService,
|
private readonly tenants: TenantsService,
|
||||||
private readonly credit: DriverCreditService,
|
private readonly credit: DriverCreditService,
|
||||||
|
private readonly gateways: PaymentGatewayRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async charge(tenantId: string, dto: ChargeDto) {
|
async charge(tenantId: string, dto: ChargeDto) {
|
||||||
const amount = Number(dto.amount);
|
const amount = Number(dto.amount);
|
||||||
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
|
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
|
||||||
if (!dto.provider) throw new BadRequestException('provider is required');
|
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';
|
const purpose: PaymentPurpose = dto.purpose ?? 'topup';
|
||||||
if (!PURPOSES.includes(purpose)) throw new BadRequestException('invalid purpose');
|
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');
|
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(
|
let payment = await this.repo.save(
|
||||||
this.repo.create({
|
this.repo.create({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
@@ -71,26 +85,76 @@ export class PaymentsService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// مزوّد فوري (كاش) — ينجح مباشرة
|
const adapter = this.gateways.get(dto.provider);
|
||||||
if (this.instantProviders.includes(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) };
|
return { payment: await this.markSuccess(payment) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// بوابة خارجية — نية دفع + رابط تحويل (يؤكّده webhook لاحقاً)
|
if (result.mode === 'redirect') {
|
||||||
payment.redirect_url = `https://pay.${dto.provider}.gateway/checkout/${payment.id}`;
|
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);
|
payment = await this.repo.save(payment);
|
||||||
this.logger.log(`intent ${dto.provider} payment=${payment.id} amount=${amount}`);
|
this.logger.log(`invoice ${dto.provider} payment=${payment.id} ref=${result.reference}`);
|
||||||
return { payment, redirectUrl: payment.redirect_url };
|
return {
|
||||||
|
payment,
|
||||||
|
reference: result.reference,
|
||||||
|
transferTarget: result.transferTarget,
|
||||||
|
instructions: result.instructions,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تأكيد من بوابة الدفع (async). التحقق من التوقيع يُضاف مع كل مزوّد. */
|
/**
|
||||||
async webhook(provider: string, payload: any) {
|
* تأكيد من بوابة دفع حقيقية (PayMob). **الحارس الأمني الفعلي هنا**: كان
|
||||||
const id = payload?.payment_id ?? payload?.ref ?? payload?.id;
|
* هذا المسار يقبل أي جسم `{ payment_id }` بلا أي تحقّق — أي أن أي شخص
|
||||||
if (!id) throw new BadRequestException('missing payment reference');
|
* يعرف معرّف دفعة معلَّقة يستطيع تحويلها «ناجحة» ويشحن رصيداً من عدم.
|
||||||
const payment = await this.repo.findOne({ where: { id } });
|
*
|
||||||
|
* الترتيب حرِج: نقرأ `providerRef` من الحمولة لنعرف **أي مستأجر** نتحقّق
|
||||||
|
* بسرّه (كل مستأجر حساب PayMob مستقل) — هذه قراءة بحتة لا قرار ثقة. القرار
|
||||||
|
* الوحيد الذي يُبنى عليه تغييرٌ فعلي هو `verifyWebhook`، ولا شيء قبله.
|
||||||
|
*/
|
||||||
|
async webhook(provider: string, payload: any, headers: Record<string, any> = {}) {
|
||||||
|
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');
|
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 };
|
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);
|
await this.markSuccess(payment);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+21
-18
@@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
> المبدأ: كل تكامل خارجي = **محوّل (Adapter)** خلف واجهة موحّدة، يُفعَّل من [country pack](06-tenant-model.md). إضافة مزوّد جديد = ملف adapter واحد، لا مساس بالمنطق.
|
> المبدأ: كل تكامل خارجي = **محوّل (Adapter)** خلف واجهة موحّدة، يُفعَّل من [country pack](06-tenant-model.md). إضافة مزوّد جديد = ملف adapter واحد، لا مساس بالمنطق.
|
||||||
|
|
||||||
## 1. الدفع (Payment Adapters)
|
## 1. الدفع (Payment Adapters) — منجَز (docs/24 — P1)
|
||||||
```
|
```
|
||||||
interface PaymentAdapter {
|
interface PaymentAdapter {
|
||||||
charge(ctx, amount, currency): PaymentResult
|
charge(ctx, tenant): Promise<ChargeResult> // instant | redirect | invoice
|
||||||
refund(ctx, txId): RefundResult
|
verifyWebhook(headers, rawBody, tenant): boolean
|
||||||
status(txId): PaymentStatus
|
parseWebhook(payload): WebhookResult | null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
| المزوّد | الأولوية | السوق |
|
| المزوّد | النمط الفعلي | السوق |
|
||||||
|---------|---------|-------|
|
|---------|-------------|-------|
|
||||||
| Cash | اليوم الأول | الكل |
|
| Cash | `instant` | الكل |
|
||||||
| CliQ | P2 | الأردن |
|
| PayMob | `redirect` — **API حقيقية** (auth→order→payment_key→iframe) + HMAC | مصر |
|
||||||
| ZainCash | P2 | الأردن |
|
| CliQ · شام كاش · MTN · سيرياتيل · زين كاش | `invoice` — **بلا API فعلية**، مرجع + تسوية عبر SMS (P2) | الأردن/سوريا |
|
||||||
| Syriatel Cash / MTN Cash | P2 | سوريا |
|
|
||||||
| Binance Pay | P2 (عمل جاهز في مستودعك) | عابر |
|
|
||||||
- التسويات (settlements) للسائقين والمستأجر عبر BullMQ + سجل `payouts`.
|
- التسويات (settlements) للسائقين والمستأجر عبر BullMQ + سجل `payouts`.
|
||||||
|
- فلسفة المال (إيراد مقابل أمانة) ومحفظتا المستأجر: [24-tenant-wallet-revenue](24-tenant-wallet-revenue.md).
|
||||||
|
|
||||||
## 2. OTP و SMS
|
## 2. OTP و SMS
|
||||||
- **ذاتي أولاً** (أصل موجود من مشروعك) + **مزوّد احتياطي لكل دولة**.
|
- **ذاتي أولاً** (أصل موجود من مشروعك) + **مزوّد احتياطي لكل دولة**.
|
||||||
@@ -42,18 +41,22 @@ interface RegulatorAdapter {
|
|||||||
- **Webhooks عامة** للمستأجرين المتقدمين (أسطول+/سيادة): نظام محاسبة، ERP أسطول... — توقيع HMAC + إعادة محاولة.
|
- **Webhooks عامة** للمستأجرين المتقدمين (أسطول+/سيادة): نظام محاسبة، ERP أسطول... — توقيع HMAC + إعادة محاولة.
|
||||||
|
|
||||||
## 5. الخرائط (انطلق) — الخندق التنافسي
|
## 5. الخرائط (انطلق) — الخندق التنافسي
|
||||||
- البلاطات عبر **Martin** (HTTPS)، الترميز/التوجيه/snapping من واجهات انطلق.
|
- البلاطات من **خوادم انطلق نفسها مباشرة** — لا نستضيفها (قرار مصحَّح 2026-07-18، docs/25 §1: أُزيلت حاوية `martin` التي كانت مخطَّطة سابقاً؛ انطلق منصّة قائمة بذاتها، دورنا طلبٌ وردّ).
|
||||||
|
- الترميز/التوجيه/snapping من واجهات انطلق عبر سيرفرنا (يحمي المفتاح ويكيّش).
|
||||||
- **صفر اعتماد على Google** في القلب.
|
- **صفر اعتماد على Google** في القلب.
|
||||||
- **طبقة تجريد** في الباك إند والموبايل تسمح بتبديل مزوّد البلاطات لأي مستأجر خارج تغطية انطلق.
|
|
||||||
- سطر البيع: «خرائط غير محدودة مشمولة — بلا فاتورة للأبد».
|
- سطر البيع: «خرائط غير محدودة مشمولة — بلا فاتورة للأبد».
|
||||||
|
|
||||||
## هيكل الكود
|
## هيكل الكود (الفعلي)
|
||||||
```
|
```
|
||||||
backend/src/integrations/
|
backend/src/integrations/
|
||||||
├── payments/ { cash, cliq, zaincash, syriatel, mtn, binance }.adapter.ts
|
├── payments/
|
||||||
├── sms/ { self, twilio, local-jo, local-sy }.adapter.ts
|
│ ├── payment-adapter.interface.ts
|
||||||
├── regulator/ { ltrc, generic }.adapter.ts
|
│ ├── cash.adapter.ts
|
||||||
└── registry.ts # يربط اسم المزوّد (من country pack) بالمحوّل
|
│ ├── 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)
|
← السابق: [06-tenant-model](06-tenant-model.md) · التالي: [08-data-model](08-data-model.md)
|
||||||
|
|||||||
@@ -93,7 +93,16 @@
|
|||||||
- `GET /payments/sms/review` + `POST /payments/sms/review/:id/match` لطابور المراجعة والربط اليدوي.
|
- `GET /payments/sms/review` + `POST /payments/sms/review/:id/match` لطابور المراجعة والربط اليدوي.
|
||||||
- 13 اختباراً تغطّي التزوير والتكرار والتخمين والالتباس.
|
- 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. أثره على ما هو مبنيّ الآن
|
## 7. أثره على ما هو مبنيّ الآن
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user