feat: المجموعة J — الرصيد التشغيلي + تصحيح نموذج العمولة (B3/B6)
فلسفة العمولة الصحيحة (docs/18): السائق يقبض أجرة الراكب **كاملة**،
والعمولة تُخصم من رصيد تشغيلي مدفوع سلفاً. ما بُني في f3fdc1b كان نموذج
أوبر (اقتطاع من الأرباح) — عكس المطلوب.
- J1: driver_credits + credit_txns. خصم ذرّي كنمط I1 لكن **بلا شرط رصيد**
ولا قيد >= 0: الدين مسموح عمداً (قرار المالك: الرحلة لا تُقطع أبداً)
- J2: خصم العمولة عند completed لا paid — الرحلة تمّت فالعمولة استُحقّت.
قاعدة واحدة للكاش والمحفظة (§5.5): لا تفريع حسب وسيلة الدفع
- J3: price_for_driver = price_for_passenger. TariffEngine.split →
commission، وبلا سقف بالأجرة لأن الخصم على رصيد منفصل يجوز أن يسلب
- J4: الحجب عند تجاوز credit_floor فقط، عند setOnline و accept — لا أثناء
رحلة. الحجب يمنع الاتصال أصلاً وإلا عُرضت رحلات يُرفض قبولها
- J6: مكافأة التسجيل عند الاعتماد (3 JOD / 300 SYP جديدة / 300 EGP).
حارسان: فحص تطبيقي للحالة الشائعة + فهرس فريد جزئي للسباق
- J7: credit_floor لكل عملة
- J8: GET /credit + /credit/transactions
تصحيح B3: مشوار الوصول تعويضُ إلغاء بعد انقضاء الانتظار المجاني — لا بند
في كل أجرة. أُزيل مفتاح pickup_leg.charge.
هجرة: DriverCredit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2edd8f6916
commit
d9b0faab2d
@@ -0,0 +1,42 @@
|
||||
import { Controller, ForbiddenException, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { DriverCreditService, CREDIT_FLOOR } from './driver-credit.service';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('credit')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('credit')
|
||||
export class CreditController {
|
||||
constructor(
|
||||
private readonly credit: DriverCreditService,
|
||||
private readonly drivers: DriversService,
|
||||
) {}
|
||||
|
||||
/** رصيدي التشغيلي — يعرضه تطبيق السائق (docs/18 — J8). */
|
||||
@Get()
|
||||
async mine(@CurrentUser() user: AuthUser) {
|
||||
const driver = await this.drivers.findByUser(user.tenantId, user.userId);
|
||||
if (!driver) throw new ForbiddenException('Not a driver');
|
||||
|
||||
const c = await this.credit.get(user.tenantId, driver.id);
|
||||
const floor = CREDIT_FLOOR[c.currency] ?? 0;
|
||||
const balance = Number(c.balance);
|
||||
return {
|
||||
balance,
|
||||
currency: c.currency,
|
||||
floor,
|
||||
inDebt: balance < 0,
|
||||
blocked: balance < floor,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
async history(@CurrentUser() user: AuthUser) {
|
||||
const driver = await this.drivers.findByUser(user.tenantId, user.userId);
|
||||
if (!driver) throw new ForbiddenException('Not a driver');
|
||||
return this.credit.history(user.tenantId, driver.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DriverCredit } from './entities/driver-credit.entity';
|
||||
import { CreditTxn } from './entities/credit-txn.entity';
|
||||
import { DriverCreditService } from './driver-credit.service';
|
||||
import { CreditController } from './credit.controller';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
|
||||
/**
|
||||
* دورة متبادلة مقصودة: drivers يحتاج الرصيد (حجب/مكافأة)، والكنترولر هنا
|
||||
* يحتاج drivers (ليحوّل user_id إلى driver_id) — forwardRef يفكّها.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([DriverCredit, CreditTxn]),
|
||||
forwardRef(() => DriversModule),
|
||||
],
|
||||
controllers: [CreditController],
|
||||
providers: [DriverCreditService],
|
||||
exports: [DriverCreditService],
|
||||
})
|
||||
export class CreditModule {}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { newDb } from 'pg-mem';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DriverCredit } from './entities/driver-credit.entity';
|
||||
import { CreditTxn } from './entities/credit-txn.entity';
|
||||
import { DriverCreditService, SIGNUP_BONUS, CREDIT_FLOOR } from './driver-credit.service';
|
||||
|
||||
const TENANT = '11111111-1111-1111-1111-111111111111';
|
||||
const DRIVER = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
describe('DriverCreditService (docs/18)', () => {
|
||||
let ds: DataSource;
|
||||
let credit: DriverCreditService;
|
||||
|
||||
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: [DriverCredit, CreditTxn],
|
||||
entityPrefix: 'tripz_',
|
||||
})) as DataSource;
|
||||
await ds.initialize();
|
||||
await ds.synchronize();
|
||||
|
||||
credit = new DriverCreditService(ds.getRepository(DriverCredit), ds.getRepository(CreditTxn));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (ds?.isInitialized) await ds.destroy();
|
||||
});
|
||||
|
||||
it('حساب جديد يبدأ بصفر', async () => {
|
||||
expect(Number((await credit.get(TENANT, DRIVER)).balance)).toBe(0);
|
||||
});
|
||||
|
||||
it('الشحن يرفع الرصيد', async () => {
|
||||
await credit.topup(TENANT, DRIVER, 10, 'pay-1');
|
||||
expect(Number((await credit.get(TENANT, DRIVER)).balance)).toBe(10);
|
||||
});
|
||||
|
||||
it('مثال المالك المرجعي: شحن 4 ثم عمولة 0.4 → 3.6', async () => {
|
||||
await credit.topup(TENANT, DRIVER, 4);
|
||||
const c = await credit.chargeCommission(TENANT, DRIVER, 0.4, 'trip-1');
|
||||
expect(Number(c.balance)).toBeCloseTo(3.6);
|
||||
});
|
||||
|
||||
describe('الرصيد السالب مسموح — الرحلة لا تُقطع', () => {
|
||||
it('العمولة تُخصم ولو تجاوزت الرصيد', async () => {
|
||||
await credit.topup(TENANT, DRIVER, 1);
|
||||
const c = await credit.chargeCommission(TENANT, DRIVER, 3, 'trip-1');
|
||||
expect(Number(c.balance)).toBeCloseTo(-2); // دين، لا رفض
|
||||
});
|
||||
|
||||
it('الخصم من رصيد صفر ينجح ويصير ديناً', async () => {
|
||||
const c = await credit.chargeCommission(TENANT, DRIVER, 0.5, 'trip-1');
|
||||
expect(Number(c.balance)).toBeCloseTo(-0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('الحجب عند تجاوز الأرضية فقط', () => {
|
||||
it('دين ضمن الأرضية لا يحجب', async () => {
|
||||
await credit.chargeCommission(TENANT, DRIVER, 2, 'trip-1'); // −2، والأرضية −5
|
||||
expect(await credit.isBlocked(TENANT, DRIVER)).toBe(false);
|
||||
});
|
||||
|
||||
it('تجاوز الأرضية يحجب', async () => {
|
||||
await credit.chargeCommission(TENANT, DRIVER, 6, 'trip-1'); // −6 < −5
|
||||
expect(await credit.isBlocked(TENANT, DRIVER)).toBe(true);
|
||||
});
|
||||
|
||||
it('الشحن يفكّ الحجب', async () => {
|
||||
await credit.chargeCommission(TENANT, DRIVER, 6, 'trip-1');
|
||||
await credit.topup(TENANT, DRIVER, 10);
|
||||
expect(await credit.isBlocked(TENANT, DRIVER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('مكافأة التسجيل — مرة واحدة لكل سائق', () => {
|
||||
it('تُمنح بقيمة العملة', async () => {
|
||||
const c = await credit.grantSignupBonus(TENANT, DRIVER, 'JOD');
|
||||
expect(Number(c.balance)).toBe(SIGNUP_BONUS.JOD);
|
||||
});
|
||||
|
||||
it('استدعاء ثانٍ لا يمنحها مرة أخرى', async () => {
|
||||
await credit.grantSignupBonus(TENANT, DRIVER, 'JOD');
|
||||
const c = await credit.grantSignupBonus(TENANT, DRIVER, 'JOD');
|
||||
expect(Number(c.balance)).toBe(SIGNUP_BONUS.JOD); // لا مضاعفة
|
||||
});
|
||||
|
||||
it('كل عملة ومكافأتها', () => {
|
||||
expect(SIGNUP_BONUS.SYP).toBe(300); // بالعملة السورية الجديدة
|
||||
expect(SIGNUP_BONUS.EGP).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
it('الدفتر يطابق الرصيد ويسجّل سبب كل خصم', async () => {
|
||||
await credit.topup(TENANT, DRIVER, 10, 'pay-1');
|
||||
await credit.chargeCommission(TENANT, DRIVER, 0.4, 'trip-1');
|
||||
await credit.promoBonus(TENANT, DRIVER, 5, 'promo-1');
|
||||
|
||||
const txns = await credit.history(TENANT, DRIVER);
|
||||
expect(txns).toHaveLength(3);
|
||||
|
||||
const sum = txns.reduce((acc, t) => acc + Number(t.amount), 0);
|
||||
expect(sum).toBeCloseTo(Number((await credit.get(TENANT, DRIVER)).balance));
|
||||
|
||||
// كل خصم عمولة يشير لرحلته — وإلا لم يعرف السائق لماذا نقص رصيده
|
||||
const commissionTxn = txns.find((t) => t.type === 'commission')!;
|
||||
expect(commissionTxn.trip_id).toBe('trip-1');
|
||||
expect(Number(commissionTxn.amount)).toBeCloseTo(-0.4);
|
||||
});
|
||||
|
||||
it('أنواع الهدايا منفصلة عن المدفوع فعلاً (محاسبياً)', async () => {
|
||||
await credit.topup(TENANT, DRIVER, 10);
|
||||
await credit.grantSignupBonus(TENANT, DRIVER, 'JOD');
|
||||
await credit.promoBonus(TENANT, DRIVER, 5);
|
||||
|
||||
const txns = await credit.history(TENANT, DRIVER);
|
||||
const paid = txns.filter((t) => t.type === 'topup');
|
||||
const gifts = txns.filter((t) => ['signup_bonus', 'promo_bonus'].includes(t.type));
|
||||
|
||||
expect(paid).toHaveLength(1);
|
||||
expect(gifts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('المستأجرون معزولون', async () => {
|
||||
await credit.topup(TENANT, DRIVER, 10);
|
||||
expect(Number((await credit.get('33333333-3333-3333-3333-333333333333', DRIVER)).balance)).toBe(0);
|
||||
});
|
||||
|
||||
it('الأرضية معرَّفة لكل عملة مدعومة', () => {
|
||||
expect(CREDIT_FLOOR.JOD).toBeLessThan(0);
|
||||
expect(CREDIT_FLOOR.SYP).toBeLessThan(0);
|
||||
expect(CREDIT_FLOOR.EGP).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { DriverCredit } from './entities/driver-credit.entity';
|
||||
import { CreditTxn, CreditTxnType } from './entities/credit-txn.entity';
|
||||
|
||||
/** مكافأة التسجيل لكل عملة (docs/18 §5.4 — قرار المالك). */
|
||||
export const SIGNUP_BONUS: Record<string, number> = {
|
||||
JOD: 3,
|
||||
SYP: 300, // بالعملة السورية الجديدة (حُذف صفران)
|
||||
EGP: 300,
|
||||
};
|
||||
|
||||
/** أقصى دين مسموح قبل الحجب، لكل عملة (docs/18 §5.2). */
|
||||
export const CREDIT_FLOOR: Record<string, number> = {
|
||||
JOD: -5,
|
||||
SYP: -500,
|
||||
EGP: -500,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DriverCreditService {
|
||||
private readonly logger = new Logger('DriverCredit');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriverCredit) private readonly credits: Repository<DriverCredit>,
|
||||
@InjectRepository(CreditTxn) private readonly txns: Repository<CreditTxn>,
|
||||
) {}
|
||||
|
||||
async get(tenantId: string, driverId: string, currency = 'JOD'): Promise<DriverCredit> {
|
||||
await this.ensure(this.credits.manager, tenantId, driverId, currency);
|
||||
return (await this.credits.findOne({
|
||||
where: { tenant_id: tenantId, driver_id: driverId },
|
||||
}))!;
|
||||
}
|
||||
|
||||
/** شحن مدفوع — يُنادى بعد نجاح الدفع فقط (docs/18 §5.3). */
|
||||
topup(tenantId: string, driverId: string, amount: number, ref?: string) {
|
||||
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
|
||||
return this.apply(tenantId, driverId, amount, 'topup', { ref });
|
||||
}
|
||||
|
||||
/** حافز الباقة — قيد منفصل عن المدفوع فعلاً (تكلفة تسويق لا إيراد). */
|
||||
promoBonus(tenantId: string, driverId: string, amount: number, ref?: string) {
|
||||
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
|
||||
return this.apply(tenantId, driverId, amount, 'promo_bonus', { ref });
|
||||
}
|
||||
|
||||
/**
|
||||
* خصم عمولة رحلة (docs/18 §5.2).
|
||||
* **لا يفشل أبداً** — ولو صار الرصيد سالباً. الرحلة تمّت فالعمولة استُحقّت؛
|
||||
* رفض الخصم يعني عمولة ضائعة إلى الأبد. الدين يُحاسَب لاحقاً عبر الأرضية.
|
||||
*/
|
||||
chargeCommission(tenantId: string, driverId: string, amount: number, tripId: string) {
|
||||
if (!(amount > 0)) return this.get(tenantId, driverId);
|
||||
return this.apply(tenantId, driverId, -amount, 'commission', { tripId });
|
||||
}
|
||||
|
||||
/**
|
||||
* مكافأة التسجيل — **مرة واحدة لكل سائق** (docs/18 — J6).
|
||||
*
|
||||
* حارسان: فحص هنا للحالة الشائعة (اعتماد يتكرر)، وفهرس فريد جزئي على
|
||||
* القاعدة للسباق الحقيقي (اعتمادان متزامنان). الفحص وحده لا يكفي، والفهرس
|
||||
* وحده يجعل الحالة الشائعة استثناءً — فكلاهما.
|
||||
*/
|
||||
async grantSignupBonus(
|
||||
tenantId: string,
|
||||
driverId: string,
|
||||
currency = 'JOD',
|
||||
): Promise<DriverCredit> {
|
||||
const amount = SIGNUP_BONUS[currency] ?? 0;
|
||||
if (amount <= 0) return this.get(tenantId, driverId, currency);
|
||||
|
||||
const already = await this.txns.findOne({
|
||||
where: { tenant_id: tenantId, driver_id: driverId, type: 'signup_bonus' },
|
||||
});
|
||||
if (already) return this.get(tenantId, driverId, currency);
|
||||
|
||||
try {
|
||||
return await this.apply(tenantId, driverId, amount, 'signup_bonus', {}, currency);
|
||||
} catch (e: any) {
|
||||
// 23505 = انتهاك تفرّد → سبقنا إليها نداء متزامن. ليست حالة خطأ.
|
||||
if (e?.code === '23505' || e?.driverError?.code === '23505') {
|
||||
this.logger.debug(`signup bonus already granted to ${driverId}`);
|
||||
return this.get(tenantId, driverId, currency);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** هل تجاوز السائق حدّ الدين؟ يُفحص عند الاتصال والقبول — لا أثناء رحلة. */
|
||||
async isBlocked(tenantId: string, driverId: string, currency = 'JOD'): Promise<boolean> {
|
||||
const c = await this.get(tenantId, driverId, currency);
|
||||
const floor = CREDIT_FLOOR[c.currency ?? currency] ?? 0;
|
||||
return Number(c.balance) < floor;
|
||||
}
|
||||
|
||||
history(tenantId: string, driverId: string) {
|
||||
return this.txns.find({
|
||||
where: { tenant_id: tenantId, driver_id: driverId },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- داخلي ----
|
||||
|
||||
private async ensure(
|
||||
em: EntityManager,
|
||||
tenantId: string,
|
||||
driverId: string,
|
||||
currency: string,
|
||||
): Promise<void> {
|
||||
await em
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(DriverCredit)
|
||||
.values({ tenant_id: tenantId, driver_id: driverId, balance: 0, currency })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* تعديل ذرّي — نفس نمط I1: عبارة `UPDATE` واحدة، والقيد والرصيد في معاملة
|
||||
* واحدة. الفرق الجوهري عن المحفظة: **لا شرط `balance >= amount`** هنا،
|
||||
* فالسالب مسموح عمداً.
|
||||
*/
|
||||
private async apply(
|
||||
tenantId: string,
|
||||
driverId: string,
|
||||
delta: number,
|
||||
type: CreditTxnType,
|
||||
meta: { tripId?: string; ref?: string } = {},
|
||||
currency = 'JOD',
|
||||
): Promise<DriverCredit> {
|
||||
return this.credits.manager.transaction(async (em) => {
|
||||
await this.ensure(em, tenantId, driverId, currency);
|
||||
|
||||
const res = await em
|
||||
.createQueryBuilder()
|
||||
.update(DriverCredit)
|
||||
.set({ balance: () => 'balance + :delta' })
|
||||
.where('tenant_id = :tenantId AND driver_id = :driverId')
|
||||
.setParameters({ delta, tenantId, driverId })
|
||||
.returning('*')
|
||||
.execute();
|
||||
|
||||
const row = res.raw?.[0];
|
||||
if (!row) throw new BadRequestException('Driver credit account not found');
|
||||
const credit = { ...row, balance: Number(row.balance) } as DriverCredit;
|
||||
|
||||
await em.getRepository(CreditTxn).insert({
|
||||
tenant_id: tenantId,
|
||||
driver_id: driverId,
|
||||
amount: delta,
|
||||
type,
|
||||
balance_after: credit.balance,
|
||||
trip_id: meta.tripId ?? null,
|
||||
ref: meta.ref ?? null,
|
||||
});
|
||||
|
||||
return credit;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* أنواع حركات الرصيد التشغيلي (docs/18).
|
||||
* `topup` و`signup_bonus` و`promo_bonus` مفصولة عمداً: المدفوع فعلاً إيراد،
|
||||
* والهدايا تكلفة تسويق — خلطها يُفسد المحاسبة.
|
||||
*/
|
||||
export type CreditTxnType =
|
||||
| 'topup' // شحن مدفوع — إيراد
|
||||
| 'signup_bonus' // مكافأة تسجيل — تكلفة تجنيد
|
||||
| 'promo_bonus' // حافز باقة («اشحن 50 خذ 55») — تكلفة تسويق
|
||||
| 'commission' // خصم عمولة رحلة
|
||||
| 'adjustment'; // تسوية يدوية من الأدمن
|
||||
|
||||
/** حركة على الرصيد التشغيلي. الجدول: tripz_credit_txns. دفتر append-only. */
|
||||
@Entity('credit_txns')
|
||||
@Index(['tenant_id', 'driver_id', 'created_at'])
|
||||
export class CreditTxn {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
driver_id: string;
|
||||
|
||||
/** موجب = إضافة · سالب = خصم. القيمة الموقَّعة تجعل مجموع الدفتر = الرصيد. */
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3 })
|
||||
amount: number;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
type: CreditTxnType;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
balance_after: number | null;
|
||||
|
||||
/** الرحلة سبب الخصم — بدونه لا يعرف السائق «لماذا نقص رصيدي؟» (docs/18 §6). */
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
trip_id: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
ref: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* الرصيد التشغيلي للسائق — عمولة مدفوعة **سلفاً** (docs/18).
|
||||
* الجدول: tripz_driver_credits.
|
||||
*
|
||||
* ليس محفظة أرباح السائق (`wallets`): تلك أمواله، وهذه رصيد يشتريه ليعمل.
|
||||
* لا تُخلطان أبداً.
|
||||
*
|
||||
* **الرصيد هنا يجوز أن يكون سالباً** (دين) — بخلاف `wallets` التي يحرسها
|
||||
* قيد `balance >= 0`.
|
||||
*/
|
||||
@Entity('driver_credits')
|
||||
@Index(['tenant_id', 'driver_id'], { unique: true })
|
||||
export class DriverCredit {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
driver_id: string;
|
||||
|
||||
/** موجب = رصيد متاح · سالب = دين على السائق. */
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
balance: number;
|
||||
|
||||
@Column({ default: 'JOD' })
|
||||
currency: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
Reference in New Issue
Block a user