Laravel PAY.JPで決済機能を実装

# はじめに
決済代行サービスであるPAY.JPをLaravelで実装する方法を紹介します。
実務で決済機能のレビューを担当した事があり、自分で情報を整理したいと思い、記事を書いてみました。
実際に実装した経験はありませんが、中身の処理はざっくり分かってるつもりなので、そんな感じの人間が書いてる前提で、あくまで参考程度に参照ください。


・・・ちなみに今後はStripe, PayPay, GMOペイメントの決済代行サービスも紹介したいと思います！！

# 目次
<!-- タイトルとアンカー名を編集 -->
1. [事前準備](#事前準備)
2. [インストール](#インストール)
3. [導線の確認](#導線の確認)
4. [テーブル設計](#テーブル設計)
5. [クレジット決済画面](#クレジット決済画面)
6. [トークンの保存](#トークンの保存)
7. [決済確認画面](#決済確認画面)
8. [決済処理](#決済処理)
9. [最後に](#最後に)


# 事前準備
### PAY.JPのアカウント登録
https://pay.jp/signup

### ダッシュボードで公開鍵と秘密鍵を確認
PayJPに登録後、ダッシュボードの「API」から、公開鍵と秘密鍵を確認します。
その後公開鍵と秘密鍵をenvファイルに記述します。

![Image2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1416941/a059595d-0b0f-cb3f-4df7-24739183ec89.png)

![Image3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1416941/dcab0c5f-51e2-9b2e-fafa-bba82346c55c.png)

### envファイル
envファイルに先程のダッシュボードのテスト秘密鍵とテスト公開鍵を記述します。
本番環境では本番秘密鍵と本番公開鍵を用います。

```
PAY_JP_SECRET="秘密鍵"
PAY_JP_KEY="公開鍵"
```

<br>
### config/app.php
こちらで設定したものはPAY.JPの決済時の処理で使用します。

```
'aliases' => [
    // 秘密鍵
    'pay_jp_secret' => env('PAY_JP_SECRET'),
    // 公開鍵
    'pay_jp_key' => env('PAY_JP_KEY'),
],
```

# インストール
composerでPAY.JPをインストールします。

```
composer require payjp/payjp-php
```

# 導線の確認
まず決済機能を実装する前に導線の確認です。

__【導線】__
① 商品を購入する為、「クレジット決済画面」へ
②「クレジット決済画面」でクレジットカード情報を入力
③「クレジット決済画面」で「確認画面へ」ボタンをクリック→「決済確認画面」へ
④「決済確認画面」で「決済する」ボタンをクリック
⑤ 決済処理が完了し、成功→「決済完了画面」

__【内部の処理】__
❶導線②で、クレジットカードの情報を全て入力→PAY.JPのAPIでトークンを発行する。(js処理)
※このトークンは決済時の処理で1回のみ使用する。使い回す事は出来ない。
❷inputタグ(type="hidden")のvalueに❶で発行されたトークンを挿入する。
❸導線③で、「確認画面へ」ボタンを押して、トークン情報を決済テーブルに保管する。
※「決済が完了したかどうか判別するカラム」をfalseで保存する。
 後に決済が無事成功したらtrueに変更する。(導線⑤)
❹導線④で「決済する」ボタンを押すと、決済処理が実行され、決済が無事完了すると、決済テーブルの「決済が完了したかどうか判別するカラム」をtrueで保存します。
そこで決済に失敗すると「決済が完了したかどうか判別するカラム」はfalseのままで、決済は未完の状態とします。

# テーブル設計
とりあえず最低限のカラムだけで想定します。

__【Goods(商品)テーブルの各カラム】__
・title(タイトル)
・price(価格)

__【Payments(決済)テーブルの各カラム】__
・goods_id(親である商品テーブルのid)
・payment_method_id(決済に使用するトークン / 決済後の顧客id)
・payment_is_finished(決済が完了したかどうか判別)

# ①クレジット決済画面
ここではクレジット情報を入力して、決済する商品の「確認画面へ」ボタンを押すまでの処理を紹介します。
クレジットカード情報後、決済に使用するトークンが発行され、そのトークンをDBに送る & 決済確認画面へ遷移する処理です。

<font color="blue">__ポイントは下記の3つです。__</font>
__・クレジットカード入力フォーム__
　PAY.JPのjsで作成された入力フォームが表示されます。
__・トークンが挿入される入力フォーム__
　type="hidden"のinputタグにPAY.JPのjsで作成されたトークンが挿入されます。
__・PAY.JPの決済用トークンを発行する為のjs処理__
　ここではトークンの発行や入力フォームの作成、入力エラーの処理を記載します。


```
// こちらはクレジット入力フォーム、トークンが挿入される入力フォーム、PAY.JPのjsを読み込む処理を記述しています。

<h1>クレジット決済画面</h1>
// クレジットカード情報入力フォーム
<div>
  <div>
    <p>クレジットカード番号</p>
    // ここにjsで作成した入力フォームが入る。idを指定。
    <div id="number-form"></div>
    // ここにjsから送られてきたエラーメッセージが表示される。idを指定。
    <span id="number-errors"></span>
  </div>

  <div>
    <p>セキュリティコード</p>
    <div id="cvc-form"></div>
    <span id="cvc-errors"></span>
  </div>

  <div>
    <p>有効期限</p>
    <div id="expiry-form"></div>
    <span id="expiry-errors"></span>
  </div>
</div>


// トークン情報をDBに送信するフォームとボタン
<form
  action="{{ route('user.goods.reconfirmationPayment', ['goods' => $goods]) }}" 
  method="post"
>
　　　　　@csrf

  // valueにPAY.JPのAPIで作成したトークンを入れる
  <input type="hidden" name="payment_method_id" id="payment_method_id" value="">
  <button>確認画面へ</button>
</form>

// payjp.jsのAPIを使用する為に読み込む必要がある
<script src="https://js.pay.jp/v2/pay.js"></script>
// 決済時に使用するトークンを発行する処理
<script src="{{ asset('/js/payjp-create-card-token.js') }}"></script>
```

<br>
### Pay.JPの決済トークンを発行するJSファイル
こちらはトークンの発行や入力フォームの作成、バリデーションエラー表示処理等を行なっています。

こちらは公式のリファレンスになります。
https://pay.jp/docs/payjs

```
payjp-create-card-token.jsファイル

// elementsインスタンス1つにつき、1組のカード情報入力フォームを用意できる。
// ページ内に複数のフォームを作りたい場合は、その分だけelementsインスタンスを用意する。
var elements = payjp.elements()

//　カード入力時にバリデーションエラーメッセージを出す為に、メッセージを表示する場所を指定
var numberErrors = document.getElementById('number-errors');
var expiryErrors = document.getElementById('expiry-errors');
var cvcErrors = document.getElementById('cvc-errors');

// create()の引数に設置したい項目(今回はcardNumber, cardExpiry, cardCvcのみ)を入力後、
下のmount()で入力フォームをDOM上に配置します。
// カード番号入力フォーム
var numberElement = elements.create('cardNumber')
// 有効期限入力フォーム
var expiryElement = elements.create('cardExpiry')
// CVC入力フォーム
var cvcElement = elements.create('cardCvc')

// この処理でHTML上に入力フォームが表示される
numberElement.mount('#number-form')
expiryElement.mount('#expiry-form')
cvcElement.mount('#cvc-form')

let numberElIsCompleted = false;
let expiryElIsCompleted = false;
let cvcElIsCompleted = false;

// 入力内容に何らかのエラーがある際にエラーを表示する処理
// 入力フォームの値が変更された事を検知してイベントが発火
numberElement.on('change', (event) => {
    numberErrors.innerHTML = '';
    if (event.error) {
        numberErrors.innerHTML = event.error.message;
    }
    numberElIsCompleted = event.complete
});
expiryElement.on('change', (event) => {
    expiryErrors.innerHTML = '';
    if (event.error) {
        expiryErrors.innerHTML = event.error.message;
    }
    expiryElIsCompleted = event.complete
});
cvcElement.on('change', (event) => {
    cvcErrors.innerHTML = '';
    if (event.error) {
        cvcErrors.innerHTML = event.error.message;
    }
    cvcElIsCompleted = event.complete
});

// blurでフォーカスが外れると(入力フォームから離れると)下記イベントが実行される
numberElement.on('blur', () => {
    if (numberElIsCompleted && expiryElIsCompleted && cvcElIsCompleted) {
        // payjp.createToken(対象のインスタンス)を作成する
        payjp.createToken(numberElement).then((response) => {
            if (response.error) {
                // APIのレスポンスでエラーが返ってきた場合メッセージを送る
                numberErrors.innerHTML = response.error.message;
                return false;
            } else {
                // APIのレスポンスでトークンを取得出来た場合、
                // inputタグのtype="hidden"のvalueにトークンを挿入する
                document.querySelector('#payment_method_id').value = response.id;
                console.log(response);
                numberErrors.innerHTML = ''
            };
        })
    }
});
expiryElement.on('blur', () => {
    if (numberElIsCompleted && expiryElIsCompleted && cvcElIsCompleted) {
        payjp.createToken(numberElement).then((response) => {
            if (response.error) {
                expiryErrors.innerHTML = response.error.message;
                return false;
            } else {
                document.querySelector('#payment_method_id').value = response.id;
                console.log(response);
                expiry_errors.innerHTML = ''
            };
        })
    }
});
cvcElement.on('blur', () => {
    if (numberElIsCompleted && expiryElIsCompleted && cvcElIsCompleted) {
        payjp.createToken(numberElement).then((response) => {
            if (response.error) {
                cvcErrors.innerHTML = response.error.message;
                return false;
            } else {
                document.querySelector('#payment_method_id').value = response.id;
                console.log(response);
                cvcErrors.innerHTML = ''
            };
        })
    }
});

```

#②トークンの保存
こちらでは先程作成したPAY.JPのトークンをPayments(決済)テーブルに保存します。保存したトークンは後の決済処理で使用します。
今回は決済テーブルに購入したものを保存していますが、payment_is_finishedがfalseのものは購入が完了していないものとして扱います。

```
public function reconfirmationPayment(
  Goods $goods, 
  Payment $payments,
  Request $request
)
{
    DB::beginTransaction();
    try {
        // 支払い履歴のテーブルに各データを登録
　　　　　　　　　　　　　　　　// どの商品を購入したか識別する為、goods_idを保存
        $payments->goods_id = $goods->id;
　　　　　　　　　　　　　　　　// 先程payjp-create-card-token.jsファイルで発行したidを保存
        $payments->payment_method_id = $request->payment_method_id;
　　　　　　　　　　　　　　　　// このカラムをfalseで保存。後に決済が完了したもののみtrueに変更する。
        $payments->payment_is_finished = false;
        DB::commit();
    } catch (\Exception $e) {
        DB::rollback();
        throw $e;
    }

    return view('user.goods.reconfirmation_payment',['goods' => $goods]);
}
```


# ③決済確認画面
こちらは決済前の確認画面です。問題なければ「決済する」ボタンで決済の処理が走ります。

```
<div>
  <h1>決済確認画面</h1>

  // 商品情報
  <div>
    <p>商品名</p>
    <p>{{ $goods->title }}</p>
  </div>
  <div>
    <p>決済金額</p>
    <p>{{ $goods->price }}円</p>
  </div>

  〜〜〜  その他商品情報を表示  〜〜〜

  //　商品を確認後、決済する場合はこちら
  <a href="{{ route('user.goods.payment', ['goods' => $goods]) }}">決済する</a>

  //　クレジットカード入力画面へ戻る
  <a href="javascript:history.back()">戻る</a>
<div>
```

# ④決済処理
こちら決済を実行しています。PayJPクラスのchargeメソッドで決済を行います。
<font color="blue">__PayJPクラスはサービス層に処理を記述しています。PayJPクラスの処理は下の方に記載しています。__</font>
※charge( ), refund( )等のメソッドはPayJP.phpファイルに記述します。

```
// Service層のPayJPクラスを使用する為、追記
use App\Services\Payment\PayJP;

public function payment(
  Goods $goods,
  Payment $payments,
  PayJP $payjp
)
{
   // PayJPクラスのcharge処理(決済処理)
   // 引数として、商品の金額とPAY.JPのjsで発行したトークンを記述
   $response = $payjp->charge($goods->price, $payment->payment_method_id);
   DB::beginTransaction();
   try {
　　　　　　　　　　　　　　　　// pi(payment intent id)は決済完了後に発行されるユニークなid
        // $responseのidとしてpiを返す
        // piを用いて、PayJPのダッシュボードで決済履歴を検索できる
        $payment->payment_method_id = $response->id;
        // 決済が完了したらpayment_is_finishedをtrueとして、決済成功となる
        $payment->payment_is_finished = true;
        $payment->save();
        DB::commit();
    } catch (\Exception $e) {
        DB::rollback();
        // 決済が失敗したら、決済代行サービス側の決済情報も削除させる処理
        $payjp->refund($response->id);
        throw $e;
    }

    // 購入完了画面へ遷移する
    return view('user.goods.purchased', ['goods' => $goods]);
}
```
<br>
###PayJPクラス
__【PayJP.phpファイル作成】__
サービス層にPayJPクラスを作成するので、appフォルダ下に「Service」フォルダを作成し、その下に「Payment」フォルダを作成してください。
さらにPaymentフォルダに「PayJP.php」ファイルを作成してください。

__【処理を記述】__
・charge
秘密鍵をセットして、決済処理を実行します。

・refundメソッド・・・決済取り消し処理
コントローラーでDBへの決済情報登録処理が失敗した際に支払いをキャンセルします。これは<font color="blue">__PayJPとPaymentsテーブルの整合性を保つ為に記述__</font>しています。
秘密鍵をセットして、retrieve( )メソッドで決済情報を取得後、refund( )メソッドを用いて決済をキャンセルします。

```
// app/Service/Payment/PayJP.phpファイル

<?php

namespace App\Services\Payment;

use Illuminate\Http\Request;

class PayJP
{
    public function charge($price, $payment_method_id)
    {
        // 事前準備のconfig/app.phpファイルで設定した秘密鍵をセットします
        \Payjp\Payjp::setApiKey(config('app.pay_jp_secret'));
        try {
            // 新規決済情報を作成する処理(決済)
            $result = \Payjp\Charge::create([
                // $payment_method_idはpayjp.jsのAPIで発行されたトークン
                "card" => $payment_method_id,
                // 商品の金額
                "amount" => $price,
　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　// 通貨の選択(今回は日本円="jpy")
                "currency" => "jpy",
            ]);
        // 以下は各例外処理
        } catch (\Payjp\Error\Card $e) {
            throw $e;
        } catch (\Payjp\Error\InvalidRequest $e) {
            // Invalid parameters were supplied to Payjp's API
            throw $e;
        } catch () {
            // その他例外処理を記述してください...
        }
        return $result;
    }

    // 先程登録した決済情報を削除する処理(決済の取り消し)
    // DBに決済情報を保存する際に何らかの障害で保存出来なかった場合、
　　　　　　　　　　　　　　PAY.JP側の決済もキャンセルさせ、DBとの整合性を保つ
    public function refund($payment_id)
    {
        // 事前準備のconfig/app.phpファイルで設定した秘密鍵をセットします
        \Payjp\Payjp::setApiKey(config('app.pay_jp_secret'));
        // retrieveで支払い情報を取得します。
        // retrieveメソッドの引数にpi(charge後に返ってくるレスポンスのid)をセットします。
        $charge = \Payjp\Charge::retrieve($payment_id);
        // refundメソッドで支払いをキャンセルします。
        return $charge->refund();
    }
}
```

# 最後に
今回解説しましたが、このコードは実際に検証していないので、若干の修正が必要になるかと思います。
（多分、ちょっと修正すれば、動くはずです・・・）
また、実務経験も半年〜1年程度なので、もっといい方法があれば是非教えてください！！
本来ならテストコードでモックを使用する事を想定して、DIができる構造にした方が良いです。
今後はStripe, PayPay, GMOペイメントの決済代行サービスも紹介したいと思います！！


# 参考
【PAY.JP API】
PAY.JP APIの公式リファレンス
https://pay.jp/docs/api/

【PAY.JP.js】
トークン発行のjs処置が記載されている公式リファレンス
https://pay.jp/docs/payjs