laravel-shop/docs/04-subscriptions.md

13 KiB

Subscriptions

Creating Subscription Products

Basic Subscription Product

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

// 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

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

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

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

// 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

// 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

// 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

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

$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,
    ],
]);