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
@@ -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<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')
|
||||
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<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 { 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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<Payment>,
|
||||
@@ -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<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');
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user