決済サービスの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)がありますが、こちらは現在のところ動作しませんでした。
1. サーバーサイドの準備
まずは、PaymentIntentを作成しclient_secret
をアプリに返すAPIを簡単に用意します。
このサンプルでは、express
サーバーをFirebase Cloud Functionsにデプロイしています。
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公式のサンプルはこちらにあります。
2. アプリ側を作成
WebViewで表示する場合は基本的にReact等で作りこんだものをどこかでホスティングするか、アプリ内で生成したHTMLを表示するかのどちらかの方針が考えられます。
またはCheckoutを使ってStripeによって生成された画面を表示する方法もありますが、そちらはまた調査しようと思います。
→ [2020/1/13]別記事で追記しました
ejectなしのExpoアプリにStripe決済をWebViewを使って埋め込む(Checkout編)
このためだけにどこかにホスティングするのは面倒なのと、どちらにしろ外部サイトを開くのであればCheckoutを使ってしまった方がセキュアだと思うので、
この記事ではまずアプリ内でHTMLを生成する方法をとって、ギリギリまでネイティブ領域とシームレスなコンポーネントとして実装してみます。
[補足]react-stripe-elements
について
react-stripe-elementsはReactを使用したWebアプリケーションにStripe Elements
を導入するための公式のライブラリです。
やっていることはリッチなUIの提供等ではなくStripe.js
とStripe Elements
をReactのレンダリングツリーの中でうまく扱うためのProviderの役目であり、Stripe Elements
自体はもともと外部から状態を変更できないようになっているのでReactのコントロール外です。
既存のReactアプリケーション内に組み込む場合には有用なことは確かですが、最初からカード入力部分のみを作る場合にはそれほど重要ではないかもという印象です。今回は使っていません。
完成イメージ
このように、カード入力フォームの表示と決済処理のみをWebViewに任せ、ネイティブ領域と連携させます。
WebViewを使ったコンポーネントを作成
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などが使いやすいかも。
画面を作成
上記のコンポーネントを使って画面を作成します。ローディングや入力完了フラグをチェックしたりして、試しに固定で1000円分を決済します。
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'
}
});