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
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { Trip } from '../trips/entities/trip.entity';
import { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.entity';
import { TenantsService } from './tenants.service';
import { TenantsController } from './tenants.controller';
@Module({
// Trip هنا للقراءة فقط — تقرير GMV للسوبر-أدمن (docs/22 — N1).
imports: [TypeOrmModule.forFeature([Tenant, Trip])],
// Trip ودفتر الإيراد هنا **للقراءة فقط** — تقرير GMV والإيراد للسوبر-أدمن
// (docs/22 — N1، docs/24 — P5). الكتابة في الدفتر تخصّ TenantWalletService وحده.
imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry])],
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
+23 -5
View File
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant, TenantPlan } from '../../database/entities/tenant.entity';
import { Trip } from '../trips/entities/trip.entity';
import { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.entity';
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
import { EntitlementsService } from '../../common/entitlements/entitlements.service';
import { defaultPaymentMethods, validPaymentMethods } from '../../common/entitlements/payment-methods';
@@ -25,6 +26,8 @@ export class TenantsService {
private readonly repo: Repository<Tenant>,
@InjectRepository(Trip)
private readonly trips: Repository<Trip>,
@InjectRepository(TenantRevenueEntry)
private readonly revenueLedger: Repository<TenantRevenueEntry>,
private readonly cache: CacheService,
private readonly entitlements: EntitlementsService,
) {}
@@ -191,8 +194,10 @@ export class TenantsService {
* المستأجرين يصير الثاني N+1 يُبطئ اللوحة الأهم عندنا.
*
* `gmv` = مجموع الأجرة النهائية للرحلات المكتملة/المدفوعة (حجم الأعمال).
* `revenue` = مجموع العمولة — **إيراد المنصة الفعلي**، وهو الرقم الذي يهم
* عند التسعير لا الـGMV.
* `trip_commission` = عمولة الرحلات وحدها.
* `revenue` = **الإيراد الحقيقي** من دفتر الإيراد (docs/24 — P5): عمولة
* الرحلات + شحن السائقين + رسوم العمليات. الأمانات مستثناة بحكم كونها في
* دفتر آخر — لا شرط ينساه أحد.
*/
async platformOverview(days = 30) {
const since = new Date(Date.now() - days * 86400_000);
@@ -212,7 +217,7 @@ export class TenantsService {
)
.addSelect(
"COALESCE(SUM(t.commission_amount) FILTER (WHERE t.status IN ('completed','paid')), 0)",
'revenue',
'trip_commission',
)
// `requested_at` لا `created_at`: كيان الرحلة يسمّي طابع الإنشاء بوقت
// **طلب** الرحلة (docs/17 — B7). الاسم الخاطئ كان يُسقط النقطة بـ500
@@ -221,6 +226,17 @@ export class TenantsService {
.groupBy('t.tenant_id')
.getRawMany();
// الإيراد الحقيقي من دفتر الإيراد لا من الرحلات (docs/24 — P5): يشمل شحن
// السائقين ورسوم العمليات، وهي غالباً أكبر من عمولة الرحلات.
const revenueRows = await this.revenueLedger
.createQueryBuilder('l')
.select('l.tenant_id', 'tenant_id')
.addSelect('COALESCE(SUM(l.amount), 0)', 'revenue')
.where('l.created_at >= :since', { since })
.groupBy('l.tenant_id')
.getRawMany();
const revenueBy = new Map(revenueRows.map((r: any) => [r.tenant_id, Number(r.revenue ?? 0)]));
const byTenant = new Map(rows.map((r: any) => [r.tenant_id, r]));
const perTenant = tenants.map((t) => {
const r: any = byTenant.get(t.id) ?? {};
@@ -237,11 +253,12 @@ export class TenantsService {
trips: Number(r.trips ?? 0),
completed_trips: Number(r.completed_trips ?? 0),
gmv: Number(r.gmv ?? 0),
revenue: Number(r.revenue ?? 0),
trip_commission: Number(r.trip_commission ?? 0),
revenue: revenueBy.get(t.id) ?? 0,
};
});
const sum = (k: 'trips' | 'completed_trips' | 'gmv' | 'revenue') =>
const sum = (k: 'trips' | 'completed_trips' | 'gmv' | 'trip_commission' | 'revenue') =>
perTenant.reduce((a, t) => a + t[k], 0);
return {
@@ -252,6 +269,7 @@ export class TenantsService {
trips: sum('trips'),
completed_trips: sum('completed_trips'),
gmv: sum('gmv'),
trip_commission: sum('trip_commission'),
revenue: sum('revenue'),
},
tenants: perTenant.sort((a, b) => b.gmv - a.gmv),