カテゴリー: BackEnd

Stripe Connectを使ってCheckoutを利用した継続課金を実装

はじめに

前回は、Stripe Connectを使った継続課金の実装について説明しました。今回は、Checkoutを使った場合の継続課金の実装について説明します。

なお、継続課金に使用する商品については、前回の記事で登録したものを使うものとします。

Checkoutを使う場合の動線

Checkoutを使う場合と使わない場合の大きな違いとして、決済の動線が異なります。

Checkoutを使う場合、一度、下の画像のようなStripeのサイトに遷移し、決済に成功すると、こちらが指定したURLにリダイレクトされます。

決済成功や、その後の継続課金成功時などのイベントが発生した場合、こちらが設定したWebhook URLに通知されます。

Webhook URLの指定は、Stripe Connectアカウントの開発者の中にあるWebhookから設定できます。

「Connect アプリケーションからイベントを受信するエンドポイント」の「エンドポイントを追加」から新しいWebhook URLを設定することができます。

Webhook URLと通知するイベントの種類を選択してエンドポイントを作成します。

作成するとこのような画面になります。この「署名シークレット」はWebhook URLに送られてきたパラメータを検証する際に使用します。

決済画面への遷移

Checkoutを使って継続課金を実装する場合、決済完了情報がWebhookで送られてくるため、どのユーザが決済を完了した通知なのかを特定する必要があります。

その方法としては、いくつかやり方があると思いますが、例えば、Checkoutの画面に遷移するためのCheckoutSessionを作成する際に、client_reference_idとして決済するユーザのIDを渡しても良いですし、CheckoutSessionを作る際に、session_idとuser_idをテーブルに保存しても良いと思います。

今回は、継続課金のログを残す意味も兼ねて、継続課金の管理テーブルをインサートする方法でやってみます。

マイグレーション

継続課金の状態を管理するuser_subscriptionsテーブルを追加します。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateUserSubscriptionsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */    public function up()
    {
        Schema::create('user_subscriptions', function (Blueprint $table) {
            $table->id();
            $table->integer('user_id');
            $table->string('price_id');
            $table->string('session_id');
            $table->string('subscription_id')->nullable();
            $table->tinyInteger('status')->default(0);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */    public function down()
    {
        Schema::dropIfExists('user_subscriptions');
    }
}

Checkout Sessionの作成

継続課金を処理するSubscriptionControllerを作成します。

<?php

namespace App\Http\Controllers\Shop;

use App\Http\Controllers\Controller;
use App\Models\UserSubscription;
use Illuminate\Http\Request;
use Stripe\Stripe;

class SubscriptionController extends Controller
{
    function index()
    {
        return view('subscription.index');
    }

    function success(Request $request)
    {
        \Log::debug($request->all()); // ['session_id' => 'cs_test_XXXXXXX']
        return redirect('/shop/dashboard');
    }

    function createCheckoutSession(Request $request)
    {
        Stripe::setApiKey(env('STRIPE_SECRET'));
        $user = Auth::user();

        $priceId = $request->get('priceId');
        try {
            $checkout_session = \Stripe\Checkout\Session::create([
                'success_url' => 'http://localhost/shop/subscription/success?session_id={CHECKOUT_SESSION_ID}',
                'cancel_url' => 'http://localhost/shop/subscription',
                'payment_method_types' => ['card'],
                'mode' => 'subscription',
                'line_items' => [[
                    'price' => $priceId,
                    // For metered billing, do not pass quantity
                    'quantity' => 1,
                ]],
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'error' => [
                    'message' => $e->getError()->message,
                ],
                400
            ]);
        }
        // 継続課金の状態管理
        UserSubscription::create([
            'user_id' => $user->id,
            'price_id' => $priceId,
            'session_id' => $checkout_session['id'],
        ]);
        return response()->json(['sessionId' => $checkout_session['id']]);
    }
}

indexは継続課金のためのボタンが1つだけある画面で、successは決済完了後に遷移する画面です。

createCheckoutSessionはindexから呼ばれるAPIになっており、Checkout Sessionを作成し、Session IDを返却します。

また、既存のStripeの顧客が存在する場合、\Stripe\Checkout\Session::createの引数のcustomerにStripeの顧客IDを渡してあげると、Stripeの決済画面にはじめからメールアドレスやカード情報が設定された状態になります(デフォルトの支払い方法が選択されます)

その他のパラメータについては、こちらを参照してください。

indexのViewはこの様になっています。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Checkout</title>
    <script src="https://js.stripe.com/v3/"></script>
</head>
<body class="antialiased">
<div style="text-align: center">
    <button id="checkout">Subscribe</button>
</div>
<script>
    var priceId = 'price_1IjRNqCQ3ppMoW38BEP0h1iF';
    var createCheckoutSession = function(priceId) {
        return fetch("/api/shop/subscription/create_checkout_session", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({
                priceId: priceId
            })
        }).then(function(result) {
            return result.json();
        });
    };
    var stripe = Stripe('{{ env('STRIPE_PUBLIC') }}');
    document
        .getElementById("checkout")
        .addEventListener("click", function(evt) {
            // You'll have to define PRICE_ID as a price ID before this code block
            createCheckoutSession(priceId).then(function(data) {
                // Call Stripe.js method to redirect to the new Checkout page
                stripe
                    .redirectToCheckout({
                        sessionId: data.sessionId
                    })
                    .then(handleResult);
            });
        });

    var handleResult = function(result) {
        if (result.error) {
            showErrorMessage(result.error.message);
        }
    };

    var showErrorMessage = function(message) {
        var errorEl = document.getElementById("error-message")
        errorEl.textContent = message;
        errorEl.style.display = "block";
    };
</script>
</body>
</html>

createCheckoutSessionのAPIを実行し、返却されたSession IDをもとに、Stripeの決済画面に遷移します。

決済完了後の制御

決済が完了すると、設定したWebhook URL宛にイベントが飛んできます。リクエストパラメータを確認し、アプリケーション側の継続課金の処理を行います。

class SubscriptionController extends Controller
{
// 省略
    function hook(Request $request)
    {
        $webhookSecret = 'whsec_ZlO3mWGMY9XOMfdJ3mqjUpmaMie8kp1e'; // Stripeの画面を参照
        try {
            // パラメータの検証
            $event = \Stripe\Webhook::constructEvent(
                $request->all(),
                $request->header('stripe-signature'),
                $webhookSecret
            );
        } catch (\Exception $e) {
            \Log::debug($e->getMessage());
            return response()->json(['error' => $e->getMessage()], 403);
        }
        $type = $event['type'];
        $object = $event['data']['object'];

        switch ($type) {
            case 'checkout.session.completed':
                // 購読成功後の処理を実装する
                $userSubscription = \App\Models\UserSubscription::where('session_id', '=', $request
                    ->get('session_id'))
                    ->first();
                $userSubscription->status = 1;
                // subscription_idを保存
                $userSubscription->subscription_id = $object['subscription'];
                $userSubscription->save();
                // Stripeの顧客と紐付ける
                $user = \App\Models\User::find($userSubscription->user_id);
                $user->stripe_customer_id = $object['customer'];
                $user->save();
                break;
            case 'invoice.paid':
                // 支払いが継続された場合
                break;
            case 'invoice.payment_failed':
                // 支払いが継続されなかった場合
                break;
            // ... handle other event types
            default:
                // Unhandled event type
        }
        return response()->json([ 'status' => 'success' ]);
    }

// 省略
}

$typeにはイベントの種類が格納されています。また、$objectには以下のような値が格納されています。

array (
  'id' => 'evt_1IjUIlCQ3ppMoW38xiQR0XW8',
  'object' => 'event',
  'api_version' => '2020-08-27',
  'created' => 1619204671,
  'data' => 
  array (
    'object' => 
    array (
      'id' => 'cs_test_a1a2xn7wUtHVhpvceLWdXWXb0rpIojce3Mk1WkPIXnX5YWkPQ71pd1AofP',
      'object' => 'checkout.session',
      'allow_promotion_codes' => NULL,
      'amount_subtotal' => 300,
      'amount_total' => 300,
      'billing_address_collection' => NULL,
      'cancel_url' => 'http://localhost/shop/subscription',
      'client_reference_id' => NULL,
      'currency' => 'jpy',
      'customer' => 'cus_JMCig1CqTAfyVj',
      'customer_details' => 
      array (
        'email' => 'hoge@gmail.com',
        'tax_exempt' => 'none',
        'tax_ids' => 
        array (
        ),
      ),
      'customer_email' => NULL,
      'livemode' => false,
      'locale' => NULL,
      'metadata' => 
      array (
      ),
      'mode' => 'subscription',
      'payment_intent' => NULL,
      'payment_method_options' => 
      array (
      ),
      'payment_method_types' => 
      array (
        0 => 'card',
      ),
      'payment_status' => 'paid',
      'setup_intent' => NULL,
      'shipping' => NULL,
      'shipping_address_collection' => NULL,
      'submit_type' => NULL,
      'subscription' => 'sub_JMCiIVLsEvBMJj',
      'success_url' => 'http://localhost/shop/subscription/success?session_id={CHECKOUT_SESSION_ID}',
      'total_details' => 
      array (
        'amount_discount' => 0,
        'amount_shipping' => 0,
        'amount_tax' => 0,
      ),
    ),
  ),
  'livemode' => false,
  'pending_webhooks' => 1,
  'request' => 
  array (
    'id' => 'req_3n3q6qRFMGbCa5',
    'idempotency_key' => NULL,
  ),
  'type' => 'checkout.session.completed',
)

object['data']['id']がSession IDになります。

おまけ

ローカルでWebhookをテストする

Stripeの画面で設定するWebhook URLは外部に公開されている必要があります。ローカルでWebhookをテストするにはStripe CLIを使います。

$ stripe listen --forward-to localhost/shop/subscription/hooks
> Ready! Your webhook signing secret is whsec_ZlO3mWGMY9XOMfdJ3mqjUpmaMie8kp1e (^C to quit)

上記のコマンドを実行すると、決済完了後に指定したURLにリクエストが送られてきます。

既存の顧客をCheckoutSessionで渡す

CheckoutSessionを作成する際に、StripeのCustomerを渡すこともできます。

function createCheckoutSession(Request $request)
{
    // customer作成
    Stripe::setApiKey(env('STRIPE_SECRET'));
    $user = Auth::user();
    $customer = Customer::create([
        'email' => $user->mail,
    ]);
    \Log::debug($customer->id);

    $priceId = $request->get('priceId');
    try {
        $checkout_session = \Stripe\Checkout\Session::create([
            'success_url' => 'http://localhost/shop/subscription/success?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => 'http://localhost/shop/subscription',
            'payment_method_types' => ['card'],
            'customer' => $customer->id,
            'mode' => 'subscription',
            'line_items' => [[
                'price' => $priceId,
                // For metered billing, do not pass quantity
                'quantity' => 1,
            ]],
        ]);
    } catch (\Exception $e) {
        return response()->json([
            'error' => [
                'message' => $e->getError()->message,
            ],
            400
        ]);
    }
    // 継続課金の状態管理
    UserSubscription::create([
        'user_id' => $user->id,
        'price_id' => $priceId,
        'session_id' => $checkout_session['id'],
    ]);
    return response()->json(['sessionId' => $checkout_session['id']]);
}

継続課金の決済状態をバッチで確認する

一般的にWebhookは到達保障性が担保されていないため、決済が完了したかをバッチで確認し、完了していれば、user_subscriptionsテーブルを更新します。

<?php

namespace App\Console\Commands;

use App\Models\UserSubscription;
use Illuminate\Console\Command;
use Stripe\Checkout\Session;
use Stripe\Stripe;

class CheckUserSubscriptions extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */    protected $signature = 'command:name';

    /**
     * The console command description.
     *
     * @var string
     */    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */    public function handle()
    {
        Stripe::setApiKey(env('STRIPE_SECRET'));
        $userSubscriptionIds = [];
        // 決済未完了のデータを100件ずつ取得して問い合わせる
        UserSubscription::where('status', '=', 0)->chunk(100, function ($userSubscriptions) use(&$userSubscriptionIds) {
            foreach ($userSubscriptions as $userSubscription) {
                $retrieve = Session::retrieve($userSubscription->session_id);
                \Log::debug($retrieve);
                if ($retrieve['payment_status'] == 'paid') {
                    $userSubscriptionIds[] = $userSubscription->id;
                }
            }
        });
        // 決済が完了していればstatusを更新する
        if (count($userSubscriptionIds) > 0) {
            UserSubscription::whereIn('id', $userSubscriptionIds)->update(['status' => 1]);
        }
        return 0;
    }
}

StripeにCheckoutSessionを問い合わせた際、決済が成功していれば、以下のような値が返却されます。

{
    "id": "cs_test_a18RnMauHgardtwwMVUYlw9KBXWhj6I3FDaSE2Jk8iR7AaGs6tcTpg1VOa",
    "object": "checkout.session",
    "allow_promotion_codes": null,
    "amount_subtotal": 300,
    "amount_total": 300,
    "billing_address_collection": null,
    "cancel_url": "http:\/\/localhost\/shop\/subscription",
    "client_reference_id": null,
    "currency": "jpy",
    "customer": "cus_JMrcwK5js39hgm",
    "customer_details": {
        "email": "hoge@gmail.com",
        "tax_exempt": "none",
        "tax_ids": []
    },
    "customer_email": null,
    "livemode": false,
    "locale": null,
    "metadata": [],
    "mode": "subscription",
    "payment_intent": null,
    "payment_method_options": [],
    "payment_method_types": [
        "card"
    ],
    "payment_status": "paid",
    "setup_intent": null,
    "shipping": null,
    "shipping_address_collection": null,
    "submit_type": null,
    "subscription": "sub_JMrcgXJjWMAf8t",
    "success_url": "http:\/\/localhost\/shop\/subscription\/success?session_id={CHECKOUT_SESSION_ID}",
    "total_details": {
        "amount_discount": 0,
        "amount_shipping": 0,
        "amount_tax": 0
    }
}  

継続課金の自動更新をバッチで確認する

自動更新の決済が正常に行われたかを確認し、決済に失敗していた場合、user_subscriptionsテーブルを更新します。

<?php

namespace App\Console\Commands;

use App\Models\UserSubscription;
use Illuminate\Console\Command;
use Stripe\Checkout\Session;
use Stripe\Stripe;
use Stripe\Subscription;

class CheckUserSubscriptionRenewals extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */    protected $signature = 'command:name';

    /**
     * The console command description.
     *
     * @var string
     */    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */    public function handle()
    {
        Stripe::setApiKey(env('STRIPE_SECRET'));
        $userSubscriptionIds = [];
        // 有効なデータを100件ずつ取得して問い合わせる
        UserSubscription::where('status', '=', 1)->chunk(100, function ($userSubscriptions) use(&$userSubscriptionIds) {
            foreach ($userSubscriptions as $userSubscription) {
                $subscription = Subscription::retrieve($userSubscription->subscription_id);
                \Log::debug($subscription);
                if ($subscription['status'] != 'active') {
                    $userSubscriptionIds[] = $userSubscription->id;
                }
            }
        });
        // サブスクリプションの継続決済に失敗していた場合、statusを更新する
        if (count($userSubscriptionIds) > 0) {
            UserSubscription::whereIn('id', $userSubscriptionIds)->update(['status' => 0]);
        }
        return 0;
    }
}

StripeにSubscriptionを問い合わせた際、決済が成功していて、Subscriptionが有効な場合、以下のような値が返却されます。

{
    "id": "sub_JOmoh0vTapjOSI",
    "object": "subscription",
    "application_fee_percent": null,
    "billing_cycle_anchor": 1619800277,
    "billing_thresholds": null,
    "cancel_at": null,
    "cancel_at_period_end": false,
    "canceled_at": null,
    "collection_method": "charge_automatically",
    "created": 1619800277,
    "current_period_end": 1622392277,
    "current_period_start": 1619800277,
    "customer": "cus_JOmoumD2Eh3PbX",
    "days_until_due": null,
    "default_payment_method": "pm_1IlzFICQ3ppMoW38bQjo57Jh",
    "default_source": null,
    "default_tax_rates": [],
    "discount": null,
    "ended_at": null,
    "items": {
        "object": "list",
        "data": [
            {
                "id": "si_JOmoWMmJ4gGc6w",
                "object": "subscription_item",
                "billing_thresholds": null,
                "created": 1619800277,
                "metadata": [],
                "plan": {
                    "id": "price_1IjRNqCQ3ppMoW38BEP0h1iF",
                    "object": "plan",
                    "active": true,
                    "aggregate_usage": null,
                    "amount": 300,
                    "amount_decimal": "300",
                    "billing_scheme": "per_unit",
                    "created": 1619193454,
                    "currency": "jpy",
                    "interval": "month",
                    "interval_count": 1,
                    "livemode": false,
                    "metadata": [],
                    "nickname": null,
                    "product": "prod_JM9ha6vuy1nJxA",
                    "tiers_mode": null,
                    "transform_usage": null,
                    "trial_period_days": null,
                    "usage_type": "licensed"
                },
                "price": {
                    "id": "price_1IjRNqCQ3ppMoW38BEP0h1iF",
                    "object": "price",
                    "active": true,
                    "billing_scheme": "per_unit",
                    "created": 1619193454,
                    "currency": "jpy",
                    "livemode": false,
                    "lookup_key": null,
                    "metadata": [],
                    "nickname": null,
                    "product": "prod_JM9ha6vuy1nJxA",
                    "recurring": {
                        "aggregate_usage": null,
                        "interval": "month",
                        "interval_count": 1,
                        "trial_period_days": null,
                        "usage_type": "licensed"
                    },
                    "tiers_mode": null,
                    "transform_quantity": null,
                    "type": "recurring",
                    "unit_amount": 300,
                    "unit_amount_decimal": "300"
                },
                "quantity": 1,
                "subscription": "sub_JOmoh0vTapjOSI",
                "tax_rates": []
            }
        ],
        "has_more": false,
        "total_count": 1,
        "url": "\/v1\/subscription_items?subscription=sub_JOmoh0vTapjOSI"
    },
    "latest_invoice": "in_1IlzFJCQ3ppMoW38IrDvgJE2",
    "livemode": false,
    "metadata": [],
    "next_pending_invoice_item_invoice": null,
    "pause_collection": null,
    "pending_invoice_item_interval": null,
    "pending_setup_intent": null,
    "pending_update": null,
    "plan": {
        "id": "price_1IjRNqCQ3ppMoW38BEP0h1iF",
        "object": "plan",
        "active": true,
        "aggregate_usage": null,
        "amount": 300,
        "amount_decimal": "300",
        "billing_scheme": "per_unit",
        "created": 1619193454,
        "currency": "jpy",
        "interval": "month",
        "interval_count": 1,
        "livemode": false,
        "metadata": [],
        "nickname": null,
        "product": "prod_JM9ha6vuy1nJxA",
        "tiers_mode": null,
        "transform_usage": null,
        "trial_period_days": null,
        "usage_type": "licensed"
    },
    "quantity": 1,
    "schedule": null,
    "start_date": 1619800277,
    "status": "active",
    "transfer_data": null,
    "trial_end": null,
    "trial_start": null
}  

statusが決済状態を表しています。collection_methodがcharge_automaticallyの場合、最初の支払いが23時間以内に行われないと、statusがincomplete_expiredに変わり、以降の請求は行われません。

また、collection_methodがcharge_automaticallyの場合、更新のための支払いに失敗するとstatusがpast_dueが変わり、Stripeがすべての支払い再試行を終了するとcancelまたはunpaidとなります。

collection_methodがsend_invoiceの場合、請求書の支払いが期日までに行われなかった場合、statusがpast_dueに変わり、その後、追加の期日までに支払いが行われないと、cancelまたはunpaidとなります。

さいごに

Stripeで継続課金を実装する際の参考になれば幸いです。

おすすめ書籍

Hiroki Ono

シェア
執筆者:
Hiroki Ono

最近の投稿

Goの抽象構文木でコードを解析する

はじめに Goでアプリケーショ…

5日 前

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

1か月 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

2か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前