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:
co-authored by
Claude Fable 5
parent
b1a060c5ed
commit
da035e46a4
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user