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
This makes things a little more complicated, but it's entirely possible.
Here's a rough idea of what I'd do:
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.
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!
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
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!
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
I found this GH issue which may help?
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
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?
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.
Could you paste the entire WebhookController
you've modified here and I'll take a look :)
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);
}
}
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!
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());
}
}
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!
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
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:
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
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?