السلسلة: جاهزية التوثيق في إيجار
في أملاكي صمّمنا مساراً كاملاً لتوثيق عقود الإيجار عبر منصة إيجار الحكومية السعودية. هذا المقال يشرح القرارات الهندسية في دورة حياة الطلب — من الفحص إلى التوثيق أو الرفض أو إعادة المحاولة.
المقال السابق في السلسلة غطّى طبقة التحقق نفسها. هذا يغطّي ما بعدها: آلة الحالة، المال، والصلاحيات.
تنويه: أملاكي لم تنضم بعد لبرنامج التكامل الرقمي مع شبكة إيجار. المسار الموصوف هنا هو مسار طلب توثيق يُنفَّذ عبر وسيط مرخّص، مسبوقاً بطبقة جاهزية بيانات.
السياق
منصة إيجار توفّر خدمة رسمية للتكامل الرقمي مع المنصات العقارية، وقد أبرمت ١٢ اتفاقية تكامل معها. لكن أياً كان المسار — تكامل مباشر أو وسيط — يبقى ثابت واحد: إيجار تتحقق من الهويات عبر ربطها بوزارة الداخلية ومركز المعلومات الوطني، والبيانات الخاطئة تُرفض.
فالمسار كله مبني حول هذه الحقيقة.
عقد في النظام
|
فحص جاهزية (واجهة، لحظي)
|
إنشاء طلب --> 400 + قائمة النواقص إن كان ناقصاً
|
حساب الرسوم -> خصم من المحفظة -> PENDING
|
IN_PROGRESS
|
+-- COMPLETED (يتطلب رقم عقد إيجار)
+-- REJECTED (يتطلب سبباً -> استرجاع تلقائي -> إعادة المحاولة متاحة)
١. آلة الحالة: أربع حالات، لا أكثر
الإغراء أن تضيف حالات لكل فرق دقيق: DATA_READY، SUBMITTED، PENDING_PARTIES، PARTIALLY_SIGNED... قاومناه.
enum EjarRequestStatus {
PENDING // بانتظار التنفيذ
IN_PROGRESS // جاري التنفيذ
COMPLETED // تم التوثيق
REJECTED // مرفوض
}
القاعدة التي استخدمناها: الحالة تستحق الوجود إذا كان سلوك النظام يختلف فيها — لا إذا كان وصفها يختلف.
DATA_READY مثلاً لا تستحق حالة: جاهزية البيانات ليست مرحلة يمرّ بها الطلب، هي شرط لوجوده أصلاً. الطلب لا يُنشأ إلا وبياناته مكتملة، فحالة "بياناته جاهزة" لا تعني شيئاً.
وكل حالة إضافية تعني فرعاً في كل استعلام، وعموداً في كل لوحة، وحالة يجب أن تفكر فيها في كل ترحيل بيانات.
الحالتان النهائيتان محميتان صراحةً:
if (request.status === 'COMPLETED') throw new BadRequestException('الطلب مكتمل بالفعل');
if (request.status === 'REJECTED') throw new BadRequestException('الطلب مرفوض بالفعل');
بدون هذين السطرين، طلبان متزامنان من لوحة الإدارة يمكن أن يُنتجا استرجاعين لنفس المبلغ.
٢. البوابة قبل المال
ترتيب العمليات عند الإنشاء ليس اعتباطياً:
// 1. التحقق من اكتمال البيانات — قبل أي شيء له تكلفة
const missingFields = this.validateContractData(contract);
if (missingFields.length > 0) {
throw new BadRequestException({
message: 'البيانات غير مكتملة لإنشاء عقد إلكتروني',
missingFields,
});
}
// 2. حساب الرسوم
const durationYears = this.calculateDurationYears(contract.startDate, contract.endDate);
const fees = this.calculateFee(dto.propertyUsageType, durationYears);
// 3. الخصم من محفظة المكتب
const { transaction: walletTx } = await this.wallet.debit(
agencyId, fees.totalFee, 'رسوم عقد إلكتروني', user.id, 'EJAR_REQUEST',
);
// 4. إنشاء الطلب ثم ربط الحركة المالية به مرجعياً
لماذا missingFields في جسم الاستثناء؟ لأن 400 Bad Request وحدها تجعل الواجهة تعرض "حدث خطأ". القائمة تجعلها تعرض "ناقص: تاريخ ميلاد المستأجر، رقم الصك" — وهذا فرق بين تذكرة دعم ومستخدم يُكمل عمله.
وحساب المدة يعامل جزء السنة كسنة كاملة، مطابقةً للتسعير الرسمي:
calculateDurationYears(startDate: Date, endDate: Date): number {
const diffDays = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
return Math.ceil(diffDays / 365);
}
Math.ceil هنا قرار عمل لا قرار تقني — عقد ١٣ شهراً يُسعَّر سنتين. لو استخدمت Math.round لاختلفت الفاتورة، ولاكتشفت ذلك من شكوى عميل لا من اختبار.
٣. الصلاحيات: من يملك الطلب؟
النظام متعدد المكاتب. عند القراءة، الفلترة تُبنى في where لا تُطبَّق بعد الجلب:
const where: any = {};
if (filters.status) where.status = filters.status;
if (user.userType === 'AGENCY_ADMIN') {
where.agencyId = user.agency?.id;
} else if (user.userType !== 'SUPER_ADMIN') {
throw new ForbiddenException('لا تملك صلاحية لعرض الطلبات');
}
النقطة المهمة: العزل بين المكاتب يجب أن يكون في شرط الاستعلام، لا في تصفية بعد الجلب ولا في إخفاء بالواجهة. جلب كل الطلبات ثم تصفيتها في الذاكرة يعني أن أي خطأ لاحق في مسار التصفية يسرّب بيانات مكتب لآخر — والأسوأ أن الترقيم يصبح خاطئاً بصمت.
وagencyId يُشتق من العقار لا من المستخدم. المستخدم يقول ما يريد؛ البيانات تقول لمن تعود.
٤. الرفض: الاسترجاع جزء من الانتقال
أكثر خطأ رأيته في مسارات مشابهة: الرفض يُحدّث الحالة، والاسترجاع يُعالج لاحقاً. النتيجة أرصدة لا تتطابق.
عندنا الاسترجاع داخل نفس الانتقال:
if (dto.status === 'REJECTED') {
if (!dto.rejectionReason) throw new BadRequestException('سبب الرفض مطلوب');
updateData.rejectedAt = new Date();
const { transaction: refundTx } = await this.wallet.refund(
request.agencyId, Number(request.totalFee),
'استرجاع رسوم عقد إلكتروني مرفوض',
user.id, 'EJAR_REQUEST_REFUND', request.id,
);
updateData.refundTransactionId = refundTx.id;
await this.prisma.contract.update({
where: { id: request.contractId },
data: { ejarRequested: false },
});
}
ثلاثة تفاصيل: سبب الرفض إلزامي لأن رفضاً بلا سبب يولّد تذكرة دعم فورية. الاسترجاع مربوط بالطلب عبر request.id، فالحركتان تظهران في كشف المحفظة موصولتين بسببهما. وعلامة ejarRequested تُلغى وإلا بقي العقد يبدو "مطلوب توثيقه" إلى الأبد.
وفي المقابل، الإكمال يشترط رقم عقد إيجار. القاعدة العامة: إذا كانت الحالة تعني وجود بيانات، فاجعل تلك البيانات شرطاً للانتقال إليها. طلب COMPLETED بلا رقم عقد صف بيانات كاذب.
٥. إعادة المحاولة مقابل القيد الفريد
contractId @unique يمنع طلبين لعقد واحد. لكن الطلب المرفوض يجب أن يكون قابلاً لإعادة المحاولة، وإلا صار عقد المستخدم عالقاً بسبب خطأ إداري.
// السماح بإعادة الطلب إذا كان السابق مرفوضاً
if (contract.ejarRequest && contract.ejarRequest.status !== 'REJECTED') {
throw new BadRequestException('يوجد طلب عقد إلكتروني قائم لهذا العقد');
}
// حذف الطلب المرفوض القديم (لأن contractId فريد)
if (contract.ejarRequest && contract.ejarRequest.status === 'REJECTED') {
await this.prisma.ejarContractRequest.delete({ where: { id: contract.ejarRequest.id } });
}
المقايضة صريحة: نخسر صف الطلب المرفوض، ونحتفظ بأثره في سجل النشاط. البديل — مفتاح مركّب على (contractId, status) أو عدّاد attempt — يحفظ تاريخاً أكمل بتعقيد أعلى في كل استعلام.
هذا نمط عام: القيد الفريد يمنع الازدواج، وإعادة المحاولة تحتاج مخرجاً منه. قرّر أيهما تدفع ثمنه — التعقيد أو التاريخ.
القرارات الهندسية باختصار
١. الحالة تستحق الوجود إذا اختلف سلوك النظام فيها، لا إذا اختلف وصفها.
٢. البوابة قبل المال: تحقق، ثم احسب، ثم اخصم.
٣. أرجع النواقص بأسمائها في جسم الخطأ.
٤. العزل بين المكاتب في where، واشتق المالك من البيانات لا من الطلب.
٥. المال جزء من انتقال الحالة، لا مهمة مؤجلة.
٦. اشترط بيانات الحالة قبل الانتقال إليها.
٧. القيد الفريد وإعادة المحاولة يتعارضان — احسم المقايضة صراحةً.
٨. لا تدّعِ تكاملاً لم تنضم إليه. "جاهزية بيانات إيجار" ≠ "متكامل مع إيجار".
Stack
NestJS · Prisma · PostgreSQL · Next.js · React Native
المصادر الرسمية: منصة إيجار (ejar.sa) | الهيئة العامة للعقار (rega.gov.sa)
المنصة: amlakire.com