feat: implement billing module for tenant subscription management and super-admin payment processing

This commit is contained in:
Hamza-Ayed
2026-07-18 17:26:16 +03:00
parent d4ac38ca87
commit d239f51d5d
17 changed files with 593 additions and 67 deletions
@@ -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 };