I tests & documentation

This commit is contained in:
a6a2f5842 2025-11-25 17:25:20 +01:00
parent 3593a462a1
commit 82ee18b0f1
8 changed files with 1178 additions and 1367 deletions

View File

@ -1,5 +1,15 @@
# Product Management
## Overview
The Laravel Shop package provides a complete product management system with support for:
- Multi-language content through `HasMetaTranslation` trait
- Flexible pricing with `ProductPrice` model
- Stock management and reservations
- Product variants (parent/child relationships)
- Categories, attributes, and actions
- Product relations (related, upsell, cross-sell)
## Creating Products
### Minimal Product Creation
@ -15,8 +25,8 @@ $product = Product::create([
```
This will automatically:
- Generate a random slug if not provided
- Create a default name "New Product [slug]"
- Generate a random slug if not provided (e.g., 'new-product-abc12345')
- Initialize meta field as empty JSON object
- Set status to 'draft'
- Set type to 'simple'
@ -27,14 +37,12 @@ $product = Product::create([
'slug' => 'blue-hoodie',
'sku' => 'HOOD-BLU-001',
'type' => 'simple',
'price' => 49.99,
'regular_price' => 49.99,
'status' => 'published',
'is_visible' => true,
'featured' => false,
]);
// Add translated content
// Add translated content (stored in meta column)
$product->setLocalized('name', 'Blue Hoodie', 'en');
$product->setLocalized('description', 'Comfortable cotton hoodie', 'en');
$product->setLocalized('short_description', 'Cotton hoodie', 'en');
@ -54,19 +62,13 @@ $product = Product::create([
'published_at' => now(),
'sort_order' => 10,
// Pricing
'price' => 199.99,
'regular_price' => 249.99,
'sale_price' => 199.99,
// Sale Period
'sale_start' => now(),
'sale_end' => now()->addDays(7),
// Stock Management
'manage_stock' => true,
'stock_quantity' => 50,
'low_stock_threshold' => 10,
'in_stock' => true,
'stock_status' => 'instock',
// Physical Properties
'weight' => 0.5, // kg
@ -78,13 +80,6 @@ $product = Product::create([
// Tax
'tax_class' => 'standard',
// Custom Meta
'meta' => [
'brand' => 'AudioPro',
'color' => 'black',
'warranty' => '2 years',
],
]);
// Add translations
@ -93,158 +88,349 @@ $product->setLocalized('name', 'Auriculares Premium Inalámbricos', 'es');
$product->setLocalized('description', 'High-quality wireless headphones with noise cancellation', 'en');
$product->setLocalized('short_description', 'Premium wireless headphones', 'en');
// Add custom meta data
$product->meta = (object)[
'brand' => 'AudioPro',
'color' => 'black',
'warranty' => '2 years',
];
$product->save();
```
## Product Types
## Product Pricing
### Simple Product
Products use the `ProductPrice` model for flexible pricing. Each product must have at least one price to be purchasable.
### Creating Product Prices
```php
$product = Product::create([
'type' => 'simple',
'slug' => 't-shirt',
'price' => 19.99,
use Blax\Shop\Models\ProductPrice;
// Create a default price
$price = ProductPrice::create([
'purchasable_type' => Product::class,
'purchasable_id' => $product->id,
'currency' => 'USD',
'unit_amount' => 4999, // $49.99 in cents
'is_default' => true,
'active' => true,
'type' => 'one_time',
]);
// Add sale price
$price->update([
'sale_unit_amount' => 3999, // $39.99
]);
```
### Variable Product (Parent)
### Multi-Currency Pricing
```php
// USD price (default)
ProductPrice::create([
'purchasable_type' => Product::class,
'purchasable_id' => $product->id,
'currency' => 'USD',
'unit_amount' => 4999,
'is_default' => true,
'active' => true,
]);
// EUR price
ProductPrice::create([
'purchasable_type' => Product::class,
'purchasable_id' => $product->id,
'currency' => 'EUR',
'unit_amount' => 4499,
'is_default' => false,
'active' => true,
]);
```
### Recurring Prices (Subscriptions)
```php
ProductPrice::create([
'purchasable_type' => Product::class,
'purchasable_id' => $product->id,
'currency' => 'USD',
'unit_amount' => 999, // $9.99/month
'type' => 'recurring',
'interval' => 'month',
'interval_count' => 1,
'trial_period_days' => 7,
'is_default' => true,
'active' => true,
]);
```
### Get Current Price
```php
// Get the current price (considers sale prices and dates)
$currentPrice = $product->getCurrentPrice(); // Returns float
// Check if product is on sale
if ($product->isOnSale()) {
echo "On sale!";
}
// Get default price
$defaultPrice = $product->defaultPrice()->first();
```
## Stock Management
### Enable Stock Management
```php
$product->update([
'manage_stock' => true,
'low_stock_threshold' => 10,
]);
```
### Increase/Decrease Stock
```php
// Increase stock
$product->increaseStock(50);
// Decrease stock
$product->decreaseStock(1);
// Get available stock
$available = $product->getAvailableStock();
// Check if in stock
if ($product->isInStock()) {
echo "In stock!";
}
// Check if low stock
if ($product->isLowStock()) {
echo "Low stock warning!";
}
```
### Stock Reservations
```php
use Blax\Shop\Models\ProductStock;
// Reserve stock temporarily
$reservation = $product->reserveStock(
quantity: 2,
reference: $cart,
until: now()->addMinutes(15),
note: 'Cart reservation'
);
// Release reservation
$reservation->update(['status' => 'completed']);
// Get active reservations
$reservations = $product->reservations()->get();
```
### Stock History
```php
// Get all stock records
$stockRecords = $product->stocks()->get();
// Filter by type
$increases = $product->stocks()->where('type', 'increase')->get();
$decreases = $product->stocks()->where('type', 'decrease')->get();
$reservations = $product->stocks()->where('type', 'reservation')->get();
```
## Product Variants
### Create Parent Product
```php
$parent = Product::create([
'type' => 'variable',
'slug' => 'hoodie',
'price' => 49.99, // Base price
'status' => 'published',
]);
// Create variants
$parent->setLocalized('name', 'Hoodie', 'en');
```
### Create Variants
```php
// Small variant
$small = Product::create([
'type' => 'simple',
'slug' => 'hoodie-small',
'sku' => 'HOOD-S',
'parent_id' => $parent->id,
'price' => 49.99,
'status' => 'published',
]);
ProductPrice::create([
'purchasable_type' => Product::class,
'purchasable_id' => $small->id,
'currency' => 'USD',
'unit_amount' => 4999,
'is_default' => true,
'active' => true,
]);
// Medium variant
$medium = Product::create([
'type' => 'simple',
'slug' => 'hoodie-medium',
'sku' => 'HOOD-M',
'parent_id' => $parent->id,
'price' => 49.99,
'status' => 'published',
]);
$large = Product::create([
'type' => 'simple',
'slug' => 'hoodie-large',
'sku' => 'HOOD-L',
'parent_id' => $parent->id,
'price' => 54.99, // Different price
ProductPrice::create([
'purchasable_type' => Product::class,
'purchasable_id' => $medium->id,
'currency' => 'USD',
'unit_amount' => 4999,
'is_default' => true,
'active' => true,
]);
// Get all variants
$variants = $parent->children()->get();
```
### Grouped Product
## Categories
```php
$bundle = Product::create([
'type' => 'grouped',
'slug' => 'starter-bundle',
'price' => 99.99,
]);
// Link products to the bundle (handle this in your app logic)
```
### Virtual/Downloadable Product
```php
$ebook = Product::create([
'slug' => 'laravel-guide',
'price' => 29.99,
'virtual' => true,
'downloadable' => true,
'manage_stock' => false, // Virtual products don't need stock
]);
```
## Product Attributes
Add custom attributes to products:
```php
use Blax\Shop\Models\ProductAttribute;
// Add size attribute
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'size',
'value' => 'Large',
'type' => 'select',
'sort_order' => 1,
]);
// Add color attribute
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'color',
'value' => '#FF0000',
'type' => 'color',
'sort_order' => 2,
]);
// Retrieve attributes
$attributes = $product->attributes;
```
## Product Categories
### Create Categories
```php
use Blax\Shop\Models\ProductCategory;
// Create category
$category = ProductCategory::create([
'slug' => 'clothing',
'name' => 'Electronics',
'slug' => 'electronics',
'description' => 'Electronic products',
'is_visible' => true,
'sort_order' => 1,
]);
$category->setLocalized('name', 'Clothing', 'en');
// Attach product to category
$product->categories()->attach($category->id);
// Detach
$product->categories()->detach($category->id);
// Sync categories
$product->categories()->sync([
$category1->id,
$category2->id,
// Create child category
$subCategory = ProductCategory::create([
'name' => 'Headphones',
'slug' => 'headphones',
'parent_id' => $category->id,
'is_visible' => true,
'sort_order' => 1,
]);
```
## Multi-Currency Pricing
### Attach Products to Categories
```php
use Blax\Shop\Models\ProductPrice;
// Attach single category
$product->categories()->attach($category->id);
// Add EUR pricing
ProductPrice::create([
// Attach multiple categories
$product->categories()->attach([$category1->id, $category2->id]);
// Sync categories (removes others)
$product->categories()->sync([$category1->id, $category2->id]);
// Get product categories
$categories = $product->categories()->get();
```
### Query Products by Category
```php
// Get products in category
$products = Product::byCategory($category->id)->get();
// Get category tree
$tree = ProductCategory::getTree();
// Get visible categories
$visible = ProductCategory::visible()->get();
// Get root categories
$roots = ProductCategory::roots()->get();
```
## Product Attributes
### Add Attributes
```php
use Blax\Shop\Models\ProductAttribute;
// Add color attribute
ProductAttribute::create([
'product_id' => $product->id,
'currency' => 'EUR',
'price' => 39.99,
'is_default' => false,
'key' => 'Color',
'value' => 'Blue',
'sort_order' => 1,
]);
// Add GBP pricing
ProductPrice::create([
// Add size attribute
ProductAttribute::create([
'product_id' => $product->id,
'currency' => 'GBP',
'price' => 34.99,
'is_default' => false,
'key' => 'Size',
'value' => 'Large',
'sort_order' => 2,
]);
// Get all prices
$prices = $product->prices;
// Get product attributes
$attributes = $product->attributes()->get();
```
// Get price for specific currency
$eurPrice = $product->prices()->where('currency', 'EUR')->first();
## Product Actions
Product actions allow you to trigger events when certain things happen (e.g., on purchase).
### Create Product Actions
```php
use Blax\Shop\Models\ProductAction;
// Send email on purchase
ProductAction::create([
'product_id' => $product->id,
'action_type' => 'SendWelcomeEmail',
'event' => 'purchased',
'parameters' => [
'template' => 'welcome',
'delay' => 0,
],
'active' => true,
'sort_order' => 1,
]);
// Grant access on purchase
ProductAction::create([
'product_id' => $product->id,
'action_type' => 'GrantCourseAccess',
'event' => 'purchased',
'parameters' => [
'course_id' => 123,
],
'active' => true,
'sort_order' => 2,
]);
```
### Trigger Actions
```php
// Actions are automatically triggered on events
// You can also manually trigger them:
$product->callActions('purchased', $productPurchase);
// On refund
$product->callActions('refunded', $productPurchase);
```
## Product Relations
@ -252,155 +438,155 @@ $eurPrice = $product->prices()->where('currency', 'EUR')->first();
### Related Products
```php
// Attach related products
// Add related products
$product->relatedProducts()->attach($relatedProduct->id, [
'type' => 'related',
'sort_order' => 1,
]);
// Get all related products
$related = $product->relatedProducts()->get();
// Get related products
$related = $product->relatedProducts()->wherePivot('type', 'related')->get();
```
### Upsells
```php
// Attach upsell product
$product->relatedProducts()->attach($premiumProduct->id, [
// Add upsell product
$product->relatedProducts()->attach($upsellProduct->id, [
'type' => 'upsell',
'sort_order' => 1,
]);
// Get upsells
$upsells = $product->upsells;
// Get upsell products
$upsells = $product->upsells()->get();
```
### Cross-sells
```php
// Attach cross-sell product
$product->relatedProducts()->attach($accessory->id, [
// Add cross-sell product
$product->relatedProducts()->attach($crossSellProduct->id, [
'type' => 'cross-sell',
'sort_order' => 1,
]);
// Get cross-sells
$crossSells = $product->crossSells;
// Get cross-sell products
$crossSells = $product->crossSells()->get();
```
## Querying Products
### Basic Queries
### Scopes
```php
// Published products
$products = Product::published()->get();
$published = Product::published()->get();
// In stock products
$products = Product::inStock()->get();
// Visible products (published + visible + published_at check)
$visible = Product::visible()->get();
// Featured products
$products = Product::featured()->get();
$featured = Product::featured()->get();
// Visible products (published and within publish date)
$products = Product::visible()->get();
```
### Advanced Queries
```php
// Search products
$products = Product::search('hoodie')->get();
// Filter by category
$products = Product::byCategory($categoryId)->get();
// Price range
$products = Product::priceRange(10, 50)->get();
// Order by price
$products = Product::orderByPrice('asc')->get();
// In stock products
$inStock = Product::inStock()->get();
// Low stock products
$products = Product::lowStock()->get();
$lowStock = Product::lowStock()->get();
// Combined query
// By type
$simple = Product::where('type', 'simple')->get();
$virtual = Product::where('virtual', true)->get();
$downloadable = Product::where('downloadable', true)->get();
```
### Search
```php
// Search by slug, SKU, or name
$results = Product::search('headphones')->get();
// Price range
$results = Product::priceRange(min: 10.00, max: 100.00)->get();
// Order by price
$cheapest = Product::orderByPrice('asc')->get();
$expensive = Product::orderByPrice('desc')->get();
```
### Combining Scopes
```php
$products = Product::visible()
->inStock()
->byCategory($categoryId)
->priceRange(20, 100)
->priceRange(min: 20, max: 50)
->orderByPrice('asc')
->paginate(20);
->get();
```
## Product Methods
### Sale Detection
```php
if ($product->isOnSale()) {
echo "On sale!";
}
```
### Current Price
```php
$price = $product->getCurrentPrice(); // Returns sale_price if on sale, otherwise regular_price
```
### Visibility Check
## Product Visibility
```php
// Check if product is visible
if ($product->isVisible()) {
// Show product
// Product is published, visible, and published_at is in past
}
// Set visibility
$product->update([
'status' => 'published',
'is_visible' => true,
'published_at' => now(),
]);
```
### Low Stock Check
## Virtual & Downloadable Products
```php
if ($product->isLowStock()) {
// Show low stock warning
}
// Virtual product (no shipping)
$product->update([
'virtual' => true,
]);
// Downloadable product
$product->update([
'downloadable' => true,
]);
// Both
$product->update([
'virtual' => true,
'downloadable' => true,
]);
```
## API Serialization
## API Export
```php
// Get API-friendly array
// Get product as API array
$data = $product->toApiArray();
// Returns:
// [
// 'id' => '...',
// 'slug' => '...',
// 'name' => '...',
// 'sku' => '...',
// 'name' => '...', // localized
// 'description' => '...', // localized
// 'short_description' => '...', // localized
// 'type' => '...',
// 'price' => 49.99,
// 'is_on_sale' => true,
// 'in_stock' => true,
// 'sale_price' => null,
// 'is_on_sale' => false,
// 'low_stock' => false,
// 'featured' => false,
// 'virtual' => false,
// 'downloadable' => false,
// 'weight' => 0.5,
// 'dimensions' => [...],
// 'categories' => [...],
// 'attributes' => [...],
// 'variants' => [...],
// // ...
// 'parent' => null,
// 'created_at' => '...',
// 'updated_at' => '...',
// ]
```
## Events
The package dispatches events on product lifecycle:
```php
use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated;
// Listen to events in your EventServiceProvider
protected $listen = [
ProductCreated::class => [
SendProductCreatedNotification::class,
],
ProductUpdated::class => [
ClearProductCache::class,
],
];
```

View File

@ -1,18 +1,28 @@
# 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
### Enable Stripe
### Environment Setup
Add to your `.env`:
```env
SHOP_STRIPE_ENABLED=true
SHOP_STRIPE_SYNC_PRICES=true
STRIPE_KEY=your_stripe_key
STRIPE_SECRET=your_stripe_secret
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
```
### Config File
Update `config/shop.php`:
```php
@ -23,88 +33,284 @@ Update `config/shop.php`:
],
```
## Creating Products in Stripe
## Syncing Products from Stripe
### Manual Stripe Product Creation
### Sync Individual Product
```php
use App\Services\StripeService;
use Blax\Shop\Services\ShopStripeService;
use Stripe\Product as StripeProduct;
// Get Stripe product
$stripeProduct = StripeProduct::retrieve('prod_xxxxx');
// Sync to local database
$product = ShopStripeService::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
```php
// Sync all prices for a product
ShopStripeService::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
```php
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',
'price' => 29.99,
'status' => 'published',
]);
// Create in Stripe
$stripeProduct = StripeService::createProduct($product);
$product->setLocalized('name', 'Premium Plan', 'en');
$product->setLocalized('description', 'Access to all premium features', 'en');
// Store Stripe product ID
// 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 Sync
## Automatic Syncing with Events
If you have event listeners set up, products can be automatically synced to Stripe:
You can set up event listeners to automatically sync products to Stripe when they're created or updated.
### Create Event Listener
```php
// app/Listeners/SyncProductToStripe.php
namespace App\Listeners;
use Blax\Shop\Events\ProductCreated;
use App\Listeners\SyncProductToStripe;
use Stripe\Stripe;
use Stripe\Product as StripeProduct;
// In EventServiceProvider
protected $listen = [
ProductCreated::class => [
SyncProductToStripe::class,
],
];
// Listener implementation
class SyncProductToStripe
{
public function handle(ProductCreated $event)
{
if (config('shop.stripe.enabled')) {
$stripeProduct = StripeService::createProduct($event->product);
$event->product->update([
'stripe_product_id' => $stripeProduct->id,
]);
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,
]);
}
}
```
## Syncing Prices to Stripe
### Create Stripe Prices
### Register Event Listener
```php
use App\Services\StripeService;
use Blax\Shop\Models\ProductPrice;
// app/Providers/EventServiceProvider.php
use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated;
use App\Listeners\SyncProductToStripe;
use App\Listeners\UpdateStripeProduct;
// Sync default price
StripeService::syncProductPricesDown($product);
protected $listen = [
ProductCreated::class => [
SyncProductToStripe::class,
],
ProductUpdated::class => [
UpdateStripeProduct::class,
],
];
```
// Create additional currency prices
$eurPrice = ProductPrice::create([
'product_id' => $product->id,
'currency' => 'EUR',
'price' => 24.99,
## Working with Stripe Prices
### One-Time Prices
```php
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 corresponding Stripe price
$stripePrice = StripeService::createPrice($product, $eurPrice);
$eurPrice->update([
// 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,
]);
```
## Creating Checkout Sessions
### Recurring Prices
### One-time Payment
```php
// 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
```php
// 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
```php
use Stripe\Stripe;
@ -112,244 +318,224 @@ use Stripe\Checkout\Session;
Stripe::setApiKey(config('services.stripe.secret'));
$product = Product::find($productId);
Route::post('/checkout', function () {
$user = auth()->user();
$cartItems = $user->cartItems()->with('purchasable.prices')->get();
$session = Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price_data' => [
'currency' => 'usd',
'product_data' => [
'name' => $product->getLocalized('name'),
'description' => $product->getLocalized('short_description'),
],
'unit_amount' => $product->getCurrentPrice() * 100, // Convert to cents
// 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,
],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
'metadata' => [
'product_id' => $product->id,
],
]);
]);
return redirect($session->url);
return redirect($session->url);
});
```
### Using Stripe Price IDs
### Handle Successful Payment
```php
// If you have synced prices
$priceId = $product->prices()
->where('currency', 'USD')
->where('is_default', true)
->first()
->stripe_price_id;
Route::get('/checkout/success', function (Request $request) {
$sessionId = $request->get('session_id');
$session = Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $priceId,
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
]);
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->checkout();
// 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');
});
```
## Handling Webhooks
## Webhook Handling
### Register Webhook Endpoint
```php
// routes/api.php
use App\Http\Controllers\StripeWebhookController;
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']);
// routes/web.php
Route::post(
'/stripe/webhook',
[StripeWebhookController::class, 'handleWebhook']
)->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
```
### Webhook Controller
### Handle Webhooks
```php
<?php
// app/Http/Controllers/StripeWebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Stripe\Stripe;
use Stripe\Webhook;
use Blax\Shop\Models\Product;
use Illuminate\Http\Request;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
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);
$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->handleCheckoutCompleted($event->data->object);
$this->handleCheckoutComplete($event->data->object);
break;
case 'payment_intent.succeeded':
$this->handlePaymentSucceeded($event->data->object);
case 'product.created':
case 'product.updated':
$this->handleProductUpdate($event->data->object);
break;
case 'charge.refunded':
$this->handleRefund($event->data->object);
case 'price.created':
case 'price.updated':
$this->handlePriceUpdate($event->data->object);
break;
}
return response()->json(['status' => 'success']);
return response()->json(['success' => true]);
}
protected function handleCheckoutCompleted($session)
protected function handleCheckoutComplete($session)
{
$productId = $session->metadata->product_id ?? null;
// Find purchase by session metadata
$userId = $session->metadata->user_id ?? null;
$cartId = $session->metadata->cart_id ?? null;
if (!$productId) {
return;
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,
]);
}
$product = Product::find($productId);
if (!$product) {
return;
}
// Decrease stock
$quantity = $session->metadata->quantity ?? 1;
$product->decreaseStock($quantity);
// Create purchase record
$purchase = $product->purchases()->create([
'purchasable_type' => get_class(auth()->user()),
'purchasable_id' => $session->customer ?? $session->client_reference_id,
'quantity' => $quantity,
'status' => 'completed',
'meta' => [
'stripe_session_id' => $session->id,
'stripe_payment_intent' => $session->payment_intent,
],
]);
// Trigger product actions
$product->callActions('purchased', $purchase, [
'stripe_session' => $session,
]);
}
protected function handlePaymentSucceeded($paymentIntent)
protected function handleProductUpdate($stripeProduct)
{
// Handle successful payment
ShopStripeService::syncProductDown($stripeProduct);
}
protected function handleRefund($charge)
protected function handlePriceUpdate($stripePrice)
{
// Handle refund
$metadata = $charge->metadata;
$productId = $metadata->product_id ?? null;
// Update local price
$price = ProductPrice::where('stripe_price_id', $stripePrice->id)->first();
if ($productId) {
$product = Product::find($productId);
$quantity = $metadata->quantity ?? 1;
$product->increaseStock($quantity);
// Trigger refund actions
$product->callActions('refunded', null, [
'stripe_charge' => $charge,
if ($price) {
$price->update([
'active' => $stripePrice->active,
'unit_amount' => $stripePrice->unit_amount,
]);
}
}
}
```
### Configure Webhook Secret
## Best Practices
Add to `.env`:
### 1. Always Use Stripe Price IDs
```env
STRIPE_WEBHOOK_SECRET=whsec_...
```
Get your webhook secret from Stripe Dashboard → Developers → Webhooks.
## Multi-Currency Support
### Create Prices for Multiple Currencies
When integrating with Stripe Checkout or subscriptions, always use Stripe Price IDs:
```php
$product = Product::create([
'slug' => 'premium-plan',
'price' => 29.99, // USD base price
]);
// USD (default)
ProductPrice::create([
'product_id' => $product->id,
'currency' => 'USD',
'price' => 29.99,
'is_default' => true,
]);
// EUR
ProductPrice::create([
'product_id' => $product->id,
'currency' => 'EUR',
'price' => 24.99,
]);
// GBP
ProductPrice::create([
'product_id' => $product->id,
'currency' => 'GBP',
'price' => 21.99,
]);
// Sync all to Stripe
StripeService::syncProductPricesDown($product);
$price = $product->defaultPrice()->first();
$stripePriceId = $price->stripe_price_id;
```
### Checkout with Currency Selection
### 2. Keep Prices in Sync
Use webhooks or scheduled commands to keep prices synchronized:
```php
$currency = $request->input('currency', 'USD');
// app/Console/Commands/SyncStripePrices.php
use Stripe\Stripe;
use Stripe\Product as StripeProduct;
$price = $product->prices()
->where('currency', $currency)
->first();
Stripe::setApiKey(config('services.stripe.secret'));
$session = Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $price->stripe_price_id,
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('checkout.success'),
'cancel_url' => route('checkout.cancel'),
Product::whereNotNull('stripe_product_id')->each(function ($product) {
ShopStripeService::syncProductPricesDown($product);
});
```
### 3. Store Stripe References
Always store Stripe IDs for traceability:
```php
$product->update([
'stripe_product_id' => $stripeProduct->id,
]);
$price->update([
'stripe_price_id' => $stripePrice->id,
]);
$purchase->update([
'charge_id' => $paymentIntent->id,
]);
```
## Testing
### 4. Handle Errors Gracefully
### Use Stripe Test Mode
```env
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
```php
try {
$stripeProduct = StripeProduct::create([
'name' => $product->getLocalized('name'),
]);
} catch (\Stripe\Exception\ApiErrorException $e) {
\Log::error('Stripe API error: ' . $e->getMessage());
// Handle error appropriately
}
```
### Test Card Numbers

View File

@ -1,8 +1,8 @@
# Purchasing Products
# Purchasing & Shopping Cart
## Setup
First, add the `HasShoppingCapabilities` trait to your User model (or any model that should purchase products):
Add the `HasShoppingCapabilities` trait to your User model (or any model that should be able to purchase products):
```php
use Blax\Shop\Traits\HasShoppingCapabilities;
@ -13,21 +13,29 @@ class User extends Authenticatable
}
```
This trait provides methods for:
- Direct product purchases
- Shopping cart management
- Purchase history
- Cart checkout
## Direct Purchase
### Simple Purchase
### Purchase a Product
```php
$user = auth()->user();
$product = Product::find($productId);
// Product must have a default price
try {
$purchase = $user->purchase($product, quantity: 1);
$purchase = $user->purchase($product);
// Purchase successful
return response()->json([
'success' => true,
'purchase_id' => $purchase->id,
'amount' => $purchase->amount,
]);
} catch (\Exception $e) {
return response()->json([
@ -36,32 +44,40 @@ try {
}
```
### Purchase with Options
### Purchase with Specific Price
```php
$purchase = $user->purchase($product, quantity: 2, options: [
'price_id' => $priceId, // Use specific price
'charge_id' => $paymentId, // Associate with payment
'cart_id' => $cartId, // Associate with cart
'status' => 'pending', // Custom status
]);
$price = ProductPrice::find($priceId);
$purchase = $user->purchase(
$price,
quantity: 2
);
```
### Check Purchase History
### Purchase with Metadata
```php
// Check if user has purchased a product
if ($user->hasPurchased($product)) {
// User has purchased this product
}
// Get purchase history for a product
$history = $user->getPurchaseHistory($product);
// Get all completed purchases
$purchases = $user->completedPurchases()->get();
$purchase = $user->purchase(
$product,
quantity: 1,
meta: [
'gift' => true,
'message' => 'Happy Birthday!',
'gift_recipient' => 'john@example.com',
]
);
```
### Important Notes
- Product must have at least one default price
- Product must not have multiple default prices (will throw `MultiplePurchaseOptions` exception)
- If stock management is enabled, sufficient stock must be available
- Product must be visible (published, visible flag, and published_at date)
- Purchase automatically decreases stock if `manage_stock` is enabled
- Product actions are automatically triggered on purchase
## Shopping Cart
### Add to Cart
@ -84,13 +100,39 @@ try {
}
```
### Update Cart Quantity
### Add with Parameters
```php
$cartItem = ProductPurchase::find($cartItemId);
$cartItem = $user->addToCart(
$product,
quantity: 2,
parameters: [
'color' => 'blue',
'size' => 'large',
]
);
```
### Get Cart Items
```php
$cartItems = $user->cartItems()->get();
foreach ($cartItems as $item) {
echo $item->purchasable->getLocalized('name');
echo $item->quantity;
echo $item->price;
echo $item->subtotal;
}
```
### Update Cart Item Quantity
```php
$cartItem = CartItem::find($cartItemId);
try {
$user->updateCartQuantity($cartItem, quantity: 3);
$updatedItem = $user->updateCartQuantity($cartItem, quantity: 3);
return response()->json([
'success' => true,
@ -104,479 +146,369 @@ try {
### Remove from Cart
```php
$cartItem = ProductPurchase::find($cartItemId);
$cartItem = CartItem::find($cartItemId);
$user->removeFromCart($cartItem);
return response()->json([
'success' => true,
'cart_total' => $user->getCartTotal(),
'cart_count' => $user->getCartItemsCount(),
]);
```
### Get Cart Information
### Clear Cart
```php
// Get all cart items
$cartItems = $user->cartItems()->with('product')->get();
$count = $user->clearCart();
return response()->json([
'success' => true,
'removed_items' => $count,
]);
```
### Get Cart Totals
```php
// Get cart total
$total = $user->getCartTotal();
// Get items count
// Get cart items count
$count = $user->getCartItemsCount();
// Clear cart
$user->clearCart();
// Get cart stats
$stats = [
'total' => $user->getCartTotal(),
'count' => $user->getCartItemsCount(),
'items' => $user->cartItems()->with('purchasable')->get(),
];
```
### Checkout
## Cart Checkout
### Convert Cart to Purchases
```php
try {
$completedPurchases = $user->checkout(options: [
'charge_id' => $paymentIntent->id,
]);
$purchases = $user->checkout();
// Checkout successful
// Cart items are now converted to completed purchases
// Cart is marked as converted
return response()->json([
'success' => true,
'purchases' => $completedPurchases,
'total' => $completedPurchases->sum('amount'),
'purchases' => $purchases,
'total_items' => $purchases->count(),
]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
return response()->json([
'error' => $e->getMessage()
], 400);
}
```
## Refunds
### Important Notes
- Checkout validates stock availability for all items
- Creates `ProductPurchase` records for each cart item
- Decreases stock for each item
- Triggers product actions
- Marks cart as converted (`converted_at` timestamp)
- Removes cart items after successful checkout
## Purchase History
### Check if User Purchased Product
```php
$purchase = ProductPurchase::find($purchaseId);
$user = $purchase->purchasable;
$product = Product::find($productId);
try {
$user->refundPurchase($purchase, options: [
'refund_id' => $refundId,
'reason' => 'Customer request',
]);
return response()->json(['success' => true]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
if ($user->hasPurchased($product)) {
// User has purchased this product
echo "You own this product!";
}
```
## Purchase Statistics
### Get All Purchases
```php
// Get all purchases (any status)
$allPurchases = $user->purchases()->get();
// Get only completed purchases
$completedPurchases = $user->completedPurchases()->get();
// Get purchases for specific product
$productPurchases = $user->purchases()
->where('purchasable_id', $product->id)
->where('purchasable_type', Product::class)
->get();
```
### Purchase Statistics
```php
$stats = $user->getPurchaseStats();
// Returns:
// [
// 'total_purchases' => 10,
// 'total_spent' => 299.90,
// 'total_items' => 15,
// 'total_purchases' => 15,
// 'total_spent' => 450.00,
// 'total_items' => 23,
// 'cart_items' => 2,
// 'cart_total' => 49.98,
// 'cart_total' => 89.99,
// ]
```
## Basic Purchase Flow
## Refunds
### 1. Check Product Availability
### Refund a Purchase
```php
use Blax\Shop\Models\Product;
$purchase = ProductPurchase::find($purchaseId);
$product = Product::find($productId);
$quantity = 1;
try {
$success = $user->refundPurchase($purchase);
// Check if product is available
if (!$product->isVisible()) {
return response()->json(['error' => 'Product not available'], 404);
}
// Check stock
if ($product->manage_stock) {
$available = $product->getAvailableStock();
if ($available < $quantity) {
return response()->json([
'error' => 'Insufficient stock',
'available' => $available
], 400);
}
}
```
### 2. Reserve Stock (Optional)
Reserve stock during checkout process:
```php
// Reserve for 15 minutes
$reservation = $product->reserveStock(
quantity: $quantity,
reference: auth()->user(),
until: now()->addMinutes(15),
note: 'Checkout reservation'
);
if (!$reservation) {
return response()->json(['error' => 'Unable to reserve stock'], 400);
}
// Store reservation ID in session
session(['stock_reservation_id' => $reservation->id]);
```
### 3. Process Payment
```php
// Your payment processing logic
$payment = PaymentService::process([
'amount' => $product->getCurrentPrice() * $quantity,
'currency' => 'USD',
'product_id' => $product->id,
]);
if ($payment->failed()) {
// Release reservation
$reservation->update(['status' => 'cancelled']);
return response()->json(['error' => 'Payment failed'], 400);
}
```
### 4. Complete Purchase
```php
use Blax\Shop\Models\ProductPurchase;
// Decrease stock
$product->decreaseStock($quantity);
// Create purchase record
$purchase = ProductPurchase::create([
'product_id' => $product->id,
'purchasable_type' => get_class(auth()->user()),
'purchasable_id' => auth()->id(),
'quantity' => $quantity,
'status' => 'completed',
'meta' => [
'payment_id' => $payment->id,
'price_paid' => $product->getCurrentPrice(),
'currency' => 'USD',
],
]);
// Complete reservation
if ($reservation) {
$reservation->update(['status' => 'completed']);
}
// Trigger product actions
$product->callActions('purchased', $purchase, [
'user' => auth()->user(),
'payment' => $payment,
]);
return response()->json([
'success' => true,
'purchase_id' => $purchase->id,
]);
```
## Shopping Cart Implementation
### Cart Item Model
```php
// app/Models/CartItem.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Blax\Shop\Models\Product;
class CartItem extends Model
{
protected $fillable = [
'cart_id',
'product_id',
'quantity',
'price',
];
protected $casts = [
'price' => 'decimal:2',
];
public function product()
{
return $this->belongsTo(Product::class);
}
public function getSubtotal()
{
return $this->price * $this->quantity;
}
}
```
### Cart Service
```php
// app/Services/CartService.php
namespace App\Services;
use App\Models\CartItem;
use Blax\Shop\Models\Product;
class CartService
{
public function add(Product $product, int $quantity = 1)
{
$cart = $this->getCart();
// Check stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception('Insufficient stock');
}
// Check if item already in cart
$cartItem = $cart->items()->where('product_id', $product->id)->first();
if ($cartItem) {
$newQuantity = $cartItem->quantity + $quantity;
// Check stock for new quantity
if ($product->manage_stock && $product->getAvailableStock() < $newQuantity) {
throw new \Exception('Insufficient stock for requested quantity');
}
$cartItem->update(['quantity' => $newQuantity]);
} else {
$cartItem = $cart->items()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'price' => $product->getCurrentPrice(),
]);
}
return $cartItem;
}
public function update(CartItem $cartItem, int $quantity)
{
$product = $cartItem->product;
// Check stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception('Insufficient stock');
}
$cartItem->update(['quantity' => $quantity]);
return $cartItem;
}
public function remove(CartItem $cartItem)
{
$cartItem->delete();
}
public function clear()
{
$cart = $this->getCart();
$cart->items()->delete();
}
public function getTotal()
{
$cart = $this->getCart();
return $cart->items->sum(fn($item) => $item->getSubtotal());
}
public function checkout()
{
$cart = $this->getCart();
$items = $cart->items()->with('product')->get();
// Reserve stock for all items
$reservations = [];
foreach ($items as $item) {
$reservation = $item->product->reserveStock(
$item->quantity,
$cart,
now()->addMinutes(15)
);
if (!$reservation) {
// Rollback previous reservations
foreach ($reservations as $res) {
$res->update(['status' => 'cancelled']);
}
throw new \Exception('Unable to reserve stock for: ' . $item->product->getLocalized('name'));
}
$reservations[] = $reservation;
}
return [
'items' => $items,
'reservations' => $reservations,
'total' => $this->getTotal(),
];
}
protected function getCart()
{
// Implementation depends on your cart system
// Could be session-based or user-based
return auth()->user()->cart ?? session()->get('cart');
}
}
```
### Cart Controller
```php
// app/Http/Controllers/CartController.php
namespace App\Http\Controllers;
use App\Services\CartService;
use Blax\Shop\Models\Product;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function __construct(
protected CartService $cartService
) {}
public function add(Request $request, Product $product)
{
$validated = $request->validate([
'quantity' => 'required|integer|min:1',
]);
try {
$cartItem = $this->cartService->add($product, $validated['quantity']);
return response()->json([
'success' => true,
'cart_item' => $cartItem,
'cart_total' => $this->cartService->getTotal(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
public function update(Request $request, $cartItemId)
{
$validated = $request->validate([
'quantity' => 'required|integer|min:1',
]);
$cartItem = CartItem::findOrFail($cartItemId);
try {
$this->cartService->update($cartItem, $validated['quantity']);
return response()->json([
'success' => true,
'cart_total' => $this->cartService->getTotal(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
public function remove($cartItemId)
{
$cartItem = CartItem::findOrFail($cartItemId);
$this->cartService->remove($cartItem);
if ($success) {
// Refund successful
// Stock has been returned
// Purchase status changed to 'refunded'
// Product 'refunded' actions triggered
return response()->json([
'success' => true,
'cart_total' => $this->cartService->getTotal(),
'message' => 'Purchase refunded successfully',
]);
}
public function checkout()
{
try {
$checkoutData = $this->cartService->checkout();
return response()->json([
'success' => true,
'checkout' => $checkoutData,
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
```
## Handling Refunds
### Important Notes
- Only completed purchases can be refunded
- Stock is automatically returned to inventory
- Product actions with event 'refunded' are triggered
## Cart Model
### Get Current Cart
```php
public function refund($purchaseId)
{
$purchase = ProductPurchase::findOrFail($purchaseId);
$product = $purchase->product;
// Get or create current active cart
$cart = $user->currentCart();
// Process refund with payment processor
$refund = PaymentService::refund($purchase->meta['payment_id']);
// Cart properties
$cart->session_id; // Session ID for guest carts
$cart->customer_id; // User ID
$cart->customer_type; // User model class
$cart->currency; // Cart currency (default: USD)
$cart->status; // active, abandoned, converted, expired
$cart->converted_at; // When cart was checked out
$cart->expires_at; // Cart expiration date
$cart->last_activity_at; // Last activity timestamp
```
if ($refund->success) {
// Return stock
$product->increaseStock($purchase->quantity);
### Cart Relationships
// Update purchase status
$purchase->update([
'status' => 'refunded',
'meta' => array_merge($purchase->meta, [
'refund_id' => $refund->id,
'refunded_at' => now(),
]),
]);
```php
// Get cart items
$items = $cart->items()->get();
// Trigger refund actions
$product->callActions('refunded', $purchase, [
'refund' => $refund,
]);
// Get cart purchases (if converted)
$purchases = $cart->purchases()->get();
return response()->json(['success' => true]);
}
// Get cart customer (user)
$customer = $cart->customer;
```
return response()->json(['error' => 'Refund failed'], 400);
### Cart Methods
```php
// Get cart total
$total = $cart->getTotal();
// Get total items
$itemCount = $cart->getTotalItems();
// Check if cart is expired
if ($cart->isExpired()) {
// Cart has expired
}
// Check if cart is converted
if ($cart->isConverted()) {
// Cart has been checked out
}
```
## Product Actions on Purchase
Product actions allow you to execute custom logic when products are purchased:
### Add Items to Cart Directly
```php
use Blax\Shop\Models\ProductAction;
use Blax\Shop\Models\Cart;
// Create action to grant access to a course
ProductAction::create([
'product_id' => $product->id,
'action_type' => 'grant_access',
'event' => 'purchased',
'config' => [
'resource_type' => 'course',
'resource_id' => 123,
],
'active' => true,
]);
$cart = Cart::find($cartId);
// Action is automatically triggered when product is purchased
// Implement the action handler in your application
$cartItem = $cart->addToCart(
$product, // or $productPrice
quantity: 2,
parameters: ['size' => 'L']
);
```
See [Product Actions documentation](docs/07-product-actions.md) for more details.
## Product Purchase Model
### Purchase Properties
```php
$purchase = ProductPurchase::find($purchaseId);
$purchase->status; // cart, pending, unpaid, completed, refunded
$purchase->cart_id; // Associated cart ID
$purchase->price_id; // Associated price ID
$purchase->purchasable_id; // Product ID
$purchase->purchasable_type; // Product class
$purchase->purchaser_id; // User ID
$purchase->purchaser_type; // User class
$purchase->quantity; // Quantity purchased
$purchase->amount; // Total amount
$purchase->amount_paid; // Amount paid
$purchase->charge_id; // Payment charge ID
$purchase->meta; // Additional metadata
```
### Purchase Relationships
```php
// Get purchased product
$product = $purchase->purchasable;
// Get purchaser (user)
$user = $purchase->purchaser;
```
### Purchase Scopes
```php
// Get purchases in cart
$cartPurchases = ProductPurchase::inCart()->get();
// Get completed purchases
$completed = ProductPurchase::completed()->get();
// Get purchases from specific cart
$cartPurchases = ProductPurchase::fromCart($cartId)->get();
```
## Stock Reservations
When adding products to cart, stock is automatically reserved:
```php
// Stock is reserved when adding to cart
$cartItem = $user->addToCart($product, quantity: 2);
// Reservation is created automatically
// It expires after configured time (default: 15 minutes)
// Stock is released back when:
// - Reservation expires
// - Cart item is removed
// - Cart is abandoned
```
## Error Handling
### Common Exceptions
```php
use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotEnoughStockException;
try {
$purchase = $user->purchase($product);
} catch (NotPurchasable $e) {
// Product has no default price
} catch (MultiplePurchaseOptions $e) {
// Product has multiple default prices - need to specify which one
$price = $product->prices()->where('currency', 'USD')->first();
$purchase = $user->purchase($price);
} catch (NotEnoughStockException $e) {
// Insufficient stock available
$available = $product->getAvailableStock();
echo "Only {$available} items available";
} catch (\Exception $e) {
// General error
echo $e->getMessage();
}
```
## Complete Example
```php
// Product listing
Route::get('/products', function () {
$products = Product::visible()
->inStock()
->with(['prices' => fn($q) => $q->where('is_default', true)])
->get();
return view('products.index', compact('products'));
});
// Add to cart
Route::post('/cart/add/{product}', function (Product $product) {
$user = auth()->user();
try {
$cartItem = $user->addToCart($product, quantity: 1);
return redirect()->back()->with('success', 'Product added to cart!');
} catch (\Exception $e) {
return redirect()->back()->with('error', $e->getMessage());
}
});
// View cart
Route::get('/cart', function () {
$user = auth()->user();
$cartItems = $user->cartItems()->with('purchasable')->get();
$cartTotal = $user->getCartTotal();
$cartCount = $user->getCartItemsCount();
return view('cart.index', compact('cartItems', 'cartTotal', 'cartCount'));
});
// Checkout
Route::post('/checkout', function () {
$user = auth()->user();
try {
$purchases = $user->checkout();
return redirect()->route('orders.success')
->with('success', 'Order placed successfully!');
} catch (\Exception $e) {
return redirect()->back()->with('error', $e->getMessage());
}
});
// Order history
Route::get('/orders', function () {
$user = auth()->user();
$purchases = $user->completedPurchases()
->with('purchasable')
->orderBy('created_at', 'desc')
->get();
return view('orders.index', compact('purchases'));
});
```

View File

@ -1,489 +0,0 @@
# 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,
],
]);
```

View File

@ -23,10 +23,8 @@ class Product extends Model implements Purchasable, Cartable
use HasFactory, HasUuids, HasMetaTranslation;
protected $fillable = [
'name',
'slug',
'short_description',
'description',
'sku',
'type',
'stripe_product_id',
'sale_start',
@ -45,7 +43,6 @@ class Product extends Model implements Purchasable, Cartable
'status',
'published_at',
'meta',
'sku',
'tax_class',
'sort_order',
];

View File

@ -16,12 +16,10 @@ class ProductAttribute extends Model
'key',
'value',
'sort_order',
'meta',
];
protected $casts = [
'sort_order' => 'integer',
'meta' => 'object',
];
protected $hidden = [

View File

@ -23,13 +23,11 @@ class ProductStock extends Model
'reference_id',
'expires_at',
'note',
'meta',
];
protected $casts = [
'quantity' => 'integer',
'expires_at' => 'datetime',
'meta' => 'object',
];
public function __construct(array $attributes = [])

View File

@ -103,18 +103,21 @@ class ProductAttributeTest extends TestCase
{
$product = Product::factory()->create();
$attribute = ProductAttribute::create([
// Attributes now store structured data in value or as separate attributes
$dimensionAttr = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Dimensions',
'value' => '10x20x30',
'meta' => [
'unit' => 'cm',
'display_format' => 'length x width x height',
],
]);
$this->assertEquals('cm', $attribute->meta->unit);
$this->assertEquals('length x width x height', $attribute->meta->display_format);
$unitAttr = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Dimension Unit',
'value' => 'cm',
]);
$this->assertEquals('10x20x30', $dimensionAttr->value);
$this->assertEquals('cm', $unitAttr->value);
}
/** @test */