Laravel Jetstream Subscription Billing With Stripe Checkout and Customer Portal

Laravel Jetstream Subscription Billing With Stripe Checkout and Customer Portal

https://ift.tt/2K4IDz3


Laravel Jetstream was recently released and is a great starting point for a new project. I prefer the Livewire stack, which is used in this guide, but there is also an Inertia.js + Vue option.

I recently started a project using Jetstream since it gives you a lot of the same features as Laravel Spark, minus the subscription billing. But, I still needed billing. So, I decided to add Stripe Checkout and customer portal, offloading all of the billing front-end to Stripe.

Here’s how I did it from start to finish.



Start a new Laravel project

laravel new cool-project

cd cool-project

You’ll want to set up your preferred database at this point and ensure that your application can connect to it.



Install Jetstream

Check out the Jetstream documentation if you’re not familiar with it. Install Jetstream with the teams option.

composer require laravel/jetstream

php artisan jetstream:install livewire --teams

Publish the Jetstream views in your application so we can make a few updates later.

php artisan vendor:publish --tag=jetstream-views

And, migrate the database.

php artisan migrate

Lastly, let’s build the front-end.

npm install && npm run dev



Install Laravel Cashier

Why do we need Cashier? Because it’s still a great option to handle the Stripe webhooks for us. And, we’ll extend it to handle a custom webhook.

Install Cashier:

composer require laravel/cashier

If you don’t have a Stripe account, you’ll want to set that up and add your API keys.

Add the following to your .env file.

STRIPE_KEY=your-stripe-key
STRIPE_SECRET=your-stripe-secret
Enter fullscreen mode

Exit fullscreen mode



Team Billing

This is where I fork from the typical Cashier installation. I prefer team billing, but Cashier, by default, expects billing to be set up by user.

You can tell Cashier what model to use by adding an .env variable.

Add the following to your .env file.

CASHIER_MODEL=App\Models\Team

We also need to update the Cashier database migrations to use teams instead of users. Let’s publish the migrations to our project so we can make those updates.

php artisan vendor:publish --tag="cashier-migrations"

In migration 2019_05_03_000001_create_customer_columns.php, replace any instance of “users” with “teams”.

In migration 2019_05_03_000002_create_subscriptions_table.php, replace any instance of “user_id” with “team_id”.

And, let’s migrate the database again.

php artisan migrate

Next, you’ll add the Billable trait to your Team model.

use Laravel\Cashier\Billable;

class Team extends JetstreamTeam
{
    use Billable;
}
Enter fullscreen mode

Exit fullscreen mode

That’s it for the initial setup. Now, it’s time to add the billing front-end and handle the Stripe webhooks.



Stripe Checkout, Customer Portal and Webhooks

Let’s create a controller to handle the checkout and portal links.

php artisan make:controller StripeController

And, update the file with the following:

<?php

namespace App\Http\Controllers;

use Exception;
use Illuminate\Http\Request;

class StripeController extends Controller
{
    public function checkout(Request $request) {
        \Stripe\Stripe::setApiKey(env('STRIPE_SECRET'));
        $user = $request->user();
        $plan = $request->input('plan');

        try {
            $session = \Stripe\Checkout\Session::create([
                'customer_email' => $user->email,
                'payment_method_types' => ['card'],
                'mode' => 'subscription',
                'client_reference_id' => $user->currentTeam->id,
                'line_items' => [[
                    'price' => $plan,
                    'quantity' => 1,
                ]],
                'success_url' => route('dashboard'),
                'cancel_url' => route('billing'),
            ]);
        }
        catch (Exception $e) {
            return response()->json([
                'error' => [
                    'message' => $e->getMessage(),
                ]
            ], 400);
        }

        return response()->json(['sessionId' => $session['id']]);
    }

    public function portal(Request $request)
    {
        return $request->user()->currentTeam->redirectToBillingPortal(
            route('dashboard')
        );
    }
}

Enter fullscreen mode

Exit fullscreen mode

Let’s make a controller to handle the Stripe session.checkout.completed webhook. This is the webhook that is called after a person successfully sets up their subscription and is not handled by Cashier.

php artisan make:controller WebhookController

And, update the file with the following:

<?php

namespace App\Http\Controllers;

use App\Models\Team;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;

class WebhookController extends CashierController
{
    public function handleCheckoutSessionCompleted(array $payload)
    {
        $data = $payload['data']['object'];
        $team = Team::findOrFail($data['client_reference_id']);

        DB::transaction(function () use ($data, $team) {
            $team->update(['stripe_id' => $data['customer']]);

            $team->subscriptions()->create([
                'name' => 'default',
                'stripe_id' => $data['subscription'],
                'stripe_status' => 'active'
            ]);
        });

        return $this->successMethod();
    }
}

Enter fullscreen mode

Exit fullscreen mode

One gotcha is that we need to add an exception in the CSRF middlware for the Stripe webhooks.

Update the VerifyCsrfToken.php class with the following:

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array
     */
    protected $except = [
        'stripe/*'
    ];
}
Enter fullscreen mode

Exit fullscreen mode

Another gotcha is that we need to make sure the “stripe_id” column is fillable on the Team model.

Update the Team model with the following:

class Team extends JetstreamTeam
{
    ...

    protected $fillable = [
        'name',
        'personal_team',
        'stripe_id'
    ];

    ...
}
Enter fullscreen mode

Exit fullscreen mode

Finally, add the routes for these new controller actions.

Add the following to the routes file:

Route::middleware(['auth:sanctum', 'verified'])->group(function () {
    Route::post('/stripe/checkout', [StripeController::class, 'checkout'])->name('stripe.checkout');
    Route::get('/stripe/portal', [StripeController::class, 'portal'])->name('stripe.portal');
});

Route::post(
    'stripe/webhook',
    '\App\Http\Controllers\WebhookController@handleWebhook'
);
Enter fullscreen mode

Exit fullscreen mode



Jetstream Billing View

We still need to give the user a way to access this new team billing functionality. Let’s add Stripe.js and create the Jetstream-flavored billing view. This view is a good start and works, but it could be slicker.

Add Stripe.js to resources/views/layouts/app.blade.php in the <head> section.

<script src="https://js.stripe.com/v3/"></script>
Enter fullscreen mode

Exit fullscreen mode

Create a new view at resources/views/billing.blade.php and update with the following:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
             ()
        </h2>
    </x-slot>

    <div>
        <div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
            <div class="mt-10 sm:mt-0">
                <x-jet-action-section>
                    <x-slot name="title">
                        
                    </x-slot>

                    <x-slot name="description">
                        
                    </x-slot>

                    <x-slot name="content">
                        <p></p>

                        @if (Auth::user()->currentTeam->subscribed('default'))
                            <div class="mt-6">
                                <a class="btn" href="">
                                    
                                </a>
                            </div>
                        @else
                            <div class="mt-4">
                                <button data-plan="price_XXX" class="btn checkout-btn">
                                    
                                </button>
                            </div>
                            <div class="mt-4">
                                <button data-plan="price_XXX" class="btn checkout-btn">
                                    
                                </button>
                            </div>
                        @endif
                    </x-slot>
                </x-jet-action-section>
             </div>
        </div>
    </div>
</x-app-layout>

<script>
var handleFetchResult = function(result) {
    if (!result.ok) {
        return result.json().then(function(json) {
            if (json.error && json.error.message) {
                throw new Error(result.url + ' ' + result.status + ' ' + json.error.message);
            }
        });
    }
    return result.json();
};

var createCheckoutSession = function(plan) {
    return fetch("/stripe/checkout", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify({
            plan: plan
        })
    }).then(handleFetchResult);
};

var stripe = Stripe('');
var checkoutBtns = document.getElementsByClassName('checkout-btn');

for (var i = 0; i < checkoutBtns.length; i++) {
    checkoutBtns[i].addEventListener("click", function(e) {
        var plan = e.target.getAttribute("data-plan");

        createCheckoutSession(plan).then(function(data) {
        stripe
            .redirectToCheckout({
                sessionId: data.sessionId
            })
            .then(handleResult);
        });
    })
}
</script>


Enter fullscreen mode

Exit fullscreen mode

You’ll want to update the “Subscribe” buttons’ data-plan attributes with your own Stripe product price IDs.

Let’s add a route to make this billing view work. Add the following to your routes file.

Route::middleware(['auth:sanctum', 'verified'])->group(function () {
    Route::get('/billing', function() {
        return view('billing');
    })->name('billing');
});
Enter fullscreen mode

Exit fullscreen mode

Finally (finally!), we’ll add a “Billing” link to the Jetstream navigation dropdown.

In navigation-dropdown.blade.php, add the following in the “Team Management” section.

<!-- Team Billing -->
<x-jet-dropdown-link href="">
    
</x-jet-dropdown-link>
Enter fullscreen mode

Exit fullscreen mode



That’s it! 🎉

This was super long, but I hope it helped someone. Laravel Jetstream is a great starting point and Stripe checkeout and customer portal make it relatively easy to add subscription and billing management.

programming

via Laravel News Links https://ift.tt/2dvygAJ

December 13, 2020 at 08:45PM