feat: implement billing module for tenant subscription management and super-admin payment processing
This commit is contained in:
@@ -61,6 +61,27 @@ export class SmsSettlementService {
|
||||
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 يجب ألّا نفقد الرسالة — يمكن إعادة معالجتها لاحقاً من السجل.
|
||||
@@ -69,6 +90,11 @@ export class SmsSettlementService {
|
||||
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);
|
||||
@@ -84,6 +110,12 @@ export class SmsSettlementService {
|
||||
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,
|
||||
@@ -93,19 +125,23 @@ export class SmsSettlementService {
|
||||
fingerprint,
|
||||
device_id: dto.deviceId ?? null,
|
||||
sent_at: dto.sentAt ? new Date(dto.sentAt) : null,
|
||||
status: 'received',
|
||||
status: untrustedSender ? 'unmatched' : 'received',
|
||||
note: untrustedSender ? `مرسل غير معتمد: ${sender}` : null,
|
||||
}),
|
||||
);
|
||||
|
||||
// التحليل والمطابقة لا يُفشلان الاستقبال: الجهاز تلقّى «حُفظت» بالفعل،
|
||||
// وأي خطأ هنا يترك الرسالة في الطابور بدل أن يدفع الجهاز لإعادة الإرسال.
|
||||
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);
|
||||
// مرسل غير معتمد → يُحفظ لكن لا يُعالج آلياً (طابور مراجعة).
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user