Files
tripz-llc/backend/src/modules/payments/payouts.service.ts
T
Hamza-AyedandClaude Opus 4.8 1605754722 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>
2026-07-17 16:22:00 +03:00

303 lines
10 KiB
TypeScript

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<string, any>;
}
/** سياق الطلب — أثرٌ للتحقيق، وليس مصدر هوية. */
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<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,
) {}
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');
// فحص مبكّر — لا نرسل رمزاً لطلب مستحيل. **ليس حجزاً**: الحجز الحقيقي
// ذرّي عند التأكيد، فلا فجوة بين الفحص والخصم.
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<Payout> {
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<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;
}
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');
}
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);
}
}