laravel-shop/docs/02-stripe.md

13 KiB

Stripe Integration

Overview

The Laravel Shop package includes Stripe integration for:

  • Syncing products from Stripe to your database
  • Syncing prices from Stripe
  • Associating products with Stripe product IDs
  • Automatic price synchronization

Configuration

Environment Setup

Add to your .env:

SHOP_STRIPE_ENABLED=true
SHOP_STRIPE_SYNC_PRICES=true
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...

Config File

Update config/shop.php:

'stripe' => [
    'enabled' => env('SHOP_STRIPE_ENABLED', false),
    'sync_prices' => env('SHOP_STRIPE_SYNC_PRICES', true),
    'api_version' => '2023-10-16',
],

Syncing Products from Stripe

Sync Individual Product

use Blax\Shop\Services\StripeService;
use Stripe\Product as StripeProduct;

// Get Stripe product
$stripeProduct = StripeProduct::retrieve('prod_xxxxx');

// Sync to local database
$product = StripeService::syncProductDown($stripeProduct);

// This creates/updates a Product with:
// - stripe_product_id
// - slug (generated from name)
// - type
// - virtual flag
// - status (based on Stripe active status)
// - name (localized)
// - features (if available)

Sync Product Prices

// Sync all prices for a product
StripeService::syncProductPricesDown($product);

// This creates/updates ProductPrice records with:
// - stripe_price_id
// - name (from Stripe nickname)
// - type (one_time or recurring)
// - unit_amount (price in cents)
// - currency
// - billing_scheme
// - interval (for recurring)
// - interval_count (for recurring)
// - trial_period_days (for recurring)

Manual Product Creation with Stripe

Create Product and Sync to Stripe

use Blax\Shop\Models\Product;
use Stripe\Stripe;
use Stripe\Product as StripeProduct;
use Stripe\Price;

Stripe::setApiKey(config('services.stripe.secret'));

// Create local product first
$product = Product::create([
    'slug' => 'premium-plan',
    'status' => 'published',
]);

$product->setLocalized('name', 'Premium Plan', 'en');
$product->setLocalized('description', 'Access to all premium features', 'en');

// Create in Stripe
$stripeProduct = StripeProduct::create([
    'name' => $product->getLocalized('name'),
    'description' => $product->getLocalized('description'),
    'metadata' => [
        'product_id' => $product->id,
    ],
]);

// Save Stripe product ID
$product->update([
    'stripe_product_id' => $stripeProduct->id,
]);

// Create price in Stripe
$stripePrice = Price::create([
    'product' => $stripeProduct->id,
    'unit_amount' => 2999, // $29.99
    'currency' => 'usd',
    'recurring' => [
        'interval' => 'month',
    ],
]);

// Create local price
ProductPrice::create([
    'purchasable_type' => Product::class,
    'purchasable_id' => $product->id,
    'stripe_price_id' => $stripePrice->id,
    'currency' => 'usd',
    'unit_amount' => 2999,
    'type' => 'recurring',
    'interval' => 'month',
    'interval_count' => 1,
    'is_default' => true,
    'active' => true,
]);

Automatic Syncing with Events

You can set up event listeners to automatically sync products to Stripe when they're created or updated.

Create Event Listener

// app/Listeners/SyncProductToStripe.php
namespace App\Listeners;

use Blax\Shop\Events\ProductCreated;
use Stripe\Stripe;
use Stripe\Product as StripeProduct;

class SyncProductToStripe
{
    public function handle(ProductCreated $event)
    {
        if (!config('shop.stripe.enabled')) {
            return;
        }

        $product = $event->product;

        // Skip if already has Stripe ID
        if ($product->stripe_product_id) {
            return;
        }

        Stripe::setApiKey(config('services.stripe.secret'));

        $stripeProduct = StripeProduct::create([
            'name' => $product->getLocalized('name') ?? $product->slug,
            'description' => $product->getLocalized('description'),
            'metadata' => [
                'product_id' => $product->id,
                'sku' => $product->sku,
            ],
        ]);

        $product->update([
            'stripe_product_id' => $stripeProduct->id,
        ]);
    }
}

Register Event Listener

// app/Providers/EventServiceProvider.php
use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated;
use App\Listeners\SyncProductToStripe;
use App\Listeners\UpdateStripeProduct;

protected $listen = [
    ProductCreated::class => [
        SyncProductToStripe::class,
    ],
    ProductUpdated::class => [
        UpdateStripeProduct::class,
    ],
];

Working with Stripe Prices

One-Time Prices

use Stripe\Stripe;
use Stripe\Price;

Stripe::setApiKey(config('services.stripe.secret'));

// Create one-time price in Stripe
$stripePrice = Price::create([
    'product' => $product->stripe_product_id,
    'unit_amount' => 4999, // $49.99
    'currency' => 'usd',
]);

// Create local price
ProductPrice::create([
    'purchasable_type' => Product::class,
    'purchasable_id' => $product->id,
    'stripe_price_id' => $stripePrice->id,
    'currency' => 'usd',
    'unit_amount' => 4999,
    'type' => 'one_time',
    'is_default' => true,
    'active' => true,
]);

Recurring Prices

// Monthly subscription
$stripePrice = Price::create([
    'product' => $product->stripe_product_id,
    'unit_amount' => 999, // $9.99/month
    'currency' => 'usd',
    'recurring' => [
        'interval' => 'month',
        'interval_count' => 1,
        'trial_period_days' => 7,
    ],
]);

// Create local price
ProductPrice::create([
    'purchasable_type' => Product::class,
    'purchasable_id' => $product->id,
    'stripe_price_id' => $stripePrice->id,
    'currency' => 'usd',
    'unit_amount' => 999,
    'type' => 'recurring',
    'interval' => 'month',
    'interval_count' => 1,
    'trial_period_days' => 7,
    'is_default' => true,
    'active' => true,
]);

Multiple Currency Prices

// USD price
$usdPrice = Price::create([
    'product' => $product->stripe_product_id,
    'unit_amount' => 2999,
    'currency' => 'usd',
    'recurring' => ['interval' => 'month'],
]);

ProductPrice::create([
    'purchasable_type' => Product::class,
    'purchasable_id' => $product->id,
    'stripe_price_id' => $usdPrice->id,
    'currency' => 'usd',
    'unit_amount' => 2999,
    'type' => 'recurring',
    'interval' => 'month',
    'interval_count' => 1,
    'is_default' => true,
    'active' => true,
]);

// EUR price
$eurPrice = Price::create([
    'product' => $product->stripe_product_id,
    'unit_amount' => 2499,
    'currency' => 'eur',
    'recurring' => ['interval' => 'month'],
]);

ProductPrice::create([
    'purchasable_type' => Product::class,
    'purchasable_id' => $product->id,
    'stripe_price_id' => $eurPrice->id,
    'currency' => 'eur',
    'unit_amount' => 2499,
    'type' => 'recurring',
    'interval' => 'month',
    'interval_count' => 1,
    'is_default' => false,
    'active' => true,
]);

Stripe Checkout Integration

Create Checkout Session

use Stripe\Stripe;
use Stripe\Checkout\Session;

Stripe::setApiKey(config('services.stripe.secret'));

Route::post('/checkout', function () {
    $user = auth()->user();
    $cartItems = $user->cartItems()->with('purchasable.prices')->get();

    // Build line items from cart
    $lineItems = $cartItems->map(function ($item) {
        $price = $item->purchasable->defaultPrice()->first();
        
        return [
            'price' => $price->stripe_price_id,
            'quantity' => $item->quantity,
        ];
    })->toArray();

    // Create checkout session
    $session = Session::create([
        'payment_method_types' => ['card'],
        'line_items' => $lineItems,
        'mode' => 'payment', // or 'subscription' for recurring
        'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
        'cancel_url' => route('checkout.cancel'),
        'customer_email' => $user->email,
        'metadata' => [
            'user_id' => $user->id,
            'cart_id' => $user->currentCart()->id,
        ],
    ]);

    return redirect($session->url);
});

Handle Successful Payment

Route::get('/checkout/success', function (Request $request) {
    $sessionId = $request->get('session_id');
    
    Stripe::setApiKey(config('services.stripe.secret'));
    $session = Session::retrieve($sessionId);

    // Verify payment succeeded
    if ($session->payment_status === 'paid') {
        $user = auth()->user();
        
        // Convert cart to purchases
        $purchases = $user->checkoutCart();
        
        // Store charge ID
        foreach ($purchases as $purchase) {
            $purchase->update([
                'status' => 'completed',
                'charge_id' => $session->payment_intent,
                'amount_paid' => $session->amount_total / 100,
            ]);
        }

        return view('checkout.success', compact('purchases'));
    }

    return redirect()->route('cart.index')
        ->with('error', 'Payment was not successful');
});

Webhook Handling

Register Webhook Endpoint

// routes/web.php
Route::post(
    '/stripe/webhook',
    [StripeWebhookController::class, 'handleWebhook']
)->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

Handle Webhooks

// app/Http/Controllers/StripeWebhookController.php
namespace App\Http\Controllers;

use Stripe\Stripe;
use Stripe\Webhook;
use Illuminate\Http\Request;

class StripeWebhookController extends Controller
{
    public function handleWebhook(Request $request)
    {
        Stripe::setApiKey(config('services.stripe.secret'));

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

        // Handle the event
        switch ($event->type) {
            case 'checkout.session.completed':
                $this->handleCheckoutComplete($event->data->object);
                break;
            
            case 'product.created':
            case 'product.updated':
                $this->handleProductUpdate($event->data->object);
                break;
            
            case 'price.created':
            case 'price.updated':
                $this->handlePriceUpdate($event->data->object);
                break;
        }

        return response()->json(['success' => true]);
    }

    protected function handleCheckoutComplete($session)
    {
        // Find purchase by session metadata
        $userId = $session->metadata->user_id ?? null;
        $cartId = $session->metadata->cart_id ?? null;

        if ($userId && $cartId) {
            // Mark purchases as completed
            ProductPurchase::where('cart_id', $cartId)
                ->update([
                    'status' => 'completed',
                    'charge_id' => $session->payment_intent,
                    'amount_paid' => $session->amount_total / 100,
                ]);
        }
    }

    protected function handleProductUpdate($stripeProduct)
    {
        StripeService::syncProductDown($stripeProduct);
    }

    protected function handlePriceUpdate($stripePrice)
    {
        // Update local price
        $price = ProductPrice::where('stripe_price_id', $stripePrice->id)->first();
        
        if ($price) {
            $price->update([
                'active' => $stripePrice->active,
                'unit_amount' => $stripePrice->unit_amount,
            ]);
        }
    }
}

Best Practices

1. Always Use Stripe Price IDs

When integrating with Stripe Checkout or subscriptions, always use Stripe Price IDs:

$price = $product->defaultPrice()->first();
$stripePriceId = $price->stripe_price_id;

2. Keep Prices in Sync

Use webhooks or scheduled commands to keep prices synchronized:

// app/Console/Commands/SyncStripePrices.php
use Stripe\Stripe;
use Stripe\Product as StripeProduct;

Stripe::setApiKey(config('services.stripe.secret'));

Product::whereNotNull('stripe_product_id')->each(function ($product) {
    StripeService::syncProductPricesDown($product);
});

3. Store Stripe References

Always store Stripe IDs for traceability:

$product->update([
    'stripe_product_id' => $stripeProduct->id,
]);

$price->update([
    'stripe_price_id' => $stripePrice->id,
]);

$purchase->update([
    'charge_id' => $paymentIntent->id,
]);

4. Handle Errors Gracefully

try {
    $stripeProduct = StripeProduct::create([
        'name' => $product->getLocalized('name'),
    ]);
} catch (\Stripe\Exception\ApiErrorException $e) {
    \Log::error('Stripe API error: ' . $e->getMessage());
    // Handle error appropriately
}