feat: P0/P3/P5 — دفترا المستأجر ورسم العملية وتوجيه المال

الأساس المالي حسب docs/24: فصل ما نملكه عمّا نحتفظ به لغيرنا.

- جدولان منفصلان (`tenant_revenue_ledger` · `tenant_pending_ledger`) لا جدول
  واحد بعمود نوع: استعلامٌ ينسى الشرط كان يجعل المالك يسحب من مال الركّاب.
  مضافان فقط، والرصيد مشتقّ لا حقل يُحدَّث.
- فهرس فريد (tenant_id, ref) = حارس التسوية المزدوجة. التصادم يُلتقط من
  القاعدة لا بالفحص المسبق وحده — نداءان متزامنان يمرّان معاً قبل أي كتابة.
- رسم العملية من مصدر واحد (35 ل.س · 5 ج.م · 0.20 د.أ) قابل للتجاوز من إعداد
  المستأجر، مع قصّه عند المبلغ حتى لا يخرج المستخدم بصافٍ سالب.
- توجيه الدفع: شحن السائق ← إيراد + رصيده التشغيلي · شحن الراكب ← أمانة ·
  الرسم ← إيراد. وفُتح مسار شحن السائق الذي لم يكن له مدخل إطلاقاً.
- ثغرة سُدّت: `purpose` يصل من الجسم، فراكب كان يستطيع إرسال `credit_topup`
  فيُسجَّل مالُه إيراداً ويُشحن حساب سائق لا يملكه. الدور الآن من التوكن.
- `/admin/overview`: الإيراد من الدفتر، وعمولة الرحلات حقل منفصل عنه.
- `/admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر.

25 اختباراً جديداً (188 إجمالاً، كلها خضراء). أحدها أمسك عطلاً فعلياً: صافٍ
صفري كان ينادي الدفتر بصفر فيرمي خطأً بعد قيد الرسم — عملية نصف مطبَّقة.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-18 15:04:45 +03:00
co-authored by Claude Fable 5
parent 4ca932dd1f
commit b1a060c5ed
19 changed files with 853 additions and 25 deletions
@@ -0,0 +1,75 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* دفتر مال المستأجر (docs/24). **جدولان منفصلان لا جدول واحد بعمود نوع.**
*
* السبب ليس أناقة: استعلامُ تقريرٍ أو سحبٍ ينسى شرط النوع يخلط **مال المستأجر
* بأمانات الركّاب**، فيسحب المالك من مالٍ ليس له. مع جدولين، الخطأ يصير
* مستحيلاً لا مستبعَداً — لا يوجد صفٌّ خطأ ليُقرأ أصلاً.
*
* الدفتر **مضاف فقط**: كل حركة صفٌّ جديد، والرصيد مجموعٌ مشتق لا حقلٌ يُحدَّث.
* (سيرو خزّن المال في `varchar(10)` وجمع نصوصاً — docs/21.)
*/
export abstract class TenantLedgerBase {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
/** موجب = دخول، سالب = خروج. الإشارة في المبلغ نفسه لا في عمود اتجاه. */
@Column({ type: 'numeric', precision: 14, scale: 3 })
amount: number;
@Column({ default: 'JOD' })
currency: string;
/** سبب الحركة — عليه تُبنى التقارير. راجع `LedgerReason`. */
@Column()
reason: string;
/**
* مرجع العملية الخارجي (معرّف الدفع · مرجع كليك · رسالة المزوّد).
* **مفتاح منع التكرار**: رسالة تأكيد تصل مرتين يجب ألّا تُسوّى مرتين.
* فريدٌ لكل مستأجر؛ و`null` مسموح ومتعدّد (postgres لا يوحّد القيم الفارغة).
*/
@Column({ type: 'varchar', nullable: true })
ref: string | null;
@Column({ type: 'jsonb', default: {} })
meta: Record<string, any>;
@CreateDateColumn()
created_at: Date;
}
/** ما نملكه فعلاً: شحن السائقين · عمولة الرحلات · رسوم العمليات. */
@Entity('tenant_revenue_ledger')
@Index(['tenant_id', 'created_at'])
@Index(['tenant_id', 'ref'], { unique: true })
export class TenantRevenueEntry extends TenantLedgerBase {}
/** ما نحتفظ به لغيرنا: شحن الركّاب وأجرة لم تُسوَّ. التزامٌ لا دخل. */
@Entity('tenant_pending_ledger')
@Index(['tenant_id', 'created_at'])
@Index(['tenant_id', 'ref'], { unique: true })
export class TenantPendingEntry extends TenantLedgerBase {}
/** أسباب الحركة — نصوص ثابتة حتى لا تتفرّق التقارير على صياغات مختلفة. */
export const LedgerReason = {
DRIVER_CREDIT_TOPUP: 'driver_credit_topup', // إيراد: السائق يشحن رصيده التشغيلي
TRIP_COMMISSION: 'trip_commission', // إيراد: عمولة الرحلة
TRANSACTION_FEE: 'transaction_fee', // إيراد: رسم ثابت لكل عملية دفع
RIDER_TOPUP: 'rider_topup', // أمانة: شحن الراكب
TRIP_SETTLEMENT: 'trip_settlement', // أمانة: تسوية أجرة للسائق
REFUND: 'refund', // أمانة: ردّ للراكب
OWNER_WITHDRAWAL: 'owner_withdrawal', // إيراد: سحب المالك أرباحه
} as const;
export type LedgerReasonValue = (typeof LedgerReason)[keyof typeof LedgerReason];
@@ -0,0 +1,42 @@
import { Controller, Get, 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';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
/**
* محفظة المستأجر — يقرأها **أدمن المستأجر** لا السوبر-أدمن (docs/24).
* النطاق من التوكن دائماً: `user.tenantId`، فلا يمكن لأدمن مستأجر قراءة
* أرقام مستأجر آخر بتمرير معرّف في الطلب.
*/
@ApiTags('tenant-wallet')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@Controller('admin/wallet')
export class TenantWalletController {
constructor(private readonly wallet: TenantWalletService) {}
/** الأرباح القابلة للسحب مقابل الأمانات المحتفَظ بها. */
@Get('summary')
summary(@CurrentUser() user: AuthUser, @Query('currency') currency?: string) {
return this.wallet.summary(user.tenantId, currency || 'JOD');
}
/** من أين جاء الإيراد: شحن سائقين · عمولات · رسوم. */
@Get('revenue-by-reason')
byReason(
@CurrentUser() user: AuthUser,
@Query('days') days?: string,
@Query('currency') currency?: string,
) {
const n = Number(days);
return this.wallet.revenueByReason(
user.tenantId,
Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30,
currency || 'JOD',
);
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
TenantRevenueEntry,
TenantPendingEntry,
} from './entities/tenant-ledger.entity';
import { TenantWalletService } from './tenant-wallet.service';
import { TenantWalletController } from './tenant-wallet.controller';
@Module({
imports: [TypeOrmModule.forFeature([TenantRevenueEntry, TenantPendingEntry])],
controllers: [TenantWalletController],
providers: [TenantWalletService],
exports: [TenantWalletService],
})
export class TenantWalletModule {}
@@ -0,0 +1,143 @@
import { randomUUID } from 'crypto';
import { newDb } from 'pg-mem';
import { DataSource } from 'typeorm';
import { BadRequestException } from '@nestjs/common';
import {
TenantRevenueEntry,
TenantPendingEntry,
LedgerReason,
} from './entities/tenant-ledger.entity';
import { TenantWalletService } from './tenant-wallet.service';
const TENANT = '11111111-1111-1111-1111-111111111111';
const OTHER = '33333333-3333-3333-3333-333333333333';
describe('TenantWalletService — محفظتا المستأجر (docs/24)', () => {
let ds: DataSource;
let wallet: TenantWalletService;
beforeEach(async () => {
const db = newDb({ autoCreateForeignKeyIndices: true });
db.public.registerFunction({
name: 'version',
returns: 'text' as any,
implementation: () => 'pg-mem',
});
db.public.registerFunction({
name: 'current_database',
returns: 'text' as any,
implementation: () => 'tripz',
});
db.registerExtension('uuid-ossp', (schema) =>
schema.registerFunction({
name: 'uuid_generate_v4',
returns: 'uuid' as any,
implementation: () => randomUUID(),
impure: true,
}),
);
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
ds = (await db.adapters.createTypeormDataSource({
type: 'postgres',
entities: [TenantRevenueEntry, TenantPendingEntry],
entityPrefix: 'tripz_',
})) as DataSource;
await ds.initialize();
await ds.synchronize();
wallet = new TenantWalletService(
ds.getRepository(TenantRevenueEntry),
ds.getRepository(TenantPendingEntry),
);
});
afterEach(async () => {
if (ds?.isInitialized) await ds.destroy();
});
it('الدفتران منفصلان تماماً — الأمانة لا تظهر في الإيراد', async () => {
await wallet.creditRevenue({
tenantId: TENANT, amount: 100, reason: LedgerReason.DRIVER_CREDIT_TOPUP,
});
await wallet.creditPending({
tenantId: TENANT, amount: 500, reason: LedgerReason.RIDER_TOPUP,
});
const s = await wallet.summary(TENANT);
expect(s.revenue).toBe(100);
expect(s.pending).toBe(500);
// جوهر docs/24: القابل للسحب هو الإيراد وحده، لا 600.
expect(s.withdrawable).toBe(100);
});
it('نفس المرجع لا يُقيَّد مرتين — رسالة تأكيد مكرّرة لا تسوّي مرتين', async () => {
const ref = 'CLIQ-TX-9001';
const first = await wallet.creditRevenue({
tenantId: TENANT, amount: 35, reason: LedgerReason.TRANSACTION_FEE, ref,
});
const second = await wallet.creditRevenue({
tenantId: TENANT, amount: 35, reason: LedgerReason.TRANSACTION_FEE, ref,
});
expect(first.duplicate).toBe(false);
expect(second.duplicate).toBe(true);
expect(second.entry.id).toBe(first.entry.id);
expect(await wallet.balance('revenue', TENANT)).toBe(35);
});
it('نفس المرجع في دفترين مختلفين مسموح — دفتران مستقلان', async () => {
const ref = 'PAY-77';
await wallet.creditPending({ tenantId: TENANT, amount: 500, reason: LedgerReason.RIDER_TOPUP, ref });
const rev = await wallet.creditRevenue({
tenantId: TENANT, amount: 0.2, reason: LedgerReason.TRANSACTION_FEE, ref,
});
expect(rev.duplicate).toBe(false);
});
it('مراجع فارغة متعدّدة مسموحة — لا تتصادم', async () => {
await wallet.creditRevenue({ tenantId: TENANT, amount: 5, reason: LedgerReason.TRIP_COMMISSION });
await wallet.creditRevenue({ tenantId: TENANT, amount: 7, reason: LedgerReason.TRIP_COMMISSION });
expect(await wallet.balance('revenue', TENANT)).toBe(12);
});
it('الإفراج عن أمانة يُقيَّد سالباً مهما كانت إشارة المدخل', async () => {
await wallet.creditPending({ tenantId: TENANT, amount: 100, reason: LedgerReason.RIDER_TOPUP });
await wallet.releasePending({
tenantId: TENANT, amount: 30, reason: LedgerReason.TRIP_SETTLEMENT,
});
expect(await wallet.balance('pending', TENANT)).toBe(70);
});
it('لا يخلط مستأجرين', async () => {
await wallet.creditRevenue({ tenantId: TENANT, amount: 100, reason: LedgerReason.TRIP_COMMISSION });
await wallet.creditRevenue({ tenantId: OTHER, amount: 999, reason: LedgerReason.TRIP_COMMISSION });
expect(await wallet.balance('revenue', TENANT)).toBe(100);
});
it('لا يخلط العملات', async () => {
await wallet.creditRevenue({ tenantId: TENANT, amount: 100, reason: LedgerReason.TRIP_COMMISSION, currency: 'JOD' });
await wallet.creditRevenue({ tenantId: TENANT, amount: 5000, reason: LedgerReason.TRIP_COMMISSION, currency: 'SYP' });
expect(await wallet.balance('revenue', TENANT, 'JOD')).toBe(100);
expect(await wallet.balance('revenue', TENANT, 'SYP')).toBe(5000);
});
it('يرفض المبلغ صفراً أو غير الرقمي — قيدٌ بلا أثر يلوّث الدفتر', async () => {
await expect(
wallet.creditRevenue({ tenantId: TENANT, amount: 0, reason: 'x' }),
).rejects.toThrow(BadRequestException);
await expect(
wallet.creditRevenue({ tenantId: TENANT, amount: NaN, reason: 'x' }),
).rejects.toThrow(BadRequestException);
});
it('التفصيل حسب السبب يجمع كل سبب على حدة', async () => {
await wallet.creditRevenue({ tenantId: TENANT, amount: 100, reason: LedgerReason.DRIVER_CREDIT_TOPUP });
await wallet.creditRevenue({ tenantId: TENANT, amount: 50, reason: LedgerReason.DRIVER_CREDIT_TOPUP });
await wallet.creditRevenue({ tenantId: TENANT, amount: 0.2, reason: LedgerReason.TRANSACTION_FEE });
const rows = await wallet.revenueByReason(TENANT);
const topups = rows.find((r) => r.reason === LedgerReason.DRIVER_CREDIT_TOPUP);
expect(topups).toEqual({ reason: LedgerReason.DRIVER_CREDIT_TOPUP, count: 2, total: 150 });
});
});
@@ -0,0 +1,158 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, EntityManager } from 'typeorm';
import {
TenantRevenueEntry,
TenantPendingEntry,
TenantLedgerBase,
LedgerReason,
} from './entities/tenant-ledger.entity';
/** أي الدفترين — يُمرَّر صراحةً في كل نداء فلا يوجد افتراض صامت. */
export type Book = 'revenue' | 'pending';
export interface PostEntry {
tenantId: string;
amount: number; // موجب = دخول، سالب = خروج
reason: string;
currency?: string;
ref?: string | null;
meta?: Record<string, any>;
}
/**
* محفظتا المستأجر (docs/24 — P0).
*
* `revenue` = مالُ المستأجر فعلاً · `pending` = أماناتٌ يحتفظ بها لغيره.
* لا دالة واحدة تكتب في الدفترين معاً بلا تحديد صريح — الخلط بينهما هو
* الخطأ الذي يجعل تقرير الأرباح كاذباً والسحب من مال الركّاب ممكناً.
*/
@Injectable()
export class TenantWalletService {
private readonly logger = new Logger('TenantWallet');
constructor(
@InjectRepository(TenantRevenueEntry)
private readonly revenue: Repository<TenantRevenueEntry>,
@InjectRepository(TenantPendingEntry)
private readonly pending: Repository<TenantPendingEntry>,
) {}
/**
* الجدولان متطابقا الشكل ويرثان `TenantLedgerBase`، فنوحّد نوعهما عليه.
* الفصل الحقيقي بينهما في القاعدة (جدولان)، لا في نوع TypeScript.
*/
private repoFor(book: Book): Repository<TenantLedgerBase> {
return (book === 'revenue' ? this.revenue : this.pending) as unknown as Repository<TenantLedgerBase>;
}
/**
* يقيّد حركة. **آمن ضد التكرار عبر `ref`**: نداءٌ ثانٍ بنفس المرجع لا يقيّد
* شيئاً ويعيد القيد الأول.
*
* الفحص المسبق وحده لا يكفي: رسالتان متزامنتان تمرّان معاً قبل أن يكتب
* أيّهما. لذلك نعتمد على **فهرس التفرّد في القاعدة** ونلتقط تصادمه —
* القاعدة هي الحَكَم، والفحص المسبق مجرّد اختصار.
*/
async post(book: Book, e: PostEntry, em?: EntityManager) {
const amount = Number(e.amount);
if (!Number.isFinite(amount) || amount === 0) {
throw new BadRequestException('amount must be a non-zero number');
}
if (!e.reason) throw new BadRequestException('reason is required');
const repo = (
em ? em.getRepository(this.entityFor(book)) : this.repoFor(book)
) as Repository<TenantLedgerBase>;
const ref = e.ref ?? null;
if (ref) {
const existing = await repo.findOne({ where: { tenant_id: e.tenantId, ref } });
if (existing) {
this.logger.warn(`قيد مكرَّر تُجوهل: book=${book} ref=${ref}`);
return { entry: existing, duplicate: true };
}
}
try {
const entry = await repo.save(
repo.create({
tenant_id: e.tenantId,
amount,
currency: e.currency ?? 'JOD',
reason: e.reason,
ref,
meta: e.meta ?? {},
}),
);
return { entry, duplicate: false };
} catch (err: any) {
// 23505 = انتهاك تفرّد: سبقنا نداءٌ متزامن بنفس المرجع. هذا نجاحٌ لا فشل.
if (ref && (err?.code === '23505' || /duplicate|unique/i.test(err?.message ?? ''))) {
const existing = await repo.findOne({ where: { tenant_id: e.tenantId, ref } });
if (existing) return { entry: existing, duplicate: true };
}
throw err;
}
}
private entityFor(book: Book) {
return book === 'revenue' ? TenantRevenueEntry : TenantPendingEntry;
}
/** رصيد الدفتر = مجموع حركاته. مشتقّ دائماً، لا حقل يُحدَّث. */
async balance(book: Book, tenantId: string, currency = 'JOD'): Promise<number> {
const row = await this.repoFor(book)
.createQueryBuilder('l')
.select('COALESCE(SUM(l.amount), 0)', 'total')
.where('l.tenant_id = :tenantId AND l.currency = :currency', { tenantId, currency })
.getRawOne();
return Number(row?.total ?? 0);
}
/**
* ملخّص المستأجر المالي. `revenue` وحده هو القابل للسحب؛ `pending` يُعرض
* ليعرف المالك حجم التزامه، لا ليضيفه إلى أرباحه.
*/
async summary(tenantId: string, currency = 'JOD') {
const [revenue, pending] = await Promise.all([
this.balance('revenue', tenantId, currency),
this.balance('pending', tenantId, currency),
]);
return { currency, revenue, pending, withdrawable: revenue };
}
/** تفصيل الإيراد حسب السبب — يجيب «من أين جاء المال؟». */
async revenueByReason(tenantId: string, days = 30, currency = 'JOD') {
const since = new Date(Date.now() - days * 86400_000);
const rows = await this.revenue
.createQueryBuilder('l')
.select('l.reason', 'reason')
.addSelect('COUNT(*)', 'count')
.addSelect('COALESCE(SUM(l.amount), 0)', 'total')
.where('l.tenant_id = :tenantId AND l.currency = :currency', { tenantId, currency })
.andWhere('l.created_at >= :since', { since })
.groupBy('l.reason')
.getRawMany();
return rows.map((r: any) => ({
reason: r.reason,
count: Number(r.count),
total: Number(r.total),
}));
}
/** اختصارات مقروءة عند نقطة الاستدعاء — تمنع تمرير الدفتر الخطأ سهواً. */
creditRevenue(e: PostEntry, em?: EntityManager) {
return this.post('revenue', e, em);
}
creditPending(e: PostEntry, em?: EntityManager) {
return this.post('pending', e, em);
}
/** خروج من الأمانات (تسوية للسائق أو ردّ للراكب) — المبلغ يُقيَّد سالباً. */
releasePending(e: PostEntry, em?: EntityManager) {
return this.post('pending', { ...e, amount: -Math.abs(Number(e.amount)) }, em);
}
static readonly Reason = LedgerReason;
}
@@ -0,0 +1,52 @@
import { transactionFeeFor, cappedFee, DEFAULT_TRANSACTION_FEE } from './transaction-fee';
const tenant = (countryPack: string, settings: any = {}) =>
({ countryPack, settings }) as any;
describe('transactionFeeFor — رسم العملية (docs/24 §3.3)', () => {
it('يطبّق افتراض الدولة حين لا إعداد', () => {
expect(transactionFeeFor(tenant('sy'))).toEqual({ fee: 35, currency: 'SYP' });
expect(transactionFeeFor(tenant('eg'))).toEqual({ fee: 5, currency: 'EGP' });
expect(transactionFeeFor(tenant('jo'))).toEqual({ fee: 0.2, currency: 'JOD' });
});
it('إعداد المستأجر يتقدّم على افتراض دولته', () => {
const t = tenant('sy', { payments: { transaction_fee: 50 } });
expect(transactionFeeFor(t).fee).toBe(50);
});
it('رسم صفر إعدادٌ صالح — لا يعود إلى افتراض الدولة', () => {
// الفخّ: `configured || byCountry` كان سيبتلع الصفر ويقتطع 35 من مستأجر
// اتّفقنا معه على ألّا رسوم عليه.
const t = tenant('sy', { payments: { transaction_fee: 0 } });
expect(transactionFeeFor(t).fee).toBe(0);
});
it('يتجاهل الإعداد غير الصالح ويعود للافتراض', () => {
expect(transactionFeeFor(tenant('jo', { payments: { transaction_fee: 'كثير' } })).fee).toBe(0.2);
expect(transactionFeeFor(tenant('jo', { payments: { transaction_fee: -5 } })).fee).toBe(0.2);
});
it('دولة غير معروفة = بلا رسم، لا رقم مخمَّن', () => {
expect(transactionFeeFor(tenant('xx')).fee).toBe(0);
});
it('الرقم مصدره واحد — لا يُكتب في منطق الأعمال (درس سيرو)', () => {
expect(DEFAULT_TRANSACTION_FEE.sy.fee).toBe(35);
});
});
describe('cappedFee — الرسم لا يبتلع المبلغ', () => {
it('يقصّ الرسم عند مبلغ أصغر منه', () => {
// شحن 20 ل.س ورسمه 35: بلا القصّ يخرج المستخدم بصافٍ سالب.
expect(cappedFee(35, 20)).toBe(20);
});
it('يمرّ الرسم كاملاً حين المبلغ أكبر', () => {
expect(cappedFee(35, 1000)).toBe(35);
});
it('بلا رسم يعيد صفراً', () => {
expect(cappedFee(0, 100)).toBe(0);
});
});
@@ -0,0 +1,50 @@
import { Tenant } from '../../database/entities/tenant.entity';
/**
* رسم ثابت على كل عملية دفع → إيراد المستأجر (docs/24 §3.3).
*
* **مصدر واحد للرقم.** في سيرو كُتب رسم السحب (3500) في موضعين: الفحص يشترط
* `amount + 3500` والتسوية تخصم `amount − 3500` — فتناقضا وتسرّب المال في كل
* عملية (docs/21). لذلك لا يُكتب أي رسم في منطق الأعمال إطلاقاً؛ يُقرأ من هنا.
*/
/** الافتراضات حسب دولة المستأجر. تُتجاوَز من `tenant.settings.payments`. */
export const DEFAULT_TRANSACTION_FEE: Record<string, { fee: number; currency: string }> = {
sy: { fee: 35, currency: 'SYP' }, // بالليرة الجديدة (بعد حذف صفرين)
eg: { fee: 5, currency: 'EGP' },
jo: { fee: 0.2, currency: 'JOD' }, // 20 قرشاً
};
export const FALLBACK_FEE = { fee: 0, currency: 'JOD' };
/**
* رسم العملية الفعلي للمستأجر.
*
* الأولوية: إعداد المستأجر ← افتراض دولته ← صفر. الافتراض الأخير **صفر لا
* رقمٌ مخمَّن**: أن نعجز عن معرفة الرسم فنقتطع مبلغاً عشوائياً من مستخدم
* حقيقي أسوأ من ألّا نقتطع شيئاً ونكتشف الإعداد الناقص من التقارير.
*/
export function transactionFeeFor(tenant: Pick<Tenant, 'countryPack' | 'settings'>): {
fee: number;
currency: string;
} {
const configured = tenant?.settings?.payments;
const byCountry = DEFAULT_TRANSACTION_FEE[tenant?.countryPack ?? ''] ?? FALLBACK_FEE;
const fee = Number(configured?.transaction_fee);
return {
// `Number.isFinite` لا `??`: قيمة `0` صالحة تماماً (مستأجر بلا رسوم)،
// و`||` كان سيبتلعها ويعيدها إلى افتراض الدولة.
fee: Number.isFinite(fee) && fee >= 0 ? fee : byCountry.fee,
currency: configured?.currency ?? byCountry.currency,
};
}
/**
* الرسم لا يتجاوز المبلغ نفسه. بلا هذا، شحنٌ صغير في سوريا (20 ل.س ورسمه 35)
* يُنتج صافياً سالباً — أي أن المستخدم يدفع ويخرج برصيد أقل مما بدأ به.
*/
export function cappedFee(fee: number, amount: number): number {
if (!(fee > 0)) return 0;
return Math.min(fee, Math.max(0, Number(amount)));
}