Laravel8でStripe Connectのダイレクト支払いにて決済を行う

前回、Laravel8でStripe ConnectのStandardアカウントを作成する手順についてまとめました。

今回は続きで、Standardアカウントにて、ダイレクト支払いを行います。
本記事では、単発の決済について記載します。

ダイレクト支払いについては、Stripeダッシュボードからの操作ではできず、コーディングが必要になります。

Laravelのバージョンは「8.41.0」で実装しています。

決済用ページの実装

本記事では決済用のページの作成には、Stripe Checkoutを使用します。
Stripe Checkoutを利用すると、簡単に支払い用のページが作成できます。

▼公式ドキュメント
https://stripe.com/docs/payments/checkout

コントローラーの実装

まず、コントローラーの実装です。

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Stripe\Stripe;

class PaymentController extends Controller
{
    public function show($user_id, $product_id, $price_id)
    {
        $user = User::findOrFail($user_id);

        // 対象商品をstripeから取得
        $stripe = new \Stripe\StripeClient(config('app.stripe_secret'));
        $product = $stripe->products->retrieve(
            $product_id,
            [],
            ['stripe_account' => $user->stripe_user_id],
        );

        // 対象料金をstripeから取得
        $price = $stripe->prices->retrieve(
            $price_id,
            [],
            ['stripe_account' => $user->stripe_user_id],
        );

        // 税率を取得
        $tax_rates = $stripe->taxRates->all(
            ['active' => true],
            ['stripe_account' => $user->stripe_user_id],
        );

        // Checkoutセッションを作成
        $session = $stripe->checkout->sessions->create([
            'success_url' => route('payment.success', [
                'user_id' => $user_id, 'product_id' => $product_id, 'price_id' => $price_id]) . '?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => route('payment.show', [
                'user_id' => $user_id, 'product_id' => $product_id, 'price_id' => $price_id]),
            'payment_method_types' => ['card'],
            'line_items' => [
                [
                    'price' => $price_id,
                    'quantity' => 1,
                    'tax_rates' => [$tax_rate->id],
                ],
            ],
            'payment_intent_data' => [
                'application_fee_amount' => 100,
            ],
            'mode' => 'payment',
            'allow_promotion_codes' => true,
        ], ['stripe_account' => $user->stripe_user_id]);

        return view('payment.show', [
            'user' => $user,
            'product' => $product,
            'price' => $price,
            'session' => $session,
        ]);
    }

    /**
     * 決済完了ページを表示する
     * @return \Illuminate\View\View
     */
    public function success()
    {
        return view('payment.success');
    }
}

11行目で、URLからユーザーID、商品ID、料金IDを取得します。(ルーティングについては後述します)

16行目〜34行目で、対象の商品、料金、税率をStripeからAPIで取得します。
この時、「stripe_account」というパラメータで子アカウントのIDを渡すと、子アカウントのStripe情報が取得できます。

37行目からで、Checkoutセッションを作成します。
この時、「payment_intent_data」→「application_fee_amount」で金額を指定することで、プラットフォームへの手数料を指定できます。
上記の例だと、決済金額のうち、100円が手数料となります。
Checkoutセッションを作成する際も、「stripe_account」パラメータで、子アカウントのIDを指定します。

ビューの実装

続いて、ビューの実装です。

@extends('layouts.app')
@section('head-scripts')
    <script src="https://js.stripe.com/v3/"></script>
@endsection

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ $product->name }}</div>

                <div class="card-body">
                    <button id="checkout-button" style="background-color:#6772E5;color:#FFF;padding:8px 12px;border:0;border-radius:4px;font-size:1em">決済を行う</button>
                    <div id="error-message"></div>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
@section('footer-scripts')
    <script>
        const stripe = Stripe('{{ config('app.stripe_public') }}', {
            stripeAccount: '{{ $user->stripe_user_id }}'
        });
        document.addEventListener("DOMContentLoaded", function(){
            const checkoutButton = document.getElementById('checkout-button');
            checkoutButton.addEventListener('click', function(){
                stripe.redirectToCheckout({
                    sessionId: '{{ $session->id }}'
                }).then(function (result) {
                    if (result.error) {
                        let displayError = document.getElementById('error-message');
                        displayError.textContent = result.error.message;
                    }
                });
            });
        }, false);
    </script>
@endsection

3行目で、Stripeのスクリプトを読み込みます。

24行目からで、ボタンクリック時にStripeに飛ばす処理を実装します。
24行目で、Stripeの公開可能キー、25行目で子アカウントのIDを指定します。

30行目からで、ボタンクリック時の処理を実装します。
31行目で、コントローラーで作成したCheckoutセッションのIDを指定します。

これでボタンをクリックすることで、Stripeの決済画面に遷移するようになります。

ルーティングの実装

以下のように、URLにて各種必要な情報を指定するような形にしました。
ここは、適宜、好きなように変更してください。

Route::get('/users/{user_id}/{product_id}/{price_id}', [
    App\Http\Controllers\PaymentController::class, 'show'])->name('payment.show');
Route::get('/users/{user_id}/{product_id}/{price_id}/success', [
    App\Http\Controllers\PaymentController::class, 'success'])->name('payment.success');

決済完了時のWebhook処理の実装

決済完了時の処理は、「success_url」パラメータへのリダイレクトでは処理しないようにします。
これは、success_urlに直接アクセスした場合や、支払い完了後、リダイレクトが発生する前にブラウザを閉じてしまったり、などが起こり得るためです。

悪意を持つユーザが、支払いをせずに success_url に直接アクセスし、商品やサービスにアクセスできるようになる可能性があります。
顧客が支払いの成功後に success_url に到達するとは限りません。リダイレクトが発生する前に、顧客がブラウザタブを閉じることがあります。

Stripe 公式ドキュメント

コントローラーの実装

以下のようにwebhookの処理を実装します。

public function webhook(Request $request)
{
    Stripe::setApiKey(config('app.stripe_secret'));

    $endpoint_secret = config('app.stripe_endpoint_secret');

    $payload = $request->getContent();
    $sig_header = $request->header('stripe-signature');

    $event = null;
    try {
        $event = \Stripe\Webhook::constructEvent(
            $payload, $sig_header, $endpoint_secret
        );
    } catch(\UnexpectedValueException $e) {
        // Invalid payload.
        return response()->json('Invalid payload', 400);
    } catch(\Stripe\Exception\SignatureVerificationException $e) {
        // Invalid Signature.
        return response()->json('Invalid signature', 400);
    }

    if ($event->type == 'checkout.session.completed') {
        $connectedAccountId = $event->account;
        $session = $event->data->object;
        $this->handleCompletedCheckoutSession($connectedAccountId, $session);
    }

    return response()->json('ok', 200);
}

private function handleCompletedCheckoutSession($connectedAccountId, $session) {
    logger('Completed', ['Connected account ID' => $connectedAccountId]);
    logger($session);
}

5行目で、エンドポイントのシークレットキーを指定します。
これはStripeのダッシュボードで確認できます。

12行目で送信されてきたリクエストの情報から、webhookイベントのチェックを行います。

23行目で、イベントタイプが「checkout.session.completed」(Checkoutセッションの完了)の場合は、決済完了の処理を行う、という流れです。
24行目で、子アカウントのID、25行目でCheckoutセッションが取得できるので、それを元に適宜処理を行うという感じになります。

CSRFトークンのチェックを除外する

デフォルトでは、CSRFトークンのチェックが有効になっているため、このままではStripeからのwebhookがエラーになってしまいます。
StripeからのWebhookでは、CSRFトークンのチェックを行わないように、以下のファイルを修正します。
app/Http/Middleware/VerifyCsrfToken.php

protected $except = [
    'payment/webhook'
];

デフォルトでは何も指定されていない状態ですが、ここに「payment/webhook」とチェックを行わないようにするURIを指定します。
本記事では、https://xxxxxx//payment/webhook
というURLで記載しているため、上記のように指定しています。

ルーティングの実装

webhook用のルーティングを指定します。
ここは、適宜、好きなように変更してください。
※上記のCSRFトークンのチェックを除外する指定も変更してください。

Route::post('/payment/webhook', [App\Http\Controllers\PaymentController::class, 'webhook'])->name('payment.webhook');

ローカル(localhost)でのwebhookのテスト

開発時にローカル(localhost)でwebhookをテストする場合は以下の公式ドキュメントを参考にしてください。

https://stripe.com/docs/connect/creating-a-payments-page?platform=web&ui=checkout#%EF%BD%97ebhook-%E3%82%92%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%81%A7%E3%83%86%E3%82%B9%E3%83%88%E3%81%99%E3%82%8B

https://stripe.com/docs/payments/checkout/fulfill-orders

ローカルで実行する場合は、stripe listenを実行することで、エンドポイントのシークレットキーが確認できます。

1点、僕はDockerを使用しているので、Dockerを使用する場合のやり方を補足します。

まず、「stripe listen」でwebhookイベントを受信する側です。
docker run をする時に、「–net="host"」を指定して、コンテナ内でホスト側のネットワークを参照するようにします。
これにより、コンテナ内からhttp://localhost/payment/webhookへ転送できます。

docker run --net="host" --rm -it stripe/stripe-cli listen --forward-connect-to http://localhost/payment/webhook --api-key sk_test_XXXXX

続いて、「stripe trigger」でwebhookイベントを送信する側です。
こちらは通常通りに、イベントタイプを指定して送信しますが、docker run でコンテナを実行する時に、パラメータを指定して、コンテナ内で実行するコメントを指定するようにしています。

docker run --rm -it stripe/stripe-cli trigger checkout.session.completed --stripe-account=acct_XXXXX --api-key sk_test_XXXXX

これで、ダイレクト支払いにて決済を行うことができました。
詳細は、以下の公式ドキュメントをご覧ください。

▼公式ドキュメント
https://stripe.com/docs/connect/creating-a-payments-page?platform=web&ui=checkout