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
@@ -52,16 +52,35 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// كل الطلبات تنطلق دفعة واحدة على نفس الصف.
|
// تدفّق السحب صار خطوتين (docs/17 — I4): الطلب يرسل رمزاً ولا يخصم شيئاً،
|
||||||
console.log(`إطلاق ${ATTEMPTS} خصم متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`);
|
// والخصم يقع عند التأكيد. فالسباق هنا على `confirm` لا على `request`.
|
||||||
|
// يعتمد dev_code الذي ترجعه النقطة في وضع التطوير (OTP_DEV_MODE=true).
|
||||||
|
console.log(`تحضير ${ATTEMPTS} طلب سحب (بلا خصم)…`);
|
||||||
|
const requested = await Promise.all(
|
||||||
|
Array.from({ length: ATTEMPTS }, () =>
|
||||||
|
api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch(() => null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const pending = requested.filter((r) => r?.ok && r.body?.payout?.id && r.body?.dev_code);
|
||||||
|
if (pending.length !== ATTEMPTS) {
|
||||||
|
console.log(`⚠️ حُضّر ${pending.length}/${ATTEMPTS} فقط.`);
|
||||||
|
if (pending.length === 0) {
|
||||||
|
console.log('لا شيء لتأكيده — تأكّد أن OTP_DEV_MODE=true (يُرجع dev_code).');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// فحص وسيط: الطلب وحده يجب ألّا يمسّ الرصيد إطلاقاً.
|
||||||
|
const mid = await api('GET', '/wallet', t);
|
||||||
|
const midBalance = Number(mid.body.balance);
|
||||||
|
|
||||||
|
console.log(`إطلاق ${pending.length} تأكيد متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
Array.from({ length: ATTEMPTS }, () =>
|
pending.map((r) =>
|
||||||
api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch((e) => ({
|
api('POST', `/payouts/${r.body.payout.id}/confirm`, t, {
|
||||||
ok: false,
|
code: String(r.body.dev_code),
|
||||||
status: 0,
|
}).catch((e) => ({ ok: false, status: 0, body: { message: String(e) } })),
|
||||||
body: { message: String(e) },
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
@@ -80,8 +99,8 @@ async function main() {
|
|||||||
console.log(`الزمن : ${elapsed}ms`);
|
console.log(`الزمن : ${elapsed}ms`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// المحكّات الثلاثة
|
|
||||||
const checks = [
|
const checks = [
|
||||||
|
['الطلب وحده لا يمسّ الرصيد (لا مال قبل إثبات الهوية)', midBalance === startBalance],
|
||||||
['الرصيد لم يصبح سالباً', endBalance >= 0],
|
['الرصيد لم يصبح سالباً', endBalance >= 0],
|
||||||
['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses],
|
['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses],
|
||||||
['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6],
|
['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6],
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { I18nModule } from './common/i18n/i18n.module';
|
|||||||
import { CacheModule } from './common/cache/cache.module';
|
import { CacheModule } from './common/cache/cache.module';
|
||||||
import { EntitlementsModule } from './common/entitlements/entitlements.module';
|
import { EntitlementsModule } from './common/entitlements/entitlements.module';
|
||||||
import { PlatformModule } from './common/platform/platform.module';
|
import { PlatformModule } from './common/platform/platform.module';
|
||||||
|
import { AuditModule } from './common/audit/audit.module';
|
||||||
|
import { SigningModule } from './common/signing/signing.module';
|
||||||
import { SeedModule } from './common/seed/seed.module';
|
import { SeedModule } from './common/seed/seed.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { TenantsModule } from './modules/tenants/tenants.module';
|
import { TenantsModule } from './modules/tenants/tenants.module';
|
||||||
@@ -64,6 +66,8 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
|
|||||||
CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة)
|
CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة)
|
||||||
EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard
|
EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard
|
||||||
PlatformModule, // عالمي — حارس السوبر-أدمن (x-platform-secret)
|
PlatformModule, // عالمي — حارس السوبر-أدمن (x-platform-secret)
|
||||||
|
AuditModule, // عالمي — سجل التدقيق المالي
|
||||||
|
SigningModule, // عالمي — توقيع HMAC للعمليات المالية
|
||||||
NabehModule, // عالمي — إرسال OTP واتساب
|
NabehModule, // عالمي — إرسال OTP واتساب
|
||||||
NotificationsModule, // عالمي — FCM
|
NotificationsModule, // عالمي — FCM
|
||||||
StorageModule, // عالمي — تخزين ملفات الوثائق
|
StorageModule, // عالمي — تخزين ملفات الوثائق
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سجل التدقيق المالي (docs/17 — I7؛ مكافئ `admin_audit_log` عند سيرو).
|
||||||
|
* الجدول: tripz_audit_log. **append-only** — لا تحديث ولا حذف.
|
||||||
|
*
|
||||||
|
* يجيب عن سؤال واحد عند كل نزاع: **من فعل ماذا ومتى ومن أين؟**
|
||||||
|
*/
|
||||||
|
@Entity('audit_log')
|
||||||
|
@Index(['tenant_id', 'created_at'])
|
||||||
|
@Index(['tenant_id', 'subject_type', 'subject_id'])
|
||||||
|
export class AuditLog {
|
||||||
|
@PrimaryGeneratedColumn('increment')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
tenant_id: string;
|
||||||
|
|
||||||
|
/** من نفّذ الفعل — قد يكون السائق نفسه أو أدمن أو `null` للنظام. */
|
||||||
|
@Column({ type: 'uuid', nullable: true })
|
||||||
|
actor_user_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
actor_role: string | null;
|
||||||
|
|
||||||
|
/** payout.request · payout.confirm · payout.complete · payout.fail · credit.topup … */
|
||||||
|
@Column()
|
||||||
|
action: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
subject_type: string | null; // payout | credit | wallet
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
subject_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||||
|
amount: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
currency: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
meta: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
ip: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditLog } from './audit-log.entity';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLog } from './audit-log.entity';
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
tenantId: string;
|
||||||
|
action: string;
|
||||||
|
actorUserId?: string | null;
|
||||||
|
actorRole?: string | null;
|
||||||
|
subjectType?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
amount?: number | null;
|
||||||
|
currency?: string | null;
|
||||||
|
meta?: Record<string, any>;
|
||||||
|
ip?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** سجل التدقيق المالي (docs/17 — I7). */
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
private readonly logger = new Logger('Audit');
|
||||||
|
|
||||||
|
constructor(@InjectRepository(AuditLog) private readonly repo: Repository<AuditLog>) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يسجّل فعلاً مالياً.
|
||||||
|
*
|
||||||
|
* **لا يرمي أبداً**: فشل التدقيق يجب ألّا يُسقط عمليةً ماليةً نجحت — وإلا
|
||||||
|
* صار السجل نقطة فشل بدل أن يكون شبكة أمان. الفشل يُسجَّل في اللوغ ليُرى.
|
||||||
|
*/
|
||||||
|
async record(entry: AuditEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.repo.insert({
|
||||||
|
tenant_id: entry.tenantId,
|
||||||
|
actor_user_id: entry.actorUserId ?? null,
|
||||||
|
actor_role: entry.actorRole ?? null,
|
||||||
|
action: entry.action,
|
||||||
|
subject_type: entry.subjectType ?? null,
|
||||||
|
subject_id: entry.subjectId ?? null,
|
||||||
|
amount: entry.amount ?? null,
|
||||||
|
currency: entry.currency ?? null,
|
||||||
|
meta: entry.meta ?? {},
|
||||||
|
ip: entry.ip ?? null,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`audit write failed [${entry.action}]: ${e?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** سجلّ موضوع بعينه — للتحقيق في نزاع. */
|
||||||
|
forSubject(tenantId: string, subjectType: string, subjectId: string) {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { tenant_id: tenantId, subject_type: subjectType, subject_id: subjectId },
|
||||||
|
order: { created_at: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SigningService } from './signing.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يحرس العمليات المالية بتوقيع HMAC (docs/17 — I6).
|
||||||
|
*
|
||||||
|
* **مطفأ افتراضياً** (`PAYMENTS_REQUIRE_SIGNATURE=false`): التوقيع يحتاج فلاتر
|
||||||
|
* أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. يُفعَّل بمتغيّر بيئة فور جاهزية
|
||||||
|
* التطبيق — بلا نشر كود.
|
||||||
|
*
|
||||||
|
* ما يقدّمه فعلاً: الـAPI يعمل على **http** بلا TLS، فمن يلتقط الشبكة يسرق
|
||||||
|
* التوكن. التوقيع بمفتاح جلسة (ليس في التوكن ولا في الـAPK) يجعل التوكن
|
||||||
|
* المسروق وحده غير كافٍ لتوقيع سحب، ويمنع تبديل المبلغ في الطريق.
|
||||||
|
* **هذا ترقيع لغياب TLS لا بديل عنه** — راجع docs/17.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SignatureGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger('SignatureGuard');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly signing: SigningService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (!this.config.get<boolean>('payments.requireSignature')) return true;
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const user = req.user;
|
||||||
|
if (!user?.userId || !user?.tenantId) throw new UnauthorizedException('unsigned_request');
|
||||||
|
|
||||||
|
const signature = req.headers?.['x-signature'];
|
||||||
|
const timestamp = req.headers?.['x-timestamp'];
|
||||||
|
if (typeof signature !== 'string' || typeof timestamp !== 'string') {
|
||||||
|
throw new UnauthorizedException('unsigned_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = await this.signing.verify(
|
||||||
|
user.tenantId,
|
||||||
|
user.userId,
|
||||||
|
signature,
|
||||||
|
timestamp,
|
||||||
|
req.method,
|
||||||
|
req.originalUrl?.split('?')[0] ?? req.url,
|
||||||
|
// الجسم الخام كما وصل؛ إعادة تسلسله قد تُغيّر ترتيب المفاتيح فيفشل توقيع سليم.
|
||||||
|
req.rawBody?.toString() ?? JSON.stringify(req.body ?? {}),
|
||||||
|
);
|
||||||
|
if (reason) {
|
||||||
|
await this.audit.record({
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
actorUserId: user.userId,
|
||||||
|
action: 'signature.rejected',
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
meta: { reason, path: req.url },
|
||||||
|
});
|
||||||
|
// رسالة واحدة لكل الأسباب — لا نُعلّم المهاجم أين أخطأ.
|
||||||
|
throw new UnauthorizedException('unsigned_request');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { SigningService } from './signing.service';
|
||||||
|
import { SignatureGuard } from './signature.guard';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [SigningService, SignatureGuard],
|
||||||
|
exports: [SigningService, SignatureGuard],
|
||||||
|
})
|
||||||
|
export class SigningModule {}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import RedisMock from 'ioredis-mock';
|
||||||
|
import { SigningService } from './signing.service';
|
||||||
|
|
||||||
|
const TENANT = 't1';
|
||||||
|
const USER = 'u1';
|
||||||
|
const BODY = '{"amount":50,"channel":"cliq"}';
|
||||||
|
|
||||||
|
describe('SigningService — توقيع العمليات المالية (docs/17 I6)', () => {
|
||||||
|
let redis: any;
|
||||||
|
let signing: SigningService;
|
||||||
|
|
||||||
|
const sign = (secret: string, ts: string, method = 'POST', path = '/api/payouts/request', body = BODY) =>
|
||||||
|
SigningService.sign(secret, SigningService.payload(ts, method, path, body));
|
||||||
|
|
||||||
|
const now = () => String(Math.floor(Date.now() / 1000));
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
redis = new RedisMock({ keyPrefix: 'tripz:' });
|
||||||
|
await redis.flushall();
|
||||||
|
signing = new SigningService(redis);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('توقيع صحيح يمرّ', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
const ts = now();
|
||||||
|
const sig = sign(secret, ts);
|
||||||
|
|
||||||
|
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', BODY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تبديل المبلغ في الطريق يُكشف', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
const ts = now();
|
||||||
|
const sig = sign(secret, ts); // وُقّع على 50
|
||||||
|
|
||||||
|
const tampered = '{"amount":5000,"channel":"cliq"}';
|
||||||
|
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', tampered)).toBe(
|
||||||
|
'bad_signature',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('توقيع نقطةٍ لا يصلح لنقطة أخرى', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
const ts = now();
|
||||||
|
const sig = sign(secret, ts, 'POST', '/api/payouts/request');
|
||||||
|
|
||||||
|
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/x/confirm', BODY)).toBe(
|
||||||
|
'bad_signature',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('طابع زمني قديم يُرفض (منع إعادة البثّ)', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
const old = String(Math.floor(Date.now() / 1000) - 3600);
|
||||||
|
const sig = sign(secret, old);
|
||||||
|
|
||||||
|
expect(await signing.verify(TENANT, USER, sig, old, 'POST', '/api/payouts/request', BODY)).toBe(
|
||||||
|
'stale_timestamp',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('طابع زمني غير رقمي يُرفض بلا انهيار', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
expect(await signing.verify(TENANT, USER, sign(secret, 'x'), 'x', 'POST', '/p', BODY)).toBe(
|
||||||
|
'bad_timestamp',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مفتاح مستخدم آخر لا يوقّع نيابةً عنه', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
await signing.issue(TENANT, 'attacker');
|
||||||
|
const ts = now();
|
||||||
|
const sig = sign(secret, ts);
|
||||||
|
|
||||||
|
expect(await signing.verify(TENANT, 'attacker', sig, ts, 'POST', '/api/payouts/request', BODY)).toBe(
|
||||||
|
'bad_signature',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('المستأجرون معزولون', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
const ts = now();
|
||||||
|
expect(await signing.verify('t2', USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe(
|
||||||
|
'no_signing_key',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بلا مفتاح (لم يسجّل دخوله بعد) يُرفض لا يمرّ', async () => {
|
||||||
|
expect(await signing.verify(TENANT, 'ghost', 'a'.repeat(64), now(), 'POST', '/p', BODY)).toBe(
|
||||||
|
'no_signing_key',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('دخول جديد يُبطل مفتاح الجلسة السابقة', async () => {
|
||||||
|
const first = await signing.issue(TENANT, USER);
|
||||||
|
const second = await signing.issue(TENANT, USER);
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
|
||||||
|
const ts = now();
|
||||||
|
expect(await signing.verify(TENANT, USER, sign(first, ts), ts, 'POST', '/p', BODY)).toBe(
|
||||||
|
'bad_signature',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('السحب يُبطل التوقيع', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
await signing.revoke(TENANT, USER);
|
||||||
|
const ts = now();
|
||||||
|
expect(await signing.verify(TENANT, USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe(
|
||||||
|
'no_signing_key',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('توقيع بطول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط الطول)', async () => {
|
||||||
|
const secret = await signing.issue(TENANT, USER);
|
||||||
|
const ts = now();
|
||||||
|
expect(await signing.verify(TENANT, USER, 'short', ts, 'POST', '/p', BODY)).toBe('bad_signature');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { REDIS } from '../redis/redis.module';
|
||||||
|
|
||||||
|
/** عمر مفتاح التوقيع — يطابق عمر توكن التحديث. */
|
||||||
|
const KEY_TTL_SEC = 30 * 24 * 3600;
|
||||||
|
/** نافذة انحراف الساعة المسموحة للطابع الزمني. */
|
||||||
|
export const SIGNATURE_SKEW_SEC = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* توقيع العمليات المالية (docs/17 — I6/D3).
|
||||||
|
*
|
||||||
|
* مفتاح **لكل جلسة** يُولَّد على السيرفر عند الدخول — لا سرّ ثابت في التطبيق
|
||||||
|
* (السرّ الثابت يُستخرج بالهندسة العكسية فيصير التوقيع بلا معنى).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SigningService {
|
||||||
|
constructor(@Inject(REDIS) private readonly redis: Redis) {}
|
||||||
|
|
||||||
|
private key(tenantId: string, userId: string) {
|
||||||
|
return `sign:${tenantId}:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** يُصدر مفتاحاً جديداً — الدخول من جهاز جديد يُبطل توقيع السابق. */
|
||||||
|
async issue(tenantId: string, userId: string): Promise<string> {
|
||||||
|
const secret = randomBytes(32).toString('hex');
|
||||||
|
await this.redis.set(this.key(tenantId, userId), secret, 'EX', KEY_TTL_SEC);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(tenantId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* النصّ الموقَّع: `timestamp.METHOD.path.body`.
|
||||||
|
* تضمين المسار والطريقة يمنع إعادة استعمال توقيعٍ صالح على نقطة أخرى؛
|
||||||
|
* والجسم يمنع تبديل المبلغ في الطريق (الـAPI على http حالياً).
|
||||||
|
*/
|
||||||
|
static payload(timestamp: string, method: string, path: string, rawBody: string): string {
|
||||||
|
return `${timestamp}.${method.toUpperCase()}.${path}.${rawBody ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static sign(secret: string, payload: string): string {
|
||||||
|
return createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يتحقق من التوقيع. يرجع سبب الفشل نصّاً (أو `null` عند النجاح) ليُسجَّل
|
||||||
|
* في التدقيق — لا ليُعرض للمهاجم.
|
||||||
|
*/
|
||||||
|
async verify(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
signature: string,
|
||||||
|
timestamp: string,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
rawBody: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const ts = Number(timestamp);
|
||||||
|
if (!Number.isFinite(ts)) return 'bad_timestamp';
|
||||||
|
// نافذة ضيقة = حماية من إعادة البثّ (replay) بلا تخزين حالة.
|
||||||
|
if (Math.abs(Date.now() / 1000 - ts) > SIGNATURE_SKEW_SEC) return 'stale_timestamp';
|
||||||
|
|
||||||
|
const secret = await this.redis.get(this.key(tenantId, userId));
|
||||||
|
if (!secret) return 'no_signing_key'; // لم يُسجّل دخوله بعد هذه الميزة
|
||||||
|
|
||||||
|
const expected = SigningService.sign(secret, SigningService.payload(timestamp, method, path, rawBody));
|
||||||
|
return SigningService.safeEqual(signature, expected) ? null : 'bad_signature';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مقارنة ثابتة الزمن — العادية تسرّب التوقيع الصحيح بالتوقيت. */
|
||||||
|
private static safeEqual(a: string, b: string): boolean {
|
||||||
|
if (typeof a !== 'string' || a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,12 @@ export default () => ({
|
|||||||
baseUrl: process.env.STORAGE_BASE_URL ?? '',
|
baseUrl: process.env.STORAGE_BASE_URL ?? '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// المدفوعات: توقيع HMAC على العمليات المالية (docs/17 — I6).
|
||||||
|
// مطفأ حتى يوقّع تطبيق فلاتر؛ تفعيله قبل ذلك يقطع كل سحب.
|
||||||
|
payments: {
|
||||||
|
requireSignature: process.env.PAYMENTS_REQUIRE_SIGNATURE === 'true',
|
||||||
|
},
|
||||||
|
|
||||||
// إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط).
|
// إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط).
|
||||||
fcm: {
|
fcm: {
|
||||||
serverKey: process.env.FCM_SERVER_KEY ?? '',
|
serverKey: process.env.FCM_SERVER_KEY ?? '',
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* أمان السحب + سجل التدقيق (docs/17 — I4/I5/I7).
|
||||||
|
* - أعمدة إثبات هوية السحب على `pay_payouts`.
|
||||||
|
* - `audit_log` — من فعل ماذا ومتى ومن أين (append-only).
|
||||||
|
*/
|
||||||
|
export class PayoutSecurityAndAudit1721870000000 implements MigrationInterface {
|
||||||
|
public async up(q: QueryRunner): Promise<void> {
|
||||||
|
const add = (col: string, type: string) =>
|
||||||
|
q.query(`ALTER TABLE tripz_pay_payouts ADD COLUMN IF NOT EXISTS ${col} ${type}`);
|
||||||
|
|
||||||
|
await add('otp_verified_at', 'timestamptz');
|
||||||
|
await add('biometric_method', 'varchar');
|
||||||
|
await add('biometric_at', 'timestamptz');
|
||||||
|
await add('device_id', 'varchar');
|
||||||
|
await add('request_ip', 'varchar');
|
||||||
|
|
||||||
|
// الافتراض الجديد للطلبات الجديدة: لا شيء يتحرك قبل التأكيد.
|
||||||
|
await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'pending_otp'`);
|
||||||
|
|
||||||
|
// السحوبات القائمة أُنشئت قبل وجود OTP وقد حُجز مالها فعلاً — تُعتبر
|
||||||
|
// مُتحقَّقة، وإلا رفض `complete` صرفها إلى الأبد.
|
||||||
|
await q.query(`
|
||||||
|
UPDATE tripz_pay_payouts
|
||||||
|
SET otp_verified_at = COALESCE(otp_verified_at, created_at)
|
||||||
|
WHERE status IN ('requested', 'processing', 'paid')
|
||||||
|
`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tripz_audit_log (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
tenant_id uuid NOT NULL,
|
||||||
|
actor_user_id uuid,
|
||||||
|
actor_role varchar,
|
||||||
|
action varchar NOT NULL,
|
||||||
|
subject_type varchar,
|
||||||
|
subject_id varchar,
|
||||||
|
amount numeric(12,3),
|
||||||
|
currency varchar,
|
||||||
|
meta jsonb NOT NULL DEFAULT '{}',
|
||||||
|
ip varchar,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await q.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_tenant_time"
|
||||||
|
ON tripz_audit_log (tenant_id, created_at)
|
||||||
|
`);
|
||||||
|
await q.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_subject"
|
||||||
|
ON tripz_audit_log (tenant_id, subject_type, subject_id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(q: QueryRunner): Promise<void> {
|
||||||
|
await q.query(`DROP TABLE IF EXISTS tripz_audit_log`);
|
||||||
|
await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'requested'`);
|
||||||
|
for (const c of ['request_ip', 'device_id', 'biometric_at', 'biometric_method', 'otp_verified_at']) {
|
||||||
|
await q.query(`ALTER TABLE tripz_pay_payouts DROP COLUMN IF EXISTS ${c}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -7,7 +7,9 @@ import { AppModule } from './app.module';
|
|||||||
import { RedisIoAdapter } from './realtime/redis-io.adapter';
|
import { RedisIoAdapter } from './realtime/redis-io.adapter';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
// rawBody: التوقيع يُحسب على الجسم **كما وصل** (docs/17 — I6). إعادة تسلسل
|
||||||
|
// JSON قد تغيّر ترتيب المفاتيح أو المسافات فيفشل توقيع سليم.
|
||||||
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||||
const cfg = app.get(ConfigService);
|
const cfg = app.get(ConfigService);
|
||||||
|
|
||||||
// مُحوّل Socket.IO عبر Redis — يفعّل التوسّع الأفقي (عدة نسخ)
|
// مُحوّل Socket.IO عبر Redis — يفعّل التوسّع الأفقي (عدة نسخ)
|
||||||
@@ -35,6 +37,15 @@ async function bootstrap() {
|
|||||||
'Security',
|
'Security',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!process.env.PLATFORM_SECRET) {
|
||||||
|
Logger.warn('PLATFORM_SECRET غير مضبوط — كل نقاط السوبر-أدمن مغلقة.', 'Security');
|
||||||
|
}
|
||||||
|
if (cfg.get<boolean>('payments.requireSignature') !== true) {
|
||||||
|
Logger.warn(
|
||||||
|
'PAYMENTS_REQUIRE_SIGNATURE=false — العمليات المالية غير موقَّعة. فعّله بعد أن يوقّع تطبيق فلاتر (docs/17 I6).',
|
||||||
|
'Security',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const port = cfg.get<number>('apiPort') ?? 4010;
|
const port = cfg.get<number>('apiPort') ?? 4010;
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { REDIS } from '../../common/redis/redis.module';
|
import { REDIS } from '../../common/redis/redis.module';
|
||||||
|
import { SigningService } from '../../common/signing/signing.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { TenantsService } from '../tenants/tenants.service';
|
import { TenantsService } from '../tenants/tenants.service';
|
||||||
@@ -19,6 +20,7 @@ export class AuthService {
|
|||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private tenantsService: TenantsService,
|
private tenantsService: TenantsService,
|
||||||
private nabeh: NabehService,
|
private nabeh: NabehService,
|
||||||
|
private readonly signing: SigningService,
|
||||||
@Inject(REDIS) private readonly redis: Redis,
|
@Inject(REDIS) private readonly redis: Redis,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ export class AuthService {
|
|||||||
return this.issueTokens(user, user.tenant_id);
|
return this.issueTokens(user, user.tenant_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private issueTokens(user: User, tenantId: string) {
|
private async issueTokens(user: User, tenantId: string) {
|
||||||
const base = {
|
const base = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
@@ -120,6 +122,14 @@ export class AuthService {
|
|||||||
{ ...base, type: 'refresh' },
|
{ ...base, type: 'refresh' },
|
||||||
{ expiresIn: (this.config.get<string>('jwt.refreshExpires') ?? '30d') as any },
|
{ 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,
|
user,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} 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.
|
* طلب سحب أرباح سائق. الجدول: tripz_pay_payouts.
|
||||||
@@ -37,12 +43,31 @@ export class Payout {
|
|||||||
@Column({ type: 'jsonb', default: {} })
|
@Column({ type: 'jsonb', default: {} })
|
||||||
destination: Record<string, any>;
|
destination: Record<string, any>;
|
||||||
|
|
||||||
@Column({ type: 'varchar', default: 'requested' })
|
@Column({ type: 'varchar', default: 'pending_otp' })
|
||||||
status: PayoutStatus;
|
status: PayoutStatus;
|
||||||
|
|
||||||
@Column({ type: 'varchar', nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
ref: string | null;
|
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()
|
@CreateDateColumn()
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { PayoutsService } from './payouts.service';
|
|||||||
import { PaymentsController } from './payments.controller';
|
import { PaymentsController } from './payments.controller';
|
||||||
import { PayoutsController } from './payouts.controller';
|
import { PayoutsController } from './payouts.controller';
|
||||||
import { WalletModule } from '../wallet/wallet.module';
|
import { WalletModule } from '../wallet/wallet.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule],
|
// NabehModule و AuditModule عالميان.
|
||||||
|
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule],
|
||||||
controllers: [PaymentsController, PayoutsController],
|
controllers: [PaymentsController, PayoutsController],
|
||||||
providers: [PaymentsService, PayoutsService],
|
providers: [PaymentsService, PayoutsService],
|
||||||
exports: [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 { 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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.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')
|
@ApiTags('payouts')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -12,16 +22,42 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
|
|||||||
export class PayoutsController {
|
export class PayoutsController {
|
||||||
constructor(private readonly payouts: PayoutsService) {}
|
constructor(private readonly payouts: PayoutsService) {}
|
||||||
|
|
||||||
// السائق يطلب سحب أرباحه
|
/**
|
||||||
@UseGuards(JwtAuthGuard)
|
* الخطوة 1: السائق يطلب السحب → يصله رمز على واتساب.
|
||||||
|
* **لا يُخصم شيء هنا** (docs/17 — I4).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, SignatureGuard)
|
||||||
@Post('request')
|
@Post('request')
|
||||||
request(@CurrentUser() user: AuthUser, @Body() body: any) {
|
request(@CurrentUser() user: AuthUser, @Body() body: any, @Req() req: any) {
|
||||||
return this.payouts.request(user.tenantId, user.userId, {
|
return this.payouts.request(
|
||||||
|
user.tenantId,
|
||||||
|
user.userId,
|
||||||
|
{
|
||||||
amount: Number(body.amount),
|
amount: Number(body.amount),
|
||||||
channel: body.channel,
|
channel: body.channel,
|
||||||
currency: body.currency,
|
currency: body.currency,
|
||||||
destination: body.destination,
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -34,14 +70,22 @@ export class PayoutsController {
|
|||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('admin', 'dispatcher')
|
@Roles('admin', 'dispatcher')
|
||||||
@Patch(':id/complete')
|
@Patch(':id/complete')
|
||||||
complete(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body('ref') ref: string) {
|
complete(
|
||||||
return this.payouts.complete(user.tenantId, id, ref);
|
@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)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('admin', 'dispatcher')
|
@Roles('admin', 'dispatcher')
|
||||||
@Patch(':id/fail')
|
@Patch(':id/fail')
|
||||||
fail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
fail(@CurrentUser() user: AuthUser, @Param('id') id: string, @Req() req: any) {
|
||||||
return this.payouts.fail(user.tenantId, id);
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from '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 { Payout } from './entities/payout.entity';
|
||||||
import { WalletService } from '../wallet/wallet.service';
|
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 {
|
export interface PayoutRequestDto {
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -11,36 +26,143 @@ export interface PayoutRequestDto {
|
|||||||
destination?: Record<string, any>;
|
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()
|
@Injectable()
|
||||||
export class PayoutsService {
|
export class PayoutsService {
|
||||||
|
private readonly logger = new Logger('Payouts');
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Payout) private readonly repo: Repository<Payout>,
|
@InjectRepository(Payout) private readonly repo: Repository<Payout>,
|
||||||
|
@Inject(REDIS) private readonly redis: Redis,
|
||||||
private readonly wallet: WalletService,
|
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);
|
const amount = Number(dto.amount);
|
||||||
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
|
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
|
||||||
if (!dto.channel) throw new BadRequestException('channel is required');
|
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({
|
this.repo.create({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
driver_user_id: driverUserId,
|
driver_user_id: driverUserId,
|
||||||
amount,
|
amount,
|
||||||
currency: dto.currency ?? 'JOD',
|
currency: dto.currency ?? w.currency ?? 'JOD',
|
||||||
channel: dto.channel,
|
channel: dto.channel,
|
||||||
destination: dto.destination ?? {},
|
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) {
|
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> {
|
private async getOr404(tenantId: string, id: string): Promise<Payout> {
|
||||||
const p = await this.repo.findOne({ where: { tenant_id: tenantId, id } });
|
const p = await this.repo.findOne({ where: { tenant_id: tenantId, id } });
|
||||||
if (!p) throw new NotFoundException('payout not found');
|
if (!p) throw new NotFoundException('payout not found');
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */
|
private async issueOtp(
|
||||||
async complete(tenantId: string, id: string, ref?: string) {
|
tenantId: string,
|
||||||
const p = await this.getOr404(tenantId, id);
|
driverUserId: string,
|
||||||
if (p.status === 'paid') return p;
|
payoutId: string,
|
||||||
p.status = 'paid';
|
): Promise<string> {
|
||||||
p.ref = ref ?? p.ref;
|
const code = String(randomInt(1000, 10000));
|
||||||
return this.repo.save(p);
|
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(
|
||||||
async fail(tenantId: string, id: string) {
|
payoutId: string,
|
||||||
const p = await this.getOr404(tenantId, id);
|
code: string,
|
||||||
if (p.status === 'paid') throw new BadRequestException('already paid');
|
tenantId: string,
|
||||||
if (p.status !== 'failed') {
|
driverUserId: string,
|
||||||
await this.wallet.credit(tenantId, p.driver_user_id, Number(p.amount), 'payout_refund', p.id);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,13 +166,20 @@
|
|||||||
| # | البند | التفصيل |
|
| # | البند | التفصيل |
|
||||||
|---|-------|---------|
|
|---|-------|---------|
|
||||||
| I1 | ✅ **إصلاح سباق المحفظة** | **منفَّذ ومُثبَت على السيرفر** (commit 9d6b752): `UPDATE … SET balance = balance ± :delta WHERE tenant_id … AND balance >= :amount RETURNING *` — عبارة واحدة ذرّية، والقيد+الرصيد في معاملة واحدة. أُضيف `wallet_txns.balance_after` وقيد `CHECK (balance >= 0)` كشبكة أمان. إنشاء المحفظة عبر `ON CONFLICT DO NOTHING`.<br>**نتيجة `wallet-race-test.mjs 100 5` على Postgres حقيقي:** 100 خصم متزامن → نجح 50 بالضبط، رُفض 50، الرصيد 250→0، المخصوم = 250 (لا مال ضائع ولا مخلوق)، الزمن 1492ms. ✅ |
|
| I1 | ✅ **إصلاح سباق المحفظة** | **منفَّذ ومُثبَت على السيرفر** (commit 9d6b752): `UPDATE … SET balance = balance ± :delta WHERE tenant_id … AND balance >= :amount RETURNING *` — عبارة واحدة ذرّية، والقيد+الرصيد في معاملة واحدة. أُضيف `wallet_txns.balance_after` وقيد `CHECK (balance >= 0)` كشبكة أمان. إنشاء المحفظة عبر `ON CONFLICT DO NOTHING`.<br>**نتيجة `wallet-race-test.mjs 100 5` على Postgres حقيقي:** 100 خصم متزامن → نجح 50 بالضبط، رُفض 50، الرصيد 250→0، المخصوم = 250 (لا مال ضائع ولا مخلوق)، الزمن 1492ms. ✅ |
|
||||||
| I2 | **محفظة لكل طرف + محفظة المنصة** | راكب · سائق · المستأجر/المنصة (مكافئ `siroWallet`) — أساس العمولة (B6). |
|
| # | البند | الحالة |
|
||||||
| I3 | **جدول لكل طريقة دفع** | شام كاش · كليك · إي كاش · بيموب · MTN · فوري … لكل واحدة جدولها + محوّل (adapter) موحّد. حالياً عندنا `tripz_pay_payments` عام. |
|
|---|-------|--------|
|
||||||
| I4 | **OTP على الـpayout عبر نبيه** | إرسال كود + تحقّق قبل تنفيذ السحب (لا يوجد في سيرو). |
|
| I4 | **OTP على الـpayout عبر نبيه** | ✅ تدفّق خطوتين: `POST /payouts/request` يرسل رمزاً **بلا حركة مال**، و`POST /payouts/:id/confirm` يتحقق ثم يحجز ذرّياً. حدّ 5 محاولات، الرمز يُستهلك مرة واحدة، و`complete` يرفض طلباً بلا `otp_verified_at`. **قاعدة: لا يتحرك مال قبل إثبات الهوية.** |
|
||||||
| I5 | **بصمة (وجه/إصبع) في فلاتر** | تأكيد حيوي قبل السحب — يُربط بالطلب (device fingerprint من D2). |
|
| I5 | **بصمة (وجه/إصبع)** | ✅ الجزء الخلفي: `biometric_method`/`biometric_at` + `device_id` + `request_ip` تُسجَّل مع السحب. **أثرٌ للتحقيق لا مصادقة** — العميل يستطيع ادّعاءها؛ المصادقة الحقيقية JWT + رمز واتساب. الإثبات الحيّ نفسه في فلاتر. |
|
||||||
| I6 | **HMAC على العمليات المالية** | توقيع الطلب (D3) — إلزامي على topup/payout. |
|
| I6 | **HMAC على العمليات المالية** | 🟡 **مبنيّ ومطفأ** (`PAYMENTS_REQUIRE_SIGNATURE=false`) — يحتاج فلاتر أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. **مفتاح لكل جلسة** يُصدره الدخول (`signing_key`) لا سرّ ثابت في التطبيق (الثابت يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية). يوقّع `timestamp.METHOD.path.body` بنافذة 5 دقائق. |
|
||||||
| I7 | **سجل تدقيق مالي** | مكافئ `admin_audit_log` — من فعل ماذا ومتى على كل عملية. |
|
| I7 | **سجل تدقيق مالي** | ✅ `tripz_audit_log` (append-only): من · ماذا · متى · من أي IP وجهاز. يغطّي `payout.request/confirm/complete/fail` و`otp_failed`/`otp_blocked` و`signature.rejected`. **لا يرمي أبداً** — فشل التدقيق لا يُسقط عمليةً نجحت. |
|
||||||
| I8 | **webhook SMS + Gemini** | تبنّي نمط سيرو للأسواق بلا API رسمي (سوريا): `raw_sms_log` + استخراج بالـAI + تسوية. |
|
| I2 | **محفظة المنصة** | ⏳ **أُلغيت الحاجة إليها عملياً**: بعد نموذج الرصيد التشغيلي (docs/18) لم تعد المنصة تقبض عمولة من كل رحلة — إيرادها = **الشحن**، و`credit_txns` هو دفتر ذلك الإيراد فعلاً (`SUM` الشحن لكل مستأجر). محفظة ثانية = مسك دفتر مزدوج يحتاج مطابقة. البند الباقي = **تقرير إيراد** لا محفظة. |
|
||||||
|
| I3 | **جدول لكل طريقة دفع** | ⏳ لم يُنفَّذ — إعادة هيكلة مخطط كاملة (`tripz_pay_payments` عام حالياً). يُنفَّذ مع أول ربط بوابة حقيقية، لا قبله. |
|
||||||
|
| I8 | **webhook SMS + Gemini** | ⏳ لم يُنفَّذ — ميزة كاملة (`raw_sms_log` + استخراج + تسوية). تُبنى عند دخول السوق السوري فعلياً. |
|
||||||
|
|
||||||
|
### 🔴 أخطر ثغرة مالية قائمة — ليست في الكود
|
||||||
|
**الـAPI يعمل على `http` بلا TLS** (`194.163.173.157:4010`، والتطبيق يفعّل `usesCleartextTraffic=true`). من يلتقط الشبكة يسرق توكن أي سائق ويسحب أرباحه — ولا OTP ولا HMAC ولا تدقيق يمنع ذلك تماماً.
|
||||||
|
- توقيع I6 **يخفّف** الضرر (التوكن المسروق وحده لا يكفي: المفتاح لا يمرّ في الشبكة بعد الدخول) لكنه **ترقيع لا بديل**.
|
||||||
|
- **الإصلاح الحقيقي: TLS** (نطاق + شهادة عبر CloudPanel/Let's Encrypt) ثم `usesCleartextTraffic=false`. **يسبق أي عمل مدفوعات آخر.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user