laravel8でpaypay決済できるデジタルコンテンツECサイトを作ってみた!
githubにあげておいた。
https://github.com/fddcddhdd/paypay-laravel8
1, composer install
2, create .env file
3, php artisan migrate:fresh –seed
4, composer require paypayopa/php-sdk
5, paypay for developersでapiキー・シークレット・加盟店ID(テスト環境)を取得して、.envに記述
すれば、動作すると思う。
ガワがないと見た目がヒドいので、startbootstrap.comでcss/jsを導入
1, htmlとfreeとEcommerceで検索したらShop HomePageが一個しかなかったのでDLする
https://startbootstrap.com/template/shop-homepage
2, zip展開して、laravelフォルダにコピー
2-a, assets, css, jsフォルダをpublic以下へ
2-b, index.htmlをindex.blade.phpにリネームして
3, routes/web.phpを書き換え
Route::get(‘/’, function () {
// return view(‘welcome’);
return view(‘index’);
});
とりあえず、これでECサイトっぽい画面にはなった。
4, ログイン機能をつける
laravel8 + breezeで一般ユーザ・管理者のログインを分けてみる(usersテーブルに管理者フラグを追加するだけ)
loginとregisterは、tailwindcssだけど、まあいいや。
routes/web.phpが上書きされているので、再度修正した
1 2 3 4 5 6 7 8 9 10 11 |
Route::get('/', function () { // return view('welcome'); return view('index'); }); Route::get('/dashboard', function () { // return view('dashboard'); return view('index'); })->middleware(['auth'])->name('dashboard'); require __DIR__.'/auth.php'; |
5, 既存のShop HomePageのヘッダにログイン・ログアウト・レジスタへのリンクを追加する
ログアウトはpostなのでformを非表示で追加する。ログイン状態で表示も変える
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<li class="nav-item"> <a class="nav-link" href="#!">About</a> </li> @auth <li class="nav-item"> <a class="nav-link" href="javascript:document.logout_link.submit()">ログアウト</a> </li> <form name="logout_link" action="{{ route('logout') }}" method="post" style="display: none"> @csrf </form> @else <li class="nav-item"> <a class="nav-link" href="{{ route('login') }}">ログイン</a> </li> <li class="nav-item"> <a class="nav-link" href="{{ route('register') }}">ユーザ登録</a> </li> @endauth |
6, モデルを考える。デジタルコンテンツ商品という事にして、アップロードしたファイルのみ販売する!
Admin = controllerのみ生成。userテーブルのadmin=trueなだけ
User = model/migrationはあるのでcontrollerのみ生成。
Upload = 商品(デジタルコンテンツ)のアップロード・ダウンロードを管理
Purchase = UserとUplaodを結びつけて購入履歴となる
Payment = Purchaseに紐づく決済情報。クレカやpaypayなど
1 2 3 4 5 6 |
php artisan make:controller UserController --resource php artisan make:controller AdminController --resource php artisan make:model Upload --all php artisan make:model Purchase --all php artisan make:model Payment --all |
7, テーブル設計。paymentは要らなかったか?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
Schema::create('uploads', function (Blueprint $table) { $table->id(); $table->string('title')->comment('タイトル'); $table->string('detail')->nullable()->comment('詳細'); $table->string('file_path')->comment('アップロードされたファイルのパス'); $table->string('file_name')->comment('ハッシュ化される前のファイル名'); $table->string('free_flag')->nullable()->default(false)->comment('無料フラグ'); $table->timestamps(); }); Schema::create('purchases', function (Blueprint $table) { $table->id(); // 外部キーも制約もメソッドで指定できる! $table->foreignId('user_id')->constrained()->comment('購入者ユーザID'); $table->foreignId('upload_id')->constrained()->comment('購入されたアップロードファイルID'); $table->string('merchantPaymentId')->comment('paypay決済ID'); $table->boolean('paid_flag')->default(false)->comment('paypay決済完了フラグ'); $table->text('QRCodeDetails')->nullable()->comment('paypay決済完了のレスポンスJSON'); $table->timestamps(); }); |
8, モデル設計(リレーション設計)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Purchase extends Model { use HasFactory; // MassAssignment(INSERT/UPDATEで入力できるカラムを指定。$fillable=ホワイトリスト、$guarded=ブラックリスト) protected $guarded = array('id'); // 購入者 public function user() { return $this->belongsTo(User::class); } // 購入したファイル public function upload() { return $this->belongsTo(Upload::class); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Upload extends Model { use HasFactory; // MassAssignment(INSERT/UPDATEで入力できるカラムを指定。$fillable=ホワイトリスト、$guarded=ブラックリスト) protected $guarded = array('id'); public function purchase() { // アップロードファイルIDとログインしているユーザIDの組み合わせがあれば、購入済と判断する return $this->hasMany(Purchase::class)->where('user_id', \Auth::id())->where('paid_flag', true); } } |
paypayの決済完了のポーリングチェック
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; // use model 最初のAppをappにするとNot Foundになる use App\Models\Upload; // モデル use App\Models\Purchase; use App\Models\User; use Auth; // paypay関係 use PayPay\OpenPaymentAPI\Client; use PayPay\OpenPaymentAPI\Models\OrderItem; use PayPay\OpenPaymentAPI\Models\CreateQrCodePayload; class Payment extends Model { use HasFactory; // MassAssignment(INSERT/UPDATEで入力できるカラムを指定。$fillable=ホワイトリスト、$guarded=ブラックリスト) protected $guarded = array('id'); // paypay決済が完了したかチェックする(こちらからポーリングする必要がある) // https://paypay.ne.jp/developers-faq/open_payment_api/post-42/ public static function paypay() { // ログイン中だったら if(Auth::check()){ // .envファイルに書いておく $client = new Client([ 'API_KEY' => env('PAYPAY_API_KEY'), 'API_SECRET'=> env('PAYPAY_API_SECRET'), 'MERCHANT_ID'=> env('PAYPAY_MERCHANT_ID') ], true); // DBから未決済の決済IDがあったら $user = User::find(Auth::id()); if(Purchase::where("user_id", $user->id)->where("paid_flag", false)->exists()){ $purchase = Purchase::where("user_id", $user->id)->where("paid_flag", false)->firstOrFail(); $merchantPaymentId = $purchase->merchantPaymentId; //------------------------------------- // 決済情報を取得する //------------------------------------- $QRCodeDetails = $client->code->getPaymentDetails($merchantPaymentId); if($QRCodeDetails['resultInfo']['code'] !== 'SUCCESS') { \Session::flash('errors', '決済情報取得エラー'); return; } // paypay決済が完了したら、DBに書き込む if($QRCodeDetails['data']['status'] == 'COMPLETED') { $purchase->update([ 'paid_flag' => true, 'QRCodeDetails' => $QRCodeDetails ]); \Session::flash('success', $purchase->upload->title . 'を購入しました'); } } } return ; } } |
9, アップロードする管理画面を作る。admin.blade.phpは、index.blade.phpを適当にコピペして作る
adminController.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; // use model 最初のAppをappにするとNot Foundになる use App\Models\User; // userモデル use App\Models\Upload; // 販売ファイル use App\Models\Purchase; // 販売履歴 class AdminController extends Controller { public function index() { // アップロードファイル一覧を取得 $uploads = Upload::orderby('created_at', 'desc')->get(); // 管理者以外のユーザ一覧を取得 $users = User::WHERE('admin', false)->orderby('created_at', 'desc')->get(); // 販売履歴 $purchases = Purchase::orderby('created_at', 'desc')->with(['user'])->get(); return view('admin', compact('users', 'uploads', 'purchases')); } } |
10, ファイルのアップロード・ダウンロード処理をUploadController.phpに記述
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
public function download($id){ $upload_file = Upload::findOrFail($id); // 未購入のファイルをダウンロードしようとしたら if($upload_file->purchase->isEmpty()){ return back(); } // 第一引数がstorageファイルのパス。第二引数がダウンロードさせるファイル名 return \Storage::download('public/'. $upload_file->file_path, $upload_file->file_name); } public function store(Request $request) { // フォームの入力の値をチェック $validated = $request->validate([ 'title' => 'required|unique:uploads|max:255', 'detail' => 'required|max:255', 'file' => 'required|file', ]); // ファイルそのものはWebサーバに保存 $file_name = $request->file('file')->getClientOriginalName(); //$file_path = Storage::putFile('/uploads', $request->file('file'), 'public'); $upload_file = $request->file('file'); $file_path = $upload_file->store('uploads',"public"); // ファイル名とパスは、DBに保存する。 $upload = new Upload(); $upload->title = $request->input('title'); $upload->detail = $request->input('detail'); $upload->file_name = $file_name; $upload->file_path = $file_path; $upload->save(); // 前の画面に戻る return back(); } |
11, フロントに商品を表示する
paypayのロゴ
https://paypay.ne.jp/merchant-share/logo/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
<!-- Section--> <section class="py-5"> @if (session('success')) <div class="alert alert-success text-center" role="alert"> {{ session('success') }} </div> @endif @if (session('errors')) <div class="alert alert-danger text-center" role="alert"> {{ session('errors') }} </div> @endif <div class="container px-4 px-lg-5 mt-5"> <div class="row gx-4 gx-lg-5 row-cols-2 row-cols-md-3 row-cols-xl-4 justify-content-center"> @foreach($uploads as $upload) <div class="col mb-5"> <div class="card h-100"> <!-- Sale badge--> {{-- 無料だったら --}} @if($upload->free_flag) <div class="badge bg-dark text-white position-absolute" style="top: 0.5rem; right: 0.5rem">無料</div> @endif <!-- Product details--> <div class="card-body p-4"> <div class="text-center"> <!-- Product name--> <h5 class="fw-bolder">{{$upload->title}}</h5> {{$upload->detail}} <!-- Product reviews--> <div class="d-flex justify-content-center small text-warning mb-2"> <div class="bi-star-fill"></div> <div class="bi-star-fill"></div> <div class="bi-star-fill"></div> <div class="bi-star-fill"></div> <div class="bi-star-fill"></div> </div> <!-- Product price--> {{-- 無料だったら --}} @if($upload->free_flag) <span class="text-muted text-decoration-line-through">100円</span> 無料 @else 100円 @endif </div> </div> <!-- Product actions--> <div class="card-footer p-4 pt-0 border-top-0 bg-transparent"> {{-- 決済完了 or 無料だったらダウンロードできる --}} @if(!$upload->purchase->isEmpty() || $upload->free_flag) <div class="text-center"> <a class="btn btn-outline-dark mt-auto" href={{url("download/$upload->id")}}> ダウンロード </a> </div> {{-- それ以外は、paypay支払いボタンを表示 --}} @else <a href='{{url("user/purchase/$upload->id")}}'> <img class="card-img" src="{{url('img/accept_button300_60_200415_A.png')}}"> </a> @endif </div> </div> </div> @endforeach </div> </div> </section> |
12, トップ画面の表示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// use model 最初のAppをappにするとNot Foundになる use App\Models\Upload; // モデル use App\Models\Payment; class UserController extends Controller { public function index() { // paypay決済完了ポーリング・チェック Payment::paypay(); // アップロードファイル一覧を取得 $uploads = Upload::orderby('created_at', 'desc')->get(); return view('index', compact('uploads')); } } |
routes/web.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
use App\Http\Controllers\UserController; use App\Http\Controllers\AdminController; use App\Http\Controllers\UploadController; use App\Http\Controllers\PurchaseController; use App\Http\Controllers\PaymentController; // トップ画面 Route::resource('/', UserController::class); // ログインした一般ユーザ Route::group(['prefix' => '/user', 'middleware' => ['auth']], function () { // アップロードファイルの購入 Route::get('/purchase/{id}', [PurchaseController::class,'purchase' ]); }); // アップロードファイルのダウンロード(無料ファイルは、未ログインでもダウンロードさせたいので) Route::get('/download/{id}', [UploadController::class,'download' ]); // admin以下は管理者のみアクセス可 Route::group(['prefix' => 'admin', 'middleware' => ['auth', 'can:admin']], function () { Route::resource('/upload', UploadController::class); Route::resource('/', AdminController::class); }); // 決済完了 Route::get('/thanks', [PurchaseController::class,'thanks' ]); |
13, 新規登録・ログインしたユーザのリダイレクト先を/dashboardからルート直下に変更する。
App \ Providers \ RouteServiceProviders.php
class RouteServiceProvider extends ServiceProvider
{
/**
* The path to the “home” route for your application.
*
* This is used by Laravel authentication to redirect users after login.
*
* @var string
*/
// public const HOME = ‘/dashboard’;
public const HOME = ‘/’;
14, paypay決済処理を記述
1 |
composer require paypayopa/php-sdk |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
use App\Models\Payment; use App\Models\Purchase; use App\Models\Upload; use App\Models\User; use Illuminate\Http\Request; use Auth; // paypay関係 use PayPay\OpenPaymentAPI\Client; use PayPay\OpenPaymentAPI\Models\OrderItem; use PayPay\OpenPaymentAPI\Models\CreateQrCodePayload; class PurchaseController extends Controller { // 購入処理 public function purchase($upload_id){ // 購入するファイル情報を取得 $upload = Upload::findorFail($upload_id); // このユーザが購入済みなら何もしない $user = User::find(Auth::id()); if(Purchase::where("upload_id", $upload_id)->where("user_id", $user->id)->where("paid_flag", true)->exists()){ return redirect('/')->with('success', "$upload->file_nameは既に購入済みです。"); } // composer require paypayopa/php-sdk // .envファイルに書いておく $client = new Client([ 'API_KEY' => env('PAYPAY_API_KEY'), 'API_SECRET'=> env('PAYPAY_API_SECRET'), 'MERCHANT_ID'=> env('PAYPAY_MERCHANT_ID') ], true); // paypayの支払いサイトが完了したら、リダイレクトされるURL // ブラウザの戻るボタンで戻っても、支払いIDが決済完了になっているので3秒後にリダイレクトされ直すだけ $rediect_url = env('PAYPAY_REDIRECT_URL'); //------------------------------------- // 商品情報を生成する //------------------------------------- $items = (new OrderItem()) ->setName($upload->title) ->setQuantity(1) ->setUnitPrice(['amount' => 100, 'currency' => 'JPY']); //------------------------------------- // QRコードを生成する //------------------------------------- $payload = new CreateQrCodePayload(); $payload->setOrderItems($items); $payload->setMerchantPaymentId("mpid_".rand()); // 同じidを使いまわさないこと! $payload->setCodeType("ORDER_QR"); $payload->setAmount(["amount" => 100, "currency" => "JPY"]); $payload->setRedirectType('WEB_LINK'); $payload->setIsAuthorization(false); $payload->setRedirectUrl($rediect_url); $payload->setUserAgent($_SERVER['HTTP_USER_AGENT']); $QRCodeResponse = $client->code->createQRCode($payload); if($QRCodeResponse['resultInfo']['code'] !== 'SUCCESS') { echo("QRコード生成エラー"); return; } // 支払いIDはデータベースに保存しておく(まだ決済は未完了) $merchantPaymentId = $QRCodeResponse['data']['merchantPaymentId']; // 未決済レコードがあったら決済IDをUPDATE、なかったらINSERT Purchase::updateOrCreate([ 'upload_id'=>$upload_id, 'user_id'=>$user->id, 'paid_flag'=>false, ],[ 'upload_id'=>$upload_id, 'user_id'=>$user->id, 'paid_flag'=>false, 'merchantPaymentId'=>$merchantPaymentId, ]); // paypayの支払いページに行く。支払いが終わったら$payload->setRedirectUrlにリダイレクトされる return redirect($QRCodeResponse['data']['url']); } // 決済完了 public function thanks() { // paypay決済完了ポーリング・チェック Payment::paypay(); return redirect('/'); } |
15, paypay決済完了したかどうかは、自分で発行した決済番号を使ってpaypay apiでポーリングしに行かないと駄目!
決済完了後、3秒だったら指定したURLにリダイレクトされるので、そこのサンクスページで決済完了かどうかポーリングチェックする
一応、トップ画面やマイページでもポーリング処理を入れておいた方が良さげ
今どき、ポーリングって時代錯誤じゃないかい?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
// use model 最初のAppをappにするとNot Foundになる use App\Models\Upload; // モデル use App\Models\Purchase; use App\Models\User; use Auth; // paypay関係 use PayPay\OpenPaymentAPI\Client; use PayPay\OpenPaymentAPI\Models\OrderItem; use PayPay\OpenPaymentAPI\Models\CreateQrCodePayload; class Payment extends Model { use HasFactory; // MassAssignment(INSERT/UPDATEで入力できるカラムを指定。$fillable=ホワイトリスト、$guarded=ブラックリスト) protected $guarded = array('id'); // paypay決済が完了したかチェックする(こちらからポーリングする必要がある) // https://paypay.ne.jp/developers-faq/open_payment_api/post-42/ public static function paypay() { // ログイン中だったら if(Auth::check()){ // .envファイルに書いておく $client = new Client([ 'API_KEY' => env('PAYPAY_API_KEY'), 'API_SECRET'=> env('PAYPAY_API_SECRET'), 'MERCHANT_ID'=> env('PAYPAY_MERCHANT_ID') ], true); // DBから未決済の決済IDがあったら $user = User::find(Auth::id()); if(Purchase::where("user_id", $user->id)->where("paid_flag", false)->exists()){ $purchase = Purchase::where("user_id", $user->id)->where("paid_flag", false)->firstOrFail(); $merchantPaymentId = $purchase->merchantPaymentId; //------------------------------------- // 決済情報を取得する //------------------------------------- $QRCodeDetails = $client->code->getPaymentDetails($merchantPaymentId); if($QRCodeDetails['resultInfo']['code'] !== 'SUCCESS') { \Session::flash('errors', '決済情報取得エラー'); return; } // paypay決済が完了したら、DBに書き込む if($QRCodeDetails['data']['status'] == 'COMPLETED') { $purchase->update([ 'paid_flag' => true, 'QRCodeDetails' => $QRCodeDetails ]); \Session::flash('success', $purchase->upload->title . 'を購入しました'); } } } return ; } } |
トップ画面でもポーリング
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class UserController extends Controller { public function index() { // paypay決済完了ポーリング・チェック Payment::paypay(); // アップロードファイル一覧を取得 $uploads = Upload::orderby('created_at', 'desc')->get(); return view('index', compact('uploads')); } } |
決済番号でのポーリング・チェックするメソッドが変更された…?
https://qiita.com/rururu3/items/841e3b5a73da3447dc69
https://paypay.ne.jp/developers-faq/open_payment_api/sdkget-payment-detailsop_out_of_scope/
自分の場合、paymentだとOP_OUT_OF_SCOPEになった。
○ $client->code->getPaymentDetails(‘
× $client->payment->getPaymentDetails(‘