Laravel

New subscription course and webhooks

Hi Alex,

I'm following your new course on Laravel subscriptions with Stripe, I've got a problem with setting webhooks in my appliction. I've installed the package Tenancy for laravel for multi tenant application, each tenant has his own database and subdomain, how can I set up webhooks?

Many thanks for your support Vincenzo

vincenzo Member
vincenzo
0
17
103
alex Member
alex
Moderator

This makes things a little more complicated, but it's entirely possible.

Here's a rough idea of what I'd do:

  1. When signing the customer up, pass the tenant_id (or whatever is used to identify the tenant) into the checkout options. Here's an example:
return $request->user()->newSubscription('default', $plan['price_id'])
    ->trialDays(4)
    ->allowPromotionCodes()
    ->checkout([
        'success_url' => route('dashboard'),
        'cancel_url' => route('plans'),
        [
            'metadata' => [
                'tenant_id' => 'YOUR_TENANT_ID_HERE'
            ]
        ]
    ], [
        'email' => $request->user()->email
    ]);

Under metadata, this will send the tenant ID to stripe, so any webhook payloads you get back will contain the tenant_id. Now you're able to identify them.

  1. Copy/paste or extend the WebhookController from the Cashier package. This will allow you to specifically look up how a user is found. Here's how this looks in the WebhookController:
protected function getUserByStripeId($stripeId)
{
    // Change this to look up your customer by their tenant ID, or Stripe ID based on the tenant
    return Cashier::findBillable($stripeId);
}

This method is used quite a bit through the other methods in this controller, so for example:

$user = $this->getUserByStripeId($payload['data']['object']['customer']);

Would change to something like:

$user = $this->getUserByStripeId($payload['data']['object']['metadata']['tenant_id']);

This is all super rough and I haven't tested any of this, but these are the steps I'd take. Cashier isn't set up to work with tenancy at all, so you'll just need to be able to identify the tenant through Stripe's metadata and take it from there.

Can absolutely cover this in a course, but in the meantime happy to help work through it with you here!

vincenzo Member
vincenzo

Hi Alex thank you for your answer I will try and get back to you. Yes video tutorial will be very handy.

Can I also ask on video number 10 where you set up the webhook you need to expose the local machine url, I use laravel valet and not herd I saw online I can use stripe cli? On stripe in the webhooks do I need to register both the local domain and the subdomain? Many thanks

alex Member
alex
Moderator

You can use the Stripe CLI to test webhooks yes. I tend not to do this, because I like to verify directly through the Stripe dashboard.

There are instructions here on how to do that if you prefer.

I'd recommend using Expose either way. You should be able to set up a local tunnel with a free account.

Any problems let me know!

vincenzo Member
vincenzo

Hi Alex,

When I try to expose the subdomain I get the following error:

Tenant could not be identified on domain mmmygiic21.sharedwithexpose.com

Any way to fix it? many thanks

alex Member
alex
Moderator

I found this GH issue which may help?

https://github.com/stancl/tenancy/issues/464

vincenzo Member
vincenzo

Hi Alex sorry to trouble you again.

I've managed to expose the main domain which should be visible here https://evngcgvxe1.sharedwithexpose.com/ then from there I've logged in and created a subdomain https://prova.evngcgvxe1.sharedwithexpose.com/ but when I try to access it doesn't work.

I've added the following in the tenancy.php config file

'central_domains' => [ 'evngcgvxe1.sharedwithexpose.com' ]

The problem I've got is how to expose the subdomain and therefore be able to use it for stripe webhooks. Otherwise I need to forget about webhook :( and use your tutorial here https://codecourse.com/watch/laravel-saas-boilerplate/286-subscriptions-subscribing-the-user

alex Member
alex
Moderator

Are you trying to expose each tenant's subdomain to use with Stripe's webhook? e.g. you have 10 webhooks endpoints if you have 10 tenants?

vincenzo Member
vincenzo

Hi Alex,

I've managed to list to the events using stripe cli. I've also followed your instructions and added metadata and chenged cashier webhook to identify the tenant by id. When i process the payment the database is updated with stripe_id for the tentant in the tenants table but it doesn't create a subsceiption in the database subscriptions table, the subscription looks fine on stripe instead.

alex Member
alex
Moderator

Could you paste the entire WebhookController you've modified here and I'll take a look :)

vincenzo Member
vincenzo

Hi Alex here it is

<?php

namespace Laravel\Cashier\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Notifications\Notifiable;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookHandled;
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Cashier\Http\Middleware\VerifyWebhookSignature;
use Laravel\Cashier\Payment;
use Laravel\Cashier\Subscription;
use Stripe\Stripe;
use Stripe\Subscription as StripeSubscription;
use Symfony\Component\HttpFoundation\Response;

class WebhookController extends Controller
{
    /**
     * Create a new WebhookController instance.
     *
     * @return void
     */
    public function __construct()
    {
        if (config('cashier.webhook.secret')) {
            $this->middleware(VerifyWebhookSignature::class);
        }
    }

    /**
     * Handle a Stripe webhook call.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function handleWebhook(Request $request)
    {
        $payload = json_decode($request->getContent(), true);
        $method = 'handle'.Str::studly(str_replace('.', '_', $payload['type']));

        WebhookReceived::dispatch($payload);

        if (method_exists($this, $method)) {
            $this->setMaxNetworkRetries();

            $response = $this->{$method}($payload);

            WebhookHandled::dispatch($payload);

            return $response;
        }

        return $this->missingMethod($payload);
    }

    /**
     * Handle customer subscription created.
     *
     * @param  array  $payload
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function handleCustomerSubscriptionCreated(array $payload)
    {
 /*        $user = $this->getUserByStripeId($payload['data']['object']['customer']);   */
        $user = $this->getUserByStripeId($payload['data']['object']['metadata']['tenant_id']);  

        if ($user) {
            $data = $payload['data']['object'];

            if (! $user->subscriptions->contains('stripe_id', $data['id'])) {
                if (isset($data['trial_end'])) {
                    $trialEndsAt = Carbon::createFromTimestamp($data['trial_end']);
                } else {
                    $trialEndsAt = null;
                }

                $firstItem = $data['items']['data'][0];
                $isSinglePrice = count($data['items']['data']) === 1;

                $subscription = $user->subscriptions()->create([
                    'type' => $data['metadata']['type'] ?? $data['metadata']['name'] ?? $this->newSubscriptionType($payload),
                    'stripe_id' => $data['id'],
                    'stripe_status' => $data['status'],
                    'stripe_price' => $isSinglePrice ? $firstItem['price']['id'] : null,
                    'quantity' => $isSinglePrice && isset($firstItem['quantity']) ? $firstItem['quantity'] : null,
                    'trial_ends_at' => $trialEndsAt,
                    'ends_at' => null,
                ]);

                foreach ($data['items']['data'] as $item) {
                    $subscription->items()->create([
                        'stripe_id' => $item['id'],
                        'stripe_product' => $item['price']['product'],
                        'stripe_price' => $item['price']['id'],
                        'quantity' => $item['quantity'] ?? null,
                    ]);
                }
            }

            // Terminate the billable's generic trial if it exists...
            if (! is_null($user->trial_ends_at)) {
                $user->trial_ends_at = null;
                $user->save();
            }
        }

        return $this->successMethod();
    }

    /**
     * Determines the type that should be used when new subscriptions are created from the Stripe dashboard.
     *
     * @param  array  $payload
     * @return string
     */
    protected function newSubscriptionType(array $payload)
    {
        return 'default';
    }

    /**
     * Handle customer subscription updated.
     *
     * @param  array  $payload
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function handleCustomerSubscriptionUpdated(array $payload)
    {
        if ($user = $this->getUserByStripeId($payload['data']['object']['customer'])) {
            $data = $payload['data']['object'];

            $subscription = $user->subscriptions()->firstOrNew(['stripe_id' => $data['id']]);

            if (
                isset($data['status']) &&
                $data['status'] === StripeSubscription::STATUS_INCOMPLETE_EXPIRED
            ) {
                $subscription->items()->delete();
                $subscription->delete();

                return;
            }

            $subscription->type = $subscription->type ?? $data['metadata']['type'] ?? $data['metadata']['name'] ?? $this->newSubscriptionType($payload);

            $firstItem = $data['items']['data'][0];
            $isSinglePrice = count($data['items']['data']) === 1;

            // Price...
            $subscription->stripe_price = $isSinglePrice ? $firstItem['price']['id'] : null;

            // Quantity...
            $subscription->quantity = $isSinglePrice && isset($firstItem['quantity']) ? $firstItem['quantity'] : null;

            // Trial ending date...
            if (isset($data['trial_end'])) {
                $trialEnd = Carbon::createFromTimestamp($data['trial_end']);

                if (! $subscription->trial_ends_at || $subscription->trial_ends_at->ne($trialEnd)) {
                    $subscription->trial_ends_at = $trialEnd;
                }
            }

            // Cancellation date...
            if ($data['cancel_at_period_end'] ?? false) {
                $subscription->ends_at = $subscription->onTrial()
                    ? $subscription->trial_ends_at
                    : Carbon::createFromTimestamp($data['current_period_end']);
            } elseif (isset($data['cancel_at']) || isset($data['canceled_at'])) {
                $subscription->ends_at = Carbon::createFromTimestamp($data['cancel_at'] ?? $data['canceled_at']);
            } else {
                $subscription->ends_at = null;
            }

            // Status...
            if (isset($data['status'])) {
                $subscription->stripe_status = $data['status'];
            }

            $subscription->save();

            // Update subscription items...
            if (isset($data['items'])) {
                $subscriptionItemIds = [];

                foreach ($data['items']['data'] as $item) {
                    $subscriptionItemIds[] = $item['id'];

                    $subscription->items()->updateOrCreate([
                        'stripe_id' => $item['id'],
                    ], [
                        'stripe_product' => $item['price']['product'],
                        'stripe_price' => $item['price']['id'],
                        'quantity' => $item['quantity'] ?? null,
                    ]);
                }

                // Delete items that aren't attached to the subscription anymore...
                $subscription->items()->whereNotIn('stripe_id', $subscriptionItemIds)->delete();
            }
        }

        return $this->successMethod();
    }

    /**
     * Handle the cancellation of a customer subscription.
     *
     * @param  array  $payload
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function handleCustomerSubscriptionDeleted(array $payload)
    {
        if ($user = $this->getUserByStripeId($payload['data']['object']['customer'])) {
            $user->subscriptions->filter(function ($subscription) use ($payload) {
                return $subscription->stripe_id === $payload['data']['object']['id'];
            })->each(function ($subscription) {
                $subscription->skipTrial()->markAsCanceled();
            });
        }

        return $this->successMethod();
    }

    /**
     * Handle customer updated.
     *
     * @param  array  $payload
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function handleCustomerUpdated(array $payload)
    {
        if ($user = $this->getUserByStripeId($payload['data']['object']['id'])) {
            $user->updateDefaultPaymentMethodFromStripe();
        }

        return $this->successMethod();
    }

    /**
     * Handle deleted customer.
     *
     * @param  array  $payload
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function handleCustomerDeleted(array $payload)
    {
        if ($user = $this->getUserByStripeId($payload['data']['object']['id'])) {
            $user->subscriptions->each(function (Subscription $subscription) {
                $subscription->skipTrial()->markAsCanceled();
            });

            $user->forceFill([
                'stripe_id' => null,
                'trial_ends_at' => null,
                'pm_type' => null,
                'pm_last_four' => null,
            ])->save();
        }

        return $this->successMethod();
    }

    /**
     * Handle payment method automatically updated by vendor.
     *
     * @param  array  $payload
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function handlePaymentMethodAutomaticallyUpdated(array $payload)
    {
        if ($user = $this->getUserByStripeId($payload['data']['object']['customer'])) {
            $user->updateDefaultPaymentMethodFromStripe();
        }

        return $this->successMethod();
    }

    /**
     * Handle payment action required for invoice.
     *
     * @param  array  $payload
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function handleInvoicePaymentActionRequired(array $payload)
    {
        if (is_null($notification = config('cashier.payment_notification'))) {
            return $this->successMethod();
        }

        if ($payload['data']['object']['metadata']['is_on_session_checkout'] ?? false) {
            return $this->successMethod();
        }

        if ($payload['data']['object']['subscription_details']['metadata']['is_on_session_checkout'] ?? false) {
            return $this->successMethod();
        }

        if ($user = $this->getUserByStripeId($payload['data']['object']['customer'])) {
            if (in_array(Notifiable::class, class_uses_recursive($user))) {
                $payment = new Payment($user->stripe()->paymentIntents->retrieve(
                    $payload['data']['object']['payment_intent']
                ));

                $user->notify(new $notification($payment));
            }
        }

        return $this->successMethod();
    }

    /**
     * Get the customer instance by Stripe ID.
     *
     * @param  string|null  $stripeId
     * @return \Laravel\Cashier\Billable|null
     */
    protected function getUserByStripeId($stripeId)
    {
        return Cashier::findBillable($stripeId);
    }

    /**
     * Handle successful calls on the controller.
     *
     * @param  array  $parameters
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function successMethod($parameters = [])
    {
        return new Response('Webhook Handled', 200);
    }

    /**
     * Handle calls to missing methods on the controller.
     *
     * @param  array  $parameters
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function missingMethod($parameters = [])
    {
        return new Response;
    }

    /**
     * Set the number of automatic retries due to an object lock timeout from Stripe.
     *
     * @param  int  $retries
     * @return void
     */
    protected function setMaxNetworkRetries($retries = 3)
    {
        Stripe::setMaxNetworkRetries($retries);
    }
}
alex Member
alex
Moderator

Thanks, and is:

use Billable;

On your Tenant model?

alex Member
alex
Moderator

I've also found this issue on GH. Although pretty old, it may give you some additional tips to get this working.

In a couple of weeks I'll be working on a complete refresh of the multi-tenancy courses, so I will absolutely cover Cashier integration!

vincenzo Member
vincenzo

Hi yes it is, this is my Tenant model

<?php

namespace App\Models;

use App\Presenters\SubscriptionPresenter;
use Illuminate\Support\Facades\Hash;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
use Laravel\Cashier\Billable;
use Laravel\Cashier\Subscription;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains, Billable;

    protected $casts = [
        'trial_ends_at' => 'datetime',
    ];

    public static function getCustomColumns(): array
    {
        return [
            'id',
            'email',
            'stripe_id',
            'card_brand',
            'card_last_four',
            'trial_ends_at',
        ];
    }

    public static function booted() 
    {
        static::creating(function($tenant){
            $tenant->password = Hash::make($tenant->password);
        });
    }

    public function plan()
    {
        return $this->hasOneThrough(
            Plan::class, Subscription::class,
            'tenant_id', 'stripe_id', 'id', 'stripe_price'
        );
    }

    public function presentSubscription()
    {
        if (!$subscription = $this->subscription('default')) {
            return null;
        }

        return new SubscriptionPresenter($subscription->asStripeSubscription());
    }
}
alex Member
alex
Moderator

Thanks for posting that, and sorry for the delay getting back to you.

I've had a think, and I don't have enough knowledge of working with Cashier and the Tenancy for Laravel package to figure this out without re-building.

As I mentioned, I'm planning some new courses around Tenancy with the new release, so in a few weeks once that's done, I'll start playing with a Cashier integration.

Sorry I couldn't be of much more help, and let me know if you figure this out in the meantime!

vincenzo Member
vincenzo

Hi Alex,

Thanks for all your help, I’ve managed to make it work without editing or extending cashier webhook controller, I believe I had a struggle with exporting the subdomain using stripe cli. The only problem now is when I’ll be in production not sure how I’m going to handle different webhook for different subdomains without adding them manually on stripe dashboard.

I’ll wait for you tutorial thank you. Hope you will have time to cover also multidatabase and subdomains.

Many thanks for now

vincenzo Member
vincenzo

Hi Alex,

Sorry to trouble you again, I've been thinking on what is the bese way to manage subscription for multidomains and I believe to avoid problems with creating different webhook per single subdomain mybe is better to manage the subscription in the mail domain?

This is the structure I believe could work:

  1. Register the user, the tenant and subdomain from the main domain
  2. Redirect the registered user to the main domain dashbord where he can select the plan (I can get the tenant ID using the user email)
  3. The tenant can pay and activate the subscription using stripe checkout webhook
  4. On the main domain private area the tenant will be able to mange his subscription
  5. The tenant will be able to access the subdomain once the subscription is active

The only problem is that the tenant will have two user accounts one in the main database (which will be used to access the subscription detail) and one in his subdomain where he can access ad admin. So if in the future for example the user wants to change the password or the email from his subdomain profile then I will need to update also the user stored in the main database

Do you think this approach could work? Many thanks again

alex Member
alex
Moderator

Hey, sorry for the delay in replying!

I think this could work yes. There's no harm in branching out and trying it, and then getting rid of everything if it doesn't :)

I believe to avoid problems with creating different webhook per single subdomain

I'm not sure if you saw me mention this before, but it would be a good idea to not set up multiple webhook endpoints on Stripe anyway (I think there is a limit), or is this not what you meant?