الويب هوك بتاع الإلغاء بيعمل bypass للـ Observer — المخزون المحجوز بيتسرب نهائياً
الـ handleCancelled() بيستخدم Order::where()->update() (query builder) بدل $order->update() (Eloquent model). ده بيعمل bypass للـ OrderObserver::updated() اللي هو المسؤول الوحيد عن استدعاء ReleaseOrderReservationAction اللي بتحرر الـ quantity_reserved.
💥 التأثير
خسارة مالية وتآكل تدريجي للمخزون. كل طلب بيتلغى من Tap بيسيب الـ stock محجوز للأبد. مع الوقت، المنتجات بتظهر "نفدت" رغم إنها موجودة في المخزن فعلاً. المشكلة دي بتتراكم وبتكبر مع كل عملية دفع فاشلة.
عرض الكود والحل
// File: app/Jobs/Tap/ProcessTapWebhookJob.php // استبدل handleCancelled() (سطر ١٥٠-١٥٨) - private function handleCancelled(Order $order, TapWebhookDTO $dto): void - { - Order::where('id', $order->id) - ->where('status', OrderStatusEnum::Pending) - ->update([ - 'status' => OrderStatusEnum::Cancelled, - 'tap_charge_status' => 'CANCELLED', - ]); - } + private function handleCancelled(Order $order, TapWebhookDTO $dto): void + { + // استخدم Eloquent model ->update() عشان الـ Observer يشتغل + // والـ ReleaseOrderReservationAction تحرر المخزون. + $order->refresh(); + + if ($order->status !== OrderStatusEnum::Pending) { + return; + } + + $order->update([ + 'status' => OrderStatusEnum::Cancelled, + 'tap_charge_status' => 'CANCELLED', + ]); + }
الويب هوك بتاع الدفع الفاشل مش بيحرر المخزون — نفس مشكلة الـ Observer Bypass
الـ handleFailed() بيعمل update للـ tap_charge_status بس بيسيب الـ order status Pending. يعني الـ stock فاضل محجوز ومحدش بيحرره. الـ CancelUnpaidOrderJob بيشيل الطلبات القديمة بعد ٣٠ دقيقة بس، يعني في الفترة دي المخزون مقفول.
💥 التأثير
المخزون محتجز ٣٠+ دقيقة بعد كل دفع فاشل. في أوقات الذروة والعروض، العميل اللي كارته اترفض بيقفل المنتجات لمدة نص ساعة، وده بيمنع باقي العملاء من الشراء.
عرض الكود والحل
// File: app/Jobs/Tap/ProcessTapWebhookJob.php // استبدل handleFailed() (سطر ١٤١-١٤٨) - private function handleFailed(Order $order, TapWebhookDTO $dto): void - { - Order::where('id', $order->id) - ->where('status', OrderStatusEnum::Pending) - ->update(['tap_charge_status' => 'FAILED']); - - Log::warning('ProcessTapWebhookJob: payment failed', ['order_id' => $order->id]); - } + private function handleFailed(Order $order, TapWebhookDTO $dto): void + { + $order->refresh(); + + if ($order->status !== OrderStatusEnum::Pending) { + return; + } + + // ألغي الطلب فوراً عشان الـ stock يتحرر عن طريق الـ Observer + $order->update([ + 'status' => OrderStatusEnum::Cancelled, + 'tap_charge_status' => 'FAILED', + ]); + + Log::warning('ProcessTapWebhookJob: payment failed — order cancelled', [ + 'order_id' => $order->id, + ]); + }
Pending بس حرر الـ stock مباشرةً عن طريق ReleaseOrderReservationAction. عملية إعادة الدفع هتعمل reserve جديد.
الـ TapCallbackController بيستخدم متغيرات مش معرفة في الـ catch block — بيعمل crash
في الـ catch (TapException) block، الكود بيستخدم $payment_method و $amount و $currency — بس دول بيتعرفوا جوا الـ try block بس. لو الـ $tap->getCharge() عمل throw، المتغيرات دي مش موجودة. PHP 8 بيعمل warning وبيبعت null للـ redirect.
💥 التأثير
الـ redirect بتاع العميل بيبوظ لما Tap API بتقع. العميل بيتوجه لصفحة الفشل بـ URL فيها قيم null زي: ?payment_method=&amount=¤cy=. والـ error logs بتتملي بـ undefined variable warnings.
عرض الكود والحل
// File: app/Http/Controllers/Payment/TapCallbackController.php // عرف المتغيرات قبل الـ try block (سطر ٤١-٥٦) + $payment_method = null; + $amount = null; + $currency = null; + try { $charge = $tap->getCharge($tapId); $success = ($charge['status'] ?? '') === 'CAPTURED'; $payment_method = data_get($charge, 'card.brand', null); $amount = data_get($charge, 'amount', null); $currency = data_get($charge, 'currency', null); } catch (TapException $e) { // دلوقتي المتغيرات معرفة — الـ redirect هيشتغل صح return $this->toFailure($order, $payment_method, $amount, $currency); }
الموقع مش بيبعت country_id — الشحن دايماً بيتحسب ٠.٠٠
الـ CheckoutAddress interface مفيهاش field اسمها country_id — فيها country بس كـ string (زي "Saudi Arabia"). الـ mapAddress() مش بتبعت الـ ID خالص.
الباك اند بيعمل fallback للـ country_id بـ ?? 0. ومفيش shipping zone عندها country بـ ID = 0. فالـ resolveZone() بترجع null والشحن بيبقى ٠.٠٠ دايماً.
💥 التأثير
مفيش إيرادات شحن خالص على أي طلب. كل تكاليف الشحن بيتم تجاهلها بالكامل. مناطق وأسعار الشحن اللي الـ admin عملها كلها bypassed. ده خسارة مالية مباشرة على كل طلب.
عرض الكود والحل
// الحل — جزء الـ Frontend: // File: abajora-website/app/services/checkout.service.ts // ١. ضيف country_id في الـ CheckoutAddress interface: + country_id: number // ← ضيف الـ field ده // ٢. في mapAddress() ابعت الـ country_id: + country_id: formAddr.countryId, // ───────────────────────────────────────────────── // الحل — جزء الـ Backend: // File: app/Http/Requests/Checkout/PlaceOrderRequest.php // ضيف validation rule: + 'shipping_address.country_id' => ['required', 'integer', 'exists:countries,id'], // File: app/Services/Checkout/CheckoutService.php سطر ٩٣ - countryId: (int) ($data['shipping_address']['country_id'] ?? 0), + countryId: (int) $data['shipping_address']['country_id'],
N+1 Query في resolveCartWeight() — كويريز مش محدودة وقت الـ Checkout
الفانكشن بتعمل Product::find() لكل منتج في السلة لوحده. وبعدين buildItems() بتعمل نفس الكلام تاني. يعني لو في السلة ١٠ منتجات = ٢٠ كويري بدل واحدة. وكمان resolveCartWeight() بتشتغل برا الـ lock scope.
⚠️ التأثير
ضغط مضاعف على الداتابيز وقت الـ Checkout. في أوقات الذروة والفلاش سيلز، ده بيزود الـ contention على جدول المنتجات في أهم flow عند العملاء.
عرض الكود والحل
// الحل: احذف resolveCartWeight() واستخدم بيانات buildItems() // في placeOrder()، حرك الشحن بعد buildItems(): + $orderItems = $this->buildItems($data['items']); + $subtotal = $orderItems->sum('line_total'); + + $shipping = $this->shippingCalculator->calculate( + countryId: (int) $data['shipping_address']['country_id'], + subtotal: $subtotal, + totalWeightKg: $orderItems->sum('_weight'), + ); // في buildItems()، ضيف الوزن: + '_weight' => (float) ($product->weight ?? 0) * $qty, // وامسح resolveCartWeight() خالص (سطر ٣٤٣-٣٥٦)
مفيش Index على orders.order_reference — Full Table Scan على كل ويب هوك
الـ order_reference column بيُستخدم في كل webhook lookup بس مفيش عليه index. كل webhook بيعمل full table scan. مع زيادة الطلبات، الأداء بيتدهور.
⚠️ التأثير
أداء الويب هوكس بيبطئ مع كل طلب جديد. على ١٠٠ ألف+ طلب، كل webhook بياخد وقت أطول. في ساعات الذروة ممكن الـ queue يتكدس.
عرض الكود والحل
// migration جديد: + Schema::table('orders', function (Blueprint $table): void { + $table->unique('order_reference'); + });
إعادة محاولة الدفع بتعمل charge تاني من غير ما تتأكد — خطر دفع مزدوج
retryCharge() بتعمل charge جديد لو الطلب Pending بس مش بتشيك لو الـ charge الأول لسه INITIATED (يعني Tap لسه بتعالجه). لو العميل دوس "إعادة المحاولة" قبل ما Tap تخلص، بيتعمل charge تاني. لو الاتنين اتقبضوا = العميل بيدفع مرتين. والـ tap_charge_id بيتمسح ويتحط الجديد.
⚠️ التأثير
ممكن العميل يتخصم منه مرتين. الـ charge الأول الـ ID بتاعه بيتمسح فمش هتعرف ترجعه أوتوماتيك — لازم تعمل refund يدوي من لوحة Tap.
عرض الكود والحل
// File: app/Services/Payments/PaymentService.php // ضيف الحماية بعد سطر ١١٦: + // امنع إعادة المحاولة لو في charge لسه شغال + if ($order->tap_charge_status === 'INITIATED') { + throw new \RuntimeException( + "مينفعش تعيد المحاولة — في عملية دفع لسه شغالة للطلب {$order->order_reference}." + ); + }
CancelUnpaidOrderJob مش بيحرر المخزون مباشرةً — معتمد كلياً على الـ Observer
الـ docblock بيقول "Step 3: Release quantity_reserved" بس الكود مفيهوش أي call مباشر للـ ReleaseOrderReservationAction. الجوب بيعتمد على إن الـ $order->update() هيفعّل الـ Observer. لو حد عدل الـ Observer أو عمله disable = المخزون مش هيتحرر. ده تصميم هش وخطير.
⚠️ التأثير
اعتماد ضمني وخطير. الجوب مشتغل صح دلوقتي بالصدفة عشان الـ Observer مسجل. بس أي تعديل مستقبلي (زي withoutEvents أو seeder) ممكن يكسر تحرير المخزون من غير ما حد ياخد باله.
عرض الكود والحل
// File: app/Jobs/Orders/CancelUnpaidOrderJob.php // ضيف call صريح بعد الـ update (حوالي سطر ٧٠): $order->update(['status' => OrderStatusEnum::Cancelled]); + // حرر المخزون صراحةً — متعتمدش على الـ Observer لوحده + app(ReleaseOrderReservationAction::class)->execute($order);
Cache::tags() في الشحن — بتعمل crash على الـ File/Database cache driver
Cache::tags(['shipping_zones']) بيشتغل بس على Redis و Memcached. لو الـ env بيستخدم file أو database cache driver، بيعمل BadMethodCallException. ده بيكسر حساب الشحن بالكامل.
⚠️ التأثير
الـ Checkout بيقع في بيئات مفيهاش Redis. أي بيئة development أو staging أو production بـ file cache، حساب الشحن بيعمل crash.
عرض الكود والحل
// File: app/Services/Shipping/ShippingCalculatorService.php - Cache::tags(['shipping_zones'])->remember(...); + // استخدم Cache::remember() بدون tags — بيشتغل على أي driver + Cache::remember("shipping_zone_{$countryId}", 3600, function () use (...) { + return ...; + });
الداشبورد بيبعت country كنص بدل country_id — نفس مشكلة الموقع
الداشبورد لما بيعمل طلبات يدوي أو بيعدل عناوين، ممكن يبعت country كنص بدل country_id كرقم.
الباك اند مفيهوش required validation على country_id — بيعمل fallback لـ 0 بهدوء. محتاج validation صريح.
⚠️ التأثير
مشكلة الشحن المجاني بتتكرر من الداشبورد كمان — مش الموقع بس.
عرض الكود والحل
// نفس حل BUG-004 — ضيف validation في الباك اند: + 'shipping_address.country_id' => ['required', 'integer', 'exists:countries,id'], // وعدل الداشبورد يبعت country_id
الكوبون بيتطبق في الباك اند بس العرض في الفرونت اند مش دقيق — مفيش grand_total_preview
الفرونت بيحاول يقرأ res.discount_amount أو res.discount أو res.total_after_discount — بس مش واثق أي واحد فيهم الباك اند بيرجعه. بيعمل Number(res.discount_amount ?? res.discount ?? 0) كـ fallback chain.
الباك اند بيرجع response مش واضح الـ key names فيه. الفرونت بيحاول يخمن الـ field name. لازم يبقى في عقد واضح بين الطرفين.
⚠️ التأثير
العميل ممكن يشوف خصم غلط في صفحة الـ checkout — الرقم الحقيقي بيتحسب بعد الدفع.
عرض الكود والحل
// وحد response الـ coupon validation API: + return response()->json([ + 'valid' => true, + 'discount_amount' => $discount, + 'total_after_discount' => $grandTotal, + ]);
الباك اند مفيهوش Validation على country_id — Fallback هادئ لـ 0
مفيش validation في الفرونت اند على الـ country_id — حتى لو المستخدم ما اختارش بلد، الفورم بيتبعت.
الباك اند مفيهوش rule required على country_id. بيعمل ?? 0 وبيكمل عادي — ده مفروض يرجع 422 error مش يعمل fallback هادئ.
⚠️ التأثير
أي client (موقع، داشبورد، API خارجي) يقدر يعمل طلب بشحن ٠.٠٠ لو نسي يبعت الـ country_id.
عرض الكود والحل
// File: app/Http/Requests/Checkout/PlaceOrderRequest.php + 'shipping_address.country_id' => ['required', 'integer', 'exists:countries,id'], + 'billing_address.country_id' => ['required_if:same_as_shipping,false', 'integer', 'exists:countries,id'], // File: CheckoutService.php سطر ٩٣ — شيل الـ fallback: - countryId: (int) ($data['shipping_address']['country_id'] ?? 0), + countryId: (int) $data['shipping_address']['country_id'],
سلاسة بتحط رقم التتبع في عمود carrier_name — بيمسح اسم شركة الشحن
في updateOrder()، الـ $dto->trackingNumber بيتحط في عمود carrier_name بدل ما يتحط في عمود tracking_number (اللي مش موجود أصلاً). يعني: (١) اسم شركة الشحن بيتمسح، (٢) رقم التتبع بيظهر مكان اسم الشركة في الداشبورد، (٣) مفيش طريقة تتبع الشحنة صح.
⚠️ التأثير
بيانات الطلبات مخربة. الداشبورد بيعرض رقم التتبع مكان اسم شركة الشحن. أي تقارير أو فلترة بناء على اسم الشركة بترجع بيانات غلط.
عرض الكود والحل
// ١. migration جديد — ضيف عمود tracking_number: + $table->string('tracking_number', 200)->nullable()->after('carrier_name'); // ٢. File: SalasaWebhookService.php سطر ١٨٤: - $fields['carrier_name'] = $dto->trackingNumber; + $fields['tracking_number'] = $dto->trackingNumber;
خطوة الشحن في الـ Checkout بتعرض "شحن مجاني ٠.٠٠" hardcoded — مش بتجيب السعر الحقيقي
خطوة الشحن (Step 3) في الـ guest checkout بتعرض radio button واحد ثابت: "Free" — 0.00 SAR. مش بتعمل API call للباك اند عشان تجيب سعر الشحن الفعلي بناءً على عنوان العميل. حتى لو BUG-004 اتصلح، العميل هيفضل يشوف "مجاني" عشان الـ UI ثابت.
⚠️ التأثير
تجربة checkout مضللة. العميل بيشوف "شحن مجاني" بس ممكن يتفاجئ بمبلغ مختلف في الإيصال. ده ممكن يخالف قوانين حماية المستهلك في دول الخليج اللي بتلزم بعرض السعر النهائي قبل الدفع.
عرض الكود والحل
// ١. ضيف API endpoint في الباك اند: // GET /api/user/v1/checkout/shipping-estimate // Params: country_id, subtotal // Returns: { shipping_cost: number, method: string } // ٢. في checkout.vue، ضيف: + const shippingCost = ref(0) + const shippingLoading = ref(false) + + async function fetchShippingEstimate() { + shippingLoading.value = true + try { + const res = await $fetch('/api/user/v1/checkout/shipping-estimate', { + params: { country_id: form.shipping.countryId, subtotal: totalPrice.value } + }) + shippingCost.value = res.data?.shipping_cost ?? 0 + } finally { shippingLoading.value = false } + } // ٣. استدعي fetchShippingEstimate() لما المستخدم يتقدم للخطوة ٣
SalasaWebhookController بيرجع HTTP 200 على أي error — بيخبي الأخطاء
الكنترولر بيمسك كل الـ exceptions ويرجع 200. ده معناه: (١) أي باج حقيقي بيتخبى ومش بيظهر في الـ monitoring، (٢) الـ order status updates بتضيع نهائي مفيش retry، (٣) لو Salasa غيرت format الـ webhook، الـ integration كله بيقع من غير ما حد يحس.
⚠️ التأثير
حالة الطلبات مش بتتحدث ومحدش حاسس. الطلبات المشحونة بتفضل "قيد المعالجة" للأبد.
عرض الكود والحل
// خزن الويب هوكس الفاشلة عشان تقدر تعيد تشغيلها: + } catch (\Throwable $e) { + Log::critical('Salasa webhook processing failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + DB::table('failed_salasa_webhooks')->insert([ + 'payload' => json_encode($request->all()), + 'error' => $e->getMessage(), + 'created_at' => now(), + ]); + + return response()->json(['status' => 'error'], 200); + }
سينك مخزون سلاسة بيمسح الـ quantity_reserved المحلي — بيحذف حجوزات الـ checkout
updateStockForSku() بتحط quantity_reserved من بيانات سلاسة. بس سلاسة الـ reserved بتاعها = حجوزات المخزن. مش حجوزات الـ checkout المحلية اللي CheckoutService عملها. لما السينك بيشتغل، بيمسح كل حجوزات العملاء اللي في الـ checkout — وده بيسمح لعملاء تانيين يشتروا نفس المنتجات = overselling.
⚠️ التأثير
حجوزات الـ checkout بتتمسح مع كل stock sync. كل ما السينك يشتغل أكتر، كل ما خطر الـ overselling يزيد.
عرض الكود والحل
// File: app/Services/Salasa/SyncProductsService.php سطر ٢٢٠ // امسح الـ quantity_reserved من السينك: - 'quantity_reserved' => $reserved, + // متعملش سينك للـ quantity_reserved من سلاسة. + // الـ quantity_reserved المحلي بيتدار من: + // CheckoutService (حجز) و ReleaseOrderReservationAction (تحرير) + // سلاسة الـ "reserved" بتاعها = حجوزات مخزن مختلفة تماماً.
كوكي الـ checkout بصلاحية ٧ أيام — بيانات قديمة بتسبب مشاكل
الـ checkout state بيتحفظ في cookie صلاحيتها ٧ أيام. لو العميل رجع بعد أسبوع، الفورم بيترجع بالبيانات القديمة — بس السلة ممكن تكون اتغيرت خالص. والكوبون اللي كان متطبق ممكن يكون انتهى. النتيجة: بيانات مش متوافقة وتجربة مربكة.
⚠️ التأثير
تجربة checkout مربكة ببيانات قديمة. العملاء بيشوفوا بيانات عناوين قديمة وكوبونات منتهية مع سلة مختلفة.
عرض الكود والحل
// File: abajora-website/app/pages/checkout.vue // قلل صلاحية الكوكي وضيف validation للسلة: - maxAge: 60 * 60 * 24 * 7, // 7 days + maxAge: 60 * 60 * 2, // ساعتين بس // ضيف cart hash عشان تتأكد السلة ما اتغيرتش: + const cartHash = computed(() => + items.value.map(i => `${i.product.id}:${i.quantity}`).join(',') + ) + if (checkoutCookie.value?.cartHash && + checkoutCookie.value.cartHash !== cartHash.value) { + checkoutCookie.value = null // السلة اتغيرت — امسح البيانات القديمة + }