تحويل الأموال وإنهاء الوصول حدثان منفصلان على كلتا المنصتين، ولا يحدث تلقائيًا إلا أحدهما. تترك Google إزالة entitlement خلف خيار يكون معطّلًا افتراضيًا. تتولى Apple التراخيص، لكنها لا تستطيع لمس عنصر consumable أنفقه أحد المستخدمين بالفعل. وأي شيء يفشل خادمك في تنظيفه يستمر في العمل على عائدات لم تعد لديك.
أهم النقاط
رد أموال طلب في Play يترك وصول العميل كما هو دون تغيير، إلا إذا قررت أنت خلاف ذلك. فإما أن يتم تحديد خيار "Remove entitlement" داخل Play Console، أو أن يحمل استدعاء API الخاص بك القيمة revoke=true. ولا يحدث أي من الأمرين من تلقاء نفسه.
عمليات رد الأموال التي تتم دون خيار revoke هذا تكون غائبة تمامًا عن Voided Purchases API. وأي مهمة إلغاء وصول (revocation) تُبنى فقط بالاعتماد على هذه الخلاصة ستتجاهل كل واحدة منها بصمت.
تراخيص العناصر غير الاستهلاكية (non-consumables) والاشتراكات هي أمور تستطيع Apple سحبها نيابةً عنك. أما العناصر الاستهلاكية (consumables) فتُعتبر مُسلَّمة في اللحظة التي يتم إنفاقها فيها، مما لا يترك لـ StoreKit شيئًا لسحبه، لذا يقع خصم الرصيد على عاتق خادمك الخاص.
تتراجع Apple عن عمليات رد أموالها الخاصة في بعض الأحيان. يشير إشعار REFUND_REVERSED إلى أن رد أموال تمت الموافقة عليه قد تم التراجع عنه، وتعود قيمة revocationDate إلى null، ويجب إعادة كل ما قمت بإزالته.
تضم خاصية voidedReason في Google تسع قيم مختلفة، تتراوح بين remorse وaccidental_purchase وصولًا إلى fraud وfriendly_fraud وunacknowledged_purchase، بحيث تخبرك خلاصة واحدة متى تقطع الوصول وتشير أيضًا إلى الحالات التي تبدو مريبة من ناحية الاحتيال.
طوال الوقت الذي تحتفظ فيه عملية شراء تم رد أموالها بإمكانية الوصول، فأنت تموّل استخدامها لموارد الحوسبة والتخزين واستدعاءات API من عائدات أُعيدت فعليًا إلى المشتري.
لماذا يستمر الوصول بعد رد الأموال افتراضيًا
تتعامل كل من الشركتين مع الدفع ومع entitlement باعتبارهما عمليتين منفصلتين. يتبع الدفع سياسة المتجر، وبعد الموافقة على رد الأموال تتم تسوية العملية وتعكسها السجلات المالية. أما entitlement فهو أمر مختلف، فهو اختياري في بعض الأحيان، وفي أحيان أخرى يقع على عاتقك التعامل معه، وغالبًا ما يكون كلا الأمرين معًا تبعًا للمنصة ونوع المنتج. لا يفترض أي من المتجرين أن إعادة الأموال تعني إنهاء الوصول، لأن كثيرًا من الحالات المشروعة، كرد جزئي للأموال، أو رصيد كبادرة حسن نية، أو إجراء من فريق الدعم، يُقصد منها أن يظل العميل محتفظًا بما اشتراه.
Google Play: خانة الاختيار التي تمر دون أن يلحظها أحد
تقسّم Play Console عملية رد الأموال إلى قرارين. الأول هو المبلغ، والثاني، وهو منفصل تمامًا، ما إذا كان خيار "Remove entitlement" سيُحدَّد أم لا. يترك معظم وكلاء الدعم هذا الخيار دون تغيير، وهو أمر منطقي بالنظر إلى أن معظم طلبات رد الأموال هي في حقيقتها كبادرة حسن نية ويُفترض أن يحتفظ العميل بما حصل عليه. وما لا تذكره شاشة التأكيد أبدًا هو أن هذه الخانة نفسها، إذا تُركت دون تحديد، هي السبب في أن يظل شخص ما محتفظًا باشتراك أو عنصر داخل التطبيق بعد عملية رد أموال كنت تقصد أن تكون نهائية.
نفس الفخ موجود في API
يقدّم الكود نفس التفرّع. تتضمن نقطة النهاية orders.refund معيار استعلام اختياريًا يُسمى revoke، وضبطه على true ينهي الوصول إلى العنصر فورًا، ويلغي أيضًا كل دفعة مستقبلية في حال كان الأمر يتعلق باشتراك متكرر. وإذا تُرك هذا المعيار دون تحديد، فستحصل على القيمة false: تتم عملية رد الأموال، ويتغيّر سجل الشراء، ويستمر العميل تمامًا كما كان من قبل.
رد الأموال في Play Console مع ترك "Remove entitlement" غير محدد، يستمر الوصول. ولا يصل الطلب إلى Voided Purchases API أيضًا.
رد الأموال في Play Console مع تحديد "Remove entitlement"، ينتهي الوصول فورًا. ويظهر الطلب في Voided Purchases API.
استدعاء orders.refund وترك معيار revoke دون تحديد، بحيث تكون قيمته الافتراضية false، فيستمر الوصول. ومرة أخرى، لا يصل شيء إلى Voided Purchases API.
استدعاء orders.refund مع revoke=true، ينتهي الوصول فورًا، ويظهر الطلب في Voided Purchases API.
مهمة إلغاء الوصول التي لا تقرأ إلا من Voided Purchases API لن تصادف أبدًا عملية رد أموال صدرت دون علم revoke. فالسجل ليس متأخرًا، بل إنه لن يصل مطلقًا، ويبقى الطلب خارج هذه الخلاصة كما لو أن عملية رد الأموال لم تحدث إطلاقًا.
هذا السطر الأخير هو ما يُعطّل عمليات الدمج (integrations). إن بناء خط أنابيب لإلغاء الوصول دون متابعة Play Console يدويًا هو تمامًا الغرض من وجود Voided Purchases API، لكن نطاقه يقتصر على الطلبات التي أُلغيت فعليًا مع revocation. وعمليات رد الأموال التي تمت دون تحديد الخانة، أو دون revoke=true، تكون غير مرئية هناك بحسب التصميم وليس بسبب أي خلل.
فهم معنى voidedReason
تصل عمليات الشراء الملغاة مصحوبة بقيمة voidedReason تشرح السبب من بين تسع قيم ممكنة: other وremorse وnot_received وdefective وaccidental_purchase وfraud وfriendly_fraud وchargeback وunacknowledged_purchase. والتعامل مع هذا باعتباره مجرد توثيق للسجلات يهدر فائدته. فـ remorse وaccidental_purchase تصفان عمليات رد أموال عادية لا شيء مميز فيها. أما fraud وfriendly_fraud وchargeback فتستحق إحالتها إلى قائمة انتظار لمراجعة الاحتيال بدلًا من إلغاء الوصول وإغلاق الحالة تلقائيًا، لأن الحسابات التي تنتج هذه القيم تتكرر بمعدل أعلى إحصائيًا.
أي شخص يبني حلًا يعتمد مباشرة على هذه الخلاصة يحتاج إلى رقم تشغيلي واحد: يسمح purchases.voidedpurchases.list بـ 6,000 استعلام يوميًا، وبحد أقصى 30 استعلامًا خلال أي فترة 30 ثانية. وحلقات الاستقصاء (polling) المتكررة بشكل مكثف تستهلك هذا الحد المسموح به بسرعة. والتصميم المقصود هو عمليات سحب مُجدولة باستخدام pagination tokens.
Apple: يمكن سحب التراخيص، ولا يمكن سحب العناصر الاستهلاكية
تُرسل App Store Server Notifications V2 إشعار REFUND في اللحظة التي تتم فيها تسوية عملية رد الأموال، ويغطي ذلك العناصر الاستهلاكية وغير الاستهلاكية والاشتراكات ذاتية التجديد والاشتراكات غير المتجددة دون تفريق. وما يُفترض حدوثه بعد ذلك يختلف تمامًا باختلاف نوع المنتج، لأن نوعين فقط من هذه الأربعة يقعان ضمن آلية الإلغاء (revocation) الخاصة بـ StoreKit.
سبب بقاء العناصر الاستهلاكية خارج هذه الآلية
الصلاحيات الدائمة (entitlements) تخص العناصر غير الاستهلاكية والاشتراكات، وتستطيع أنظمة Apple إيقافها. أما العناصر الاستهلاكية فلا تملك شيئًا من هذا القبيل. فإنفاق عنصر استهلاكي يُسجَّل على أنه تم تسليمه، وهذا لا يترك لـ StoreKit شيئًا لاستعادته. فالعميل الذي اشترى 500 عملة، وأنفقها، ثم حصل على رد أموال، يُعتبر من منظور Apple قد استلم كل شيء بالفعل. وسجلك الخاص هو السجل الوحيد الذي لا تزال تلك العملات موجودة فيه. وإشعار REFUND هذا هو تنبيهك الوحيد لتحديد موقع رقم المعاملة الأصلي (transaction ID) المطابق وخصم الرصيد بنفسك، لأن شيئًا لن يحدث من جانب Apple.
REFUND يعني أن عملية شراء، استهلاكية أو غير استهلاكية أو ذاتية التجديد أو غير متجددة، قد تمت رد أموالها. اسحب الوصول إلى العناصر غير الاستهلاكية والاشتراكات فورًا، واخصم أي رصيد استهلاكي بنفسك.
REFUND_DECLINED يعني أن Apple رفضت طلب العميل لرد الأموال. ولا حاجة لاتخاذ أي إجراء، لأن الوصول لم يكن في خطر أصلًا.
REFUND_REVERSED يعني أن عملية رد أموال كانت Apple قد وافقت عليها قد تم التراجع عنها. أعِد كل ما أدى إشعار REFUND الأصلي إلى إزالته.
CONSUMPTION_REQUEST يعني أن Apple تطلب منك تقديم بيّنات قبل أن تتخذ قرارها. استجب خلال 12 ساعة، ولاحظ أن هذا الإشعار يصل قبل REFUND وليس بعده.
الإشعار الذي يتجاهله الجميع تقريبًا
REFUND_REVERSED هو الإشعار الذي تتجاهله عمليات الدمج (integrations) في العادة، لأنه يمثل تراجع Apple عن قرار اتخذته هي نفسها، وهو أمر نادرًا ما يصمم أحد له منذ البداية. ووصوله يُعيد قيمة revocationDate إلى null، أي أن Apple تعتبر الآن عملية الشراء صالحة تمامًا من جديد. وأي شيء قام به خادمك عند حدوث REFUND الأصلي، كحذف entitlement، أو إلغاء سجل اشتراك، أو تصفير رصيد استهلاكي، يحتاج إلى التراجع عنه.
لا تتوقع أن يشبه رد الأموال الذي جرى التراجع عنه عملية شراء جديدة، لأنه لن يكون كذلك. حظر مفروض، وصول مسحوب، رصيد مُسجَّل كأنه أُنفق: REFUND_REVERSED هو التوجيه بالتراجع عن هذه الأمور بالتحديد، ولن يصل أي إشعار لاحق ليذكّرك بذلك.
سد الفجوة، خطوة بخطوة
اجعل revoke=true هو الخيار الافتراضي في كل مسارات رد الأموال على Play، سواء عبر Console أو API، واحتفظ بالخيار البديل لحالات الدعم التي تستدعي تحديدًا احتفاظ العميل بالوصول.
اسحب بيانات purchases.voidedpurchases.list وفق جدول زمني باستخدام pagination tokens بدلًا من تكرار الاستدعاء بشكل مكثف، مع الحفاظ على عدد الطلبات أقل من 6,000 يوميًا وأقل من 30 لكل 30 ثانية.
وجّه قيم voidedReason الخاصة بـ fraud وfriendly_fraud وchargeback إلى قائمة انتظار للمراجعة بدلًا من التعامل معها تلقائيًا بإلغاء الوصول وإغلاق الحالة.
اشترك في App Store Server Notifications V2 لدى Apple، واسحب الوصول إلى العناصر غير الاستهلاكية والاشتراكات بمجرد أن يحدّدها إشعار REFUND.
احتفظ برقم المعاملة الأصلي (transaction ID) مرتبطًا بكل عملية شراء استهلاكية، بحيث يستطيع إشعار REFUND تحديد الرصيد الدقيق المطلوب خصمه دون أن يبحث عنه أحد يدويًا.
نفّذ معالجة REFUND_REVERSED بشكل متعمَّد، وسجّل كل عملية إلغاء وصول مرتبطة برقم المعاملة أو الطلب الخاص بها، بحيث تُعيد عملية التراجع بالضبط ما تم أخذه.
ما الذي تكلفه هذه الفجوة فعليًا
العدالة تجاه العميل ليست القضية هنا. القضية هي ما الذي يستمر في العمل بعد أن تذهب العائدات. اشتراك تم رد أمواله ولا يزال يستدعي API الخاص بك، ولا يزال يحتفظ بالبيانات، ولا يزال يستهلك موارد الحوسبة، لا يمثل أي مشكلة أخلاقية على الإطلاق؛ بل يمثل فاتورة. فقد دُفعت تكلفة البنية التحتية مقابل عملية شراء أعدت أنت عائداتها بالفعل. ولا يتابع أي من المتجرين هذه التكلفة نيابةً عنك، لأنه من منظورهما انتهت عملية رد الأموال في اللحظة التي تحركت فيها الأموال. والتحكم في جانب الوصول هو تكلفة تقع كاملةً على عاتقك لإدارتها.
لهذا تتعامل Refund Sensor مع إلغاء الوصول (revocation) كجزء من خط أنابيب رد الأموال بدلًا من كونه إجراءً يُضاف لاحقًا بشكل منفصل. يُكتب حدث REFUND وتغيير الوصول المقابل له كعملية واحدة، ويستعيد حدث REFUND_REVERSED الحالة السابقة من تلقاء نفسه، بدلًا من انتظار أن يلاحظ أحد أن عميلًا محروم من شيء أعادته Apple له منذ أيام.
أين تم توثيق هذه القواعد
الأسئلة الشائعة
لا، لا يحدث ذلك. ينتهي الوصول فقط في الحالات التي تم فيها تحديد "Remove entitlement" في Play Console، أو كان استدعاء orders.refund يحمل القيمة revoke=true. وبما أن لا أحد منهما هو الخيار الافتراضي، فإن عمليات رد الأموال تتم بشكل منتظم بينما يستمر العملاء في استخدام المنتج.
يصل إشعار REFUND، لكن StoreKit لا يمكنها إلغاء الوصول إلى عنصر استهلاكي لأن إنفاقه يُحسب على أنه تسليم. ويقع على عاتق خادمك الخاص العثور على المعاملة الأصلية وخصم الرصيد.
يعني أن عملية رد أموال كانت Apple قد وافقت عليها قد تم التراجع عنها. وتعود قيمة revocationDate إلى null، وتُعتبر عملية الشراء صالحة من جديد. ويجب إعادة أي وصول أو رصيد تسبب إشعار REFUND الأصلي في إزالته، ولا تشترك نسبة كبيرة من عمليات الدمج (integrations) في هذا الإشعار على الإطلاق.
لأن نقطة النهاية هذه لا تُعيد سوى الطلبات التي أُلغيت مع revocation. ولا تتضمن عمليات رد الأموال التي تمت دون خيار revoke، لذا فإن أي خط أنابيب يعتمد فقط على هذه الخلاصة سيتجاهلها تمامًا.
ستة آلاف استعلام في اليوم، وبحد أقصى 30 استعلامًا خلال أي نافذة زمنية مدتها 30 ثانية. وقد صُممت نقطة النهاية هذه لعمليات سحب مُجدولة باستخدام pagination tokens، لا للاستقصاء المستمر.



