From b1a060c5ed99e0d807259df5306657e432a698b9 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 18 Jul 2026 15:04:45 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20P0/P3/P5=20=E2=80=94=20=D8=AF=D9=81?= =?UTF-8?q?=D8=AA=D8=B1=D8=A7=20=D8=A7=D9=84=D9=85=D8=B3=D8=AA=D8=A3=D8=AC?= =?UTF-8?q?=D8=B1=20=D9=88=D8=B1=D8=B3=D9=85=20=D8=A7=D9=84=D8=B9=D9=85?= =?UTF-8?q?=D9=84=D9=8A=D8=A9=20=D9=88=D8=AA=D9=88=D8=AC=D9=8A=D9=87=20?= =?UTF-8?q?=D8=A7=D9=84=D9=85=D8=A7=D9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit الأساس المالي حسب 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 --- backend/scripts/provision-test.mjs | 3 + backend/src/app.module.ts | 2 + .../1721930000000-TenantWalletLedgers.ts | 47 ++++++ .../payments/entities/payment.entity.ts | 8 +- .../modules/payments/payments.controller.ts | 2 + .../src/modules/payments/payments.module.ts | 11 +- .../modules/payments/payments.routing.spec.ts | 118 +++++++++++++ .../src/modules/payments/payments.service.ts | 91 ++++++++-- .../entities/tenant-ledger.entity.ts | 75 +++++++++ .../tenant-wallet/tenant-wallet.controller.ts | 42 +++++ .../tenant-wallet/tenant-wallet.module.ts | 16 ++ .../tenant-wallet.service.spec.ts | 143 ++++++++++++++++ .../tenant-wallet/tenant-wallet.service.ts | 158 ++++++++++++++++++ .../tenant-wallet/transaction-fee.spec.ts | 52 ++++++ .../modules/tenant-wallet/transaction-fee.ts | 50 ++++++ backend/src/modules/tenants/tenants.module.ts | 6 +- .../src/modules/tenants/tenants.service.ts | 28 +++- dashboards/superadmin-web/index.html | 13 +- docs/24-tenant-wallet-revenue.md | 13 ++ 19 files changed, 853 insertions(+), 25 deletions(-) create mode 100644 backend/src/database/migrations/1721930000000-TenantWalletLedgers.ts create mode 100644 backend/src/modules/payments/payments.routing.spec.ts create mode 100644 backend/src/modules/tenant-wallet/entities/tenant-ledger.entity.ts create mode 100644 backend/src/modules/tenant-wallet/tenant-wallet.controller.ts create mode 100644 backend/src/modules/tenant-wallet/tenant-wallet.module.ts create mode 100644 backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts create mode 100644 backend/src/modules/tenant-wallet/tenant-wallet.service.ts create mode 100644 backend/src/modules/tenant-wallet/transaction-fee.spec.ts create mode 100644 backend/src/modules/tenant-wallet/transaction-fee.ts diff --git a/backend/scripts/provision-test.mjs b/backend/scripts/provision-test.mjs index 16136a6..c0b319d 100644 --- a/backend/scripts/provision-test.mjs +++ b/backend/scripts/provision-test.mjs @@ -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); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3f27422..dbd266d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/database/migrations/1721930000000-TenantWalletLedgers.ts b/backend/src/database/migrations/1721930000000-TenantWalletLedgers.ts new file mode 100644 index 0000000..136cc3a --- /dev/null +++ b/backend/src/database/migrations/1721930000000-TenantWalletLedgers.ts @@ -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 { + 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 { + for (const t of this.tables) { + await q.query(`DROP TABLE IF EXISTS ${t}`); + } + } +} diff --git a/backend/src/modules/payments/entities/payment.entity.ts b/backend/src/modules/payments/entities/payment.entity.ts index bd26c83..67e78d4 100644 --- a/backend/src/modules/payments/entities/payment.entity.ts +++ b/backend/src/modules/payments/entities/payment.entity.ts @@ -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 الدفع» منطقياً — قابل للفصل). diff --git a/backend/src/modules/payments/payments.controller.ts b/backend/src/modules/payments/payments.controller.ts index 3a4c1c4..d2fd32f 100644 --- a/backend/src/modules/payments/payments.controller.ts +++ b/backend/src/modules/payments/payments.controller.ts @@ -27,6 +27,8 @@ export class PaymentsController { purpose: body.purpose, tripId: body.tripId, method: body.method, + // الدور من التوكن لا من الجسم — عليه يتوقّف السماح بـcredit_topup. + userRole: user.role, }); } diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts index e0ad00c..186675b 100644 --- a/backend/src/modules/payments/payments.module.ts +++ b/backend/src/modules/payments/payments.module.ts @@ -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], diff --git a/backend/src/modules/payments/payments.routing.spec.ts b/backend/src/modules/payments/payments.routing.spec.ts new file mode 100644 index 0000000..3fe583e --- /dev/null +++ b/backend/src/modules/payments/payments.routing.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts index 09bee06..ae2e28f 100644 --- a/backend/src/modules/payments/payments.service.ts +++ b/backend/src/modules/payments/payments.service.ts @@ -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, 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.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; } } diff --git a/backend/src/modules/tenant-wallet/entities/tenant-ledger.entity.ts b/backend/src/modules/tenant-wallet/entities/tenant-ledger.entity.ts new file mode 100644 index 0000000..c5a2716 --- /dev/null +++ b/backend/src/modules/tenant-wallet/entities/tenant-ledger.entity.ts @@ -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; + + @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]; diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts b/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts new file mode 100644 index 0000000..2b7e493 --- /dev/null +++ b/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts @@ -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', + ); + } +} diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.module.ts b/backend/src/modules/tenant-wallet/tenant-wallet.module.ts new file mode 100644 index 0000000..77ee875 --- /dev/null +++ b/backend/src/modules/tenant-wallet/tenant-wallet.module.ts @@ -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 {} diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts b/backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts new file mode 100644 index 0000000..f98f2c9 --- /dev/null +++ b/backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts @@ -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 }); + }); +}); diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.service.ts b/backend/src/modules/tenant-wallet/tenant-wallet.service.ts new file mode 100644 index 0000000..783cfbb --- /dev/null +++ b/backend/src/modules/tenant-wallet/tenant-wallet.service.ts @@ -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; +} + +/** + * محفظتا المستأجر (docs/24 — P0). + * + * `revenue` = مالُ المستأجر فعلاً · `pending` = أماناتٌ يحتفظ بها لغيره. + * لا دالة واحدة تكتب في الدفترين معاً بلا تحديد صريح — الخلط بينهما هو + * الخطأ الذي يجعل تقرير الأرباح كاذباً والسحب من مال الركّاب ممكناً. + */ +@Injectable() +export class TenantWalletService { + private readonly logger = new Logger('TenantWallet'); + + constructor( + @InjectRepository(TenantRevenueEntry) + private readonly revenue: Repository, + @InjectRepository(TenantPendingEntry) + private readonly pending: Repository, + ) {} + + /** + * الجدولان متطابقا الشكل ويرثان `TenantLedgerBase`، فنوحّد نوعهما عليه. + * الفصل الحقيقي بينهما في القاعدة (جدولان)، لا في نوع TypeScript. + */ + private repoFor(book: Book): Repository { + return (book === 'revenue' ? this.revenue : this.pending) as unknown as Repository; + } + + /** + * يقيّد حركة. **آمن ضد التكرار عبر `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; + 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 { + 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; +} diff --git a/backend/src/modules/tenant-wallet/transaction-fee.spec.ts b/backend/src/modules/tenant-wallet/transaction-fee.spec.ts new file mode 100644 index 0000000..5d02f4c --- /dev/null +++ b/backend/src/modules/tenant-wallet/transaction-fee.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/tenant-wallet/transaction-fee.ts b/backend/src/modules/tenant-wallet/transaction-fee.ts new file mode 100644 index 0000000..c7c4921 --- /dev/null +++ b/backend/src/modules/tenant-wallet/transaction-fee.ts @@ -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 = { + 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): { + 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))); +} diff --git a/backend/src/modules/tenants/tenants.module.ts b/backend/src/modules/tenants/tenants.module.ts index 2f47545..9fd18fb 100644 --- a/backend/src/modules/tenants/tenants.module.ts +++ b/backend/src/modules/tenants/tenants.module.ts @@ -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], diff --git a/backend/src/modules/tenants/tenants.service.ts b/backend/src/modules/tenants/tenants.service.ts index 6e26627..67dd368 100644 --- a/backend/src/modules/tenants/tenants.service.ts +++ b/backend/src/modules/tenants/tenants.service.ts @@ -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, @InjectRepository(Trip) private readonly trips: Repository, + @InjectRepository(TenantRevenueEntry) + private readonly revenueLedger: Repository, 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), diff --git a/dashboards/superadmin-web/index.html b/dashboards/superadmin-web/index.html index d116ed4..c170a84 100644 --- a/dashboards/superadmin-web/index.html +++ b/dashboards/superadmin-web/index.html @@ -62,8 +62,8 @@

المستأجرون

- - + +
الاسمslugالدولةالباقةالحالةرحلاتGMVإيراد المنصة
…
الاسمslugالدولةالباقةالحالةرحلاتGMVعمولة الرحلاتالإيراد الكلي
…
@@ -170,17 +170,18 @@ document.getElementById('ovTotals').innerHTML = `مستأجرون: ${t0.tenants ?? 0} (فعّال: ${t0.active_tenants ?? 0}) · رحلات: ${t0.trips ?? 0} (مكتملة: ${t0.completed_trips ?? 0}) · - GMV: ${money(t0.gmv)} · إيراد المنصة: ${money(t0.revenue)}`; + GMV: ${money(t0.gmv)} · عمولة رحلات: ${money(t0.trip_commission)} · الإيراد الكلي: ${money(t0.revenue)}`; const tenants = ov.tenants || []; - rows.innerHTML = tenants.length ? '' : 'لا مستأجرين بعد'; + rows.innerHTML = tenants.length ? '' : 'لا مستأجرين بعد'; for (const t of tenants) { const suspended = t.status !== 'active'; const tr = document.createElement('tr'); tr.innerHTML = `${esc(t.name)}${esc(t.slug)}${esc(t.countryPack || '-')} ${esc(t.plan)} ${esc(t.status)} - ${t.completed_trips}/${t.trips}${money(t.gmv)}${money(t.revenue)} + ${t.completed_trips}/${t.trips}${money(t.gmv)} + ${money(t.trip_commission)}${money(t.revenue)} @@ -233,7 +234,7 @@ }; rows.appendChild(tr); } - } catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `${esc(e.message)}`; } + } catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `${esc(e.message)}`; } } document.getElementById('ovDays').onchange = loadTenants; diff --git a/docs/24-tenant-wallet-revenue.md b/docs/24-tenant-wallet-revenue.md index 340826c..78866db 100644 --- a/docs/24-tenant-wallet-revenue.md +++ b/docs/24-tenant-wallet-revenue.md @@ -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.