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 # 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 ## Creating Products
### Minimal Product Creation ### Minimal Product Creation
@ -15,8 +25,8 @@ $product = Product::create([
``` ```
This will automatically: This will automatically:
- Generate a random slug if not provided - Generate a random slug if not provided (e.g., 'new-product-abc12345')
- Create a default name "New Product [slug]" - Initialize meta field as empty JSON object
- Set status to 'draft' - Set status to 'draft'
- Set type to 'simple' - Set type to 'simple'
@ -27,14 +37,12 @@ $product = Product::create([
'slug' => 'blue-hoodie', 'slug' => 'blue-hoodie',
'sku' => 'HOOD-BLU-001', 'sku' => 'HOOD-BLU-001',
'type' => 'simple', 'type' => 'simple',
'price' => 49.99,
'regular_price' => 49.99,
'status' => 'published', 'status' => 'published',
'is_visible' => true, 'is_visible' => true,
'featured' => false, 'featured' => false,
]); ]);
// Add translated content // Add translated content (stored in meta column)
$product->setLocalized('name', 'Blue Hoodie', 'en'); $product->setLocalized('name', 'Blue Hoodie', 'en');
$product->setLocalized('description', 'Comfortable cotton hoodie', 'en'); $product->setLocalized('description', 'Comfortable cotton hoodie', 'en');
$product->setLocalized('short_description', 'Cotton hoodie', 'en'); $product->setLocalized('short_description', 'Cotton hoodie', 'en');
@ -54,19 +62,13 @@ $product = Product::create([
'published_at' => now(), 'published_at' => now(),
'sort_order' => 10, 'sort_order' => 10,
// Pricing // Sale Period
'price' => 199.99,
'regular_price' => 249.99,
'sale_price' => 199.99,
'sale_start' => now(), 'sale_start' => now(),
'sale_end' => now()->addDays(7), 'sale_end' => now()->addDays(7),
// Stock Management // Stock Management
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 50,
'low_stock_threshold' => 10, 'low_stock_threshold' => 10,
'in_stock' => true,
'stock_status' => 'instock',
// Physical Properties // Physical Properties
'weight' => 0.5, // kg 'weight' => 0.5, // kg
@ -78,13 +80,6 @@ $product = Product::create([
// Tax // Tax
'tax_class' => 'standard', 'tax_class' => 'standard',
// Custom Meta
'meta' => [
'brand' => 'AudioPro',
'color' => 'black',
'warranty' => '2 years',
],
]); ]);
// Add translations // 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('description', 'High-quality wireless headphones with noise cancellation', 'en');
$product->setLocalized('short_description', 'Premium wireless headphones', '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 ```php
$product = Product::create([ use Blax\Shop\Models\ProductPrice;
'type' => 'simple',
'slug' => 't-shirt', // Create a default price
'price' => 19.99, $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 ```php
$parent = Product::create([ $parent = Product::create([
'type' => 'variable', 'type' => 'variable',
'slug' => 'hoodie', 'slug' => 'hoodie',
'price' => 49.99, // Base price 'status' => 'published',
]); ]);
// Create variants $parent->setLocalized('name', 'Hoodie', 'en');
```
### Create Variants
```php
// Small variant
$small = Product::create([ $small = Product::create([
'type' => 'simple', 'type' => 'simple',
'slug' => 'hoodie-small', 'slug' => 'hoodie-small',
'sku' => 'HOOD-S', 'sku' => 'HOOD-S',
'parent_id' => $parent->id, '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([ $medium = Product::create([
'type' => 'simple', 'type' => 'simple',
'slug' => 'hoodie-medium', 'slug' => 'hoodie-medium',
'sku' => 'HOOD-M', 'sku' => 'HOOD-M',
'parent_id' => $parent->id, 'parent_id' => $parent->id,
'price' => 49.99, 'status' => 'published',
]); ]);
$large = Product::create([ ProductPrice::create([
'type' => 'simple', 'purchasable_type' => Product::class,
'slug' => 'hoodie-large', 'purchasable_id' => $medium->id,
'sku' => 'HOOD-L', 'currency' => 'USD',
'parent_id' => $parent->id, 'unit_amount' => 4999,
'price' => 54.99, // Different price 'is_default' => true,
'active' => true,
]); ]);
// Get all variants
$variants = $parent->children()->get();
``` ```
### Grouped Product ## Categories
```php ### Create Categories
$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
```php ```php
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;
// Create category
$category = ProductCategory::create([ $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 // Create child category
$product->categories()->attach($category->id); $subCategory = ProductCategory::create([
'name' => 'Headphones',
// Detach 'slug' => 'headphones',
$product->categories()->detach($category->id); 'parent_id' => $category->id,
'is_visible' => true,
// Sync categories 'sort_order' => 1,
$product->categories()->sync([
$category1->id,
$category2->id,
]); ]);
``` ```
## Multi-Currency Pricing ### Attach Products to Categories
```php ```php
use Blax\Shop\Models\ProductPrice; // Attach single category
$product->categories()->attach($category->id);
// Add EUR pricing // Attach multiple categories
ProductPrice::create([ $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, 'product_id' => $product->id,
'currency' => 'EUR', 'key' => 'Color',
'price' => 39.99, 'value' => 'Blue',
'is_default' => false, 'sort_order' => 1,
]); ]);
// Add GBP pricing // Add size attribute
ProductPrice::create([ ProductAttribute::create([
'product_id' => $product->id, 'product_id' => $product->id,
'currency' => 'GBP', 'key' => 'Size',
'price' => 34.99, 'value' => 'Large',
'is_default' => false, 'sort_order' => 2,
]); ]);
// Get all prices // Get product attributes
$prices = $product->prices; $attributes = $product->attributes()->get();
```
// Get price for specific currency ## Product Actions
$eurPrice = $product->prices()->where('currency', 'EUR')->first();
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 ## Product Relations
@ -252,155 +438,155 @@ $eurPrice = $product->prices()->where('currency', 'EUR')->first();
### Related Products ### Related Products
```php ```php
// Attach related products // Add related products
$product->relatedProducts()->attach($relatedProduct->id, [ $product->relatedProducts()->attach($relatedProduct->id, [
'type' => 'related', 'type' => 'related',
'sort_order' => 1,
]); ]);
// Get all related products // Get related products
$related = $product->relatedProducts()->get(); $related = $product->relatedProducts()->wherePivot('type', 'related')->get();
``` ```
### Upsells ### Upsells
```php ```php
// Attach upsell product // Add upsell product
$product->relatedProducts()->attach($premiumProduct->id, [ $product->relatedProducts()->attach($upsellProduct->id, [
'type' => 'upsell', 'type' => 'upsell',
'sort_order' => 1,
]); ]);
// Get upsells // Get upsell products
$upsells = $product->upsells; $upsells = $product->upsells()->get();
``` ```
### Cross-sells ### Cross-sells
```php ```php
// Attach cross-sell product // Add cross-sell product
$product->relatedProducts()->attach($accessory->id, [ $product->relatedProducts()->attach($crossSellProduct->id, [
'type' => 'cross-sell', 'type' => 'cross-sell',
'sort_order' => 1,
]); ]);
// Get cross-sells // Get cross-sell products
$crossSells = $product->crossSells; $crossSells = $product->crossSells()->get();
``` ```
## Querying Products ## Querying Products
### Basic Queries ### Scopes
```php ```php
// Published products // Published products
$products = Product::published()->get(); $published = Product::published()->get();
// In stock products // Visible products (published + visible + published_at check)
$products = Product::inStock()->get(); $visible = Product::visible()->get();
// Featured products // Featured products
$products = Product::featured()->get(); $featured = Product::featured()->get();
// Visible products (published and within publish date) // In stock products
$products = Product::visible()->get(); $inStock = Product::inStock()->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();
// Low stock products // 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() $products = Product::visible()
->inStock() ->inStock()
->byCategory($categoryId) ->byCategory($categoryId)
->priceRange(20, 100) ->priceRange(min: 20, max: 50)
->orderByPrice('asc') ->orderByPrice('asc')
->paginate(20); ->get();
``` ```
## Product Methods ## Product Visibility
### 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
```php ```php
// Check if product is visible
if ($product->isVisible()) { 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 ```php
if ($product->isLowStock()) { // Virtual product (no shipping)
// Show low stock warning $product->update([
} 'virtual' => true,
]);
// Downloadable product
$product->update([
'downloadable' => true,
]);
// Both
$product->update([
'virtual' => true,
'downloadable' => true,
]);
``` ```
## API Serialization ## API Export
```php ```php
// Get API-friendly array // Get product as API array
$data = $product->toApiArray(); $data = $product->toApiArray();
// Returns: // Returns:
// [ // [
// 'id' => '...', // 'id' => '...',
// 'slug' => '...', // 'slug' => '...',
// 'name' => '...', // 'sku' => '...',
// 'name' => '...', // localized
// 'description' => '...', // localized
// 'short_description' => '...', // localized
// 'type' => '...',
// 'price' => 49.99, // 'price' => 49.99,
// 'is_on_sale' => true, // 'sale_price' => null,
// 'in_stock' => true, // 'is_on_sale' => false,
// 'low_stock' => false,
// 'featured' => false,
// 'virtual' => false,
// 'downloadable' => false,
// 'weight' => 0.5,
// 'dimensions' => [...],
// 'categories' => [...], // 'categories' => [...],
// 'attributes' => [...], // 'attributes' => [...],
// 'variants' => [...], // '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 # 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 ## Configuration
### Enable Stripe ### Environment Setup
Add to your `.env`: Add to your `.env`:
```env ```env
SHOP_STRIPE_ENABLED=true SHOP_STRIPE_ENABLED=true
SHOP_STRIPE_SYNC_PRICES=true SHOP_STRIPE_SYNC_PRICES=true
STRIPE_KEY=your_stripe_key STRIPE_KEY=pk_test_...
STRIPE_SECRET=your_stripe_secret STRIPE_SECRET=sk_test_...
``` ```
### Config File
Update `config/shop.php`: Update `config/shop.php`:
```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 ```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([ $product = Product::create([
'slug' => 'premium-plan', 'slug' => 'premium-plan',
'price' => 29.99,
'status' => 'published', 'status' => 'published',
]); ]);
// Create in Stripe $product->setLocalized('name', 'Premium Plan', 'en');
$stripeProduct = StripeService::createProduct($product); $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([ $product->update([
'stripe_product_id' => $stripeProduct->id, '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 ```php
// app/Listeners/SyncProductToStripe.php
namespace App\Listeners;
use Blax\Shop\Events\ProductCreated; 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 class SyncProductToStripe
{ {
public function handle(ProductCreated $event) public function handle(ProductCreated $event)
{ {
if (config('shop.stripe.enabled')) { if (!config('shop.stripe.enabled')) {
$stripeProduct = StripeService::createProduct($event->product); return;
$event->product->update([
'stripe_product_id' => $stripeProduct->id,
]);
} }
$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 ### Register Event Listener
### Create Stripe Prices
```php ```php
use App\Services\StripeService; // app/Providers/EventServiceProvider.php
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated;
use App\Listeners\SyncProductToStripe;
use App\Listeners\UpdateStripeProduct;
// Sync default price protected $listen = [
StripeService::syncProductPricesDown($product); ProductCreated::class => [
SyncProductToStripe::class,
],
ProductUpdated::class => [
UpdateStripeProduct::class,
],
];
```
// Create additional currency prices ## Working with Stripe Prices
$eurPrice = ProductPrice::create([
'product_id' => $product->id, ### One-Time Prices
'currency' => 'EUR',
'price' => 24.99, ```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 // Create local price
$stripePrice = StripeService::createPrice($product, $eurPrice); ProductPrice::create([
'purchasable_type' => Product::class,
$eurPrice->update([ 'purchasable_id' => $product->id,
'stripe_price_id' => $stripePrice->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 ```php
use Stripe\Stripe; use Stripe\Stripe;
@ -112,244 +318,224 @@ use Stripe\Checkout\Session;
Stripe::setApiKey(config('services.stripe.secret')); 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([ // Build line items from cart
'payment_method_types' => ['card'], $lineItems = $cartItems->map(function ($item) {
'line_items' => [[ $price = $item->purchasable->defaultPrice()->first();
'price_data' => [
'currency' => 'usd', return [
'product_data' => [ 'price' => $price->stripe_price_id,
'name' => $product->getLocalized('name'), 'quantity' => $item->quantity,
'description' => $product->getLocalized('short_description'), ];
], })->toArray();
'unit_amount' => $product->getCurrentPrice() * 100, // Convert to cents
// 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 ```php
// If you have synced prices Route::get('/checkout/success', function (Request $request) {
$priceId = $product->prices() $sessionId = $request->get('session_id');
->where('currency', 'USD')
->where('is_default', true)
->first()
->stripe_price_id;
$session = Session::create([ Stripe::setApiKey(config('services.stripe.secret'));
'payment_method_types' => ['card'], $session = Session::retrieve($sessionId);
'line_items' => [[
'price' => $priceId, // Verify payment succeeded
'quantity' => 1, if ($session->payment_status === 'paid') {
]], $user = auth()->user();
'mode' => 'payment',
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}', // Convert cart to purchases
'cancel_url' => route('checkout.cancel'), $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 ### Register Webhook Endpoint
```php ```php
// routes/api.php // routes/web.php
use App\Http\Controllers\StripeWebhookController; Route::post(
'/stripe/webhook',
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']); [StripeWebhookController::class, 'handleWebhook']
)->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
``` ```
### Webhook Controller ### Handle Webhooks
```php ```php
<?php // app/Http/Controllers/StripeWebhookController.php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request; use Stripe\Stripe;
use Stripe\Webhook; use Stripe\Webhook;
use Blax\Shop\Models\Product; use Illuminate\Http\Request;
class StripeWebhookController extends Controller class StripeWebhookController extends Controller
{ {
public function handle(Request $request) public function handleWebhook(Request $request)
{ {
Stripe::setApiKey(config('services.stripe.secret'));
$payload = $request->getContent(); $payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature'); $sigHeader = $request->header('Stripe-Signature');
$webhookSecret = config('services.stripe.webhook_secret'); $webhookSecret = config('services.stripe.webhook_secret');
try { try {
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret); $event = Webhook::constructEvent(
$payload,
$sigHeader,
$webhookSecret
);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json(['error' => 'Invalid signature'], 400); return response()->json(['error' => 'Invalid signature'], 400);
} }
// Handle the event
switch ($event->type) { switch ($event->type) {
case 'checkout.session.completed': case 'checkout.session.completed':
$this->handleCheckoutCompleted($event->data->object); $this->handleCheckoutComplete($event->data->object);
break; break;
case 'payment_intent.succeeded': case 'product.created':
$this->handlePaymentSucceeded($event->data->object); case 'product.updated':
$this->handleProductUpdate($event->data->object);
break; break;
case 'charge.refunded': case 'price.created':
$this->handleRefund($event->data->object); case 'price.updated':
$this->handlePriceUpdate($event->data->object);
break; 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) { if ($userId && $cartId) {
return; // 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 // Update local price
$metadata = $charge->metadata; $price = ProductPrice::where('stripe_price_id', $stripePrice->id)->first();
$productId = $metadata->product_id ?? null;
if ($productId) { if ($price) {
$product = Product::find($productId); $price->update([
$quantity = $metadata->quantity ?? 1; 'active' => $stripePrice->active,
'unit_amount' => $stripePrice->unit_amount,
$product->increaseStock($quantity);
// Trigger refund actions
$product->callActions('refunded', null, [
'stripe_charge' => $charge,
]); ]);
} }
} }
} }
``` ```
### Configure Webhook Secret ## Best Practices
Add to `.env`: ### 1. Always Use Stripe Price IDs
```env When integrating with Stripe Checkout or subscriptions, always use Stripe Price IDs:
STRIPE_WEBHOOK_SECRET=whsec_...
```
Get your webhook secret from Stripe Dashboard → Developers → Webhooks.
## Multi-Currency Support
### Create Prices for Multiple Currencies
```php ```php
$product = Product::create([ $price = $product->defaultPrice()->first();
'slug' => 'premium-plan', $stripePriceId = $price->stripe_price_id;
'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);
``` ```
### Checkout with Currency Selection ### 2. Keep Prices in Sync
Use webhooks or scheduled commands to keep prices synchronized:
```php ```php
$currency = $request->input('currency', 'USD'); // app/Console/Commands/SyncStripePrices.php
use Stripe\Stripe;
use Stripe\Product as StripeProduct;
$price = $product->prices() Stripe::setApiKey(config('services.stripe.secret'));
->where('currency', $currency)
->first();
$session = Session::create([ Product::whereNotNull('stripe_product_id')->each(function ($product) {
'payment_method_types' => ['card'], ShopStripeService::syncProductPricesDown($product);
'line_items' => [[ });
'price' => $price->stripe_price_id, ```
'quantity' => 1,
]], ### 3. Store Stripe References
'mode' => 'payment',
'success_url' => route('checkout.success'), Always store Stripe IDs for traceability:
'cancel_url' => route('checkout.cancel'),
```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 ```php
try {
```env $stripeProduct = StripeProduct::create([
STRIPE_KEY=pk_test_... 'name' => $product->getLocalized('name'),
STRIPE_SECRET=sk_test_... ]);
} 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 ## 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 ```php
use Blax\Shop\Traits\HasShoppingCapabilities; 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 ## Direct Purchase
### Simple Purchase ### Purchase a Product
```php ```php
$user = auth()->user(); $user = auth()->user();
$product = Product::find($productId); $product = Product::find($productId);
// Product must have a default price
try { try {
$purchase = $user->purchase($product, quantity: 1); $purchase = $user->purchase($product);
// Purchase successful // Purchase successful
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'purchase_id' => $purchase->id, 'purchase_id' => $purchase->id,
'amount' => $purchase->amount,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
@ -36,32 +44,40 @@ try {
} }
``` ```
### Purchase with Options ### Purchase with Specific Price
```php ```php
$purchase = $user->purchase($product, quantity: 2, options: [ $price = ProductPrice::find($priceId);
'price_id' => $priceId, // Use specific price
'charge_id' => $paymentId, // Associate with payment $purchase = $user->purchase(
'cart_id' => $cartId, // Associate with cart $price,
'status' => 'pending', // Custom status quantity: 2
]); );
``` ```
### Check Purchase History ### Purchase with Metadata
```php ```php
// Check if user has purchased a product $purchase = $user->purchase(
if ($user->hasPurchased($product)) { $product,
// User has purchased this product quantity: 1,
} meta: [
'gift' => true,
// Get purchase history for a product 'message' => 'Happy Birthday!',
$history = $user->getPurchaseHistory($product); 'gift_recipient' => 'john@example.com',
]
// Get all completed purchases );
$purchases = $user->completedPurchases()->get();
``` ```
### 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 ## Shopping Cart
### Add to Cart ### Add to Cart
@ -84,13 +100,39 @@ try {
} }
``` ```
### Update Cart Quantity ### Add with Parameters
```php ```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 { try {
$user->updateCartQuantity($cartItem, quantity: 3); $updatedItem = $user->updateCartQuantity($cartItem, quantity: 3);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -104,479 +146,369 @@ try {
### Remove from Cart ### Remove from Cart
```php ```php
$cartItem = ProductPurchase::find($cartItemId); $cartItem = CartItem::find($cartItemId);
$user->removeFromCart($cartItem); $user->removeFromCart($cartItem);
return response()->json([
'success' => true,
'cart_total' => $user->getCartTotal(),
'cart_count' => $user->getCartItemsCount(),
]);
``` ```
### Get Cart Information ### Clear Cart
```php ```php
// Get all cart items $count = $user->clearCart();
$cartItems = $user->cartItems()->with('product')->get();
return response()->json([
'success' => true,
'removed_items' => $count,
]);
```
### Get Cart Totals
```php
// Get cart total // Get cart total
$total = $user->getCartTotal(); $total = $user->getCartTotal();
// Get items count // Get cart items count
$count = $user->getCartItemsCount(); $count = $user->getCartItemsCount();
// Clear cart // Get cart stats
$user->clearCart(); $stats = [
'total' => $user->getCartTotal(),
'count' => $user->getCartItemsCount(),
'items' => $user->cartItems()->with('purchasable')->get(),
];
``` ```
### Checkout ## Cart Checkout
### Convert Cart to Purchases
```php ```php
try { try {
$completedPurchases = $user->checkout(options: [ $purchases = $user->checkout();
'charge_id' => $paymentIntent->id,
]); // Checkout successful
// Cart items are now converted to completed purchases
// Cart is marked as converted
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'purchases' => $completedPurchases, 'purchases' => $purchases,
'total' => $completedPurchases->sum('amount'), 'total_items' => $purchases->count(),
]); ]);
} catch (\Exception $e) { } 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 ```php
$purchase = ProductPurchase::find($purchaseId); $product = Product::find($productId);
$user = $purchase->purchasable;
try { if ($user->hasPurchased($product)) {
$user->refundPurchase($purchase, options: [ // User has purchased this product
'refund_id' => $refundId, echo "You own this product!";
'reason' => 'Customer request',
]);
return response()->json(['success' => true]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
} }
``` ```
## 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 ```php
$stats = $user->getPurchaseStats(); $stats = $user->getPurchaseStats();
// Returns: // Returns:
// [ // [
// 'total_purchases' => 10, // 'total_purchases' => 15,
// 'total_spent' => 299.90, // 'total_spent' => 450.00,
// 'total_items' => 15, // 'total_items' => 23,
// 'cart_items' => 2, // 'cart_items' => 2,
// 'cart_total' => 49.98, // 'cart_total' => 89.99,
// ] // ]
``` ```
## Basic Purchase Flow ## Refunds
### 1. Check Product Availability ### Refund a Purchase
```php ```php
use Blax\Shop\Models\Product; $purchase = ProductPurchase::find($purchaseId);
$product = Product::find($productId); try {
$quantity = 1; $success = $user->refundPurchase($purchase);
// Check if product is available if ($success) {
if (!$product->isVisible()) { // Refund successful
return response()->json(['error' => 'Product not available'], 404); // Stock has been returned
} // Purchase status changed to 'refunded'
// Product 'refunded' actions triggered
// 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);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'cart_total' => $this->cartService->getTotal(), 'message' => 'Purchase refunded successfully',
]); ]);
} }
} catch (\Exception $e) {
public function checkout() return response()->json([
{ 'error' => $e->getMessage()
try { ], 400);
$checkoutData = $this->cartService->checkout();
return response()->json([
'success' => true,
'checkout' => $checkoutData,
]);
} 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 ```php
public function refund($purchaseId) // Get or create current active cart
{ $cart = $user->currentCart();
$purchase = ProductPurchase::findOrFail($purchaseId);
$product = $purchase->product;
// Process refund with payment processor // Cart properties
$refund = PaymentService::refund($purchase->meta['payment_id']); $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) { ### Cart Relationships
// Return stock
$product->increaseStock($purchase->quantity);
// Update purchase status ```php
$purchase->update([ // Get cart items
'status' => 'refunded', $items = $cart->items()->get();
'meta' => array_merge($purchase->meta, [
'refund_id' => $refund->id,
'refunded_at' => now(),
]),
]);
// Trigger refund actions // Get cart purchases (if converted)
$product->callActions('refunded', $purchase, [ $purchases = $cart->purchases()->get();
'refund' => $refund,
]);
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 ### Add Items to Cart Directly
Product actions allow you to execute custom logic when products are purchased:
```php ```php
use Blax\Shop\Models\ProductAction; use Blax\Shop\Models\Cart;
// Create action to grant access to a course $cart = Cart::find($cartId);
ProductAction::create([
'product_id' => $product->id,
'action_type' => 'grant_access',
'event' => 'purchased',
'config' => [
'resource_type' => 'course',
'resource_id' => 123,
],
'active' => true,
]);
// Action is automatically triggered when product is purchased $cartItem = $cart->addToCart(
// Implement the action handler in your application $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; use HasFactory, HasUuids, HasMetaTranslation;
protected $fillable = [ protected $fillable = [
'name',
'slug', 'slug',
'short_description', 'sku',
'description',
'type', 'type',
'stripe_product_id', 'stripe_product_id',
'sale_start', 'sale_start',
@ -45,7 +43,6 @@ class Product extends Model implements Purchasable, Cartable
'status', 'status',
'published_at', 'published_at',
'meta', 'meta',
'sku',
'tax_class', 'tax_class',
'sort_order', 'sort_order',
]; ];

View File

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

View File

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

View File

@ -103,18 +103,21 @@ class ProductAttributeTest extends TestCase
{ {
$product = Product::factory()->create(); $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, 'product_id' => $product->id,
'key' => 'Dimensions', 'key' => 'Dimensions',
'value' => '10x20x30', 'value' => '10x20x30',
'meta' => [
'unit' => 'cm',
'display_format' => 'length x width x height',
],
]); ]);
$this->assertEquals('cm', $attribute->meta->unit); $unitAttr = ProductAttribute::create([
$this->assertEquals('length x width x height', $attribute->meta->display_format); 'product_id' => $product->id,
'key' => 'Dimension Unit',
'value' => 'cm',
]);
$this->assertEquals('10x20x30', $dimensionAttr->value);
$this->assertEquals('cm', $unitAttr->value);
} }
/** @test */ /** @test */