はじめに
決済代行サービスであるPAY.JPをLaravelで実装する方法を紹介します。
実務で決済機能のレビューを担当した事があり、自分で情報を整理したいと思い、記事を書いてみました。
実際に実装した経験はありませんが、中身の処理はざっくり分かってるつもりなので、そんな感じの人間が書いてる前提で、あくまで参考程度に参照ください。
・・・ちなみに今後はStripe, PayPay, GMOペイメントの決済代行サービスも紹介したいと思います!!
目次
事前準備
PAY.JPのアカウント登録
ダッシュボードで公開鍵と秘密鍵を確認
PayJPに登録後、ダッシュボードの「API」から、公開鍵と秘密鍵を確認します。
その後公開鍵と秘密鍵をenvファイルに記述します。
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