1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-13

はじめに

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

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

目次

  1. 事前準備
  2. インストール
  3. 導線の確認
  4. テーブル設計
  5. クレジット決済画面
  6. トークンの保存
  7. 決済確認画面
  8. 決済処理
  9. 最後に

事前準備

PAY.JPのアカウント登録

ダッシュボードで公開鍵と秘密鍵を確認

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

Image2.png

Image3.png

envファイル

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

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

### 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に送る & 決済確認画面へ遷移する処理です。

ポイントは下記の3つです。
・クレジットカード入力フォーム
 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>

### 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メソッドで決済を行います。
PayJPクラスはサービス層に処理を記述しています。PayJPクラスの処理は下の方に記載しています。
※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]);
}

###PayJPクラス __【PayJP.phpファイル作成】__ サービス層にPayJPクラスを作成するので、appフォルダ下に「Service」フォルダを作成し、その下に「Payment」フォルダを作成してください。 さらにPaymentフォルダに「PayJP.php」ファイルを作成してください。

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

・refundメソッド・・・決済取り消し処理
コントローラーでDBへの決済情報登録処理が失敗した際に支払いをキャンセルします。これはPayJPとPaymentsテーブルの整合性を保つ為に記述しています。
秘密鍵をセットして、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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?