返金が成立してもアプリ側のフラグは立ったままで、Premium 機能は使われ続けます。App Store Server Notifications V2 の REFUND、Google Play の Voided Purchases、RevenueCat の照合で権限を確実に失効させる実装を記録しました。
壁紙アプリの月次レポートを確認していたとき、売上の一覧に小さなマイナスの行が並んでいることに気づきました。App Store の返金です。金額そのものは大きくありませんでしたが、引っかかったのはそこではありません。返金したユーザーの端末で、買い切りの Premium 解除がそのまま有効になっている可能性に思い当たったのです。
調べてみると、案の定でした。当時の私のアプリは購入成功時に「Premium 済み」のフラグをローカルに保存し、起動時はそれを参照するだけの設計になっていました。返金が成立しても、Apple や Google からアプリへ自動で連絡が来るわけではありません。サーバー側で通知を受け取り、自分で権限を失効させない限り、返金後も機能は使われ続けます。
App Store の返金は、ユーザーと Apple の間で完結します。ユーザーが「問題を報告する」から申請し、Apple が承認すれば返金は成立しますが、その瞬間にアプリへ何かが届くわけではありません。StoreKit 2 では該当トランザクションに revocationDate が記録されますが、これはアプリが能動的にトランザクションを照合して初めて分かる情報です。
Google Play も同様です。Play Console や API 経由の返金で「アクセス権の取り消し」を選ぶと購入は無効化されますが、クライアントに反映されるのは Billing Library が次に購入状態を照合したタイミングになります。
Rork で生成したアプリに RevenueCat を組み込む構成は課金実装の定番ですが、返金検知の観点でも有力です。RevenueCat は Apple と Google の両ストアからサーバー通知を受け取り、返金を検知すると entitlement を自動で失効させてくれます。アプリ側の仕事は CustomerInfo の照合だけになります。
構成B: 自前サーバーで Server Notifications を受ける
サブスク管理を自前で持っている場合は、App Store Server Notifications V2 と Google Play の Real-time Developer Notifications(RTDN)を直接受けます。実装量は増えますが、返金以外の通知(請求リトライ・解約予約・オファー利用)も一次情報として扱えるようになります。
なお、返金に関連して CONSUMPTION_REQUEST という通知も届きます。ユーザーが返金申請したときに「このユーザーはコンテンツをどの程度消費したか」を Apple が尋ねてくるもので、12時間以内に Send Consumption Information API で回答すると返金判断の材料になります。誠実に回答する運用へ切り替えてから、明らかに使い込んだ後の返金は通りにくくなった実感があります。
Android 側 — SUBSCRIPTION_REVOKED と Voided Purchases API
Google Play では、返金とともにアクセス権が取り消されると、サブスクの場合は RTDN で SUBSCRIPTION_REVOKED(notificationType 12)が届きます。一方、買い切りアイテムの返金は RTDN だけでは取りこぼす場合があるため、Voided Purchases API での照合を併用しています。
// Google Play: 返金で無効化された購入を日次バッチで照合するimport { google } from 'googleapis';const androidpublisher = google.androidpublisher('v3');export async function syncVoidedPurchases(packageName: string) { const auth = new google.auth.GoogleAuth({ scopes: ['https://www.googleapis.com/auth/androidpublisher'], }); const res = await androidpublisher.purchases.voidedpurchases.list({ auth, packageName, }); for (const voided of res.data.voidedPurchases ?? []) { // voidedReason: 1=返金 2=チャージバック(0はその他) await revokeByPurchaseToken(voided.purchaseToken); }}
検知と失効を整えたうえで、返金の件数を減らす余地も残っています。iOS 15 以降は presentRefundRequestSheet を呼ぶと、アプリ内から Apple の返金申請シートを開けます。意外に思われるかもしれませんが、私はこの導線を設定画面に置いています。返金の入口をアプリ内に持つと、不満を抱えたユーザーが App Store のレビュー欄ではなくこちらの導線へ流れてくれるからです。