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:
Hamza-Ayed
2026-07-17 14:03:23 +03:00
co-authored by Claude Opus 4.8
parent 2edd8f6916
commit d9b0faab2d
17 changed files with 736 additions and 142 deletions
@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* الرصيد التشغيلي للسائق (docs/18 — المجموعة J).
* عمولة مدفوعة سلفاً، منفصلة تماماً عن محفظة أرباحه.
*
* **بلا قيد `balance >= 0`** — بخلاف `tripz_wallets`: الدين مسموح عمداً
* (قرار المالك: الرحلة لا تُقطع أبداً ولو صار الرصيد سالباً).
*/
export class DriverCredit1721850000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_driver_credits (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
balance numeric(12,3) NOT NULL DEFAULT 0,
currency varchar NOT NULL DEFAULT 'JOD',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_driver_credits_tenant_driver"
ON tripz_driver_credits (tenant_id, driver_id)
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_credit_txns (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
amount numeric(12,3) NOT NULL,
type varchar NOT NULL,
balance_after numeric(12,3),
trip_id uuid,
ref varchar,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_driver_time"
ON tripz_credit_txns (tenant_id, driver_id, created_at)
`);
// مكافأة التسجيل مرة واحدة لكل سائق — تُفرض على القاعدة لا بفحص تطبيقي،
// فتسجيلان متزامنان لا يمنحانها مرتين (docs/18 — J6).
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_signup_once"
ON tripz_credit_txns (tenant_id, driver_id)
WHERE type = 'signup_bonus'
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_credit_txns`);
await q.query(`DROP TABLE IF EXISTS tripz_driver_credits`);
}
}