บทความทั้งหมด
Deep diveใช้เวลาอ่าน 8 นาที

เพิกถอนสิทธิ์การเข้าถึงหลังคืนเงิน: ขั้นตอนที่ 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 จะไม่มีอะไรทำสิ่งนี้ให้คุณเลย

การแจ้งเตือนความหมายสิ่งที่เซิร์ฟเวอร์ของคุณต้องทำ
REFUNDApple คืนเงินสำหรับ consumable, non-consumable, การสมัครสมาชิกแบบต่ออายุอัตโนมัติ หรือแบบไม่ต่ออายุอัตโนมัติเพิกถอนสิทธิ์การเข้าถึง non-consumable และการสมัครสมาชิกทันที และหักยอดคงเหลือของ consumable ด้วยตัวเอง
REFUND_DECLINEDApple ปฏิเสธคำขอคืนเงินของลูกค้าไม่ต้องทำอะไร สิทธิ์การเข้าถึงไม่เคยตกอยู่ในความเสี่ยง
REFUND_REVERSEDApple พลิกกลับการคืนเงินที่เคยอนุมัติไปแล้วคืนสิทธิ์การเข้าถึงหรือยอดคงเหลือใดๆ ที่การแจ้งเตือน REFUND ทำให้คุณเอาออกไป
CONSUMPTION_REQUESTApple ต้องการหลักฐานจากคุณก่อนตัดสินใจเรื่องคืนเงินตอบกลับภายใน 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 วินาที จึงออกแบบมาให้ใช้แบบดึงข้อมูลตามตารางเวลาพร้อมโทเคนแบ่งหน้า ไม่ใช่การดึงข้อมูลต่อเนื่องตลอดเวลา

แหล่งข้อมูลและเนื้อหาเพิ่มเติม

RefundHalt

ระบบอัตโนมัติสำหรับการคืนเงินบน App Store และ Google Play

อ่านต่อ

คำขอคืนเงินครั้งต่อไปกำลังมา

ตั้งค่า RefundHalt ได้ในเวลาพอ ๆ กับการอ่านอีเมลฝ่ายช่วยเหลืออีกฉบับเกี่ยวกับการคืนเงินที่คุณไม่มีโอกาสโต้แย้ง