عادل صادق

إجمالي الكارت بقى بالسالب

كارت إيجار متسحب لحد ما إجماليه بقى بالسالب

English

عكست التواريخ في تاني ريكويست وانا بضيف منتج، فالكارت نزل لـ −€477,673.13.

الحالة. بلّغت عنها، واتصلحت، واتقفلت كـ duplicate. حد كان سابقني ليها. بس طريقة التفكير لسه تستاهل تتكتب.

النوع. Business logic. المسار اللي بيحدّث الكارت مكانش بيعمل validation على فترة الإيجار. الخطورة high.


من الآخر (TL;DR)

التارجت منصة retail فيها خاصية إيجار. بتختار منتج، وتحدد تاريخ بداية وتاريخ نهاية، والباك إند بيحسب سعر السطر على حسب المدة.

لو ضفت إيجار تاريخ نهايته قبل تاريخ بدايته، بيترفض أول مرة، لأن مسار الإنشاء (create) بيعمل validation على الفترة. بس لو ضفت نفس المنتج تاني مرة، الرد بيرجع cartUpdateResult: UPDATED. مسار التحديث ده بيقبل الفترة المعكوسة وبيسعّر السطر بالسالب. سطر واحد مسموم سحب إجمالي الإيجار لـ −€477,673.13.

مسار الإنشاء ومسار التحديث مش بيستخدموا نفس الـvalidation. أول write محمي، والتاني لأ.


النطاق وقواعد اللعبة


1. بفهم الخاصية الأول

فتحت منتج ينفع يتأجر، وفضلت باصص على الـnetwork tab وأنا بغيّر تواريخ الإيجار.

تواريخ الإيجار مش ديكور. هي ماشية مع سطر الكارت، وهي اللي بتحدد السعر اللي السيرفر بيحسبه.

Endpoint
POST /api/rental
طلب الإيجار في الـproxy history
إضافة الإيجار عبارة عن POST على الـendpoint بتاع الإيجار.
Payload
{
  "productCode": "018517",
  "storeId": 39,
  "startDate": [2026, 10, 31, 17, 0, 0],
  "endDate": [2026, 11, 20, 17, 0, 0],
  "quantity": 1
}

رد الكارت: كل سطر معاه سعر السيرفر هو اللي حاسبه.

الـrequest بتاع الإيجار جنب تحديث الكارت
التواريخ ماشية مع السطر. والإضافة دي اترد عليها كـ update.

الإيجار العادي بيتسعّر صح وبالموجب. لما تقرا الكارت تاني بـGET بأكونتك، هتلاقي: €20 إيجار، €2 waiver، €22 إجمالي، و€50 تأمين (deposit). السيرفر هو اللي ماسك السعر.

الكارت وفيه إيجار عادي متسعّر بـ22 يورو
فترة عادية. الإجمالي فاضل موجب.

ده التصميم الصح، بس معناه كمان إن دالة التسعير هي نقطة الفشل الوحيدة. السؤال مبقاش "أقدر ألعب في السعر؟"، بقى "أقدر أدّي المعادلة input المطوّر عمره ما تخيله؟". والتواريخ في إيد اليوزر، فهي دي الحتة اللي هلعب فيها.


2. بدوّر على حدود فترة الحجز

قبل ما أجرب أي حركة ذكية، جسّيت الحدود الأول. فضلت أبعّد التواريخ في المستقبل لحد ما السيرفر قال لأ.

الفترة اللي برة الـwindow بتترفض. الترتيب صح، البداية قبل النهاية، بس النهاية بعيدة أوي في المستقبل.

Request
{
  "productCode": "018517",
  "storeId": 39,
  "startDate": [2030, 10, 31, 17, 0, 0],
  "endDate": [2031, 11, 20, 17, 0, 0],
  "quantity": 1
}
Response
{
  "success": false,
  "errorCode": "RENTAL_PERIOD_INVALID",
  "cartUpdateResult": "NOT_UPDATED",
  "validationErrors": []
}
التواريخ البعيدة على الشمال، والسيرفر رافضها على اليمين
التواريخ مترتبة صح، بس هي ببساطة برة الـwindow.

الـwindow المسموح بيقف عند 2031 تقريباً، يعني حوالي خمس سنين لقدام. والأهم لباقي الحكاية: فيه validation حقيقي على مسار الإنشاء. فيه كود بيبص على فترة الإيجار ويرفضها بـ RENTAL_PERIOD_INVALID قبل ما السطر يتعمل أصلاً.

خلّي النقطة دي في دماغك. الباج هو مسار تاني مش بيعمل نفس الـcheck ده.


3. الـvalidation بتاع التواريخ صارم قد إيه؟

بدأت أبعت للـrequest بتاع الإضافة ranges معكوسة وتواريخ مش مظبوطة.

على إضافة جديدة، مسار الإنشاء ماسك كويس. الـrange المعكوس بيترفض، والـrange المترتب صح بس بعيد أوي في المستقبل بيترفض بـ RENTAL_PERIOD_INVALID وNOT_UPDATED. الفترة بتتراجع قبل ما السطر يتعمل أصلاً.

يعني أول write محمي. الحتة المثيرة هي اللي بيحصل في التاني.


4. الإضافة التانية

ده قلب الموضوع.

ضفت منتج، وبعدين ضفت نفس المنتج تاني، وبصيت الـAPI سمّى النتيجة إيه.

الإضافة التانية رجعت كـ update، مش سطر جديد.

Response
{
  "success": true,
  "cartUpdateResult": "UPDATED",
  "validationErrors": [],
  "cart": { … }
}

الكارت بيعرف سطر الإيجار من المنتج، مش من التواريخ اللي في الـrequest. فالإضافة التانية بتنزل على السطر الموجود أصلاً.

أكشن واحد، "ضيف المنتج ده"، بيتحول لسلوكين مختلفين على السيرفر.

مسار التحديث بياخد تاريخ البداية والنهاية الجداد من غير ما يعيد الـvalidation بتاع الفترة اللي مسار الإنشاء بيشغّله. فيه validation على الإنشاء، والإضافة التانية راجعة UPDATED بصريح العبارة، والكارت في الآخر شايل سطر بالسالب.

الإضافة التانية كانت بـrange معكوس بحوالي سنة، فالمدة بتطلع بالسالب جامد:

Payload
{
  "productCode": "018517",
  "storeId": 39,
  "startDate": [2031, 10, 31, 11, 0, 0],
  "endDate": [2030, 10, 31, 10, 0, 0],
  "quantity": 100
}

الخلاصة: مسارين كود لأكشن واحد، وواحد بس فيهم اللي محمي. دي حاجة تستاهل تدوّر عليها في أي كارت أو wishlist أو flow حجز.


5. ليه السعر بيطلع بالسالب

دالة تسعير شكلها كدا بتطلّع النتيجة دي بالظبط:

Pricing
function price(item, start, end):
    hours = (end - start) / 3600
    if hours < 24:
        return ceil(hours) * item.hourlyRate
    return ceil(hours / 24) * item.dailyRate

مع range معكوس، hours بتطلع بالسالب. وأي رقم سالب أصغر من 24، فبيدخل في فرع الساعات والنتيجة سعر سطر بالسالب. مفيش check زي hours > 0. المطوّر افترض إن الـvalidation ضمن قبل كدا إن المدة موجبة. على مسار الإنشاء، فعلاً كان ضامنها. على مسار التحديث، لأ.

والإضافة التانية كان فيها كمان quantity: 100، فسعر السطر السالب بيتضرب في 100 وحدة. وعشان كدا الرقم بيطلع ضخم كدا.

الـvalidation والتسعير كانوا منطقتين ثقة منفصلين، ومسار التحديث خلاني أنط فوق الأولى.


6. الكارت المسموم

الكارت في الآخر مجموع. أول ما سطر واحد يبقى بالسالب، بيسحب الإجمالي لتحت وبيلغي قصاده أي سطر موجب.

من الـresponse بتاع الكارت على طول:

الحقلالقيمة
rentalPrice بتاع السطر المسموم−€434,880.00
damageWaiverPrice بتاع السطر المسموم (10%)−€43,488.00
totalPrice بتاع السطر المسموم−€478,368.00
totalRentalRentPrice بتاع الكارت−€434,248.30
totalDamageWaiverPrice بتاع الكارت−€43,424.83
totalRentalPrice بتاع الكارت−€477,673.13

إجمالي الإيجار −€434,248.30 معناه إن فيه سطر موجب بـ +€631.70 السطر السالب بلعه. والإجمالي النهائي هو الرقم ده زائد الـdamage waiver بتاع الـ10%.

الكارت بعد الإيجار المعكوس
الكارت متقري بـGET بالأكونت. الأرقام دي state متخزنة على السيرفر.

الـresponse بتاع الـAPI نفسه شايل القيم السالبة، يعني ده مش glitch في عرض الفرونت إند. الإيجار اتضاف للكارت فعلاً، والكارت شال القيمة السالبة.

سطور الإيجار والمنتجات العادية بيتجمعوا في إجمالي واحد للكارت. فالسطر السالب بيلغي قصاده كل اللي في الكارت، مش الإيجارات بس.


7. طبقة الدفع

أنا وقفت عند الكارت، فالجزء ده عن الـpayment processors بتشتغل إزاي بشكل عام، وليه ده مهم في باج زي ده.

الـprocessor ده آلة حاسبة بتديها رقم. الرقم لازم يكون صح قبل ما يوصلها.


اللي مجربتوش

الـcheckout. أنا وقفت عند الكارت ومعملتش أوردر بإجمالي سالب أو صفر، فالـcheckout هيعمل إيه مع الإجمالي ده هو الحتة الوحيدة اللي لسه مفتوحة.


المسارين ماشيين إزاي

Paths
POST /api/rental
        |
        +-- no line yet ---- create
        |                      |
        |                      +-- period refused --> RENTAL_PERIOD_INVALID
        |                      +-- period accepted -> positive price
        |
        +-- line already there -- update
                                   |
                                   +-- reversed dates
                                         |
                                         +-- negative line
                                               |
                                               +-- poisoned cart total

بالعربي: لو السطر لسه مش موجود، بيروح على create، والفترة يا إما تترفض بـ RENTAL_PERIOD_INVALID يا إما تتقبل بسعر موجب. لو السطر موجود، بيروح على update، والتواريخ المعكوسة بتعدّي، فيطلع سطر بالسالب، وإجمالي الكارت كله يتسمم.


السبب الأساسي

  1. مفيش check مشترك لـ end > start على مسار التحديث. الـvalidation موجود على الإنشاء بس.
  2. دالة تسعير مفيهاش check لمدة سالبة أو نتيجة سالبة.
  3. الكارت بيقبل سطر سعره صفر أو أقل.

التأثير


الإصلاحات المقترحة

  1. اعمل validation لـ end > start، بالتاريخ والساعة كاملين والـtimezone، على كل المسارات: الإنشاء والتحديث والدمج (merge).
  2. حط قواعد الفترة في function واحدة مشتركة، والمسارين الاتنين ينادوها.
  3. ارفض أي مدة مش موجبة وأي سعر مش موجب جوا دالة التسعير نفسها.
  4. في الـcheckout، احسب الكارت من الأول وافشل لو أي سطر <= 0.
  5. متعملش أوردر بدفع صفر إلا لو الإجمالي المجاني ده اتحسب صح فعلاً.
  6. ضيف regression tests للـranges المعكوسة، وحافة فترة الحجز، والإضافة مرتين، والإضافة بعد التحديث.

تطلع بإيه من الحكاية دي


بخصوص النشر. منشور بإذن البرنامج. المشكلة اتبلّغ عنها، واتصلحت، واتقفلت كـ duplicate لريبورت كان قبلها.

شكراً إنك قريت. دي أول writeup ليا.