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