この記事は「PAY Advent Calendar 2024」の11日目です。
はじめに:ExpoでPAY.JP SDKを使いたい!
Expoで開発するモバイルアプリでPAY.JPのクレジットカード決済を組み込む際には、通常PAY.JP SDK React Nativeプラグインを利用します。
PAY.JP SDK React Nativeプラグインはネイティブモジュールを含んでおり、公式ドキュメントには「プラグインにはネイティブモジュールが含まれているため、現時点ではExpoのManaged workflowには対応していません」と明記されています。
このため、PAY.JPを利用を開始するにはBare workflowへの移行や、ネイティブコードを追加してExpo Modules API経由でSDKを呼び出す方法が求められます。
Bare workflowではExpo Goへのデプロイができず、アプリのネイティブコードを自分でコンパイルする必要があるため、ウェブベースのExpoの軽量さを活かした開発には不向きです。
そしてExpo Modules APIを使う方法は、ネイティブコードを記述する必要があり、SwiftやKotlinの知識が必要になります。
そこで、代替手段としてブラウザ向けのpayjp.js v2とExpo SDK 52から提供される"use dom"ディレクティブで有効になるDOMコンポーネントを活用する方法があります。
"use dom"を利用することで、ブラウザ環境のJSライブラリをExpoアプリ内で動作させることが可能になります。
本稿では、Expoアプリでネイティブアプリの体験からPAY.JPを利用する方法として、payjp.js v2 & "use dom" を使ったカード入力フォームの実装方法を提案します。
ExpoによるネイティブアプリのUIで、ExpoのManaged workflowの環境を維持しながら、自由にカスタマイズ可能なカード決済フォームを実現できます。
ヒント:サーバーサイドで実装する方法も
他には決済処理はすべてサーバーサイドで行い、ExpoアプリからはリモートのURLにWebViewでアクセスする方法もあります。
この方法では、Expoアプリ内での決済処理を最小限に抑え、サーバーサイドでの決済処理を行うことで、Expoアプリの開発を簡素化できます。
開発のしやすさやメンバーのスキルセットの観点から、サーバーサイドでの決済処理を行うことが望ましい場合もあります。
ExpoでのPAY.JP決済組み込み方法比較
| 方法 | workflow | メリット | デメリット | 
|---|---|---|---|
| PAY.JP SDK React Nativeプラグイン | Bare workflow | ・ 公式の方法。ネイティブSDKのためパフォーマンス・機能性に優れる | ・ Managed workflow非対応のためBare workflowへの移行が必要 ・ ネイティブコードの知識・開発経験が必要 ・ Expo Goでの開発不可 | 
| Expo Modules API & PAY.JP モバイルSDK | Managed workflow対応 | ・ ネイティブSDKのためパフォーマンス・機能性に優れる | ・ ネイティブコードの知識・開発経験が必要 ・ Swift/Kotlinコードを記述する必要あり ・ ドキュメントの不足 | 
| payjp.js v2 & "use dom" | Managed workflow対応 | ・ Expo Go, EAS BuildなどのManaged workflowの利点を活用可能 ・ ネイティブアプリ開発の知識が不要 | ・ WebViewでの動作のためネイティブアプリより機能性で劣る可能性 ・ ドキュメントの不足 | 
| サーバーサイド(payjp.js or Checkout) | Managed workflow対応 | ・ Expoアプリ側はWebViewでサーバーサイドの決済画面にアクセスするのみのため実装が比較的容易 ・ 設計上も決済機能が分離される | ・ サーバーサイドのUI開発が必要 ・ WebViewでの動作のためネイティブアプリより機能性で劣る可能性 | 
"use dom"について
"use dom"はExpo SDKにreact-native-webviewが統合された機能といえます。
ExpoアプリでHTMLで記述されたビューとネイティブのビューを連携させる従来の方法としては、URLでページとして埋め込むか、react-native-webviewを使いHTMLを直接渡す手法が一般的でした。
しかし、このアプローチには、Webコンポーネントとネイティブコンポーネント間のデータのやり取りやデバッグ、ルーティングなどに多くの課題がありました。
この課題を解決するために登場したのが、Expo SDK 52で提供される"use dom"ディレクティブによって定義されるDOMコンポーネントです。
"use dom"使用すると、ExpoアプリでReact DOMコンポーネントを内部WebViewにシームレスにレンダリングでき、Webとネイティブの間の統合が自動的に行われます。
内部的にはreact-native-webviewを使用していますが、開発者からは意識することなく、アプリ画面の一部としてレンダリングされます。
 
use domの特徴
| 機能 | use domでの対応 | 
|---|---|
| データ連携 | props, native actionsを通じてDOMコンポーネントとネイティブコンポーネント間でシームレスなデータ連携が可能 | 
| イベント処理 | Native Actions(コールバック関数)を通じてDOMコンポーネントのイベントをネイティブコンポーネントで処理することが可能 | 
| デバッグ | (iOS)Safariの開発者ツールを用いてDOMコンポーネントのデバッグが可能 | 
| ルーティング | Expo RouterのAPIを用いてDOMコンポーネントからルーティングをExpoアプリのルーティングシステムと連携させることが可能 | 
| メンテナンス性 | Reactコンポーネントのコードを共通化できる | 
| 将来性 | 今後のReact Server Componentsへの統合により、更なる進化が期待される | 
| DOM要素のサイズ取得 | matchContentspropまたはnative actionを用いて、DOM要素のサイズを取得し、ネイティブ側に伝えることが可能 | 
use domの制約
また、現時点では以下のような制約があります。
- コンポーネントにはchildrenを渡せない
- ネイティブビューをコンポーネント内に描画できない
- 関数propsは同期的に値を返すことができない
注意点:実験的な段階
"use dom"は実験的な機能であり、今後のExpo SDKのバージョンアップに伴い、仕様が変更される可能性があります。
また、DOMコンポーネントはネイティブビューと比較してパフォーマンスが劣る場合があるため、パフォーマンスクリティカルな部分にはネイティブビューを使用することが推奨されています。
payjp.js v2の概要
payjp.js v2は、ブラウザ向けJavaScriptライブラリです。scriptタグとして加盟店のページから読み込む形式で提供されます。
PCI-DSSのセキュリティ要件を満たしながらカード情報入力フォームの安全な管理と柔軟なカスタマイズを可能にしています。
従来のpayjp.js v1では、加盟店が自らカード情報入力フォームを用意する必要がありましたが、最新のPCI-DSS準拠のため、v2ではPAY.JPドメイン内のiframeにフォームを設置する仕組みを採用しています。
この仕組みにより、加盟店側でカード情報を入力するフォーム要素を直接扱う必要がなくなります。
さらに、payjp.js v2ではJavaScript APIを活用して、フォームの文字色やフォントなどをstyleオブジェクトで簡単にカスタマイズしたり、ユーザー入力に応じてフォームの表示や振る舞いを動的に変更したりすることが可能です。
CreditCardFormコンポーネントの作成
本サンプルアプリ(ソースコードリポジトリ)では"use dom"を活用し、payjp.js v2をWebView経由でロードしてカード入力フォームをアプリに埋め込みます。
この実装では、CreditCardFormというDOMコンポーネントを作成し、その中でpayjp.js v2を動作させることで、フォームの入力からトークン化までのプロセスを実現します。
基礎となるExpoアプリはcreate-expo-appで作成したものを想定しています。
$ npx create-expo-app@latest expo-payjp-sample
$ cd expo-payjp-sample
CreditCardFormのコンポーネントは、以下のように作成します。
このコンポーネントをベースに、追加したい機能やデザインに合わせてカスタマイズします。
"use dom";
import React, { useEffect, useState } from "react";
const PAYJP_PUBLIC_KEY = process.env.EXPO_PUBLIC_PAYJP_PUBLIC_KEY;
export default function CreditCardForm({ onTokenized, onError, dom }: {
  onTokenized: (token: string) => void;
  onError: (error: any) => void;
  dom: import("expo/dom").DOMProps;
}) {
  const [payjp, setPayjp] = useState<Payjp | null>(null);
  const [cardElement, setCardElement] = useState<any>(null);
  useEffect(() => {
    const script = document.createElement("script");
    script.src = "https://js.pay.jp/v2/pay.js";
    script.onload = () => {
      if (!PAYJP_PUBLIC_KEY) {
        throw new Error("EXPO_PUBLIC_PAYJP_PUBLIC_KEY is not defined. Please set it in .env");
      }
      const payjpInstance = window.Payjp(PAYJP_PUBLIC_KEY);
      setPayjp(payjpInstance);
      const elements = payjpInstance.elements();
      const cardElementInstance = elements.create("card");
      cardElementInstance.mount("#card-element");
      setCardElement(cardElementInstance);
    };
    document.body.appendChild(script);
    return () => {
      document.body.removeChild(script);
    };
  }, []);
  async function onSubmit(event: React.FormEvent) {
    event.preventDefault();
    if (payjp && cardElement) {
      try {
        const token = await payjp.createToken(cardElement);
        console.log(token);
        onTokenized(token.id);
      } catch (error) {
        onError(error);
      }
    }
  }
  return (
    <div style={styles.container}>
      <form onSubmit={onSubmit}>
        <div
          id="card-element"
          style={styles.cardElement}
        ></div>
        <div style={styles.buttonContainer}>
          <button type="submit" style={styles.button}>
            支払う
          </button>
        </div>
      </form>
    </div>
  );
}
EXPO_PUBLIC_PAYJP_PUBLIC_KEYは.envファイルに記述しておくとExpoによって環境変数として読み込まれます。
EXPO_PUBLIC_PAYJP_PUBLIC_KEY=pk_test_XXXXXXXX
CreditCardFormコンポーネントの利用
各画面の定義はExpo Routeのfile-based routingによって行います。
以下のように、CreditCardFormコンポーネントを読み出したい画面のrouteに通常のReactコンポーネントとして記述すると、アプリ起動時にカードフォームが表示されます
export default function HomeScreen() {
  async function sendToken(token: string) {
    // TODO
  }
  return (
    <ParallaxScrollView
      headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }}
      headerImage={
        <Image
          source={require("@/assets/images/partial-react-logo.png")}
          style={styles.reactLogo}
        />
      }
    >
      <ThemedView style={styles.titleContainer}>
        <ThemedText type="title">Welcome!</ThemedText>
        <HelloWave />
      </ThemedView>
      <ThemedView style={styles.stepContainer}>
        <ThemedText type="subtitle">CreditCardForm</ThemedText>
        // ↓↓↓ここだけWebViewになる↓↓↓
        <CreditCardForm
          onTokenized={sendToken}
          onError={console.error}
          dom={{ 
            scrollEnabled: false,
            matchContents: true 
          }}
        />
        // ↑↑↑ここだけWebViewになる↑↑↑
      </ThemedView>
    </ParallaxScrollView>
  );
}
起動後には、デフォルトのカード入力フォームが表示されるのを確認できます
試しにテストカード番号を入力してみるとブランドロゴが自動で切り替わるのを確認できました。
 
「支払う」ボタンをタップすると、payjp.js v2のcreateToken()によってユーザーが入力したカード情報がトークン化されます。
このトークンをsendToken()で送信して、サーバーサイドでの決済処理に使用します。
POST /api/chargeのサーバーサイドの実装は後述します。
  async function sendToken(token: string) {
    try {
      const response = await fetch(`/api/charge`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ token }),
      });
      const json = await response.json();
      console.log(json);
    } catch (error) {
      console.error(error);
    }
  }
ポイントとしては
- CreditCardFormコンポーネントはWebViewにレンダリングされる
- 
onTokenizedを通じてDOM→ネイティブのメッセージングが発生する
- 
sendToken()はReact Nativeアプリ側で実行される
 という点です。
もちろんWebViewの中でfetch(/api/charge)を呼んでAPI通信を行うことができますが、ネイティブに寄せることができるのならば、開発のしやすさやパフォーマンスの観点から、ネイティブ側で行うことが望ましいです。
API Routesによるサーバーサイド実装
ExpoのルーティングライブラリであるExpo RouterのAPI Routes機能を活用することで、サーバーサイドのAPIを実装できます。
この機能はWeb版アプリのバックエンドとして機能し、モバイルアプリからは外部のWeb APIとして呼び出すことが可能です。
先ほどクライアントサイドで呼び出すことにしたPOST /api/chargeというエンドポイントを追加し、このエンドポイントでpayjp-nodeを使用して決済処理を実行します。
このエンドポイントをExpo Routerで定義することで、フロントエンドアプリからサーバーサイドAPIへのリクエストをExpoアプリですべて行えるようになります。
$ npm install payjp
import Payjp from "payjp";
const PAYJP_SECRET = "sk_test_XXX";
export async function POST(request: Request) {
    const body = await request.json();
    
    const payjp = Payjp(PAYJP_SECRET);
    try {
        const charge = await payjp.charges.create({
            amount: 100, // TODO: 決済金額を指定
            currency: "jpy",
            card: body.token,
        });
        return Response.json({ charge });
    } catch (error) {
        console.error(error);
        return Response.json({ error });
    }
}
PAYJP_SECRETは本番環境では環境変数経由で設定するとよいでしょう。
注意点としてはEXPO_PUBLIC_でPAYJP_SECRETを設定すると、クライアントサイドからも参照可能になるため避けましょう。
実際のサーバーをデプロイする環境(VercelやDocker系)が推奨するSecrets管理機能を利用するとよいでしょう。
API RoutesはExpo Webのサーバーサイド機能なのでapp.jsonの設定を変更します。
{
  "expo": {
    "web": {
      "bundler": "metro",
-      "output": "static",
+      "output": "server",
      "favicon": "./assets/images/favicon.png"
    },
  }
}
試しにcurlコマンドを使ってAPIを呼び出してみましょう。トークンがダミーであるため、エラーレスポンスが返されるはずです。
$ curl -X POST http://localhost:8081/api/charge -H "Content-Type: application/json" -d '{"token":"tok_XXXXXXXX"}'
これで、Expoアプリ内でpayjp.js v2を使用したカード入力フォームを実装し、サーバーサイドでの決済処理を行う準備ができました。
http://localhost:8081というURLはExpo Webの開発サーバーのデフォルトのURLです。実機からこのAPIにアクセスできるように、app.jsonにoriginを設定します。
    "plugins": [
      [
        "expo-router",
        {
          "origin": "http://192.168.1.38:8081"
        }
      ]
    ],
このIPアドレスはexpo startを実行した際に表示されるQRコードの下に表示されるLANアドレスです。
本番環境にデプロイした場合は、そのURLを記述して、開発時は環境変数を使って切り替えるとよいでしょう。
決済処理完了後の画面遷移
決済処理が完了したら、ユーザーを別の画面にリダイレクトすることが一般的です。
Expo Routerを使用して、決済処理が成功した場合には"/success"に移動して、失敗した場合にはconsole.error()で画面にエラーメッセージが表示されるようにします。
+ import { router } from "expo-router";
  async function sendToken(token: string) {
    try {
      const response = await fetch(`/api/charge`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ token }),
      });
      const json = await response.json();
      console.log(json);
+      router.push("/success");
    } catch (error) {
      console.error(error);
    }
  }
フォームのカスタマイズ
payjp.js v2のJavaScript APIを活用することで、カード入力フォームのデザインや動作をカスタマイズすることが可能です。
カード番号・有効期限・セキュリティコードの入力を分割する
カード番号、有効期限、セキュリティコードをそれぞれ独立した入力フォームとして扱うことができます。
デフォルトのカード入力フォームでは、カード番号、有効期限、セキュリティコードが1つのElementにまとめられています。
これをモバイルアプリのUIに合わせて、2段に分割して表示するように変更します。
const elements = payjpInstance.elements();
const numberElement = elements.create("cardNumber");
const expiryElement = elements.create("cardExpiry");
const cvcElement = elements.create("cardCvc");
numberElement.mount("#number-element");
expiryElement.mount("#expiry-element");
cvcElement.mount("#cvc-element");
マウント先を用意するため要素を追加します。
  return (
    <div style={styles.container}>
      <form onSubmit={onSubmit}>
        <div style={styles.row}>
          <div id="number-form" style={styles.cardElement}></div>
        </div>
        <div style={styles.row}>
          <div id="expiry-form" style={styles.cardElement}></div>
          <div id="cvc-form" style={styles.cardElement}></div>
        </div>
        <div style={styles.buttonContainer}>
          <button
            type="submit"
            style={styles.button}
          >
            支払う
          </button>
        </div>
      </form>
    </div>
  );
ここで登場するstylesはreact-nativeの機能です。DOMコンポーネントはExpoのWeb版と同じをMetroバンドラ使用しているため、react-nativeのスタイルをそのまま利用できます。
import { StyleSheet } from "react-native";
const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  row: {
    display: "flex",
    flexDirection: "row",
  },
  cardElement: {
    width: "100%",
    height: 40,
    paddingTop: 8,
    paddingBottom: 8,
  },
  buttonContainer: {
    paddingTop: 8,
    paddingBottom: 8,
  },
  button: {
    width: "100%",
    height: 50,
    fontSize: 16,
  },
});
以下のようにカードフォームの構造をカスタマイズすることができました。
 
バリデーション:エラーメッセージの表示
payjp.js v2ではフォームの入力イベントを監視し、任意の処理を追加できます。
これを利用して、ユーザーのカード情報の入力に問題がある場合にエラーメッセージを表示するようにします。
またすべての入力が正常に完了した時点で「支払い」ボタンを有効化します。
const [isNumberComplete, setIsNumberComplete] = useState(false);
const [isExpiryComplete, setIsExpiryComplete] = useState(false);
const [isCvcComplete, setIsCvcComplete] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleNumberChange = (event: FormEvent) => {
  setErrorMessage(event.error ? event.error.message : null);
  setIsNumberComplete(event.complete);
};
const handleExpiryChange = (event: FormEvent) => {
  setErrorMessage(event.error ? event.error.message : null);
  setIsExpiryComplete(event.complete);
};
const handleCvcChange = (event: FormEvent) => {
  setErrorMessage(event.error ? event.error.message : null);
  setIsCvcComplete(event.complete);
};
numberElement.on("change", handleNumberChange);
expiryElement.on("change", handleExpiryChange);
cvcElement.on("change", handleCvcChange);
  return (
    <div style={styles.container}>
      <form onSubmit={onSubmit}>
        <div style={styles.row}>
          <div id="number-form" style={styles.cardElement}></div>
        </div>
        <div style={styles.row}>
          <div id="expiry-form" style={styles.cardElement}></div>
          <div id="cvc-form" style={styles.cardElement}></div>
        </div>
        {errorMessage && (
          <div style={styles.errorContainer}>
            <span style={styles.errorText}>{errorMessage}</span>
          </div>
        )}
        <div style={styles.buttonContainer}>
          <button
            type="submit"
            style={{ ...styles.button, opacity: isNumberComplete && isExpiryComplete && isCvcComplete ? 1 : 0.5 }}
            disabled={!isNumberComplete || !isExpiryComplete || !isCvcComplete}
          >
            支払う
          </button>
        </div>
      </form>
    </div>
  );
  errorContainer: {
    paddingTop: 8,
    paddingBottom: 8,
  },
  errorText: {
    color: "red",
  },
これでエラーメッセージの表示とボタンの有効無効切り替えができるようになりました。
 
3Dセキュア認証の実装
3Dセキュア認証は、オンラインでのクレジットカード決済がカード所有者本人であることを確認し、セキュリティを強化するための仕組みです。
従来のカード番号、有効期限、セキュリティコードに加えて、カード発行会社が設定したパスワードなどの追加認証を行うことで、不正利用のリスクを低減します。
payjp.js v2では、3Dセキュア認証をサポートしており、基本的な構成方法は以下のドキュメントに記載されています。
Bare workflowに移行したReact Nativeアプリでは、モバイルSDKが提供するカードフォーム画面を通じてカスタムスキーマベースで3Dセキュア認証を行うことができます。
その場合の実装方法については、以下のドキュメントを参照してください。
payjp.js v2を使った3Dセキュアには、主に「リダイレクト型」と「サブウィンドウ型」の2種類があります。
- サブウィンドウ型: 決済処理中に、加盟店のサイト上に認証画面を表示するサブウィンドウを開き、その中で認証を行う方式です。デフォルトの方式です。
- リダイレクト型: 決済処理中に、カード発行会社の認証画面へ遷移し、認証完了後に加盟店のサイトへ戻ってくる方式です。よりカスタマイズしたい時の方式です。
"use dom"を使ったExpoアプリではリダイレクト型の3Dセキュア認証を使用します。
ブラウザのウィンドウ管理を使ったサブウィンドウ型の認証は、Expoアプリ内で実現できないためです。
3Dセキュアの実施タイミングは、大きく分けて以下の2つの種類があります。
- トークン作成時: クレジットカード情報からトークンを生成する際に、3Dセキュア認証を行う方法です。
- 支払い作成時: 支払い処理を行う際に、3Dセキュア認証を行う方法です。
サンプルアプリでは「支払い作成時の3Dセキュア認証」を実装します。認証フローはそれぞれ異なりますが、基本的な方針は同じです。
Expoアプリでの3Dセキュア認証の実装方法のパターン
本稿では、Expoアプリ内でpayjp.js v2を使って2つのパターンで3Dセキュア認証を実装する方法を説明します。
- アプリ内ブラウザ(expo-web-browser)でWeb版のExpoのサーバーサイド処理を使って3Dセキュア認証を実装する方法
- 標準ブラウザ(Safari等)で3Dセキュア認証を行いカスタムスキーマでアプリに戻る方法
アプリ内ブラウザ版では、カスタムスキーマではなく、Expo WebでデプロイしたウェブアプリへのURLにリダイレクトをして認証を行います。
標準ブラウザ版では、カスタムスキーマを使ってアプリに戻る方法を説明します。カスタムスキーマで起動するために、Expo Goではなく独自にビルドしたアプリ(expo prebuild)を使用する必要があります。
アプリ内ブラウザ版
sendToken()で呼び出しているAPIエンドポイントを変更し、3Dセキュア認証を行うためのAPIエンドポイントを追加します。
現在の実装ではPOST /api/chargeを呼び出し決済処理を行っていますが、3Dセキュア認証のためにPOST /api/tokenを呼び出しPAY.JPよりリダイレクトURLを一度取得します。
外部ブラウザでカード発行会社の認証画面を開き、認証が完了した後に/confirmへコールバックされるようにします。
全体の流れを図で示します。
POST /api/tokenではcharge.idをクエリパラメーターに付与したJWT URLを生成し、コールバック先のURLを返します。
このIDを使い、3Dセキュア認証が完了したらPOST /api/chargeで決済処理を確定します。
コールバック先のURLはhttp://localhost:8181/confirm/とします。これはExpo WebにデプロイしたアプリのURLです。
export async function POST(request: Request) {
  const body = await request.json();
  console.log(!PAYJP_PUBLIC_KEY || !PAYJP_SECRET);
  if (!PAYJP_PUBLIC_KEY || !PAYJP_SECRET) {
    return Response.json({
      error: "PAYJP_PUBLIC_KEY and PAYJP_SECRET is not defined.",
    });
  }
  const payjp = Payjp(PAYJP_SECRET);
  try {
    const charge = await payjp.charges.create({
      amount: 100, // TODO
      currency: "jpy",
      card: body.token,
      three_d_secure: true,
    });
    const baseUrl = request.url.split("/api/")[0]; // NOTE: 現在起動しているサーバーのURLを取得
    const queryParams = {
      cid: charge.id
    };
    const jwtUrl = generateJwtUrl(baseUrl + "/confirm/", queryParams, PAYJP_SECRET);
    const redirectUrl = `https://api.pay.jp/v1/tds/${charge.id}/start?publickey=${PAYJP_PUBLIC_KEY}&back_url=${jwtUrl}`;
    return Response.json({ redirectUrl });
  } catch (error) {
    console.error(error);
    return Response.json({ error: "Internal Server Error" }, { status: 500 });
  }
}
generateJwtUrl()のためにjsonwebtokenをインストールします。
$ npm install jsonwebtoken
import jwt from "jsonwebtoken";
function generateJwtUrl(
  baseUrl: string,
  params: Record<string, string>,
  secretKey: string
) {
  const encodedParams = Object.keys(params)
    .map((key) => `${key}=${encodeURIComponent(params[key])}`)
    .join("&");
  const encodedUrl = `${baseUrl}?${encodedParams}`;
  const payload = {
    url: encodedUrl,
  };
  return jwt.sign(payload, secretKey, { algorithm: "HS256" });
}
次にsendToken()の実装を変更します。
アプリ内でWeb版のExpoアプリを開くために、expo-web-browserをインストールします。
$ npx expo install expo-web-browser
import * as WebBrowser from "expo-web-browser";
// ...
  async function sendToken(token: string) {
    try {
      const response = await fetch(`/api/token`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ token }),
      });
      const json = await response.json();
      console.log(json);
      WebBrowser.openBrowserAsync(json.redirectUrl);
    } catch (error) {
      console.error(error);
    }
  }
openBrowserAsync()が呼び出されると、Expoアプリ内のWebブラウザが開き、PAY.JPの3Dセキュア認証画面が表示されます。
この認証が正常に完了すると、/confirm/にリダイレクトされ、パラメーターにはcidが含まれます。
コールバック先の画面を作成し、確定ボタンを押したらcidを使ってPOST /api/chargeを呼び出す処理を追加します。
export default function ConfirmScreen() {
  const localParams = useLocalSearchParams() as { cid?: string };
  const [isPurchased, setIsPurchased] = useState(false);
  async function retrieveCharge(cid: string) {
    try {
      const response = await fetch(`/api/charge`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ cid: localParams.cid }),
      });
      console.log({ response });
      if (response.ok) {
        setIsPurchased(true);
      }
    } catch (error) {
      console.error(error);
    }
  }
  return (
    <ThemedView style={styles.container}>
      <ThemedView style={styles.titleContainer}>
        <ThemedText type="title">
          {isPurchased ? "支払いが完了しました" : "購入を確認してください"}
        </ThemedText>
      </ThemedView>
      <ThemedView style={styles.stepContainer}>
        <ThemedText>
          {isPurchased ? "この画面を閉じてください" : localParams.cid}
        </ThemedText>
        {!isPurchased && (
          <View style={styles.buttonContainer}>
            <Button
              title="購入する"
              onPress={async () => {
                if (localParams.cid) {
                  await retrieveCharge(localParams.cid);
                }
              }}
            />
          </View>
        )}
      </ThemedView>
    </ThemedView>
  );
}
POST /api/chargeを以下のように変更します。
export async function POST(request: Request) {
    const body = await request.json();
    if (!PAYJP_SECRET) {
        return Response.json({ error: 'PAYJP_SECRET is not defined. Please set it in .env' });
    }
    const payjp = Payjp(PAYJP_SECRET);
    try {
        const charge = await payjp.charges.retrieve(body.cid);
        console.log({ charge });
        return Response.json({ charge });
    } catch (error) {
        console.error(error);
        if ((error as PayjpError).response) {
            const payjpError = error as PayjpError;
            return new Response(JSON.stringify({ error: payjpError.response.error.message }), { status: payjpError.response.status });
        }
        return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500 });
    }
}
以下がここまでのフローを実装したアプリの動作です。
"この画面を閉じてください"と表示されたら、3Dセキュア認証が完了し、決済処理が成功したことを意味します。
このメッセージが必要な理由は、Webブラウザ内のJavaScriptからウィンドウを制御して自動でネイティブのExpoアプリに戻ることができないためです。
この方式はユーザーに手動での操作をしてもらうことが必要になるため不便ですが、Expoアプリ内で完結した3Dセキュア認証を行うための方法の1つです。
次はこれを回避するために、カスタムスキーマを使った標準ブラウザ版の実装方法を説明します。
標準ブラウザ版
アプリ内ブラウザ版で基本的な3Dセキュア認証の実装方法を説明しましたが、標準ブラウザ版ではカスタムスキーマを使ってアプリに戻る方法を説明します。
基本的な設計は同じですがいくつかの違いがあります。
まず、POST /api/tokenの実装を変更します。コールバック先のURLを自分で定義したカスタムスキーマに変更します。これはapp.jsonで設定したschemeになります。
{
  "expo": {
    "scheme": "expo-payjp.example.com"
  }
}
  const payjp = Payjp(PAYJP_SECRET);
  try {
    const charge = await payjp.charges.create({
      amount: 100, // TODO
      currency: "jpy",
      card: body.token,
      three_d_secure: true,
    });
-    const baseUrl = request.url.split("/api/")[0];
+    const baseUrl = "expo-payjp.example.com:/";
    const queryParams = {
      cid: charge.id
    };
    const jwtUrl = generateJwtUrl(baseUrl + "/confirm/", queryParams, PAYJP_SECRET);
    const redirectUrl = `https://api.pay.jp/v1/tds/${charge.id}/start?publickey=${PAYJP_PUBLIC_KEY}&back_url=${jwtUrl}`;
    return Response.json({ redirectUrl });
  }
そして外部URLを開くのにはExpo Routerを使います。
      if (response.ok) {
        const tds = await response.json();
-        await WebBrowser.openBrowserAsync(tds.redirectUrl, {
-          dismissButtonStyle: "close",
-        });
+        router.push(tds.redirectUrl);
      }
これによって、標準ブラウザで3Dセキュア認証を行い、カスタムスキーマでアプリに戻ることができます。
しかしカスタムスキーマでアプリに戻るためには、Expo Goではなく独自にビルドしたアプリを使用する必要があります。
expo prebuildでReact Nativeプロジェクトを生成し、expo build:iosやexpo build:androidを使ってビルドして、シュミレーターやデバイスにインストールしてテストしてください。
EASでリモートビルドする方法もありますが、ここでは説明しません)
以下がここまでのフローを実装したアプリの動作です。
このあとは:決済情報をセッションに保持する
ここまでの実装では、コールバック先のURLにcidを含めることで、それをキーにして認証が完了した時点で決済処理を行うことができます。
しかし一般的にはセッションやトークンを使用して認証情報を保持し、決済処理を行う際に追加の検証を行うことが推奨されます。
本稿ではセッションを使った認証情報の保持方法については説明しませんが、Expo RouterはカスタムサーバーとしてExpressを使うことができるため、Expressのセッション機能を使って認証情報を保持できます。
それを実現するための参考情報を以下に示します。
- Expo RouterをExpressと組み合わせる
- APIサーバーでjsonwebtokenパッケージを使用してトークンを生成し、クライアントに返します。
- アプリ側でAsyncStorageでトークンを保存します。
- APIを呼び出す際に、Authorizationヘッダーにトークンを付与します。
- サーバーサイドでトークンを検証し、認証情報を取得します。
- セッションに決済情報を保存し、3Dセキュア認証が完了した時点で決済処理を行います。
まとめ
以上、駆け足でExpoアプリにpayjp.js v2を組み込み、3Dセキュア認証を実装する方法までを説明しました。
Expoの機能やPAY.JPのサービスを説明するために、段階的に実装を進めましたが、実際の開発では公式のドキュメントやサンプルコードに基本的に従いながら参考にしながら、自分のアプリに合った実装を行ってください。
著者が実装したコードは以下のリポジトリで公開していますので、参考にしてください。


