Open10

StoreKit: SubscriptionInfo.Statusにハマる

kabeyakabeya

StoreKit2の、Product.SubscriptionInfo.Statusにハマっていました。

ドキュメントに

The array can have more than one subscription status if your subscription supports Family Sharing. Provide the customer with service for the subscription based on the highest level of service where the state is subscribed.

というようなことが書いてあるので、複数返ってくるケースもあるのだな、その中から一番グレードの高いものを選べばよいのだな、という認識でした。

ところがやってみると、何かおかしい。status.state == .subscribedのレコードが何かおかしい。

よくよく調べてみると、その前に以下のような前提があってそれを見落としていたんです。

An array that contains status information for a subscription group, including renewal and transaction information.

そのサブスクリプションと同じサブスクリプショングループに所属するすべてのサブスクリプションのステータスが入っているんですね。ファミリーシェアリング云々は小さい話。そもそも他のサブスクのステータスが入ってるんです。

このため、例えば年額プランと月額プランがあって、年額プランが有効というような状態でも、月額プランにstatus.state == .subscribedのレコードが入ってきます。

現在有効なプランは、status.renewalInfoを取って、renewalInfo.currentProductIDを見てやる必要があります。

kabeyakabeya

あと、なにげに不満なのは、サブスクリプショングループ内にあるサブスクリプションの一覧を取得する機能がないことですね。サブスクリプションのIDをApp側で用意して問い合わせしないといけない。
JSONとかで用意して動的に変えられるようにしても良いのですが、App Store Connectと、そのJSONとの二重管理になってしまいます。

サブスクリプショングループのIDだけ指定して、その中の一覧を取得できるだけで良いんですけど。

Product.SubscriptionInfo.status(for:)が、指定したサブスクリプショングループIDに含まれるサブスクのステータスを返す、ということなんですが

An array of Product.SubscriptionInfo.Status. This array is empty if the customer has never subscribed to a product in this subscription group.

ということで、一回もそのグループ内のサブスクリプションを購入してなければ空なんですね。もうやっかいなんです。

kabeyakabeya

メモ。

https://developer.apple.com/jp/app-store/subscriptions/

アップグレード:ユーザーが、現在のサブスクリプションよりもサービスレベルの高いサブスクリプションを購入することを指します。この場合、ユーザーはただちにアップグレードされ、元々のサブスクリプションの利用日数に基づく金額が返金されます。追加のコンテンツや機能をユーザーがただちに利用できるようにしたい場合は、より高いランクのサブスクリプションとして設定し、ユーザーがアップグレードとして購入できるようにしてください。
ダウングレード:ユーザーが、現在のサブスクリプションよりもサービスレベルの低いサブスクリプションを選択することを指します。この場合、現在のサブスクリプションは次回の更新日まで継続され、その後低いレベルと価格で更新されます。
クロスグレード:ユーザーが、同等のレベルの新しいサブスクリプションに切り替えることを指します。サブスクリプションの期間が同じであれば、新しいサブスクリプションはただちに開始されます。期間が異なる場合は、新しいサブスクリプションは次回の更新日に有効になります。

アップグレード、ダウングレード、クロスグレードのとき、それぞれこのstatusが何を返してくるのか、調べる必要がありますね。

kabeyakabeya

あと、ファミリーシェアリングですね。

これ、どれぐらいのユーザがファミリーシェアリングという機能を使ってるんですかね。
少なくとも我が家は使ってません。

あからさまにユーザにもベンダーにもメリットがありそうなので、実装してみたいのはやまやまなのですが、ケースが複雑になりすぎる気がして二の足を踏んでいます。

  • ファミリーシェアになっているプランと、そのユーザが購入したプランの期間が違うケース
  • ファミリーにユーザが追加されたり抜けたりするケース
  • 既存のプランが有効なところに、ファミリーシェアの上位プランが後から有効になるケース
  • 上記の状態で、どっちかがキャンセルされるケース
  • ファミリープランが有効なところに、ユーザの上位プランが後から有効になるケース
  • 上記の状態で、どっちかがキャンセルされるケース

ざっと思いついて書くだけでこうなので、ちゃんとマトリクスなり状態遷移表を書くともっと複雑ですよね…。
どうすっかな。
有効なプランの判定自体は複雑ではない気がしますが、課金の話なんで漏れがあると嫌ですもんね。

kabeyakabeya

Product.SubscriptionInfo.status(for:)が、指定したサブスクリプショングループIDに含まれるサブスクのステータスを返す、ということなんですが

これ、返ってきませんね。
Product.products(for:)で取ってきた[Product].firstから、Product.subscription.statusでたどって取ると取れます。

なんでしょうね。

kabeyakabeya

複数のプランで、アップグレードとかダウングレード、クロスグレードやキャンセルなど組み合わせて動きを見ています。

Transaction.updatesの監視をしたり、Transaction.currentEntitlementsで最新のトランザクションを参照したりして、有効なトランザクションをとってきた上で、個々のProductごとに自分のトランザクションについて処理する、としていたんですが、それだけでは足りないと感じる部分があります。
(すごく問題かというとそうでもない部分ではありますが)

例えば、上位プランから下位プランへのダウングレードの場合、上位プランの有効期間が切れたタイミングで下位プランが自動更新されます。

トランザクションは、上位プランのレコードのRenewalInfo.autoRenewPreferenceに、下位プランのproductIDが入ってきます。下位プラン自身のproductIDのトランザクションは、自動更新されるまで作られません。

このため、下位プランのボタンとかの状態を、下位プラン自身のproductIDを持つトランザクションだけで設定しようとすると完全には表現しきれません。
(この場合、下位プランはもう発注済み・未購入という状態ですが、下位プランのトランザクションに発注したという情報がなくボタンは未購入という状態のままなので、ユーザは「あれ?ダウングレードしてなかったっけ?もう一回押す?」ってなります。ちなみにもう一回押すと「あなたはもう発注してますよ?」みたいなメッセージがOS側から表示されます)

上位プラン側に「次回更新時(yyyy/mm/dd)からは下位プランになります」って書くだけでも良いという気もしますが、やっぱりちょっと不親切。

全プランのトランザクションを見て、各プランがどういう状態かを判定して、そのうえで各プランのボタンの状態とか説明文言を調整するというようなことが必要なのかなと思います。

そこまでやらなくても、機能的には購入自体は動作するんですけども。

kabeyakabeya

あと、サブスクリプションの場合、請求の猶予期間というのが重要そうですね。

https://developer.apple.com/jp/help/app-store-connect/manage-subscriptions/enable-billing-grace-period-for-auto-renewable-subscriptions/

クレジットカードやiTunesカードの残高がないだとか、クレジットカードが期限切れしてるだとかで更新できない場合、猶予期間がないとすぐにサブスク解除になってしまうということなんですね。

猶予期間があると、猶予期間内はサブスク解除されないで、App側は猶予期間中は更新が済んでいるかのように権限を与える必要があります。猶予期間に決済されない場合はそこでSubscripitonStatus.state == .expiredになるんでしょうか、とにかくApp側で権限を剥奪します。猶予期間内に決済された場合は、自動更新日に更新がされたかのような状態になるのだろうと推測します。

新しいトランザクションが起票されて、RenewalInfo.gracePeriodExpirationDateに猶予期間終了日が入ってくるということなんですかね。
ともかくこの辺の、猶予期間に関係するトランザクションの動きも調べましょうか…

kabeyakabeya

もう1つ、返金ですね。

返金に対応しないApp(というかサブスクリプション)の場合だと、キャンセルしても返金はされず、代わりに自動更新がされない、というようなステータスになります。App側は、有効期間中はサービスを継続します。
返金だと、返金依頼をAppからApp Storeにだし、App Store側で処理されて、トランザクションに返金の印がついたレコードが入ってくる、というような感じのようですね。返金の場合は、App側はサービスを即座に停止します。

ただこうやってステータス遷移が増えるのは、1個しかプランがなければいいのですが複数プランあると大変。

  • アップグレード、ダウングレード、クロスグレード
  • ファミリーシェアリング
  • 猶予期間
  • キャンセル(自動更新の停止)
  • 返金

最初から欲張らない方が良さそうです。

kabeyakabeya

Grace Period(請求の猶予期間)に入ったトランザクションが、Transaction.currentEntitlementsに入ってきません。(StoreKit Configを使ったローカルテスト)

以下の公式フォーラムのトピックに

https://forums.developer.apple.com/forums/thread/697369

if Billing Grace Period is enabled then it should have service rendered until grace period expires date. This status should be reflected in SK2 and the status from App Store Server API Subscription Status. If not, please file FeedbackAssistant.Apple.com

というAppleのエンジニアからの回答が載っていますので(2年も前?)、本番環境(あるいはSandbox環境)では入ってくるのが正しいんでしょうか。

それともSubscriptionInfo.statusから取るべきなんでしょうか。

kabeyakabeya

猶予期間に入ったときは、SubscriptionInfo.statusから情報を取ってくるしかなさそうです。

ちなみに。

猶予期間に決済されない場合はそこでSubscripitonStatus.state == .expiredになるんでしょうか

ならなかったですね。
また猶予期間に入ったときでもSubscriptionStatus.state == .inGracePeriodにもなりません。

猶予期間に入ったときも出たときもSubscriptionStatus.state == .inBillingRetryPeriodになってしまいます。

このため、猶予期間の判定としてはrenewalInfo.gracePeriodExpirationDate?を使って、

  • nilの場合→猶予期間に入ってない。
  • non-nilの場合→猶予期間に入った。
  • non-nilかつ、gracePeriodExpirationDate < Date.nowの場合→猶予期間を出た。

というような感じになるのかなと思います。
猶予期間内に支払われた場合は、Transaction.updatesに新たなトランザクションが入ってきて、renewalInfo.gracePeriodExpirationDate = nilになります。