feat: implement billing module for tenant subscription management and super-admin payment processing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -73,6 +73,17 @@ async function bootstrap() {
|
||||
Logger.warn('PLATFORM_SECRET غير مضبوط — كل نقاط السوبر-أدمن مغلقة.', 'Security');
|
||||
}
|
||||
if (cfg.get<boolean>('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',
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<TenantSubscription>,
|
||||
@InjectRepository(SuperAdminPayment)
|
||||
private readonly payments: Repository<SuperAdminPayment>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly gateways: PaymentGatewayRegistry,
|
||||
) {}
|
||||
|
||||
async getSubscription(tenantId: string): Promise<TenantSubscription | null> {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string>('PAYMOB_HMAC_SECRET'),
|
||||
},
|
||||
};
|
||||
|
||||
await this.billing.processPaymobWebhook(headers, body, superAdminSettings);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user