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 }); }); });