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>
64 lines
2.5 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
}
|