import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between } from 'typeorm'; import { createHash, timingSafeEqual } from 'crypto'; import { RawSms } from './entities/raw-sms.entity'; import { Payment } from './entities/payment.entity'; import { PaymentsService } from './payments.service'; import { TenantsService } from '../tenants/tenants.service'; import { GeminiService } from '../../integrations/gemini/gemini.service'; export interface InboundSms { provider: string; body: string; sender?: string; deviceId?: string; sentAt?: string; } /** * التسوية من رسائل المزوّد (docs/24 §5 — P2) — للأسواق بلا API (كليك · شام كاش). * * التدفّق: جهاز أندرويد يلتقط رسالة التأكيد → يرفعها **خاماً** → نحفظها فوراً * → Gemini يستخرج المبلغ والمرجع → نطابقها بفاتورة معلّقة → تُسوَّى مرة واحدة. * * ⚠️ **هذه النقطة تصنع المال.** رسالةٌ مزوَّرة مقبولة = رصيدٌ من عدم. لذلك: * سرٌّ لكل مستأجر بمقارنة ثابتة الزمن · بصمة محتوى تمنع إعادة الإرسال · * مطابقة بالمرجع لا بالمبلغ وحده · وكل ما لا يُطابَق **يُحجز للمراجعة** لا * يُسوَّى تفاؤلاً. */ @Injectable() export class SmsSettlementService { private readonly logger = new Logger('SmsSettlement'); constructor( @InjectRepository(RawSms) private readonly sms: Repository, @InjectRepository(Payment) private readonly payments: Repository, private readonly paymentsService: PaymentsService, private readonly tenants: TenantsService, private readonly gemini: GeminiService, ) {} /** * يتحقّق من سرّ الجهاز الرافع. المقارنة **ثابتة الزمن**: المقارنة النصّية * العادية تنتهي عند أول حرف مختلف، فيتسرّب طول البادئة الصحيحة زمنياً * ويمكن استخراج السرّ حرفاً حرفاً. */ private assertSecret(tenantSecret: string | undefined, provided: string | undefined) { if (!tenantSecret) { // بلا سرّ مضبوط تبقى النقطة **مغلقة**، لا مفتوحة. الافتراض المتساهل هنا // يعني أن أي مستأجر لم يُهيَّأ بعد تُقبل له رسائل مجهولة المصدر. throw new UnauthorizedException('sms webhook not configured for this tenant'); } const a = Buffer.from(String(provided ?? '')); const b = Buffer.from(tenantSecret); if (a.length !== b.length || !timingSafeEqual(a, b)) { throw new UnauthorizedException('invalid sms webhook secret'); } } private fingerprint(provider: string, sender: string | null, body: string): string { return createHash('sha256').update(`${provider}|${sender ?? ''}|${body}`).digest('hex'); } /** * T1: هل المرسل غير معتمد؟ يفحص `tenant.settings.payments.trusted_senders` * (خريطة مزوّد → قائمة أسماء). بلا قائمة مضبوطة = كل مرسل مقبول (توافق * رجعي)، وقائمة فارغة = كل مرسل مشبوه. المقارنة بلا حالة: «CliQ» و«CLIQ» * سواء. */ private isUntrustedSender( tenant: { settings?: any }, provider: string, sender: string | null, ): boolean { const trusted: Record | undefined = tenant?.settings?.payments?.trusted_senders; if (!trusted) return false; // بلا إعداد → لا تصفية (توافق رجعي) const allowed = trusted[provider]; if (!Array.isArray(allowed)) return false; // المزوّد بلا قائمة → لا تصفية if (!sender) return true; // قائمة موجودة ومرسل فارغ → مشبوه const norm = sender.trim().toLowerCase(); return !allowed.some((s) => String(s).trim().toLowerCase() === norm); } /** * استقبال رسالة. **الحفظ أولاً، التحليل بعده**: لو انهار التحليل أو تعطّل * Gemini يجب ألّا نفقد الرسالة — يمكن إعادة معالجتها لاحقاً من السجل. */ async ingest(tenantSlug: string, secret: string | undefined, dto: InboundSms) { if (!dto?.body || !dto?.provider) { throw new BadRequestException('provider and body are required'); } // T4: حدّ حجم النصّ — رسالة SMS عادية ≤ 1600 حرف (10 أجزاء). نصّ أكبر // يُكلّف Gemini tokens بلا داعٍ ويوحي بحقن لا برسالة حقيقية. if (dto.body.length > 2000) { throw new BadRequestException('body exceeds maximum length (2000)'); } const tenant = await this.tenants.resolve(tenantSlug); if (!tenant) throw new UnauthorizedException('unknown tenant'); this.assertSecret(tenant.settings?.payments?.sms_webhook_secret, secret); const sender = dto.sender ?? null; const fingerprint = this.fingerprint(dto.provider, sender, dto.body); const existing = await this.sms.findOne({ where: { tenant_id: tenant.id, fingerprint }, }); if (existing) { this.logger.warn(`رسالة مكرّرة تُجوهل: ${existing.id}`); return { id: existing.id, status: existing.status, duplicate: true }; } // T1: التحقّق من Sender ID — قائمة مرسلين معتمدين لكل مزوّد في إعدادات // المستأجر. المرسل غير المعتمد تُحفظ رسالته (أثر للنزاع) لكن لا تُسوَّى // آلياً — تذهب للمراجعة البشرية. Sender ID وحده ليس دليلاً قاطعاً (يُنتحل // على مستوى الشبكة) لكنه طبقة دفاع فعّالة ضد الاحتيال العادي. const untrustedSender = this.isUntrustedSender(tenant, dto.provider, sender); let row = await this.sms.save( this.sms.create({ tenant_id: tenant.id, provider: dto.provider, sender, body: dto.body, fingerprint, device_id: dto.deviceId ?? null, sent_at: dto.sentAt ? new Date(dto.sentAt) : null, status: untrustedSender ? 'unmatched' : 'received', note: untrustedSender ? `مرسل غير معتمد: ${sender}` : null, }), ); // التحليل والمطابقة لا يُفشلان الاستقبال: الجهاز تلقّى «حُفظت» بالفعل، // وأي خطأ هنا يترك الرسالة في الطابور بدل أن يدفع الجهاز لإعادة الإرسال. // مرسل غير معتمد → يُحفظ لكن لا يُعالج آلياً (طابور مراجعة). if (!untrustedSender) { try { row = await this.process(row); } catch (e: any) { this.logger.error(`تعذّرت معالجة ${row.id}: ${e?.message}`); row.status = 'failed'; row.note = String(e?.message ?? 'processing error').slice(0, 200); row = await this.sms.save(row); } } return { id: row.id, status: row.status, payment_id: row.payment_id, duplicate: false }; } /** يحلّل رسالة محفوظة ويحاول مطابقتها. قابل لإعادة التشغيل على أي صفّ. */ async process(row: RawSms): Promise { if (!this.gemini.enabled) { row.status = 'received'; row.note = 'GEMINI_API_KEY غير مضبوط — بانتظار المعالجة'; return this.sms.save(row); } const parsed = await this.gemini.extractTransferSms(row.body, row.provider); row.parsed = parsed ?? {}; const amount = Number(parsed?.amount); const reference = parsed?.reference ? String(parsed.reference).trim() : null; if (parsed?.is_incoming === false) { row.status = 'unmatched'; row.note = 'ليست رسالة استلام'; return this.sms.save(row); } if (!Number.isFinite(amount) || amount <= 0) { // النموذج لم يجد مبلغاً صريحاً — نتوقّف بدل التخمين. row.status = 'unmatched'; row.note = 'لا مبلغ صريح في الرسالة'; return this.sms.save(row); } const payment = await this.findMatch(row.tenant_id, row.provider, amount, reference); if (!payment) { row.status = 'unmatched'; row.note = `لا فاتورة معلّقة تطابق ${amount}${reference ? ` (مرجع ${reference})` : ''}`; return this.sms.save(row); } // التسوية تمرّ بنفس مسار الدفع العادي، فينطبق توجيه المال ذاته // (إيراد/أمانة/رسم) بلا منطق مالي ثانٍ يتفرّع هنا ويتناقض لاحقاً. await this.paymentsService.settleFromSms(payment.id, reference ?? row.id); row.status = 'matched'; row.payment_id = payment.id; row.note = null; return this.sms.save(row); } /** * المطابقة: المرجع أولاً (قاطع)، ثم المبلغ ضمن نافذة زمنية. * * المطابقة بالمبلغ وحده خطرة — فاتورتان بنفس المبلغ تجعل الرسالة تسوّي * الخطأ منهما. لذلك نشترط **فاتورة واحدة فقط** مطابقة؛ التعدّد يذهب * للمراجعة البشرية. */ private async findMatch( tenantId: string, provider: string, amount: number, reference: string | null, ): Promise { if (reference) { const byRef = await this.payments.findOne({ where: { tenant_id: tenantId, status: 'pending', tx_ref: reference }, }); if (byRef) return byRef; } // نافذة 24 ساعة: فاتورة أقدم من ذلك غالباً متروكة، ومطابقتها بمبلغ // متشابه تربط دفعةً جديدة بفاتورة قديمة خطأً. const since = new Date(Date.now() - 24 * 3600_000); const candidates = await this.payments.find({ where: { tenant_id: tenantId, status: 'pending', provider, amount: amount as any, created_at: Between(since, new Date()) as any, }, take: 2, }); if (candidates.length === 1) return candidates[0]; return null; // صفر = لا مطابقة · أكثر من واحدة = التباس يُراجَع بشرياً } /** طابور المراجعة: ما لم يُطابَق آلياً — لا يُهمَل بصمت. */ async reviewQueue(tenantId: string, limit = 100) { return this.sms.find({ where: [ { tenant_id: tenantId, status: 'unmatched' }, { tenant_id: tenantId, status: 'failed' }, ], order: { received_at: 'DESC' }, take: Math.min(limit, 500), }); } /** ربط يدوي من طابور المراجعة (أدمن المستأجر). */ async manualMatch(tenantId: string, smsId: string, paymentId: string) { const row = await this.sms.findOne({ where: { id: smsId, tenant_id: tenantId } }); if (!row) throw new BadRequestException('sms not found'); if (row.status === 'matched') throw new BadRequestException('already matched'); const payment = await this.payments.findOne({ where: { id: paymentId, tenant_id: tenantId }, }); if (!payment) throw new BadRequestException('payment not found'); if (payment.status === 'success') throw new BadRequestException('payment already settled'); await this.paymentsService.settleFromSms(payment.id, row.parsed?.reference ?? row.id); row.status = 'matched'; row.payment_id = payment.id; row.note = 'مطابقة يدوية'; return this.sms.save(row); } }