React Nativeでアプリ内課金【Android編】

# はじめに

Androidアプリ内課金課金を行うための手順と確認しておくべきドキュメントを整理します。
手順に移る前にまずは最低下記の2つドキュメントには目を通しておくべきかと思います。  
[アプリ内課金の管理](https://developer.android.com/google/play/billing/billing_admin.html?hl=ja)
[Google Play 請求サービスの概要](https://developer.android.com/google/play/billing/billing_overview.html?hl=ja)  

ざっとまとめると。。。

## アイテム課金

- 商品タイプを「inapp」で購入する。
- 同じ課金アイテムを重複して購入できない。
- 同じ課金アイテムを重複して買わせるためには、購入アイテムを消費する必要がある。
- アプリ内で消費メカニズムをどう使うかは、デベロッパーの判断に委ねられている。
- ユーザがゲーム内通貨等を購入した場合、購入金額に基づいてプレイヤーの残高を更新する必要がある。
- セキュリティ上の推奨事項: 消費可能なアプリ内購入のメリットをユーザーに与える前に、消費リクエストを送る必要がある。
  アイテムを提供する前に、Google Play からの消費レスポンスを確実に受け取っていることを確認する。

## 定期購入

- 定期購入の購入フローを開始する方法は、商品タイプを「subs」に指定することを除いては、商品購入のフローと同様。
- 定期購入商品は消費することはできない。
- 購入の詳細情報をサーバーにキャッシュしてユーザーにできるだけ早くレスポンスを返すようにすることが推奨されている。
- サーバーで Subscriptions and In-App Purchases API を使って新しい定期購入トークンの期限を照会して、その期限をサーバに保存する必要がある。
- 解約にはGoogle Playのアプリのページから行う必要があり、解約の方法をちゃんと告知しないとクレームに繋がる恐れがある。

# 手順

1. Google ペイメントの販売者アカウントを作成する。 [Developer Console](http://play.google.com/apps/publish?hl=ja) で登録できる。
2. API プロジェクトをリンクする。[Google Play Developer API スタートガイド](https://developers.google.com/android-publisher/getting_started)
3. [商品リストを作成する](https://developer.android.com/google/play/billing/billing_admin.html?hl=ja#billing-list-setup)
4. [テストアカウントを設定する](https://developer.android.com/google/play/billing/billing_admin.html?hl=ja#billing-testing-setup)
5. [アプリのライセンスキーを取得する](https://developer.android.com/google/play/billing/billing_admin.html?hl=ja#license_key)
6. 処理を実装する。
7. APKを作成する。(署名をしてAPKを作成しないとエラーになる)
8. アプリを Google Play Devloper Console にアップロードして公開する(β版でOK)。
9. 課金のテストを行う。(テスト用であることが表示されていることを確認すること。)

ここで注意したい点は、**No.2のAPIプロジェクトを課金商品登録前に行う** ところです。  
商品登録後にAPIプロジェクトをリンクするとGoogle API実行時に以下のようなエラーが発生します。  

```json
{
  code: 403,
  errors:
   [ { domain: 'androidpublisher',
       reason: 'projectNotLinked',
       message:
        'The project id used to call the Google Play Developer API has not been linked in the Google Play Developer Console.' } ]
}
```

このような状況になった場合は、新しい商品を登録することで回避できます。  
(非公開でOK、事象解消後商品を削除してもOK)  

# 実装

Android In-app Billing Version3 API を実装しているライブラリのラッパーで、「React Native Android課金」で検索するとほぼ1択っぽい。
[react-native-billing](https://github.com/idehub/react-native-billing)  

## アイテム課金購入フロー

1. Google Play サービスチャンネルを開く。
2. 消費リクエストをGoogle Playに送る。
3. 正常終了したら購入リクエストをGoogle Playに送る。
4. 購入リクエストが正常終了したら、アプリ側のポイントを加算する。
5. 消費リクエストをGoogle Playに送る。
6. Google Play サービスチャンネルを閉じる。

注意点としては、サービスチャンネルを複数開かないこと、サービスチャンネルを必ず閉じるところです。

``` typescript
async pay(productId:string) {
  try {
    await InAppBilling.open();
    await InAppBilling.loadOwnedPurchasesFromGoogle();
    const isPurchased = await InAppBilling.isPurchased(productId);
    if (isPurchased) {
      // TODO: リカバリ処理をしてアイテムを消費する
      await InAppBilling.consumePurchase(productId);
    }
    try {
      await InAppBilling.purchase(productId);
    } catch(e) {
      // TODO: 購入キャンセル
      return;
    }
    const details = await InAppBilling.getPurchaseTransactionDetails(productId);
    if (details.purchaseState === 'PurchasedSuccessfully') {
      // TODO: サーバ側ポイント加算処理(detailsにpurchaseTokenなどが含まれているのでサーバで保存する)
      await InAppBilling.consumePurchase(productId);
    }
  } catch (err) {
    console.log(err);
  } finally {
    await InAppBilling.close();
  }
}
```

## 定期購入フロー

基本的な流れとしてはアイテム課金とほぼ同じです。  

``` typescript
async subscribe(productId:string) {
  try {
    await InAppBilling.open();
    await InAppBilling.loadOwnedPurchasesFromGoogle();
    if (!await InAppBilling.isSubscribed(productId)) {
      try {
        await InAppBilling.subscribe(productId);
      } catch(e) {
        // TODO: 購入キャンセル
        return;
      }
      const details = await InAppBilling.getSubscriptionTransactionDetails(productId);
      if (details.purchaseState === 'PurchasedSuccessfully') {
        // TODO: サーバ側定期購入済み処理(detailsにpurchaseTokenなどが含まれているのでサーバで保存する)
      }
    } else {
      // TODO: サブスクリプション購入済みの処理
    }
  } catch (err) {
    console.log(err);
  } finally {
    await InAppBilling.close();
  }
}
```

## サーバサイドの処理

課金時に発行されたレシート情報が正しいかチェックを行い、ポイント加算・サブスクリプション期間の保存を行います。
サンプルとして公式のGoogle APIs Client Library for JavaScriptを使用します。  

``` typescript
import { google, androidpublisher_v3 } from 'googleapis';
import * as moment from 'moment';
export class GoogleApiJob {
  publisher: androidpublisher_v3.Androidpublisher;

  constructor() {
    const jwtClient = new google.auth.JWT(
      process.env.GOOGLE_CLIENT_EMAIL,
      null,
      process.env.GOOGLE_PRIVATE_KEY,
      ['https://www.googleapis.com/auth/androidpublisher'],
      null,
    );
    this.publisher = google.androidpublisher({
      version: 'v3',
      auth: jwtClient,
    });
  }

  async checkSubscription(purchaseToken) {
    try {
      const ret = await this.publisher.purchases.subscriptions.get({
        packageName: process.env.ANDROID_PACKAGE_NAME,
        subscriptionId: process.env.ANDROID_SUBSCRIPTION_ID,
        token: purchaseToken,
      });
      const start = moment(Number(ret.data.startTimeMillis)).format('YYYY-MM-DD HH:mm');
      const expiry = moment(Number(ret.data.expiryTimeMillis)).format('YYYY-MM-DD HH:mm');
      console.log(`開始日時: ${start} 終了日時: ${expiry}`);
      // キャンセルされるとcancelReasonに値が設定される
      if (ret.data.cancelReason === void 0) {
        // TODO: 正常系処理
      } else {
        // TODO: キャンセル処理
      }

    } catch (e) {
      console.log(e);
    }

  }
}

```

# 参考文献

[Androidでアプリ内課金テストを行う](https://qiita.com/soratyan/items/91423b9d0d474cb044c7)
[Androidでのアプリ内課金でハマったポイント・注意するポイント](https://qiita.com/intel_au/items/0d3266d028c788f3ac2d)
[アプリ内課金のテスト](https://developer.android.com/google/play/billing/billing_testing.html)
[Client Libraries and Code Samples](https://developers.google.com/android-publisher/libraries?hl=JA)