Cordova（Monaca）でアプリ内課金を実装する

とりあえずCordovaの消耗（消費）型課金の処理を実装、検証してみたいってときの手順です。

# アプリの準備
まずは課金処理を実装したアプリを準備します。
[App Store Connect](App Store Connect)と[Google Play Console](https://play.google.com/console/u/0/developers)でとりあえずアプリの枠は作ってあるものとします。

## Cordovaプラグインの追加
プロジェクトに[cordova-plugin-purchase](https://github.com/j3k0/cordova-plugin-purchase)プラグインを追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/2e53cc5f-c0a6-3172-0d73-f3062522a309.png)

### BILLING_KEYの取得・設定
Google Play Consoleの「収益化のセットアップ」からキーを取得します。
MIIBで始まる文字列です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/e9a4487c-bd4a-9b49-715b-5a7353a032eb.png)

取得した文字列をBILLING_KEYとしてcordova-plugin-purchaseのインストールパラメータに設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/e1f3f672-8789-70e9-f57e-3b0470c0898c.png)

## cordova-plugin-purchaseプラグイン
GitHubにプラグインの解説リンクが載ってます。

>To ease the beggining of your journey into the intimidating world of In-App Purchase with >Cordova, we wrote a guide which hopefully will help you get things done: https://purchase.cordova.fovea.cc/

「Cordovaでのアプリ内課金の恐ろしい世界への旅の始まりを容易にするために、私たちはあなたが物事を成し遂げるのに役立つことを願ってガイドを書きました：[https://purchase.cordova.fovea.cc/](https://purchase.cordova.fovea.cc/)」

ずいぶん剣呑な感じですが…。
基本はこれと[APIドキュメント](https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#registering-products)とにらめっこしながら実装していく感じになります。

### プラグインの解説
ドキュメントの特に重要そうな部分を和訳しました。
https://purchase.cordova.fovea.cc/discover/about-the-plugin
https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#life-cycle

#### 課金アイテムの状態
* registered：登録済み（製品はプラグインに認識されています）
* valid：有効（製品の詳細はストアから読み込まれ、製品は購入できます））
 * invalid：無効（製品の詳細を読み込めません）
* requested：リクエスト済み（注文済み）
* approved：承認済み（注文は承認済み）
* finished：終了（アプリは注文を配信しました）
* owned：所有（製品を所有済）

#### 課金アイテムの購入プロセス
1. Requesting a purchase.（購入をリクエスト）
1. Getting approval from the bank（口座情報の確認）
1. elivery by the application（アプリによる配信）
1. Finalization（終了処理）

注文が行われた後、ストアプラットフォームは顧客の口座から金額を引き落とすための処理を行います。
引き落とし処理が承認されると、商品は承認された状態（approved）になります。
アプリは承認通知を受け取り、次のことを行う必要があります。

1. レシートの検証
1. 商品の提供

アプリを商品をユーザーに納品すると、注文が確定します（finished）。その時、お金はあなたに送金されます。
このようにして、納品できなかった商品に対して顧客に請求されないようにします。

終了処理後、商品は所有（owned）されます。消耗品は再度購入できます。

#### 課金アイテムのライフサイクル
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/8f1ad16b-da23-0b9c-f52b-1e43c4af6f6e.png)

### プラグインの実装
[Micro Example](https://purchase.cordova.fovea.cc/discover/micro-example)を参考に実装したのがこちら。

```html:index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="viewport-fit=cover, width=device-width, height=device-height, initial-scale=1, maximum-scale=1, user-scalable=no">
        <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: content: https://ssl.gstatic.com; style-src * 'unsafe-inline'; script-src * 'unsafe-inline' 'unsafe-eval'">

        <script src="components/loader.js"></script>
        <link rel="stylesheet" href="components/loader.css">
        <link rel="stylesheet" href="css/style.css">
    </head>

    <body>
        <main class="container"></main>
        <div class="row"></div>
       <textarea id="log" class="container" rows="20" cols="40" disabled></textarea>
       <script>
            console._log = console.log;
            console.log = function(msg) {
                document.getElementById('log').innerHTML += msg + '\n';
                console._log(msg);
            };
            console.log('init');
        </script>
        <script src="js/app.js"></script>
    </body>
</html>
```

```css:style.css
.container {
    display: flex;
    justify-content: center;
}
.row{
    padding: 10px;
}
```

```javascript:app.js
document.addEventListener('deviceready', onDeviceReady);

// デバイスの準備が完了
function onDeviceReady() {
    // プラグインが有効か確認
    if (!window.store) {
        console.log('Store not available');
        return;
    }

    // UIのリフレッシュ
    refreshUI();
    
    // プラグインに商品を認識させる
    // コードで商品を使用する前に、商品のタイプと識別子を知っている必要がある
    store.register({
        type: store.CONSUMABLE,
        id: 'coins100',
        alias: '100コイン'
    });    // 消耗型商品を登録

    // エラーハンドラを登録
    store.error(function(error) {
        console.info('STORE ERROR ' + error.code + ': ' + error.message);
    });

    // 商品にコールバックを設定
    store.when('coins100')
        .updated(refreshUI) // 商品に変更があった
        .cancelled(cancelled) // キャンセルされた
        // ↓レシート検証する
        .approved(verifyPurchase) // 商品が承認済みになった
        .verified(finishPurchase) // 商品レシートを検証済み
        .unverified(unverifyPurchase) // 商品レシート検証失敗
        // ↓レシート検証しない
        // .approved(finishPurchase) // 商品が承認済みになった
        .error(function(error) {
            console.info('PRODUCT ERROR ' + error.code + ': ' + error.message);
        });
    
    // ストアに接続して商品の詳細情報を取得（商品情報のハードコーディングはNG）
    store.refresh();
}

// 購入
function purchase() {
    console.log('start purchase');
    store.order('coins100');
    console.log('end purchase');
}

// キャンセルされた
function cancelled(product) {
    console.log('cancelled');
}

// サーバーサイドでレシート検証
function verifyPurchase(product) {
    console.log('start verifyPurchase');
    store.validator = "https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/check-purchase";
    product.verify();
    console.log('end verifyPurchase');
}

function unverifyPurchase(product) {
    console.log('unverifyPurchase');
}

// 購入完了処理
function finishPurchase(product){
    console.log('finishPurchase');
    // ゲームコインを追加
    localStorage.goldCoins = (localStorage.goldCoins | 0) + 100;

    // アプリがコンテンツを配信できなかった場合、product.finish()は呼び出されない
    // その場合は、store.refresh()で再度approvedがトリガーされる
    product.finish();
    refreshUI();
}

// UIのリフレッシュ
function refreshUI() {
    console.log('refreshUI');

    // 商品情報を参照
    const product = store.get('coins100');

    if (!product) {
        // 商品情報が取得できない
        return;
    }else if (product.state === store.INVALID) {
        // 商品が無効
        console.log('product invalid');
        return;
    }

    // 商品情報をUIにセット
    const button = `<button onclick="purchase()">Purchase</button>`;

    document.getElementsByTagName('main')[0].innerHTML = `
    <div>
        <pre>
        Gold: ${localStorage.goldCoins | 0}

        Product.state: ${product.state}
                .title: ${product.title}
                .descr: ${product.description}
                .price: ${product.price}

        </pre>
        ${product.canPurchase ? button : ''}
    </div>`;
}
```

### レシート検証API
https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#receipt-validation
とりあえず呼び出せてレシートの内容をログに出せればよかったので、簡易的にAWS Chaliceで実装しました。

```python:app.py
from chalice import Chalice
import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

app = Chalice(app_name='app-purchase-test-api')

@app.route('/check-purchase', methods = ['POST'], content_types=['application/json'], cors=True)
def index():
    request = app.current_request

    logger.info(json.dumps(request.json_body))

    error_response = {
        'ok': False,
        'data': {
            'code': 6778003,
        },
        'error': {
            'message' : "invalid receipt"
        },
    }

    if 'transaction' in request.json_body:
        logger.info(request.json_body['transaction'])
        transaction = request.json_body['transaction']
    else:
        logger.error('transaction not in body')
        return error_response

    if transaction['type'] == 'ios-appstore':
        # iOSレシート検証処理を書く
        pass

    elif transaction['type'] == 'android-playstore':
        # Androidレシート検証処理を書く
        pass

    else:
        logger.error('type not in transaction')
        return error_response

    return {
        'ok': True,
        'data': {
            'transaction': request.json_body['transaction'],
        },
    }
```

## アプリのビルド
iOSはデバッグビルド、Androidはリリースビルドまで済ませる必要があります。
[MonacaでiOSデバッグビルドを実行するまで](https://qiita.com/danishi/items/f0d3329a57aa677fdc39)

但し端末にインストールし実行してもまだ、ストアに商品が登録されていないので何も表示されません。
各プラットフォームに設定していく必要があります。

# App Store Connect設定・iOSアプリの実行
App Store Connectを開き課金に必要な設定をやっていきます。
こちらの記事を参考にしました。
「[App内課金をSANDBOXユーザーでテストする - AppStoreConnect編(2019年版)](https://qiita.com/shuntiger/items/a9ae2649ff642cd17ca9)」

## 契約/税金/口座情報の設定
初めて課金アプリを作る場合は、口座等の設定が必要になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/57ae35f0-24a5-170b-f396-d4ed8a853c79.png)

## 課金アイテムの登録
上記が完了していれば課金アイテムがアプリに登録できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/a3e82360-1117-a0d6-97e8-e15c1a807828.png)
製品IDはアプリで指定しているものを設定しましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/23787c3d-b7cc-4855-a9e1-abc2e712d48d.png)

## Sandboxアカウント追加
[iOSアプリの購入テストでSandboxアカウントを作って使うマニュアル](https://spinners.work/posts/ios-sandbox/)

## iOSアプリの実行
ここまでできればiOSで課金の一連の流れが試せます。
デバッグビルドのアプリをインストールしてSandboxアカウントを設定した端末で実行します。

![.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/efb9c1a4-7c7b-e0d2-6a6f-5f24b3d6f094.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/b1c3b179-e125-d58c-6d3e-083c66669347.png)

## TestFlightで実行する
リリースビルドを作成して、App Store Connectにビルドをアップロード。
TestFlightにビルドを登録して、テスターを登録すればTestFlightアプリで配布ができます。

# Google Play Console設定・Androidアプリの実行
Google Play Consoleを開き課金に必要な設定をやっていきます。

## 販売アカウントの設定
[Android有料課金アプリ販売登録方法 - 初心者向け](https://qiita.com/QRES/items/66226f2fb452f8a209a2)

## アルファ版のリリース
iOSと違いアルファ版を一度リリースしなければいけません。
こちらの記事を参考に
[Androidアプリの内部テストを実施する](https://qiita.com/taketakekaho/items/0d2fe1f4204015414863)

まずはダッシュボードの初期設定を適当でもいいのですべて消化します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/784a0f2b-03f6-7473-7946-f287aa965f2b.png)

埋め終わったら「クローズドテスト」から「トラックを管理」へ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/f27b86db-9062-2c71-8f24-ba245163e9e0.png)

新しいリリースを作成します。国とテスターも設定しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/108d4230-092f-2216-6b84-69baa9af42d3.png)

リリースビルドのAPKをアップロード、説明はこんな感じでもいいので審査に回します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/390af87f-6ea6-dacb-4759-2da71a5dd418.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/51695a42-21f8-cf9f-35be-225ffee1d264.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/7b83b27c-144e-7851-1f5c-47dc4eae84a2.png)

審査が完了してリリース済みになるまで気長に待ちます。

## 課金アイテムの登録
リリース済みになれば課金アイテムが登録できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/126f69f5-9f10-bc80-d080-59c11dcf8822.png)
登録した後に有効化するのを忘れないようにしましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/da47db03-6337-0c72-7126-f4a0f0e05f4c.png)

## ライセンステスターの登録
購入テストをするために登録が必要です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/9036e573-e6ff-3662-5ff7-fdc97f6bfc8b.png)

## Androidアプリの実行
ここまでできればAndroidで課金の一連の流れが試せます。
テスターに登録したアカウントでGoogle Playにログインした端末で試しましょう。
アプリ自体はGoogle Playからインストールしなくても、デバッグビルドでも試せます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/8783d51a-f836-9a6d-7e55-32800086924d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/bce90f9d-03d7-b010-1571-f630c7f13e06.png)
![.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96507/4821517e-5df3-6410-7e4b-3494867c75c2.png)

---

cordova-plugin-purchaseと各アプリプラットフォームの課金のエコシステムを理解するのが非常に大変でした。
端折ったレシート検証についても後日詳しく書こうと思います。

---

書きました。
[cordova-plugin-purchase用の消耗型課金レシート検証APIをLaravelで実装する](https://qiita.com/danishi/items/4eeca49e03a3c723673f)