feat: باقي المجموعة I — أمان السحب (OTP + بصمة + HMAC + تدقيق)
I4 — OTP على السحب عبر نبيه. تدفّق خطوتين، وقاعدته: **لا يتحرك مال قبل إثبات الهوية**: - POST /payouts/request → يرسل رمزاً، حالة pending_otp، **بلا خصم** - POST /payouts/:id/confirm → يتحقق ثم يحجز ذرّياً (خصم I1) - حدّ 5 محاولات (رمز 4 خانات يُخمَّن في دقائق بلا حدّ)، الرمز يُستهلك مرة - complete يرفض طلباً بلا otp_verified_at (حارس ضد تخطّي التأكيد) - fail لا يردّ مالاً لطلب pending_otp — لم يُخصم منه شيء، وردّه يخلق مالاً I5 — biometric_method/at + device_id + request_ip على السحب. أثرٌ للتحقيق لا مصادقة: العميل يستطيع ادّعاءها، والمصادقة الحقيقية JWT + رمز واتساب. I6 — HMAC **مبنيّ ومطفأ** (PAYMENTS_REQUIRE_SIGNATURE=false) حتى يوقّع فلاتر؛ تفعيله الآن يقطع كل سحب. مفتاح **لكل جلسة** يُصدره الدخول لا سرّ ثابت في التطبيق (الثابت يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية). يوقّع timestamp.METHOD.path.body بنافذة 5 دقائق؛ rawBody مفعّل في main. I7 — tripz_audit_log append-only: من·ماذا·متى·أي IP وجهاز. لا يرمي أبداً — فشل التدقيق يجب ألّا يُسقط عمليةً مالية نجحت. I2 أُلغيت الحاجة إليها: إيراد المنصة = الشحن، و credit_txns دفتره فعلاً؛ محفظة ثانية = دفتر مزدوج يحتاج مطابقة. الباقي تقرير لا محفظة. I3/I8 مؤجَّلتان بوعي (توثيق في docs/17). wallet-race-test.mjs حُدِّث: السباق انتقل من request إلى confirm، وأُضيف محكّ أن الطلب وحده لا يمسّ الرصيد. هجرة: PayoutSecurityAndAudit (تعتبر السحوبات القائمة مُتحقَّقة وإلا رفض complete صرفها للأبد). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91e1692457
commit
1605754722
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS } from '../../common/redis/redis.module';
|
||||
import { SigningService } from '../../common/signing/signing.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { TenantsService } from '../tenants/tenants.service';
|
||||
@@ -19,6 +20,7 @@ export class AuthService {
|
||||
private config: ConfigService,
|
||||
private tenantsService: TenantsService,
|
||||
private nabeh: NabehService,
|
||||
private readonly signing: SigningService,
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
@@ -107,7 +109,7 @@ export class AuthService {
|
||||
return this.issueTokens(user, user.tenant_id);
|
||||
}
|
||||
|
||||
private issueTokens(user: User, tenantId: string) {
|
||||
private async issueTokens(user: User, tenantId: string) {
|
||||
const base = {
|
||||
sub: user.id,
|
||||
phone: user.phone,
|
||||
@@ -120,6 +122,14 @@ export class AuthService {
|
||||
{ ...base, type: 'refresh' },
|
||||
{ expiresIn: (this.config.get<string>('jwt.refreshExpires') ?? '30d') as any },
|
||||
),
|
||||
// مفتاح توقيع العمليات المالية (docs/17 — I6). يُسلَّم مرة واحدة عند
|
||||
// الدخول ويُخزَّن في flutter_secure_storage.
|
||||
//
|
||||
// **لماذا مفتاح لكل جلسة لا سرّ ثابت في التطبيق؟** أي سرّ داخل التطبيق
|
||||
// يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية. المفتاح هنا يُولَّد على
|
||||
// السيرفر لكل دخول، فمن يفكّك الـAPK لا يجد شيئاً، ومن يسرق توكناً
|
||||
// (الـAPI على http حالياً) لا يملك المفتاح فلا يستطيع توقيع سحب.
|
||||
signing_key: await this.signing.issue(tenantId, user.id),
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type PayoutStatus = 'requested' | 'processing' | 'paid' | 'failed';
|
||||
export type PayoutStatus =
|
||||
| 'pending_otp' // أُنشئ وأُرسل الرمز — **لا مال محجوز بعد** (docs/17 — I4)
|
||||
| 'requested' // تحقّقت الهوية وحُجز المبلغ
|
||||
| 'processing'
|
||||
| 'paid'
|
||||
| 'failed'
|
||||
| 'expired'; // لم يُؤكَّد الرمز
|
||||
|
||||
/**
|
||||
* طلب سحب أرباح سائق. الجدول: tripz_pay_payouts.
|
||||
@@ -37,12 +43,31 @@ export class Payout {
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
destination: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', default: 'requested' })
|
||||
@Column({ type: 'varchar', default: 'pending_otp' })
|
||||
status: PayoutStatus;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
ref: string | null;
|
||||
|
||||
// ---- إثبات هوية السحب (docs/17 — I4/I5) ----
|
||||
/** لحظة تأكيد رمز واتساب — بدونها لا يُحجز مال ولا يُنفَّذ تحويل. */
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
otp_verified_at: Date | null;
|
||||
|
||||
/** التأكيد الحيوي من فلاتر: face | fingerprint | device_credential | none. */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
biometric_method: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
biometric_at: Date | null;
|
||||
|
||||
/** بصمة الجهاز ومصدر الطلب — أثرٌ للتحقيق عند النزاع (docs/17 — D2). */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
device_id: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
request_ip: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import { PayoutsService } from './payouts.service';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PayoutsController } from './payouts.controller';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule],
|
||||
// NabehModule و AuditModule عالميان.
|
||||
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule],
|
||||
controllers: [PaymentsController, PayoutsController],
|
||||
providers: [PaymentsService, PayoutsService],
|
||||
exports: [PaymentsService, PayoutsService],
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { PayoutsService } from './payouts.service';
|
||||
import { PayoutsService, RequestContext } from './payouts.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 { SignatureGuard } from '../../common/signing/signature.guard';
|
||||
|
||||
/** IP يُؤخذ من الطلب لا من الجسم — العميل لا يُملي عنوانه. */
|
||||
function ctxOf(req: any, body?: any): RequestContext {
|
||||
return {
|
||||
ip: req?.ip ?? req?.socket?.remoteAddress ?? null,
|
||||
deviceId: (req?.headers?.['x-device-id'] as string) ?? null,
|
||||
biometricMethod: body?.biometric_method ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags('payouts')
|
||||
@ApiBearerAuth()
|
||||
@@ -12,16 +22,42 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
|
||||
export class PayoutsController {
|
||||
constructor(private readonly payouts: PayoutsService) {}
|
||||
|
||||
// السائق يطلب سحب أرباحه
|
||||
@UseGuards(JwtAuthGuard)
|
||||
/**
|
||||
* الخطوة 1: السائق يطلب السحب → يصله رمز على واتساب.
|
||||
* **لا يُخصم شيء هنا** (docs/17 — I4).
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, SignatureGuard)
|
||||
@Post('request')
|
||||
request(@CurrentUser() user: AuthUser, @Body() body: any) {
|
||||
return this.payouts.request(user.tenantId, user.userId, {
|
||||
amount: Number(body.amount),
|
||||
channel: body.channel,
|
||||
currency: body.currency,
|
||||
destination: body.destination,
|
||||
});
|
||||
request(@CurrentUser() user: AuthUser, @Body() body: any, @Req() req: any) {
|
||||
return this.payouts.request(
|
||||
user.tenantId,
|
||||
user.userId,
|
||||
{
|
||||
amount: Number(body.amount),
|
||||
channel: body.channel,
|
||||
currency: body.currency,
|
||||
destination: body.destination,
|
||||
},
|
||||
ctxOf(req, body),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* الخطوة 2: تأكيد الرمز → يُحجز المبلغ.
|
||||
*
|
||||
* `biometric_method` هو ما أثبته فلاتر محلياً (وجه/إصبع). يُسجَّل **كأثر
|
||||
* فقط ولا يُعتمد كمصادقة** — العميل يستطيع ادّعاءه. المصادقة الحقيقية هي
|
||||
* JWT + رمز واتساب (docs/17 — I5).
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, SignatureGuard)
|
||||
@Post(':id/confirm')
|
||||
confirm(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { code: string; biometric_method?: string },
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.payouts.confirm(user.tenantId, user.userId, id, body.code, ctxOf(req, body));
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -34,14 +70,22 @@ export class PayoutsController {
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body('ref') ref: string) {
|
||||
return this.payouts.complete(user.tenantId, id, ref);
|
||||
complete(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body('ref') ref: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.payouts.complete(user.tenantId, id, ref, {
|
||||
userId: user.userId,
|
||||
ip: ctxOf(req).ip,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Patch(':id/fail')
|
||||
fail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.payouts.fail(user.tenantId, id);
|
||||
fail(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() req: any) {
|
||||
return this.payouts.fail(user.tenantId, id, { userId: user.userId, ip: ctxOf(req).ip });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
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;
|
||||
@@ -11,36 +26,143 @@ export interface PayoutRequestDto {
|
||||
destination?: Record<string, any>;
|
||||
}
|
||||
|
||||
/** سياق الطلب — أثرٌ للتحقيق، وليس مصدر هوية. */
|
||||
export interface RequestContext {
|
||||
ip?: string | null;
|
||||
deviceId?: string | null;
|
||||
biometricMethod?: string | null;
|
||||
}
|
||||
|
||||
const OTP_TTL_SEC = 300;
|
||||
const OTP_MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* سحب أرباح السائق: يحجز المبلغ من المحفظة فوراً (debit)، ثم يُحوَّل خارجياً.
|
||||
* الفشل يُعيد المبلغ للمحفظة. التحويل الخارجي الفعلي يُنفَّذ يدوياً/بمزوّد لاحقاً.
|
||||
* سحب أرباح السائق (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<Payout>,
|
||||
@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,
|
||||
) {}
|
||||
|
||||
async request(tenantId: string, driverUserId: string, dto: PayoutRequestDto) {
|
||||
private otpKey(payoutId: string) {
|
||||
return `payout:otp:${payoutId}`;
|
||||
}
|
||||
|
||||
private get devMode(): boolean {
|
||||
return this.config.get<boolean>('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');
|
||||
|
||||
// يحجز المبلغ (يرمي لو الرصيد غير كافٍ)
|
||||
await this.wallet.debit(tenantId, driverUserId, amount, 'payout_hold');
|
||||
// فحص مبكّر — لا نرسل رمزاً لطلب مستحيل. **ليس حجزاً**: الحجز الحقيقي
|
||||
// ذرّي عند التأكيد، فلا فجوة بين الفحص والخصم.
|
||||
const w = await this.wallet.getOrCreate(tenantId, driverUserId);
|
||||
if (Number(w.balance) < amount) throw new BadRequestException('Insufficient balance');
|
||||
|
||||
return this.repo.save(
|
||||
const payout = await this.repo.save(
|
||||
this.repo.create({
|
||||
tenant_id: tenantId,
|
||||
driver_user_id: driverUserId,
|
||||
amount,
|
||||
currency: dto.currency ?? 'JOD',
|
||||
currency: dto.currency ?? w.currency ?? 'JOD',
|
||||
channel: dto.channel,
|
||||
destination: dto.destination ?? {},
|
||||
status: 'requested',
|
||||
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) {
|
||||
@@ -50,29 +172,131 @@ export class PayoutsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */
|
||||
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<Payout> {
|
||||
const p = await this.repo.findOne({ where: { tenant_id: tenantId, id } });
|
||||
if (!p) throw new NotFoundException('payout not found');
|
||||
return p;
|
||||
}
|
||||
|
||||
/** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */
|
||||
async complete(tenantId: string, id: string, ref?: string) {
|
||||
const p = await this.getOr404(tenantId, id);
|
||||
if (p.status === 'paid') return p;
|
||||
p.status = 'paid';
|
||||
p.ref = ref ?? p.ref;
|
||||
return this.repo.save(p);
|
||||
private async issueOtp(
|
||||
tenantId: string,
|
||||
driverUserId: string,
|
||||
payoutId: string,
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
/** فشل التحويل — يُعاد المبلغ للمحفظة. */
|
||||
async fail(tenantId: string, id: string) {
|
||||
const p = await this.getOr404(tenantId, id);
|
||||
if (p.status === 'paid') throw new BadRequestException('already paid');
|
||||
if (p.status !== 'failed') {
|
||||
await this.wallet.credit(tenantId, p.driver_user_id, Number(p.amount), 'payout_refund', p.id);
|
||||
private async verifyOtp(
|
||||
payoutId: string,
|
||||
code: string,
|
||||
tenantId: string,
|
||||
driverUserId: string,
|
||||
ctx: RequestContext,
|
||||
): Promise<void> {
|
||||
// حدّ المحاولات: بلا حدّ يُخمَّن رمزٌ من 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');
|
||||
}
|
||||
p.status = 'failed';
|
||||
return this.repo.save(p);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user