【Swift】In-App Purchase(自動更新購読Auto-renewable subscriptions)を実装

iOSのIn-App Purchase(自動更新購読 Auto-Renewing subscription)を実装してみたのでそのやり方を書いていきます。この実装でとりあえずは問題なく動いているのですが、やり方・考え方が間違っている、コードで改善点等指摘があればぜひ教えていただきたいです。

1.契約/税金/口座情報の設定
まずここの情報が有効になっていないとsandboxであろうと課金機能が機能しないのでここを完了させておきます。ここテスト段階では全く関係なさそうなんですがやっておかないとだめです。このへん口座情報の登録を完了させてないとという話がでてきますが、Contact Info Bank Info Tax Infoの3つを登録してContracts In Effectにさせておく必要があります。


なお当たり前かもしれませんがappstoreconnectはできればSafariで操作したほうがいいです。ChromeだとContact Infoの設定ができませんでした…

2.App Store ConnectからApp 内課金の追加
App Store Connect(旧itunesconnect)にて対象アプリにApp内課金を追加します。金額とお試し期間(料金)があるならそれもここで設定。審査に関する情報も記載しておきます。ステータスが"送信準備完了"になっていることを確認します。

3.Xcodeで実装(Swift4.1.2)
まずTargetのCapabilitiesのIn-App-Purchaseを有効にしておきます。

ここからは実際にコードを書いて実装していきます。参考にしたのは
In-App−Purchaseについて
レシート検証プログラミングガイド
この辺のリンクは何故かコロコロ変わってそのうちリンク切れになっているかもしれません。同じような単語でぐぐると最新のページが見つかるかと思います。

まずはAppDelegateにオブザーバを登録します。オブザーバにはSKPaymentTransactionObserverプロトコルを実装する必要があります。トランザクションの状態が変化した場合、StoreKitはアプリケーションのトランザクションキューのオブザーバを呼び出します。例えば支払い要求に成功した場合などです。
下記コードのPurchaseManagerは独自で実装したクラスです。この後そのクラスを実装していきます。

// AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    SKPaymentQueue.default().add(PurchaseManager.shared)
    return true
}
class PurchaseManager:NSObject, SKPaymentTransactionObserver, SKProductsRequestDelegate {
    
    static var shared = PurchaseManager()
}

上述の通りSKPaymentTransactionObserverを実装します。またアイテム情報を取得するためにSKProductsRequestDelegateも実装します。
ここからは課金時の実装を行っていきます。

// PurchaseManager.swift
// 独自メソッド
func requestPurchase() {
    let productIdentifier:Set = ["SubscriptionTest"] // 製品ID
    let productsRequest: SKProductsRequest = SKProductsRequest.init(productIdentifiers: productIdentifier)
    productsRequest.delegate = self
    productsRequest.start()
}

製品IDはAppStoreConnectで設定したものを設定します。SKProductsRequestのdelegateを自身に設定し、startメソッドを呼び出します。するとproductsRequest(_:didReceive:)が呼び出されます。

// PurchaseManager.swift
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
    for product in response.products {
        let payment: SKPayment = SKPayment(product: product)
        SKPaymentQueue.default().add(payment)
    }
}

ここでリクエストしたプロダクトが存在する場合、レスポンスにプロダクトが含まれ、支払い情報をキューに追加することが可能です。もしプロダクトが空の場合はresponse.invalidProductIdentifiers.countを調べましょう。おそらく1以上の値が帰ってきていているはずです(正常であれば0)。僕の場合、invalidProductIdentifiersが最初に帰ってきたのですが、それは①で述べた契約/税金/口座情報の設定が未設定だったためです。この辺細かい理由は取得できないみたいなのでひとつずつ確かめるしかなさそうです。少し古い記事ですが、こちらのリンクを参考に調査しました。

Have you enabled In-App Purchases for your App ID?*1
Have you checked Cleared for Sale for your product?*2
Have you submitted (and optionally rejected) your application binary?
Does your project's .plist Bundle ID match your App ID?*3
Have you generated and installed a new provisioning profile for the new App ID?
Have you configured your project to code sign using this new provisioning profile?
Are you building for iPhone OS 3.0 or above?
Are you using the full product ID when when making an SKProductRequest?*4
Have you waited several hours since adding your product to iTunes Connect?*5
Are your bank details active on iTunes Connect?*6
Have you tried deleting the app from your device and reinstalling?
Is your device jailbroken? If so, you need to revert the jailbreak for IAP to work.

無事キューに支払い情報が追加し、課金処理が進むとpaymentQueue(_:updatedTransactions:)が呼ばれます。

// PurchaseManager.swift
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    for transaction:SKPaymentTransaction in transactions {
        switch transaction.transactionState {
        case SKPaymentTransactionState.purchasing:
            print("課金が進行中")
        case SKPaymentTransactionState.deferred:
            print("課金が遅延中")
        case SKPaymentTransactionState.failed:
            print("課金に失敗")
            queue.finishTransaction(transaction)
        case SKPaymentTransactionState.purchased:
            receiptValidation(url: "https://buy.itunes.apple.com/verifyReceipt")
            print("購入に成功")
            queue.finishTransaction(transaction)
        case SKPaymentTransactionState.restored:
            print("リストア")
            queue.finishTransaction(transaction)
            receiptValidation(url: "https://buy.itunes.apple.com/verifyReceipt")
        }
    }
}

購入の失敗・成功、リストア完了時はqueue.finishTransaction(transaction)でトランザクションを終了させます。それ以外purchasingとdeferredが呼ばれた場合は待機中なので必要に応じてindicatorの表示などでユーザーに課金処理に時間がかかっている旨を知らせます。場合によっては特に何もしなくてもいいかもしれません。時間がかかっているだけなのでここは待ち時間だなとわかるだけです。
receiptValidationは独自メソッドでこの中でレシート検証を行い、機能開放などを実装します。引数のURLはレシート検証のためのAppleのURLとなります。基本的に課金の実装はここまでです。ここからは課金後のレシート検証を行います。なおレシート検証はアプリ内で行うことは非推奨です。ただAppleのレシート検証プログラミングガイドにはObjective-Cで書かれており、アプリ内で行っているっぽく、実装を簡易にするため今回はアプリ内でまず行いました。別noteでサーバーサイドで検証を行いたいと思います。

// PurchaseManager.swift
// Appleサーバーに問い合わせてレシートを取得
func receiptValidation(url: String) {
    let receiptUrl = Bundle.main.appStoreReceiptURL
    let receiptData = try! Data(contentsOf: receiptUrl!)

    let requestContents = [
        "receipt-data": receiptData.base64EncodedString(options: .endLineWithCarriageReturn),
        "password": "App 用共有シークレット" // appstoreconnectからApp 用共有シークレットを取得しておきます
    ]
    
    let requestData = try! JSONSerialization.data(withJSONObject: requestContents, options: .init(rawValue: 0))

    var request = URLRequest(url: URL(string: url)!)
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField:"content-type")
    request.timeoutInterval = 5.0
    request.httpMethod = "POST"
    request.httpBody = requestData
    
    let session = URLSession.shared
    let task = session.dataTask(with: request, completionHandler: {(data, response, error) -> Void in
        
        guard let jsonData = data else {
            return
        }
        
        do {
            let json:Dictionary<String, AnyObject> = try JSONSerialization.jsonObject(with: jsonData, options: .init(rawValue: 0)) as! Dictionary<String, AnyObject>
            
            let status:Int = json["status"] as! Int
            if status == receiptErrorStatus.invalidReceiptForProduction.rawValue {
                self.receiptValidation(url: "https://sandbox.itunes.apple.com/verifyReceipt")
            }
            
            guard let receipts:Dictionary<String, AnyObject> = json["receipt"] as? Dictionary<String, AnyObject> else {
                return
            }
            
            // 機能開放
            self.provideFunctions(receipts: receipts)
        } catch let error {
            print("SKPaymentManager : Failure to validate receipt: \(error)")
        }
    })
    task.resume()
}

長いので各部分について解説します

let receiptUrl = Bundle.main.appStoreReceiptURL
let receiptData = try! Data(contentsOf: receiptUrl!)

let requestContents = [
    "receipt-data": receiptData.base64EncodedString(options: .endLineWithCarriageReturn),
    "password": "App 用共有シークレット" // appstoreconnectからApp 用共有シークレットを取得しておきます
]

let requestData = try! JSONSerialization.data(withJSONObject: requestContents, options: .init(rawValue: 0))

var request = URLRequest(url: URL(string: url)!)
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField:"content-type")
request.timeoutInterval = 5.0
request.httpMethod = "POST"
request.httpBody = requestData

この辺はAppleのサーバーに問い合わせるリクエストの作成です。ほとんどそのまま出いけるのではないかと。

let task = session.dataTask(with: request, completionHandler: {(data, response, error) -> Void in
    
    guard let jsonData = data else {
        return
    }
    
    do {
        let json:Dictionary<String, AnyObject> = try JSONSerialization.jsonObject(with: jsonData, options: .init(rawValue: 0)) as! Dictionary<String, AnyObject>
        
        let status:Int = json["status"] as! Int
        // invalidReceiptForProductionは独自enum
        if status == receiptErrorStatus.invalidReceiptForProduction.rawValue {
            self.receiptValidation(url: "https://sandbox.itunes.apple.com/verifyReceipt")
        }
        
        guard let receipts:Dictionary<String, AnyObject> = json["receipt"] as? Dictionary<String, AnyObject> else {
            return
        }
        
        self.provideFunctions(receipts: receipts)
    } catch let error {
        print("SKPaymentManager : Failure to validate receipt: \(error)")
    }
})
task.resume()

Appleサーバーにリクエストし、そのレスポンスを受け取ります。レスポンスが存在する場合statusキーから値を取ります。レスポンスステータスが21007の場合はテスト環境なのに本番URL(https://buy.itunes.apple.com/verifyReceipt)にリクエストしてしまったという意味です。その場合サンドボックス環境のhttps://sandbox.itunes.apple.com/verifyReceiptにリクエストを送りましょう。この本番・サンドボックスの実装はテスト段階ではなくても動く(最初からサンドボックスに問い合わせれば)のですが、Appleのレビューには本番に問い合わせ→サンドボックスという流れで実装しておかないとレビューに通らないので必ず実装します。

ステータスはenumにしておきました。

enum receiptErrorStatus: Int {
    case invalidJson = 21000
    case invalidReceiptDataProperty = 21002
    case authenticationError = 21003
    case commonSecretKeyMisMatch = 21004
    case receiptServerNotWorking = 21005
    case invalidReceiptForProduction = 21007
    case invalidReceiptForSandbox = 21008
    case unknownError = 21010
}

正常の場合はstatusが0で帰ってきます。この後独自メソッドprovideFunctionsの中でレシートの有効期限を取得、有効期限内であれば機能を提供するよう実装します。

private func provideFunctions(receipts:Dictionary<String, AnyObject>) {
   let in_apps = receipts["in_app"] as! Array<Dictionary<String, AnyObject>>
   
   var latestExpireDate:Int = 0
   for in_app in in_apps {
       let receiptExpireDateMs = Int(in_app["expires_date_ms"] as? String ?? "") ?? 0
       let receiptExpireDateS = receiptExpireDateMs / 1000
       if receiptExpireDateS > latestExpireDate {
           latestExpireDate = receiptExpireDateS
       }
   }
   UserDefaults.standard.set(latestExpireDate, forKey: "expireDate")
}

レシートのJsonの中にin_appというキーがあり、その中に一つ一つの課金データがあります。10回課金すれば(1ヶ月更新なら10ヶ月分)配列が10個あるはずです。その中にexpires_date_msというキーで、有効期限がそれぞれ記載されています。レシートの並びはおそらく保証されていないので(基本的に法則性はありますが)全ての課金情報をループで取得し、最大の有効期限を取得しています。有効期限を1000で割っているのはミリ秒で取得されるためです。この処理は特になくてもいいです。
無料トライアルを設定した場合もとにかくこのexpires_date_msを見ておけば間違いないです。課金中かトライアルかどうかをユーザーに伝える場合は他の項目も見る必要がありますが、機能開放の点だけ考えるとこれでいいはずです。
他にも課金アイテムが複数ある場合はどの課金かも見る必要がありますがここでは割愛します。
取得した期限はUserDefaultsに設定しています。この期限と現在時刻を比較して機能を提供するか判定すれば良いはずです。

次にリストア機能です。リストアがないとAppleのレビュー通りません、というかリストアがないとユーザーも困るので実装します。

func restore() {
    let request = SKReceiptRefreshRequest()
    request.delegate = self
    request.start()
    SKPaymentQueue.default().add(self)
    SKPaymentQueue.default().restoreCompletedTransactions()
}

リストア機能は基本これだけです。restoreCompletedTransactionsの呼び出しでpaymentQueue(_:updatedTransactions:)が呼ばれ、SKPaymentTransactionState.restoredのステータスが来てその後レシート検証すればOKです。個人的にはアプリインストール・起動した時点で勝手にリストアするようにすればいいのでは?と思いますがリストアボタンの実装は必須で勝手にリストアはさせてはいけないようです。そのへん言及している記事をどっかでみました笑

実装は以上なんですが、ここからは実装で困ったこと・疑問点を書いていきます。

困ったこと①...SandBox環境が安定していない
SandBox環境がなぜか安定していないため課金に失敗することがある。頻繁に起こり、一度失敗するとしばらくずっと失敗が続く…実装に問題ある場合もあると思われるが切り分けが難しいため、最初はかなり困った。

困ったこと②…実際のコードが少ない
探し方が悪いのかもしれないんですが、課金〜レシート検証〜機能開放までを記載してくれている記事がほとんど見当たらなかった。ドキュメントにもバラバラに書かれていたり、参考にできるものが少なかった。ビジネスロジックであれば自分で勿論やるのだが、課金周りはiOS機能との関連なのでなるべく一般的な実装にしたかった。困ったこと①のようなiOS特有な事象がでてきたときに本当に困った。

困ったこと③…課金のレビューが終わらないままアプリのレビューが通った。
アプリのリリースが先で、後々に課金機能を実装した。その際課金のレビューをするにはアプリも課金機能が実装済みのバイナリを提出してくれと言われて提出。アプリのレビューが通り、やったー!と思いリリースした。そしたら課金のレビューがまだ通っていない(未だレビュー中)ことが発覚。当然課金機能は動いていなかった。TestFlightまでは課金のレビュー通っていなくても動いてしまうため気づかなかった。
結局レビューは忘れられていたか問い合わせて、レビュー中だとの回答で2〜3週間放置された。しょうがないので課金機能を省いたものを急いでリリース…この辺レビューが必須にするのならちゃんとして欲しいなと思う。もちろん課金のレビューの確認をしていなかった自分が悪いのだが、その後のレビューの放置は理解できない。さらにバグフィックスだから早めにリリースさせてくれと言ったところ、今までずっと存在していた(全く致命的ではない)レイアウト崩れを急に指摘、リジェクトされリリースが伸びたりなど、嫌がらせかと思うような体験をした。もちろん未然に防げたこともあるが…とは思った。

疑問点①…paymentQueue(_:updatedTransactions:)が何度も呼ばれる
これはいまだ解決していない問題なのだが、課金するとpaymentQueue(_:updatedTransactions:)が何度も呼ばれる。最初は1回だけなのだが、課金回数が増えるとそれに比例して何度も呼ばれる。実装が間違っているからなのかもしれないが、原因がなんともわからない。

長くなりましたが以上となります。訂正があればぜひ教えてください。
ソースコードはgithubにあげてあります。IDなどを変えればそのまま課金機能を試せるはずなので、初めて課金される方はやってみてください。

この記事が気に入ったらサポートをしてみませんか?