Files
tripz-llc/backend/src/modules/payments/sms-settlement.service.ts
T

262 lines
12 KiB
TypeScript

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<RawSms>,
@InjectRepository(Payment) private readonly payments: Repository<Payment>,
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<string, string[]> | 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<RawSms> {
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<Payment | null> {
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);
}
}