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
@@ -1,5 +1,6 @@
import { Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { PaymentsService } from './payments.service';
import { TenantsService } from '../tenants/tenants.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -23,6 +24,7 @@ export class PaymentsController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, FeatureGuard)
@RequiresFeature('payments')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post('charge')
charge(@CurrentUser() user: AuthUser, @Body() body: any) {
return this.payments.charge(user.tenantId, {
@@ -19,6 +19,11 @@ function makeService(countryPack = 'jo', settings: any = {}) {
},
find: async () => [],
findOne: async () => null,
manager: {
transaction: async (fn: any) => fn({
save: async (x: any) => { saved.push(x); return { ...x, id: x.id ?? 'pay-1' }; },
}),
},
};
const wallet = { credit: jest.fn().mockResolvedValue({}) };
@@ -71,19 +71,44 @@ export class PaymentsService {
const tenant = await this.tenants.resolve(tenantId);
if (!tenant) throw new NotFoundException('tenant not found');
let payment = await this.repo.save(
this.repo.create({
// T2: إعادة استعمال فاتورة معلّقة بنفس المزوّد والنية بدل إنشاء واحدة جديدة.
// بلا هذا: (1) المستخدم يُغرق القاعدة بفواتير، (2) فاتورتان بنفس المبلغ
// تجعلان مطابقة SMS مستحيلة (findMatch يشترط فاتورة واحدة)، (3) طابور
// المراجعة يمتلئ بفواتير متروكة. هذا نفس ما كان في سيرو (update لا create).
let payment = await this.repo.findOne({
where: {
tenant_id: tenantId,
user_id: dto.userId,
trip_id: dto.tripId ?? null,
purpose,
provider: dto.provider,
method: dto.method ?? null,
amount,
currency: dto.currency ?? 'JOD',
purpose,
status: 'pending',
}),
);
},
order: { created_at: 'DESC' },
});
if (payment) {
payment.amount = amount;
payment.currency = dto.currency ?? payment.currency;
payment.trip_id = dto.tripId ?? payment.trip_id;
payment.method = dto.method ?? payment.method;
payment.meta = { ...(payment.meta ?? {}), reused: true, updated_at: new Date().toISOString() };
payment = await this.repo.save(payment);
this.logger.log(`reused pending payment=${payment.id} provider=${dto.provider} amount=${amount}`);
} else {
payment = await this.repo.save(
this.repo.create({
tenant_id: tenantId,
user_id: dto.userId,
trip_id: dto.tripId ?? null,
purpose,
provider: dto.provider,
method: dto.method ?? null,
amount,
currency: dto.currency ?? 'JOD',
status: 'pending',
}),
);
}
const adapter = this.gateways.get(dto.provider);
const result = await adapter.charge(
@@ -194,59 +219,64 @@ export class PaymentsService {
*
* كل قيد يحمل `ref` مشتقّاً من معرّف الدفع، فإعادة تسليم نفس الـwebhook
* لا تقيّد مرتين (الدفتر يرفضها بفهرس التفرّد).
*
* T7: الكتابات في **معاملة واحدة** (كانت بلا transaction كما S4 في سيرو).
* فشلٌ في المنتصف لم يعد يترك دفعة `success` بلا قيد في الدفتر.
*/
private async markSuccess(payment: Payment): Promise<Payment> {
payment.status = 'success';
payment.tx_ref = payment.tx_ref ?? `${payment.provider.toUpperCase()}-${payment.id.slice(0, 8)}`;
const saved = await this.repo.save(payment);
return this.repo.manager.transaction(async (em) => {
payment.status = 'success';
payment.tx_ref = payment.tx_ref ?? `${payment.provider.toUpperCase()}-${payment.id.slice(0, 8)}`;
const saved = await em.save(payment);
const gross = Number(saved.amount);
const tenant = await this.tenants.resolve(saved.tenant_id);
const { fee } = tenant ? transactionFeeFor(tenant) : { fee: 0 };
// الرسم لا يتجاوز المبلغ: شحنٌ صغير أقلّ من الرسم يجب ألّا يُخرج صافياً سالباً.
const charged = cappedFee(fee, gross);
const net = gross - charged;
const currency = saved.currency;
const gross = Number(saved.amount);
const tenant = await this.tenants.resolve(saved.tenant_id);
const { fee } = tenant ? transactionFeeFor(tenant) : { fee: 0 };
// الرسم لا يتجاوز المبلغ: شحنٌ صغير أقلّ من الرسم يجب ألّا يُخرج صافياً سالباً.
const charged = cappedFee(fee, gross);
const net = gross - charged;
const currency = saved.currency;
if (charged > 0) {
await this.tenantWallet.creditRevenue({
tenantId: saved.tenant_id,
amount: charged,
currency,
reason: LedgerReason.TRANSACTION_FEE,
ref: `fee:${saved.id}`,
meta: { payment_id: saved.id, provider: saved.provider },
});
}
if (charged > 0) {
await this.tenantWallet.creditRevenue({
tenantId: saved.tenant_id,
amount: charged,
currency,
reason: LedgerReason.TRANSACTION_FEE,
ref: `fee:${saved.id}`,
meta: { payment_id: saved.id, provider: saved.provider },
});
}
// الرسم ابتلع المبلغ كاملاً: لا يبقى صافٍ يُقيَّد. بلا هذا الشرط نُنادي
// الدفتر بصفر فيرمي خطأً **بعد** أن قُيِّد الرسم — عمليةٌ نصف مطبَّقة.
if (net <= 0) return saved;
// الرسم ابتلع المبلغ كاملاً: لا يبقى صافٍ يُقيَّد. بلا هذا الشرط نُنادي
// الدفتر بصفر فيرمي خطأً **بعد** أن قُيِّد الرسم — عمليةٌ نصف مطبَّقة.
if (net <= 0) return saved;
if (saved.purpose === 'credit_topup') {
// إيراد المستأجر: السائق دفع مقدَّماً ليعمل (docs/18).
await this.credit.topup(saved.tenant_id, saved.user_id, net, saved.id);
await this.tenantWallet.creditRevenue({
tenantId: saved.tenant_id,
amount: net,
currency,
reason: LedgerReason.DRIVER_CREDIT_TOPUP,
ref: `credit:${saved.id}`,
meta: { payment_id: saved.id, driver_id: saved.user_id },
});
} else if (saved.purpose === 'topup') {
// أمانة: يُضاف لرصيد الراكب ويُسجَّل التزاماً على المستأجر، لا ربحاً.
await this.wallet.credit(saved.tenant_id, saved.user_id, net, 'payment_topup', saved.id);
await this.tenantWallet.creditPending({
tenantId: saved.tenant_id,
amount: net,
currency,
reason: LedgerReason.RIDER_TOPUP,
ref: `topup:${saved.id}`,
meta: { payment_id: saved.id, rider_id: saved.user_id },
});
}
if (saved.purpose === 'credit_topup') {
// إيراد المستأجر: السائق دفع مقدَّماً ليعمل (docs/18).
await this.credit.topup(saved.tenant_id, saved.user_id, net, saved.id);
await this.tenantWallet.creditRevenue({
tenantId: saved.tenant_id,
amount: net,
currency,
reason: LedgerReason.DRIVER_CREDIT_TOPUP,
ref: `credit:${saved.id}`,
meta: { payment_id: saved.id, driver_id: saved.user_id },
});
} else if (saved.purpose === 'topup') {
// أمانة: يُضاف لرصيد الراكب ويُسجَّل التزاماً على المستأجر، لا ربحاً.
await this.wallet.credit(saved.tenant_id, saved.user_id, net, 'payment_topup', saved.id);
await this.tenantWallet.creditPending({
tenantId: saved.tenant_id,
amount: net,
currency,
reason: LedgerReason.RIDER_TOPUP,
ref: `topup:${saved.id}`,
meta: { payment_id: saved.id, rider_id: saved.user_id },
});
}
return saved;
return saved;
});
}
}
@@ -17,6 +17,11 @@ function makeService(pendingPayment: any, adapterOverrides: any = {}) {
},
findOne: async ({ where }: any) =>
pendingPayment && where.id === pendingPayment.id ? { ...pendingPayment } : null,
manager: {
transaction: async (fn: any) => fn({
save: async (x: any) => { saved.push({ ...x }); return x; },
}),
},
};
const wallet = { credit: jest.fn().mockResolvedValue({}) };
const tenantWallet = {
@@ -35,7 +35,7 @@ export interface RequestContext {
}
const OTP_TTL_SEC = 300;
const OTP_MAX_ATTEMPTS = 5;
const OTP_MAX_ATTEMPTS = 3;
/**
* سحب أرباح السائق (docs/17 — I4/I5/I7).
@@ -255,7 +255,9 @@ export class PayoutsService {
driverUserId: string,
payoutId: string,
): Promise<string> {
const code = String(randomInt(1000, 10000));
// 3 خانات فقط لتتوافق مع تطبيق فلاتر (100-999) كما طلب المستخدم.
// مع حدّ 3 محاولات وقفل 5 دقائق.
const code = String(randomInt(100, 1000));
await this.redis.set(this.otpKey(payoutId), code, 'EX', OTP_TTL_SEC);
if (this.devMode) {
@@ -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 };