In-App Purchase(自動更新購読 Auto-Renewing subscription)を実装する際に調べたことまとめ

  • 129
    いいね
  • 12
    コメント
この記事は最終更新日から1年以上が経過しています。

概要

本記事を読むことで、iOSアプリでアプリ内課金をするために必要なことがわかるようになります。

要所を押さえて解説するので、詳しくは参考資料等をご覧ください。
なお、今回のメインターゲットは自動更新型(Auto-renewable Subscriptions)なので、その他の種類では一部異なる場合があります。

テスト環境の構築

In-App Purchaseは本番環境の他にテスト環境(SandBox)が用意されています。
開発中はSandBoxを使うことになりますが、利用するために必要なことがいくつかあります。

iTunes Connect

契約/税金/口座情報の設定(未設定の場合)

  1. 「契約/税金/口座情報」を開き、「Paid Applications」をリクエスト
  2. "Contact Info"、"Bank Info"、"Tax Info"の3つをすべて"Set Up"する

注意すべきはTax Infoです。
アメリカ以外で申請する場合、二重課税を防ぐために、米国法人番号 EIN(Employer Identification Number)を取得しておく必要があります。
郵送、Fax、電話で取得できますが、急ぐなら電話を使いましょう。即時発行されます。

cf.
EINの電話での取得方法(即時発行)
EINの取得方法:IRSフォームSS-4の書き方

App 内課金の登録

  1. アプリ内課金したいアプリを選択し、「App 内課金」というところからプロダクトを登録する
  2. 「App 内課金」ページ内から「共有シークレット」の発行

プロダクト登録時に設定したプロダクトIDが、アプリ内での購入申請時や後述するレシート検証時に必要になります。
また、自動更新型の場合、レシート検証にて共有シークレットが必要になります。

テストユーザーの追加

「ユーザーと役割」=>「Sandboxテスター」と進み、アカウントを作成してください。
Sandboxでテストする際は、ここで登録したアカウントのメールアドレスとパスワードを使います。

Xcode

該当するアプリのProvisioning Profile(Development)を用いてアプリをビルドし、実機に転送します。
シミュレーターではテストすることはできません。

iOS

「設定」アプリから「iTunes & App Store」を選択し、ログイン済みであればサインアウトします。

アプリ内課金の実装

処理の流れ

pixivのブログ記事によくまとまっている図があったのでこちらを参照してください。

iOS/Androidアプリ内課金の不正なレシートによる有料会員登録を防ぐ - pixiv inside

実装例

AppDelegate.m
import StoreKit

func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
    /* ... */

    SKPaymentQueue.defaultQueue().addTransactionObserver(observer)

    /* ... */
    return true
}
import StoreKit

class YourAnyClass: UIViewController {
    // 諸々省略

    func startPurchaseTransaction {
        let productRequest = SKProductsRequest(productIdentifiers: ["YOUR_PRODUCT_ID"])
        // 要求完了前に開放されないように強参照しておくこと
        self.request = productsRequest
        productRequest.delegate = self
        productRequest.start()
    }
}

extension YourAnyClass: SKProductsRequestDelegate {
    func productsRequest(request: SKProductsRequest, didReceiveResponse response: SKProductsResponse) {
        for invalidProductIdentifier in response.invalidProductIdentifiers {
            Print("不正なプロダクトID: \(invalidProductIdentifier)")
        }

        for product in response.products {
            let payment = SKPayment(product: product)
            SKPaymentQueue.defaultQueue().addPayment(payment)
        }
    }
}

extension YourAnyClass: SKPaymentTransactionObserver {
    func paymentQueue(queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in transactions {
            switch transaction.transactionState {
            case .Purchasing:
                Print("購入処理中")
            case .Restored:
                Print("リストア完了")
                SKPaymentQueue.defaultQueue().finishTransaction(transaction)

                // 適切なリストア処理(コンテンツの復元やレシート検証)を行う
            case .Failed:
                Print("購入失敗")
                SKPaymentQueue.defaultQueue().finishTransaction(transaction)
            case .Deferred:
                // ファミリー購入で"Ask to Buy"が有効になっている子どものアカウントで購入しようとした場合等
                // Appleによると、購入前と同じように動くように処理する、とのこと
                Print("Ask to Buy")
            case .Purchased:
                Print("購入完了")
                SKPaymentQueue.defaultQueue().finishTransaction(transaction)

                // ここでレシートをサーバーに送信して検証 or ローカル検証 or App Store に送信して検証する
                // ただし、セキュリティ上の観点から、Appleは「iOSアプリで直接AppStoreを用いたレシート検証」を推奨していません
            }
        }
    }
}

注意点

SKPaymentTransactionObserverの登録はアプリ起動時にすること

SKPaymentQueue.defaultQueue().addTransactionObserver() はアプリ起動時に確実に実行してください。

例えば、トランザクションが完了する前にアプリがクラッシュした場合、次回のアプリ起動時にSKPaymentTransactionObserverプロトコルのdelegateメソッドが実行されます。

以下 In-App Purchase プログラミングガイド から引用。

アプリケーションの起動時に、トランザクションキューのオブザーバを登録します(リスト 4-1を参照)。
オブザーバは、キューにトランザクションを追加した直後だけでなく、いつでもトランザクショ ンを処理できることを確認します。

たとえば、トンネルに入る直前にユーザがアプリケーションで何かを購入する場合について考慮します。
ネットワーク接続がないため、アプリケーションは購入されたコンテンツを配信できません。アプリケーションが次回起動されたときに、Store Kitはトランザクションキューのオブザーバを再度呼び出して、購入された項目をその時点で配信します。

同様に、アプリケーションがトランザクションに終了のマークを付けられなかった場合、トランザクションが終了したと適切にマークされるまで、Store Kitはアプリケーションが起動されるたびに毎回オブザーバを呼び出します。

また、自動更新型プロダクトを購入済みで、自動更新が行われた場合、アプリ起動時に購入完了の通知が来ます。

ちなみに、クレカ情報が誤っている/iTunesカード残高が足りない、等で自動更新に失敗した場合、購入失敗通知が来るのかどうか確認できていないので、もしご存知の方がいましたら、コメント欄にて教えて頂けると助かりす(汗

SKProductsRequestは強参照する

2015年10月21日付けでIn-App Purchase プログラミングガイドに追記された内容ですが、SKProductsRequest は必要がなくなるまで強参照しておくことが推奨されています。

App Storeに問い合わせるには、プロダクト要求オブジェクトを使用します。最初に、 SKProductsRequestのインスタンスを作成し、プロダクトIDのリストで初期化します。要求オブジェクトへの強い参照を保持してください。そうしないと、要求が完了しないうちに、システムが割り当て解除してしまうおそれがあります

レシート検証(自動更新継続確認)

In-App Purchaseでは、正規の手順で購入されたものなのかどうか確認する手段が提供されています。

アプリ内で課金処理が完了した際に、Appleからレシートが発行され、iOS端末内に保持されます。
このレシートをローカルで検証またはApp Storeに対して送信することで、正規のレシートであるかどうかを検証することができます。

上記処理にて、正規のレシートであることが確認できた場合、
別アプリの正規購入レシート正しいアプリの別ユーザーによる正規購入レシートでないことをを確認するために、product_id および transaction_id を確認することが望ましいです。

また、App Storeに送信してレシート検証をすることで、最新の購買状況が確認できる(レシートの一覧を入手できる)ため、自動更新が継続しているかどうか確認することが可能です。

本記事では、App Storeに送信する方法について要点を抑えて解説します。
詳しくはAppleの レシート検証 プログラミングガイド を参照して下さい。

レシートの取得例

import StoreKit

// 自動更新されている場合、ローカルには最新のレシートが存在していないので、必要に応じて以下のようなメソッドを用意して取得する
// ただし、App Storeへ接続するためにユーザーにパスワードの入力が求められるので要注意
extension YourAnyClass: SKRequestDelegate {
    private func fetchReceipt() {
        let request = SKReceiptRefreshRequest()
        request.delegate = self
        request.start()
    }

    // MARK: - SKRequestDelegate
    func requestDidFinish(request: SKRequest) {
        // レシート取得完了時にしたい処理
    }

    func request(request: SKRequest, didFailWithError error: NSError) {
        // レシート取得失敗時にしたい処理
    }
}
import StoreKit

// ローカルに存在しているレシートの取得およびbase64エンコード
if let receiptURL = NSBundle.mainBundle().appStoreReceiptURL, receiptData = NSData(contentsOfURL: receiptURL) {
    let receipt = receiptData.base64EncodedStringWithOptions([])

    // レシート認証用サーバーへの送信処理 or ローカル検証 or App Store を用いたレシート検証
    // ただし、セキュリティ上の観点から、Appleは「iOSアプリで直接AppStoreを用いたレシート検証」を推奨していません
}

レシートの値

詳しくはレシート検証 プログラミングガイドを参照してください。

以下上記資料より引用。

レシートは多数のフィールドから構成されています。
フィールドの中にはASN.1形式のレシートでのみ、またはJSON形式のレシートをApp Storeで検証したときにのみ、ローカルで使用できるものがあります。
以下に記載されていないキーは、Appleで使用するために予約されており、アプリケーションでは無視する必要があります。

検証で特に重要なのは product_idtransaction_id です。
自動更新型プロダクトの有効期限確認には、expires_dateexpires_date_ms を利用します。

App Storeを用いたレシート検証方法

以下の表のJSONオブジェクトを、HTTP POSTリクエストのペイロードとして送信します。

Key
receipt-data base64エンコードを施したレシートデータ
password 自動更新型の購読に用いるレシートの場合のみ。 アプリケーションの共有鍵(16進文字列)
※前述の共有シークレット
環境 送信先URL
テスト環境 https://sandbox.itunes.apple.com/verifyReceipt
実稼働環境 https://buy.itunes.apple.com/verifyReceipt

レスポンス

レシート検証 プログラミングガイドから引用しつつ、一部改変。

Key
status レシートが有効であれば0、そうでなければ下表のいずれかのエラーコード。
この値は、当該レシート全体の状態を表す。
たとえば、有効なレシートではあるが、その内容(購読物)は期限切れである場合、レシートそのものは有効なので、応答は0となる
receipt 検証用に送信されたレシートをJSON形式で表したもの
latest_receipt 自動更新型プロダクトがある場合のみ存在。直近の更新に対応するレシートにbase-64エンコードを施したもの。配列
latest_receipt_info 自動更新型プロダクトがある場合のみ存在。直近の更新に対応するレシートをJSON形式で表したもの。配列

ステータスコード一覧

レシート検証 プログラミングガイドから引用及び補足。
ステータスコード 0 以外の場合は、適切に再リクエストすること。

ステータスコード 説明 対応例
0 レシートが有効
21000 App Storeは、提供したJSONオブジェクトを読むことができません。 適切なJSONで送信し直す
21002 receipt-dataプロパティのデータが不正であるか、または欠落しています。 iOSからレシートを再取得して送信し直す
21003 レシートを認証できません。 不正なレシートであるため、課金処理が行われていないとみなし、クライアントには適切に通知
21004 この共有秘密鍵は、アカウントのファイルに保存された共有秘密鍵と一致しません。(自動更新型プロダクトの場合のみ) JSONに共有シークレットが含まれていない or iTunes Connectで確認できる値と異なっているため、適切な値をJSONに含めて送信し直す
21005 レシートサーバは現在利用できません。 時間を置いて同内容で再送信する
21006 このレシートは有効ですが、定期購読の期限が切れています。ステータスコードがサーバに返される際、レシートデータもデコードされ、応答の一部として返されます。(自動更新型プロダクトの場合のみ) iOS 7以降のレシートを送信した場合はこのステータスコードでは返ってこないはず
21007 テスト環境のレシートを、実稼働環境に送信して検証しようとしました。これはテスト環境に送信してください。 同内容をテスト環境に送信し直す
21008 実稼働環境のレシートを、テスト環境に送信して検証しようとしました。これは実稼働環境に送信してください。 同内容を実稼働環境に送信し直す

自動更新継続の確認方法

自動更新型の購読が現時点でアクティブかどうか判断するには、レシート検証した際のレスポンスを利用します。

正常に自動更新された場合には、latest_receipt および latest_receipt_info に新しいレシートが追加されているため、
該当するプロダクトIDの最新レシートの expires_dateexpires_date_ms の値(1970年1月1日 00:00:00 GMTからのミリ秒単位)を確認することで、有効期限が切れているかどうか確認することができます。

※自動更新されるたびにレシートは配列に追加されるため、最新のレシートを探す必要があります

サーバーが用意すべきもの

自動更新型のサービスでは、複数のプラットフォームで利用可能なケースが多いと思います。
iOS以外のプラットフォームでも自動更新が継続しているかどうか確認する必要があるため、サーバー側でレシート検証するのが適しています。

  1. iOSアプリ向けAPI(レシートおよび transaction_id を受け取るAPI)
  2. バッチ処理(レシート検証: 不正チェック、自動更新継続の確認)

注意すべき点

不正なレシートを受理しない工夫

不正なレシートを受理しないようにいくつか工夫すべき点があります。
こちらについても、先ほどのpixivのブログ記事にまとめられているので、そちらを参照してください。

iOS/Androidアプリ内課金の不正なレシートによる有料会員登録を防ぐ - pixiv inside

App Storeを用いたレシート検証では、まず本番環境に送信する

Appleはレビューの際、Sandboxを利用するとのこと。
そのため、App Storeを用いたレシート検証の場合、まずは本番環境に送信するようにし、ステータスコードが 21007 で返ってきたらテスト環境に送信するように実装すること。

cf. iOSアプリ内課金(In-App Purchase)のはまりどころ ‹ ワンダープラネット株式会社(Wonderplanet Inc.)

購読管理

ユーザーが自動更新を解除するには、Mac/WindowsのiTunesまたはiOSのApp Store/iTunes Storeの「Manage Subscription」から行う必要があります。
アプリやWebサービスからは以下のURLで該当画面を開くことができます。

https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/manageSubscriptions

※SandBox環境では開くことができません

おまけ

自動更新購読 Auto-Renewing subscriptionが使えるアプリの種類

以下の様なアプリであればリジェクトされないようです。

  • 定期刊行物
    • 新聞
    • 雑誌
  • ビジネス
    • エンタープライズ
    • 生産的(App Storeの翻訳に合わせるなら"仕事効率化"カテゴリということになるんだろうか)
    • プロ向けクリエイティブツール
    • クラウドストレージ
  • メディア
    • 動画
    • 音楽
    • 音声

以下、App Store Review Guidelines から引用。

11.15 Apps may only use auto-renewing subscriptions for periodicals (newspapers, magazines), business Apps (enterprise, productivity, professional creative, cloud storage), and media Apps (video, audio, voice), or the App will be rejected

レシート検証サンプルコード

iOSアプリからApp Storeへレシートを送り、レシート検証するコードはレシート検証 プログラミングガイドに記載されています。
ですが、未だに NSURLConnection を使ったコードだったので、NSURLSession で書きなおしたものを一応記載しておきます。

※ セキュリティ上の観点から、Appleは「iOSアプリで直接AppStoreを用いたレシート検証」を推奨していません

func validateReceipt {
    if let receiptURL = NSBundle.mainBundle().appStoreReceiptURL, receiptData = NSData(contentsOfURL: receiptURL) {
        let receipt = receiptData.base64EncodedStringWithOptions([])
        let requestContents = ["receipt-data": receipt, "password": "共有シークレット"]

        do {
            let requestData = try NSJSONSerialization.dataWithJSONObject(requestContents, options: [])
            let storeURL = NSURL(string: "https://sandbox.itunes.apple.com/verifyReceipt")!
            let storeRequest = NSMutableURLRequest(URL: storeURL)
            storeRequest.HTTPMethod = "POST"
            storeRequest.HTTPBody = requestData

            let session = NSURLSession.sharedSession()
            let task = session.dataTaskWithRequest(storeRequest, completionHandler: {(data, response, error) -> Void in
                Print("completed!")

                guard let jsonData = data else {
                    // エラー処理
                    return 
                }

                do {
                    let jsonResponse = try NSJSONSerialization.JSONObjectWithData(jsonData, options: [])
                } catch let error ad NSError {
                    // エラー処理
                }
            })
            task.resume()
        } catch let error as NSError {
            Print(error)
            // エラー処理
        }
    }
}

参考資料

Apple 公式ドキュメント

日本語ドキュメント

参考サイト