feat: P2 — التسوية بالرسائل + إزالة مارتن + مستند البنية (docs/25)

P2 (كليك/شام كاش بلا API):
- الرسالة تُحفظ خاماً **قبل** أي تحليل: التحليل قد يفشل فنحتاج الأصل لإعادة
  المعالجة، وعند النزاع يكون النصّ الأصلي هو الحجّة لا تفسيرُنا له.
- النقطة تصنع المال، فرسالة مزوّرة = رصيد من عدم. الحماية: سرّ لكل مستأجر
  بمقارنة ثابتة الزمن (المقارنة النصّية تسرّب السرّ حرفاً حرفاً زمنياً)،
  وبصمة محتوى فريدة تمنع احتساب إعادة الإرسال مرتين.
- Gemini بحرارة صفر ومطالَب بإرجاع null عند عدم اليقين: نموذج يخمّن مبلغاً
  يسوّي فاتورة بمال لم يصل. بلا مبلغ صريح → مراجعة بشرية لا تسوية.
- المطابقة بالمرجع أولاً، ثم بالمبلغ خلال 24 ساعة وبشرط فاتورة وحيدة —
  فاتورتان بنفس المبلغ التباسٌ يُراجَع، لا تسويةٌ عشوائية لإحداهما.
- التسوية تمرّ بـmarkSuccess نفسه فلا يتفرّع مسار مالي ثانٍ.

إزالة حاوية martin: انطلق منصّة قائمة بذاتها لها خوادمها؛ دورنا طلب وردّ لا
استضافة خرائط (قرار المالك).

docs/25: جرد الحاويات · لماذا الدفع ليس خدمة منفصلة · سيرفر السوبر-أدمن
المنفصل ونطاقه الفرعي · WebSocket مقابل الاستطلاع بالأرقام · أحجام السيرفرات
على أساس الذروة لا المعدّل · نقل مستأجر · ترتيب التوسّع.

13 اختباراً جديداً (201 إجمالاً، كلها خضراء).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-18 15:19:17 +03:00
co-authored by Claude Fable 5
parent b1a060c5ed
commit da035e46a4
12 changed files with 789 additions and 15 deletions
@@ -0,0 +1,225 @@
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');
}
/**
* استقبال رسالة. **الحفظ أولاً، التحليل بعده**: لو انهار التحليل أو تعطّل
* Gemini يجب ألّا نفقد الرسالة — يمكن إعادة معالجتها لاحقاً من السجل.
*/
async ingest(tenantSlug: string, secret: string | undefined, dto: InboundSms) {
if (!dto?.body || !dto?.provider) {
throw new BadRequestException('provider and body are required');
}
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 };
}
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: 'received',
}),
);
// التحليل والمطابقة لا يُفشلان الاستقبال: الجهاز تلقّى «حُفظت» بالفعل،
// وأي خطأ هنا يترك الرسالة في الطابور بدل أن يدفع الجهاز لإعادة الإرسال.
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);
}
}