6
8

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 3 years have passed since last update.

qnoteAdvent Calendar 2020

Day 13

Laravelでサブスクリプションの決済機能を実装してみた

Last updated at Posted at 2020-12-12

環境

  • Laravel Homestead 7.19.2
  • Laravel 7.3
  • node 8.12.0
  • npm 6.10.2
  • jquery 3.5.1
  • stripe.js v3

環境の準備

プロジェクト作成

プロジェクト名は、subscription とします。

composer create-project --prefer-dist laravel/laravel subscription

認証系

composer require laravel/ui

php artisan ui vue --auth

アセットのコンパイル

npm install

npm run watch

migration

一度、user, password_resetsのmigration
あとで、cashierを導入したあとにmigrationするのでここはやらなくてもOK)

php artisan migrate

laravel cashier のインストール

composer require laravel/cashier

php artisan migrate

Billable TraitをUser Modelに追加

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Stripeに登録

登録したら、テスト用のAPIキーを取得し

  • 公開可能キー
  • シークレットキー

を確認する

.envに値を設定

STRIPE_KEY=公開可能キー
STRIPE_SECRET=シークレットキー

商品を追加

Stripeのダッシュボードから商品を追加

商品詳細ページからAPI_IDを確認(price_~~~で始まるやつ)

ダッシュボードの商品ページのメタデータ欄にて、データを追加登録する
「localName」というキー名で登録してますが、任意名でOKです。
後々に設定したキー名を利用しているのでこの名前でない場合には適宜変更してください。
値には、Laravel側で利用する名前を任意で設定します。(DBにこの値が登録される)
stripe_meta.PNG

curl https://api.stripe.com/v1/plans/{API_ID} -u {.envに登録したシークレットキー}

で、商品詳細が返ってくればOK
(passwordとか聞かれても、そのままEnter)

サブスクリプションの生成

Stripeでサブスクリプションを生成するか「一度だけ」の課金を実行するためには、支払い方法を登録し、IDを取得する必要があります

どうやら支払い方法を登録するとこからスタートするようです。

支払い方法の保存

支払い情報を集めるフォームをレンダーするコントローラーから以下のメソッドを呼び出し、ビューに渡す

HomeController.php
    public function index()
    {
        $user = Auth::user();
        return view('home', [
            'intent' => $user->createSetupIntent()
        ]);
    }

支払い情報を集めるビュー(home.blade.php)

home.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="card col-md-8">
            <form action="/subscribe" method="post" id="payment-form">
                @csrf

                {{-- 商品情報 --}}
                <div class="form-group">
                    <label>サブスクリプション商品:</label>
                    <select id="plan" name="plan" class="form-control">
                        <option value="Stripeの商品ページにあるAPI_ID">テストプレミアムプラン</option>
                    </select>
                </div>

                {{-- カード情報 --}}
                <div class="form-group">
                    <label for="card-holder-name">支払い情報:</label>
                    <div>
                        <input id="card-holder-name" class="form-control" type="text" placeholder="カード名義人">
                    </div>
                    <div id="card-element" class="w-100">
                    <!-- A Stripe Element will be inserted here. -->
                    </div>

                    <!-- Used to display form errors. -->
                    <div id="card-errors" role="alert"></div>
                </div>
                <input type="hidden" id="stripeToken" name="stripeToken">

                <div id="card-button" class="btn btn-primary mt-5" data-secret="{{ $intent->client_secret }}">Submit Payment</div>
            </form>
        </div>
    </div>
</div>
@endsection

{{-- jquery --}}
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
{{-- stripe.js --}}
<script src="https://js.stripe.com/v3/"></script>
<script>
    $(function() {
        // Create a Stripe client.
        var stripe = Stripe('.envに追加した公開可能キー');

        // Create an instance of Elements.
        var elements = stripe.elements();

        // Custom styling can be passed to options when creating an Element.
        // (Note that this demo uses a wider set of styles than the guide below.)
        var style = {
        base: {
            color: '#32325d',
            fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
            fontSmoothing: 'antialiased',
            fontSize: '16px',
            '::placeholder': {
            color: '#aab7c4'
            }
        },
        invalid: {
            color: '#fa755a',
            iconColor: '#fa755a'
        }
        };

        // Create an instance of the card Element.
        var cardElement = elements.create('card', {style: style});

        // Add an instance of the card Element into the `card-element` <div>.
        cardElement.mount('#card-element');

        const cardHolderName = $("#card-holder-name");
        const cardButton     = $("#card-button");
        const clientSecret   = cardButton.data('secret');

        cardButton.on('click', async (e) => {
            cardButton.prop('disabled', true);
            const { setupIntent, error } = await stripe.confirmCardSetup(
                clientSecret, {
                    payment_method: {
                        card: cardElement,
                        billing_details: { name: cardHolderName.value }
                    }
                }
            );

            if (error) {
                // ユーザーに"error.message"を表示する…
                cardButton.prop('disabled', false);
            } else {
                // カードの検証に成功した…
                cardButton.prop('disabled', false);

                // 支払い方法識別子
                var form = $('#payment-form');
                var hiddenInput = $("#stripeToken");
                hiddenInput.attr('value', setupIntent.payment_method);

                form.submit();
            }
        });
    })
</script>

<style>
.StripeElement {
  box-sizing: border-box;

  height: 40px;

  padding: 10px 12px;

  border: 1px solid transparent;
  border-radius: 4px;
  background-color: white;

  box-shadow: 0 1px 3px 0 #e6ebf1;
  -webkit-transition: box-shadow 150ms ease;
  transition: box-shadow 150ms ease;
}

.StripeElement--focus {
  box-shadow: 0 1px 3px 0 #cfd7df;
}

.StripeElement--invalid {
  border-color: #fa755a;
}

.StripeElement--webkit-autofill {
  background-color: #fefde5 !important;
}
</style>

  • カードの入力フォームはStripe Elementsが勝手に作ってくれます。
  • w-100 を設定しているのは、既存のapp.cssと競合し、上手くフォームが表示されないため。
  • HomeController@indexにて設定した$intentclient_secretdata-secretに設定し、

    cardButton.data('secret');にて取得後、カードの検証に利用する
    subscription_top.PNG

テスト用カード

https://stripe.com/docs/testing
上記リンク先にて用意されているので、こちらを利用して検証を行える。

サブスクリプションの開始

web.phpに以下を追加

web.php
Route::post('/subscribe', 'SubscriptionController@subscribe');
  • HomeController.php のindex()を修正
  • home.blade.php の商品情報を修正
  • User.php にproducts()追加
  • Price Modelを追加
  • SubscribeRequest.phpを追加

上記を以下のように改修

HomeController.php
    public function index()
    {
        $user = Auth::user();

        return view('home', [
            'intent'       => $user->createSetupIntent(),
            // 現在のユーザーに紐づいているサブスクリプション
            'userProducts' => $user->products(),
            // dashboardで作成されているサブスクリプション全件
            'products'     => Price::getAll(),
        ]);
    }
home.blade.php
    {{-- 商品情報 --}}
    <div class="form-group">
        <label>サブスクリプション商品:</label>
        <select id="plan" name="plan" class="form-control">
            @foreach ($products as $product)
                <option value="{{ $product->id }}">{{ $product->productName }}</option>
            @endforeach
        </select>
    </div>

Stripeのダッシュボードにあるメタデータを利用するために、
prod_xxxxxxxxxxxxxxxxxxxxx
が必要になるので、stripe_idから紐づくProductオブジェクトを取得

User.php
    /**
     * ユーザーに紐づいているサブスクリプションを返す
     */
    public function products() {
        $products = [];
        foreach ($this->subscriptions()->get() as $subscription) {
            $priceId = $subscription->stripe_plan;

            // price id から plan を取得
            $plan = Plan::retrieve($priceId);
            // prod id から product を取得
            $product =Product::retrieve($plan->product);

            // dashboardで設定したメタデータを取得
            $localName           = $product->metadata->localName;
            $product->cancelled  = $this->subscription($localName)->cancelled();

            $products[] = $product;
        }

        return $products;
    }

Stripe\Price.phpを継承したモデルを追加
ダッシュボードで設定した商品名を利用するために、Productオブジェクトからnameを取得

Price.php
<?php

namespace App\Http\Models;

use Stripe\Product;
use Stripe\Price as StripePrice;

class Price extends StripePrice
{
    /**
     * price に紐づくproduct name を付加して全件返す
     */
    public static function getAll()
    {
        $retPrices = [];
        foreach (StripePrice::all() as $price) {
            $product = Product::retrieve($price->product);

            // price に紐づく product name を付加
            $price->productName = $product->name;

            $retPrices[] = $price;
        }

        return $retPrices;
    }
}

サブスクリプションを開始する商品のAPI_IDとカードの検証が成功して返ってきた支払い方法識別子を引数に渡し、createを実行

ダッシュボードで設定したメタデータのlocalNameを利用して登録するようにする

SubscriptionController.php
    public function subscribe(SubscribeRequest $request)
    {
        $user          = $request->user();
        $priceId       = $request->get('plan');
        $paymentMethod = $request->get('stripeToken');

        // price id から plan を取得
        $plan = Plan::retrieve($priceId);
        // prod id から product を取得
        $product   = Product::retrieve($plan->product);
        $localName = $product->metadata->localName;

        // サブスクリプション開始
        $user->newSubscription($localName, $priceId)->create($paymentMethod);

        return redirect('/home');
    }

定期課金中、または、キャンセルした商品の場合には、重複して登録できないようにValidationを追加

SubscribeRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Laravel\Cashier\Subscription;

class SubscribeRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
        ];
    }

    public function withValidator($validator)
    {
        $validator->after(function ($validator) {
            $user         = $this->user();
            $priceId      = $this->get('plan');
            $subscription = Subscription::whereStripePlan($priceId);

            $isSubscribed = $subscription->count()
                ? $user->subscribed($subscription->first()->name)
                : false;
            $isCancelled = $subscription->count()
                ? $user->subscription($subscription->first()->name)->cancelled()
                : false;

            if ($isSubscribed) {
                if ($isCancelled) {
                    $validator->errors()->add('is_cancelled', "選択された商品は、キャンセル中です。再開ボタンを利用してください");
                } else {
                    $validator->errors()->add('is_subscribed', '選択された商品は、定期課金中です');
                }
            }
        });
    }
}


ボタンをクリックしてみて、Stripeのダッシュボードでサイドメニューの「支払い」に反映されていれば成功

siharai.PNG

サブスクリプションのキャンセルと再開

  • web.phpにルート追加
  • home.blade.phpにform追加
  • SubscriptionController.phpにキャンセルと再開のメソッド追加
web.php
Route::post('/cancel', 'SubscriptionController@cancel');
Route::post('/resume', 'SubscriptionController@resume');

クリックされたボタンによってaction属性を切り替えています。

home.blade.php
@extends('layouts.app')
~~~
            {{-- error ディレクティブ --}}
            @if ($errors->any())
                <div class="alert alert-danger">
                    @foreach ($errors->all() as $error)
                        {{ $error }}
                    @endforeach
                </div>
            @endif

            {{-- 現在の課金状況 --}}
            <div class="mb-6">
                <form method="post" id="subscribed-plan">
                    @csrf
                    @foreach ($userProducts as $product)
                        @if ($product->cancelled)
                            <div class="mb-3">
                                <div class="mb-0 alert alert-secondary radius">現在 {{ $product->name }}はキャンセル中です。</div>
                                <div id="resume-button" class="resume btn btn-success" onclick="resume('{{ $product->id }}')">再開</div>
                            </div>
                        @else
                            <div class="mb-3">
                                <div class="mb-0 alert alert-success radius">現在 {{ $product->name }}を定期課金中です。</div>
                                <div id="cancel-button" class="cancel btn btn-danger" onclick="cancel('{{ $product->id }}')">キャンセル</div>
                            </div>
                        @endif
                    @endforeach
                    <input type="hidden" id="prodId" name="prodId">
                </form>
            </div>
~~~

<script>
    function cancel(prodId) {
        const form         = $('#subscribed-plan');
        const cancelButton = $(".cancel");
        const resumeButton = $(".resume");
        const hiddenProdId = $("#prodId");

        // 商品IDを設定
        hiddenProdId.attr('value', prodId);

        cancelButton.prop('disabled', true);
        resumeButton.prop('disabled', true);

        form.attr('action', '/cancel');
        form.submit();
    }

    function resume(prodId) {
        const form         = $('#subscribed-plan');
        const cancelButton = $(".cancel");
        const resumeButton = $(".resume");
        const hiddenProdId = $("#prodId");

        // 商品IDを設定
        hiddenProdId.attr('value', prodId);

        cancelButton.prop('disabled', true);
        resumeButton.prop('disabled', true);

        form.attr('action', '/resume');
        form.submit();
    }
</script>
SubscriptionController.php
    public function cancel(Request $request)
    {
        $user    = $request->user();
        $prodId  = $request->get('prodId');
        $product = Product::retrieve($prodId);

        // サブスクリプションキャンセル
        $user->subscription($product->metadata->localName)->cancel();

        return redirect('/home');
    }

    public function resume(Request $request)
    {
        $user    = $request->user();
        $prodId  = $request->get('prodId');
        $product = Product::retrieve($prodId);

        // // サブスクリプション再開
        $user->subscription($product->metadata->localName)->resume();

        return redirect('/home');
    }

最終的に完成したフォーム

subscription_all.PNG

STRIPE_SECRETに関して

今回のコードを書いている際にシークレットキーが設定されてない旨のエラーが発生しました。

下記のコードを追加すれば解決しますが、最適な設定場所に迷ったので、とりあえず、各Controller.php@__construct()に記述して今回は対応しました。

Stripe::setApiKey(env('STRIPE_SECRET'));

まとめ

今回は、サブスクリプションの作成、登録、キャンセル、再開の処理に関して実装しました。
まだまだ冗長な個所も多く、間に合わせな処理も多く見られますが、基本的な処理は実装できたかと思います。

まだ、リファレンスを見るとカードの事前登録だったり、インボイスの作成も可能なようなので、今後実装ができたら記事を更新しようかと思います。

参考資料

6
8
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
6
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?