iOSの In App Purchase アプリ内課金 消耗型 購入処理

iOSアプリ内課金の消耗型アイテムの実装方法に関する話です。

アプリ内課金実装時にやりがちな失敗例を解説した後に正しい処理の流れを解説します。

#間違った購入処理の流れ

※購入処理を書くうえで以下の実装処理は完全なる間違いです。絶対に以下のような流れで実装してはいけません。大変なことになります。

1'. 商品情報の取得
2'. 商品を購入トランザクションに追加
3'. ユーザー側での処理（アカウント情報確認や購入の確認）
4'. Appleのサーバーでの購入処理の完了
5'. 商品情報を購入トランザクションから削除
6'. レシート検証
7'. ユーザーへの報酬（コイン追加等）

###間違った実装で生じる問題

もしも6'のレシート検証の時点でイレギュラーな事態が発生して処理が終わってしまった場合、報酬を受け取ることができず、ユーザーのお金だけが消えてしまうという事態になります。  

レシート検証は多くの場合クライアント側ではなくWebサーバーを用意してその中で行い、その結果を持ってユーザーに報酬を与えます。
Appleでの購入処理が成功したあと、
レシート検証の時点で通信が切断してしまった場合、もしくはアプリが落ちてしまった場合
**ユーザーは報酬を受け取ることができず、ユーザーのお金だけが消滅します。**

###やってはいけないこと

間違った流れの中で最もやってはいけなかったのが
5',6'の流れです。
> 5'. 商品情報を購入トランザクションから削除
> 6'. レシート検証  

問題なのはレシート検証が成功する前に購入トランザクションから商品情報を削除してしまったことです。
つまりSKPaymentQueueのfinishTransactionを呼んでしまうことです。
コードにすると以下のようになります

``` AkanPurchaseManager.swift
// ※この実装には間違いが含まれています
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for t in transactions {
            switch t.transactionState {
                case .purchased:
                    // トランザクション処理終了 ここが間違い
                    SKPaymentQueue.default().finishTransaction(t)
                    // レシート検証 ここが間違い
                    self.validateReceipt() { [unowned self] in
                        self.delegate.success()
                    }
                case .failed:
                    SKPaymentQueue.default().finishTransaction(t)                default:
                    break
            }
        }
    }
// ※この実装には間違いが含まれています
```


#正しい購入処理の流れ

上記の問題へ対応するための正しい実装処理の流れは以下のようになります。

1. 購入トランザクションの監視の開始 
2. 未処理のトランザクションのチェック
3. 未送信のレシート情報を保存しているかチェック
4. 商品情報の取得
5. 商品を購入トランザクションに追加
6. ユーザー側での処理（アカウント情報確認や購入の確認）
7. Appleのサーバーでの購入処理の完了
8. レシート情報を暗号化して保存(KeyChainAccess等）
9. レシート検証
10. 商品情報を購入トランザクションから削除
11. ユーザーへの報酬（コイン追加等）
12. レシート検証で成功したレシート情報を削除



###やるべきこと

問題に対応するために、中断してしまった場合の処理を再開させるという実装をします。
そのポイントとなるのが、正しい流れの中の1,2,3と8,9,10の処理になります。

まず8,9,10に関して

> 8, レシート情報を暗号化して保存(KeyChainAccess等）
> 9, レシート検証
> 10, 商品情報を購入トランザクションから削除

レシート検証がサーバー側で成功するまでfinishTransactionを呼び出さなければSKPaymentQueueの内容を
**次回のアプリ起動時もしくはログイン時まで持ち越すことができる**
ため、途中の処理を復元することができます。

###更なる注意点

しかし弊社デバッグチームが100回ほどの購入処理テストを行ったところ、30回に1回ほどそれでも復元できないという事例が出ていました。

それに対応するために8の処理でレシート情報を保存して次回のログイン時に失敗したレシート情報を確認してサーバー側で検証するという処理を入れています。

これによって弊社デバッグチームの鬼のような回数の検証にも耐え抜き、購入を途中中断からの復元を100％成功させるということができました。

###コード例

コードは以下になります。

``` OKPurchaseManager.swift
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for t in transactions {
            switch t.transactionState {
                case .purchased:
                   self.saveReceipt() // 未送信レシート保存
                    // レシート検証 
                    self.validateReceipt() { [unowned self] in
                        // トランザクション処理終了 
                        SKPaymentQueue.default().finishTransaction(t)
                        self.deleteReceipt() // 送信済成功レシートの削除
                        self.delegate.success()
                    }
                case .failed:
                    SKPaymentQueue.default().finishTransaction(t)                default:
                    break
            }
        }
    }
```

続いて1,2,3の処理
> 1. 購入トランザクションの監視の開始 
> 2. 未処理のトランザクションのチェック
> 3. 未送信のレシート情報を保存しているかチェック

アプリ起動時、もしくはログイン時に行う購入復元処理は以下になります。

``` OKPurchaseManager.swift
    func resumePurchase() {
        if SKPaymentQueue.default().transactions.count > 0 {
            for t in SKPaymentQueue.default().transactions {
                switch t.transactionState {
                    case .purchasing, .deferred: // 購入処理中
                        SKPaymentQueue.default().add(t.payment)
                        SKPaymentQueue.default().add(self)
                        break
                    case .purchased: // 購入済み
                        self.validateReceipt() { [unowned self] in
                            SKPaymentQueue.default().finishTransaction(t)
                            self.delegate.resumeSuccess()
                        }
                    case .failed:
                        SKPaymentQueue.default().finishTransaction(t)
                    default:
                        break
                }
            }
        } else {
            // トランザクションには何も処理が無い状態だが、未送信のレシート情報が保存してあるかをチェック
            if let receipt = self.getSavedReceipt {
                self.validateReceipt(receipt) { [unowned self] in
                    self.delegate.resumeSuccess()
                }
            }
        }
    }
```


####終わりです
最後までお読みくださりありがとうございました。

Brewus,Inc.
株式会社ブリューアス
https://brewus.co.jp