262 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|