I tests & documentation
This commit is contained in:
parent
3593a462a1
commit
82ee18b0f1
|
|
@ -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,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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 = [])
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue