Expoで開発しているReactNativeアプリにPAY.JPを使って決済機能をつけてみます。
過去に以下のようなStripeを使った記事を投稿していますが、
ejectなしのExpoアプリにStripe決済をWebViewを使って埋め込む
https://qiita.com/mildsummer/items/f95fd53864be6f14e3b0
ejectなしのExpoアプリにStripe決済をWebViewを使って埋め込む(Checkout編)
https://qiita.com/mildsummer/items/616677286e79cb8f8f75
ExpoアプリでのStripe決済を整理+Paymentsを使ってみる
https://qiita.com/mildsummer/items/36c043b5e8466d338292
手段を把握するのが目的で書いているので、StripeとPAY.JPどちらを選択するのがよいかという主張は(今のところ)ありません。
この記事ではEjectが必要なReactNative用のパッケージを使用せず、Managed Workflowにおいてpayjp.jsをWebView内で使用する方針で実装してみます。
CheckoutをWebViewで使用することも考えたのですが、こちらはStripeのリダイレクトによるCheckout画面と違ってボタンを表示するタイプなので、うまいことシームレスにReactNativeと連携させることが難しいため一旦諦めました。
payjp.js
について
payjp.jsではカード情報をPAY.JP側に送信しトークンを取得する処理が提供されており、事業者はカード情報を保持せずこのトークンのみを使用して決済等をすることになります。
Stripeの最初の記事で使用したStripe Elementsは、ユーザーの入力したカード番号等が秘匿化されたinputを生成するようなAPIですが、
payjp.jsではそのような縛りがなく、以下のようにカード情報入力フォームの内容を直接JSで渡すようになっています。
const card = {
number: document.querySelector('input[name="number"]').value,
cvc: document.querySelector('input[name="cvc"]').value,
exp_month: document.querySelector('select[name="exp_month"]').value,
exp_year: document.querySelector('input[name="exp_year"]').value
};
Payjp.createToken(card, function(status, token) {
if (status === 200) {
// 成功
} else {
// エラー
};
});
この場合、実装方法次第ではカード情報を保持・通過できてしまうという点でStripeと比べ堅牢さが劣ることになります。
一方、HTMLに依存する部分が少なくなるため、カード情報入力フォームはWebView側ではなくReactNative側で実装することも可能です。
以下ではWebView経由でpayjp.jsを使用しつつ、ReactNativeでの通常の実装方法で入力フォームを実装します。
WebViewを使用する部分をコンポーネント化
カード情報からトークンを作成する処理に関しては直接使用できるWebAPIがないので、payjp.js
をWebViewを通して使用します。
以下のように、WebViewとReactNative側を橋渡しするような不可視(幅・高さ0)のコンポーネントを作成します。
必要なパッケージは適宜インストールしてください。
import React, { Component } from "react";
import * as PropTypes from "prop-types";
import { WebView } from "react-native-webview";
let payJpJs = null;
const PAY_JP_JS_URL = "https://js.pay.jp/";
class PayJpBridge extends Component {
callbacks = {};
loaded = false;
constructor(props) {
super(props);
this.eventHandlers = {
// WebViewから送られたイベントに対応するメソッドを設定
TOKEN: this.onToken,
LOG: console.log
};
}
onLoad = async () => {
const { publicKey } = this.props;
const payJpJs = await this.getPayJpJs();
this.webview.injectJavaScript(payJpJs);
this.webview.injectJavaScript(`
Payjp.setPublicKey('${publicKey}');
/**
* アプリ側にデータを送る
* @param {string} type
* @param data
*/
function postMessage(type, data) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type, data }));
}
/**
* トークンを生成
* @param {object} card
* @param {string} callbackKey
*/
function createToken(card, callbackKey) {
try {
Payjp.createToken(card, function(status, response) {
if (status === 200) {
postMessage('TOKEN', {
callbackKey: callbackKey,
token: JSON.stringify(response)
});
} else {
postMessage('TOKEN', {
callbackKey: callbackKey,
error: JSON.stringify(response.error)
});
}
});
} catch (e) {
postMessage('LOG', e.message);
}
}
`);
this.loaded = true;
};
/**
* payjp.jsを読み込む
* @returns {Promise<string>}
*/
getPayJpJs = async () => {
if (payJpJs) {
return payJpJs;
} else {
const response = await fetch(PAY_JP_JS_URL);
return await response.text();
}
};
/**
* WebViewからのデータを処理
* @param event
*/
onMessage = event => {
const json = JSON.parse(event.nativeEvent.data);
const { data, type } = json;
this.eventHandlers[type] && this.eventHandlers[type](data);
};
/**
* トークン作成時
* @param {object} data
*/
onToken = data => {
const error = data.error && JSON.parse(data.error);
const token = data.token && JSON.parse(data.token);
const callback = this.callbacks[data.callbackKey];
callback && callback(token, error);
};
/**
* カードトークンの作成
* @param {object} card
* @returns {Promise<any>}
*/
createToken(card) {
return new Promise((resolve, reject) => {
if (this.loaded) {
const callbackKey = (+new Date()).toString(); // WebViewとコールバックのやり取りをするためのキーを作る
this.callbacks[callbackKey] = (token, error) => {
if (token) {
resolve(token);
} else {
reject(new Error(error.description || error.message));
}
};
this.webview.injectJavaScript(
`createToken(${JSON.stringify(card)}, ${callbackKey});`
);
} else {
reject(new Error("PayJPが初期化されていません"));
}
});
}
render() {
return (
<WebView
ref={ref => {
if (ref) {
this.webview = ref;
}
}}
javaScriptEnabled={true}
scrollEnabled={false}
bounces={false}
source={{
uri: "https://.../" // HTMLを指定
}}
onLoad={this.onLoad}
onMessage={this.onMessage}
style={{
width: 0,
height: 0
}}
/>
);
}
}
PayJpBridge.propTypes = {
publicKey: PropTypes.string.isRequired // 公開鍵
};
export default PayJpBridge;
payjp.js
では内部的にiframeを使用してセキュアなことをしているようで、<WebView>
のsource
にHTML文字列を直接指定する方法だとトークンが作成できませんでした。
何かしらhttpsでホスティングされたHTMLをuri
に指定する必要があります。
以下のような全く空のHTMLでもOKなはずです。
<!DOCTYPE html>
<html>
<head></head>
<body></body>
</html>
payjp.jsをfetchして、WebView内で読み込んでいます。
親要素からコンポーネントのインスタンスメソッドcreateToken
をカード情報を渡してコールすると、WebView内でトークンが生成されるような仕組みにしてみました。
PAY.JPの公開鍵はpropsで渡します。PAY.JPアカウントの作成後、左メニューの「API」から各種キーを取得してください。
画面・入力フォームを作成
カード情報を入力するフォームを作成し、送信ボタンが押されたらサーバー側でトークンを使って決済する流れになります。
カード番号、セキュリティーコード(CVC)、有効期限(月・年)の入力欄を作ります。
カード情報としてPAY.JPに登録することができるプロパティはこちらを参照してください。
以下の例では下記の記事のパターンでFormikを使ってフォームを実装しています。
ReactNativeで基本的なフォームを作成する(Formik/Yupを使用)
https://qiita.com/mildsummer/items/aba2a4434bb99697b8fa
import React, { Component } from "react";
import {
StyleSheet,
TouchableWithoutFeedback,
Keyboard,
View,
ScrollView,
Alert
} from "react-native";
import { Formik } from "formik";
import * as Yup from "yup";
import Input from "./app/components/Input";
import Button from "./app/components/Button";
import PayJpBridge from "./app/components/PayJpBridge";
import { functions } from "./app/utils/firebase";
const schema = Yup.object().shape({
number: Yup.string()
.min(14, "正しい番号を入力してください")
.max(16, "正しい番号を入力してください")
.required("番号を入力してください"),
cvc: Yup.string()
.min(3, "セキュリティーコードを入力してください")
.max(4, "セキュリティーコードを入力してください")
.required("セキュリティーコードを入力してください"),
exp_month: Yup.string()
.max(2, "有効期限を入力してください")
.required("有効期限を入力してください"),
exp_year: Yup.string()
.length(4, "有効期限を入力してください")
.required("有効期限を入力してください")
});
const styles = StyleSheet.create({
container: {
width: "100%",
paddingVertical: 60,
paddingHorizontal: 24,
backgroundColor: "#fff",
alignItems: "center",
justifyContent: "center"
},
form: {
width: "100%"
}
});
export default class CustomFormApp extends Component {
onSubmit = async (values, actions) => {
try {
const token = await this.payJp.createToken(values);
const response = await fetch('https://.../api/createCharge', { // サーバー側で決済
method: 'POST',
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify({ token: token.id })
});
const data = await response.json();
console.log(data);
Alert.alert('決済完了', `${data.amount}円の支払いが完了しました`);
actions.resetForm();
} catch ({ message }) {
console.log(message);
}
};
render() {
return (
<ScrollView contentContainerStyle={styles.container}>
<PayJpBridge
ref={(ref) => {
if (ref) {
this.payJp = ref;
}
}}
publicKey="pk_test_..." // 公開鍵を入れる
/>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={styles.form}>
<Formik
initialValues={{
number: "4242424242424242",
cvc: '123',
exp_month: '12',
exp_year: '2020'
}}
validateOnMount
validationSchema={schema}
onSubmit={this.onSubmit}
>
{({ handleSubmit, isValid, isSubmitting }) => (
<>
<Input
label="カード番号"
name="number"
/>
<Input
label="セキュリティーコード"
name="cvc"
/>
<Input
label="有効期限(月)"
name="exp_month"
/>
<Input
label="有効期限(年)"
name="exp_year"
/>
<Button
title="Submit"
onPress={handleSubmit}
disabled={!isValid || isSubmitting}
/>
</>
)}
</Formik>
</View>
</TouchableWithoutFeedback>
</ScrollView>
);
}
}
サーバー側で決済処理を行う
上記のソースでは送信ボタンが押下されたら、先ほどのコンポーネントを通してトークンが生成され、サーバー側で決済を完了します。
サーバー側処理は例えばFirebase Functionsで行う場合このようにPAY.JPのNode.jsパッケージを使用します。
const functions = require('firebase-functions');
const app = require('express')();
const payjp = require('payjp')('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('/createCharge', async (req, res) => {
const { token } = req.body;
const result = await payjp.charges.create({
amount: 1000,
currency: 'jpy',
card: token,
description: 'test charge'
});
res.json(result);
});
exports.api = functions.https.onRequest(app);
Stripe版の記事とほぼ同様です。
こちらも、ひとまずexpressサーバーを使用していますが、httpsCallableを使用する場合は二重決済が発生しないようにサーバー側で冪等性(Idempotency)を保証する必要があります(もちろんクライアント側でもダブルタップによる二重決済が発生しないように注意)。
その場合、Stripeにはあるような冪等性の機能がないため、本格的にサービスとして実装する場合はセッション管理等と合わせて決済完了フラグのようなものをDBで管理する感じになるかと思います(参考:Cloud Functions pro tips: Building idempotent functions)。
PAY.JPダッシュボードを確認
決済が通りました。
簡単な決済処理ですがExpoアプリでも問題なく動作するようです。