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
@@ -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<string, any>;
@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];