From d239f51d5d50013f4ce13bd5672dade2d5d5f288 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 18 Jul 2026 17:26:16 +0300 Subject: [PATCH] feat: implement billing module for tenant subscription management and super-admin payment processing --- backend/src/app.module.ts | 2 + backend/src/main.ts | 11 ++ backend/src/modules/billing/billing.module.ts | 22 +++ .../src/modules/billing/billing.service.ts | 183 ++++++++++++++++++ .../entities/superadmin-payment.entity.ts | 37 ++++ .../entities/tenant-subscription.entity.ts | 25 +++ .../billing/superadmin-billing.controller.ts | 22 +++ .../billing/superadmin-webhook.controller.ts | 28 +++ .../billing/tenant-billing.controller.ts | 48 +++++ .../modules/payments/payments.controller.ts | 2 + .../modules/payments/payments.routing.spec.ts | 5 + .../src/modules/payments/payments.service.ts | 142 ++++++++------ .../modules/payments/payments.webhook.spec.ts | 5 + .../src/modules/payments/payouts.service.ts | 6 +- .../payments/sms-settlement.service.ts | 52 ++++- .../tenant-wallet/tenant-wallet.controller.ts | 33 +++- .../tenant-wallet/tenant-wallet.service.ts | 37 ++++ 17 files changed, 593 insertions(+), 67 deletions(-) create mode 100644 backend/src/modules/billing/billing.module.ts create mode 100644 backend/src/modules/billing/billing.service.ts create mode 100644 backend/src/modules/billing/entities/superadmin-payment.entity.ts create mode 100644 backend/src/modules/billing/entities/tenant-subscription.entity.ts create mode 100644 backend/src/modules/billing/superadmin-billing.controller.ts create mode 100644 backend/src/modules/billing/superadmin-webhook.controller.ts create mode 100644 backend/src/modules/billing/tenant-billing.controller.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index dbd266d..7ae000f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -42,6 +42,7 @@ import { StorageModule } from './common/storage/storage.module'; import { DocumentsModule } from './modules/documents/documents.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { GeminiModule } from './integrations/gemini/gemini.module'; +import { BillingModule } from './modules/billing/billing.module'; @Module({ imports: [ @@ -98,6 +99,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module'; TenantWalletModule, // دفترا المستأجر: إيراد وأمانات (docs/24) CreditModule, PaymentsModule, + BillingModule, TripsModule, DispatchModule, ChatModule, diff --git a/backend/src/main.ts b/backend/src/main.ts index c499a92..fe61377 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -73,6 +73,17 @@ async function bootstrap() { Logger.warn('PLATFORM_SECRET غير مضبوط — كل نقاط السوبر-أدمن مغلقة.', 'Security'); } if (cfg.get('auth.otpDevMode') === true) { + // T6: في الإنتاج otpDevMode يسرّب رمز السحب في الاستجابة ويجعل رمز الدخول + // ثابتاً (1234). نُسيانه مفعَّلاً عند النشر يعني سحباً بلا واتساب ودخولاً + // بلا رمز حقيقي. لذلك ننهي التشغيل لا نحذّر فقط. + const nodeEnv = process.env.NODE_ENV ?? ''; + if (nodeEnv === 'production') { + Logger.error( + '⛔ OTP_DEV_MODE=true في الإنتاج — لا يمكن الإقلاع. أطفئ OTP_DEV_MODE أو غيّر NODE_ENV.', + 'Security', + ); + process.exit(1); + } Logger.warn( 'OTP_DEV_MODE=true — رمز الدخول ثابت (1234) ولا تُرسَل رسائل، ورمز السحب كذلك. أطفئه في الإنتاج.', 'Security', diff --git a/backend/src/modules/billing/billing.module.ts b/backend/src/modules/billing/billing.module.ts new file mode 100644 index 0000000..d1d2c74 --- /dev/null +++ b/backend/src/modules/billing/billing.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TenantSubscription } from './entities/tenant-subscription.entity'; +import { SuperAdminPayment } from './entities/superadmin-payment.entity'; +import { BillingService } from './billing.service'; +import { TenantBillingController } from './tenant-billing.controller'; +import { SuperAdminBillingController } from './superadmin-billing.controller'; +import { SuperAdminWebhookController } from './superadmin-webhook.controller'; +import { PaymentsModule } from '../payments/payments.module'; +import { StorageModule } from '../../common/storage/storage.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([TenantSubscription, SuperAdminPayment]), + PaymentsModule, + StorageModule, + ], + controllers: [TenantBillingController, SuperAdminBillingController, SuperAdminWebhookController], + providers: [BillingService], + exports: [BillingService], +}) +export class BillingModule {} diff --git a/backend/src/modules/billing/billing.service.ts b/backend/src/modules/billing/billing.service.ts new file mode 100644 index 0000000..4aef657 --- /dev/null +++ b/backend/src/modules/billing/billing.service.ts @@ -0,0 +1,183 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TenantSubscription } from './entities/tenant-subscription.entity'; +import { SuperAdminPayment } from './entities/superadmin-payment.entity'; +import { StorageService } from '../../common/storage/storage.service'; +import { PaymentGatewayRegistry } from '../../integrations/payments/payment-gateway.registry'; + +@Injectable() +export class BillingService { + private readonly logger = new Logger('BillingService'); + + constructor( + @InjectRepository(TenantSubscription) + private readonly subscriptions: Repository, + @InjectRepository(SuperAdminPayment) + private readonly payments: Repository, + private readonly storage: StorageService, + private readonly gateways: PaymentGatewayRegistry, + ) {} + + async getSubscription(tenantId: string): Promise { + return this.subscriptions.findOne({ where: { tenant_id: tenantId } }); + } + + async createSubscriptionPayment( + tenantId: string, + provider: string, + amount: number, + currency: string = 'JOD', + tenantSettings?: any, + ) { + if (!['paymob', 'cliq'].includes(provider)) { + throw new BadRequestException('Provider not supported for online subscription payment'); + } + + const adapter = this.gateways.get(provider); + + // Reuse pending payment if exists to prevent invoice flood + let payment = await this.payments.findOne({ + where: { tenant_id: tenantId, provider, status: 'pending' }, + order: { created_at: 'DESC' }, + }); + + if (payment) { + payment.amount = amount; + payment.currency = currency; + payment.updated_at = new Date(); + payment = await this.payments.save(payment); + } else { + payment = await this.payments.save( + this.payments.create({ + tenant_id: tenantId, + provider, + amount, + currency, + status: 'pending', + }), + ); + } + + const result = await adapter.charge( + { paymentId: payment.id, amount, currency }, + tenantSettings, + ); + + if (result.mode === 'redirect') { + payment.tx_ref = result.providerRef; + payment.meta = { redirect_url: result.redirectUrl }; + await this.payments.save(payment); + return { paymentId: payment.id, redirectUrl: result.redirectUrl }; + } + + if (result.mode === 'invoice') { + payment.tx_ref = result.reference; + payment.meta = { + transfer_target: result.transferTarget, + instructions: result.instructions, + }; + await this.payments.save(payment); + return { + paymentId: payment.id, + reference: result.reference, + transferTarget: result.transferTarget, + instructions: result.instructions, + }; + } + + throw new BadRequestException('Unexpected charge mode'); + } + + async submitBankTransfer(tenantId: string, amount: number, currency: string, imageFile: Express.Multer.File) { + if (!imageFile) throw new BadRequestException('Receipt image is required'); + + const imageUrl = await this.storage.upload(imageFile, `transfers/${tenantId}`); + + const payment = await this.payments.save( + this.payments.create({ + tenant_id: tenantId, + provider: 'bank', + amount, + currency, + status: 'needs_review', + receipt_image: imageUrl, + }), + ); + + return payment; + } + + async getPendingTransfers() { + return this.payments.find({ + where: { status: 'needs_review' }, + order: { created_at: 'ASC' }, + }); + } + + async processPaymobWebhook(headers: any, body: any, superAdminSettings: any) { + const adapter = this.gateways.get('paymob'); + + // Verify HMAC signature + if (!adapter.verifyWebhook(headers, body, superAdminSettings)) { + this.logger.warn('Invalid PayMob webhook signature for superadmin payment'); + throw new BadRequestException('Invalid signature'); + } + + const parsed = adapter.parseWebhook(body); + if (!parsed) return { processed: false }; + + // Find the pending superadmin payment + const payment = await this.payments.findOne({ + where: { tx_ref: parsed.providerRef, provider: 'paymob', status: 'pending' }, + }); + + if (!payment) { + this.logger.warn(`No pending superadmin payment found for ref ${parsed.providerRef}`); + return { processed: false }; + } + + if (parsed.success) { + // Approve and extend subscription + await this.approvePayment(payment.id); + return { processed: true, paymentId: payment.id, status: 'success' }; + } else { + payment.status = 'failed'; + await this.payments.save(payment); + return { processed: true, paymentId: payment.id, status: 'failed' }; + } + } + + async approvePayment(paymentId: string) { + const payment = await this.payments.findOne({ where: { id: paymentId } }); + if (!payment) throw new NotFoundException('Payment not found'); + if (payment.status !== 'needs_review' && payment.status !== 'pending') { + throw new BadRequestException('Payment is not pending or in review'); + } + + return this.payments.manager.transaction(async (em) => { + payment.status = 'success'; + await em.save(payment); + + let sub = await em.findOne(TenantSubscription, { where: { tenant_id: payment.tenant_id } }); + if (!sub) { + sub = em.create(TenantSubscription, { + tenant_id: payment.tenant_id, + plan_name: 'standard', + status: 'active', + valid_until: new Date(), + }); + } + + // Extend subscription by 30 days + const currentValid = sub.valid_until && sub.valid_until > new Date() ? sub.valid_until : new Date(); + currentValid.setDate(currentValid.getDate() + 30); + sub.valid_until = currentValid; + sub.status = 'active'; + + await em.save(sub); + + return { payment, subscription: sub }; + }); + } +} diff --git a/backend/src/modules/billing/entities/superadmin-payment.entity.ts b/backend/src/modules/billing/entities/superadmin-payment.entity.ts new file mode 100644 index 0000000..f44fdd0 --- /dev/null +++ b/backend/src/modules/billing/entities/superadmin-payment.entity.ts @@ -0,0 +1,37 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('superadmin_payments') +export class SuperAdminPayment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'numeric', precision: 12, scale: 3 }) + amount: number; + + @Column({ default: 'JOD' }) + currency: string; + + @Column({ type: 'varchar' }) + provider: string; // paymob | cliq | bank + + @Column({ type: 'varchar', default: 'pending' }) // pending, success, failed, needs_review + status: string; + + @Column({ type: 'varchar', nullable: true }) + receipt_image: string | null; // for bank transfers + + @Column({ type: 'varchar', nullable: true }) + tx_ref: string | null; + + @Column({ type: 'jsonb', default: {} }) + meta: Record; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/billing/entities/tenant-subscription.entity.ts b/backend/src/modules/billing/entities/tenant-subscription.entity.ts new file mode 100644 index 0000000..a3e4ad2 --- /dev/null +++ b/backend/src/modules/billing/entities/tenant-subscription.entity.ts @@ -0,0 +1,25 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('tenant_subscriptions') +export class TenantSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', unique: true }) + tenant_id: string; + + @Column({ type: 'varchar' }) + plan_name: string; + + @Column({ type: 'timestamptz', nullable: true }) + valid_until: Date | null; + + @Column({ type: 'varchar', default: 'active' }) // active, expired, suspended + status: string; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/billing/superadmin-billing.controller.ts b/backend/src/modules/billing/superadmin-billing.controller.ts new file mode 100644 index 0000000..bbbce89 --- /dev/null +++ b/backend/src/modules/billing/superadmin-billing.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { BillingService } from './billing.service'; +import { PlatformGuard } from '../../common/platform/platform.guard'; + +@ApiTags('superadmin-billing') +@ApiSecurity('platform-secret') +@UseGuards(PlatformGuard) +@Controller('superadmin/billing') +export class SuperAdminBillingController { + constructor(private readonly billing: BillingService) {} + + @Get('pending-transfers') + getPendingTransfers() { + return this.billing.getPendingTransfers(); + } + + @Post(':paymentId/approve') + approvePayment(@Param('paymentId') paymentId: string) { + return this.billing.approvePayment(paymentId); + } +} diff --git a/backend/src/modules/billing/superadmin-webhook.controller.ts b/backend/src/modules/billing/superadmin-webhook.controller.ts new file mode 100644 index 0000000..25030fe --- /dev/null +++ b/backend/src/modules/billing/superadmin-webhook.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Headers, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import { BillingService } from './billing.service'; + +@ApiTags('superadmin-webhook') +@Controller('superadmin/billing/webhook') +export class SuperAdminWebhookController { + constructor( + private readonly billing: BillingService, + private readonly config: ConfigService, + ) {} + + @Post('paymob') + async paymobWebhook(@Headers() headers: any, @Body() body: any) { + // For the super admin, we expect PayMob settings to be configured globally + // or loaded from an env variable (e.g., config.get('superadmin.paymob')) + // In this basic implementation, we pass empty config if none found + const superAdminSettings = { + paymob: { + hmac_secret: this.config.get('PAYMOB_HMAC_SECRET'), + }, + }; + + await this.billing.processPaymobWebhook(headers, body, superAdminSettings); + return { success: true }; + } +} diff --git a/backend/src/modules/billing/tenant-billing.controller.ts b/backend/src/modules/billing/tenant-billing.controller.ts new file mode 100644 index 0000000..5c6074c --- /dev/null +++ b/backend/src/modules/billing/tenant-billing.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { BillingService } from './billing.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; + +@ApiTags('tenant-billing') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('owner', 'admin') // Only tenant owners/admins can manage billing +@Controller('tenant/billing') +export class TenantBillingController { + constructor(private readonly billing: BillingService) {} + + @Get('subscription') + getSubscription(@CurrentUser() user: AuthUser) { + return this.billing.getSubscription(user.tenantId); + } + + @Post('subscribe') + subscribe( + @CurrentUser() user: AuthUser, + @Body() body: { provider: string; amount: number; currency?: string }, + ) { + // Note: tenantSettings should ideally be resolved here via TenantsService. + // For simplicity we pass empty settings, but the real integration will need it if PayMob is used. + return this.billing.createSubscriptionPayment( + user.tenantId, + body.provider, + body.amount, + body.currency || 'JOD', + {} as any, + ); + } + + @Post('bank-transfer') + @UseInterceptors(FileInterceptor('receipt')) + submitTransfer( + @CurrentUser() user: AuthUser, + @Body() body: { amount: number; currency?: string }, + @UploadedFile() receipt: Express.Multer.File, + ) { + return this.billing.submitBankTransfer(user.tenantId, body.amount, body.currency || 'JOD', receipt); + } +} diff --git a/backend/src/modules/payments/payments.controller.ts b/backend/src/modules/payments/payments.controller.ts index a294713..b4573b4 100644 --- a/backend/src/modules/payments/payments.controller.ts +++ b/backend/src/modules/payments/payments.controller.ts @@ -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, { diff --git a/backend/src/modules/payments/payments.routing.spec.ts b/backend/src/modules/payments/payments.routing.spec.ts index 2bf1165..88e6702 100644 --- a/backend/src/modules/payments/payments.routing.spec.ts +++ b/backend/src/modules/payments/payments.routing.spec.ts @@ -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({}) }; diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts index cf1ed02..3a0441a 100644 --- a/backend/src/modules/payments/payments.service.ts +++ b/backend/src/modules/payments/payments.service.ts @@ -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.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; + }); } } diff --git a/backend/src/modules/payments/payments.webhook.spec.ts b/backend/src/modules/payments/payments.webhook.spec.ts index 69d4a4b..7f4b29b 100644 --- a/backend/src/modules/payments/payments.webhook.spec.ts +++ b/backend/src/modules/payments/payments.webhook.spec.ts @@ -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 = { diff --git a/backend/src/modules/payments/payouts.service.ts b/backend/src/modules/payments/payouts.service.ts index e3afc8e..f9e757f 100644 --- a/backend/src/modules/payments/payouts.service.ts +++ b/backend/src/modules/payments/payouts.service.ts @@ -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 { - 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) { diff --git a/backend/src/modules/payments/sms-settlement.service.ts b/backend/src/modules/payments/sms-settlement.service.ts index 83f549d..b59d5fe 100644 --- a/backend/src/modules/payments/sms-settlement.service.ts +++ b/backend/src/modules/payments/sms-settlement.service.ts @@ -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 | 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 }; diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts b/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts index 2b7e493..e0a72fd 100644 --- a/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts +++ b/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { TenantWalletService } from './tenant-wallet.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -39,4 +39,35 @@ export class TenantWalletController { currency || 'JOD', ); } + + /** كشف حساب تفصيلي للدفتر. book يمكن أن يكون 'revenue' أو 'pending' */ + @Get('statement') + statement( + @CurrentUser() user: AuthUser, + @Query('book') book: 'revenue' | 'pending', + @Query('currency') currency?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.wallet.statement( + book || 'revenue', + user.tenantId, + currency || 'JOD', + limit ? parseInt(limit, 10) : 100, + offset ? parseInt(offset, 10) : 0, + ); + } + + /** سحب أرباح المستأجر (من دفتر الإيرادات حصراً) */ + @Post('withdraw') + withdraw( + @CurrentUser() user: AuthUser, + @Body() body: { amount: number; currency?: string }, + ) { + return this.wallet.withdraw( + user.tenantId, + Number(body.amount), + body.currency || 'JOD', + ); + } } diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.service.ts b/backend/src/modules/tenant-wallet/tenant-wallet.service.ts index 783cfbb..b48f1f4 100644 --- a/backend/src/modules/tenant-wallet/tenant-wallet.service.ts +++ b/backend/src/modules/tenant-wallet/tenant-wallet.service.ts @@ -142,6 +142,43 @@ export class TenantWalletService { })); } + /** + * كشف الحساب التفصيلي للدفتر. + */ + async statement(book: Book, tenantId: string, currency = 'JOD', limit = 100, offset = 0) { + const repo = this.repoFor(book); + const [rows, total] = await repo.findAndCount({ + where: { tenant_id: tenantId, currency } as any, + order: { created_at: 'DESC' }, + take: limit, + skip: offset, + }); + return { data: rows, total, limit, offset }; + } + + /** + * سحب أرباح المستأجر (من دفتر الإيرادات حصراً). + */ + async withdraw(tenantId: string, amount: number, currency = 'JOD') { + if (amount <= 0) throw new BadRequestException('Amount must be positive'); + + const currentBalance = await this.balance('revenue', tenantId, currency); + if (currentBalance < amount) { + throw new BadRequestException('Insufficient revenue balance'); + } + + const ref = `PAYOUT-${Date.now()}`; + const entry = await this.post('revenue', { + tenantId, + amount: -amount, // سالب = سحب + currency, + reason: 'payout', // Should exist or fall under general + ref, + }); + + return { success: true, ref, withdrawn: amount, remaining: currentBalance - amount }; + } + /** اختصارات مقروءة عند نقطة الاستدعاء — تمنع تمرير الدفتر الخطأ سهواً. */ creditRevenue(e: PostEntry, em?: EntityManager) { return this.post('revenue', e, em);