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:
co-authored by
Claude Fable 5
parent
4ca932dd1f
commit
b1a060c5ed
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user