【Laravel5.8+Stripe⑦】サブスクリプション決済の作成

f:id:nekorokkekun:20190827102831p:plain:w1000
こちらの連載記事では、LaravelとStripeを使用して企業サイト兼Eコマース(ECサイト)を作成していきます。

Laravelのプロジェクト作成からStripeの実装まで行い、最終的に単発決済・サブスクリプションの実装までを目指します。

シリーズ

【Laravel5.8+Stripe⓪】ECサイト作成チュートリアル概要 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe①】ベースプロジェクトの作成 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe②】メールフォームの実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe③】ユーザー認証機能のカスタマイズ - Laravelとねころっけくん5.8
【Laravel5.8+Stripe④】ディスカウントページを作成する - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑤】Laravel CasherとStripeを導入して管理者権限を設定する その1 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑥】Laravel CasherとStripeを導入して管理者権限を設定する その2 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑦】サブスクリプション決済の作成 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑧】請求書ダウンロード機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑨】サブスクリプションプラン変更機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑩】サブスクリプション中止機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe11】Webhookの実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe12】クーポン機能を実装する - Laravelとねころっけくん5.8
【Laravel5.8+Stripe13】ショッピングカートの実装 - Laravelとねころっけくん5.8


ちなみにこちらの記事は、Easy E-Commerce Using Laravel and Stripeという書籍をもとに執筆しています。

今回の記事ではサブスクリプション決済機能を作成していきます。

Stripeダッシュボード上で定期決済プランを作成

まずはStripeのダッシュボードにアクセスし、定期決済プランを作成していきましょう。

サイドメニューの「商品」をクリックすると以下の画面が表示されます。
(Stripeテストは、こちらで過去に作成したもののため、表示されません)
f:id:nekorokkekun:20190818092755p:plain

新規ボタンをクリックすると以下の画面が表示されるので、「定期利用する商品」を選択して、商品名を任意で付けます。
f:id:nekorokkekun:20190818092953p:plain

商品の作成ができると、次に定期決済プランを作成する画面が表示されます。
f:id:nekorokkekun:20190818093230p:plain

今回は

  • プランのニックネーム
  • 単価

のみ入力し、3つほどプランを作成しておきます。
f:id:nekorokkekun:20190818093451p:plain

SubscriptionControllerの作成

次に定期決済に関する操作を行うためのSubscriptionControllerを作成します。

$ php artisan make:controller SubscriptionsController

SubscriptionControllerにはコンストラクタとアクションを追記します。
/app/Http/Controllers/SubscriptionsController.php

class SubscriptionsController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }
    
    public function index()
    {
        return view('subscriptions.index');
    }
}

次にルーティングを行います。
/routes/web.php

Route::get('plans', 'SubscriptionsController@index')->name('plans');

定期決済プランを選ぶViewの作成

次に、SubscriptionControllerで表示するべきViweを作成していきましょう。

/resource/viewディレクトリ内にsubscriptionsディレクトリを作成し、その中にindex.blade.phpを作成しましょう。

/resources/views/subscriptions/index.blade.php

@extends('app')
@section('intro')
  <div class="intro">
    <div class="container">
      <h1>Make your neighbors envious!</h1>
      <p>Let the professionals at We Dew Lawns, Inc. service your lawn.</p>
    </div>
  </div>
@endsection
@section('content')
    <div class="col-xs-12 col-sm-4">
        <div class="panel panel-default">
            <div class="panel-heading">
                <h3 class="panel-title text-center">Trial ¥1,000</h3>
            </div>
            <div class="panel-body">
                <p>Get a healthy, weed-free lawn with our patented
                monthly weed-free service.</p>
                <div class="text-center">
                    <a class="btn btn-primary"
                        href="/plans/subscribe/plan_xxxxxxxxxxxxxx"> // Trialのplan_idをURLのパスに指定
                        Select
                    </a>
                </div>
            </div>
        </div>
    </div>
    <div class="col-xs-12 col-sm-4">
        <div class="panel panel-default">
            <div class="panel-heading">
                <h3 class="panel-title text-center">Premium ¥5,000</h3>
            </div>
            <div class="panel-body">
                <p>Take your lawn to the next level with our Premium plan.
                With this plan you get monthly spraying and fertilizing. </p>
                <div class="text-center">
                    <a class="btn btn-primary"
                        href="/plans/subscribe/plan_xxxxxxxxxxxxxx"> // Premiumのplan_idをURLのパスに指定
                        Select
                    </a>
                </div>
            </div>
        </div>
    </div>
    <div class="col-xs-12 col-sm-4">
        <div class="panel panel-success">
            <div class="panel-heading">
                <h3 class="panel-title text-center">Regular ¥3,000</h3>
            </div>
            <div class="panel-body">
                <p><b>Our Best Value!</b></p>
                <p>Make your lawn shine! With this plan you get monthly
                spraying, fertilizing, and spring/fall aeration. </p>
                <div class="text-center">
                    <a class="btn btn-primary"
                        href="/plans/subscribe/plan_xxxxxxxxxxxxxx">// Regularのplan_idをURLのパスに指定
                        Select
                    </a>
                </div>
            </div>
        </div>
    </div>
@endsection

ちなみにプランごとのplan_idとは、Stripeのダッシュボードサイドメニューの商品>商品名>料金プランごとのページを押すと表示されているplan_から始まるIDのことです。
f:id:nekorokkekun:20190824181026p:plain

各プランの決済画面遷移

次に各プランごとの決済画面への遷移を形成していきましょう。

/routes/web.phpに追記

Route::get('plans/subscribe/{planId}', 'SubscriptionsController@subscribe');

/app/Http/Controllers/SubscriptionsController.phpにアクションを追加

    public function subscribe($planId)
    {
        if ($this->planNotAvailable($planId)) {
            return redirect()->route('plans');
        }
        return view('subscriptions.form', compact('planId'));
    }
    
    protected function planNotAvailable($id){
    $available = ['plan_xxxxxxxxxxxxxx', 'plan_xxxxxxxxxxxxxx', 'plan_xxxxxxxxxxxxxx']; //index.blade.php同様にplan_idを指定しておく
        if ( ! in_array($id, $available)){ //in_arrayで$availableの中から$idを探す
         return true;
        }
        return false;
    }


これで各プランごとの決済画面遷移を分別することができるので、次にプランによって表示を切り替えるViewを作成していきます。

resources/views/subscriptions/subscribe.blade.phpを作成していきましょう。

@extends('app')
@section('content')
  <div class="payment-errors alert alert-danger"
    style="display: none;">
  </div>
  {!! Form::open([
    'route' => 'plans.process',
    'class' => 'form',
    'id' => 'purchase-form'
  ]) !!}
    <input type="hidden" name="plan_id" value="{{ $planId }}" id="plan_id">
    <div class="form-group">
      <div class="row">
        <div class="col-xs-12">
          <label for="card-number" class="control-label">
            Credit Card Number
          </label>
        </div>
        <div class="col-sm-4">
          <input type="text"
            class="form-control"
            id="card-number"
            placeholder="Valid Card Number"
            required autofocus data-stripe="number"
            value="
            {{ App::environment() == 'local' ? '4242424242424242' : '' }}
            ">
        </div>
      </div>
    </div>
    <div class="form-group">
      <div class="row">
        <div class="col-xs-4">
          <label for="card-month">Expiration Date</label>
        </div>
        <div class="col-xs-8">
          <label for="card-cvc">Security Code</label>
        </div>
      </div>
      <div class="row">
        <div class="col-xs-2">
          <input type="text" size="3"
            class="form-control"
            name="exp_month"
            data-stripe="exp-month"
            placeholder="MM"
            id="card-month"
            value="{{ App::environment() == 'local' ? '12' : '' }}"
            required>
        </div>
        <div class="col-xs-2">
          <input type="text" size="4"
            class="form-control"
            name="exp_year" data-stripe="exp-year"
            placeholder="YYYY" id="card-year"
            value="{{ App::environment() == 'local' ? '2020' : '' }}"
            required>
        </div>
        <div class="col-xs-2">
          <input type="text"
            class="form-control" id="card-cvc"
            placeholder=""
            size="6"
            value="{{ App::environment() == 'local' ? '111' : '' }}"
            >
        </div>
      </div>
    </div>
    <div class="center-block form-actions">
      <button type="submit" class="submit-button btn btn-primary btn-lg">
        Complete Order
      </button>
    </div>
  {!! Form::close() !!}
  @include('subscriptions.form')
@endsection


必要箇所のみ説明をしていきます。
まず、inputタグに関してですがこちらではあえてLaravelのFormファサードを使用していません。

          <input type="text"
            class="form-control"
            id="card-number"
            placeholder="Valid Card Number"
            required autofocus data-stripe="number"
            value="
            {{ App::environment() == 'local' ? '4242424242424242' : '' }}
            ">

Formファサードでは、第二引数のname属性が必須となっているためです。

inputタグ内にname属性を入れないことによって、ブラウザからはinputフィールドがないものとして扱われることになります。

つまり、カード所有者の詳細の暗号化、またはPCIコンプライアンスに関連するその他の要件を心配する必要がなくなります。

さらにinputタグ内におけるこちら12桁の数値ですが、仮のクレジットカード番号です。

            {{ App::environment() == 'local' ? '4242424242424242' : '' }}

こちらの三項演算子では、開発環境を確かめることができるenvironment()関数を用いています。

開発段階において何度もテスト用のクレジットカード番号を打ち込む手間を省略しています。

ちなみにこちらの4242が繰り返されるクレジットカード番号はStripeより提供される、テスト用のクレジットカード番号となります。


次に/routes/web.phpに追記をします。

Route::post('plans/process','SubscriptionsController@process')->name('plans.process');

formボタンのJavascriptを作成

実際にStripeのカード入力画面に使用するJavascriptを作成していきます。

resources/views/subscriptions/form.blade.phpを作成し、以下のような中身にしましょう。

<script src="//code.jquery.com/jquery-1.11.3.min.js"></script>
<script type="text/javascript" src="https://js.stripe.com/v2/"></script>
<script>
  Stripe.setPublishableKey('{{ env("STRIPE_API_PUBLIC") }}');
  jQuery(function($) {
    $("#card-number").focusout(function() {
      var el = $(this);
      if ( ! Stripe.validateCardNumber(el.val())) {
        el.closest(".form-group").addClass("has-error");
      } else {
        el.closest(".form-group").removeClass("has-error");
      }
    });
    $("#card-cvc").focusout(function() {
      var el = $(this);
      if ( ! Stripe.validateCVC(el.val())) {
        el.closest("div").addClass("has-error");
      } else {
        el.closest("div").removeClass("has-error");
      }
    });
    $('#purchase-form').submit(function(e) {
      $('.submit-button').prop('disabled', true);
      var $form = $(this);
      $form.find('.payment-errors').hide()
      Stripe.card.createToken({
        number: $form.find('#card-number').val(),
        cvc: $form.find('#card-cvc').val(),
        exp_month: $form.find('#card-month').val(),
        exp_year: $form.find('#card-year').val()
      }, stripeResponseHandler);
      return false;
    });
  });
  var stripeResponseHandler = function(status, response) {
    var $form = $('#purchase-form');
    var $errors = $('.payment-errors');
    // Reset any errors
    $errors.text("");
    if (response.error) {
      $errors.text(response.error.message).show();
      $form.find('button').prop('disabled', false);
    } else {
      var token = response.id;
      $form
        .append($('<input type="hidden" name="stripe_token" />')
        .val(token));
      $form.get(0).submit();
      $form.find('button').html('Processing...');
    }
  };
</script>

こちらも必要箇所にのみ説明を加えていきます。

まず、以下の部分ですがそれぞれクレジットカードナンバーとCVCにバリデーションをかけています。

  jQuery(function($) {
    $("#card-number").focusout(function() {
      var el = $(this);
      if ( ! Stripe.validateCardNumber(el.val())) {
        el.closest(".form-group").addClass("has-error");
      } else {
        el.closest(".form-group").removeClass("has-error");
      }
    });
    $("#card-cvc").focusout(function() {
      var el = $(this);
      if ( ! Stripe.validateCVC(el.val())) {
        el.closest("div").addClass("has-error");
      } else {
        el.closest("div").removeClass("has-error");
      }
    });

バリデーションエラーが発生すると、/resources/views/subscriptions/subscribe.blade.phpのformの該当箇所にhas-errorクラスが追加され、枠が赤く表示されます。


次に以下のコードですが、

    $('#purchase-form').submit(function(e) {
      $('.submit-button').prop('disabled', true);
      var $form = $(this);
      $form.find('.payment-errors').hide()
      Stripe.card.createToken({
        number: $form.find('#card-number').val(),
        cvc: $form.find('#card-cvc').val(),
        exp_month: $form.find('#card-month').val(),
        exp_year: $form.find('#card-year').val()
      }, stripeResponseHandler);
      return false;
    });

こちらでは決済ボタンを押した時点で二重送信とならないようにsubmit-buttonへdisabledを追加するようにしています。

次にStripe.card.createTokenメソッドを使い、第一引数に入力されたデータを、第二引数にstripeResponseHandlerを設定します。

stripeResponseHandlerはその下で定義されており、入力されたデータを検証する役割を持たせます。

  var stripeResponseHandler = function(status, response) {
    var $form = $('#purchase-form');
    var $errors = $('.payment-errors');
    // Reset any errors
    $errors.text("");
    if (response.error) {
      $errors.text(response.error.message).show();
      $form.find('button').prop('disabled', false);
    } else {
      var token = response.id;
      $form
        .append($('<input type="hidden" name="stripe_token" />')
        .val(token));
      $form.get(0).submit();
      $form.find('button').html('Processing...');
    }
  };

こちらの役割としては、まずpayment-errorsのメッセージを削除。

クレジットカードが受け入れられなかった場合は、返ってきたresponseオブジェクトのerror項目からmessageを表示させます。また、ボタンのdisabledをfalseにして再びボタンをクリックできるようにします。

クレジットカードが受け入れられた場合、stripe_tokenという名前の新しい非表示の入力にStripeのトークンを追加し、改めてLaravel Casherを経由してStripe APIへフォームを送信します。


再びSubscribeControllerへ追記をします。

/app/Http/Controllers/SubscriptionsController.php

    public function process(Request $request)
    {
        $planId = $request->get('plan_id');
        if ($this->planNotAvailable($planId)) {
            return redirect()->back()->withErrors('Plan is required');
        }
        Stripe::setApiKey(env('STRIPE_API_SECRET'));
        $user = Auth::user();
        $user->newSubscription('main', $planId)->create($request->stripe_token,
        ['email' => $user->email,
            'metadata' => [
                'name' => $user->name,
            ]]);
        return redirect('invoices');
    }

先ほど同様、まずはgetパラメータで渡ってきた$planIdが実在するものかどうかを確かめます。もしない場合には、請求フォームにリダイレクトするようにしつつ、エラーメッセージを表示しています。

planNotAvailableメソッドを通過した場合には、stripe_tokenを元にcreateメソッドで定期決済データを作成し、同時に顧客のemailとnameをLaravel Casher経由で送信しています。


今回、Laravel Cashierのダウングレードが必要なため、以下のコマンドを打ち込みます。

$ composer remove laravel/cashier 
$ composer require "laravel/cashier":"~9.0"

作業内容としては、一度Laravel Cashierをリムーブし、その後、Laravel Cashier9.xを入れ直しています。現在はLaravel Cashier10.xが入っているため、実質上はダウングレードです。

その理由は以下の記事にまとめています。
【Laravel+Stripe】No such payment_methodが吐き出される - Laravelとねころっけくん5.8


また、この状態だとエラーが発生してしまいます。

SQLSTATE[HY000]: General error: 1364 Field 'stripe_status' doesn't have a default value (SQL: insert into `subscriptions` (`name`, `stripe_id`, `stripe_plan`, `quantity`, `trial_ends_at`, `ends_at`, `user_id`, `updated_at`, `created_at`) values (main, xxxxxxxxxxx, plan_xxxxxxxxxxx, 1, ?, ?, 1, 2019-08-24 08:42:36, 2019-08-24 08:42:36))

そのため、以下のように処理を施しましょう。
/config/database.php

        'mysql' => [
            // 省略
            'strict' => false, // trueからfalseに変更
            // 省略
        ],

参考:
programmer-jobs.blogspot.com


ここまでできたら一度、サブスクリプション決済が登録できるか試してみましょう。

まだ/invoicesのルーティングとViewの作成を行なっていないため、画面上には404 NotFoundが表示されますが、StripeのダッシュボードのサイドメニューからBilling>定期支払いに画面移動をすると、登録したプランが表示されているはずです。
f:id:nekorokkekun:20190824180152p:plain

次はサブスクリプションのキャンセルなど、定期支払いに必須の機能を実装していきましょう。

連載記事

【Laravel5.8+Stripe⓪】ECサイト作成チュートリアル概要 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe①】ベースプロジェクトの作成 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe②】メールフォームの実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe③】ユーザー認証機能のカスタマイズ - Laravelとねころっけくん5.8
【Laravel5.8+Stripe④】ディスカウントページを作成する - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑤】Laravel CasherとStripeを導入して管理者権限を設定する その1 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑥】Laravel CasherとStripeを導入して管理者権限を設定する その2 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑦】サブスクリプション決済の作成 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑧】請求書ダウンロード機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑨】サブスクリプションプラン変更機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑩】サブスクリプション中止機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe11】Webhookの実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe12】クーポン機能を実装する - Laravelとねころっけくん5.8
【Laravel5.8+Stripe13】ショッピングカートの実装 - Laravelとねころっけくん5.8