28
common/Billing/Gateways/Stripe/FormatsMoney.php
Executable file
28
common/Billing/Gateways/Stripe/FormatsMoney.php
Executable file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use Common\Billing\Models\Price;
|
||||
use Money\Currencies\ISOCurrencies;
|
||||
use Money\Currency;
|
||||
use Money\Parser\IntlLocalizedDecimalParser;
|
||||
use NumberFormatter;
|
||||
|
||||
trait FormatsMoney
|
||||
{
|
||||
protected function priceToCents(Price $price): string
|
||||
{
|
||||
$currencies = new ISOCurrencies();
|
||||
$numberFormatter = new NumberFormatter('en', NumberFormatter::DECIMAL);
|
||||
$moneyParser = new IntlLocalizedDecimalParser(
|
||||
$numberFormatter,
|
||||
$currencies,
|
||||
);
|
||||
$money = $moneyParser->parse(
|
||||
$price->amount,
|
||||
new Currency($price->currency),
|
||||
);
|
||||
|
||||
return $money->getAmount();
|
||||
}
|
||||
}
|
||||
130
common/Billing/Gateways/Stripe/Stripe.php
Executable file
130
common/Billing/Gateways/Stripe/Stripe.php
Executable file
@@ -0,0 +1,130 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Billing\Gateways\Contracts\CommonSubscriptionGatewayActions;
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Settings\Settings;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class Stripe implements CommonSubscriptionGatewayActions
|
||||
{
|
||||
public StripePlans $plans;
|
||||
public StripeSubscriptions $subscriptions;
|
||||
public StripeClient $client;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new StripeClient([
|
||||
'api_key' => config('services.stripe.secret'),
|
||||
'stripe_version' => '2022-08-01',
|
||||
]);
|
||||
|
||||
$this->plans = new StripePlans($this->client);
|
||||
$this->subscriptions = new StripeSubscriptions($this->client);
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) app(Settings::class)->get('billing.stripe.enable');
|
||||
}
|
||||
|
||||
public function syncPlan(Product $product): bool
|
||||
{
|
||||
return $this->plans->sync($product);
|
||||
}
|
||||
|
||||
public function getAllPlans(): array
|
||||
{
|
||||
return $this->plans->getAll();
|
||||
}
|
||||
|
||||
public function changePlan(
|
||||
Subscription $subscription,
|
||||
Product $newProduct,
|
||||
Price $newPrice,
|
||||
): bool {
|
||||
return $this->subscriptions->changePlan(
|
||||
$subscription,
|
||||
$newProduct,
|
||||
$newPrice,
|
||||
);
|
||||
}
|
||||
|
||||
public function deletePlan(Product $product): bool
|
||||
{
|
||||
return $this->plans->delete($product);
|
||||
}
|
||||
|
||||
public function isSubscriptionIncomplete(Subscription $subscription): bool
|
||||
{
|
||||
return $this->subscriptions->isIncomplete($subscription);
|
||||
}
|
||||
|
||||
public function isSubscriptionPastDue(Subscription $subscription): bool
|
||||
{
|
||||
return $this->subscriptions->isPastDue($subscription);
|
||||
}
|
||||
|
||||
public function cancelSubscription(
|
||||
Subscription $subscription,
|
||||
bool $atPeriodEnd = true,
|
||||
): bool {
|
||||
return $this->subscriptions->cancel($subscription, $atPeriodEnd);
|
||||
}
|
||||
|
||||
public function resumeSubscription(
|
||||
Subscription $subscription,
|
||||
array $gatewayParams = [],
|
||||
): bool {
|
||||
return $this->subscriptions->resume($subscription, $gatewayParams);
|
||||
}
|
||||
|
||||
public function createSetupIntent(User $user): string
|
||||
{
|
||||
$setupIntent = $this->client->setupIntents->create([
|
||||
'customer' => $user->stripe_id,
|
||||
]);
|
||||
|
||||
return $setupIntent->client_secret;
|
||||
}
|
||||
|
||||
public function changeDefaultPaymentMethod(
|
||||
User $user,
|
||||
string $paymentMethodId,
|
||||
): bool {
|
||||
$updatedUser = $this->client->customers->update($user->stripe_id, [
|
||||
'invoice_settings' => [
|
||||
'default_payment_method' => $paymentMethodId,
|
||||
],
|
||||
]);
|
||||
|
||||
$isSuccess =
|
||||
$updatedUser->invoice_settings['default_payment_method'] ==
|
||||
$paymentMethodId;
|
||||
|
||||
if ($isSuccess) {
|
||||
$paymentMethod = $this->client->paymentMethods->retrieve(
|
||||
$paymentMethodId,
|
||||
);
|
||||
if ($paymentMethod->type === 'card') {
|
||||
$this->storeCardDetailsLocally(
|
||||
$user,
|
||||
$paymentMethod->card->toArray(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $isSuccess;
|
||||
}
|
||||
|
||||
public function storeCardDetailsLocally(User $user, array $card): void
|
||||
{
|
||||
$user->update([
|
||||
'card_brand' => $card['brand'],
|
||||
'card_last_four' => $card['last4'],
|
||||
'card_expires' => "{$card['exp_month']}/{$card['exp_year']}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
common/Billing/Gateways/Stripe/StripeController.php
Executable file
75
common/Billing/Gateways/Stripe/StripeController.php
Executable file
@@ -0,0 +1,75 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use Auth;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
use Common\Core\BaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class StripeController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected Subscription $subscription,
|
||||
protected Stripe $stripe,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function createPartialSubscription(): Response|JsonResponse
|
||||
{
|
||||
$data = $this->validate($this->request, [
|
||||
'product_id' => 'required|integer|exists:products,id',
|
||||
'price_id' => 'integer|exists:prices,id',
|
||||
'start_date' => 'string',
|
||||
]);
|
||||
|
||||
$product = Product::findOrFail($data['product_id']);
|
||||
$clientSecret = $this->stripe->subscriptions->createPartial(
|
||||
$product,
|
||||
Auth::user(),
|
||||
$data['price_id'] ?? null,
|
||||
);
|
||||
|
||||
return $this->success(['clientSecret' => $clientSecret]);
|
||||
}
|
||||
|
||||
public function createSetupIntent(): Response|JsonResponse
|
||||
{
|
||||
$clientSecret = $this->stripe->createSetupIntent(Auth::user());
|
||||
return $this->success(['clientSecret' => $clientSecret]);
|
||||
}
|
||||
|
||||
public function changeDefaultPaymentMethod(): Response|JsonResponse
|
||||
{
|
||||
$data = $this->validate($this->request, [
|
||||
'payment_method_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$this->stripe->changeDefaultPaymentMethod(
|
||||
$this->request->user(),
|
||||
$data['payment_method_id'],
|
||||
);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
public function storeSubscriptionDetailsLocally(): Response|JsonResponse
|
||||
{
|
||||
$data = $this->validate($this->request, [
|
||||
'payment_intent_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$paymentIntent = $this->stripe->client->paymentIntents->retrieve(
|
||||
$data['payment_intent_id'],
|
||||
);
|
||||
|
||||
$this->stripe->subscriptions->sync(
|
||||
$paymentIntent->invoice->subscription,
|
||||
);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
97
common/Billing/Gateways/Stripe/StripePlans.php
Executable file
97
common/Billing/Gateways/Stripe/StripePlans.php
Executable file
@@ -0,0 +1,97 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\Price as StripePrice;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class StripePlans
|
||||
{
|
||||
use FormatsMoney;
|
||||
|
||||
public function __construct(protected StripeClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
public function sync(Product $product): bool
|
||||
{
|
||||
$product->load('prices');
|
||||
|
||||
// create product on stripe, if it does not exist already
|
||||
try {
|
||||
$stripeProduct = $this->client->products->retrieve($product->uuid);
|
||||
} catch (ApiErrorException $err) {
|
||||
$stripeProduct = null;
|
||||
}
|
||||
|
||||
if (!$stripeProduct) {
|
||||
$this->client->products->create([
|
||||
'id' => $product->uuid,
|
||||
'name' => $product->name,
|
||||
]);
|
||||
}
|
||||
|
||||
// create any local product prices on stripe, that don't exist there already
|
||||
$product->prices->each(function (Price $price) use ($product) {
|
||||
if (
|
||||
!$price->stripe_id ||
|
||||
!$this->priceExistsOnStripe($price->stripe_id)
|
||||
) {
|
||||
$this->createPrice($product, $price);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function createPrice(Product $product, Price $price): StripePrice
|
||||
{
|
||||
$stripePrice = $this->client->prices->create([
|
||||
'product' => $product->uuid,
|
||||
'unit_amount' => $this->priceToCents($price),
|
||||
'currency' => $price->currency,
|
||||
'recurring' => [
|
||||
'interval' => $price->interval,
|
||||
'interval_count' => $price->interval_count,
|
||||
],
|
||||
]);
|
||||
|
||||
$price->fill(['stripe_id' => $stripePrice->id])->save();
|
||||
|
||||
return $stripePrice;
|
||||
}
|
||||
|
||||
public function delete(Product $product): bool
|
||||
{
|
||||
// stripe does not allow deleting product if it has prices attached,
|
||||
// and prices can't be deleted via API, we archive the product instead
|
||||
try {
|
||||
$this->client->products->update($product->uuid, [
|
||||
'active' => false,
|
||||
]);
|
||||
} catch (InvalidRequestException $e) {
|
||||
// if this product is already deleted on stripe, ignore
|
||||
if ($e->getStripeCode() !== 'resource_missing') {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
return $this->client->products->all()->toArray();
|
||||
}
|
||||
|
||||
protected function priceExistsOnStripe(string $stripePriceId): bool
|
||||
{
|
||||
try {
|
||||
$this->client->prices->retrieve($stripePriceId);
|
||||
return true;
|
||||
} catch (InvalidRequestException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
287
common/Billing/Gateways/Stripe/StripeSubscriptions.php
Executable file
287
common/Billing/Gateways/Stripe/StripeSubscriptions.php
Executable file
@@ -0,0 +1,287 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Common\Billing\Invoices\CreateInvoice;
|
||||
use Common\Billing\Invoices\Invoice;
|
||||
use Common\Billing\Models\Price;
|
||||
use Common\Billing\Models\Product;
|
||||
use Common\Billing\Subscription;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\Invoice as StripeInvoice;
|
||||
use Stripe\StripeClient;
|
||||
use Stripe\Subscription as StripeSubscription;
|
||||
|
||||
class StripeSubscriptions
|
||||
{
|
||||
public function __construct(public StripeClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
public function isIncomplete(Subscription $subscription): bool
|
||||
{
|
||||
return $subscription->gateway_status ===
|
||||
StripeSubscription::STATUS_INCOMPLETE ||
|
||||
($subscription->gateway_status ===
|
||||
StripeSubscription::STATUS_INCOMPLETE_EXPIRED &&
|
||||
$subscription->gateway_status !==
|
||||
StripeSubscription::STATUS_UNPAID);
|
||||
}
|
||||
|
||||
public function isPastDue(Subscription $subscription): bool
|
||||
{
|
||||
return $subscription->gateway_status ===
|
||||
StripeSubscription::STATUS_PAST_DUE;
|
||||
}
|
||||
|
||||
public function sync(string $stripeSubscriptionId): void
|
||||
{
|
||||
$stripeSubscription = $this->client->subscriptions->retrieve(
|
||||
$stripeSubscriptionId,
|
||||
['expand' => ['latest_invoice']],
|
||||
);
|
||||
$price = Price::where(
|
||||
'stripe_id',
|
||||
$stripeSubscription->items->data[0]->price->id,
|
||||
)->firstOrFail();
|
||||
$user = User::where(
|
||||
'stripe_id',
|
||||
$stripeSubscription->customer,
|
||||
)->firstOrFail();
|
||||
|
||||
$subscription = $user->subscriptions()->firstOrNew([
|
||||
'gateway_name' => 'stripe',
|
||||
'gateway_id' => $stripeSubscription->id,
|
||||
]);
|
||||
|
||||
// Cancellation date...
|
||||
if ($stripeSubscription->cancel_at_period_end) {
|
||||
$subscription->ends_at = $subscription->onTrial()
|
||||
? $subscription->trial_ends_at
|
||||
: Carbon::createFromTimestamp(
|
||||
$stripeSubscription->current_period_end,
|
||||
);
|
||||
} elseif (
|
||||
$stripeSubscription->cancel_at ||
|
||||
$stripeSubscription->canceled_at
|
||||
) {
|
||||
$subscription->ends_at = Carbon::createFromTimestamp(
|
||||
$stripeSubscription->cancel_at ??
|
||||
$stripeSubscription->canceled_at,
|
||||
);
|
||||
} else {
|
||||
$subscription->ends_at = null;
|
||||
}
|
||||
|
||||
$subscription
|
||||
->fill([
|
||||
'price_id' => $price->id,
|
||||
'product_id' => $price->product_id,
|
||||
'gateway_name' => 'stripe',
|
||||
'gateway_id' => $stripeSubscription->id,
|
||||
'gateway_status' => $stripeSubscription->status,
|
||||
'renews_at' =>
|
||||
$subscription->ends_at ||
|
||||
$stripeSubscription->status ===
|
||||
StripeSubscription::STATUS_INCOMPLETE
|
||||
? null
|
||||
: Carbon::createFromTimestamp(
|
||||
$stripeSubscription->current_period_end,
|
||||
),
|
||||
])
|
||||
->save();
|
||||
|
||||
if ($stripeSubscription->latest_invoice) {
|
||||
$this->createOrUpdateInvoice(
|
||||
$subscription,
|
||||
$stripeSubscription->latest_invoice->id,
|
||||
$stripeSubscription->latest_invoice->status ===
|
||||
StripeInvoice::STATUS_PAID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function createPartial(
|
||||
Product $product,
|
||||
User $user,
|
||||
?int $priceId = null,
|
||||
): string {
|
||||
$price = $priceId
|
||||
? $product->prices()->findOrFail($priceId)
|
||||
: $product->prices->firstOrFail();
|
||||
|
||||
$user = $this->syncStripeCustomer($user);
|
||||
|
||||
// find incomplete subscriptions for this customer and price
|
||||
$stripeSubscription = $this->client->subscriptions
|
||||
->all([
|
||||
'customer' => $user->stripe_id,
|
||||
'price' => $price->stripe_id,
|
||||
'status' => 'incomplete',
|
||||
'expand' => ['data.latest_invoice.payment_intent'],
|
||||
])
|
||||
->first();
|
||||
|
||||
// if matching subscription was not created yet, do it now
|
||||
if (!$stripeSubscription) {
|
||||
$stripeSubscription = $this->client->subscriptions->create([
|
||||
'customer' => $user->stripe_id,
|
||||
'items' => [
|
||||
[
|
||||
'price' => $price->stripe_id,
|
||||
],
|
||||
],
|
||||
'payment_behavior' => 'default_incomplete',
|
||||
'payment_settings' => [
|
||||
'save_default_payment_method' => 'on_subscription',
|
||||
],
|
||||
'expand' => ['latest_invoice.payment_intent'],
|
||||
]);
|
||||
}
|
||||
|
||||
// return client secret, needed in frontend to complete subscription
|
||||
return $stripeSubscription->latest_invoice->payment_intent
|
||||
->client_secret;
|
||||
}
|
||||
|
||||
public function cancel(
|
||||
Subscription $subscription,
|
||||
bool $atPeriodEnd = true,
|
||||
): bool {
|
||||
if (!$subscription->user->stripe_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $this->client->subscriptions->retrieve(
|
||||
$subscription->gateway_id,
|
||||
);
|
||||
} catch (InvalidRequestException $e) {
|
||||
if ($e->getStripeCode() === 'resource_missing') {
|
||||
return true;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// cancel subscription at current period end and don't delete
|
||||
if ($atPeriodEnd) {
|
||||
$updatedSubscription = $this->client->subscriptions->update(
|
||||
$stripeSubscription->id,
|
||||
[
|
||||
'cancel_at_period_end' => true,
|
||||
],
|
||||
);
|
||||
$subscription
|
||||
->fill([
|
||||
'gateway_status' => $updatedSubscription->status,
|
||||
])
|
||||
->save();
|
||||
return $updatedSubscription->cancel_at_period_end;
|
||||
// cancel and delete subscription instantly
|
||||
} else {
|
||||
try {
|
||||
$stripeSubscription = $this->client->subscriptions->cancel(
|
||||
$stripeSubscription->id,
|
||||
);
|
||||
return $stripeSubscription->status === 'cancelled';
|
||||
} catch (InvalidRequestException $e) {
|
||||
return $e->getStripeCode() === 'resource_missing';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function resume(Subscription $subscription, array $params): bool
|
||||
{
|
||||
$stripeSubscription = $this->client->subscriptions->retrieve(
|
||||
$subscription->gateway_id,
|
||||
);
|
||||
|
||||
$updatedSubscription = $this->client->subscriptions->update(
|
||||
$stripeSubscription->id,
|
||||
array_merge(
|
||||
[
|
||||
'cancel_at_period_end' => false,
|
||||
],
|
||||
$params,
|
||||
),
|
||||
);
|
||||
|
||||
$subscription
|
||||
->fill([
|
||||
'gateway_status' => $updatedSubscription->status,
|
||||
])
|
||||
->save();
|
||||
|
||||
return $updatedSubscription->status === 'active';
|
||||
}
|
||||
|
||||
public function changePlan(
|
||||
Subscription $subscription,
|
||||
Product $newProduct,
|
||||
Price $newPrice,
|
||||
): bool {
|
||||
$stripeSubscription = $this->client->subscriptions->retrieve(
|
||||
$subscription->gateway_id,
|
||||
);
|
||||
|
||||
$updatedSubscription = $this->client->subscriptions->update(
|
||||
$stripeSubscription->id,
|
||||
[
|
||||
'proration_behavior' => 'always_invoice',
|
||||
'items' => [
|
||||
[
|
||||
'id' => $stripeSubscription->items->data[0]->id,
|
||||
'price' => $newPrice->stripe_id,
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $updatedSubscription->status === 'active';
|
||||
}
|
||||
|
||||
public function createOrUpdateInvoice(
|
||||
Subscription $subscription,
|
||||
string $stripeInvoiceId,
|
||||
bool $isPaid,
|
||||
): void {
|
||||
$existing = Invoice::where('uuid', $stripeInvoiceId)->first();
|
||||
if ($existing) {
|
||||
// paid invoices should never be set to unpaid
|
||||
if (!$existing->paid) {
|
||||
$existing->update(['paid' => $isPaid]);
|
||||
}
|
||||
} else {
|
||||
(new CreateInvoice())->execute([
|
||||
'subscription_id' => $subscription->id,
|
||||
'uuid' => $stripeInvoiceId,
|
||||
'paid' => $isPaid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function syncStripeCustomer(User $user): User
|
||||
{
|
||||
// make sure user with stored stripe ID actually exists on stripe
|
||||
if ($user->stripe_id) {
|
||||
try {
|
||||
$this->client->customers->retrieve($user->stripe_id);
|
||||
} catch (InvalidRequestException $e) {
|
||||
$user->stripe_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
// create customer object on stripe, if it does not exist already
|
||||
if (!$user->stripe_id) {
|
||||
$customer = $this->client->customers->create([
|
||||
'email' => $user->email,
|
||||
'metadata' => [
|
||||
'userId' => $user->id,
|
||||
],
|
||||
]);
|
||||
$user->fill(['stripe_id' => $customer->id])->save();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
153
common/Billing/Gateways/Stripe/StripeWebhookController.php
Executable file
153
common/Billing/Gateways/Stripe/StripeWebhookController.php
Executable file
@@ -0,0 +1,153 @@
|
||||
<?php namespace Common\Billing\Gateways\Stripe;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Billing\Notifications\PaymentFailed;
|
||||
use Common\Billing\Subscription;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Contracts\Routing\ResponseFactory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Stripe\Invoice as StripeInvoice;
|
||||
use Stripe\Subscription as StripeSubscription;
|
||||
use Stripe\Webhook;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected Stripe $stripe,
|
||||
protected Subscription $subscription,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handleWebhook(Request $request): Response|JsonResponse
|
||||
{
|
||||
$webhookSecret = config('services.stripe.webhook_secret');
|
||||
if ($webhookSecret) {
|
||||
try {
|
||||
$event = Webhook::constructEvent(
|
||||
$request->getContent(),
|
||||
$request->header('stripe-signature'),
|
||||
$webhookSecret,
|
||||
)->toArray();
|
||||
} catch (Exception $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 403);
|
||||
}
|
||||
} else {
|
||||
$event = $request->all();
|
||||
}
|
||||
|
||||
return match ($event['type']) {
|
||||
'invoice.paid' => $this->handleInvoicePaid($event),
|
||||
// sync user payment methods with local database
|
||||
'customer.updated' => $this->handleCustomerUpdated($event),
|
||||
// user subscription ended and can't be resumed
|
||||
'customer.subscription.deleted' => $this->deleteSubscription(
|
||||
$event,
|
||||
),
|
||||
// automatic subscription renewal failed on stripe
|
||||
'invoice.payment_failed' => $this->handleInvoicePaymentFailed(
|
||||
$event,
|
||||
),
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated'
|
||||
=> $this->handleSubscriptionCreatedAndUpdated($event),
|
||||
default => response('Webhook handled', 200),
|
||||
};
|
||||
}
|
||||
|
||||
protected function handleInvoicePaid(
|
||||
array $payload,
|
||||
): Response|Application|ResponseFactory {
|
||||
$stripeInvoice = $payload['data']['object'];
|
||||
$stripeSubscriptionId = $stripeInvoice['subscription'];
|
||||
|
||||
$subscription = Subscription::where(
|
||||
'gateway_id',
|
||||
$stripeSubscriptionId,
|
||||
)->first();
|
||||
|
||||
if ($subscription) {
|
||||
$this->stripe->subscriptions->createOrUpdateInvoice(
|
||||
$subscription,
|
||||
$stripeInvoice['id'],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return response('Webhook Handled', 200);
|
||||
}
|
||||
|
||||
protected function handleCustomerUpdated(
|
||||
array $payload,
|
||||
): Response|Application|ResponseFactory {
|
||||
$stripeCustomer = $payload['data']['object'];
|
||||
$user = User::where('stripe_id', $stripeCustomer['id'])->firstOrFail();
|
||||
|
||||
$stripePaymentMethods = $this->stripe->client->customers
|
||||
->allPaymentMethods($stripeCustomer['id'], ['type' => 'card'])
|
||||
->toArray()['data'];
|
||||
|
||||
if (!empty($stripePaymentMethods)) {
|
||||
$card = $stripePaymentMethods[0]['card'];
|
||||
$this->stripe->storeCardDetailsLocally($user, $card);
|
||||
}
|
||||
|
||||
return response('Webhook Handled', 200);
|
||||
}
|
||||
|
||||
protected function handleInvoicePaymentFailed(array $payload): Response
|
||||
{
|
||||
$stripeUserId = $payload['data']['object']['customer'];
|
||||
$user = User::where('stripe_id', $stripeUserId)->first();
|
||||
|
||||
$reason = $payload['data']['object']['billing_reason'];
|
||||
$shouldNotify =
|
||||
$reason === StripeInvoice::BILLING_REASON_SUBSCRIPTION_CYCLE ||
|
||||
$reason === StripeInvoice::BILLING_REASON_SUBSCRIPTION_THRESHOLD;
|
||||
|
||||
if ($user && $shouldNotify) {
|
||||
$stripeSubscription = $user
|
||||
->subscriptions()
|
||||
->where('gateway_name', 'stripe')
|
||||
->first();
|
||||
if ($stripeSubscription) {
|
||||
$user->notify(new PaymentFailed($stripeSubscription));
|
||||
}
|
||||
}
|
||||
|
||||
return response('Webhook handled', 200);
|
||||
}
|
||||
|
||||
protected function handleSubscriptionCreatedAndUpdated(array $payload)
|
||||
{
|
||||
$stripeSubscriptions = $payload['data']['object'];
|
||||
|
||||
// initial payment failed and 24 hours passed, subscription can't be renewed anymore
|
||||
if (
|
||||
$stripeSubscriptions['status'] ===
|
||||
StripeSubscription::STATUS_INCOMPLETE_EXPIRED
|
||||
) {
|
||||
$this->deleteSubscription($payload);
|
||||
// sync subscription with latest data on stripe, regardless of event type
|
||||
} else {
|
||||
$this->stripe->subscriptions->sync($stripeSubscriptions['id']);
|
||||
}
|
||||
|
||||
return response('Webhook Handled', 200);
|
||||
}
|
||||
|
||||
protected function deleteSubscription(array $payload)
|
||||
{
|
||||
$subscription = Subscription::where(
|
||||
'gateway_id',
|
||||
$payload['data']['object']['id'],
|
||||
)->first();
|
||||
|
||||
$subscription?->cancelAndDelete();
|
||||
|
||||
return response('Webhook handled', 200);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user