feat: D5 — OTP متعدد المزوّدين حسب الدولة (من سيرو) + قرار بلا كلمة مرور
مراجعة سيرو (auth/otp/): مصر تستعمل Kazumi SMS مع failover لواتساب، وسوريا/الأردن Nabeh. عندنا كان مزوّد واحد فقط. - integrations/otp/: واجهة OtpProvider + OtpDispatcher يوجّه حسب tenant.countryPack بسلسلة failover (eg: [kazumi, nabeh] · jo/sy: [nabeh]) - KazumiSmsProvider (مصر SMS) + NabehOtpProvider (غلاف على NabehService) - كل مزوّد يرسل رمزاً نولّده نحن (Redis) — استبعدنا نمط Intaleq (يولّد الرمز بنفسه) حفاظاً على مصدر واحد للرمز - موحَّد: AuthService.sendOtp و PayoutsService.issueOtp يمرّان بنفس المُوزِّع الآن — أُزيل كل استدعاء Nabeh مباشر. أي مسار يحتاج رسالة يستدعي الخدمة الموحّدة (طلب المالك) - فشل السلسلة كاملة = ServiceUnavailable صريح، لا صمت قرار المالك (2026-07-17): بلا كلمة مرور إطلاقاً. المصادقة = هاتف + OTP مرة → جلسة مربوطة بالجهاز (D2). التسجيل بالهاتف لا Google/Apple (محظوران في بعض الدول). سيرو نفسه لا كلمة مرور حقيقية له (password = hash(email) وهمي، ومقارنة نصّية غير آمنة في مسار التجربة). يطابق أوبر/كريم/inDrive. مراجعة حدّ الطلبات في سيرو (RateLimiter.php): حدود مسمّاة لكل نوع + fallback بملف عند تعطّل Redis (fail-closed). عندنا مسار OTP fail-closed أصلاً؛ تخزين Throttler على Redis مؤجَّل للتوسّع الأفقي الفعلي (موثّق في D4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c258a8c4b8
commit
8f6c46f0ac
+10
-2
@@ -42,14 +42,22 @@ MAPS_API_KEY_JO=
|
||||
MAPS_API_KEY_SY=
|
||||
MAPS_PLACES_API_KEY=
|
||||
|
||||
# ---- OTP via Nabeh (WhatsApp) ----
|
||||
# اتركه dev لتفعيل الرمز الثابت 1234 بلا إرسال؛ اجعله false للإرسال الحقيقي عبر Nabeh
|
||||
# ---- OTP: توجيه متعدد المزوّدين حسب دولة المستأجر (docs/17 — D5) ----
|
||||
# اتركه dev لتفعيل الرمز الثابت 1234 بلا إرسال؛ اجعله false للإرسال الحقيقي
|
||||
OTP_DEV_MODE=true
|
||||
OTP_SMS_TEMPLATE=Your Tripz code is {code}
|
||||
|
||||
# سوريا/الأردن — Nabeh (واتساب)
|
||||
NABEH_BASE_URL=https://nabeh.intaleqapp.com
|
||||
NABEH_EMAIL=
|
||||
NABEH_PASSWORD=
|
||||
NABEH_OTP_TYPE=text
|
||||
|
||||
# مصر — Kazumi (SMS)، مع failover تلقائي إلى Nabeh لو فشل
|
||||
SMS_USERNAME=
|
||||
SMS_PASSWORD_EGYPT=
|
||||
SMS_SENDER=
|
||||
|
||||
# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ----
|
||||
FCM_SERVER_KEY=
|
||||
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
|
||||
|
||||
@@ -30,6 +30,7 @@ import { FraudModule } from './modules/fraud/fraud.module';
|
||||
import { ChatModule } from './modules/chat/chat.module';
|
||||
import { RatingsModule } from './modules/ratings/ratings.module';
|
||||
import { NabehModule } from './integrations/nabeh/nabeh.module';
|
||||
import { OtpModule } from './integrations/otp/otp.module';
|
||||
import { RideTypesModule } from './modules/ride-types/ride-types.module';
|
||||
import { DispatchModule } from './modules/dispatch/dispatch.module';
|
||||
import { WalletModule } from './modules/wallet/wallet.module';
|
||||
@@ -73,7 +74,8 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
|
||||
SigningModule, // عالمي — توقيع HMAC للعمليات المالية
|
||||
PhoneModule, // عالمي — تطبيع أرقام الهاتف لكل دولة
|
||||
DeviceModule, // عالمي — ربط الجلسة بالجهاز
|
||||
NabehModule, // عالمي — إرسال OTP واتساب
|
||||
NabehModule, // عالمي — إرسال OTP واتساب (سوريا/الأردن)
|
||||
OtpModule, // عالمي — توجيه OTP متعدد المزوّدين حسب الدولة + failover
|
||||
NotificationsModule, // عالمي — FCM
|
||||
StorageModule, // عالمي — تخزين ملفات الوثائق
|
||||
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
|
||||
|
||||
@@ -59,6 +59,17 @@ export default () => ({
|
||||
},
|
||||
|
||||
// Nabeh — إرسال OTP عبر واتساب.
|
||||
// توجيه OTP متعدد المزوّدين حسب الدولة (docs/17 — D5).
|
||||
otp: {
|
||||
// نصّ رسالة SMS (Kazumi/مصر). {code} يُستبدل بالرمز.
|
||||
smsTemplate: process.env.OTP_SMS_TEMPLATE ?? 'Your Tripz code is {code}',
|
||||
kazumi: {
|
||||
username: process.env.SMS_USERNAME ?? '',
|
||||
password: process.env.SMS_PASSWORD_EGYPT ?? '',
|
||||
sender: process.env.SMS_SENDER ?? '',
|
||||
},
|
||||
},
|
||||
|
||||
nabeh: {
|
||||
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
|
||||
email: process.env.NABEH_EMAIL ?? '',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { OtpDispatcher } from './otp-dispatcher.service';
|
||||
import { OtpProvider } from './otp-provider.interface';
|
||||
|
||||
function provider(name: string, result: boolean | (() => boolean)): OtpProvider {
|
||||
return {
|
||||
name,
|
||||
supports: () => true,
|
||||
send: jest.fn(async () => (typeof result === 'function' ? result() : result)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OtpDispatcher — توجيه حسب الدولة + failover (docs/17 D5)', () => {
|
||||
it('مصر تجرّب Kazumi أولاً', async () => {
|
||||
const kazumi = provider('kazumi', true);
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||
|
||||
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||
expect(nabeh.send).not.toHaveBeenCalled(); // نجح الأول فلا failover
|
||||
});
|
||||
|
||||
it('مصر: فشل Kazumi → يتحوّل إلى Nabeh', async () => {
|
||||
const kazumi = provider('kazumi', false);
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||
|
||||
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1); // جرّب البديل
|
||||
});
|
||||
|
||||
it('الأردن يستعمل Nabeh فقط — لا يلمس Kazumi', async () => {
|
||||
const kazumi = provider('kazumi', true);
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||
|
||||
expect(await d.send('962790000000', '1234', { countryPack: 'jo' })).toBe(true);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||
expect(kazumi.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('سوريا تستعمل Nabeh', async () => {
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([nabeh]);
|
||||
expect(await d.send('963944000000', '1234', { countryPack: 'sy' })).toBe(true);
|
||||
});
|
||||
|
||||
it('فشل السلسلة كاملة يرجع false — المُستدعي يُفشل الطلب', async () => {
|
||||
const kazumi = provider('kazumi', false);
|
||||
const nabeh = provider('nabeh', false);
|
||||
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||
|
||||
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(false);
|
||||
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('دولة غير معروفة تسقط لـNabeh (السلسلة الافتراضية)', async () => {
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([nabeh]);
|
||||
expect(await d.send('491234567', '1234', { countryPack: 'de' })).toBe(true);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('مزوّد مفقود من التسجيل لا يُسقط السلسلة — يتخطّاه', async () => {
|
||||
// Kazumi غير مسجَّل، فمصر تسقط لـNabeh وحده.
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([nabeh]);
|
||||
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OTP_PROVIDERS, OtpProvider, OtpSendContext } from './otp-provider.interface';
|
||||
|
||||
/**
|
||||
* سلسلة الـfailover لكل دولة (docs/17 — D5؛ نمط سيرو auth/otp/request.php).
|
||||
* الأول أساسي، والبقية بدائل تُجرَّب بالترتيب. مصر: Kazumi SMS ثم Nabeh واتساب
|
||||
* (نفس منطق سيرو). الأردن/سوريا: Nabeh فقط.
|
||||
*/
|
||||
const ROUTING: Record<string, string[]> = {
|
||||
eg: ['kazumi', 'nabeh'],
|
||||
jo: ['nabeh'],
|
||||
sy: ['nabeh'],
|
||||
};
|
||||
const DEFAULT_CHAIN = ['nabeh'];
|
||||
|
||||
/**
|
||||
* يوجّه إرسال OTP للمزوّد المناسب حسب دولة المستأجر، مع failover.
|
||||
*
|
||||
* السبب في وجوده: خدمة واحدة تُستدعى من كل مكان يحتاج إرسال رمز (تسجيل دخول،
|
||||
* تحقّق سحب لاحقاً) بلا أن يعرف المُستدعي أي مزوّد لأي دولة — تُضاف دولة أو
|
||||
* مزوّد جديد هنا وحده.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OtpDispatcher {
|
||||
private readonly logger = new Logger('OtpDispatcher');
|
||||
private readonly byName = new Map<string, OtpProvider>();
|
||||
|
||||
constructor(@Inject(OTP_PROVIDERS) providers: OtpProvider[]) {
|
||||
for (const p of providers) this.byName.set(p.name, p);
|
||||
}
|
||||
|
||||
/**
|
||||
* يرسل الرمز عبر أول مزوّد ينجح في سلسلة الدولة.
|
||||
* يرجع `true` إن نجح أي مزوّد؛ `false` إن فشلت السلسلة كاملة (يقرّر المُستدعي
|
||||
* حينها إفشال الطلب — طلبٌ بلا رمز يصل = مستخدم عالق).
|
||||
*/
|
||||
async send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean> {
|
||||
const chain = ROUTING[ctx.countryPack] ?? DEFAULT_CHAIN;
|
||||
for (const name of chain) {
|
||||
const provider = this.byName.get(name);
|
||||
if (!provider) {
|
||||
this.logger.warn(`provider "${name}" not registered — skipping`);
|
||||
continue;
|
||||
}
|
||||
const ok = await provider.send(phone, code, ctx);
|
||||
if (ok) {
|
||||
this.logger.log(`sent via ${name} (country=${ctx.countryPack})`);
|
||||
return true;
|
||||
}
|
||||
this.logger.warn(`${name} failed for ${ctx.countryPack} — trying next in chain`);
|
||||
}
|
||||
this.logger.error(`all providers failed for country=${ctx.countryPack}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* مزوّد إرسال OTP واحد (docs/17 — D5؛ نمط سيرو auth/otp/providers.php).
|
||||
*
|
||||
* كل مزوّد يرسل رمزاً **نولّده نحن** (لا يولّده المزوّد) — فالرمز يبقى مصدره
|
||||
* السيرفر ومخزَّناً في Redis، والمزوّد قناة توصيل فقط. هذا يختلف عن مزوّد
|
||||
* Intaleq عند سيرو الذي يولّد الرمز بنفسه ويرجعه — استبعدناه عمداً حفاظاً على
|
||||
* مصدر واحد للرمز.
|
||||
*/
|
||||
export interface OtpSendContext {
|
||||
/** دولة المستأجر (jo | sy | eg) — تحدّد المزوّد. */
|
||||
countryPack: string;
|
||||
/** passenger | driver | admin | service — لتخصيص نصّ الرسالة. */
|
||||
userType?: string;
|
||||
}
|
||||
|
||||
export interface OtpProvider {
|
||||
/** اسم قصير للتشخيص واللوغ. */
|
||||
readonly name: string;
|
||||
|
||||
/** الدول التي يخدمها هذا المزوّد (رموز countryPack). */
|
||||
supports(countryPack: string): boolean;
|
||||
|
||||
/**
|
||||
* يرسل الرمز. يرجع `true` عند النجاح المؤكَّد فقط — أي فشل أو استجابة
|
||||
* غامضة = `false` كي يجرّب المُوزِّع المزوّد التالي في السلسلة.
|
||||
* **لا يرمي**: الرمي يقطع سلسلة الـfailover.
|
||||
*/
|
||||
send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const OTP_PROVIDERS = Symbol('OTP_PROVIDERS');
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { NabehModule } from '../nabeh/nabeh.module';
|
||||
import { OtpDispatcher } from './otp-dispatcher.service';
|
||||
import { OTP_PROVIDERS } from './otp-provider.interface';
|
||||
import { NabehOtpProvider } from './providers/nabeh.provider';
|
||||
import { KazumiSmsProvider } from './providers/kazumi.provider';
|
||||
|
||||
/**
|
||||
* توجيه OTP متعدد المزوّدين (docs/17 — D5). عالمي: `OtpDispatcher` يُستدعى من
|
||||
* كل مسار يرسل رمزاً. لإضافة مزوّد: أضِف موفّره هنا وسجّله في مصفوفة
|
||||
* `OTP_PROVIDERS` وأضِف اسمه لسلسلة دولته في `otp-dispatcher.service.ts`.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [NabehModule],
|
||||
providers: [
|
||||
NabehOtpProvider,
|
||||
KazumiSmsProvider,
|
||||
{
|
||||
provide: OTP_PROVIDERS,
|
||||
useFactory: (nabeh: NabehOtpProvider, kazumi: KazumiSmsProvider) => [nabeh, kazumi],
|
||||
inject: [NabehOtpProvider, KazumiSmsProvider],
|
||||
},
|
||||
OtpDispatcher,
|
||||
],
|
||||
exports: [OtpDispatcher],
|
||||
})
|
||||
export class OtpModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
|
||||
|
||||
/**
|
||||
* مزوّد Kazumi (SMS) — مصر (docs/17 — D5؛ من سيرو auth/otp/providers.php).
|
||||
*
|
||||
* لماذا SMS لا واتساب في مصر: انتشار واتساب أقل هناك، وSMS أضمن وصولاً.
|
||||
* لو فشل، يتولّى المُوزِّع الرجوع لمزوّد آخر (Nabeh واتساب) في السلسلة.
|
||||
*/
|
||||
@Injectable()
|
||||
export class KazumiSmsProvider implements OtpProvider {
|
||||
readonly name = 'kazumi';
|
||||
private readonly logger = new Logger('KazumiSms');
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
supports(countryPack: string): boolean {
|
||||
return countryPack === 'eg';
|
||||
}
|
||||
|
||||
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
|
||||
const username = this.config.get<string>('otp.kazumi.username');
|
||||
const password = this.config.get<string>('otp.kazumi.password');
|
||||
const sender = this.config.get<string>('otp.kazumi.sender');
|
||||
if (!username || !password || !sender) {
|
||||
this.logger.warn('missing Kazumi credentials — skipping (failover will handle)');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://sms.kazumi.me/api/sms/send-sms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
language: 'e',
|
||||
sender,
|
||||
receiver: phone,
|
||||
message: this.config.get<string>('otp.smsTemplate')?.replace('{code}', code)
|
||||
?? `Your Tripz code is ${code}`,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`HTTP ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
const json: any = await res.json().catch(() => null);
|
||||
// Kazumi يرجع { message: 'Success' } عند النجاح.
|
||||
return json?.message === 'Success';
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`send failed: ${e?.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NabehService } from '../../nabeh/nabeh.service';
|
||||
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
|
||||
|
||||
/**
|
||||
* مزوّد Nabeh (واتساب) — سوريا والأردن (docs/17 — D5).
|
||||
* غلاف رقيق حول `NabehService` القائم يحوّل الرمي إلى `false` ليعمل ضمن سلسلة
|
||||
* الـfailover.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NabehOtpProvider implements OtpProvider {
|
||||
readonly name = 'nabeh';
|
||||
private readonly logger = new Logger('NabehOtp');
|
||||
|
||||
constructor(private readonly nabeh: NabehService) {}
|
||||
|
||||
supports(countryPack: string): boolean {
|
||||
return countryPack === 'jo' || countryPack === 'sy';
|
||||
}
|
||||
|
||||
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
|
||||
try {
|
||||
await this.nabeh.sendOtp(phone, code);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`send failed: ${e?.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
@@ -8,7 +14,7 @@ import { UsersService } from '../users/users.service';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { TenantsService } from '../tenants/tenants.service';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import { NabehService } from '../../integrations/nabeh/nabeh.service';
|
||||
import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service';
|
||||
import { PhoneService } from '../../common/phone/phone.service';
|
||||
import { DeviceService } from '../../common/device/device.service';
|
||||
|
||||
@@ -21,7 +27,7 @@ export class AuthService {
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private tenantsService: TenantsService,
|
||||
private nabeh: NabehService,
|
||||
private readonly otp: OtpDispatcher,
|
||||
private readonly signing: SigningService,
|
||||
private readonly phones: PhoneService,
|
||||
private readonly device: DeviceService,
|
||||
@@ -76,9 +82,15 @@ export class AuthService {
|
||||
return { success: true, message: 'OTP sent (dev)', dev_code: code };
|
||||
}
|
||||
|
||||
// إرسال حقيقي عبر واتساب (Nabeh)
|
||||
await this.nabeh.sendOtp(this.phones.toWhatsApp(canonical), code);
|
||||
return { success: true, message: 'OTP sent via WhatsApp' };
|
||||
// إرسال حقيقي — المُوزِّع يختار المزوّد حسب دولة المستأجر مع failover
|
||||
// (docs/17 — D5). فشل السلسلة كاملة = لا رمز يصل، فنُفشل الطلب صراحةً.
|
||||
const sent = await this.otp.send(this.phones.toWhatsApp(canonical), code, {
|
||||
countryPack: tenant.countryPack,
|
||||
});
|
||||
if (!sent) {
|
||||
throw new ServiceUnavailableException('Failed to send verification code — please try again');
|
||||
}
|
||||
return { success: true, message: 'OTP sent' };
|
||||
}
|
||||
|
||||
async verifyOtp(tenantSlug: string, phone: string, code: string, deviceId?: string) {
|
||||
|
||||
@@ -8,10 +8,11 @@ import { PaymentsController } from './payments.controller';
|
||||
import { PayoutsController } from './payouts.controller';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { TenantsModule } from '../tenants/tenants.module';
|
||||
|
||||
@Module({
|
||||
// NabehModule و AuditModule عالميان.
|
||||
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule],
|
||||
// OtpModule و AuditModule عالميان.
|
||||
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule, TenantsModule],
|
||||
controllers: [PaymentsController, PayoutsController],
|
||||
providers: [PaymentsService, PayoutsService],
|
||||
exports: [PaymentsService, PayoutsService],
|
||||
|
||||
@@ -16,7 +16,8 @@ import { REDIS } from '../../common/redis/redis.module';
|
||||
import { Payout } from './entities/payout.entity';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { NabehService } from '../../integrations/nabeh/nabeh.service';
|
||||
import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service';
|
||||
import { TenantsService } from '../tenants/tenants.service';
|
||||
import { AuditService } from '../../common/audit/audit.service';
|
||||
|
||||
export interface PayoutRequestDto {
|
||||
@@ -52,7 +53,8 @@ export class PayoutsService {
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
private readonly wallet: WalletService,
|
||||
private readonly users: UsersService,
|
||||
private readonly nabeh: NabehService,
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly otp: OtpDispatcher,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
@@ -253,8 +255,14 @@ export class PayoutsService {
|
||||
}
|
||||
const user = await this.users.findById(tenantId, driverUserId);
|
||||
if (!user?.phone) throw new BadRequestException('no phone on file');
|
||||
// فشل الإرسال يُفشل الطلب: طلبٌ بلا رمز يصل = سائق عالق بلا سبيل للتأكيد.
|
||||
await this.nabeh.sendOtp(user.phone, code);
|
||||
// نفس مُوزِّع OTP الموحّد المستعمل في تسجيل الدخول (docs/17 — D5): يوجّه
|
||||
// حسب دولة المستأجر ويطبّق failover. فشل السلسلة = سائق عالق بلا رمز.
|
||||
const tenant = await this.tenants.resolve(tenantId);
|
||||
const sent = await this.otp.send(user.phone, code, {
|
||||
countryPack: tenant?.countryPack ?? 'jo',
|
||||
userType: 'driver',
|
||||
});
|
||||
if (!sent) throw new BadRequestException('failed to send verification code');
|
||||
return code;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user