laravel-shop/docs/04-subscriptions.md

490 lines
13 KiB
Markdown
Raw Normal View History

2025-11-21 10:49:41 +00:00
# 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,
],
]);
```