payjpの3Dセキュアをphpで実装する

# payjpの3Dセキュアをphpで実装する

案外資料が転がっていなくて思わぬ苦戦を強いられたので、備忘も兼ねて書き留めておくことにします。
Node.jsとかなら[公式ドキュメント](https://pay.jp/docs/guideline-payjp-three-d-secure#redirect-sample)にあるのでそれも参考に。

## 準備するもの

### composer install

- pay.jp
    - [ver1.7以上](https://github.com/payjp/payjp-php/commit/f8fe3185fc0a481214c35421f051d8cc50f3f175)
- firebase/php-jwt
    - こちらは下記のコマンド実行で入る最新バージョンで問題ない筈

```sh
$ composer require firebase/php-jwt
```

### 鍵

- pay.jpの鍵
    - livemode用の秘密鍵
        - sk_live_***
    - livemode用の公開鍵
        - pk_live_***
    - testmode用の秘密鍵
        - sk_test_***
    - testmode用の公開鍵
        - pk_test_***

## 実装要綱

今回はフロント側にチェックアウトを実装し、それをformでバックエンドに控えるphpに渡してphp側で支払い処理などを実装する形とします。
そのため、pay.jpに記載されているドキュメントになぞらえると「支払い時の3Dセキュア」を導入する形になります。

### フロント側

今回はsmartyを使ったのでその書き方になっている箇所があります。

```smarty
<form method="post" action="credit.php">
    <script
            type="text/javascript"
            src="https://checkout.pay.jp/"
            class="payjp-button"
            data-key="{$smarty.const.PJ_PK}"
            data-partial="false">
    </script>
    <input type="hidden" name="order_id" value="">
</form>
```

- data-keyには公開鍵を指定します、ここでの指定方法は一例です
- data-partialにはfalseを指定します、falseでないとsubmitが走らないので
- その他注文番号や金額など必要な値があればinput hiddenに持たせておきます
- 2025/2/4まではURL部分にはprereleaseが必要になります（[公式ドキュメント](https://pay.jp/info/2024/10/29/140000)も参照下さい）
    - なお2025/4/30にこのプレリリース版は終了しますのでそれまでにprereleaseとした部分については削除し元に戻す必要が

### バックエンド側 リダイレクト前

今回は前述のとおりバックエンド側はphpです。
支払い処理用のcredit.phpと、3Dセキュア認証後の支払い完了処理用のcredit2.phpを作成します。（名称は簡略化しています）

```credit.php
<?php

// ログインチェック、Payjpなど外部ライブラリの読み込み、
// 注文番号や金額の取得など必要な処理(issetでの確認なども必要に応じて実装)

// 元の画面のURLを設定
$back_url = 'https://xxx.jp';

// トークンの取得
$token = $_POST['payjp-token'];

try {

    // 支払いの作成
    Payjp\Payjp::setApiKey(PJ_SK);
    $result = Payjp\Charge::create(array(
        "card" => $token,
        "amount" => $total,
        "currency" => "jpy",
        "capture" => false,
        "expiry_days" => 60,
        'three_d_secure' => true,
    ));

    // 作成に失敗した場合は元のURLに戻す
    if(!$result->id) {
        header("Location:".$back_url);
        exit;
    }

    // 支払いIDをセッションに保持しておく
    $_SESSION['credit_id'] = $result->id;
    
    // jwtを生成
    $payload = array(
        'url' => HTTPS_PATH . "credit2.php?order_id=" . $order_id
    );
    $jwt = Firebase\JWT\JWT::encode($payload, PJ_SK, 'HS256');

    // 3Dセキュアの認証画面にリダイレクト
    header('Location: https://api.pay.jp/v1/tds/' . $result->id . '/start?publickey=' . PJ_PK . '&back_url=' . $jwt);
    exit;

// エラー
} catch(\Payjp\Error\Card $e) {
    // 必要な処理
} catch (\Payjp\Error\InvalidRequest $e) {
    // 必要な処理
} catch (\Payjp\Error\Authentication $e) {
    // 必要な処理
} catch (\Payjp\Error\ApiConnection $e) {
    // 必要な処理
} catch (\Payjp\Error\Base $e) {
    // 必要な処理
} catch (Exception $e) {
    // 必要な処理
}


?>
```

- 公式ドキュメントは[こちら](https://pay.jp/docs/charge-tds)
- `Payjp\Payjp::setApiKey` に鍵情報を渡すことで鍵を設定できます
- `Payjp\Charge::create` で支払い情報を作成できます
    - `token` にはチェックアウトで作成されたトークンを設定します（$_POSTで取得）
    - `amount` には金額が入ります
    - 日本円の場合 `currency` はjpyで固定です
    - `three_d_secure` が今回の肝です、trueにしておかないと3Dセキュアの認証画面に飛びません
- 作成結果からidを取得することで支払いIDが取得できます
    - credit2.phpで使うためセッションに保持しておきます
- 3Dセキュアの認証画面から戻ってくるURLは `back_url` パラメータで指定しますが、jwtの形式でフォーマットしてあげる必要があります
    - keyとして `url` を定義し、valueにURLを設定します
        - このとき日本語が含まれる場合はURLエンコードしてあげる必要があります
    - 外部ライブラリのJWTを使用し、encodeの引数として、URL、秘密鍵、HS256を指定します
- header関数で指定のURLに飛ばすことで3Dセキュア認証画面に遷移させます
- エラーハンドリングは公式の[APIリファレンス](https://pay.jp/docs/api/#error)も適宜参照してください

### 3Dセキュア

3Dセキュアの画面ではクレジットカードに紐づくメールアドレスにワンタイムトークンが送られるなどされますが、
秘密鍵および公開鍵においてtestのものを使用することで、テストモードで動作を確認することができます。

payjp側が、[テストカード](https://pay.jp/docs/testcard)を用意していますのでこれを有難く使わせてもらいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/465525/83d35240-fd53-81d0-f919-d54d1fa42eac.png)

テスト用の鍵を設定した状態で支払いを試行すると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/465525/ebf96fdb-4d26-d964-f935-3db56326012f.png)

デバッグモードっぽい3Dセキュア画面が現れます。
ラジオボタンの動作は下記の通りです。

- 認証成功
    - 認証が成功した場合の挙動
- アテンプト(attempted)
    - 3Dセキュアをカード側が未設定の場合の挙動
    - [完全認証](https://pay.jp/docs/guideline-three-d-secure#about-attempted-and-full-auth)かどうかを判定する場合に使う
- 認証失敗
    - 異なる値を入力した場合の挙動
- エラー
    - ほかの何らかの原因でエラーとなった場合の挙動

この値は `three_d_secure_status` に保持されますので、完全認証を確認する場合は3Dセキュアからリダイレクトするphpにおいてこれを確認する処理を挟みます。

### バックエンド側 リダイレクト後

リダイレクト前のphpでback_urlに指定したURLが呼び出されます。

```credit2.php

<?php

// ログインチェック、セッションに保持したIDから注文データを取得するなど必要な処理を実施しておく

try {
    Payjp\Payjp::setApiKey(PJ_SK);
    $charge = Payjp\Charge::retrieve($_SESSION['credit_id']);
    
    if ($charge->three_d_secure_status === 'attempted') {
        // 認証が完全でない場合は決済を完了させずに戻す
        header("Location:".$back_url."&back=1&error=2");
        exit;
    }
    
    $charge->tdsFinish();   // 処理完了
    
} catch ...(割愛) {
}

// 注文者・管理者などにメールを送信する、DBの値を更新しておく、など必要な処理を実施する

header("Location:".$back_url);
exit;

?>
```

- 公式ドキュメントは[こちら](https://pay.jp/docs/guideline-payjp-three-d-secure#handling-three-d-secure-status)
- `Payjp\Charge::retrieve` で支払い状態を確認できます
    - 引数にはセッションに格納しておいた支払いIDを使います
    - 完全認証かどうかを確認する必要がある場合は `three_d_secure_status` の値を確認します
- `tdsFinish()` を実行することで一連の3Dセキュアおよび支払い処理が完了します
    - 3Dセキュアで認証失敗或いはエラーがあった場合は例外が発生します
    - これが実行されない限りは支払いが確定しません
- 処理完了後にメール送信処理などがあればそれを実行し、header関数でサンキューページや元の画面に遷移させるなどして完了です

これで一連の3Dセキュア導入対応は終わりです。
公式ドキュメントにある[コードサンプル](https://pay.jp/docs/guideline-payjp-three-d-secure#redirect-sample)等も適宜参照してください。