From 82ee18b0f1a04431f0bb58ed9a0cc31875306c18 Mon Sep 17 00:00:00 2001 From: a6a2f5842 Date: Tue, 25 Nov 2025 17:25:20 +0100 Subject: [PATCH] I tests & documentation --- docs/01-products.md | 598 ++++++++++++------- docs/02-stripe.md | 638 +++++++++++++------- docs/03-purchasing.md | 794 +++++++++++-------------- docs/04-subscriptions.md | 489 --------------- src/Models/Product.php | 5 +- src/Models/ProductAttribute.php | 2 - src/Models/ProductStock.php | 2 - tests/Feature/ProductAttributeTest.php | 17 +- 8 files changed, 1178 insertions(+), 1367 deletions(-) delete mode 100644 docs/04-subscriptions.md diff --git a/docs/01-products.md b/docs/01-products.md index d28cd62..be27c14 100644 --- a/docs/01-products.md +++ b/docs/01-products.md @@ -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, - ], -]; -``` diff --git a/docs/02-stripe.md b/docs/02-stripe.md index 35c1a0d..87abf4d 100644 --- a/docs/02-stripe.md +++ b/docs/02-stripe.md @@ -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 -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 - diff --git a/docs/03-purchasing.md b/docs/03-purchasing.md index da2be59..936e5c0 100644 --- a/docs/03-purchasing.md +++ b/docs/03-purchasing.md @@ -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')); +}); +``` diff --git a/docs/04-subscriptions.md b/docs/04-subscriptions.md deleted file mode 100644 index 0b4d4d1..0000000 --- a/docs/04-subscriptions.md +++ /dev/null @@ -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, - ], -]); -``` diff --git a/src/Models/Product.php b/src/Models/Product.php index a54cdf4..8be5bd1 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -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', ]; diff --git a/src/Models/ProductAttribute.php b/src/Models/ProductAttribute.php index 8127b42..860ff0e 100644 --- a/src/Models/ProductAttribute.php +++ b/src/Models/ProductAttribute.php @@ -16,12 +16,10 @@ class ProductAttribute extends Model 'key', 'value', 'sort_order', - 'meta', ]; protected $casts = [ 'sort_order' => 'integer', - 'meta' => 'object', ]; protected $hidden = [ diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php index d1ad3f8..a14a7e0 100644 --- a/src/Models/ProductStock.php +++ b/src/Models/ProductStock.php @@ -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 = []) diff --git a/tests/Feature/ProductAttributeTest.php b/tests/Feature/ProductAttributeTest.php index a442721..6fa4b88 100644 --- a/tests/Feature/ProductAttributeTest.php +++ b/tests/Feature/ProductAttributeTest.php @@ -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 */