決済の完了とアクセスの終了は、どちらのプラットフォームでも別々の出来事であり、自動的に行われるのはそのうち一方だけです。Googleでは、entitlement(権利)の削除はデフォルトでオフになっているオプションの裏に隠れています。Appleはライセンスの処理は行いますが、すでに消費された消費型アイテムには手を加えられません。サーバー側で後始末できなかった分は、もう手元にない収益のまま動き続けます。
重要なポイント
Playの注文を返金しても、特別な指定をしない限り顧客のアクセスはそのまま残ります。Play Console内で「Remove entitlement」にチェックを入れるか、APIコールにrevoke=trueを含めるか、どちらかの対応が必要です。どちらも自動的には行われません。
revokeオプションを使わずに発行された返金は、Voided Purchases APIには一切表示されません。このフィードだけに基づいて構築された取り消し処理は、そうした返金をすべて静かに見落としてしまいます。
非消費型アイテムやサブスクリプションのライセンスは、Appleが代わりに取り消すことができます。消費型アイテムは消費された瞬間に「提供済み」とみなされるため、StoreKitには取り消す対象が残りません。そのため、残高を差し引く処理は自社サーバー側で行う必要があります。
Appleは自ら行った返金を取り消すことがあります。REFUND_REVERSED通知は、承認済みの返金が取り消されたことを示し、revocationDateはnullに戻り、それまでに取り除いたものはすべて元に戻す必要があります。
GoogleのvoidedReasonには9種類の値があり、remorse(後悔)やaccidental_purchase(誤購入)から、fraud(不正)、friendly_fraud、unacknowledged_purchaseまで多岐にわたります。つまり同じフィードが、アクセスを停止すべきタイミングを示すと同時に、不正の疑いがあるケースにもフラグを立てているのです。
返金済みの購入がアクセスを保持している限り、そのコンピューティング、ストレージ、API呼び出しの費用を、すでに購入者へ返金してしまった収益から支払い続けることになります。
デフォルトでは返金後もアクセスが残る理由
両社とも、決済とentitlement(権利)を別々の処理として扱っています。決済はストアのポリシーに従い、承認されれば返金は確定し、台帳にも反映されます。entitlementは別の問題で、プラットフォームや商品タイプによっては任意だったり、自社で処理すべきものだったり、あるいはその両方の場合もあります。どちらのストアも、お金を返すことがアクセスの終了を意味するとは想定していません。部分返金、善意のクレジット、サポートからの配慮など、顧客が購入したものをそのまま持ち続けられるべき正当なケースが数多くあるからです。
Google Play:見過ごされがちなチェックボックス
Play Consoleでは、返金処理は2つの判断に分かれています。まず金額、そして、それとは完全に別に「Remove entitlement」にチェックを入れるかどうかです。サポート担当者の多くはこの項目をそのままにしておきますが、実際のところ返金依頼の大半は善意によるものであり、顧客が商品を持ち続けることを想定しているため、これは合理的な対応です。確認画面には決して記載されていないのは、この同じ未チェックの項目こそが、あなたが完結させたつもりの返金後も、顧客がサブスクリプションやアプリ内アイテムを持ち続けてしまう理由だということです。
同じ落とし穴がAPIにも存在する
コードレベルでも、まったく同じ分岐が存在します。orders.refundエンドポイントには任意のrevokeクエリパラメータがあり、これをtrueに設定すると、アイテムへのアクセスは即座に終了し、定期購入が関わる場合は今後の支払いもすべてキャンセルされます。このパラメータを省略すると結果はfalseとなり、返金は処理され、購入レコードは変化しますが、顧客はそれまでと変わらず利用を続けられます。
Refund in Play Console with "Remove entitlement" left unticked(Play Consoleで「Remove entitlement」にチェックを入れずに返金する)と、アクセスはそのまま継続します。この注文はVoided Purchases APIにも届きません。
Refund in Play Console with "Remove entitlement" ticked(Play Consoleで「Remove entitlement」にチェックを入れて返金する)と、アクセスは即座に終了します。この注文はVoided Purchases APIにも表示されます。
Call orders.refund and leave the revoke parameter off(orders.refundを呼び出してrevokeパラメータを指定しない)場合、デフォルトでfalseとなり、アクセスはそのまま継続します。この場合もVoided Purchases APIには何も届きません。
Call orders.refund with revoke=true(orders.refundをrevoke=trueで呼び出す)と、アクセスは即座に終了し、注文はVoided Purchases APIに表示されます。
Voided Purchases APIしか読み取らない取り消し処理では、revokeフラグなしで発行された返金に遭遇することは決してありません。記録が遅れているわけではなく、そもそも届かないのです。その注文は、返金が存在しなかったかのように、このフィードの対象外に置かれたままになります。
この最後のパターンこそが連携を破綻させる原因です。Play Consoleを手動で監視せずに取り消しパイプラインを構築することは、まさにVoided Purchases APIが想定している用途ですが、そのスコープは「取り消しを伴って実際に無効化された注文」に限られます。チェックボックスなし、あるいはrevoke=trueなしで処理された返金は、不具合ではなく設計上、そこには表示されません。
voidedReasonを読み解く
無効化された購入には、原因を説明するvoidedReasonが付与されており、other、remorse、not_received、defective、accidental_purchase、fraud、friendly_fraud、chargeback、unacknowledged_purchaseの9種類の値のいずれかが入ります。これを単なる記録として扱うのはもったいないことです。remorseとaccidental_purchaseは、取り立てて問題のない返金を示しています。fraud、friendly_fraud、chargebackについては、自動的な取り消し・完了処理ではなく、不正レビューキューに回すべきです。これらの値を出すアカウントは、統計的に高い頻度で繰り返す傾向があるためです。
このフィードに直接組み込みを行う場合、知っておくべき運用上の数値が1つあります。purchases.voidedpurchases.listは、1日あたり6,000回、30秒間に30回までのクエリが許可されています。タイトなポーリングループでは、この許容量をすぐに使い切ってしまいます。ページネーショントークンを使ったスケジュール実行が、本来想定されている設計です。
Apple:ライセンスは取り消せるが、消費型アイテムは取り消せない
App Store Server Notifications V2は、返金が確定した瞬間にREFUND通知を配信し、消費型アイテム、非消費型アイテム、自動更新サブスクリプション、非更新サブスクリプションのいずれも区別なくカバーします。しかし、次に何をすべきかは商品タイプによって完全に異なります。これら4種類のうち、StoreKit自体の取り消し機構の対象となるのは2種類だけだからです。
消費型アイテムが対象外である理由
持続的なentitlementは非消費型アイテムとサブスクリプションに属しており、Appleのシステムはこれらをオフに切り替えることができます。消費型アイテムにはそのようなものがありません。消費された時点で「提供済み」とマークされるため、StoreKitには取り戻すべき対象が残りません。500コインを購入して使い切り、その後返金を受けた顧客は、Appleの視点からすれば、すでにすべてを受け取り済みです。そのコインがまだ存在しているのは、あなた自身の台帳だけです。このREFUND通知は、該当する元のtransaction IDを特定し、残高を自分で差し引くための唯一の合図です。Apple側では、それ以上何も行われないからです。
REFUNDは、消費型アイテム、非消費型アイテム、自動更新サブスクリプション、非更新サブスクリプションのいずれかの購入が返金されたことを意味します。非消費型アイテムとサブスクリプションのアクセスは直ちに取り消し、消費型アイテムの残高は自分で差し引いてください。
REFUND_DECLINEDは、Appleが顧客の返金リクエストを拒否したことを意味します。アクセスはそもそも危険にさらされていないため、対応は不要です。
REFUND_REVERSEDは、Appleがすでに承認していた返金が取り消されたことを意味します。元のREFUND通知を受けて削除したものを、すべて元に戻してください。
CONSUMPTION_REQUESTは、Appleが判断を下す前にあなたからの証拠を求めていることを意味します。12時間以内に返答してください。なお、これはREFUNDの後ではなく前に届く点に注意が必要です。
ほぼ全員が見落としている通知
REFUND_REVERSEDは、多くの連携実装で日常的に省かれがちな通知です。これはAppleが自らの決定を取り消すものであり、最初の設計段階で想定されることはほとんどないためです。この通知が届くとrevocationDateはnullに戻り、Appleはこの購入を再び完全に有効なものとして扱います。元のREFUNDに対してサーバーが行った処理(entitlementの削除、サブスクリプションレコードのキャンセル、消費型アイテムの残高をゼロにする、など)は、すべて元に戻す必要があります。
取り消された返金が新規購入と同じように扱われることを期待してはいけません。実際にはそうならないからです。適用済みの制限、取り消されたアクセス、消費済みとマークされた残高。REFUND_REVERSEDは、まさにこれらを元に戻すための指示であり、後になってそれを思い出させるような追加の通知が届くことはありません。
ギャップを段階的に埋める
Play Console、APIを問わず、すべてのPlay返金ルートでrevoke=trueをデフォルトにし、顧客がアクセスを保持すべきと明確に判断されるサポートケースに限って例外とします。
purchases.voidedpurchases.listは、タイトなループではなくページネーショントークンを使ったスケジュール実行で取得し、1日6,000リクエスト、30秒あたり30リクエストの範囲内に収めます。
voidedReasonがfraud、friendly_fraud、chargebackの場合は、自動的な取り消し・完了処理ではなく、レビューキューに送ります。
AppleのApp Store Server Notifications V2を購読し、REFUND通知が非消費型アイテムやサブスクリプションを特定したら、直ちにアクセスを取り消します。
すべての消費型アイテムの購入について元のtransaction IDを保存しておき、REFUND通知が届いた際に、誰かが手動で調べることなく、差し引くべき正確な残高を特定できるようにします。
REFUND_REVERSEDを意図的に実装し、すべての取り消しをそのtransaction IDまたはorder IDに紐づけて記録することで、取り消しの取り消しが発生した際に、取り除いたものを正確に復元できるようにします。
このギャップが実際に生むコスト
ここで問題にしているのは顧客に対する公正さではありません。問題は、収益がなくなった後も何が動き続けているかです。返金済みのサブスクリプションがそれでもAPIを呼び続け、データを保持し続け、コンピューティングリソースを消費し続けていること自体に、倫理的な問題はまったくありません。そこにあるのは請求書です。インフラのコストは、すでに収益を返却済みの購入に対して支払われています。どちらのストアもこの費用をあなたの代わりに追跡してくれません。ストア側の視点では、お金が動いた瞬間に返金は完結しているからです。アクセス側の管理は、完全にあなた自身のコストです。
そのため、Refund Sensorは取り消し処理を、後から追加するものではなく返金パイプラインの一部として扱います。REFUNDイベントとそれに対応するアクセス変更は単一のアクションとして記録され、REFUND_REVERSEDイベントは、数日前にAppleが返却したものへのアクセスを顧客がブロックされたままになっていることに誰かが気づくのを待つことなく、自動的に元の状態を復元します。
これらのルールの参照元
よくある質問
取り消されません。アクセスが終了するのは、Play Consoleで「Remove entitlement」にチェックが入れられた場合、またはorders.refundの呼び出しにrevoke=trueが指定された場合のみです。どちらもデフォルトではないため、顧客が商品を使い続けたまま返金処理が行われることが日常的に発生します。
REFUND通知が届きますが、消費することが提供(delivery)とみなされるため、StoreKitは消費型アイテムを取り消すことができません。元のtransactionを特定し、残高を差し引く処理は、自社サーバー側で行う必要があります。
Appleがすでに承認していた返金が取り消されたことを意味します。revocationDateはnullに戻り、購入は再び有効なものとして扱われます。元のREFUNDによって削除したアクセスや残高はすべて復元する必要がありますが、この通知を購読していない連携実装は非常に多く存在します。
このエンドポイントが返すのは、取り消し(revocation)を伴って無効化された注文のみだからです。revokeオプションなしで処理された返金は含まれないため、このフィードだけに依存するパイプラインは、それらを完全に見落としてしまいます。
1日あたり6,000回のクエリ、かつ30秒間に30回以内という制限があります。このエンドポイントは、連続的なポーリングではなく、ページネーショントークンを使ったスケジュール実行を想定して設計されています。



