Files
tripz-llc/backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts
T
Hamza-AyedandClaude Fable 5 b1a060c5ed 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>
2026-07-18 15:04:45 +03:00

144 lines
5.9 KiB
TypeScript

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