first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
<?php
namespace Common\Billing\Gateways\Actions;
use Common\Billing\Gateways\Paypal\Paypal;
use Common\Billing\Gateways\Stripe\Stripe;
use Common\Billing\Models\Product;
class SyncProductOnEnabledGateways
{
public function __construct(
protected Stripe $stripe,
protected Paypal $paypal
) {
}
public function execute(Product $product): void
{
@ini_set('max_execution_time', 300);
if ($this->stripe->isEnabled()) {
$this->stripe->syncPlan($product);
}
if ($this->paypal->isEnabled()) {
$this->paypal->syncPlan($product);
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Common\Billing\Gateways\Contracts;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Common\Billing\Subscription;
interface CommonSubscriptionGatewayActions
{
public function isEnabled(): bool;
/**
* Sync plan from local database with the gateway
*/
public function syncPlan(Product $product): bool;
public function deletePlan(Product $product): bool;
public function changePlan(
Subscription $subscription,
Product $newProduct,
Price $newPrice,
): bool;
public function cancelSubscription(
Subscription $subscription,
bool $atPeriodEnd = true,
): bool;
public function resumeSubscription(
Subscription $subscription,
array $gatewayParams = [],
): bool;
public function isSubscriptionIncomplete(Subscription $subscription): bool;
public function isSubscriptionPastDue(Subscription $subscription): bool;
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Common\Billing\Gateways\Paypal;
use Carbon\Carbon;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
trait InteractsWithPaypalRestApi
{
protected string|null $accessToken = null;
protected Carbon|null $tokenExpires = null;
public function paypal(): PendingRequest
{
$baseUrl = settings('billing.paypal_test_mode')
? 'https://api-m.sandbox.paypal.com/v1'
: 'https://api-m.paypal.com/v1';
if (
!$this->accessToken ||
$this->tokenExpires->lessThan(Carbon::now())
) {
$clientId = config('services.paypal.client_id');
$secret = config('services.paypal.secret');
$response = Http::withBasicAuth($clientId, $secret)
->throw()
->asForm()
->post("$baseUrl/oauth2/token", [
'grant_type' => 'client_credentials',
]);
if (!$response->successful()) {
$response->throw();
}
$this->accessToken = $response['access_token'];
$this->tokenExpires = Carbon::now()->addSeconds(
$response['expires_in'],
);
}
return Http::withToken($this->accessToken)->baseUrl($baseUrl);
}
}

View File

@@ -0,0 +1,70 @@
<?php namespace Common\Billing\Gateways\Paypal;
use Common\Billing\Gateways\Contracts\CommonSubscriptionGatewayActions;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Common\Billing\Subscription;
use Common\Settings\Settings;
class Paypal implements CommonSubscriptionGatewayActions
{
use InteractsWithPaypalRestApi;
public function __construct(
protected Settings $settings,
protected PaypalPlans $plans,
public PaypalSubscriptions $subscriptions,
) {
}
public function isSubscriptionIncomplete(Subscription $subscription): bool
{
return $this->subscriptions->isIncomplete($subscription);
}
public function isSubscriptionPastDue(Subscription $subscription): bool
{
return $this->subscriptions->isPastDue($subscription);
}
public function isEnabled(): bool
{
return (bool) app(Settings::class)->get('billing.paypal.enable');
}
public function syncPlan(Product $product): bool
{
return $this->plans->sync($product);
}
public function deletePlan(Product $product): bool
{
return $this->plans->delete($product);
}
public function changePlan(
Subscription $subscription,
Product $newProduct,
Price $newPrice,
): bool {
return $this->subscriptions->changePlan(
$subscription,
$newProduct,
$newPrice,
);
}
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);
}
}

View File

@@ -0,0 +1,33 @@
<?php namespace Common\Billing\Gateways\Paypal;
use Common\Billing\Subscription;
use Common\Core\BaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
class PaypalController extends BaseController
{
public function __construct(
protected Request $request,
protected Subscription $subscription,
protected Paypal $paypal,
) {
$this->middleware('auth');
}
public function storeSubscriptionDetailsLocally(): Response|JsonResponse
{
$data = $this->validate($this->request, [
'paypal_subscription_id' => 'required|string',
]);
$this->paypal->subscriptions->sync(
$data['paypal_subscription_id'],
Auth::id(),
);
return $this->success();
}
}

View File

@@ -0,0 +1,99 @@
<?php namespace Common\Billing\Gateways\Paypal;
use Common\Billing\GatewayException;
use Common\Billing\Models\Price;
use Common\Billing\Models\Product;
use Illuminate\Support\Str;
class PaypalPlans
{
use InteractsWithPaypalRestApi;
public function sync(Product $product): bool
{
$product->load('prices');
// there's only one global product on PayPal and not one per plan as on stripe
$productId = config('services.paypal.product_id');
$response = $this->paypal()->get("catalogs/products/$productId");
if (!$response->successful()) {
$this->paypal()->post('catalogs/products', [
'id' => $productId,
'name' => config('services.paypal.product_name'),
'type' => 'DIGITAL',
]);
}
// create any local product prices (plans) on PayPal, that don't exist there already
$product->prices->each(function (Price $price) use ($product) {
if (
!$price->paypal_id ||
!$this->planExistsOnPaypal($price->paypal_id)
) {
$this->create($product, $price);
}
});
return true;
}
protected function planExistsOnPaypal(string $paypalPlanId): bool
{
$response = $this->paypal()->get("billing/plans/{$paypalPlanId}");
return $response->successful();
}
protected function create(Product $product, Price $price): bool
{
$response = $this->paypal()->post('billing/plans', [
'name' => $product->name,
'product_id' => config('services.paypal.product_id'),
'status' => 'ACTIVE',
'payment_preferences' => [
'auto_bill_outstanding' => true,
'payment_failure_threshold' => 2,
],
'billing_cycles' => [
[
'frequency' => [
'interval_unit' => Str::upper($price->interval),
'interval_count' => $price->interval_count,
],
'tenure_type' => 'REGULAR',
'sequence' => 1,
'total_cycles' => 0, // infinite
'pricing_scheme' => [
'fixed_price' => [
'value' => number_format(
$price->amount,
2,
'.',
'',
),
'currency_code' => Str::upper($price->currency),
],
],
],
],
]);
if (!$response->successful()) {
throw new GatewayException('Could not create plan on PayPal');
}
$price->fill(['paypal_id' => $response['id']])->save();
return true;
}
public function delete(Product $product): bool
{
$statuses = $product->prices->map(function (Price $price) {
$response = $this->paypal()->post(
"billing/plans/{$price->paypal_id}/deactivate",
);
return $response->successful();
});
return $statuses->every(fn($status) => $status);
}
}

View File

@@ -0,0 +1,163 @@
<?php namespace Common\Billing\Gateways\Paypal;
use App\Models\User;
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 Illuminate\Support\Carbon;
class PaypalSubscriptions
{
use InteractsWithPaypalRestApi;
public function isIncomplete(Subscription $subscription): bool
{
return $subscription->gateway_status === 'APPROVAL_PENDING' ||
$subscription->gateway_status === 'APPROVED';
}
public function isPastDue(Subscription $subscription): bool
{
// no way to check this via PayPal API
return false;
}
public function sync(
string $paypalSubscriptionId,
?int $userId = null,
): void {
$response = $this->paypal()->get(
"billing/subscriptions/$paypalSubscriptionId",
);
$price = Price::where('paypal_id', $response['plan_id'])->firstOrFail();
if ($userId != null) {
$user = User::where('id', $userId)->firstOrFail();
$user->update(['paypal_id' => $response['subscriber']['payer_id']]);
} else {
$user = User::where(
'paypal_id',
$response['subscriber']['payer_id'],
)->firstOrFail();
}
$subscription = $user->subscriptions()->firstOrNew([
'gateway_name' => 'paypal',
'gateway_id' => $response['id'],
]);
if (
in_array($response['status'], ['CANCELLED', 'EXPIRED', 'SUSPENDED'])
) {
$subscription->markAsCancelled();
}
$data = [
'price_id' => $price->id,
'product_id' => $price->product_id,
'gateway_name' => 'paypal',
'gateway_id' => $paypalSubscriptionId,
'gateway_status' => $response['status'],
'renews_at' =>
$response['status'] === 'ACTIVE' &&
isset($response['billing_info']['next_billing_time'])
? Carbon::parse(
$response['billing_info']['next_billing_time'],
)
: null,
];
if ($response['status'] === 'ACTIVE') {
$data['ends_at'] = null;
}
$subscription->fill($data)->save();
$this->createOrUpdateInvoice($subscription, $response->json());
}
public function createOrUpdateInvoice(
Subscription $subscription,
array $paypalSubscription,
): void {
// subscription is no longer active, no need to update invoice
if (!isset($paypalSubscription['billing_info']['next_billing_time'])) {
return;
}
$startTime = Carbon::parse($paypalSubscription['start_time']);
$renewsAt = Carbon::parse(
$paypalSubscription['billing_info']['next_billing_time'],
);
$isPaid = $paypalSubscription['status'] === 'ACTIVE';
$existing = Invoice::whereBetween('created_at', [
$startTime,
$renewsAt,
])->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,
'paid' => $isPaid,
]);
}
}
public function changePlan(
Subscription $subscription,
Product $newProduct,
Price $newPrice,
): bool {
$this->paypal()->post(
"billing/subscriptions/$subscription->gateway_id/revise",
[
'plan_id' => $newPrice->paypal_id,
],
);
$this->sync($subscription->gateway_id);
return true;
}
public function cancel(
Subscription $subscription,
$atPeriodEnd = true,
): bool {
if ($atPeriodEnd) {
$this->paypal()->post(
"billing/subscriptions/$subscription->gateway_id/suspend",
['reason' => 'User requested cancellation.'],
);
} else {
$this->paypal()->post(
"billing/subscriptions/$subscription->gateway_id/cancel",
['reason' => 'Subscription deleted locally.'],
);
}
$this->sync($subscription->gateway_id);
return true;
}
public function resume(Subscription $subscription, array $params): bool
{
$this->paypal()->post(
"billing/subscriptions/$subscription->gateway_id/activate",
['reason' => 'Subscription resumed by user.'],
);
$this->sync($subscription->gateway_id);
return true;
}
}

View File

@@ -0,0 +1,103 @@
<?php namespace Common\Billing\Gateways\Paypal;
use Common\Billing\GatewayException;
use Common\Billing\Notifications\PaymentFailed;
use Common\Billing\Subscription;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Arr;
use Symfony\Component\HttpFoundation\Response;
class PaypalWebhookController extends Controller
{
use InteractsWithPaypalRestApi;
public function __construct(
protected Subscription $subscription,
protected Paypal $paypal,
) {
}
public function handleWebhook(Request $request): Response
{
$payload = $request->all();
if (
config('common.site.verify_paypal_webhook') &&
!$this->webhookIsValid()
) {
return response('Webhook validation failed', 422);
}
return match ($payload['event_type']) {
'BILLING.SUBSCRIPTION.PAYMENT.FAILED'
=> $this->handleInvoicePaymentFailed($payload),
'BILLING.SUBSCRIPTION.ACTIVATED',
'BILLING.SUBSCRIPTION.CANCELLED',
'BILLING.SUBSCRIPTION.EXPIRED',
'BILLING.SUBSCRIPTION.SUSPENDED'
=> $this->handleSubscriptionStateChanged($payload),
'PAYMENT.SALE.COMPLETED' => $this->handleSaleCompleted($payload),
default => response('Webhook Handled', 200),
};
}
protected function handleInvoicePaymentFailed(array $payload): Response
{
$paypalSubscriptionId = Arr::get(
$payload,
'resource.billing_agreement_id',
);
$subscription = $this->subscription
->where('gateway_id', $paypalSubscriptionId)
->first();
$subscription?->user->notify(new PaymentFailed($subscription));
return response('Webhook handled', 200);
}
protected function handleSaleCompleted(array $payload): Response
{
$this->paypal->subscriptions->sync(
$payload['resource']['billing_agreement_id'],
);
return response('Webhook Handled', 200);
}
protected function handleSubscriptionStateChanged(array $payload): Response
{
$this->paypal->subscriptions->sync($payload['resource']['id']);
return response('Webhook Handled', 200);
}
protected function webhookIsValid(): bool
{
$payload = [
'auth_algo' => request()->header('PAYPAL-AUTH-ALGO'),
'cert_url' => request()->header('PAYPAL-CERT-URL'),
'transmission_id' => request()->header('PAYPAL-TRANSMISSION-ID'),
'transmission_sig' => request()->header('PAYPAL-TRANSMISSION-SIG'),
'transmission_time' => request()->header(
'PAYPAL-TRANSMISSION-TIME',
),
'webhook_id' => config('services.paypal.webhook_id'),
'webhook_event' => request()->all(),
];
$response = $this->paypal()->post(
'notifications/verify-webhook-signature',
$payload,
);
if (!$response->successful()) {
throw new GatewayException(
"Could not validate paypal webhook: {$response->body()}",
);
}
return $response['verification_status'] === 'SUCCESS';
}
}

View 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();
}
}

View 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']}",
]);
}
}

View 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();
}
}

View 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;
}
}
}

View 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;
}
}

View 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);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Common\Billing\Gateways;
use Common\Billing\GatewayException;
use Common\Billing\Gateways\Actions\SyncProductOnEnabledGateways;
use Common\Billing\Models\Product;
use Common\Core\BaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class SyncProductsController extends BaseController
{
public function syncProducts(): Response|JsonResponse
{
$products = Product::where('free', false)
->whereHas('prices')
->get();
foreach ($products as $product) {
try {
app(SyncProductOnEnabledGateways::class)->execute($product);
} catch (GatewayException $e) {
return $this->error(
"Could not sync \"$product->name\" product: {$e->getMessage()}",
);
}
}
return $this->success();
}
}