PAY.JPで3Dセキュア認証ありクレジットカード決済を実装してみた

# はじめに

**経済産業省より2025年3月末までに EMV 3Dセキュアの導入**が求められており、クレジットカード決済が利用可能なECサイトでの3Dセキュア対応は必須となってきました。

そこで本記事では、ECサイトでクレジットカード決済の仕組みを容易に組み込むことができる PAY.JP を用いて、3Dセキュア認証ありクレジットカード決済を実装してみました。

# 機能説明

PAY.JP では3Dセキュア実施のパターンを3種類に分類されております。

https://pay.jp/docs/guideline-payjp-three-d-secure

本記事では**ECサイトで決済する際に都度クレジットカード情報を入力するケースを想定して支払い作成時の3Dセキュア**を実施します。

また、3Dセキュアを実施する際のワークフローとして「サブウィンドウ型」と「リダイレクト型」の2パターンがあります。サブウィンドウ型は容易に実装できることがメリットですが、利用環境によって動作しない可能性があるため、本記事では**利用環境に依存せず動作させることが可能なリダイレクト型**で3Dセキュアを実施します。

リダイレクト型の3Dセキュアについて公式ドキュメントに処理フロー図がありますので、そちらをご確認いただければ処理の流れがイメージしやすいと思います。

https://pay.jp/docs/guideline-payjp-three-d-secure#redirect

# 技術構成

PHP 8.4 を用いて実装します。また、フレームワークとして Laravel 11 を用います。
また、最低限の見た目を整えるため、CDN経由で Bootstrap 5 を用います。

# 実装手順

前提として、Laravel 11 のインストールが完了している状態から開始します。

## 1.PAY.JP APIキー情報の設定

PAY.JP を用いてクレジットカード決済を実装するために、API を呼び出すために必要な「秘密鍵」と「公開鍵」の情報を設定します。`.env` に以下の設定値を追加してください。

```text:.env
PAYJP_PUBLIC_KEY=[PAY.JP API設定から取得した公開鍵]
PAYJP_SECRET_KEY=[PAY.JP API設定から取得した秘密鍵]
```

:::note info

「秘密鍵」と「公開鍵」の情報は、PAY.JP のアカウント登録後に API 設定の画面から取得できます。テスト環境と本番環境で異なる値が用意されています。
本記事を作成するために実際にアカウントを作成しましたが、登録は非常に簡単でした！

:::

`.env` に設定した値を参照できるように `config` ディレクトリに `pay.php` を作成して以下のコードを記述してください。

```php:pay.php
<?php

return [
    'public_key' => env('PAYJP_PUBLIC_KEY'),
    'secret_key' => env('PAYJP_SECRET_KEY'),
];
```

## 2. 支払い情報入力画面の作成

定義ファイルの設定が完了したら、ユーザーがクレジットカード情報を入力する画面を作成します。

クレジットカード決済では入力されたカード情報をサーバー側で扱わないように、**ユーザーから直接 PAY.JP にカード情報を送信して、返却されたトークンをサーバーに送信する**方式がセキュリティ的に望ましいです。

PAY.JP ではトークン化を容易に行う方法がいくつか提供されています。

https://pay.jp/docs/cardtoken

本記事では **デザインや挙動をカスタマイズ可能な payjp.js を用いた方法**で実装します。`resources/views` ディレクトリに `payment.blade.php` を作成して以下のコードを記述してください。

クレジットカード決済において最低限必要な項目「カード番号」「有効期限」「セキュリティコード」については PCI-DSS に準拠するため、payjp.js を利用して生成しています。

また、**3Dセキュア認証では上記に加えて「カード名義」と「メールアドレス（or 電話番号）」が求められる**ため、本記事では「カード名義」と「メールアドレス」の入力項目を作成しております。

```html:payment.blade.php
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>3Dセキュア動作確認 - 支払い情報入力</title>
  <script src="https://js.pay.jp/v2/pay.js"></script>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous">
  </script>
</head>

<body>
  <div class="container py-4">
    <div class="row justify-content-center">
      <div class="col-md-6">
        <h1 class="h2 text-center">支払い情報入力</h1>

        <div id="token-error" class="alert alert-danger p-2 my-3" role="alert"></div>
        <div class="my-4">
          <label for="number-form" class="form-label mb-0">カード番号</label>
          <div id="number-form" class="form-control border border-secondary-subtle my-2"></div>
          <label for="expiry-form" class="form-label mb-0">メールアドレス</label>
          <div id="expiry-form" class="form-control border border-secondary-subtle my-2"></div>
          <label for="cvc-form" class="form-label mb-0">セキュリティコード</label>
          <div id="cvc-form" class="form-control border border-secondary-subtle my-2"></div>
          <label for="name-form" class="form-label mb-0">カード名義</label>
          <input id="name-form" type="text" class="form-control border border-secondary-subtle my-2"></input>
          <label for="email-form" class="form-label mb-0">メールアドレス</label>
          <input id="email-form" type="text" class="form-control border border-secondary-subtle my-2"></input>
        </div>

        <form name="payment" action="/payment" method="POST">
          @csrf
          <input id="token" name="cardToken" type="hidden"></input>
          <div class="text-center">
            <button type="button" class="btn btn-primary" onclick="onSubmit(event)">決済する</button>
          </div>
        </form>
      </div>
    </div>
  </div>

  <script>
    const payjp = Payjp("{{ config('pay.public_key')}}")

    // カード情報入力欄を生成
    const elements = payjp.elements()
    const numberElement = elements.create("cardNumber")
    numberElement.mount("#number-form")
    const expiryElement = elements.create("cardExpiry")
    expiryElement.mount("#expiry-form")
    const cvcElement = elements.create("cardCvc")
    cvcElement.mount("#cvc-form")

    // エラーメッセージ表示
    const showError = (message) => {
      document.querySelector("#token-error").style.display = "block"
      document.querySelector("#token-error").innerText = message
    }
    // エラーメッセージ非表示
    const hideError = () => {
      document.querySelector("#token-error").style.display = "none"
    }
    // 初期表示ではエラーメッセージ表示エリアを非表示
    hideError()

    // 決済するボタン押下時イベント
    const onSubmit = () => {}
  </script>
</body>

</html>
```

作成した `payment.blade.php` のルーティング設定を `web.php` に追加してください。

```diff_php:web.php
<?php

use Illuminate\Support\Facades\Route;

+ // 支払いページ表示
+ Route::get('/payment', fn () => view('payment'));
```

ブラウザで /payment にアクセスして以下の画面が表示されればOKです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/dccf6f57-6ec6-b7db-df9f-202a6b995824.png" width="400" />

## 3. トークン取得処理の作成

「決済する」ボタンを押下した際に入力したカード情報をもとにトークンを取得して、サーバーに送信する処理を作成します。

payjp.js を利用している場合は `createToken()` を実行するだけで容易にトークンを取得できます。

https://pay.jp/docs/payjs#create-token

「カード番号」「有効期限」「セキュリティコード」については個別に指定する必要はありませんが、3Dセキュアで必要となる追加項目「カード名義」「メールアドレス」については別途指定が必要となります。

```diff_html:payment.blade.php
  <script>
    // 省略

    // 決済するボタン押下時イベント
-    const onSubmit = () => {};
+    const onSubmit = async () => {
+      hideError()
+      const response = await payjp.createToken(numberElement, {
+        card: {
+          name: document.getElementById("name-form").value,
+          email: document.getElementById("email-form").value,
+        },
+      })
+      if (response.error) {
+        showError(response.error.message)
+        return
+      }
+      document.querySelector("#token").value = response.id
+      document.payment.submit()
+    }
  </script>
```

## 4. リダイレクト前処理の作成

ブラウザから送信されたトークンを受け取り、3Dセキュア認証のため PAY.JP にリダイレクトする処理を作成します。

リダイレクト前の処理でやることは大きく2つです。

１．「支払いを作成」の API を実行

https://pay.jp/docs/api/#%E6%94%AF%E6%89%95%E3%81%84%E3%82%92%E4%BD%9C%E6%88%90

２．「3Dセキュア開始」で指定された URL にリダイレクト

https://pay.jp/docs/api/#3d%E3%82%BB%E3%82%AD%E3%83%A5%E3%82%A2%E9%96%8B%E5%A7%8B

上記の処理を実行した後、一旦 PAY.JP の画面にリダイレクトするため、後から戻ってくる URL を指定する必要があります。PAY.JP では戻り先 URL を指定する方法として
* 管理画面から事前に登録しておく方法
* JWS（JSON Web Signatures）形式に変換した URL をパラメータとして指定する方法

の2パターンが用意されております。本記事では柔軟に URL を指定することが可能な「JWS（JSON Web Signatures）形式に変換した URL をパラメータとして指定する方法」を用います。

まずは JWS 形式に変換を行うためのライブラリ `firebase/php-jwt` をインストールします。

```bash
composer require firebase/php-jwt
```

インストールが完了したら、`app/Http/Controllers` ディレクトリに `PayController.php` を作成して以下のコードを記述してください。

:::note info
API を実行するためには Basic 認証のユーザーネームとして秘密鍵を指定する必要があります。
:::

```php:PayController.php
<?php

namespace App\Http\Controllers;

use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Psr\Http\Message\ResponseInterface;

class PayController extends Controller
{
    /**
     * リダイレクト前処理
     */
    public function redirect(Request $request): RedirectResponse
    {
        // 支払い作成
        $response = $this->createCharge($request->cardToken, 1000);
        if ($response->getStatusCode() !== 200) {
            return redirect('payment')->with('errorMessage', '支払いの作成に失敗しました。');
        }
        $body = json_decode($response->getBody()->getContents(), true);

        // 支払いIDをセッションに保存（リダイレクト後に取得するため）
        $request->session()->put('pay_id', $body['id']);

        // PAY.JPへリダイレクト
        $backUrl = url('/callback');
        $query = http_build_query([
            'publickey' => config('pay.public_key'),
            'back_url' => JWT::encode(['url' => $backUrl], config('pay.secret_key'), 'HS256'),
        ]);

        return redirect("https://api.pay.jp/v1/tds/{$body['id']}/start?{$query}");
    }

    /**
     * 支払いを作成する
     */
    private function createCharge(string $token, int $amount): ResponseInterface
    {
        $client = new Client([
            'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
            'auth' => [config('pay.secret_key'), ''],
        ]);
        try {
            $uri = 'https://api.pay.jp/v1/charges';
            return $client->request('POST', $uri, [
                'form_params' => [
                    'amount' => $amount,
                    'currency' => 'jpy',
                    'card' => $token,
                    'three_d_secure' => 'true',
                ]
            ]);
        } catch (ClientException $e) {
            return $e->getResponse();
        }
    }
}
```

作成したリダイレクト前処理のルーティング設定を `web.php` に追加してください。

```diff_php:web.php
// 支払いページ表示
Route::get('/payment', fn () => view('payment'));
+ // リダイレクト前処理
+ Route::post('/payment', [App\Http\Controllers\PayController::class, 'redirect']);
```

## 5. リダイレクト後処理の作成

3Dセキュア認証が完了して PAY.JP から戻ってきた後に行う処理を作成します。

リダイレクト後の処理でやることは大きく2つです。

１．「支払い情報を取得」の API を実行

https://pay.jp/docs/api/#%E6%94%AF%E6%89%95%E3%81%84%E6%83%85%E5%A0%B1%E3%82%92%E5%8F%96%E5%BE%97

２．「3Dセキュアフローを完了する」の API を実行

https://pay.jp/docs/api/#3d%E3%82%BB%E3%82%AD%E3%83%A5%E3%82%A2%E3%83%95%E3%83%AD%E3%83%BC%E3%82%92%E5%AE%8C%E4%BA%86%E3%81%99%E3%82%8B

そもそも、3Dセキュアを利用するためには事前に3Dセキュアの利用設定が行われている必要がありますが、もちろん設定されていないカードも存在します。**利用設定が行われていないカードを利用して決済した場合、アテンプト状態となりますがエラーにはならず決済が正常に完了**するため、不正防止の観点からすれば望ましくはありません。

本記事では、アテンプト状態に対して厳しく対処しないといけない場合でも対応できるように「支払い情報を取得」の API を実行して、3Dセキュア認証ステータスの確認を行うように作成します。

:::note info
3Dセキュア認証ステータス（アテンプト状態）の確認が不要であれば、「支払い情報を取得」の API を実行しなくても問題ありません。
:::

`app/Http/Controllers` ディレクトリの `PayController.php` に以下のコードを追記してください。

```diff_php:PayController.php
class PayController extends Controller
{
    // 省略

+    public function callback(Request $request)
+    {
+        // 支払いID取得（取得できなければ不正アクセスとしてエラー）
+        $payId = $request->session()->get('pay_id');
+        if (empty($payId)) {
+            abort(404);
+        }
+
+        // 支払い情報取得
+        $response = $this->fetchCharge($payId);
+        if ($response->getStatusCode() !== 200) {
+            // エラー処理
+            return redirect('payment')->with('errorMessage', '支払いの取得に失敗しました。');
+        }
+        $body = json_decode($response->getBody()->getContents(), true);
+
+        // 3Dセキュア未登録のカードはエラーとして扱う
+        $threeDSecureStatus = $body['three_d_secure_status'];
+        if ($threeDSecureStatus === 'attempted') {
+            return redirect('payment')->with('errorMessage', 'ご利用のカードは使用できません。');
+        }
+
+        // 支払い完了
+        $response = $this->finishCharge($payId);
+        if ($response->getStatusCode() !== 200) {
+            return redirect('payment')->with('errorMessage', 'カード決済に失敗しました。');
+        }
+
+        return redirect('complete');
+    }
+
+    /**
+     * 支払い情報を取得する
+     */
+    private function fetchCharge(string $payId): ResponseInterface
+    {
+        $client = new Client(['auth' => [config('pay.secret_key'), '']]);
+        try {
+            $uri = "https://api.pay.jp/v1/charges/{$payId}";
+            return $client->request('GET', $uri);
+        } catch (ClientException $e) {
+            return $e->getResponse();
+        }
+    }
+
+    /**
+     * 支払いを完了させる
+     */
+    private function finishCharge(string $payId): ResponseInterface
+    {
+        $client = new Client([
+            'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
+            'auth' => [config('pay.secret_key'), ''],
+        ]);
+        try {
+            $uri = "https://api.pay.jp/v1/charges/{$payId}/tds_finish";
+            return $client->request('POST', $uri);
+        } catch (ClientException $e) {
+            return $e->getResponse();
+        }
+    }
}
```

作成したリダイレクト後処理のルーティング設定を `web.php` に追加してください。

```diff_php:web.php
// リダイレクト前処理
Route::post('/payment', [App\Http\Controllers\PayController::class, 'redirect']);
+ // リダイレクト後処理
+ Route::get('/callback', [App\Http\Controllers\PayController::class, 'callback']);
```

また、サーバー処理においてエラーが発生した際、支払い情報入力画面にエラーメッセージを表示するため、`resources/views` ディレクトリの `payment.blade.php` に以下のコードを追記してください。

```diff_html:payment.blade.php
  <script>
    // 省略

-    // 初期表示ではエラーメッセージ表示エリアを非表示
-    hideError()
+    // 初期表示時にサーバーで設定されたエラーメッセージ表示（設定されてなければ非表示）
+    const errorMessage = "{{ session('errorMessage') ?? '' }}"
+    if (errorMessage === "") {
+      hideError()
+    } else {
+      showError(errorMessage)
+    }

    // 省略
  </script>
```

## 6. 支払い完了画面の作成

支払い完了した後に表示する画面を作成します。

`resources/views` ディレクトリに `complete.blade.php` を作成して以下のコードを記述してください。

```html:complete.blade.php
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>3Dセキュア動作確認 - 支払い完了</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous">
  </script>
</head>

<body>
  <div class="container py-4">
    <div class="row justify-content-center">
      <div class="col-md-6">
        <h1 class="h2 text-center">支払い完了</h1>
        <p class="text-center py-4">
          正常に支払いが完了しました。
        </p>
        <div class="text-center">
          <a href="{{ url('/payment') }}" class="btn btn-secondary">支払い情報入力画面に戻る</a>
        </div>
      </div>
    </div>
  </div>
</body>

</html>
```

作成した `complete.blade.php` のルーティング設定を `web.php` に追加してください。

```diff_php:web.php
// リダイレクト後処理
Route::get('/callback', [App\Http\Controllers\PayController::class, 'callback']);
+ // 支払い完了ページ表示
+ Route::get('/complete', fn () => view('complete'));
```

# 動作確認

PAY.JP の公式ドキュメントに記載されているテストカードを用いて、動作確認を行ってみます。

https://pay.jp/docs/testcard

ブラウザで /payment にアクセスして「カード番号」にテストカードの情報を入力してください。その他の項目においては任意の値を設定することができます。

## 決済成功の場合

「トークン作成が可能なテストカード」を用いて動作確認を行います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/b0d2b588-87a2-31c2-2945-fee9a57e8c72.png" width="400" />

カード情報を入力したら「決済する」ボタンを押下してください。押下すると PAY.JP の画面にリダイレクトします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/052ded4e-c594-b7ec-cd5e-21df77f9c941.png" width="400" />

テストモードのため、3Dセキュア認証の結果を選択することができます。「認証成功」を選択して「実行」ボタンを押下すると完了画面が表示されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/31d0380f-749d-c9b1-8cb4-385929065734.png" width="400" />

PAY.JP の管理画面を確認すると、支払い情報が「支払済み」、3Dセキュア認証が「認証済み」となっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/882c5452-8fc8-aa7e-f792-5a11511ec078.png" width="400" />

## 決済失敗の場合

「トークン作成は可能だが、支払い作成時にエラーを返すテストカード」を用いて動作確認を行います。カード情報を入力したら「決済する」ボタンを押下してください。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/d359fe01-abc3-70df-4521-b63c41490542.png" width="400" />

PAY.JP の画面が表示されたら「認証成功」を選択して「実行」ボタンを押下してください。支払い情報入力画面に遷移し、エラーメッセージが表示されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/75361ac9-8189-3ded-825b-aaf454305607.png" width="400" />

PAY.JP の管理画面を確認すると、支払い情報が「支払失敗」、3Dセキュア認証が「認証済み」となっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/b97d0d67-f6b6-8a9b-d0f0-16b30b6b4dd7.png" width="400" />

# おわりに

PAY.JP で3Dセキュア認証ありのクレジットカード決済を実装することができました。
テスト環境の利用手続きは非常に簡単でしたし、ログイン不要で公式ドキュメントを確認することができますので、興味のある方は一度試してみてはいかがでしょうか。

# GitHub

完成版の Laravel プロジェクトはこちら
※ 動作には Docker 環境が必要となります

https://github.com/falya128/laravel-3dsecure