تخطَّ إلى المحتوى

دورة حياة الدفع عند الاستلام

الحالات، وكيف يصدر order.paid من الدفتر، ومسار شركة الشحن بطلبات واستجابات حقيقية.

آخر تحديث 2 أيلول 2026قراءة 2 د
في هذه الصفحة

هذه الصفحة لمن يبني تطبيق شحن أو إشعارات أو محاسبة. سوقنا يشتري بالدفع عند الاستلام، والمنصّات المستنسخة عن الغرب تعامل «التسليم» و«الدفع» كحدث واحد؛ دكان تفصلهما لأن الواقع يفصلهما. في نهاية الصفحة تعرف كل حالة، ومتى يصدر كل حدث، وكيف تكتب كشركة شحن.

الحالات#

retry placed approved processing shipped delivered delivered_failed returned cancelled
Mermaid
stateDiagram-v2
    direction LR
    [*] --> placed
    placed --> approved
    approved --> processing
    processing --> shipped
    shipped --> delivered
    shipped --> delivered_failed
    delivered_failed --> delivered: retry
    delivered_failed --> returned
    placed --> cancelled
    approved --> cancelled
    processing --> cancelled
    delivered --> [*]
    returned --> [*]
    cancelled --> [*]
  • delivered_failed: لم يستلم الزبون (رفض عند الباب، لا يردّ). ليست نهاية الطريق: إعادة المحاولة (delivered) أو الإرجاع (returned).
  • returned لطلب COD غير مدفوع حالة نهائية بلا استرداد؛ لا مال قُبض أصلاً.
  • الحالات المالية (completed، partially_refunded، refunded) لا تُكتب عبر تغيير الحالة؛ تصدر عن كتّاب الاسترداد والدفتر. القيم القابلة للكتابة هي SettableOrderStatus في مرجع الواجهة.

payment_status انعكاس لدفتر المدفوعات: يُبثّ order.paid فقط عندما تعبر المبالغ المحصَّلة فعلاً إجمالي الطلب (سجل cod_collection في الدفتر). تسليم الشحنة وحده لا يعني أن التاجر قبض؛ تحصيل المندوب وتأكيد التاجر خطوة مستقلة.

لتطبيقات الإشعارات: اشترك في order.created وorder.status_changed وorder.paid الثلاثة وعاملها كثلاث قصص مختلفة للزبون:

  • shipped → «طلبك في الطريق»
  • delivered_failed → «لم نتمكن من التسليم وسنعيد المحاولة»
  • order.paid → إيصال الدفع

مسار شركة الشحن#

النطاقات المطلوبة: orders:read للأحداث والقراءة، وfulfillments:write للشحنات، وorders:write إن كنت ستغيّر حالة الطلب مباشرة. كل كتابة تتطلب Idempotency-Key؛ مندوبك سيعيد الإرسال، والنظام مبني لذلك.

١. احجز المندوب#

أنشئ شحنة بحالة pending: تحجز الكميات دون شحن الطلب (حجز قبل الاستلام من المتجر).

Shell
curl -X POST "https://dukkan.one/platform-api/v1/orders/ORDER_ID/fulfillments" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Idempotency-Key: booking-ORDER_ID-1" \
  -H "Content-Type: application/json" \
  -d @fulfillment.json
fulfillment-request.jsonJSON
{
  "provider": "aramex",
  "tracking_number": null,
  "status": "pending",
  "lines": [
    {
      "order_item_id": "a1b2c3d4-0001-4e8f-9b70-2d1c3e4f5a6b",
      "quantity": 1
    }
  ]
}
fulfillment-response.jsonJSON
{
  "data": {
    "id": "f0f0f0f0-0001-4e8f-9b70-2d1c3e4f5a6b",
    "order_id": "6f1d2c3b-4a59-4e8f-9b70-2d1c3e4f5a6b",
    "status": "pending",
    "order_status": "placed",
    "lines": [
      {
        "order_item_id": "a1b2c3d4-0001-4e8f-9b70-2d1c3e4f5a6b",
        "quantity": 1
      }
    ],
    "created_at": "2026-08-18T16:05:00.000Z"
  }
}

يصدر fulfillment.created ثم fulfillment.requested (لأن الحالة pending). إن أنشأتها مباشرة بـ status: "shipped" وغطّت كل الأسطر، ينتقل الطلب إلى shipped تلقائياً.

٢. الشحنة غادرت#

Shell
curl -X PATCH "https://dukkan.one/platform-api/v1/orders/ORDER_ID/fulfillments/FULFILLMENT_ID" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Idempotency-Key: shipped-FULFILLMENT_ID" \
  -H "Content-Type: application/json" \
  -d '{"status":"shipped","tracking_number":"ARX-778812"}'

الانتقالات المسموحة: pending → shipped → delivered، وpending|shipped → cancelled. الإلغاء يحرّر الكمية التي حجزتها الشحنة فيمكن تجهيز السطر مجدداً. الشحنة النهائية (delivered أو cancelled) ترفض أي تعديل لاحق بـ 409.

٣. التسليم واقتراح التحصيل#

Shell
curl -X PATCH "https://dukkan.one/platform-api/v1/orders/ORDER_ID/fulfillments/FULFILLMENT_ID" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Idempotency-Key: delivered-FULFILLMENT_ID" \
  -H "Content-Type: application/json" \
  -d '{"status":"delivered","cod_collected_minor":275000,"cod_collected_note":"Collected in full at the door"}'
fulfillment-delivered-response.jsonJSON
{
  "data": {
    "id": "f0f0f0f0-0001-4e8f-9b70-2d1c3e4f5a6b",
    "order_id": "6f1d2c3b-4a59-4e8f-9b70-2d1c3e4f5a6b",
    "status": "delivered",
    "tracking_number": "ARX-778812",
    "created_at": "2026-08-18T16:05:00.000Z",
    "updated_at": "2026-08-20T11:40:00.000Z",
    "order_status": "delivered",
    "order_status_skipped": false,
    "cod_collection_proposal_id": "c0d00001-0001-4e8f-9b70-2d1c3e4f5a6b"
  }
}
  • عندما تُسلَّم آخر شحنة معلّقة وتكون كل الأسطر مغطاة، ينتقل الطلب إلى delivered تلقائياً (order_status: "delivered"). إن رفضت مصفوفة الانتقالات ذلك بسبب حالة الطلب الحالية، تبقى الشحنة مسلَّمة ويكون order_status_skipped: true.
  • cod_collected_minor اقتراح تحصيل: المبلغ الذي أبلغ المندوب عن قبضه بالوحدات الصغرى (قد يقلّ عن الإجمالي). لا يحرّك أي مال. اقتراح مفتوح واحد لكل طلب، وإعادة الإرسال تحدّثه.

٤. التاجر يؤكّد#

يؤكّد التاجر التحصيل من لوحته؛ يكتب التأكيد سجل cod_collection في الدفتر، فيتحدّث payment_status ويصدر order.paid بـ amount_minor المحصَّل. هذا هو الحدث الذي يعني «التاجر قبض».

التعامل مع 409#

تغيير الحالة عبر PATCH /orders/{id}/status يمرّ بمصفوفة انتقالات على الخادم مع قفل صف. التعارض (التاجر ألغى وأنت تُسلِّم) يعيد 409:

error.jsonJSON
{
  "error": {
    "code": "conflict",
    "message": "Order cannot be fulfilled in its current status",
    "request_id": "7d0c2b1a-5e4f-4a3b-8c9d-0e1f2a3b4c5d"
  }
}

أعد قراءة الطلب بـ GET /orders/{id} وقرّر بناءً على حالته الحالية؛ لا تعد المحاولة بالحمولة نفسها. إعادة استخدام Idempotency-Key بحمولة مختلفة تعيد 409 أيضاً، وبالحمولة نفسها تعيد النتيجة الأصلية مع الترويسة Idempotency-Replayed: true.

الإرجاع والبضاعة#

الإرجاع حدثان مستقلان: مال (استرداد عبر POST /orders/{id}/refunds، فقط إن كان مدفوعاً) وبضاعة (حركة مخزون بسبب return). لا تفترض أحدهما من الآخر: بضاعة تالفة تُرجَع بلا إدخال إلى المخزون، وطلب غير مدفوع يُرجَع بلا استرداد. راجع حركات المخزون.