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
+2
View File
@@ -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,
+11
View File
@@ -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,7 +71,31 @@ export class PaymentsService {
const tenant = await this.tenants.resolve(tenantId);
if (!tenant) throw new NotFoundException('tenant not found');
let payment = await this.repo.save(
// T2: إعادة استعمال فاتورة معلّقة بنفس المزوّد والنية بدل إنشاء واحدة جديدة.
// بلا هذا: (1) المستخدم يُغرق القاعدة بفواتير، (2) فاتورتان بنفس المبلغ
// تجعلان مطابقة SMS مستحيلة (findMatch يشترط فاتورة واحدة)، (3) طابور
// المراجعة يمتلئ بفواتير متروكة. هذا نفس ما كان في سيرو (update لا create).
let payment = await this.repo.findOne({
where: {
tenant_id: tenantId,
user_id: dto.userId,
provider: dto.provider,
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,
@@ -84,6 +108,7 @@ export class PaymentsService {
status: 'pending',
}),
);
}
const adapter = this.gateways.get(dto.provider);
const result = await adapter.charge(
@@ -194,11 +219,15 @@ export class PaymentsService {
*
* كل قيد يحمل `ref` مشتقّاً من معرّف الدفع، فإعادة تسليم نفس الـwebhook
* لا تقيّد مرتين (الدفتر يرفضها بفهرس التفرّد).
*
* T7: الكتابات في **معاملة واحدة** (كانت بلا transaction كما S4 في سيرو).
* فشلٌ في المنتصف لم يعد يترك دفعة `success` بلا قيد في الدفتر.
*/
private async markSuccess(payment: Payment): Promise<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 this.repo.save(payment);
const saved = await em.save(payment);
const gross = Number(saved.amount);
const tenant = await this.tenants.resolve(saved.tenant_id);
@@ -248,5 +277,6 @@ export class PaymentsService {
}
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,12 +125,15 @@ 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,
}),
);
// التحليل والمطابقة لا يُفشلان الاستقبال: الجهاز تلقّى «حُفظت» بالفعل،
// وأي خطأ هنا يترك الرسالة في الطابور بدل أن يدفع الجهاز لإعادة الإرسال.
// مرسل غير معتمد → يُحفظ لكن لا يُعالج آلياً (طابور مراجعة).
if (!untrustedSender) {
try {
row = await this.process(row);
} catch (e: any) {
@@ -107,6 +142,7 @@ export class SmsSettlementService {
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);