【Cordova】アプリ内課金のハマりどころ（2018年版）

# はじめに

Cordovaでアプリ内課金を実装した時に、結構色んなことでハマったのでその備忘録も兼ねてハマった事例と対処方法をご紹介します。
ググれば出てくるものもたくさんありますが、まとまっている記事が無かったため、ぜひご活用ください。

なお、本記事は [Monaca UG OKAYAMA #3](https://monacaug.connpass.com/event/109787/) でLTした内容のフルバージョンです。
[MonacaUG](https://monacaug.connpass.com/) に、ぜひご参加ください！



# 使用するプラグイン

- cordova-plugin-purchase

Cordovaプロジェクトへのプラグインインストール方法は下記を参照してください。
英文ですが、マニュアルにもなっています。

https://github.com/j3k0/cordova-plugin-purchase



# アプリ内課金のポイント

さらっとアプリ内課金のポイントだけご説明します。

## アプリ内課金の種類 for Android

下記の２種類が用意されています。

- 管理対象のアプリ内アイテム
- 定期購読

## アプリ内課金の種類 for iOS

下記の4種類が用意されています。

- 消費型
- 非消費型
- 自動更新購読
- 継続なしの購読



## プラグインの使い方

ハイブリッドアプリとして作るので、原則、AndroidもiOSも同じです。



１. 商品をプラグインに登録する

```javascript
store.register(product);
```

２. ストアから商品情報を取得する

```javascript
store.refresh();
```

３. 商品を購入する

```javascript
store.order(product);
```

４. 商品を消費する

```javascript
product.finish();
```



## 商品情報のライフサイクル

<img width="565" alt="screenshot.14.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165224/897b5777-0721-fce9-0660-fe97b9a08ba9.jpeg">



# ハマった事例紹介

ここからアプリ内課金でハマった事例をご紹介します。



## プラグインのIDを間違えやすい

プロジェクトにプラグインを追加するとき、

```
cordova plugin add cordova-plugin-purchase
```

とすると、なんか古いプラグインが追加されて動かない。

（正確には現在はなおったっぽい？）



正しくは、

```
cordova plugin add cc.fovea.cordova.purchase
```



12/4にnpmを調べてみると、4 years ago！？！？
<img width="584" alt="screenshot.8.org.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165224/713a88a8-a9bd-e168-95df-cd9afa49907f.jpeg">


でも最近なにやらアップデートされた模様。すごいバージョン上がってます。

正しいのかどうかわからないので、cc.fover.〜の方を使った方が無難かもしれません。



## 基本、リリース署名必須（for Android）

Androidで動かすなら、リリース版の署名が必要。

課金を試すのにストアへ内部テスト版としてAPKを登録しなければならない。

この時、APKが `debuggable=true` になっているとAPKがストアに弾かれるので、デバッグはできなくなる。

署名無しやStoreにAPKを登録せずに動かすと、

```
6777017: E/IabHelper: In-app billing error: 

Purchase signature verification FAILED for sku 

android.test.purchased
```

みたいなエラーが出る。



Androidでどうしてもデバッグしたい場合。

一旦、 `debuggable=true` を付けずにそのままストアにアップした後、同じ署名＆バージョンコードで `debuggable=true` のリリースビルド、ローカルインストール 

```
adb install -d xxx.apk
```

すればデバッグが有効になった状態でアプリ内課金を試せる。



## ライセンステスト登録が必要（for Android）

Androidで課金テストを行う時、テスターの登録の後、ライセンステストの登録も必要。

[Androidでアプリ内課金テストを行う](https://qiita.com/soratyan/items/91423b9d0d474cb044c7#課金アプリテスト用アカウントの登録) - Qiita より

<img width="403" alt="screenshot.13.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165224/5d207580-c45c-4b97-ac84-fa62943803cb.jpeg">


ファ！？



そして、

**しれっと GooglePlay のライセンステストの登録が外れることがある。**

（タイミングは不明）



通常のテストの時に受け取るメール。

<img width="470" alt="screenshot.9.org.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165224/c491bcd9-03a0-bad3-e886-68c515555ce0.jpeg">

<img width="382" alt="screenshot.11.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165224/a3174c89-09e8-453f-a2a8-216838c2e8d7.jpeg">


テスト中、突如やらかす。

<img width="407" alt="screenshot.10.org.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165224/1f52572e-9f1c-3614-62e4-8a31bf87b547.jpeg">

<img width="377" alt="screenshot.12.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165224/7a7ec723-c850-ea15-feff-7d77eea07af3.jpeg">



あ、ちなみに、実際のクレカ登録していなくてもテストできます。



## ストアとプラグインの商品設定を取り違えると動かない（for Android）

Android は消費型と定期購入型の２パターンのアイテムが登録できるが、プラグイン側の商品の設定を取り違えると、下記のエラーが出る。

```
E/IabHelper: In-app billing error: BUG:

either purchaseData or dataSignature is null.
```

取り違えないように。



## iOSの審査時のレシート検証に注意（for iOS）

iOSのレシート検証は、テスト用の「Sandbox」と「本番」がある。

アプリリリースするには、本番用の設定のビルドをするわけですが、審査時の（審査員の）レシートは「Sandbox」のものになるため、「本番」の検証サーバにアクセスするとエラーになる。



サーバーサイドでレシート検証をしている場合は、審査で申請しているログインIDだったらSandboxで検証する、などの対応が必要。



## 定期購読型に要注意（for Android/iOS）

定期購読型の場合、「期限切れになった」というイベントは自動では発火しない。

つまり、アプリ起動しっぱなしにしておけばいつまでも権利が使えることになる。

プラグインのrefresh関数をコールすると、状態がOWNEDから再度、APPROVEDに遷移する。（APPROVEDイベントが発火する）

で、再度レシート検証をして期限切れになっていないかチェックする必要がある。



## 定期購読型に要注意 その２（for Android）

プラグインのAndroid側にバグ(?)があって、前の事例に書いたレシート検証で期限切れとした場合でも、商品の所有状態（OWNED）が `false` に戻ってくれない（！！）

検証NGでEXPIREDにするとき、一緒に `product.owned = false` にする必要がある。 

**これ重要！**

なお、iOS版はちゃんと `false` になる。



## iOSのリストアが厄介（for iOS）

iOS版は購入状態の「リストア」機能が無いとリジェクトされる。

iPhoneを買い替えたーとか、iPadでも使うー、とか。

同じAppleID使えば同じ購入状態にせよ。

という厳しいルールがある。



ところが、iOS版のプラグインは、refresh関数を２回呼ぶと、リストアが自動的に行われる仕様になっている。

SPAなUIを採用していると、2回以上呼んじゃうってところにはまって、勝手にリストアされて新しいレシートが発行されて。。

と面倒なので、私が担当した案件では勝手にリストアされないように細工した。



storekit.restoreの中にリストア処理を呼ぶ実装があるので、空の関数に差し替える。

```JavaScript
if (window.storekit) {
    // StoreKit(iOS)がある場合、refresh時のrestoreを防ぐため、
    // restore関数を無効化する
    const storekit = Object.getPrototypeOf(window.storekit)
    storekit.originalRestore = storekit.restore
    storekit.restore = () => {}
}
```



## レシート検証がiOSとAndroidで動きが違う（for iOS/Android）

レシートのバリデーションについて。

レシートのverifyをして、バリデーションの結果、期限切れ（EXPIRED）とする時、iOSとAndroidで挙動が異なる。

Androidは、そのままEXPIREDになるが、iOSでは３回検証をリトライする。

内部で下記を呼んでいるらしい。

```objective-c
[receiptRefreshRequest start];
```

期限切れになってから、再度更新になるまでに承認操作などがあるからかな？

サーバーサイドでレシート検証している場合は３回呼ばれるので注意。



## プラグイン初回refresh()時に受け取るレシートについて（for iOS/Android）

iOSの場合、リストアがあるので、 `finish()` していないリストア中のレシートもapprovedでイベント発火する。

`finish()` するか、レシート検証でEXPIREDにする。

（EXPIREDでも内部で `finish()` してくれるみたい）



また、非消費型と、定期購読型のレシート、それから、購入中でまだ `finish()` していないもののレシートも取得される。



期限切れで `finish()` 済みと、消費型のレシートはこない。

Androidは、期限切れのレシートもしばらく来る。



## StoreKitにアクセスすると毎回ID/PASSを聞かれる（for iOS）

iOSのSandboxでテストする時、StoreKit経由でStoreにアクセスしたタイミングでAppleIDを毎回入力する必要がある。

（iPhoneのStoreの設定にテストアカウントは登録することはできない）

一応、入力ダイアログをキャンセルすることもできる。



しかし、ダイアログでキャンセルすると、Cordovaプラグインの `exec()` のコールバックが呼ばれず、必ずデッドロックする。

面倒でも毎回ID/PASSを入力してください。



## ローカルストレージがパンクする（for iOS）

iOS版はトランザクションをfinishするとレシート情報やトランザクションIDが取れなくなるため、ローカルストレージに情報を保持している。

[cordova-plugin-purchaseでQuotaExceededError (DOM Exception 22): The quota has been exceededエラー](http://kimagureneet.hatenablog.com/entry/2018/05/16/172732)

しかし、レシート情報が結構なサイズなので、定期購読するとローカルストレージがいずれパンクする。

※トランザクションIDをキーに、レシート情報を保持している。トランザクションは積み上げで保持されていて、期限切れになっても消すといった処理がない。



レシート情報をあとで使いたいシーンがないのであれば、下記３つはアプリ起動時とかに消すようにしました。

```javascript
if (window.localStorage) {
    // iOSのローカルストレージのレシート情報は使用しないため破棄する
    window.localStorage.removeItem('sk_receiptForProduct')
    window.localStorage.removeItem('sk_receiptForTransaction')
    window.localStorage.removeItem('sk_appStoreReceipt')
}
```



## finishできないゴミトランザクションが大量発生（for iOS）

iOS版でリストアしたあと、finishせずに期限切れなどになると、プラグインでトランザクションIDが取れないケースがあった。

（Objective-Cのソース側では取れているので、期限切れになったあと再度購入したりして、カレントではないトランザクションなどで発生するかも）

プラグインでIDが取れないと、finishすることができないため、ゴミとして残り続ける。

必ず、finishされる実装にしておくこと。



## レシート情報更新通知サービスがある（for iOS）

iOS版の自動更新購読については、iTunesConnectからのレシート更新通知サービスを利用することができる。（ただし、Server to Server）

[In-App Purchaseプログラミングガイド](https://developer.apple.com/jp/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Chapters/Subscriptions.html) 参照



## 自動更新購読の更新時のトランザクションID（for iOS）

初回購入時にfinishさせたトランザクションIDは自動更新されてもアプリ起動中は通知されない。(refresh関数を呼んでも通知されない)

更新済みでアプリが受け取っていないトランザクションは、アプリ再起動時に受信される。

レシートは期限切れになっていないため、バリデーション→レシート検証→finishの流れとなる。

更新できず、期限が切れた場合、refresh関数を呼ぶと取得される。バリデーション→レシート検証→EXPIREDの流れとなる。



なお、プラグインは同一のオリジナルトランザクションIDに対しては、複数の更新トランザクションIDがあっても、最後（番号が大きいもの）のトランザクションIDのみ通知してくる。

ただし、例えば３つの更新トランザクションIDを受信したら、最後のトランザクションIDで３回approvedイベントが発火するので、同じバリデーションを３回行うことになる。

WebAPIでレシート検証している場合、無駄処理になるので、同じIDでバリデーション中は２回目以降はスキップすれば良い。

```javascript
 if (product.transaction.id) {
    this.validationMap[product.id] = this.validationMap[product.id] || []
    // バリデーションマップをチェックする
    if (_.includes(this.validationMap[product.id], product.transaction.id)) {
      console.log('skip validation.')
      return
    }
    this.validationMap[product.id].push(product.transaction.id)
    // コールバック関数を差し替える
    const orgResolve = resolve
    resolve = result => {
      _.pull(this.validationMap[product.id], product.transaction.id)
      orgResolve(result)
    }
    const orgReject = reject
    reject = result => {
      _.pull(this.validationMap[product.id], product.transaction.id)
      orgReject(result)
    }
  }
```



## iOS版のリストアについて（for iOS）

iOS版のリストア（ `refresh()` ２回目以降、もしくは `storekit.restore()` ）で復元されるトランザクションは、非消費型と定期購読型の期限切れも含む全てのトランザクション。

リストアを複数回行っても、 **リストアトランザクション自体のリストア** は発生しない。

例えば、非消費型１つ、定期購読型２回更新（初回含む）の状態であれば、リストアすると３つのトランザクションIDが発行される。



この状態で再度リストアすると、また新たに３つのトランザクションIDが発行される。

（リストア済みトランザクションIDのリストアが行われるわけでは無いので、６つ発行されるわけでは無い）



リストアされたトランザクションIDはfinishする必要がある。

（finishしなければ、refreshでまたapprovedイベントが発火する）

レシート検証後、finishするかEXPIREDする必要がある。



## 定期購読のアップグレード/ダウングレードについて（for iOS/Android）

iOS/Android共に定期購読期間中に定期購読のプランを変更できる。

iOS：プロダクトを同じグループに指定しておくことで、切り替えが可能になる。

ただし、プロダクトが切り替わるのは、購入済みプロダクトの期間が終わってから自動で切り替わる。（即時切り替わらない）

プロダクトが切り替わってもオリジナルトランザクションIDは引き継がれる。



Android：order関数の `additionalData` に `oldPurchasedSkus` （プロダクトIDをセット）パラメータを与えると、oldのプロダクトが解約され、新しいプロダクトの購読が即時始まる。

解約は日割りで利用金額が計算され、残額は次の購読に充当されるとのこと。

[定期購入のアップグレードとダウングレード](https://developer.android.com/google/play/billing/billing_subscriptions?hl=ja#upgrade) 参照



## iOSのレシート検証について（for iOS）

iOSのレシート検証をAppStoreを使って行う場合、レシート検証の応答は、そのAppleIDで購入した該当AppIDのプロダクト全ての情報を含んだ最新の情報が返却される（ `in_app` もしくは `latest_receipt_info` ）。

⇒つまり、iOSのレシート検証はプロダクト単位ではなくアプリ単位でしか行えない点に注意。

また、特定のプロダクトの最新の情報を取得したい場合、 `in_app` と `latest_receipt_info` の両方をマージ、 `product_id` で絞って、 `original_transaction_id` の降順にソート、先頭が最新となる。



なお、 `in_app` と `latest_receipt_info` については下記をチェック。

[iOSの月額課金レシート検証をサーバーサイドで行うときのTipsまとめ](https://qiita.com/joooee0000/items/6cf8ba1d7ba240bef14c)



## iOSのレシート検証について　その２（for iOS）

iOSのレシート検証について、 `refresh()` の件にも書いたが、消費型についてはfinishするとAppStoreからレシート情報が取得されなくなる。

なので、独自のサーバーで消費状態を管理するのであれば、finish前にレシート検証をし、OKであればサーバーに商品購入を登録、アプリ側でfinishさせ、AppStoreのレシートは消費させる。（そうしないと、消費型アイテムを連続で買えない）



## プラグインはSPAに配慮されていない？（for iOS/Android）

プロダクトのregisterは初回のrefreshにしか反応しない。

つまり、SPAアプリで一度register→refreshでロードしてしまうと、WebAPIとかで再度最新のプロダクトIDを追加取得しても追加ロードができない。

（アプリを再起動すればプロダクトを取得できる）

グローバル変数でカウントしているため、どうにもならない。。

（残念！）



# まとめ

ハマりどころがたくさんありましたが、実装自体が大変、というよりプラグインの癖に悩まされたケースが多いかなという印象です。

また、アプリ内課金に関する記事があまり多くなく、公式には書いてあったりはするのですが、それって結局どう対応すれば？という時もありましたので、この記事をお役に立てていただければ幸いです。