Files
tripz-llc/backend/src/database/migrations/1721870000000-PayoutSecurityAndAudit.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

64 lines
2.5 KiB
TypeScript

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}`);
}
}
}