feat: implement billing module for tenant subscription management and super-admin payment processing
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user