Việc chuyển tiền và việc chấm dứt quyền truy cập là hai sự kiện tách biệt trên cả hai nền tảng, và chỉ một trong hai diễn ra tự động. Google đặt việc xóa entitlement sau một tùy chọn mặc định ở trạng thái tắt. Apple xử lý license nhưng không thể can thiệp vào một consumable mà ai đó đã tiêu dùng. Bất cứ thứ gì server của bạn không dọn dẹp được sẽ tiếp tục chạy bằng doanh thu mà bạn không còn có.
Những điểm chính
Hoàn tiền một đơn hàng Play sẽ không ảnh hưởng đến quyền truy cập của khách hàng trừ khi bạn chỉ định khác. Hoặc ô "Remove entitlement" được tick trong Play Console, hoặc lệnh gọi API của bạn có revoke=true. Không điều nào tự xảy ra.
Các khoản hoàn tiền được thực hiện mà không có tùy chọn revoke đó sẽ hoàn toàn không xuất hiện trong Voided Purchases API. Bất kỳ công việc thu hồi (revocation job) nào chỉ được xây dựng dựa trên nguồn dữ liệu đó sẽ âm thầm bỏ qua từng trường hợp một.
License cho non-consumable và subscription là thứ Apple có thể thu hồi giúp bạn. Consumable được tính là đã giao ngay khi chúng được tiêu dùng, khiến StoreKit không còn gì để thu hồi, vì vậy việc trừ số dư thuộc về trách nhiệm của server của bạn.
Đôi khi Apple hoàn tác chính các khoản hoàn tiền của mình. Một thông báo REFUND_REVERSED cho biết một khoản hoàn tiền đã được cấp đã bị hoàn tác, revocationDate trở lại thành null, và mọi thứ bạn đã tước bỏ cần được khôi phục lại.
Chín giá trị riêng biệt xuất hiện trong voidedReason của Google, trải từ remorse và accidental_purchase cho đến fraud, friendly_fraud, và unacknowledged_purchase, vì vậy một nguồn dữ liệu vừa hướng dẫn bạn cắt quyền truy cập, vừa đánh dấu những trường hợp có dấu hiệu lạm dụng.
Chừng nào một giao dịch mua đã được hoàn tiền vẫn còn giữ quyền truy cập, bạn vẫn đang chi tiền cho compute, storage, và các lệnh gọi API của nó bằng doanh thu đã được trả lại cho người mua.
Vì sao quyền truy cập vẫn tồn tại sau khi hoàn tiền theo mặc định
Cả hai công ty đều coi thanh toán và entitlement là hai hoạt động riêng biệt. Thanh toán tuân theo chính sách của store, và khi được chấp thuận, khoản hoàn tiền sẽ được xử lý xong và được ghi nhận vào sổ sách. Entitlement lại là một vấn đề khác, đôi khi là tùy chọn, đôi khi là việc bạn phải tự xử lý, và thường thì là cả hai, tùy thuộc vào nền tảng và loại sản phẩm. Không store nào cho rằng việc trả lại tiền đồng nghĩa với việc chấm dứt quyền truy cập, vì có rất nhiều trường hợp hợp lý, một khoản hoàn tiền một phần, một khoản tín dụng thiện chí, một cử chỉ từ bộ phận hỗ trợ, đều nhằm mục đích để khách hàng tiếp tục giữ thứ họ đã mua.
Google Play: ô tick dễ bị bỏ qua
Play Console chia việc hoàn tiền thành hai quyết định. Trước tiên là số tiền, sau đó, hoàn toàn riêng biệt, là việc liệu ô "Remove entitlement" có được tick hay không. Hầu hết nhân viên hỗ trợ đều để nguyên ô đó, điều này hợp lý vì hầu hết các yêu cầu hoàn tiền thực sự là thiện chí và khách hàng được phép giữ lại sản phẩm. Điều mà màn hình xác nhận không bao giờ đề cập đến là chính ô chưa được đụng tới này là lý do khiến ai đó vẫn giữ được một subscription hoặc một vật phẩm trong ứng dụng sau một khoản hoàn tiền mà bạn dự định là dứt điểm.
Cùng một cái bẫy tồn tại trong API
Trong code, cùng một ngã rẽ cũng xuất hiện. Một tham số query revoke tùy chọn thuộc endpoint orders.refund, và việc đặt nó thành true sẽ chấm dứt quyền truy cập vào item ngay lập tức, đồng thời hủy mọi khoản thanh toán trong tương lai nếu đó là một subscription định kỳ. Bỏ qua tham số này và bạn sẽ nhận được false theo mặc định: khoản hoàn tiền được ghi nhận, bản ghi giao dịch mua thay đổi, và khách hàng tiếp tục sử dụng đúng như trước.
Hoàn tiền trong Play Console với "Remove entitlement" không được tick, và quyền truy cập vẫn tiếp tục. Đơn hàng đó cũng không bao giờ xuất hiện trong Voided Purchases API.
Hoàn tiền trong Play Console với "Remove entitlement" được tick, và quyền truy cập chấm dứt ngay lập tức. Đơn hàng đó sẽ xuất hiện trong Voided Purchases API.
Gọi orders.refund và bỏ qua tham số revoke, nên nó mặc định là false, và quyền truy cập vẫn tiếp tục. Một lần nữa, không có gì xuất hiện trong Voided Purchases API.
Gọi orders.refund với revoke=true, và quyền truy cập chấm dứt ngay lập tức, với đơn hàng xuất hiện trong Voided Purchases API.
Một công việc thu hồi chỉ đọc từ Voided Purchases API sẽ không bao giờ gặp được một khoản hoàn tiền được thực hiện mà không có cờ revoke. Bản ghi đó không bị trễ. Nó đơn giản là sẽ không bao giờ đến, và đơn hàng đó nằm ngoài nguồn dữ liệu đó như thể khoản hoàn tiền chưa từng xảy ra.
Dòng cuối cùng đó chính là điều phá vỡ các tích hợp. Việc xây dựng một pipeline thu hồi mà không cần theo dõi Play Console bằng tay chính xác là mục đích của Voided Purchases API, nhưng phạm vi của nó chỉ là các đơn hàng thực sự bị void kèm revocation. Các khoản hoàn tiền được xử lý mà không có ô tick đó, hoặc không có revoke=true, sẽ vô hình ở đó theo thiết kế, không phải do bất kỳ lỗi nào.
Hiểu rõ voidedReason
Các giao dịch mua bị void đến kèm với một voidedReason giải thích nguyên nhân, trải dài trên chín giá trị khả dĩ: other, remorse, not_received, defective, accidental_purchase, fraud, friendly_fraud, chargeback, và unacknowledged_purchase. Coi đó chỉ là việc ghi chép thông thường là một sự lãng phí. Remorse và accidental_purchase mô tả những khoản hoàn tiền không có gì đáng chú ý. Fraud, friendly_fraud, và chargeback xứng đáng được đưa vào một hàng đợi xem xét gian lận thay vì tự động revoke-and-close, vì các tài khoản tạo ra những giá trị đó có tỷ lệ lặp lại cao hơn về mặt thống kê.
Bất kỳ ai xây dựng trực tiếp dựa trên nguồn dữ liệu này cần nắm một con số vận hành: purchases.voidedpurchases.list cho phép 6,000 truy vấn mỗi ngày và 30 truy vấn trong bất kỳ khoảng 30 giây nào. Các vòng lặp polling liên tục sẽ nhanh chóng dùng hết hạn mức đó. Các lượt kéo dữ liệu theo lịch trình sử dụng pagination token mới là thiết kế được dự định.
Apple: license có thể bị thu hồi, consumable thì không
App Store Server Notifications V2 gửi một thông báo REFUND ngay khi khoản hoàn tiền được xử lý xong, áp dụng cho consumable, non-consumable, subscription tự động gia hạn, và subscription không tự động gia hạn, không có sự phân biệt. Điều cần xảy ra tiếp theo lại khác nhau hoàn toàn theo loại sản phẩm, vì chỉ có hai trong bốn loại đó thuộc phạm vi cơ chế thu hồi riêng của StoreKit.
Lý do consumable nằm ngoài cơ chế này
Entitlement thường trực thuộc về non-consumable và subscription, và hệ thống của Apple có thể tắt những entitlement đó. Consumable không có thứ gì như vậy. Việc tiêu dùng một consumable đánh dấu nó là đã được giao, khiến StoreKit không còn gì để lấy lại. Một khách hàng mua 500 coin, tiêu hết số coin đó, rồi được hoàn tiền, thì từ góc nhìn của Apple, họ đã nhận được mọi thứ rồi. Sổ sách của bạn là nơi duy nhất mà những coin đó vẫn còn tồn tại. Thông báo REFUND đó là lời nhắc duy nhất để bạn tìm ra transaction ID gốc tương ứng và tự trừ số dư, vì không có gì từ phía Apple sẽ làm điều đó.
REFUND có nghĩa là một giao dịch mua consumable, non-consumable, auto-renewable, hoặc non-renewing đã được hoàn tiền. Thu hồi quyền truy cập non-consumable và subscription ngay lập tức, và tự trừ bất kỳ số dư consumable nào.
REFUND_DECLINED có nghĩa là Apple đã từ chối yêu cầu hoàn tiền của khách hàng. Không có gì cần làm, vì quyền truy cập chưa bao giờ bị đe dọa.
REFUND_REVERSED có nghĩa là một khoản hoàn tiền mà Apple đã cấp trước đó đã bị hoàn tác. Khôi phục lại bất cứ thứ gì mà thông báo REFUND gốc đã khiến bạn xóa bỏ.
CONSUMPTION_REQUEST có nghĩa là Apple muốn bạn cung cấp bằng chứng trước khi họ đưa ra quyết định. Phản hồi trong vòng 12 giờ, và lưu ý rằng thông báo này đến trước một REFUND, không phải sau đó.
Thông báo mà hầu hết mọi người bỏ qua
REFUND_REVERSED là thông báo mà các tích hợp thường xuyên bỏ qua, vì nó đại diện cho việc Apple tự hoàn tác một quyết định của chính mình, điều mà hiếm khi ai đó thiết kế sẵn từ đầu. Khi nó xuất hiện, revocationDate sẽ trở lại thành null, nghĩa là Apple hiện coi giao dịch mua đó hoàn toàn hợp lệ trở lại. Bất cứ điều gì server của bạn đã làm với thông báo REFUND gốc, xóa một entitlement, hủy một bản ghi subscription, đưa số dư consumable về 0, đều cần được hoàn tác.
Đừng mong đợi một khoản hoàn tiền bị hoàn tác sẽ giống như một giao dịch mua mới, vì nó sẽ không như vậy. Lệnh cấm đã áp dụng, quyền truy cập đã thu hồi, số dư đã đánh dấu là đã tiêu dùng: REFUND_REVERSED chính là chỉ dẫn để hoàn tác chính xác những điều đó, và sẽ không có thông báo tiếp theo nào xuất hiện sau đó để nhắc bạn.
Lấp đầy lỗ hổng, từng bước một
Đặt revoke=true làm mặc định trên mọi luồng hoàn tiền của Play, cả Console và API, chỉ dành phương án thay thế cho những trường hợp hỗ trợ đặc biệt cần để khách hàng giữ lại quyền truy cập.
Lấy dữ liệu từ purchases.voidedpurchases.list theo lịch trình bằng pagination token, thay vì lặp liên tục, giữ dưới 6,000 request mỗi ngày và 30 request mỗi 30 giây.
Đưa các giá trị voidedReason là fraud, friendly_fraud, và chargeback vào một hàng đợi xem xét, thay vì xử lý revoke-and-close tự động.
Đăng ký App Store Server Notifications V2 trên Apple, và thu hồi quyền truy cập non-consumable và subscription ngay khi một thông báo REFUND xác định chúng.
Lưu giữ transaction ID gốc cho mỗi giao dịch mua consumable, để một thông báo REFUND có thể xác định chính xác số dư cần trừ mà không ai phải tra cứu bằng tay.
Triển khai xử lý REFUND_REVERSED một cách có chủ đích, và ghi lại mỗi lần thu hồi theo transaction ID hoặc order ID của nó, để một lần hoàn tác sẽ khôi phục đúng chính xác những gì đã bị lấy đi.
Lỗ hổng này thực sự gây tốn kém gì
Vấn đề ở đây không phải là sự công bằng với khách hàng. Vấn đề là những gì tiếp tục chạy sau khi doanh thu đã mất. Một subscription đã được hoàn tiền nhưng vẫn gọi API của bạn, vẫn giữ dữ liệu, vẫn tiêu tốn compute không đặt ra bất kỳ khó khăn đạo đức nào cả; nó chỉ đặt ra một hóa đơn. Hạ tầng đã được chi trả cho một giao dịch mua mà doanh thu của nó bạn đã trả lại rồi. Không store nào theo dõi khoản chi phí đó thay cho bạn, vì từ góc nhìn của họ, khoản hoàn tiền đã kết thúc ngay khi tiền được chuyển. Kiểm soát khía cạnh quyền truy cập hoàn toàn là chi phí mà bạn phải tự quản lý.
Vì vậy, Refund Sensor coi việc thu hồi là một phần của pipeline hoàn tiền, thay vì một thứ được gắn thêm sau đó. Một sự kiện REFUND và thay đổi quyền truy cập tương ứng của nó được ghi lại như một hành động duy nhất, và một sự kiện REFUND_REVERSED tự khôi phục lại trạng thái trước đó, thay vì phải chờ ai đó nhận ra một khách hàng bị khóa khỏi thứ mà Apple đã trả lại từ nhiều ngày trước.
Các quy tắc này được ghi chép ở đâu
Câu hỏi thường gặp
Không. Quyền truy cập chỉ chấm dứt khi ô "Remove entitlement" được tick trong Play Console, hoặc lệnh gọi orders.refund có revoke=true. Vì cả hai đều không phải là mặc định, các khoản hoàn tiền thường xuyên được ghi nhận trong khi khách hàng vẫn tiếp tục sử dụng sản phẩm.
Một thông báo REFUND sẽ được gửi đến, nhưng StoreKit không thể thu hồi một consumable vì việc tiêu dùng nó được tính là đã giao hàng. Việc tìm giao dịch gốc và trừ số dư là công việc mà server của bạn phải tự thực hiện.
Nó có nghĩa là một khoản hoàn tiền mà Apple đã cấp trước đó đã bị hoàn tác. revocationDate quay trở lại thành null và giao dịch mua được tính là hợp lệ trở lại. Bất kỳ quyền truy cập hoặc số dư nào mà thông báo REFUND gốc đã khiến bạn xóa bỏ phải được khôi phục, và rất nhiều tích hợp (integration) hoàn toàn không đăng ký nhận thông báo này.
Vì endpoint đó chỉ trả về các đơn hàng đã bị void kèm revocation. Các khoản hoàn tiền được xử lý mà không có tùy chọn revoke sẽ không được bao gồm, vì vậy bất kỳ pipeline nào chỉ dựa vào nguồn dữ liệu đó sẽ hoàn toàn bỏ lỡ chúng.
Sáu nghìn truy vấn mỗi ngày, và không quá 30 truy vấn trong bất kỳ khoảng thời gian 30 giây nào. Endpoint này được thiết kế cho các lượt kéo dữ liệu theo lịch trình sử dụng pagination token, thay vì polling liên tục.



