الأساس المالي حسب 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>
144 lines
5.9 KiB
TypeScript
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 });
|
|
});
|
|
});
|