เพิกถอนสิทธิ์การเข้าถึงหลังคืนเงิน: ขั้นตอนที่ Apple และ Google ไม่ทำแทนคุณ
ทั้ง Apple และ Google สามารถคืนเงินออกไปได้ทั้งที่ลูกค้ายังเก็บสินค้าที่ซื้อไว้เหมือนเดิม บทความนี้อธิบายให้ชัดว่าเมื่อไรระบบจะเพิกถอนสิทธิ์การเข้าถึงให้เองโดยอัตโนมัติ เมื่อไรที่เซิร์ฟเวอร์ของคุณต้องทำเอง และการแจ้งเตือนหนึ่งตัวที่แทบไม่มีใครจัดการ

ประเด็นสำคัญ
- บน Google Play การคืนเงินคำสั่งซื้อหนึ่งรายการไม่ได้เพิกถอนสิทธิ์การเข้าถึงของผู้ใช้ให้เองโดยอัตโนมัติ สิทธิ์การเข้าถึงจะถูกเพิกถอนก็ต่อเมื่อมีการติ๊กเลือก "Remove entitlement" ใน Play Console หรือคำเรียก API ส่งค่า revoke=true ไปด้วย ซึ่งไม่ใช่ค่าเริ่มต้น
- การคืนเงินที่ไม่ได้เลือกตัวเลือก revoke จะไม่ปรากฏใน Voided Purchases API เลย งานเพิกถอนสิทธิ์ที่อ่านข้อมูลจากฟีดนั้นเพียงอย่างเดียวจะพลาดคำสั่งซื้อเหล่านั้นไปทั้งหมดโดยไม่มีการแจ้งเตือนใดๆ
- Apple สามารถเพิกถอนใบอนุญาตของ non-consumable และการสมัครสมาชิกให้เองโดยอัตโนมัติ แต่ consumable ถือว่าถูกส่งมอบไปแล้วทันทีที่ถูกใช้ ดังนั้น StoreKit จึงไม่มีกลไกให้ revoke มันได้ เซิร์ฟเวอร์ของคุณต้องหักยอดคงเหลือด้วยตัวเอง
- Apple ยังสามารถยกเลิกการคืนเงินของตัวเองได้ด้วย การแจ้งเตือน REFUND_REVERSED หมายความว่าการคืนเงินที่เคยอนุมัติไปแล้วถูกพลิกกลับ revocationDate จะกลับไปเป็น null และสิทธิ์การเข้าถึงหรือยอดคงเหลือใดๆ ที่คุณเพิกถอนไปต้องถูกคืนกลับมา
- ฟิลด์ voidedReason ของ Google มีค่าให้เลือกทั้งหมดเก้าค่า ตั้งแต่ remorse และ accidental_purchase ไปจนถึง fraud, friendly_fraud และ unacknowledged_purchase ฟีดเดียวกันที่บอกให้คุณ revoke สิทธิ์การเข้าถึงจึงบอกด้วยว่าการเพิกถอนครั้งไหนเป็นการโกง และครั้งไหนเป็นบั๊กในระบบของคุณเอง
- ทุกวันที่สินค้าที่คืนเงินไปแล้วยังคงสิทธิ์การเข้าถึงอยู่ คือวันที่คุณกำลังจ่ายค่าประมวลผล พื้นที่จัดเก็บ และการเรียก API เบื้องหลังสินค้านั้น ด้วยเงินที่คุณคืนให้ลูกค้าไปแล้ว
การอนุมัติคืนเงินกับการเพิกถอนสิทธิ์การเข้าถึงเป็นสองขั้นตอนที่แยกจากกัน และมีเพียงขั้นตอนเดียวเท่านั้นที่เกิดขึ้นโดยอัตโนมัติ นักพัฒนาที่คิดว่าการคืนเงินที่ดำเนินการแล้วเท่ากับสินค้าที่ถูกเพิกถอนสิทธิ์นั้นเข้าใจผิดทั้งสองแพลตฟอร์มหลัก ด้วยเหตุผลและรูปแบบที่ต่างกัน การรู้ชัดว่าระบบอัตโนมัติหยุดทำงานตรงไหนคือสิ่งที่จำเป็นต่อการ revoke สิทธิ์การเข้าถึงหลังคืนเงิน แทนที่จะมาพบทีหลังหลายเดือนว่ามันไม่เคยเกิดขึ้นจริง
เหตุใดการคืนเงินจึงไม่ revoke สิทธิ์การเข้าถึงโดยค่าเริ่มต้น
ทั้ง Apple และ Google ต่างมองว่าเรื่องเงินกับเรื่องสิทธิ์การเข้าถึงเป็นคนละการทำงานกัน ฝั่งเงินเป็นเรื่องนโยบายของร้านค้า เมื่อคืนเงินได้รับการอนุมัติแล้ว ถือเป็นที่สิ้นสุดและบัญชีจะอัปเดตทันที ฝั่งสิทธิ์การเข้าถึงเป็นเรื่องที่เลือกได้ หรือเป็นหน้าที่ของนักพัฒนา หรือทั้งสองอย่าง ขึ้นอยู่กับแพลตฟอร์มและประเภทของการซื้อ ไม่มีร้านค้าไหนสมมติว่าคุณต้องการให้สิทธิ์การเข้าถึงหายไปทันทีที่คืนเงินเสร็จ เพราะมีหลายกรณีที่ถูกต้องตามปกติ เช่น การคืนเงินบางส่วน เครดิตแสดงน้ำใจ หรือท่าทีช่วยเหลือจากฝ่ายซัพพอร์ต ที่ไม่ได้ตั้งใจจะเอาสินค้าคืนไปด้วยเลย
Google Play: ช่องติ๊กที่ไม่มีใครสังเกตเห็น
ใน Play Console การคืนเงินคำสั่งซื้อหนึ่งรายการเป็นแบบฟอร์มสองส่วน คือเลือกจำนวนเงินที่จะคืน แล้วจึงตัดสินใจแยกต่างหากว่าจะติ๊กเลือก "Remove entitlement" หรือไม่ การปล่อยให้ไม่ติ๊กเป็นเส้นทางที่เจ้าหน้าที่ซัพพอร์ตส่วนใหญ่เลือกใช้เป็นค่าเริ่มต้น เพราะคำขอคืนเงินส่วนใหญ่เป็นท่าทีแสดงน้ำใจที่ลูกค้ายังคงเก็บสิ่งที่ซื้อไว้ได้ ปัญหาคือไม่มีข้อความใดในหน้ายืนยันการคืนเงินที่บอกคุณว่านี่คือวิธีเดียวกับที่ลูกค้าจะยังคงสมัครสมาชิกหรือไอเทมในแอปไว้ได้ หลังการคืนเงินที่คุณตั้งใจให้เป็นการสิ้นสุดจริงๆ
เส้นทางผ่าน API ก็มีกับดักเดียวกัน
ตัวเลือกแบบเดียวกันนี้มีอยู่ในโค้ดด้วย endpoint ของ orders.refund รับพารามิเตอร์ revoke แบบเลือกได้ เมื่อค่าเป็น true สิทธิ์การเข้าถึงไอเทมจะถูกยกเลิกทันที และสำหรับการสมัครสมาชิกแบบต่อเนื่อง การชำระเงินในอนาคตทุกครั้งจะถูกยกเลิกไปพร้อมกันด้วย หากไม่ใส่พารามิเตอร์นี้ ค่าเริ่มต้นจะเป็น false นั่นคือการคืนเงินจะดำเนินไป บันทึกการซื้อจะอัปเดต และผู้ใช้ยังคงใช้สินค้าต่อไปได้เหมือนเดิมทุกประการ
| การกระทำ | เพิกถอนสิทธิ์การเข้าถึงหรือไม่? | ปรากฏใน Voided Purchases API หรือไม่? |
|---|---|---|
| คืนเงินผ่าน Play Console, ไม่ติ๊ก "Remove entitlement" | ไม่ | ไม่ |
| คืนเงินผ่าน Play Console, ติ๊ก "Remove entitlement" | ใช่ ทันที | ใช่ |
| orders.refund API, ไม่ใส่พารามิเตอร์ revoke (ค่าเริ่มต้นเป็น false) | ไม่ | ไม่ |
| orders.refund API, revoke=true | ใช่ ทันที | ใช่ |
แถวสุดท้ายนั้นแหละที่มักดักการทำงานของระบบอื่นๆ ให้ผิดพลาด Voided Purchases API มีไว้เพื่อให้คุณสร้างกระบวนการเพิกถอนสิทธิ์โดยไม่ต้องมานั่งดึงข้อมูลจาก Play Console เอง แต่มันแสดงเฉพาะคำสั่งซื้อที่ถูก void พร้อมการเพิกถอนสิทธิ์จริงๆ เท่านั้น การคืนเงินที่ดำเนินการโดยไม่ติ๊กช่อง หรือไม่ได้ตั้งค่า revoke=true จะมองไม่เห็นในฟีดนั้นโดยการออกแบบ ไม่ใช่เพราะบั๊ก

การอ่านฟิลด์ voidedReason เมื่อการซื้อรายการหนึ่งปรากฏสถานะเป็น voided ฟิลด์ voidedReason จะบอกเหตุผลให้คุณทราบ โดยมีค่าให้เลือกทั้งหมดเก้าค่า ได้แก่ other, remorse, not_received, defective, accidental_purchase, fraud, friendly_fraud, chargeback และ unacknowledged_purchase นี่ไม่ใช่แค่เรื่องบันทึกบัญชีเฉยๆ remorse และ accidental_purchase คือการคืนเงินแบบปกติทั่วไป ส่วน fraud, friendly_fraud และ chargeback เป็นค่าที่ควรส่งต่อไปยังคิวตรวจสอบการโกง แทนที่จะปล่อยให้เดินตามเส้นทาง revoke แล้วก็ปล่อยผ่านไปเฉยๆ เพราะบัญชีเดียวกันนี้มีแนวโน้มทางสถิติที่จะเกิดซ้ำได้มากกว่า
มีรายละเอียดด้านการปฏิบัติงานอย่างหนึ่งที่สำคัญ หากคุณสร้างระบบเชื่อมต่อกับฟีดนี้โดยตรง purchases.voidedpurchases.list ถูกจำกัดไว้ที่ 6,000 คำขอต่อวัน และ 30 คำขอในทุกช่วง 30 วินาที การออกแบบที่ดึงข้อมูลถี่ๆ แบบวนลูปจะชนเพดานนี้ได้อย่างรวดเร็ว รูปแบบที่ควรใช้คือการดึงข้อมูลตามตารางเวลาพร้อมโทเคนแบ่งหน้า ไม่ใช่การดึงข้อมูลต่อเนื่องตลอดเวลา
Apple: App Store สามารถ revoke ใบอนุญาตได้ แต่ทำกับ consumable ไม่ได้
App Store Server Notifications V2 ของ Apple จะส่งการแจ้งเตือน REFUND ทันทีที่การคืนเงินเสร็จสมบูรณ์ ไม่ว่าจะเป็น consumable, non-consumable, การสมัครสมาชิกแบบต่ออายุอัตโนมัติ หรือการสมัครสมาชิกแบบไม่ต่ออายุอัตโนมัติก็ตาม สิ่งที่เกิดขึ้นหลังจากการแจ้งเตือนนั้นขึ้นอยู่กับประเภทของการซื้อทั้งหมด เพราะกลไกการเพิกถอนสิทธิ์ของ StoreKit เองครอบคลุมเพียงสองในสี่ประเภทเท่านั้น
เหตุใด consumable จึงเป็นข้อยกเว้น
non-consumable หรือการสมัครสมาชิกมีสิทธิ์การใช้งานถาวรที่ระบบของ Apple สามารถปิดได้ ส่วน consumable ไม่มีสิ่งนั้น เพราะทันทีที่ถูกใช้งาน มันจะถูกถือว่าส่งมอบไปแล้ว และไม่มีอะไรเหลืออยู่ใน StoreKit ให้ revoke ได้อีก หากลูกค้าซื้อเหรียญ 500 เหรียญ ใช้ไปหมดแล้ว และได้รับเงินคืน ในมุมมองของ Apple เหรียญเหล่านั้นหายไปแล้ว มีเพียงบัญชีของคุณเท่านั้นที่ยังมีบันทึกเหรียญเหล่านั้นอยู่ การแจ้งเตือน REFUND คือสัญญาณเดียวที่คุณมี ให้ไปค้นหา transaction ID ต้นฉบับที่ตรงกัน แล้วหักยอดคงเหลือนั้นออกจากบันทึกของคุณเอง เพราะฝั่ง Apple จะไม่มีอะไรทำสิ่งนี้ให้คุณเลย
| การแจ้งเตือน | ความหมาย | สิ่งที่เซิร์ฟเวอร์ของคุณต้องทำ |
|---|---|---|
| REFUND | Apple คืนเงินสำหรับ consumable, non-consumable, การสมัครสมาชิกแบบต่ออายุอัตโนมัติ หรือแบบไม่ต่ออายุอัตโนมัติ | เพิกถอนสิทธิ์การเข้าถึง non-consumable และการสมัครสมาชิกทันที และหักยอดคงเหลือของ consumable ด้วยตัวเอง |
| REFUND_DECLINED | Apple ปฏิเสธคำขอคืนเงินของลูกค้า | ไม่ต้องทำอะไร สิทธิ์การเข้าถึงไม่เคยตกอยู่ในความเสี่ยง |
| REFUND_REVERSED | Apple พลิกกลับการคืนเงินที่เคยอนุมัติไปแล้ว | คืนสิทธิ์การเข้าถึงหรือยอดคงเหลือใดๆ ที่การแจ้งเตือน REFUND ทำให้คุณเอาออกไป |
| CONSUMPTION_REQUEST | Apple ต้องการหลักฐานจากคุณก่อนตัดสินใจเรื่องคืนเงิน | ตอบกลับภายใน 12 ชั่วโมง การแจ้งเตือนนี้เกิดขึ้นก่อน REFUND ไม่ใช่หลังจากนั้น |
การแจ้งเตือนที่แทบไม่มีใครจัดการ
REFUND_REVERSED คือการแจ้งเตือนที่ระบบส่วนใหญ่มักข้ามไปทั้งหมด เพราะมันอธิบายถึงกรณีที่ Apple ยกเลิกการตัดสินใจของตัวเอง ซึ่งเป็นกรณีที่นักพัฒนาน้อยคนออกแบบรองรับไว้ตั้งแต่รอบแรก เมื่อมันมาถึง revocationDate ในธุรกรรมนั้นจะกลับไปเป็น null นั่นหมายความว่าในมุมมองของ Apple การซื้อนั้นกลับมามีผลใช้ได้อีกครั้ง หากเซิร์ฟเวอร์ของคุณลบสิทธิ์การใช้งาน ยกเลิกบันทึกการสมัครสมาชิก หรือปรับยอดคงเหลือของ consumable ให้เป็นศูนย์ ตอนที่ REFUND ต้นฉบับเข้ามา REFUND_REVERSED ก็คือสัญญาณให้คุณคืนทุกอย่างกลับไปเหมือนเดิม
วิธีปิดช่องว่างนี้ ทีละขั้นตอน
- บน Google Play ให้ตั้งค่าทุกเส้นทางการคืนเงินเป็น revoke=true เป็นค่าเริ่มต้น ไม่ว่าจะผ่าน Play Console หรือ API เว้นแต่มีกรณีซัพพอร์ตเฉพาะที่ต้องการให้ลูกค้ายังคงสิทธิ์การเข้าถึงไว้
- ดึงข้อมูลจาก purchases.voidedpurchases.list ตามตารางเวลาพร้อมโทเคนแบ่งหน้า ไม่ใช่แบบวนลูปถี่ๆ และรักษาระดับให้ต่ำกว่า 6,000 คำขอต่อวัน และ 30 คำขอต่อ 30 วินาที
- ส่งค่า voidedReason ที่เป็น fraud, friendly_fraud และ chargeback ไปยังคิวตรวจสอบ แทนที่จะปล่อยให้ revoke แล้วปิดเคสไปโดยอัตโนมัติ
- บนฝั่ง Apple ให้สมัครรับ App Store Server Notifications V2 และ revoke สิทธิ์การเข้าถึง non-consumable และการสมัครสมาชิกทันทีที่การแจ้งเตือน REFUND ระบุถึงรายการนั้น
- บันทึก transaction ID ต้นฉบับไว้กับการซื้อ consumable ทุกรายการ เพื่อให้การแจ้งเตือน REFUND สามารถหายอดคงเหลือที่ต้องหักออกได้อย่างแม่นยำ โดยไม่ต้องค้นหาด้วยมือ
- จัดการกับ REFUND_REVERSED อย่างชัดเจน และบันทึกการเพิกถอนสิทธิ์ทุกครั้งโดยอ้างอิงกับ transaction ID หรือ order ID ของมัน เพื่อให้การพลิกกลับสามารถคืนสิ่งที่ถูกเอาออกไปได้อย่างตรงเป๊ะ
ช่องว่างนี้มีต้นทุนจริงเท่าไร
เรื่องนี้ไม่เกี่ยวกับความเป็นธรรมต่อลูกค้าเลย แต่เกี่ยวกับสิ่งที่ยังทำงานต่อไปหลังจากเงินหายไปแล้วต่างหาก การสมัครสมาชิกที่คืนเงินไปแล้วแต่ยังคงเรียกใช้ API จัดเก็บข้อมูล และใช้ทรัพยากรประมวลผลของคุณอยู่ ไม่ใช่ปัญหาด้านศีลธรรม แต่เป็นรายการค่าใช้จ่ายรายการหนึ่ง คุณกำลังจ่ายค่าโครงสร้างพื้นฐานสำหรับการซื้อที่คุณคืนรายได้ให้ไปแล้ว ร้านค้าไม่ได้ติดตามต้นทุนนั้นให้คุณ เพราะในมุมของพวกเขา การคืนเงินเสร็จสมบูรณ์ทันทีที่เงินเคลื่อนที่ ส่วนเรื่องสิทธิ์การเข้าถึงเป็นค่าใช้จ่ายที่คุณต้องควบคุมเองทั้งหมด
นี่คือเหตุผลเดียวกันที่ RefundHalt ปฏิบัติต่อการเพิกถอนสิทธิ์ในฐานะส่วนหนึ่งของกระบวนการคืนเงิน ไม่ใช่เรื่องคิดทีหลัง เหตุการณ์ REFUND และการเปลี่ยนแปลงสิทธิ์การเข้าถึงจะถูกบันทึกเป็นการกระทำเดียวกัน และเหตุการณ์ REFUND_REVERSED จะคืนสถานะให้โดยอัตโนมัติ แทนที่จะรอให้ใครสักคนสังเกตเห็นว่าลูกค้ายังคงถูกล็อกไม่ให้เข้าถึงสิ่งที่ Apple คืนให้แล้ว
คำถามที่พบบ่อย
- การคืนเงินการซื้อบน Google Play จะ revoke สิทธิ์การเข้าถึงให้โดยอัตโนมัติหรือไม่?
- ไม่ใช่ สิทธิ์การเข้าถึงจะถูกเพิกถอนก็ต่อเมื่อมีการติ๊กเลือก "Remove entitlement" ใน Play Console หรือคำเรียก API ของ orders.refund มีการใส่ revoke=true เท่านั้น ทั้งสองอย่างไม่ใช่ค่าเริ่มต้น ดังนั้นการคืนเงินอาจดำเนินไปได้ ในขณะที่ลูกค้ายังคงใช้สินค้าต่อไปได้
- เกิดอะไรขึ้นเมื่อการซื้อในแอปแบบ consumable ถูกคืนเงินบน App Store?
- Apple จะส่งการแจ้งเตือน REFUND แต่ StoreKit ไม่มีวิธี revoke consumable ได้เลย เพราะถือว่าถูกส่งมอบไปแล้วทันทีที่ถูกใช้ เซิร์ฟเวอร์ของนักพัฒนาต้องค้นหาธุรกรรมต้นฉบับและหักยอดคงเหลือด้วยตัวเองแบบแมนนวล
- การแจ้งเตือน REFUND_REVERSED คืออะไร และต้องมีการจัดการเป็นพิเศษหรือไม่?
- REFUND_REVERSED หมายความว่า Apple พลิกกลับการคืนเงินที่เคยอนุมัติไปแล้ว revocationDate จะกลับไปเป็น null และการซื้อนั้นกลับมามีผลใช้ได้อีกครั้ง สิทธิ์การเข้าถึงหรือยอดคงเหลือใดๆ ที่ REFUND ต้นฉบับเอาออกไปต้องถูกคืนกลับมา และระบบส่วนใหญ่ไม่เคยรับฟังการแจ้งเตือนนี้เลยด้วยซ้ำ
- เหตุใดคำสั่งซื้อ Google Play ที่คืนเงินไปแล้วจึงไม่ปรากฏใน Voided Purchases API?
- API นี้แสดงเฉพาะคำสั่งซื้อที่ถูก void พร้อมการเพิกถอนสิทธิ์เท่านั้น หากการคืนเงินดำเนินการโดยไม่ได้เลือกตัวเลือก revoke คำสั่งซื้อนั้นจะไม่ถูกส่งกลับมาจาก endpoint นี้เลย ดังนั้นกระบวนการที่พึ่งพา API นี้เพียงอย่างเดียวจะพลาดการคืนเงินเหล่านั้นไปโดยสิ้นเชิง
- ฉันสามารถดึงข้อมูลจาก Voided Purchases API ของ Google ได้บ่อยแค่ไหน?
- endpoint นี้ถูกจำกัดอัตราการเรียกไว้ที่ 6,000 คำขอต่อวัน และ 30 คำขอในทุกช่วง 30 วินาที จึงออกแบบมาให้ใช้แบบดึงข้อมูลตามตารางเวลาพร้อมโทเคนแบ่งหน้า ไม่ใช่การดึงข้อมูลต่อเนื่องตลอดเวลา
แหล่งข้อมูลและเนื้อหาเพิ่มเติม
- Apple: Handling refund notifications
- Apple: notificationType (App Store Server Notifications)
- Apple: revocationReason (App Store Server Notifications)
- Google: Voided Purchases API overview
- Google: purchases.voidedpurchases REST resource
- Google: orders.refund API reference (revoke parameter)
- Google Play Console Help: manage your app's orders and issue refunds
RefundHalt
ระบบอัตโนมัติสำหรับการคืนเงินบน App Store และ Google Play
อ่านต่อ
The refunds you cannot contest: what Apple and Google decide without asking you
Only two refund flows ever ask for your evidence: Apple's consumption request and Google Play's chargeback review. Everything else, the 48-hour Play refund, support refunds, voided purchases, unacknowledged trials, is decided without you. Here is the full map, what you can prevent, and what you can only record.
Apple's CONSUMPTION_REQUEST, explained: the 12 hours that decide your refund
When a customer asks Apple for a refund, Apple asks you first. Here's exactly what the CONSUMPTION_REQUEST notification contains, what Apple expects back, how to answer one end to end, and why most developers silently forfeit.