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
+3
View File
@@ -86,6 +86,9 @@ async function main() {
check('المستأجر الجديد يظهر فيها', (ov.body.tenants || []).some((t) => t.slug === slug));
check('أرقام المال أعداد لا نصوص', typeof (ov.body.totals?.gmv) === 'number' &&
typeof (ov.body.totals?.revenue) === 'number');
// P5: الإيراد صار من دفتر الإيراد، وعمولة الرحلات حقل منفصل عنه.
check('الإيراد وعمولة الرحلات حقلان منفصلان',
typeof (ov.body.totals?.trip_commission) === 'number');
const badWindow = await api('GET', '/admin/overview?days=abc');
check('نافذة غير صالحة تسقط للافتراضي لا تنكسر', badWindow.ok && badWindow.body.window_days === 30);
+2
View File
@@ -34,6 +34,7 @@ import { OtpModule } from './integrations/otp/otp.module';
import { RideTypesModule } from './modules/ride-types/ride-types.module';
import { DispatchModule } from './modules/dispatch/dispatch.module';
import { WalletModule } from './modules/wallet/wallet.module';
import { TenantWalletModule } from './modules/tenant-wallet/tenant-wallet.module';
import { CreditModule } from './modules/credit/credit.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { PaymentsModule } from './modules/payments/payments.module';
@@ -94,6 +95,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
RealtimeModule,
FraudModule,
WalletModule,
TenantWalletModule, // دفترا المستأجر: إيراد وأمانات (docs/24)
CreditModule,
PaymentsModule,
TripsModule,
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* دفترا مال المستأجر (docs/24 — P0): الإيراد والأمانات.
*
* **جدولان منفصلان لا جدول واحد بعمود نوع**: استعلامُ سحبٍ أو تقريرٍ ينسى شرط
* النوع كان سيخلط مال المستأجر بأمانات الركّاب. مع جدولين لا يوجد صفٌّ خطأ
* ليُقرأ أصلاً.
*
* الفهرس الفريد على (tenant_id, ref) هو **حارس منع التسوية المزدوجة**: رسالة
* تأكيد من كليك/شام كاش قد تصل مرتين، فيرفض الثانية. القيم الفارغة متعدّدة
* مسموحة في postgres، فالحركات بلا مرجع خارجي لا تتصادم.
*/
export class TenantWalletLedgers1721930000000 implements MigrationInterface {
private readonly tables = ['tripz_tenant_revenue_ledger', 'tripz_tenant_pending_ledger'];
public async up(q: QueryRunner): Promise<void> {
for (const t of this.tables) {
await q.query(`
CREATE TABLE IF NOT EXISTS ${t} (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
amount numeric(14,3) NOT NULL,
currency varchar NOT NULL DEFAULT 'JOD',
reason varchar NOT NULL,
ref varchar,
meta jsonb NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_${t}_tenant_created"
ON ${t} (tenant_id, created_at)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_${t}_tenant_ref"
ON ${t} (tenant_id, ref)
`);
}
}
public async down(q: QueryRunner): Promise<void> {
for (const t of this.tables) {
await q.query(`DROP TABLE IF EXISTS ${t}`);
}
}
}
@@ -8,7 +8,13 @@ import {
} from 'typeorm';
export type PaymentStatus = 'pending' | 'success' | 'failed' | 'refunded';
export type PaymentPurpose = 'topup' | 'trip';
/**
* `topup` = شحن محفظة الراكب (**أمانة** — تبقى ملكه).
* `credit_topup` = شحن السائق رصيده التشغيلي (**إيراد** المستأجر — docs/24 §3.1).
* `trip` = دفع أجرة رحلة.
* التمييز بين الأولين جوهري: خلطهما يجعل تقرير الأرباح كاذباً.
*/
export type PaymentPurpose = 'topup' | 'credit_topup' | 'trip';
/**
* معاملة دفع. الجدول: tripz_pay_payments («schema الدفع» منطقياً — قابل للفصل).
@@ -27,6 +27,8 @@ export class PaymentsController {
purpose: body.purpose,
tripId: body.tripId,
method: body.method,
// الدور من التوكن لا من الجسم — عليه يتوقّف السماح بـcredit_topup.
userRole: user.role,
});
}
@@ -9,10 +9,19 @@ import { PayoutsController } from './payouts.controller';
import { WalletModule } from '../wallet/wallet.module';
import { UsersModule } from '../users/users.module';
import { TenantsModule } from '../tenants/tenants.module';
import { TenantWalletModule } from '../tenant-wallet/tenant-wallet.module';
import { CreditModule } from '../credit/credit.module';
@Module({
// OtpModule و AuditModule عالميان.
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule, TenantsModule],
imports: [
TypeOrmModule.forFeature([Payment, Payout]),
WalletModule,
UsersModule,
TenantsModule,
TenantWalletModule, // دفترا المستأجر (docs/24)
CreditModule, // شحن الرصيد التشغيلي للسائق
],
controllers: [PaymentsController, PayoutsController],
providers: [PaymentsService, PayoutsService],
exports: [PaymentsService, PayoutsService],
@@ -0,0 +1,118 @@
import { BadRequestException } from '@nestjs/common';
import { PaymentsService } from './payments.service';
import { LedgerReason } from '../tenant-wallet/entities/tenant-ledger.entity';
/**
* يثبت **وجهة المال** لا آلية التخزين (تلك مغطّاة في tenant-wallet.service.spec):
* أي دفتر يُقيَّد، وكم يُقتطع رسماً، ومن يُسمح له بشحن رصيد تشغيلي.
*/
const TENANT = 'tenant-1';
function makeService(countryPack = 'jo', settings: any = {}) {
const saved: any[] = [];
const repo = {
create: (x: any) => ({ ...x, id: 'pay-1' }),
save: async (x: any) => {
saved.push(x);
return { ...x, id: x.id ?? 'pay-1' };
},
find: async () => [],
findOne: async () => null,
};
const wallet = { credit: jest.fn().mockResolvedValue({}) };
const tenantWallet = {
creditRevenue: jest.fn().mockResolvedValue({ duplicate: false }),
creditPending: jest.fn().mockResolvedValue({ duplicate: false }),
};
const tenants = { resolve: jest.fn().mockResolvedValue({ countryPack, settings }) };
const credit = { topup: jest.fn().mockResolvedValue({}) };
const svc = new PaymentsService(
repo as any,
wallet as any,
tenantWallet as any,
tenants as any,
credit as any,
);
return { svc, wallet, tenantWallet, credit };
}
describe('PaymentsService — توجيه المال (docs/24)', () => {
it('شحن الراكب أمانة لا إيراد', async () => {
const { svc, tenantWallet, wallet } = makeService('jo');
await svc.charge(TENANT, {
userId: 'rider-1', amount: 10, provider: 'cash', purpose: 'topup', userRole: 'rider',
});
expect(tenantWallet.creditPending).toHaveBeenCalledTimes(1);
expect(tenantWallet.creditPending.mock.calls[0][0]).toMatchObject({
reason: LedgerReason.RIDER_TOPUP,
amount: 9.8, // 10 ناقص رسم الأردن 0.2
});
// الإيراد الوحيد هو الرسم — لا مبلغ الشحن.
const revenueReasons = tenantWallet.creditRevenue.mock.calls.map((c: any) => c[0].reason);
expect(revenueReasons).toEqual([LedgerReason.TRANSACTION_FEE]);
expect(wallet.credit).toHaveBeenCalledWith(TENANT, 'rider-1', 9.8, 'payment_topup', 'pay-1');
});
it('شحن السائق رصيده إيراد مباشر + يزيد رصيده التشغيلي', async () => {
const { svc, tenantWallet, credit } = makeService('jo');
await svc.charge(TENANT, {
userId: 'driver-1', amount: 100, provider: 'cash', purpose: 'credit_topup', userRole: 'driver',
});
expect(credit.topup).toHaveBeenCalledWith(TENANT, 'driver-1', 99.8, 'pay-1');
const reasons = tenantWallet.creditRevenue.mock.calls.map((c: any) => c[0].reason);
expect(reasons).toContain(LedgerReason.DRIVER_CREDIT_TOPUP);
expect(tenantWallet.creditPending).not.toHaveBeenCalled();
});
it('راكب لا يستطيع شحن رصيد تشغيلي — تزويرُ إيرادٍ بطلب واحد', async () => {
const { svc } = makeService('jo');
await expect(
svc.charge(TENANT, {
userId: 'rider-1', amount: 100, provider: 'cash', purpose: 'credit_topup', userRole: 'rider',
}),
).rejects.toThrow(BadRequestException);
});
it('الرسم بالدولة: سوريا 35', async () => {
const { svc, tenantWallet } = makeService('sy');
await svc.charge(TENANT, {
userId: 'r', amount: 1000, provider: 'cash', purpose: 'topup', userRole: 'rider',
});
expect(tenantWallet.creditRevenue.mock.calls[0][0]).toMatchObject({
reason: LedgerReason.TRANSACTION_FEE, amount: 35,
});
expect(tenantWallet.creditPending.mock.calls[0][0].amount).toBe(965);
});
it('مبلغ أصغر من الرسم لا يُنتج صافياً سالباً', async () => {
const { svc, tenantWallet } = makeService('sy'); // الرسم 35
await svc.charge(TENANT, {
userId: 'r', amount: 20, provider: 'cash', purpose: 'topup', userRole: 'rider',
});
expect(tenantWallet.creditRevenue.mock.calls[0][0].amount).toBe(20);
// الصافي صفر → لا قيد أمانة إطلاقاً (المحفظة ترفض الصفر أصلاً).
expect(tenantWallet.creditPending).not.toHaveBeenCalled();
});
it('مستأجر بلا رسوم: لا قيد رسم ويصل المبلغ كاملاً', async () => {
const { svc, tenantWallet } = makeService('jo', { payments: { transaction_fee: 0 } });
await svc.charge(TENANT, {
userId: 'r', amount: 10, provider: 'cash', purpose: 'topup', userRole: 'rider',
});
expect(tenantWallet.creditRevenue).not.toHaveBeenCalled();
expect(tenantWallet.creditPending.mock.calls[0][0].amount).toBe(10);
});
it('يرفض نية دفع مخترعة', async () => {
const { svc } = makeService();
await expect(
svc.charge(TENANT, {
userId: 'r', amount: 10, provider: 'cash', purpose: 'gift' as any, userRole: 'rider',
}),
).rejects.toThrow(BadRequestException);
});
});
@@ -3,6 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Payment, PaymentPurpose } from './entities/payment.entity';
import { WalletService } from '../wallet/wallet.service';
import { TenantWalletService } from '../tenant-wallet/tenant-wallet.service';
import { LedgerReason } from '../tenant-wallet/entities/tenant-ledger.entity';
import { TenantsService } from '../tenants/tenants.service';
import { DriverCreditService } from '../credit/driver-credit.service';
import { transactionFeeFor, cappedFee } from '../tenant-wallet/transaction-fee';
export interface ChargeDto {
userId: string;
@@ -12,8 +17,12 @@ export interface ChargeDto {
purpose?: PaymentPurpose;
tripId?: string;
method?: string;
/** دور صاحب التوكن — يُمرَّر من الكنترولر دائماً، لا من جسم الطلب. */
userRole?: string;
}
const PURPOSES: PaymentPurpose[] = ['topup', 'credit_topup', 'trip'];
/**
* الدفع بنمط المحوّلات (docs/07): cash فوري، والبوابات تُنشئ نية دفع + رابط تحويل،
* ثم يؤكّدها webhook. عند النجاح: شحن المحفظة (topup) أو تسجيل دفع الرحلة (trip).
@@ -27,6 +36,9 @@ export class PaymentsService {
constructor(
@InjectRepository(Payment) private readonly repo: Repository<Payment>,
private readonly wallet: WalletService,
private readonly tenantWallet: TenantWalletService,
private readonly tenants: TenantsService,
private readonly credit: DriverCreditService,
) {}
async charge(tenantId: string, dto: ChargeDto) {
@@ -34,12 +46,23 @@ export class PaymentsService {
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
if (!dto.provider) throw new BadRequestException('provider is required');
const purpose: PaymentPurpose = dto.purpose ?? 'topup';
if (!PURPOSES.includes(purpose)) throw new BadRequestException('invalid purpose');
// `purpose` يصل من جسم الطلب، و`credit_topup` **إيراد** يُقيَّد على المستأجر
// ويزيد رصيد السائق التشغيلي. بلا هذا الشرط يرسل راكبٌ `credit_topup`
// فيُسجَّل مالُه إيراداً محقَّقاً بدل أمانة، ويُشحن حساب سائق لا يملكه —
// أي تلويث الدفتر وتزوير الأرباح بطلبٍ واحد.
if (purpose === 'credit_topup' && dto.userRole !== 'driver') {
throw new BadRequestException('credit_topup is for drivers only');
}
let payment = await this.repo.save(
this.repo.create({
tenant_id: tenantId,
user_id: dto.userId,
trip_id: dto.tripId ?? null,
purpose: dto.purpose ?? 'topup',
purpose,
provider: dto.provider,
method: dto.method ?? null,
amount,
@@ -80,20 +103,68 @@ export class PaymentsService {
});
}
/**
* تسوية عملية ناجحة — **هنا يُقرَّر إلى أي دفتر يذهب المال** (docs/24).
*
* - `credit_topup` (السائق يشحن رصيده التشغيلي) → **إيراد** المستأجر.
* - `topup` (الراكب يشحن محفظته) → **أمانة**: المال يمرّ بحسابنا وهو ملكه.
* - رسم العملية الثابت → **إيراد** دائماً، ويُقتطع من المبلغ لا يُضاف عليه.
*
* كل قيد يحمل `ref` مشتقّاً من معرّف الدفع، فإعادة تسليم نفس الـwebhook
* لا تقيّد مرتين (الدفتر يرفضها بفهرس التفرّد).
*/
private async markSuccess(payment: Payment): Promise<Payment> {
payment.status = 'success';
payment.tx_ref = payment.tx_ref ?? `${payment.provider.toUpperCase()}-${payment.id.slice(0, 8)}`;
const saved = await this.repo.save(payment);
// شحن المحفظة عند نية topup
if (saved.purpose === 'topup') {
await this.wallet.credit(
saved.tenant_id,
saved.user_id,
Number(saved.amount),
'payment_topup',
saved.id,
);
const gross = Number(saved.amount);
const tenant = await this.tenants.resolve(saved.tenant_id);
const { fee } = tenant ? transactionFeeFor(tenant) : { fee: 0 };
// الرسم لا يتجاوز المبلغ: شحنٌ صغير أقلّ من الرسم يجب ألّا يُخرج صافياً سالباً.
const charged = cappedFee(fee, gross);
const net = gross - charged;
const currency = saved.currency;
if (charged > 0) {
await this.tenantWallet.creditRevenue({
tenantId: saved.tenant_id,
amount: charged,
currency,
reason: LedgerReason.TRANSACTION_FEE,
ref: `fee:${saved.id}`,
meta: { payment_id: saved.id, provider: saved.provider },
});
}
// الرسم ابتلع المبلغ كاملاً: لا يبقى صافٍ يُقيَّد. بلا هذا الشرط نُنادي
// الدفتر بصفر فيرمي خطأً **بعد** أن قُيِّد الرسم — عمليةٌ نصف مطبَّقة.
if (net <= 0) return saved;
if (saved.purpose === 'credit_topup') {
// إيراد المستأجر: السائق دفع مقدَّماً ليعمل (docs/18).
await this.credit.topup(saved.tenant_id, saved.user_id, net, saved.id);
await this.tenantWallet.creditRevenue({
tenantId: saved.tenant_id,
amount: net,
currency,
reason: LedgerReason.DRIVER_CREDIT_TOPUP,
ref: `credit:${saved.id}`,
meta: { payment_id: saved.id, driver_id: saved.user_id },
});
} else if (saved.purpose === 'topup') {
// أمانة: يُضاف لرصيد الراكب ويُسجَّل التزاماً على المستأجر، لا ربحاً.
await this.wallet.credit(saved.tenant_id, saved.user_id, net, 'payment_topup', saved.id);
await this.tenantWallet.creditPending({
tenantId: saved.tenant_id,
amount: net,
currency,
reason: LedgerReason.RIDER_TOPUP,
ref: `topup:${saved.id}`,
meta: { payment_id: saved.id, rider_id: saved.user_id },
});
}
return saved;
}
}
@@ -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)));
}
@@ -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),
+7 -6
View File
@@ -62,8 +62,8 @@
<div class="card">
<h2>المستأجرون</h2>
<table>
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th>رحلات</th><th>GMV</th><th>إيراد المنصة</th><th></th></tr></thead>
<tbody id="tenantRows"><tr><td colspan="9" class="muted">…</td></tr></tbody>
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th>رحلات</th><th>GMV</th><th>عمولة الرحلات</th><th>الإيراد الكلي</th><th></th></tr></thead>
<tbody id="tenantRows"><tr><td colspan="10" class="muted">…</td></tr></tbody>
</table>
</div>
@@ -170,17 +170,18 @@
document.getElementById('ovTotals').innerHTML =
`مستأجرون: <b>${t0.tenants ?? 0}</b> (فعّال: ${t0.active_tenants ?? 0}) ·
رحلات: <b>${t0.trips ?? 0}</b> (مكتملة: ${t0.completed_trips ?? 0}) ·
GMV: <b>${money(t0.gmv)}</b> · إيراد المنصة: <b>${money(t0.revenue)}</b>`;
GMV: <b>${money(t0.gmv)}</b> · عمولة رحلات: <b>${money(t0.trip_commission)}</b> · الإيراد الكلي: <b>${money(t0.revenue)}</b>`;
const tenants = ov.tenants || [];
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="9" class="muted">لا مستأجرين بعد</td></tr>';
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="10" class="muted">لا مستأجرين بعد</td></tr>';
for (const t of tenants) {
const suspended = t.status !== 'active';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${esc(t.name)}</td><td class="muted">${esc(t.slug)}</td><td>${esc(t.countryPack || '-')}</td>
<td>${esc(t.plan)}</td>
<td><span class="badge ${suspended ? '' : 'active'}">${esc(t.status)}</span></td>
<td>${t.completed_trips}/${t.trips}</td><td>${money(t.gmv)}</td><td>${money(t.revenue)}</td>
<td>${t.completed_trips}/${t.trips}</td><td>${money(t.gmv)}</td>
<td class="muted">${money(t.trip_commission)}</td><td>${money(t.revenue)}</td>
<td><button class="ghost" data-act="manifest">مانيفست</button>
<button class="ghost" data-act="admin">تعيين أدمن</button>
<button class="ghost" data-act="logo">اللوغو</button>
@@ -233,7 +234,7 @@
};
rows.appendChild(tr);
}
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="9" class="muted">${esc(e.message)}</td></tr>`; }
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="10" class="muted">${esc(e.message)}</td></tr>`; }
}
document.getElementById('ovDays').onchange = loadTenants;
+13
View File
@@ -73,6 +73,19 @@
- **المال في `varchar(10)`**.
- **جدولان متداخلان** لنفس المفهوم (`payout_requests` و`driver_withdrawal_requests`).
## 6.5 حالة التنفيذ (2026-07-18)
**منجَز (P0 · P3 · التوجيه · P5):**
- `tenant_revenue_ledger` و`tenant_pending_ledger` — جدولان مضافان فقط بهجرة `1721930000000`، وفهرس فريد على `(tenant_id, ref)` يمنع التسوية المزدوجة.
- `TenantWalletService`: `creditRevenue` · `creditPending` · `releasePending` · `balance` · `summary` · `revenueByReason`. التكرار يُلتقط من القاعدة لا بالفحص المسبق وحده (نداءان متزامنان يمرّان معاً قبل أن يكتب أيّهما).
- `transactionFeeFor` — مصدر واحد للرقم، و`cappedFee` يمنع صافياً سالباً حين يفوق الرسمُ المبلغَ.
- توجيه الدفع في `markSuccess`: `credit_topup` → إيراد + رصيد السائق · `topup` → أمانة + محفظة الراكب · الرسم → إيراد دائماً.
- **`credit_topup` للسائقين فقط** — الدور من التوكن لا من الجسم. بدونه يرسل راكبٌ `credit_topup` فيُسجَّل مالُه إيراداً محقَّقاً ويُشحن حساب سائق لا يملكه.
- `GET /admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر (نطاقه من التوكن).
- `/admin/overview` يفصل `trip_commission` عن `revenue` الكلي.
**الباقي**: P1 (بوابات فعلية) · P2 (تسوية بالرسائل) · P4 (تقارير أوسع) · وسحب المالك أرباحه من `tenant_wallet`.
## 7. أثره على ما هو مبنيّ الآن
`GET /admin/overview` يحسب `revenue` = مجموع عمولة الرحلات فقط. بعد هذا المستند يصير الإيراد الحقيقي = **عمولة الرحلات + شحن السائقين + عمولات عمليات الدفع**، والأمانات تُستثنى صراحةً. النقطة تحتاج تحديثاً ضمن P.