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:
co-authored by
Claude Fable 5
parent
4ca932dd1f
commit
b1a060c5ed
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user