ejectなしのExpoアプリにStripe決済をWebViewを使って埋め込む

決済サービスのStripeをExpoで使用する方法を模索していて、この記事はその一つの実装例です。
最新のStripe APIをJSで使用する際、zaburoさんの下記の記事が参考になりますので、まずは目を通してください。

**(2020年元旦時点で最新の）Stripeの決済をReactで使う**
https://qiita.com/zaburo/items/7d4de7723b6d2445f356

さて、この記事での前提条件はこんな感じです。

* ejectはしない
* `Stripe Checkout`ではなく、`Stripe Elements`を使用する
* 決済は`Charge`方式ではなく`PaymentIntent`を使う
* 当然だけど`Stripe Elements`の中の値には関与しない。つまり、入力をネイティブ領域で行わせてそれをWebViewの`Sripe Elements`に反映するというようなことはしない。（`Stripe Elements`でそれができるのかはわからないが、巧妙に回避されているのは確か）

Expoプロジェクトをejectせずに使用できるライブラリが存在しないため、必然的に決済部分をWebViewに埋め込む方向で実装していきます。
ちなみに、公式にはこちらのeject必須のもの↓が紹介されています。
https://docs.expo.io/versions/latest/sdk/payments/

また、モーダルで表示されるパターンのCheckoutをWebViewで利用した実装例（[expo-stripe-checkout](https://github.com/briansztamfater/expo-stripe-checkout)）がありますが、こちらは現在のところ動作しませんでした。

## 1. サーバーサイドの準備

まずは、PaymentIntentを作成し`client_secret`をアプリに返すAPIを簡単に用意します。
このサンプルでは、`express`サーバーをFirebase Cloud Functionsにデプロイしています。

```functions/index.js
const functions = require('firebase-functions');

const app = require('express')();
const stripe = require('stripe')('sk_test_...'); // 秘密鍵を入れる
const cors = require('cors');
const bodyParser = require('body-parser');

app.use(require('body-parser').text());
app.use(cors());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

app.post('/createPaymentIntent', async (req, res) => {
  const result = await stripe.paymentIntents.create({
    amount: req.body.amount,
    currency: req.body.currency,
    description: 'some description', //option
    metadata: { username: req.body.username, tranId: '11111' } //option
  });

  res.json(result);
});

exports.api = functions.https.onRequest(app);
```

ひとまずこれをデプロイするだけですが、PaymentIntentというのはユーザーセッションと同列のもののようなので、実際のユースケースでは支払い画面に来た時点で作成するのがベターだと思います。
その場合はユーザーの動向に合わせキャンセル処理などを行うためのAPIを用意したり、Firebase AuthユーザーやFirestoreと連携したりとやるべきことが増えるかと思います。
PaymentIntentではなくChargeを使ったFirebase公式のサンプルは[こちら](https://github.com/firebase/functions-samples/tree/master/stripe)にあります。

## 2. アプリ側を作成

WebViewで表示する場合は基本的にReact等で作りこんだものをどこかでホスティングするか、アプリ内で生成したHTMLを表示するかのどちらかの方針が考えられます。
またはCheckoutを使ってStripeによって生成された画面を表示する方法もありますが、そちらはまた調査しようと思います。

→ **[2020/1/13]別記事で追記しました**
**[ejectなしのExpoアプリにStripe決済をWebViewを使って埋め込む（Checkout編）](https://qiita.com/mildsummer/items/616677286e79cb8f8f75)**

このためだけにどこかにホスティングするのは面倒なのと、どちらにしろ外部サイトを開くのであればCheckoutを使ってしまった方がセキュアだと思うので、
この記事ではまずアプリ内でHTMLを生成する方法をとって、ギリギリまでネイティブ領域とシームレスなコンポーネントとして実装してみます。

### [補足]`react-stripe-elements`について

[react-stripe-elements](https://github.com/stripe/react-stripe-elements)はReactを使用したWebアプリケーションに`Stripe Elements`を導入するための公式のライブラリです。
やっていることはリッチなUIの提供等ではなく`Stripe.js`と`Stripe Elements`をReactのレンダリングツリーの中でうまく扱うためのProviderの役目であり、`Stripe Elements`自体はもともと外部から状態を変更できないようになっているのでReactのコントロール外です。
既存のReactアプリケーション内に組み込む場合には有用なことは確かですが、最初からカード入力部分のみを作る場合にはそれほど重要ではないかもという印象です。今回は使っていません。

### 完成イメージ

このように、カード入力フォームの表示と決済処理のみをWebViewに任せ、ネイティブ領域と連携させます。
![screens.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/e75ab28d-e7e8-12f9-bc1a-3f6d42cfb1f0.png)

### WebViewを使ったコンポーネントを作成

```jsx:StripePayment.js
import React, { Component } from 'react';
import { View, ActivityIndicator } from 'react-native';
import * as PropTypes from 'prop-types';
import { WebView } from 'react-native-webview';

/**
 * Stripe決済をWebViewを介して行うコンポーネント
 */
class StripePayment extends Component {
  callbacks = {}; // 決済処理のコールバックが入る
  state = {
    ready: false // Elementsの初期化が完了したかどうか
  };

  constructor(props) {
    super(props);
    this.readyState = { // 各Elementsの初期化状態フラグ
      cardNumber: false,
      cardExpiry: false,
      cardCvc: false
    };
    this.completeState = { // 各Elementsの入力完了フラグ
      cardNumber: false,
      cardExpiry: false,
      cardCvc: false
    };
    this.complete = false; // 全てのElementsの入力完了フラグ
    this.eventHandlers = { // WebViewから送られたイベントに対応するメソッドを設定
      PAYMENT_STATUS: this.onPaymentStatus,
      READY: this.onReady,
      CHANGE: this.onChange,
      FOCUS: this.onFocus,
      BLUR: this.onBlur,
      LOG: console.log
    };
  }

  /**
   * 決済処理を開始
   * @param {number} amount
   * @param {function} callback
   */
  payment = (amount, callback) => {
    if (this.webview) {
      const callbackKey = (+new Date()).toString(); // WebViewとコールバックのやり取りをするためのキーを作る
      this.webview.injectJavaScript(`payment(${amount}, ${callbackKey});`);
      this.callbacks[callbackKey] = callback;
    }
  };

  /**
   * WebViewからのデータを処理
   * @param event
   */
  onMessage = (event) => {
    const json = JSON.parse(event.nativeEvent.data);
    const { data, type } = json;
    this.eventHandlers[type] && this.eventHandlers[type](data);
  };

  /**
   * Elementのreadyイベントに対応
   * @param {string} elementType
   */
  onReady = ({ elementType }) => {
    this.readyState[elementType] = true;
    // 全てのElementがreadyになったらWebViewを表示する
    if (this.readyState.cardNumber && this.readyState.cardExpiry && this.readyState.cardCvc) {
      this.setState({ ready: true });
    }
  };

  /**
   * Elementのchangeイベントに対応
   * @param {string} elementType
   * @param {boolean} complete
   */
  onChange = ({ elementType, complete }) => {
    this.completeState[elementType] = complete;
    const completeAll = this.completeState.cardNumber && this.completeState.cardExpiry && this.completeState.cardCvc;
    const { onChange } = this.props;
    this.complete = completeAll;
    onChange && onChange({
      complete: completeAll, // 全てのElementがcompleteかどうか
      elements: Object.assign({}, this.completeState)
    });
  };

  /**
   * 決済処理の状態が変わったら呼ばれる
   * paymentメソッドで渡したコールバックを呼ぶ
   * @param {string} status
   * @param {string} callbackKey
   * @param {string} error
   */
  onPaymentStatus = ({ status, callbackKey, error }) => {
    if (this.callbacks[callbackKey]) {
      this.callbacks[callbackKey]({ status, error });
      delete this.callbacks[callbackKey];
    }
  };

  render() {
    const { ready } = this.state;
    const {
      publicKey,
      currency,
      elementStyle,
      locale,
      style,
      backgroundColor,
      height,
      username
    } = this.props;
    const html = `
      <!DOCTYPE html>
      <html lang="${locale}">
        <head>
          <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0,user-scalable=0" />
          <style>
            body {
              margin: 0;
              background-color: ${backgroundColor};
            }
            .container {
              display: flex;
              height: ${height}px;
              align-items: center;
            }
            .element {
              box-sizing: border-box;
              padding: 0 5px;
            }
            #card-number {
              flex-grow: 1.5;
            }
            #card-expiry {
              flex-grow: 0.6;
            }
            #card-cvc {
              flex-grow: 0.4;
            }
          </style>
        </head>
        <body>
          <div class="container">
            <div class="element" id="card-number"></div>
            <div class="element" id="card-expiry"></div>
            <div class="element" id="card-cvc"></div>
          </div>
          <script src="https://js.stripe.com/v3/"></script>
          <script>
            var style = ${JSON.stringify(elementStyle)};
            var stripe = Stripe('${publicKey}');
            var elements = stripe.elements({ locale: '${locale}' });
            var cardNumberElement = elements.create('cardNumber', { style: style });
            cardNumberElement.mount('#card-number');
            listenElement(cardNumberElement);
            var cardExpiryElement = elements.create('cardExpiry', { style: style });
            cardExpiryElement.mount('#card-expiry');
            listenElement(cardExpiryElement);
            var cardCvcElement = elements.create('cardCvc', { style: style });
            cardCvcElement.mount('#card-cvc');
            listenElement(cardCvcElement);
          　
            /**
             * Elementのイベントをハンドリング
             * @param {StripeElement} element
             */
            function listenElement(element) {
              ['ready', 'change', 'focus', 'blur'].forEach(function(eventType) {
                element.on(eventType, function(event) {
                  postMessage(eventType.toUpperCase(), event);
                });
              });
            }
            
            /**
             * アプリ側にデータを送る
             * @param {string} type
             * @param data
             */
            function postMessage(type, data) {
              window.ReactNativeWebView.postMessage(JSON.stringify({ type, data }));
            }
            
            /**
             * 決済処理を開始
             * @param {number} amount
             * @param {string} callbackKey
             */
            function payment(amount, callbackKey) {
              //paymentIntentの作成をリクエスト
              postMessage('LOG', 'payment start');
              fetch('https://[リージョン]-[プロジェクトID].cloudfunctions.net/api/createPaymentIntent', {
                method: 'POST',
                headers: {
                  'Content-type': 'application/json'
                },
                body: JSON.stringify({ amount: amount, currency: '${currency}', username: '${username}' })
              }).then(function(response) {
                return response.json();
              }).then(function(responseJson) {
                // paymentIntentを決済
                return stripe.confirmCardPayment(responseJson.client_secret, {
                  payment_method: {
                    card: cardNumberElement
                  }
                });
              }).then(function(response) {
                // 完了したらアプリにステータスを送信
                postMessage('PAYMENT_STATUS', {
                  callbackKey: callbackKey,
                  status: response.paymentIntent.status
                });
              }).catch(function(e) {
                postMessage('PAYMENT_STATUS', {
                  callbackKey: callbackKey,
                  status: 'error',
                  error: e.message
                });
              });
            }
          </script>
        </body>
      </html>
    `;
    return (
      <View style={[{
        margin: 0,
        width: '100%',
        height,
        backgroundColor
      }, style]}>
        {!ready && <ActivityIndicator style={{ width: '100%', height }}/>}
        <WebView
          ref={(ref) => {
            if (ref) {
              this.webview = ref;
            }
          }}
          javaScriptEnabled={true}
          scrollEnabled={false}
          bounces={false}
          source={{ html }}
          onMessage={this.onMessage}
          style={{
            width: '100%',
            margin: 0,
            height,
            padding: 0,
            opacity: ready ? 1 : 0
          }}
        />
      </View>
    );
  }
}

StripePayment.propTypes = {
  publicKey: PropTypes.string.isRequired,
  username: PropTypes.string,
  currency: PropTypes.string,
  onChange: PropTypes.func,
  elementStyle: PropTypes.shape({
    base: PropTypes.object,
    complete: PropTypes.object,
    empty: PropTypes.object,
    invalid: PropTypes.object
  }),
  locale: PropTypes.string,
  style: PropTypes.object,
  backgroundColor: PropTypes.string,
  height: PropTypes.number
};

StripePayment.defaultProps = {
  currency: 'JPY',
  username: null,
  onChange: null,
  elementStyle: {},
  locale: 'ja',
  style: null,
  backgroundColor: '#fff',
  height: 100
};

export default StripePayment;
```

WebView内のHTML・CSS・JSが入っているので長いですが、ややこしいのはWebViewとアプリとのやりとりをしている部分です。
HTMLは別ファイルにしてWebPackの設定を追加したり、WebViewとの連携をしやすくするユーティリティを別で作ったりしてもかなとも思いつつ、一つのソースにまとめてみました。
Elementの作成はHTML内の`<script>`タグの最初の部分にあり、決済処理はHTML内の`payment`関数の部分です。
`createPaymentIntent`APIのURLは適宜変更してください。

**[追記]**
本当に必要な部分のみをWebViewにしたい場合は、`amount`や`currency`などを渡して`client_secret`を取得する処理もWebViewの外、ネイティブ側に持たせることが可能です。その場合はCloud FunctionsのhttpsCallableなどが使いやすいかも。

### 画面を作成

上記のコンポーネントを使って画面を作成します。ローディングや入力完了フラグをチェックしたりして、試しに固定で１０００円分を決済します。

```jsx:App.js
import React, { Component } from 'react';
import { StyleSheet, Text, View, TouchableOpacity, ActivityIndicator } from 'react-native';
import StripePayment from './StripePayment';

export default class App extends Component {
  state = {
    loading: false,
    complete: false,
    succeeded: false
  };

  /**
   * 決済処理を開始
   */
  payment = () => {
    this.setState({ loading: true }, () => {
      this.stripe.payment(1000, ({ status, error }) => {
        const succeeded = status === 'succeeded';
        this.setState({ loading: false, succeeded });
        if (error) {
          Alert.alert(error);
        }
      });
    });
  };

  /**
   * Stripe Elementsのchangeイベントが発生したら呼ばれる
   * @param complete
   * @param elements
   */
  onChangeStripe = ({ complete, elements }) => {
    console.log('card completion', complete, elements);
    if (this.state.complete !== complete) { // 全てcompleteならボタンを有効化
      this.setState({ complete });
    }
  };

  render() {
    const { loading, complete, succeeded } = this.state;
    return (
      <View style={styles.container}>
        <Text style={styles.title}>支払いのテスト</Text>
        <StripePayment
          ref={(ref) => {
            if (ref) {
              this.stripe = ref;
            }
          }}
          publicKey="pk_test_..."
          onChange={this.onChangeStripe}
          backgroundColor="#eee"
          style={styles.stripe}
          elementStyle={{
            base: {
              fontSize: '16px',
              lineHeight: '32px',
              // textAlign: 'center'
            }
          }}
          username="test"
        />
        <TouchableOpacity onPress={this.payment} disabled={!complete || loading || succeeded}>
          <View
            style={[
              styles.button,
              !complete && styles.disabledButton,
              succeeded && styles.succeededButton
            ]}
          >
            {loading && <ActivityIndicator color="#ffffff" style={styles.buttonIndicator} />}
            <Text
              style={[styles.buttonText, succeeded && styles.succeededButtonText]}
            >
              {succeeded ? '支払いが完了しました' : '支払い'}
            </Text>
          </View>
        </TouchableOpacity>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    height: '100%',
    width: '100%',
    backgroundColor: '#ffffff',
    alignItems: 'center',
    justifyContent: 'center'
  },
  title: {
    position: 'relative',
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 24
  },
  stripe: {
    marginBottom: 24
  },
  button: {
    position: 'relative',
    width: 240,
    height: 50,
    borderRadius: 25,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'orange'
  },
  disabledButton: {
    opacity: 0.5
  },
  succeededButton: {
    borderColor: 'orange',
    borderWidth: 2,
    backgroundColor: 'transparent'
  },
  buttonText: {
    fontSize: 18,
    fontWeight: 'bold',
    color: '#ffffff'
  },
  succeededButtonText: {
    color: 'orange'
  },
  buttonIndicator: {
    position: 'absolute',
    top: 0,
    left: 0,
    width: 50,
    height: 50,
    justifyContent: 'center',
    alignItems: 'center'
  }
});
```

## ダッシュボードで確認

「支払い」ボタンが問題なく動作したら、ダッシュボードを確認してみます。
![ダッシュボード.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/947dfc42-44bb-97b7-3416-39b081c0adb1.jpeg)
問題なくテスト決済が成功しました。