import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { randomInt } from 'crypto'; import Redis from 'ioredis'; 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 { AuditService } from '../../common/audit/audit.service'; export interface PayoutRequestDto { amount: number; channel: string; // bank | cliq | mtn | ecash | syriatel currency?: string; destination?: Record; } /** سياق الطلب — أثرٌ للتحقيق، وليس مصدر هوية. */ export interface RequestContext { ip?: string | null; deviceId?: string | null; biometricMethod?: string | null; } const OTP_TTL_SEC = 300; const OTP_MAX_ATTEMPTS = 5; /** * سحب أرباح السائق (docs/17 — I4/I5/I7). * * **قاعدة التصميم: لا يتحرك مال قبل إثبات الهوية.** * الطلب يرسل رمزاً عبر واتساب ولا يحجز شيئاً؛ الحجز يقع عند التأكيد فقط. * (عند سيرو: لا حجز إطلاقاً ولا تحقق — راجع تدقيق المجموعة I في docs/17.) */ @Injectable() export class PayoutsService { private readonly logger = new Logger('Payouts'); constructor( @InjectRepository(Payout) private readonly repo: Repository, @Inject(REDIS) private readonly redis: Redis, private readonly wallet: WalletService, private readonly users: UsersService, private readonly nabeh: NabehService, private readonly audit: AuditService, private readonly config: ConfigService, ) {} private otpKey(payoutId: string) { return `payout:otp:${payoutId}`; } private get devMode(): boolean { return this.config.get('auth.otpDevMode') !== false; } /** * الخطوة 1: يُنشئ الطلب ويرسل رمزاً — **بلا أي حركة مال**. * `driverUserId` من التوكن دائماً لا من الجسم (ثغرة IDOR عند سيرو). */ async request( tenantId: string, driverUserId: string, dto: PayoutRequestDto, ctx: RequestContext = {}, ) { const amount = Number(dto.amount); if (!(amount > 0)) throw new BadRequestException('amount must be > 0'); if (!dto.channel) throw new BadRequestException('channel is required'); // فحص مبكّر — لا نرسل رمزاً لطلب مستحيل. **ليس حجزاً**: الحجز الحقيقي // ذرّي عند التأكيد، فلا فجوة بين الفحص والخصم. const w = await this.wallet.getOrCreate(tenantId, driverUserId); if (Number(w.balance) < amount) throw new BadRequestException('Insufficient balance'); const payout = await this.repo.save( this.repo.create({ tenant_id: tenantId, driver_user_id: driverUserId, amount, currency: dto.currency ?? w.currency ?? 'JOD', channel: dto.channel, destination: dto.destination ?? {}, status: 'pending_otp', device_id: ctx.deviceId ?? null, request_ip: ctx.ip ?? null, }), ); const code = await this.issueOtp(tenantId, driverUserId, payout.id); await this.audit.record({ tenantId, actorUserId: driverUserId, actorRole: 'driver', action: 'payout.request', subjectType: 'payout', subjectId: payout.id, amount, currency: payout.currency, ip: ctx.ip, meta: { channel: payout.channel, deviceId: ctx.deviceId ?? null }, }); return { payout, otp_sent: true, // وضع التطوير فقط — لا يُسرَّب الرمز في الإنتاج. ...(this.devMode ? { dev_code: code } : {}), }; } /** * الخطوة 2: تأكيد الرمز → **هنا فقط** يُحجز المال (الخصم الذرّي من I1). * التأكيد الحيوي القادم من فلاتر يُسجَّل معه (docs/17 — I5). */ async confirm( tenantId: string, driverUserId: string, payoutId: string, code: string, ctx: RequestContext = {}, ) { const p = await this.getOr404(tenantId, payoutId); // السائق يؤكّد طلبه هو فقط — التوكن هو المرجع. if (p.driver_user_id !== driverUserId) throw new ForbiddenException('not your payout'); if (p.status !== 'pending_otp') throw new BadRequestException('payout is not awaiting a code'); await this.verifyOtp(payoutId, code, tenantId, driverUserId, ctx); // الحجز الذرّي: يرمي «رصيد غير كافٍ» لو تغيّر الرصيد منذ الطلب. await this.wallet.debit(tenantId, driverUserId, Number(p.amount), 'payout_hold', p.id); p.status = 'requested'; p.otp_verified_at = new Date(); if (ctx.biometricMethod) { p.biometric_method = ctx.biometricMethod; p.biometric_at = new Date(); } const saved = await this.repo.save(p); await this.audit.record({ tenantId, actorUserId: driverUserId, actorRole: 'driver', action: 'payout.confirm', subjectType: 'payout', subjectId: p.id, amount: Number(p.amount), currency: p.currency, ip: ctx.ip, meta: { biometric: ctx.biometricMethod ?? 'none', deviceId: ctx.deviceId ?? null }, }); return saved; } listMine(tenantId: string, driverUserId: string) { return this.repo.find({ where: { tenant_id: tenantId, driver_user_id: driverUserId }, order: { created_at: 'DESC' }, }); } /** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */ async complete( tenantId: string, id: string, ref?: string, actor?: { userId: string; ip?: string | null }, ) { const p = await this.getOr404(tenantId, id); if (p.status === 'paid') return p; // لا يُدفع طلبٌ لم تُثبَت هويته — حارس ضد تخطّي خطوة التأكيد. if (!p.otp_verified_at) throw new BadRequestException('payout was never verified'); p.status = 'paid'; p.ref = ref ?? p.ref; const saved = await this.repo.save(p); await this.audit.record({ tenantId, actorUserId: actor?.userId ?? null, actorRole: 'admin', action: 'payout.complete', subjectType: 'payout', subjectId: p.id, amount: Number(p.amount), currency: p.currency, ip: actor?.ip, meta: { ref: ref ?? null }, }); return saved; } /** فشل التحويل — يُعاد المبلغ للمحفظة (فقط إن كان قد حُجز أصلاً). */ async fail(tenantId: string, id: string, actor?: { userId: string; ip?: string | null }) { const p = await this.getOr404(tenantId, id); if (p.status === 'paid') throw new BadRequestException('already paid'); // `pending_otp` لم يُخصم منه شيء — ردّه يخلق مالاً من العدم. const wasHeld = ['requested', 'processing'].includes(p.status); if (wasHeld) { await this.wallet.credit(tenantId, p.driver_user_id, Number(p.amount), 'payout_refund', p.id); } p.status = 'failed'; const saved = await this.repo.save(p); await this.audit.record({ tenantId, actorUserId: actor?.userId ?? null, actorRole: 'admin', action: 'payout.fail', subjectType: 'payout', subjectId: p.id, amount: Number(p.amount), currency: p.currency, ip: actor?.ip, meta: { refunded: wasHeld }, }); return saved; } // ---- داخلي ---- private async getOr404(tenantId: string, id: string): Promise { const p = await this.repo.findOne({ where: { tenant_id: tenantId, id } }); if (!p) throw new NotFoundException('payout not found'); return p; } private async issueOtp( tenantId: string, driverUserId: string, payoutId: string, ): Promise { const code = String(randomInt(1000, 10000)); await this.redis.set(this.otpKey(payoutId), code, 'EX', OTP_TTL_SEC); if (this.devMode) { this.logger.log(`payout OTP (dev) payout=${payoutId} => ${code}`); return code; } 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); return code; } private async verifyOtp( payoutId: string, code: string, tenantId: string, driverUserId: string, ctx: RequestContext, ): Promise { // حدّ المحاولات: بلا حدّ يُخمَّن رمزٌ من 4 خانات في دقائق. const attemptsKey = `${this.otpKey(payoutId)}:attempts`; const attempts = await this.redis.incr(attemptsKey); if (attempts === 1) await this.redis.expire(attemptsKey, OTP_TTL_SEC); if (attempts > OTP_MAX_ATTEMPTS) { await this.redis.del(this.otpKey(payoutId)); await this.audit.record({ tenantId, actorUserId: driverUserId, action: 'payout.otp_blocked', subjectType: 'payout', subjectId: payoutId, ip: ctx.ip, meta: { attempts }, }); throw new UnauthorizedException('too many attempts — request a new payout'); } const stored = await this.redis.get(this.otpKey(payoutId)); if (!stored || stored !== String(code ?? '')) { await this.audit.record({ tenantId, actorUserId: driverUserId, action: 'payout.otp_failed', subjectType: 'payout', subjectId: payoutId, ip: ctx.ip, meta: { attempts }, }); throw new UnauthorizedException('Invalid or expired code'); } // يُستهلك مرة واحدة — لا إعادة استعمال. await this.redis.del(this.otpKey(payoutId), attemptsKey); } }