490 lines
13 KiB
Markdown
490 lines
13 KiB
Markdown
# Subscriptions
|
|
|
|
## Creating Subscription Products
|
|
|
|
### Basic Subscription Product
|
|
|
|
```php
|
|
use Blax\Shop\Models\Product;
|
|
|
|
$subscription = Product::create([
|
|
'slug' => 'monthly-premium',
|
|
'sku' => 'SUB-PREM-M',
|
|
'type' => 'simple',
|
|
'price' => 29.99,
|
|
'virtual' => true,
|
|
'downloadable' => false,
|
|
'manage_stock' => false, // Subscriptions don't need stock management
|
|
'status' => 'published',
|
|
'meta' => [
|
|
'billing_period' => 'month',
|
|
'billing_interval' => 1,
|
|
'trial_days' => 7,
|
|
],
|
|
]);
|
|
|
|
$subscription->setLocalized('name', 'Premium Monthly Subscription', 'en');
|
|
$subscription->setLocalized('description', 'Access to all premium features', 'en');
|
|
```
|
|
|
|
### Subscription Tiers
|
|
|
|
```php
|
|
// Basic
|
|
$basic = Product::create([
|
|
'slug' => 'basic-monthly',
|
|
'price' => 9.99,
|
|
'virtual' => true,
|
|
'meta' => [
|
|
'billing_period' => 'month',
|
|
'features' => ['feature_1', 'feature_2'],
|
|
],
|
|
]);
|
|
|
|
// Pro
|
|
$pro = Product::create([
|
|
'slug' => 'pro-monthly',
|
|
'price' => 29.99,
|
|
'virtual' => true,
|
|
'meta' => [
|
|
'billing_period' => 'month',
|
|
'features' => ['feature_1', 'feature_2', 'feature_3', 'feature_4'],
|
|
],
|
|
]);
|
|
|
|
// Enterprise
|
|
$enterprise = Product::create([
|
|
'slug' => 'enterprise-monthly',
|
|
'price' => 99.99,
|
|
'virtual' => true,
|
|
'meta' => [
|
|
'billing_period' => 'month',
|
|
'features' => ['all_features', 'priority_support', 'custom_branding'],
|
|
],
|
|
]);
|
|
```
|
|
|
|
## Stripe Subscription Integration
|
|
|
|
### Create Subscription Prices in Stripe
|
|
|
|
```php
|
|
use Stripe\Stripe;
|
|
use Stripe\Product as StripeProduct;
|
|
use Stripe\Price;
|
|
|
|
Stripe::setApiKey(config('services.stripe.secret'));
|
|
|
|
// Create Stripe product
|
|
$stripeProduct = StripeProduct::create([
|
|
'name' => $subscription->getLocalized('name'),
|
|
'description' => $subscription->getLocalized('description'),
|
|
'metadata' => [
|
|
'product_id' => $subscription->id,
|
|
],
|
|
]);
|
|
|
|
// Create recurring price
|
|
$price = Price::create([
|
|
'product' => $stripeProduct->id,
|
|
'unit_amount' => $subscription->price * 100,
|
|
'currency' => 'usd',
|
|
'recurring' => [
|
|
'interval' => 'month',
|
|
'interval_count' => 1,
|
|
],
|
|
]);
|
|
|
|
// Save Stripe IDs
|
|
$subscription->update([
|
|
'stripe_product_id' => $stripeProduct->id,
|
|
]);
|
|
|
|
ProductPrice::create([
|
|
'product_id' => $subscription->id,
|
|
'currency' => 'USD',
|
|
'price' => $subscription->price,
|
|
'stripe_price_id' => $price->id,
|
|
'is_default' => true,
|
|
]);
|
|
```
|
|
|
|
### Create Subscription Checkout
|
|
|
|
```php
|
|
use Stripe\Checkout\Session;
|
|
|
|
$subscription = Product::find($subscriptionId);
|
|
$priceId = $subscription->prices()
|
|
->where('currency', 'USD')
|
|
->first()
|
|
->stripe_price_id;
|
|
|
|
$session = Session::create([
|
|
'payment_method_types' => ['card'],
|
|
'line_items' => [[
|
|
'price' => $priceId,
|
|
'quantity' => 1,
|
|
]],
|
|
'mode' => 'subscription',
|
|
'success_url' => route('subscription.success') . '?session_id={CHECKOUT_SESSION_ID}',
|
|
'cancel_url' => route('subscription.cancel'),
|
|
'client_reference_id' => auth()->id(),
|
|
'customer_email' => auth()->user()->email,
|
|
'subscription_data' => [
|
|
'trial_period_days' => $subscription->meta['trial_days'] ?? null,
|
|
'metadata' => [
|
|
'product_id' => $subscription->id,
|
|
'user_id' => auth()->id(),
|
|
],
|
|
],
|
|
]);
|
|
|
|
return redirect($session->url);
|
|
```
|
|
|
|
## Handling Subscription Webhooks
|
|
|
|
### Webhook Controller
|
|
|
|
```php
|
|
namespace App\Http\Controllers;
|
|
|
|
use Stripe\Webhook;
|
|
use Blax\Shop\Models\Product;
|
|
use Blax\Shop\Models\ProductPurchase;
|
|
|
|
class StripeSubscriptionWebhookController extends Controller
|
|
{
|
|
public function handle(Request $request)
|
|
{
|
|
$payload = $request->getContent();
|
|
$sigHeader = $request->header('Stripe-Signature');
|
|
$webhookSecret = config('services.stripe.webhook_secret');
|
|
|
|
try {
|
|
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
|
|
} catch (\Exception $e) {
|
|
return response()->json(['error' => 'Invalid signature'], 400);
|
|
}
|
|
|
|
switch ($event->type) {
|
|
case 'customer.subscription.created':
|
|
$this->handleSubscriptionCreated($event->data->object);
|
|
break;
|
|
|
|
case 'customer.subscription.updated':
|
|
$this->handleSubscriptionUpdated($event->data->object);
|
|
break;
|
|
|
|
case 'customer.subscription.deleted':
|
|
$this->handleSubscriptionCancelled($event->data->object);
|
|
break;
|
|
|
|
case 'invoice.payment_succeeded':
|
|
$this->handlePaymentSucceeded($event->data->object);
|
|
break;
|
|
|
|
case 'invoice.payment_failed':
|
|
$this->handlePaymentFailed($event->data->object);
|
|
break;
|
|
}
|
|
|
|
return response()->json(['status' => 'success']);
|
|
}
|
|
|
|
protected function handleSubscriptionCreated($subscription)
|
|
{
|
|
$productId = $subscription->metadata->product_id ?? null;
|
|
$userId = $subscription->metadata->user_id ?? null;
|
|
|
|
if (!$productId || !$userId) {
|
|
return;
|
|
}
|
|
|
|
$product = Product::find($productId);
|
|
$user = User::find($userId);
|
|
|
|
// Create purchase record
|
|
$purchase = ProductPurchase::create([
|
|
'product_id' => $product->id,
|
|
'purchasable_type' => get_class($user),
|
|
'purchasable_id' => $user->id,
|
|
'quantity' => 1,
|
|
'status' => $subscription->status,
|
|
'meta' => [
|
|
'stripe_subscription_id' => $subscription->id,
|
|
'stripe_customer_id' => $subscription->customer,
|
|
'current_period_end' => $subscription->current_period_end,
|
|
'trial_end' => $subscription->trial_end,
|
|
],
|
|
]);
|
|
|
|
// Trigger subscription started actions
|
|
$product->callActions('subscription_started', $purchase, [
|
|
'subscription' => $subscription,
|
|
'user' => $user,
|
|
]);
|
|
|
|
// Grant access
|
|
$user->subscriptions()->create([
|
|
'product_id' => $product->id,
|
|
'stripe_subscription_id' => $subscription->id,
|
|
'status' => 'active',
|
|
'trial_ends_at' => $subscription->trial_end ?
|
|
Carbon::createFromTimestamp($subscription->trial_end) : null,
|
|
'ends_at' => Carbon::createFromTimestamp($subscription->current_period_end),
|
|
]);
|
|
}
|
|
|
|
protected function handleSubscriptionUpdated($subscription)
|
|
{
|
|
$purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscription->id)->first();
|
|
|
|
if ($purchase) {
|
|
$purchase->update([
|
|
'status' => $subscription->status,
|
|
'meta' => array_merge($purchase->meta, [
|
|
'current_period_end' => $subscription->current_period_end,
|
|
]),
|
|
]);
|
|
|
|
// Update user subscription
|
|
$userSubscription = $purchase->purchasable->subscriptions()
|
|
->where('stripe_subscription_id', $subscription->id)
|
|
->first();
|
|
|
|
if ($userSubscription) {
|
|
$userSubscription->update([
|
|
'status' => $subscription->status === 'active' ? 'active' : 'inactive',
|
|
'ends_at' => Carbon::createFromTimestamp($subscription->current_period_end),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected function handleSubscriptionCancelled($subscription)
|
|
{
|
|
$purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscription->id)->first();
|
|
|
|
if ($purchase) {
|
|
$purchase->update([
|
|
'status' => 'cancelled',
|
|
]);
|
|
|
|
// Revoke access
|
|
$userSubscription = $purchase->purchasable->subscriptions()
|
|
->where('stripe_subscription_id', $subscription->id)
|
|
->first();
|
|
|
|
if ($userSubscription) {
|
|
$userSubscription->update([
|
|
'status' => 'cancelled',
|
|
'ends_at' => now(),
|
|
]);
|
|
}
|
|
|
|
// Trigger cancellation actions
|
|
$purchase->product->callActions('subscription_cancelled', $purchase, [
|
|
'subscription' => $subscription,
|
|
]);
|
|
}
|
|
}
|
|
|
|
protected function handlePaymentSucceeded($invoice)
|
|
{
|
|
$subscriptionId = $invoice->subscription;
|
|
$purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscriptionId)->first();
|
|
|
|
if ($purchase) {
|
|
// Trigger renewal actions
|
|
$purchase->product->callActions('subscription_renewed', $purchase, [
|
|
'invoice' => $invoice,
|
|
]);
|
|
}
|
|
}
|
|
|
|
protected function handlePaymentFailed($invoice)
|
|
{
|
|
$subscriptionId = $invoice->subscription;
|
|
$purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscriptionId)->first();
|
|
|
|
if ($purchase) {
|
|
// Trigger payment failed actions
|
|
$purchase->product->callActions('subscription_payment_failed', $purchase, [
|
|
'invoice' => $invoice,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## User Subscription Model
|
|
|
|
```php
|
|
// app/Models/UserSubscription.php
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Blax\Shop\Models\Product;
|
|
|
|
class UserSubscription extends Model
|
|
{
|
|
protected $fillable = [
|
|
'user_id',
|
|
'product_id',
|
|
'stripe_subscription_id',
|
|
'status',
|
|
'trial_ends_at',
|
|
'ends_at',
|
|
];
|
|
|
|
protected $casts = [
|
|
'trial_ends_at' => 'datetime',
|
|
'ends_at' => 'datetime',
|
|
];
|
|
|
|
public function user()
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
public function product()
|
|
{
|
|
return $this->belongsTo(Product::class);
|
|
}
|
|
|
|
public function isActive()
|
|
{
|
|
return $this->status === 'active' &&
|
|
(!$this->ends_at || $this->ends_at->isFuture());
|
|
}
|
|
|
|
public function onTrial()
|
|
{
|
|
return $this->trial_ends_at && $this->trial_ends_at->isFuture();
|
|
}
|
|
|
|
public function cancel()
|
|
{
|
|
if (!$this->stripe_subscription_id) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
|
|
$stripe->subscriptions->cancel($this->stripe_subscription_id);
|
|
|
|
$this->update([
|
|
'status' => 'cancelled',
|
|
'ends_at' => now(),
|
|
]);
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Checking Subscription Access
|
|
|
|
```php
|
|
// Add to User model
|
|
public function subscriptions()
|
|
{
|
|
return $this->hasMany(UserSubscription::class);
|
|
}
|
|
|
|
public function hasActiveSubscription($productSlug = null)
|
|
{
|
|
$query = $this->subscriptions()->where('status', 'active');
|
|
|
|
if ($productSlug) {
|
|
$query->whereHas('product', function ($q) use ($productSlug) {
|
|
$q->where('slug', $productSlug);
|
|
});
|
|
}
|
|
|
|
return $query->where(function ($q) {
|
|
$q->whereNull('ends_at')
|
|
->orWhere('ends_at', '>', now());
|
|
})
|
|
->exists();
|
|
}
|
|
|
|
// Usage in controllers/middleware
|
|
if (!auth()->user()->hasActiveSubscription('premium-monthly')) {
|
|
abort(403, 'Active subscription required');
|
|
}
|
|
```
|
|
|
|
## Subscription Management Routes
|
|
|
|
```php
|
|
// routes/web.php
|
|
Route::middleware('auth')->group(function () {
|
|
Route::get('/subscriptions', [SubscriptionController::class, 'index']);
|
|
Route::post('/subscriptions/{product}/subscribe', [SubscriptionController::class, 'subscribe']);
|
|
Route::post('/subscriptions/{subscription}/cancel', [SubscriptionController::class, 'cancel']);
|
|
Route::post('/subscriptions/{subscription}/resume', [SubscriptionController::class, 'resume']);
|
|
});
|
|
```
|
|
|
|
## Product Actions for Subscriptions
|
|
|
|
```php
|
|
use Blax\Shop\Models\ProductAction;
|
|
|
|
// Grant role on subscription
|
|
ProductAction::create([
|
|
'product_id' => $subscription->id,
|
|
'action_type' => 'grant_role',
|
|
'event' => 'subscription_started',
|
|
'config' => [
|
|
'role' => 'premium_member',
|
|
],
|
|
'active' => true,
|
|
]);
|
|
|
|
// Revoke role on cancellation
|
|
ProductAction::create([
|
|
'product_id' => $subscription->id,
|
|
'action_type' => 'revoke_role',
|
|
'event' => 'subscription_cancelled',
|
|
'config' => [
|
|
'role' => 'premium_member',
|
|
],
|
|
'active' => true,
|
|
]);
|
|
```
|
|
|
|
## Annual Subscriptions with Discount
|
|
|
|
```php
|
|
$annual = Product::create([
|
|
'slug' => 'premium-annual',
|
|
'price' => 299.99, // Save $60 vs monthly
|
|
'regular_price' => 359.88,
|
|
'sale_price' => 299.99,
|
|
'virtual' => true,
|
|
'meta' => [
|
|
'billing_period' => 'year',
|
|
'billing_interval' => 1,
|
|
'savings' => 59.89,
|
|
],
|
|
]);
|
|
|
|
// Create Stripe price
|
|
$price = Price::create([
|
|
'product' => $stripeProduct->id,
|
|
'unit_amount' => 29999,
|
|
'currency' => 'usd',
|
|
'recurring' => [
|
|
'interval' => 'year',
|
|
'interval_count' => 1,
|
|
],
|
|
]);
|
|
```
|