I tests & documentation
This commit is contained in:
parent
3593a462a1
commit
82ee18b0f1
|
|
@ -1,5 +1,15 @@
|
|||
# Product Management
|
||||
|
||||
## Overview
|
||||
|
||||
The Laravel Shop package provides a complete product management system with support for:
|
||||
- Multi-language content through `HasMetaTranslation` trait
|
||||
- Flexible pricing with `ProductPrice` model
|
||||
- Stock management and reservations
|
||||
- Product variants (parent/child relationships)
|
||||
- Categories, attributes, and actions
|
||||
- Product relations (related, upsell, cross-sell)
|
||||
|
||||
## Creating Products
|
||||
|
||||
### Minimal Product Creation
|
||||
|
|
@ -15,8 +25,8 @@ $product = Product::create([
|
|||
```
|
||||
|
||||
This will automatically:
|
||||
- Generate a random slug if not provided
|
||||
- Create a default name "New Product [slug]"
|
||||
- Generate a random slug if not provided (e.g., 'new-product-abc12345')
|
||||
- Initialize meta field as empty JSON object
|
||||
- Set status to 'draft'
|
||||
- Set type to 'simple'
|
||||
|
||||
|
|
@ -27,14 +37,12 @@ $product = Product::create([
|
|||
'slug' => 'blue-hoodie',
|
||||
'sku' => 'HOOD-BLU-001',
|
||||
'type' => 'simple',
|
||||
'price' => 49.99,
|
||||
'regular_price' => 49.99,
|
||||
'status' => 'published',
|
||||
'is_visible' => true,
|
||||
'featured' => false,
|
||||
]);
|
||||
|
||||
// Add translated content
|
||||
// Add translated content (stored in meta column)
|
||||
$product->setLocalized('name', 'Blue Hoodie', 'en');
|
||||
$product->setLocalized('description', 'Comfortable cotton hoodie', 'en');
|
||||
$product->setLocalized('short_description', 'Cotton hoodie', 'en');
|
||||
|
|
@ -54,19 +62,13 @@ $product = Product::create([
|
|||
'published_at' => now(),
|
||||
'sort_order' => 10,
|
||||
|
||||
// Pricing
|
||||
'price' => 199.99,
|
||||
'regular_price' => 249.99,
|
||||
'sale_price' => 199.99,
|
||||
// Sale Period
|
||||
'sale_start' => now(),
|
||||
'sale_end' => now()->addDays(7),
|
||||
|
||||
// Stock Management
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 50,
|
||||
'low_stock_threshold' => 10,
|
||||
'in_stock' => true,
|
||||
'stock_status' => 'instock',
|
||||
|
||||
// Physical Properties
|
||||
'weight' => 0.5, // kg
|
||||
|
|
@ -78,13 +80,6 @@ $product = Product::create([
|
|||
|
||||
// Tax
|
||||
'tax_class' => 'standard',
|
||||
|
||||
// Custom Meta
|
||||
'meta' => [
|
||||
'brand' => 'AudioPro',
|
||||
'color' => 'black',
|
||||
'warranty' => '2 years',
|
||||
],
|
||||
]);
|
||||
|
||||
// Add translations
|
||||
|
|
@ -93,158 +88,349 @@ $product->setLocalized('name', 'Auriculares Premium Inalámbricos', 'es');
|
|||
|
||||
$product->setLocalized('description', 'High-quality wireless headphones with noise cancellation', 'en');
|
||||
$product->setLocalized('short_description', 'Premium wireless headphones', 'en');
|
||||
|
||||
// Add custom meta data
|
||||
$product->meta = (object)[
|
||||
'brand' => 'AudioPro',
|
||||
'color' => 'black',
|
||||
'warranty' => '2 years',
|
||||
];
|
||||
$product->save();
|
||||
```
|
||||
|
||||
## Product Types
|
||||
## Product Pricing
|
||||
|
||||
### Simple Product
|
||||
Products use the `ProductPrice` model for flexible pricing. Each product must have at least one price to be purchasable.
|
||||
|
||||
### Creating Product Prices
|
||||
|
||||
```php
|
||||
$product = Product::create([
|
||||
'type' => 'simple',
|
||||
'slug' => 't-shirt',
|
||||
'price' => 19.99,
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
|
||||
// Create a default price
|
||||
$price = ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'currency' => 'USD',
|
||||
'unit_amount' => 4999, // $49.99 in cents
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
'type' => 'one_time',
|
||||
]);
|
||||
|
||||
// Add sale price
|
||||
$price->update([
|
||||
'sale_unit_amount' => 3999, // $39.99
|
||||
]);
|
||||
```
|
||||
|
||||
### Variable Product (Parent)
|
||||
### Multi-Currency Pricing
|
||||
|
||||
```php
|
||||
// USD price (default)
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'currency' => 'USD',
|
||||
'unit_amount' => 4999,
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// EUR price
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'currency' => 'EUR',
|
||||
'unit_amount' => 4499,
|
||||
'is_default' => false,
|
||||
'active' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
### Recurring Prices (Subscriptions)
|
||||
|
||||
```php
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'currency' => 'USD',
|
||||
'unit_amount' => 999, // $9.99/month
|
||||
'type' => 'recurring',
|
||||
'interval' => 'month',
|
||||
'interval_count' => 1,
|
||||
'trial_period_days' => 7,
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
### Get Current Price
|
||||
|
||||
```php
|
||||
// Get the current price (considers sale prices and dates)
|
||||
$currentPrice = $product->getCurrentPrice(); // Returns float
|
||||
|
||||
// Check if product is on sale
|
||||
if ($product->isOnSale()) {
|
||||
echo "On sale!";
|
||||
}
|
||||
|
||||
// Get default price
|
||||
$defaultPrice = $product->defaultPrice()->first();
|
||||
```
|
||||
|
||||
## Stock Management
|
||||
|
||||
### Enable Stock Management
|
||||
|
||||
```php
|
||||
$product->update([
|
||||
'manage_stock' => true,
|
||||
'low_stock_threshold' => 10,
|
||||
]);
|
||||
```
|
||||
|
||||
### Increase/Decrease Stock
|
||||
|
||||
```php
|
||||
// Increase stock
|
||||
$product->increaseStock(50);
|
||||
|
||||
// Decrease stock
|
||||
$product->decreaseStock(1);
|
||||
|
||||
// Get available stock
|
||||
$available = $product->getAvailableStock();
|
||||
|
||||
// Check if in stock
|
||||
if ($product->isInStock()) {
|
||||
echo "In stock!";
|
||||
}
|
||||
|
||||
// Check if low stock
|
||||
if ($product->isLowStock()) {
|
||||
echo "Low stock warning!";
|
||||
}
|
||||
```
|
||||
|
||||
### Stock Reservations
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductStock;
|
||||
|
||||
// Reserve stock temporarily
|
||||
$reservation = $product->reserveStock(
|
||||
quantity: 2,
|
||||
reference: $cart,
|
||||
until: now()->addMinutes(15),
|
||||
note: 'Cart reservation'
|
||||
);
|
||||
|
||||
// Release reservation
|
||||
$reservation->update(['status' => 'completed']);
|
||||
|
||||
// Get active reservations
|
||||
$reservations = $product->reservations()->get();
|
||||
```
|
||||
|
||||
### Stock History
|
||||
|
||||
```php
|
||||
// Get all stock records
|
||||
$stockRecords = $product->stocks()->get();
|
||||
|
||||
// Filter by type
|
||||
$increases = $product->stocks()->where('type', 'increase')->get();
|
||||
$decreases = $product->stocks()->where('type', 'decrease')->get();
|
||||
$reservations = $product->stocks()->where('type', 'reservation')->get();
|
||||
```
|
||||
|
||||
## Product Variants
|
||||
|
||||
### Create Parent Product
|
||||
|
||||
```php
|
||||
$parent = Product::create([
|
||||
'type' => 'variable',
|
||||
'slug' => 'hoodie',
|
||||
'price' => 49.99, // Base price
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
// Create variants
|
||||
$parent->setLocalized('name', 'Hoodie', 'en');
|
||||
```
|
||||
|
||||
### Create Variants
|
||||
|
||||
```php
|
||||
// Small variant
|
||||
$small = Product::create([
|
||||
'type' => 'simple',
|
||||
'slug' => 'hoodie-small',
|
||||
'sku' => 'HOOD-S',
|
||||
'parent_id' => $parent->id,
|
||||
'price' => 49.99,
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $small->id,
|
||||
'currency' => 'USD',
|
||||
'unit_amount' => 4999,
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// Medium variant
|
||||
$medium = Product::create([
|
||||
'type' => 'simple',
|
||||
'slug' => 'hoodie-medium',
|
||||
'sku' => 'HOOD-M',
|
||||
'parent_id' => $parent->id,
|
||||
'price' => 49.99,
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
$large = Product::create([
|
||||
'type' => 'simple',
|
||||
'slug' => 'hoodie-large',
|
||||
'sku' => 'HOOD-L',
|
||||
'parent_id' => $parent->id,
|
||||
'price' => 54.99, // Different price
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $medium->id,
|
||||
'currency' => 'USD',
|
||||
'unit_amount' => 4999,
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// Get all variants
|
||||
$variants = $parent->children()->get();
|
||||
```
|
||||
|
||||
### Grouped Product
|
||||
## Categories
|
||||
|
||||
```php
|
||||
$bundle = Product::create([
|
||||
'type' => 'grouped',
|
||||
'slug' => 'starter-bundle',
|
||||
'price' => 99.99,
|
||||
]);
|
||||
|
||||
// Link products to the bundle (handle this in your app logic)
|
||||
```
|
||||
|
||||
### Virtual/Downloadable Product
|
||||
|
||||
```php
|
||||
$ebook = Product::create([
|
||||
'slug' => 'laravel-guide',
|
||||
'price' => 29.99,
|
||||
'virtual' => true,
|
||||
'downloadable' => true,
|
||||
'manage_stock' => false, // Virtual products don't need stock
|
||||
]);
|
||||
```
|
||||
|
||||
## Product Attributes
|
||||
|
||||
Add custom attributes to products:
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductAttribute;
|
||||
|
||||
// Add size attribute
|
||||
ProductAttribute::create([
|
||||
'product_id' => $product->id,
|
||||
'key' => 'size',
|
||||
'value' => 'Large',
|
||||
'type' => 'select',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Add color attribute
|
||||
ProductAttribute::create([
|
||||
'product_id' => $product->id,
|
||||
'key' => 'color',
|
||||
'value' => '#FF0000',
|
||||
'type' => 'color',
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
// Retrieve attributes
|
||||
$attributes = $product->attributes;
|
||||
```
|
||||
|
||||
## Product Categories
|
||||
### Create Categories
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductCategory;
|
||||
|
||||
// Create category
|
||||
$category = ProductCategory::create([
|
||||
'slug' => 'clothing',
|
||||
'name' => 'Electronics',
|
||||
'slug' => 'electronics',
|
||||
'description' => 'Electronic products',
|
||||
'is_visible' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
$category->setLocalized('name', 'Clothing', 'en');
|
||||
|
||||
// Attach product to category
|
||||
$product->categories()->attach($category->id);
|
||||
|
||||
// Detach
|
||||
$product->categories()->detach($category->id);
|
||||
|
||||
// Sync categories
|
||||
$product->categories()->sync([
|
||||
$category1->id,
|
||||
$category2->id,
|
||||
// Create child category
|
||||
$subCategory = ProductCategory::create([
|
||||
'name' => 'Headphones',
|
||||
'slug' => 'headphones',
|
||||
'parent_id' => $category->id,
|
||||
'is_visible' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
```
|
||||
|
||||
## Multi-Currency Pricing
|
||||
### Attach Products to Categories
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
// Attach single category
|
||||
$product->categories()->attach($category->id);
|
||||
|
||||
// Add EUR pricing
|
||||
ProductPrice::create([
|
||||
// Attach multiple categories
|
||||
$product->categories()->attach([$category1->id, $category2->id]);
|
||||
|
||||
// Sync categories (removes others)
|
||||
$product->categories()->sync([$category1->id, $category2->id]);
|
||||
|
||||
// Get product categories
|
||||
$categories = $product->categories()->get();
|
||||
```
|
||||
|
||||
### Query Products by Category
|
||||
|
||||
```php
|
||||
// Get products in category
|
||||
$products = Product::byCategory($category->id)->get();
|
||||
|
||||
// Get category tree
|
||||
$tree = ProductCategory::getTree();
|
||||
|
||||
// Get visible categories
|
||||
$visible = ProductCategory::visible()->get();
|
||||
|
||||
// Get root categories
|
||||
$roots = ProductCategory::roots()->get();
|
||||
```
|
||||
|
||||
## Product Attributes
|
||||
|
||||
### Add Attributes
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductAttribute;
|
||||
|
||||
// Add color attribute
|
||||
ProductAttribute::create([
|
||||
'product_id' => $product->id,
|
||||
'currency' => 'EUR',
|
||||
'price' => 39.99,
|
||||
'is_default' => false,
|
||||
'key' => 'Color',
|
||||
'value' => 'Blue',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Add GBP pricing
|
||||
ProductPrice::create([
|
||||
// Add size attribute
|
||||
ProductAttribute::create([
|
||||
'product_id' => $product->id,
|
||||
'currency' => 'GBP',
|
||||
'price' => 34.99,
|
||||
'is_default' => false,
|
||||
'key' => 'Size',
|
||||
'value' => 'Large',
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
// Get all prices
|
||||
$prices = $product->prices;
|
||||
// Get product attributes
|
||||
$attributes = $product->attributes()->get();
|
||||
```
|
||||
|
||||
// Get price for specific currency
|
||||
$eurPrice = $product->prices()->where('currency', 'EUR')->first();
|
||||
## Product Actions
|
||||
|
||||
Product actions allow you to trigger events when certain things happen (e.g., on purchase).
|
||||
|
||||
### Create Product Actions
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductAction;
|
||||
|
||||
// Send email on purchase
|
||||
ProductAction::create([
|
||||
'product_id' => $product->id,
|
||||
'action_type' => 'SendWelcomeEmail',
|
||||
'event' => 'purchased',
|
||||
'parameters' => [
|
||||
'template' => 'welcome',
|
||||
'delay' => 0,
|
||||
],
|
||||
'active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Grant access on purchase
|
||||
ProductAction::create([
|
||||
'product_id' => $product->id,
|
||||
'action_type' => 'GrantCourseAccess',
|
||||
'event' => 'purchased',
|
||||
'parameters' => [
|
||||
'course_id' => 123,
|
||||
],
|
||||
'active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
```
|
||||
|
||||
### Trigger Actions
|
||||
|
||||
```php
|
||||
// Actions are automatically triggered on events
|
||||
// You can also manually trigger them:
|
||||
$product->callActions('purchased', $productPurchase);
|
||||
|
||||
// On refund
|
||||
$product->callActions('refunded', $productPurchase);
|
||||
```
|
||||
|
||||
## Product Relations
|
||||
|
|
@ -252,155 +438,155 @@ $eurPrice = $product->prices()->where('currency', 'EUR')->first();
|
|||
### Related Products
|
||||
|
||||
```php
|
||||
// Attach related products
|
||||
// Add related products
|
||||
$product->relatedProducts()->attach($relatedProduct->id, [
|
||||
'type' => 'related',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Get all related products
|
||||
$related = $product->relatedProducts()->get();
|
||||
// Get related products
|
||||
$related = $product->relatedProducts()->wherePivot('type', 'related')->get();
|
||||
```
|
||||
|
||||
### Upsells
|
||||
|
||||
```php
|
||||
// Attach upsell product
|
||||
$product->relatedProducts()->attach($premiumProduct->id, [
|
||||
// Add upsell product
|
||||
$product->relatedProducts()->attach($upsellProduct->id, [
|
||||
'type' => 'upsell',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Get upsells
|
||||
$upsells = $product->upsells;
|
||||
// Get upsell products
|
||||
$upsells = $product->upsells()->get();
|
||||
```
|
||||
|
||||
### Cross-sells
|
||||
|
||||
```php
|
||||
// Attach cross-sell product
|
||||
$product->relatedProducts()->attach($accessory->id, [
|
||||
// Add cross-sell product
|
||||
$product->relatedProducts()->attach($crossSellProduct->id, [
|
||||
'type' => 'cross-sell',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Get cross-sells
|
||||
$crossSells = $product->crossSells;
|
||||
// Get cross-sell products
|
||||
$crossSells = $product->crossSells()->get();
|
||||
```
|
||||
|
||||
## Querying Products
|
||||
|
||||
### Basic Queries
|
||||
### Scopes
|
||||
|
||||
```php
|
||||
// Published products
|
||||
$products = Product::published()->get();
|
||||
$published = Product::published()->get();
|
||||
|
||||
// In stock products
|
||||
$products = Product::inStock()->get();
|
||||
// Visible products (published + visible + published_at check)
|
||||
$visible = Product::visible()->get();
|
||||
|
||||
// Featured products
|
||||
$products = Product::featured()->get();
|
||||
$featured = Product::featured()->get();
|
||||
|
||||
// Visible products (published and within publish date)
|
||||
$products = Product::visible()->get();
|
||||
```
|
||||
|
||||
### Advanced Queries
|
||||
|
||||
```php
|
||||
// Search products
|
||||
$products = Product::search('hoodie')->get();
|
||||
|
||||
// Filter by category
|
||||
$products = Product::byCategory($categoryId)->get();
|
||||
|
||||
// Price range
|
||||
$products = Product::priceRange(10, 50)->get();
|
||||
|
||||
// Order by price
|
||||
$products = Product::orderByPrice('asc')->get();
|
||||
// In stock products
|
||||
$inStock = Product::inStock()->get();
|
||||
|
||||
// Low stock products
|
||||
$products = Product::lowStock()->get();
|
||||
$lowStock = Product::lowStock()->get();
|
||||
|
||||
// Combined query
|
||||
// By type
|
||||
$simple = Product::where('type', 'simple')->get();
|
||||
$virtual = Product::where('virtual', true)->get();
|
||||
$downloadable = Product::where('downloadable', true)->get();
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```php
|
||||
// Search by slug, SKU, or name
|
||||
$results = Product::search('headphones')->get();
|
||||
|
||||
// Price range
|
||||
$results = Product::priceRange(min: 10.00, max: 100.00)->get();
|
||||
|
||||
// Order by price
|
||||
$cheapest = Product::orderByPrice('asc')->get();
|
||||
$expensive = Product::orderByPrice('desc')->get();
|
||||
```
|
||||
|
||||
### Combining Scopes
|
||||
|
||||
```php
|
||||
$products = Product::visible()
|
||||
->inStock()
|
||||
->byCategory($categoryId)
|
||||
->priceRange(20, 100)
|
||||
->priceRange(min: 20, max: 50)
|
||||
->orderByPrice('asc')
|
||||
->paginate(20);
|
||||
->get();
|
||||
```
|
||||
|
||||
## Product Methods
|
||||
|
||||
### Sale Detection
|
||||
|
||||
```php
|
||||
if ($product->isOnSale()) {
|
||||
echo "On sale!";
|
||||
}
|
||||
```
|
||||
|
||||
### Current Price
|
||||
|
||||
```php
|
||||
$price = $product->getCurrentPrice(); // Returns sale_price if on sale, otherwise regular_price
|
||||
```
|
||||
|
||||
### Visibility Check
|
||||
## Product Visibility
|
||||
|
||||
```php
|
||||
// Check if product is visible
|
||||
if ($product->isVisible()) {
|
||||
// Show product
|
||||
// Product is published, visible, and published_at is in past
|
||||
}
|
||||
|
||||
// Set visibility
|
||||
$product->update([
|
||||
'status' => 'published',
|
||||
'is_visible' => true,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
```
|
||||
|
||||
### Low Stock Check
|
||||
## Virtual & Downloadable Products
|
||||
|
||||
```php
|
||||
if ($product->isLowStock()) {
|
||||
// Show low stock warning
|
||||
}
|
||||
// Virtual product (no shipping)
|
||||
$product->update([
|
||||
'virtual' => true,
|
||||
]);
|
||||
|
||||
// Downloadable product
|
||||
$product->update([
|
||||
'downloadable' => true,
|
||||
]);
|
||||
|
||||
// Both
|
||||
$product->update([
|
||||
'virtual' => true,
|
||||
'downloadable' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
## API Serialization
|
||||
## API Export
|
||||
|
||||
```php
|
||||
// Get API-friendly array
|
||||
// Get product as API array
|
||||
$data = $product->toApiArray();
|
||||
|
||||
// Returns:
|
||||
// [
|
||||
// 'id' => '...',
|
||||
// 'slug' => '...',
|
||||
// 'name' => '...',
|
||||
// 'sku' => '...',
|
||||
// 'name' => '...', // localized
|
||||
// 'description' => '...', // localized
|
||||
// 'short_description' => '...', // localized
|
||||
// 'type' => '...',
|
||||
// 'price' => 49.99,
|
||||
// 'is_on_sale' => true,
|
||||
// 'in_stock' => true,
|
||||
// 'sale_price' => null,
|
||||
// 'is_on_sale' => false,
|
||||
// 'low_stock' => false,
|
||||
// 'featured' => false,
|
||||
// 'virtual' => false,
|
||||
// 'downloadable' => false,
|
||||
// 'weight' => 0.5,
|
||||
// 'dimensions' => [...],
|
||||
// 'categories' => [...],
|
||||
// 'attributes' => [...],
|
||||
// 'variants' => [...],
|
||||
// // ...
|
||||
// 'parent' => null,
|
||||
// 'created_at' => '...',
|
||||
// 'updated_at' => '...',
|
||||
// ]
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
The package dispatches events on product lifecycle:
|
||||
|
||||
```php
|
||||
use Blax\Shop\Events\ProductCreated;
|
||||
use Blax\Shop\Events\ProductUpdated;
|
||||
|
||||
// Listen to events in your EventServiceProvider
|
||||
protected $listen = [
|
||||
ProductCreated::class => [
|
||||
SendProductCreatedNotification::class,
|
||||
],
|
||||
ProductUpdated::class => [
|
||||
ClearProductCache::class,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
# Stripe Integration
|
||||
|
||||
## Overview
|
||||
|
||||
The Laravel Shop package includes Stripe integration for:
|
||||
- Syncing products from Stripe to your database
|
||||
- Syncing prices from Stripe
|
||||
- Associating products with Stripe product IDs
|
||||
- Automatic price synchronization
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable Stripe
|
||||
### Environment Setup
|
||||
|
||||
Add to your `.env`:
|
||||
|
||||
```env
|
||||
SHOP_STRIPE_ENABLED=true
|
||||
SHOP_STRIPE_SYNC_PRICES=true
|
||||
STRIPE_KEY=your_stripe_key
|
||||
STRIPE_SECRET=your_stripe_secret
|
||||
STRIPE_KEY=pk_test_...
|
||||
STRIPE_SECRET=sk_test_...
|
||||
```
|
||||
|
||||
### Config File
|
||||
|
||||
Update `config/shop.php`:
|
||||
|
||||
```php
|
||||
|
|
@ -23,88 +33,284 @@ Update `config/shop.php`:
|
|||
],
|
||||
```
|
||||
|
||||
## Creating Products in Stripe
|
||||
## Syncing Products from Stripe
|
||||
|
||||
### Manual Stripe Product Creation
|
||||
### Sync Individual Product
|
||||
|
||||
```php
|
||||
use App\Services\StripeService;
|
||||
use Blax\Shop\Services\ShopStripeService;
|
||||
use Stripe\Product as StripeProduct;
|
||||
|
||||
// Get Stripe product
|
||||
$stripeProduct = StripeProduct::retrieve('prod_xxxxx');
|
||||
|
||||
// Sync to local database
|
||||
$product = ShopStripeService::syncProductDown($stripeProduct);
|
||||
|
||||
// This creates/updates a Product with:
|
||||
// - stripe_product_id
|
||||
// - slug (generated from name)
|
||||
// - type
|
||||
// - virtual flag
|
||||
// - status (based on Stripe active status)
|
||||
// - name (localized)
|
||||
// - features (if available)
|
||||
```
|
||||
|
||||
### Sync Product Prices
|
||||
|
||||
```php
|
||||
// Sync all prices for a product
|
||||
ShopStripeService::syncProductPricesDown($product);
|
||||
|
||||
// This creates/updates ProductPrice records with:
|
||||
// - stripe_price_id
|
||||
// - name (from Stripe nickname)
|
||||
// - type (one_time or recurring)
|
||||
// - unit_amount (price in cents)
|
||||
// - currency
|
||||
// - billing_scheme
|
||||
// - interval (for recurring)
|
||||
// - interval_count (for recurring)
|
||||
// - trial_period_days (for recurring)
|
||||
```
|
||||
|
||||
## Manual Product Creation with Stripe
|
||||
|
||||
### Create Product and Sync to Stripe
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\Product;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\Product as StripeProduct;
|
||||
use Stripe\Price;
|
||||
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
|
||||
// Create local product first
|
||||
$product = Product::create([
|
||||
'slug' => 'premium-plan',
|
||||
'price' => 29.99,
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
// Create in Stripe
|
||||
$stripeProduct = StripeService::createProduct($product);
|
||||
$product->setLocalized('name', 'Premium Plan', 'en');
|
||||
$product->setLocalized('description', 'Access to all premium features', 'en');
|
||||
|
||||
// Store Stripe product ID
|
||||
// Create in Stripe
|
||||
$stripeProduct = StripeProduct::create([
|
||||
'name' => $product->getLocalized('name'),
|
||||
'description' => $product->getLocalized('description'),
|
||||
'metadata' => [
|
||||
'product_id' => $product->id,
|
||||
],
|
||||
]);
|
||||
|
||||
// Save Stripe product ID
|
||||
$product->update([
|
||||
'stripe_product_id' => $stripeProduct->id,
|
||||
]);
|
||||
|
||||
// Create price in Stripe
|
||||
$stripePrice = Price::create([
|
||||
'product' => $stripeProduct->id,
|
||||
'unit_amount' => 2999, // $29.99
|
||||
'currency' => 'usd',
|
||||
'recurring' => [
|
||||
'interval' => 'month',
|
||||
],
|
||||
]);
|
||||
|
||||
// Create local price
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'stripe_price_id' => $stripePrice->id,
|
||||
'currency' => 'usd',
|
||||
'unit_amount' => 2999,
|
||||
'type' => 'recurring',
|
||||
'interval' => 'month',
|
||||
'interval_count' => 1,
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
### Automatic Sync
|
||||
## Automatic Syncing with Events
|
||||
|
||||
If you have event listeners set up, products can be automatically synced to Stripe:
|
||||
You can set up event listeners to automatically sync products to Stripe when they're created or updated.
|
||||
|
||||
### Create Event Listener
|
||||
|
||||
```php
|
||||
// app/Listeners/SyncProductToStripe.php
|
||||
namespace App\Listeners;
|
||||
|
||||
use Blax\Shop\Events\ProductCreated;
|
||||
use App\Listeners\SyncProductToStripe;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\Product as StripeProduct;
|
||||
|
||||
// In EventServiceProvider
|
||||
protected $listen = [
|
||||
ProductCreated::class => [
|
||||
SyncProductToStripe::class,
|
||||
],
|
||||
];
|
||||
|
||||
// Listener implementation
|
||||
class SyncProductToStripe
|
||||
{
|
||||
public function handle(ProductCreated $event)
|
||||
{
|
||||
if (config('shop.stripe.enabled')) {
|
||||
$stripeProduct = StripeService::createProduct($event->product);
|
||||
|
||||
$event->product->update([
|
||||
'stripe_product_id' => $stripeProduct->id,
|
||||
]);
|
||||
if (!config('shop.stripe.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$product = $event->product;
|
||||
|
||||
// Skip if already has Stripe ID
|
||||
if ($product->stripe_product_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
|
||||
$stripeProduct = StripeProduct::create([
|
||||
'name' => $product->getLocalized('name') ?? $product->slug,
|
||||
'description' => $product->getLocalized('description'),
|
||||
'metadata' => [
|
||||
'product_id' => $product->id,
|
||||
'sku' => $product->sku,
|
||||
],
|
||||
]);
|
||||
|
||||
$product->update([
|
||||
'stripe_product_id' => $stripeProduct->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Syncing Prices to Stripe
|
||||
|
||||
### Create Stripe Prices
|
||||
### Register Event Listener
|
||||
|
||||
```php
|
||||
use App\Services\StripeService;
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
// app/Providers/EventServiceProvider.php
|
||||
use Blax\Shop\Events\ProductCreated;
|
||||
use Blax\Shop\Events\ProductUpdated;
|
||||
use App\Listeners\SyncProductToStripe;
|
||||
use App\Listeners\UpdateStripeProduct;
|
||||
|
||||
// Sync default price
|
||||
StripeService::syncProductPricesDown($product);
|
||||
protected $listen = [
|
||||
ProductCreated::class => [
|
||||
SyncProductToStripe::class,
|
||||
],
|
||||
ProductUpdated::class => [
|
||||
UpdateStripeProduct::class,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
// Create additional currency prices
|
||||
$eurPrice = ProductPrice::create([
|
||||
'product_id' => $product->id,
|
||||
'currency' => 'EUR',
|
||||
'price' => 24.99,
|
||||
## Working with Stripe Prices
|
||||
|
||||
### One-Time Prices
|
||||
|
||||
```php
|
||||
use Stripe\Stripe;
|
||||
use Stripe\Price;
|
||||
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
|
||||
// Create one-time price in Stripe
|
||||
$stripePrice = Price::create([
|
||||
'product' => $product->stripe_product_id,
|
||||
'unit_amount' => 4999, // $49.99
|
||||
'currency' => 'usd',
|
||||
]);
|
||||
|
||||
// Create corresponding Stripe price
|
||||
$stripePrice = StripeService::createPrice($product, $eurPrice);
|
||||
|
||||
$eurPrice->update([
|
||||
// Create local price
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'stripe_price_id' => $stripePrice->id,
|
||||
'currency' => 'usd',
|
||||
'unit_amount' => 4999,
|
||||
'type' => 'one_time',
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
## Creating Checkout Sessions
|
||||
### Recurring Prices
|
||||
|
||||
### One-time Payment
|
||||
```php
|
||||
// Monthly subscription
|
||||
$stripePrice = Price::create([
|
||||
'product' => $product->stripe_product_id,
|
||||
'unit_amount' => 999, // $9.99/month
|
||||
'currency' => 'usd',
|
||||
'recurring' => [
|
||||
'interval' => 'month',
|
||||
'interval_count' => 1,
|
||||
'trial_period_days' => 7,
|
||||
],
|
||||
]);
|
||||
|
||||
// Create local price
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'stripe_price_id' => $stripePrice->id,
|
||||
'currency' => 'usd',
|
||||
'unit_amount' => 999,
|
||||
'type' => 'recurring',
|
||||
'interval' => 'month',
|
||||
'interval_count' => 1,
|
||||
'trial_period_days' => 7,
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
### Multiple Currency Prices
|
||||
|
||||
```php
|
||||
// USD price
|
||||
$usdPrice = Price::create([
|
||||
'product' => $product->stripe_product_id,
|
||||
'unit_amount' => 2999,
|
||||
'currency' => 'usd',
|
||||
'recurring' => ['interval' => 'month'],
|
||||
]);
|
||||
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'stripe_price_id' => $usdPrice->id,
|
||||
'currency' => 'usd',
|
||||
'unit_amount' => 2999,
|
||||
'type' => 'recurring',
|
||||
'interval' => 'month',
|
||||
'interval_count' => 1,
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// EUR price
|
||||
$eurPrice = Price::create([
|
||||
'product' => $product->stripe_product_id,
|
||||
'unit_amount' => 2499,
|
||||
'currency' => 'eur',
|
||||
'recurring' => ['interval' => 'month'],
|
||||
]);
|
||||
|
||||
ProductPrice::create([
|
||||
'purchasable_type' => Product::class,
|
||||
'purchasable_id' => $product->id,
|
||||
'stripe_price_id' => $eurPrice->id,
|
||||
'currency' => 'eur',
|
||||
'unit_amount' => 2499,
|
||||
'type' => 'recurring',
|
||||
'interval' => 'month',
|
||||
'interval_count' => 1,
|
||||
'is_default' => false,
|
||||
'active' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
## Stripe Checkout Integration
|
||||
|
||||
### Create Checkout Session
|
||||
|
||||
```php
|
||||
use Stripe\Stripe;
|
||||
|
|
@ -112,244 +318,224 @@ use Stripe\Checkout\Session;
|
|||
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
|
||||
$product = Product::find($productId);
|
||||
Route::post('/checkout', function () {
|
||||
$user = auth()->user();
|
||||
$cartItems = $user->cartItems()->with('purchasable.prices')->get();
|
||||
|
||||
$session = Session::create([
|
||||
'payment_method_types' => ['card'],
|
||||
'line_items' => [[
|
||||
'price_data' => [
|
||||
'currency' => 'usd',
|
||||
'product_data' => [
|
||||
'name' => $product->getLocalized('name'),
|
||||
'description' => $product->getLocalized('short_description'),
|
||||
],
|
||||
'unit_amount' => $product->getCurrentPrice() * 100, // Convert to cents
|
||||
// Build line items from cart
|
||||
$lineItems = $cartItems->map(function ($item) {
|
||||
$price = $item->purchasable->defaultPrice()->first();
|
||||
|
||||
return [
|
||||
'price' => $price->stripe_price_id,
|
||||
'quantity' => $item->quantity,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
// Create checkout session
|
||||
$session = Session::create([
|
||||
'payment_method_types' => ['card'],
|
||||
'line_items' => $lineItems,
|
||||
'mode' => 'payment', // or 'subscription' for recurring
|
||||
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
|
||||
'cancel_url' => route('checkout.cancel'),
|
||||
'customer_email' => $user->email,
|
||||
'metadata' => [
|
||||
'user_id' => $user->id,
|
||||
'cart_id' => $user->currentCart()->id,
|
||||
],
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'mode' => 'payment',
|
||||
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
|
||||
'cancel_url' => route('checkout.cancel'),
|
||||
'metadata' => [
|
||||
'product_id' => $product->id,
|
||||
],
|
||||
]);
|
||||
]);
|
||||
|
||||
return redirect($session->url);
|
||||
return redirect($session->url);
|
||||
});
|
||||
```
|
||||
|
||||
### Using Stripe Price IDs
|
||||
### Handle Successful Payment
|
||||
|
||||
```php
|
||||
// If you have synced prices
|
||||
$priceId = $product->prices()
|
||||
->where('currency', 'USD')
|
||||
->where('is_default', true)
|
||||
->first()
|
||||
->stripe_price_id;
|
||||
Route::get('/checkout/success', function (Request $request) {
|
||||
$sessionId = $request->get('session_id');
|
||||
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
$session = Session::retrieve($sessionId);
|
||||
|
||||
$session = Session::create([
|
||||
'payment_method_types' => ['card'],
|
||||
'line_items' => [[
|
||||
'price' => $priceId,
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'mode' => 'payment',
|
||||
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
|
||||
'cancel_url' => route('checkout.cancel'),
|
||||
]);
|
||||
// Verify payment succeeded
|
||||
if ($session->payment_status === 'paid') {
|
||||
$user = auth()->user();
|
||||
|
||||
// Convert cart to purchases
|
||||
$purchases = $user->checkout();
|
||||
|
||||
// Store charge ID
|
||||
foreach ($purchases as $purchase) {
|
||||
$purchase->update([
|
||||
'status' => 'completed',
|
||||
'charge_id' => $session->payment_intent,
|
||||
'amount_paid' => $session->amount_total / 100,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('checkout.success', compact('purchases'));
|
||||
}
|
||||
|
||||
return redirect()->route('cart.index')
|
||||
->with('error', 'Payment was not successful');
|
||||
});
|
||||
```
|
||||
|
||||
## Handling Webhooks
|
||||
## Webhook Handling
|
||||
|
||||
### Register Webhook Endpoint
|
||||
|
||||
```php
|
||||
// routes/api.php
|
||||
use App\Http\Controllers\StripeWebhookController;
|
||||
|
||||
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']);
|
||||
// routes/web.php
|
||||
Route::post(
|
||||
'/stripe/webhook',
|
||||
[StripeWebhookController::class, 'handleWebhook']
|
||||
)->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||
```
|
||||
|
||||
### Webhook Controller
|
||||
### Handle Webhooks
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// app/Http/Controllers/StripeWebhookController.php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\Webhook;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $request)
|
||||
public function handleWebhook(Request $request)
|
||||
{
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
|
||||
$payload = $request->getContent();
|
||||
$sigHeader = $request->header('Stripe-Signature');
|
||||
$webhookSecret = config('services.stripe.webhook_secret');
|
||||
|
||||
try {
|
||||
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
|
||||
$event = Webhook::constructEvent(
|
||||
$payload,
|
||||
$sigHeader,
|
||||
$webhookSecret
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => 'Invalid signature'], 400);
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch ($event->type) {
|
||||
case 'checkout.session.completed':
|
||||
$this->handleCheckoutCompleted($event->data->object);
|
||||
$this->handleCheckoutComplete($event->data->object);
|
||||
break;
|
||||
|
||||
case 'payment_intent.succeeded':
|
||||
$this->handlePaymentSucceeded($event->data->object);
|
||||
break;
|
||||
|
||||
case 'charge.refunded':
|
||||
$this->handleRefund($event->data->object);
|
||||
break;
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
protected function handleCheckoutCompleted($session)
|
||||
{
|
||||
$productId = $session->metadata->product_id ?? null;
|
||||
|
||||
if (!$productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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)
|
||||
{
|
||||
// Handle successful payment
|
||||
}
|
||||
|
||||
protected function handleRefund($charge)
|
||||
{
|
||||
// Handle refund
|
||||
$metadata = $charge->metadata;
|
||||
$productId = $metadata->product_id ?? null;
|
||||
|
||||
if ($productId) {
|
||||
$product = Product::find($productId);
|
||||
$quantity = $metadata->quantity ?? 1;
|
||||
|
||||
$product->increaseStock($quantity);
|
||||
case 'product.created':
|
||||
case 'product.updated':
|
||||
$this->handleProductUpdate($event->data->object);
|
||||
break;
|
||||
|
||||
// Trigger refund actions
|
||||
$product->callActions('refunded', null, [
|
||||
'stripe_charge' => $charge,
|
||||
case 'price.created':
|
||||
case 'price.updated':
|
||||
$this->handlePriceUpdate($event->data->object);
|
||||
break;
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
protected function handleCheckoutComplete($session)
|
||||
{
|
||||
// Find purchase by session metadata
|
||||
$userId = $session->metadata->user_id ?? null;
|
||||
$cartId = $session->metadata->cart_id ?? null;
|
||||
|
||||
if ($userId && $cartId) {
|
||||
// Mark purchases as completed
|
||||
ProductPurchase::where('cart_id', $cartId)
|
||||
->update([
|
||||
'status' => 'completed',
|
||||
'charge_id' => $session->payment_intent,
|
||||
'amount_paid' => $session->amount_total / 100,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleProductUpdate($stripeProduct)
|
||||
{
|
||||
ShopStripeService::syncProductDown($stripeProduct);
|
||||
}
|
||||
|
||||
protected function handlePriceUpdate($stripePrice)
|
||||
{
|
||||
// Update local price
|
||||
$price = ProductPrice::where('stripe_price_id', $stripePrice->id)->first();
|
||||
|
||||
if ($price) {
|
||||
$price->update([
|
||||
'active' => $stripePrice->active,
|
||||
'unit_amount' => $stripePrice->unit_amount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configure Webhook Secret
|
||||
## Best Practices
|
||||
|
||||
Add to `.env`:
|
||||
### 1. Always Use Stripe Price IDs
|
||||
|
||||
```env
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
```
|
||||
|
||||
Get your webhook secret from Stripe Dashboard → Developers → Webhooks.
|
||||
|
||||
## Multi-Currency Support
|
||||
|
||||
### Create Prices for Multiple Currencies
|
||||
When integrating with Stripe Checkout or subscriptions, always use Stripe Price IDs:
|
||||
|
||||
```php
|
||||
$product = Product::create([
|
||||
'slug' => 'premium-plan',
|
||||
'price' => 29.99, // USD base price
|
||||
]);
|
||||
|
||||
// USD (default)
|
||||
ProductPrice::create([
|
||||
'product_id' => $product->id,
|
||||
'currency' => 'USD',
|
||||
'price' => 29.99,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
// EUR
|
||||
ProductPrice::create([
|
||||
'product_id' => $product->id,
|
||||
'currency' => 'EUR',
|
||||
'price' => 24.99,
|
||||
]);
|
||||
|
||||
// GBP
|
||||
ProductPrice::create([
|
||||
'product_id' => $product->id,
|
||||
'currency' => 'GBP',
|
||||
'price' => 21.99,
|
||||
]);
|
||||
|
||||
// Sync all to Stripe
|
||||
StripeService::syncProductPricesDown($product);
|
||||
$price = $product->defaultPrice()->first();
|
||||
$stripePriceId = $price->stripe_price_id;
|
||||
```
|
||||
|
||||
### Checkout with Currency Selection
|
||||
### 2. Keep Prices in Sync
|
||||
|
||||
Use webhooks or scheduled commands to keep prices synchronized:
|
||||
|
||||
```php
|
||||
$currency = $request->input('currency', 'USD');
|
||||
// app/Console/Commands/SyncStripePrices.php
|
||||
use Stripe\Stripe;
|
||||
use Stripe\Product as StripeProduct;
|
||||
|
||||
$price = $product->prices()
|
||||
->where('currency', $currency)
|
||||
->first();
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
|
||||
$session = Session::create([
|
||||
'payment_method_types' => ['card'],
|
||||
'line_items' => [[
|
||||
'price' => $price->stripe_price_id,
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'mode' => 'payment',
|
||||
'success_url' => route('checkout.success'),
|
||||
'cancel_url' => route('checkout.cancel'),
|
||||
Product::whereNotNull('stripe_product_id')->each(function ($product) {
|
||||
ShopStripeService::syncProductPricesDown($product);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Store Stripe References
|
||||
|
||||
Always store Stripe IDs for traceability:
|
||||
|
||||
```php
|
||||
$product->update([
|
||||
'stripe_product_id' => $stripeProduct->id,
|
||||
]);
|
||||
|
||||
$price->update([
|
||||
'stripe_price_id' => $stripePrice->id,
|
||||
]);
|
||||
|
||||
$purchase->update([
|
||||
'charge_id' => $paymentIntent->id,
|
||||
]);
|
||||
```
|
||||
|
||||
## Testing
|
||||
### 4. Handle Errors Gracefully
|
||||
|
||||
### Use Stripe Test Mode
|
||||
|
||||
```env
|
||||
STRIPE_KEY=pk_test_...
|
||||
STRIPE_SECRET=sk_test_...
|
||||
```php
|
||||
try {
|
||||
$stripeProduct = StripeProduct::create([
|
||||
'name' => $product->getLocalized('name'),
|
||||
]);
|
||||
} catch (\Stripe\Exception\ApiErrorException $e) {
|
||||
\Log::error('Stripe API error: ' . $e->getMessage());
|
||||
// Handle error appropriately
|
||||
}
|
||||
```
|
||||
|
||||
### Test Card Numbers
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Purchasing Products
|
||||
# Purchasing & Shopping Cart
|
||||
|
||||
## Setup
|
||||
|
||||
First, add the `HasShoppingCapabilities` trait to your User model (or any model that should purchase products):
|
||||
Add the `HasShoppingCapabilities` trait to your User model (or any model that should be able to purchase products):
|
||||
|
||||
```php
|
||||
use Blax\Shop\Traits\HasShoppingCapabilities;
|
||||
|
|
@ -13,21 +13,29 @@ class User extends Authenticatable
|
|||
}
|
||||
```
|
||||
|
||||
This trait provides methods for:
|
||||
- Direct product purchases
|
||||
- Shopping cart management
|
||||
- Purchase history
|
||||
- Cart checkout
|
||||
|
||||
## Direct Purchase
|
||||
|
||||
### Simple Purchase
|
||||
### Purchase a Product
|
||||
|
||||
```php
|
||||
$user = auth()->user();
|
||||
$product = Product::find($productId);
|
||||
|
||||
// Product must have a default price
|
||||
try {
|
||||
$purchase = $user->purchase($product, quantity: 1);
|
||||
$purchase = $user->purchase($product);
|
||||
|
||||
// Purchase successful
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'purchase_id' => $purchase->id,
|
||||
'amount' => $purchase->amount,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
|
|
@ -36,32 +44,40 @@ try {
|
|||
}
|
||||
```
|
||||
|
||||
### Purchase with Options
|
||||
### Purchase with Specific Price
|
||||
|
||||
```php
|
||||
$purchase = $user->purchase($product, quantity: 2, options: [
|
||||
'price_id' => $priceId, // Use specific price
|
||||
'charge_id' => $paymentId, // Associate with payment
|
||||
'cart_id' => $cartId, // Associate with cart
|
||||
'status' => 'pending', // Custom status
|
||||
]);
|
||||
$price = ProductPrice::find($priceId);
|
||||
|
||||
$purchase = $user->purchase(
|
||||
$price,
|
||||
quantity: 2
|
||||
);
|
||||
```
|
||||
|
||||
### Check Purchase History
|
||||
### Purchase with Metadata
|
||||
|
||||
```php
|
||||
// Check if user has purchased a product
|
||||
if ($user->hasPurchased($product)) {
|
||||
// User has purchased this product
|
||||
}
|
||||
|
||||
// Get purchase history for a product
|
||||
$history = $user->getPurchaseHistory($product);
|
||||
|
||||
// Get all completed purchases
|
||||
$purchases = $user->completedPurchases()->get();
|
||||
$purchase = $user->purchase(
|
||||
$product,
|
||||
quantity: 1,
|
||||
meta: [
|
||||
'gift' => true,
|
||||
'message' => 'Happy Birthday!',
|
||||
'gift_recipient' => 'john@example.com',
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Product must have at least one default price
|
||||
- Product must not have multiple default prices (will throw `MultiplePurchaseOptions` exception)
|
||||
- If stock management is enabled, sufficient stock must be available
|
||||
- Product must be visible (published, visible flag, and published_at date)
|
||||
- Purchase automatically decreases stock if `manage_stock` is enabled
|
||||
- Product actions are automatically triggered on purchase
|
||||
|
||||
## Shopping Cart
|
||||
|
||||
### Add to Cart
|
||||
|
|
@ -84,13 +100,39 @@ try {
|
|||
}
|
||||
```
|
||||
|
||||
### Update Cart Quantity
|
||||
### Add with Parameters
|
||||
|
||||
```php
|
||||
$cartItem = ProductPurchase::find($cartItemId);
|
||||
$cartItem = $user->addToCart(
|
||||
$product,
|
||||
quantity: 2,
|
||||
parameters: [
|
||||
'color' => 'blue',
|
||||
'size' => 'large',
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### Get Cart Items
|
||||
|
||||
```php
|
||||
$cartItems = $user->cartItems()->get();
|
||||
|
||||
foreach ($cartItems as $item) {
|
||||
echo $item->purchasable->getLocalized('name');
|
||||
echo $item->quantity;
|
||||
echo $item->price;
|
||||
echo $item->subtotal;
|
||||
}
|
||||
```
|
||||
|
||||
### Update Cart Item Quantity
|
||||
|
||||
```php
|
||||
$cartItem = CartItem::find($cartItemId);
|
||||
|
||||
try {
|
||||
$user->updateCartQuantity($cartItem, quantity: 3);
|
||||
$updatedItem = $user->updateCartQuantity($cartItem, quantity: 3);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
|
@ -104,479 +146,369 @@ try {
|
|||
### Remove from Cart
|
||||
|
||||
```php
|
||||
$cartItem = ProductPurchase::find($cartItemId);
|
||||
$cartItem = CartItem::find($cartItemId);
|
||||
|
||||
$user->removeFromCart($cartItem);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'cart_total' => $user->getCartTotal(),
|
||||
'cart_count' => $user->getCartItemsCount(),
|
||||
]);
|
||||
```
|
||||
|
||||
### Get Cart Information
|
||||
### Clear Cart
|
||||
|
||||
```php
|
||||
// Get all cart items
|
||||
$cartItems = $user->cartItems()->with('product')->get();
|
||||
$count = $user->clearCart();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'removed_items' => $count,
|
||||
]);
|
||||
```
|
||||
|
||||
### Get Cart Totals
|
||||
|
||||
```php
|
||||
// Get cart total
|
||||
$total = $user->getCartTotal();
|
||||
|
||||
// Get items count
|
||||
// Get cart items count
|
||||
$count = $user->getCartItemsCount();
|
||||
|
||||
// Clear cart
|
||||
$user->clearCart();
|
||||
// Get cart stats
|
||||
$stats = [
|
||||
'total' => $user->getCartTotal(),
|
||||
'count' => $user->getCartItemsCount(),
|
||||
'items' => $user->cartItems()->with('purchasable')->get(),
|
||||
];
|
||||
```
|
||||
|
||||
### Checkout
|
||||
## Cart Checkout
|
||||
|
||||
### Convert Cart to Purchases
|
||||
|
||||
```php
|
||||
try {
|
||||
$completedPurchases = $user->checkout(options: [
|
||||
'charge_id' => $paymentIntent->id,
|
||||
]);
|
||||
$purchases = $user->checkout();
|
||||
|
||||
// Checkout successful
|
||||
// Cart items are now converted to completed purchases
|
||||
// Cart is marked as converted
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'purchases' => $completedPurchases,
|
||||
'total' => $completedPurchases->sum('amount'),
|
||||
'purchases' => $purchases,
|
||||
'total_items' => $purchases->count(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 400);
|
||||
return response()->json([
|
||||
'error' => $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
```
|
||||
|
||||
## Refunds
|
||||
### Important Notes
|
||||
|
||||
- Checkout validates stock availability for all items
|
||||
- Creates `ProductPurchase` records for each cart item
|
||||
- Decreases stock for each item
|
||||
- Triggers product actions
|
||||
- Marks cart as converted (`converted_at` timestamp)
|
||||
- Removes cart items after successful checkout
|
||||
|
||||
## Purchase History
|
||||
|
||||
### Check if User Purchased Product
|
||||
|
||||
```php
|
||||
$purchase = ProductPurchase::find($purchaseId);
|
||||
$user = $purchase->purchasable;
|
||||
$product = Product::find($productId);
|
||||
|
||||
try {
|
||||
$user->refundPurchase($purchase, options: [
|
||||
'refund_id' => $refundId,
|
||||
'reason' => 'Customer request',
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 400);
|
||||
if ($user->hasPurchased($product)) {
|
||||
// User has purchased this product
|
||||
echo "You own this product!";
|
||||
}
|
||||
```
|
||||
|
||||
## Purchase Statistics
|
||||
### Get All Purchases
|
||||
|
||||
```php
|
||||
// Get all purchases (any status)
|
||||
$allPurchases = $user->purchases()->get();
|
||||
|
||||
// Get only completed purchases
|
||||
$completedPurchases = $user->completedPurchases()->get();
|
||||
|
||||
// Get purchases for specific product
|
||||
$productPurchases = $user->purchases()
|
||||
->where('purchasable_id', $product->id)
|
||||
->where('purchasable_type', Product::class)
|
||||
->get();
|
||||
```
|
||||
|
||||
### Purchase Statistics
|
||||
|
||||
```php
|
||||
$stats = $user->getPurchaseStats();
|
||||
|
||||
// Returns:
|
||||
// [
|
||||
// 'total_purchases' => 10,
|
||||
// 'total_spent' => 299.90,
|
||||
// 'total_items' => 15,
|
||||
// 'total_purchases' => 15,
|
||||
// 'total_spent' => 450.00,
|
||||
// 'total_items' => 23,
|
||||
// 'cart_items' => 2,
|
||||
// 'cart_total' => 49.98,
|
||||
// 'cart_total' => 89.99,
|
||||
// ]
|
||||
```
|
||||
|
||||
## Basic Purchase Flow
|
||||
## Refunds
|
||||
|
||||
### 1. Check Product Availability
|
||||
### Refund a Purchase
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\Product;
|
||||
$purchase = ProductPurchase::find($purchaseId);
|
||||
|
||||
$product = Product::find($productId);
|
||||
$quantity = 1;
|
||||
|
||||
// Check if product is available
|
||||
if (!$product->isVisible()) {
|
||||
return response()->json(['error' => 'Product not available'], 404);
|
||||
}
|
||||
|
||||
// Check stock
|
||||
if ($product->manage_stock) {
|
||||
$available = $product->getAvailableStock();
|
||||
try {
|
||||
$success = $user->refundPurchase($purchase);
|
||||
|
||||
if ($available < $quantity) {
|
||||
return response()->json([
|
||||
'error' => 'Insufficient stock',
|
||||
'available' => $available
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Reserve Stock (Optional)
|
||||
|
||||
Reserve stock during checkout process:
|
||||
|
||||
```php
|
||||
// Reserve for 15 minutes
|
||||
$reservation = $product->reserveStock(
|
||||
quantity: $quantity,
|
||||
reference: auth()->user(),
|
||||
until: now()->addMinutes(15),
|
||||
note: 'Checkout reservation'
|
||||
);
|
||||
|
||||
if (!$reservation) {
|
||||
return response()->json(['error' => 'Unable to reserve stock'], 400);
|
||||
}
|
||||
|
||||
// Store reservation ID in session
|
||||
session(['stock_reservation_id' => $reservation->id]);
|
||||
```
|
||||
|
||||
### 3. Process Payment
|
||||
|
||||
```php
|
||||
// Your payment processing logic
|
||||
$payment = PaymentService::process([
|
||||
'amount' => $product->getCurrentPrice() * $quantity,
|
||||
'currency' => 'USD',
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
if ($payment->failed()) {
|
||||
// Release reservation
|
||||
$reservation->update(['status' => 'cancelled']);
|
||||
return response()->json(['error' => 'Payment failed'], 400);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Complete Purchase
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
|
||||
// Decrease stock
|
||||
$product->decreaseStock($quantity);
|
||||
|
||||
// Create purchase record
|
||||
$purchase = ProductPurchase::create([
|
||||
'product_id' => $product->id,
|
||||
'purchasable_type' => get_class(auth()->user()),
|
||||
'purchasable_id' => auth()->id(),
|
||||
'quantity' => $quantity,
|
||||
'status' => 'completed',
|
||||
'meta' => [
|
||||
'payment_id' => $payment->id,
|
||||
'price_paid' => $product->getCurrentPrice(),
|
||||
'currency' => 'USD',
|
||||
],
|
||||
]);
|
||||
|
||||
// Complete reservation
|
||||
if ($reservation) {
|
||||
$reservation->update(['status' => 'completed']);
|
||||
}
|
||||
|
||||
// Trigger product actions
|
||||
$product->callActions('purchased', $purchase, [
|
||||
'user' => auth()->user(),
|
||||
'payment' => $payment,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'purchase_id' => $purchase->id,
|
||||
]);
|
||||
```
|
||||
|
||||
## Shopping Cart Implementation
|
||||
|
||||
### Cart Item Model
|
||||
|
||||
```php
|
||||
// app/Models/CartItem.php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Blax\Shop\Models\Product;
|
||||
|
||||
class CartItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'cart_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'price',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function getSubtotal()
|
||||
{
|
||||
return $this->price * $this->quantity;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cart Service
|
||||
|
||||
```php
|
||||
// app/Services/CartService.php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CartItem;
|
||||
use Blax\Shop\Models\Product;
|
||||
|
||||
class CartService
|
||||
{
|
||||
public function add(Product $product, int $quantity = 1)
|
||||
{
|
||||
$cart = $this->getCart();
|
||||
|
||||
// Check stock
|
||||
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
|
||||
throw new \Exception('Insufficient stock');
|
||||
}
|
||||
|
||||
// Check if item already in cart
|
||||
$cartItem = $cart->items()->where('product_id', $product->id)->first();
|
||||
|
||||
if ($cartItem) {
|
||||
$newQuantity = $cartItem->quantity + $quantity;
|
||||
|
||||
// Check stock for new quantity
|
||||
if ($product->manage_stock && $product->getAvailableStock() < $newQuantity) {
|
||||
throw new \Exception('Insufficient stock for requested quantity');
|
||||
}
|
||||
|
||||
$cartItem->update(['quantity' => $newQuantity]);
|
||||
} else {
|
||||
$cartItem = $cart->items()->create([
|
||||
'product_id' => $product->id,
|
||||
'quantity' => $quantity,
|
||||
'price' => $product->getCurrentPrice(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $cartItem;
|
||||
}
|
||||
|
||||
public function update(CartItem $cartItem, int $quantity)
|
||||
{
|
||||
$product = $cartItem->product;
|
||||
|
||||
// Check stock
|
||||
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
|
||||
throw new \Exception('Insufficient stock');
|
||||
}
|
||||
|
||||
$cartItem->update(['quantity' => $quantity]);
|
||||
|
||||
return $cartItem;
|
||||
}
|
||||
|
||||
public function remove(CartItem $cartItem)
|
||||
{
|
||||
$cartItem->delete();
|
||||
}
|
||||
|
||||
public function clear()
|
||||
{
|
||||
$cart = $this->getCart();
|
||||
$cart->items()->delete();
|
||||
}
|
||||
|
||||
public function getTotal()
|
||||
{
|
||||
$cart = $this->getCart();
|
||||
return $cart->items->sum(fn($item) => $item->getSubtotal());
|
||||
}
|
||||
|
||||
public function checkout()
|
||||
{
|
||||
$cart = $this->getCart();
|
||||
$items = $cart->items()->with('product')->get();
|
||||
|
||||
// Reserve stock for all items
|
||||
$reservations = [];
|
||||
foreach ($items as $item) {
|
||||
$reservation = $item->product->reserveStock(
|
||||
$item->quantity,
|
||||
$cart,
|
||||
now()->addMinutes(15)
|
||||
);
|
||||
|
||||
if (!$reservation) {
|
||||
// Rollback previous reservations
|
||||
foreach ($reservations as $res) {
|
||||
$res->update(['status' => 'cancelled']);
|
||||
}
|
||||
throw new \Exception('Unable to reserve stock for: ' . $item->product->getLocalized('name'));
|
||||
}
|
||||
|
||||
$reservations[] = $reservation;
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'reservations' => $reservations,
|
||||
'total' => $this->getTotal(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getCart()
|
||||
{
|
||||
// Implementation depends on your cart system
|
||||
// Could be session-based or user-based
|
||||
return auth()->user()->cart ?? session()->get('cart');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cart Controller
|
||||
|
||||
```php
|
||||
// app/Http/Controllers/CartController.php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\CartService;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CartController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CartService $cartService
|
||||
) {}
|
||||
|
||||
public function add(Request $request, Product $product)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'quantity' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
try {
|
||||
$cartItem = $this->cartService->add($product, $validated['quantity']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'cart_item' => $cartItem,
|
||||
'cart_total' => $this->cartService->getTotal(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, $cartItemId)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'quantity' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$cartItem = CartItem::findOrFail($cartItemId);
|
||||
|
||||
try {
|
||||
$this->cartService->update($cartItem, $validated['quantity']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'cart_total' => $this->cartService->getTotal(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function remove($cartItemId)
|
||||
{
|
||||
$cartItem = CartItem::findOrFail($cartItemId);
|
||||
$this->cartService->remove($cartItem);
|
||||
|
||||
if ($success) {
|
||||
// Refund successful
|
||||
// Stock has been returned
|
||||
// Purchase status changed to 'refunded'
|
||||
// Product 'refunded' actions triggered
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'cart_total' => $this->cartService->getTotal(),
|
||||
'message' => 'Purchase refunded successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkout()
|
||||
{
|
||||
try {
|
||||
$checkoutData = $this->cartService->checkout();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'checkout' => $checkoutData,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
```
|
||||
|
||||
## Handling Refunds
|
||||
### Important Notes
|
||||
|
||||
- Only completed purchases can be refunded
|
||||
- Stock is automatically returned to inventory
|
||||
- Product actions with event 'refunded' are triggered
|
||||
|
||||
## Cart Model
|
||||
|
||||
### Get Current Cart
|
||||
|
||||
```php
|
||||
public function refund($purchaseId)
|
||||
{
|
||||
$purchase = ProductPurchase::findOrFail($purchaseId);
|
||||
$product = $purchase->product;
|
||||
// Get or create current active cart
|
||||
$cart = $user->currentCart();
|
||||
|
||||
// Process refund with payment processor
|
||||
$refund = PaymentService::refund($purchase->meta['payment_id']);
|
||||
// Cart properties
|
||||
$cart->session_id; // Session ID for guest carts
|
||||
$cart->customer_id; // User ID
|
||||
$cart->customer_type; // User model class
|
||||
$cart->currency; // Cart currency (default: USD)
|
||||
$cart->status; // active, abandoned, converted, expired
|
||||
$cart->converted_at; // When cart was checked out
|
||||
$cart->expires_at; // Cart expiration date
|
||||
$cart->last_activity_at; // Last activity timestamp
|
||||
```
|
||||
|
||||
if ($refund->success) {
|
||||
// Return stock
|
||||
$product->increaseStock($purchase->quantity);
|
||||
### Cart Relationships
|
||||
|
||||
// Update purchase status
|
||||
$purchase->update([
|
||||
'status' => 'refunded',
|
||||
'meta' => array_merge($purchase->meta, [
|
||||
'refund_id' => $refund->id,
|
||||
'refunded_at' => now(),
|
||||
]),
|
||||
]);
|
||||
```php
|
||||
// Get cart items
|
||||
$items = $cart->items()->get();
|
||||
|
||||
// Trigger refund actions
|
||||
$product->callActions('refunded', $purchase, [
|
||||
'refund' => $refund,
|
||||
]);
|
||||
// Get cart purchases (if converted)
|
||||
$purchases = $cart->purchases()->get();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
// Get cart customer (user)
|
||||
$customer = $cart->customer;
|
||||
```
|
||||
|
||||
return response()->json(['error' => 'Refund failed'], 400);
|
||||
### Cart Methods
|
||||
|
||||
```php
|
||||
// Get cart total
|
||||
$total = $cart->getTotal();
|
||||
|
||||
// Get total items
|
||||
$itemCount = $cart->getTotalItems();
|
||||
|
||||
// Check if cart is expired
|
||||
if ($cart->isExpired()) {
|
||||
// Cart has expired
|
||||
}
|
||||
|
||||
// Check if cart is converted
|
||||
if ($cart->isConverted()) {
|
||||
// Cart has been checked out
|
||||
}
|
||||
```
|
||||
|
||||
## Product Actions on Purchase
|
||||
|
||||
Product actions allow you to execute custom logic when products are purchased:
|
||||
### Add Items to Cart Directly
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductAction;
|
||||
use Blax\Shop\Models\Cart;
|
||||
|
||||
// Create action to grant access to a course
|
||||
ProductAction::create([
|
||||
'product_id' => $product->id,
|
||||
'action_type' => 'grant_access',
|
||||
'event' => 'purchased',
|
||||
'config' => [
|
||||
'resource_type' => 'course',
|
||||
'resource_id' => 123,
|
||||
],
|
||||
'active' => true,
|
||||
]);
|
||||
$cart = Cart::find($cartId);
|
||||
|
||||
// Action is automatically triggered when product is purchased
|
||||
// Implement the action handler in your application
|
||||
$cartItem = $cart->addToCart(
|
||||
$product, // or $productPrice
|
||||
quantity: 2,
|
||||
parameters: ['size' => 'L']
|
||||
);
|
||||
```
|
||||
|
||||
See [Product Actions documentation](docs/07-product-actions.md) for more details.
|
||||
## Product Purchase Model
|
||||
|
||||
### Purchase Properties
|
||||
|
||||
```php
|
||||
$purchase = ProductPurchase::find($purchaseId);
|
||||
|
||||
$purchase->status; // cart, pending, unpaid, completed, refunded
|
||||
$purchase->cart_id; // Associated cart ID
|
||||
$purchase->price_id; // Associated price ID
|
||||
$purchase->purchasable_id; // Product ID
|
||||
$purchase->purchasable_type; // Product class
|
||||
$purchase->purchaser_id; // User ID
|
||||
$purchase->purchaser_type; // User class
|
||||
$purchase->quantity; // Quantity purchased
|
||||
$purchase->amount; // Total amount
|
||||
$purchase->amount_paid; // Amount paid
|
||||
$purchase->charge_id; // Payment charge ID
|
||||
$purchase->meta; // Additional metadata
|
||||
```
|
||||
|
||||
### Purchase Relationships
|
||||
|
||||
```php
|
||||
// Get purchased product
|
||||
$product = $purchase->purchasable;
|
||||
|
||||
// Get purchaser (user)
|
||||
$user = $purchase->purchaser;
|
||||
```
|
||||
|
||||
### Purchase Scopes
|
||||
|
||||
```php
|
||||
// Get purchases in cart
|
||||
$cartPurchases = ProductPurchase::inCart()->get();
|
||||
|
||||
// Get completed purchases
|
||||
$completed = ProductPurchase::completed()->get();
|
||||
|
||||
// Get purchases from specific cart
|
||||
$cartPurchases = ProductPurchase::fromCart($cartId)->get();
|
||||
```
|
||||
|
||||
## Stock Reservations
|
||||
|
||||
When adding products to cart, stock is automatically reserved:
|
||||
|
||||
```php
|
||||
// Stock is reserved when adding to cart
|
||||
$cartItem = $user->addToCart($product, quantity: 2);
|
||||
|
||||
// Reservation is created automatically
|
||||
// It expires after configured time (default: 15 minutes)
|
||||
// Stock is released back when:
|
||||
// - Reservation expires
|
||||
// - Cart item is removed
|
||||
// - Cart is abandoned
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Exceptions
|
||||
|
||||
```php
|
||||
use Blax\Shop\Exceptions\NotPurchasable;
|
||||
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
|
||||
try {
|
||||
$purchase = $user->purchase($product);
|
||||
} catch (NotPurchasable $e) {
|
||||
// Product has no default price
|
||||
} catch (MultiplePurchaseOptions $e) {
|
||||
// Product has multiple default prices - need to specify which one
|
||||
$price = $product->prices()->where('currency', 'USD')->first();
|
||||
$purchase = $user->purchase($price);
|
||||
} catch (NotEnoughStockException $e) {
|
||||
// Insufficient stock available
|
||||
$available = $product->getAvailableStock();
|
||||
echo "Only {$available} items available";
|
||||
} catch (\Exception $e) {
|
||||
// General error
|
||||
echo $e->getMessage();
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```php
|
||||
// Product listing
|
||||
Route::get('/products', function () {
|
||||
$products = Product::visible()
|
||||
->inStock()
|
||||
->with(['prices' => fn($q) => $q->where('is_default', true)])
|
||||
->get();
|
||||
|
||||
return view('products.index', compact('products'));
|
||||
});
|
||||
|
||||
// Add to cart
|
||||
Route::post('/cart/add/{product}', function (Product $product) {
|
||||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
$cartItem = $user->addToCart($product, quantity: 1);
|
||||
|
||||
return redirect()->back()->with('success', 'Product added to cart!');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
// View cart
|
||||
Route::get('/cart', function () {
|
||||
$user = auth()->user();
|
||||
|
||||
$cartItems = $user->cartItems()->with('purchasable')->get();
|
||||
$cartTotal = $user->getCartTotal();
|
||||
$cartCount = $user->getCartItemsCount();
|
||||
|
||||
return view('cart.index', compact('cartItems', 'cartTotal', 'cartCount'));
|
||||
});
|
||||
|
||||
// Checkout
|
||||
Route::post('/checkout', function () {
|
||||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
$purchases = $user->checkout();
|
||||
|
||||
return redirect()->route('orders.success')
|
||||
->with('success', 'Order placed successfully!');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
// Order history
|
||||
Route::get('/orders', function () {
|
||||
$user = auth()->user();
|
||||
|
||||
$purchases = $user->completedPurchases()
|
||||
->with('purchasable')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('orders.index', compact('purchases'));
|
||||
});
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'short_description',
|
||||
'description',
|
||||
'sku',
|
||||
'type',
|
||||
'stripe_product_id',
|
||||
'sale_start',
|
||||
|
|
@ -45,7 +43,6 @@ class Product extends Model implements Purchasable, Cartable
|
|||
'status',
|
||||
'published_at',
|
||||
'meta',
|
||||
'sku',
|
||||
'tax_class',
|
||||
'sort_order',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -16,12 +16,10 @@ class ProductAttribute extends Model
|
|||
'key',
|
||||
'value',
|
||||
'sort_order',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'meta' => 'object',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
|
|
|||
|
|
@ -23,13 +23,11 @@ class ProductStock extends Model
|
|||
'reference_id',
|
||||
'expires_at',
|
||||
'note',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'meta' => 'object',
|
||||
];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
|
|
|
|||
|
|
@ -103,18 +103,21 @@ class ProductAttributeTest extends TestCase
|
|||
{
|
||||
$product = Product::factory()->create();
|
||||
|
||||
$attribute = ProductAttribute::create([
|
||||
// Attributes now store structured data in value or as separate attributes
|
||||
$dimensionAttr = ProductAttribute::create([
|
||||
'product_id' => $product->id,
|
||||
'key' => 'Dimensions',
|
||||
'value' => '10x20x30',
|
||||
'meta' => [
|
||||
'unit' => 'cm',
|
||||
'display_format' => 'length x width x height',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals('cm', $attribute->meta->unit);
|
||||
$this->assertEquals('length x width x height', $attribute->meta->display_format);
|
||||
$unitAttr = ProductAttribute::create([
|
||||
'product_id' => $product->id,
|
||||
'key' => 'Dimension Unit',
|
||||
'value' => 'cm',
|
||||
]);
|
||||
|
||||
$this->assertEquals('10x20x30', $dimensionAttr->value);
|
||||
$this->assertEquals('cm', $unitAttr->value);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
|||
Loading…
Reference in New Issue