diff --git a/config/shop.php b/config/shop.php index 38d4af5..045db0f 100644 --- a/config/shop.php +++ b/config/shop.php @@ -64,6 +64,9 @@ return [ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ], + // Currency configuration + 'currency' => env('SHOP_CURRENCY', 'usd'), + // Cache configuration 'cache' => [ 'enabled' => env('SHOP_CACHE_ENABLED', true), diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 25fb475..b62f9fa 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -603,15 +603,30 @@ class Cart extends Model // Calculate price per day (base price) // For pool products, get price based on how many items are already in cart + $poolSingleItem = null; + $poolPriceId = null; if ($cartable instanceof Product && $cartable->isPool()) { // Use smarter pricing that considers which price tiers are used - $pricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until); - $regularPricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, false, $from, $until) ?? $pricePerDay; + $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until); + + if ($poolItemData) { + $pricePerDay = $poolItemData['price']; + $poolSingleItem = $poolItemData['item']; + $poolPriceId = $poolItemData['price_id']; + } else { + $pricePerDay = null; + } + + // Get regular price (non-sale) for comparison + $regularPoolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, false, $from, $until); + $regularPricePerDay = $regularPoolItemData['price'] ?? $pricePerDay; // If no price found from pool items, try the pool's direct price as fallback if ($pricePerDay === null && $cartable->hasPrice()) { - $pricePerDay = $cartable->defaultPrice()->first()?->getCurrentPrice($cartable->isOnSale()); - $regularPricePerDay = $cartable->defaultPrice()->first()?->getCurrentPrice(false) ?? $pricePerDay; + $priceModel = $cartable->defaultPrice()->first(); + $pricePerDay = $priceModel?->getCurrentPrice($cartable->isOnSale()); + $regularPricePerDay = $priceModel?->getCurrentPrice(false) ?? $pricePerDay; + $poolPriceId = $priceModel?->id; } } else { $pricePerDay = $cartable->getCurrentPrice(); @@ -659,9 +674,14 @@ class Cart extends Model // Determine price_id for the cart item $priceId = null; if ($cartable instanceof Product) { - // Get the default price for the product - $defaultPrice = $cartable->defaultPrice()->first(); - $priceId = $defaultPrice?->id; + // For pool products, use the single item's price_id + if ($cartable->isPool() && $poolPriceId) { + $priceId = $poolPriceId; + } else { + // Get the default price for the product + $defaultPrice = $cartable->defaultPrice()->first(); + $priceId = $defaultPrice?->id; + } } elseif ($cartable instanceof \Blax\Shop\Models\ProductPrice) { // If adding a ProductPrice directly, use its ID $priceId = $cartable->id; @@ -681,6 +701,12 @@ class Cart extends Model 'until' => $until, ]); + // For pool products, store which single item is being used in meta + if ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) { + $cartItem->updateMetaKey('allocated_single_item_id', $poolSingleItem->id); + $cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name); + } + return $cartItem; } @@ -870,15 +896,15 @@ class Cart extends Model * * This method: * - Validates the cart (doesn't convert it) - * - Syncs products/prices to Stripe (creates them if they don't exist) + * - Uses dynamic price_data for each cart item (no pre-created Stripe prices needed) * - Creates line items with descriptions including booking dates * - Returns the Stripe checkout session * * @param array $options Optional session parameters (success_url, cancel_url, etc.) - * @return \Stripe\Checkout\Session + * @return mixed Stripe\Checkout\Session instance * @throws \Exception */ - public function checkoutSession(array $options = []): \Stripe\Checkout\Session + public function checkoutSession(array $options = []) { if (!config('shop.stripe.enabled')) { throw new \Exception('Stripe is not enabled'); @@ -890,56 +916,36 @@ class Cart extends Model // Validate cart before proceeding (doesn't convert it) $this->validateForCheckout(); - // Get all stripe price IDs and validate they exist - $stripePriceIds = $this->stripePriceIds(); - - // Check if any stripe_price_id is null - $nullPriceIndexes = []; - foreach ($stripePriceIds as $index => $priceId) { - if ($priceId === null) { - $nullPriceIndexes[] = $index; - } - } - - if (!empty($nullPriceIndexes)) { - // Get item names for better error message - $itemNames = []; - foreach ($nullPriceIndexes as $index) { - $item = $this->items[$index]; - $itemNames[] = $item->purchasable->name ?? "Item {$index}"; - } - throw new \Exception( - "Cannot create checkout session: The following items have no Stripe price ID: " . - implode(', ', $itemNames) - ); - } - - $syncService = new \Blax\Shop\Services\StripeSyncService(); $lineItems = []; - foreach ($this->items as $index => $item) { - // Use the pre-fetched stripe price ID - $stripePriceId = $stripePriceIds[$index]; + foreach ($this->items as $item) { + $product = $item->purchasable; - // Build line item with description including booking dates if applicable - $lineItem = [ - 'price' => $stripePriceId, - 'quantity' => $item->quantity, - ]; + // Get product name (use short_description if available, otherwise name) + $productName = $product->short_description ?? $product->name ?? 'Product'; - // Add description with booking dates if available - $description = null; + // Build description with booking dates if available if ($item->from && $item->until) { - $days = $this->calculateBookingDays($item->from, $item->until); $fromFormatted = $item->from->format('M j, Y H:i'); $untilFormatted = $item->until->format('M j, Y H:i'); - $daysText = number_format($days, 2) . ' day' . ($days != 1 ? 's' : ''); - $description = "Period: {$fromFormatted} to {$untilFormatted} ({$daysText})"; + $productName .= " from {$fromFormatted} to {$untilFormatted}"; } - if ($description) { - $lineItem['description'] = $description; - } + // Convert price to cents (Stripe expects smallest currency unit) + // Cart item price is already per unit for the entire period + $unitAmountCents = (int) round($item->price * 100); + + // Build line item using price_data for dynamic pricing + $lineItem = [ + 'price_data' => [ + 'currency' => config('shop.currency', 'usd'), + 'product_data' => [ + 'name' => $productName, + ], + 'unit_amount' => $unitAmountCents, + ], + 'quantity' => $item->quantity, + ]; $lineItems[] = $lineItem; } diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index d518682..aaba45c 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -222,6 +222,65 @@ trait MayBePoolProduct return $released; } + /** + * Calculate available quantity for a single item considering booking dates + * This is a DRY helper method used by multiple pool pricing methods + * + * @param Product $item The single item to check + * @param \DateTimeInterface|null $from Start date for availability check + * @param \DateTimeInterface|null $until End date for availability check + * @return int Available quantity (PHP_INT_MAX for unlimited) + */ + protected function calculateSingleItemAvailability( + $item, + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null + ): int { + $available = 0; + + if ($from && $until) { + if ($item->isBooking()) { + if (!$item->manage_stock) { + $available = PHP_INT_MAX; + } else { + // Calculate overlapping claims for this specific period + $overlappingClaims = $item->stocks() + ->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value) + ->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value) + ->where(function ($query) use ($from, $until) { + $query->where(function ($q) use ($from, $until) { + $q->whereBetween('claimed_from', [$from, $until]); + })->orWhere(function ($q) use ($from, $until) { + $q->whereBetween('expires_at', [$from, $until]); + })->orWhere(function ($q) use ($from, $until) { + $q->where('claimed_from', '<=', $from) + ->where('expires_at', '>=', $until); + })->orWhere(function ($q) use ($from, $until) { + $q->whereNull('claimed_from') + ->where(function ($subQ) use ($from, $until) { + $subQ->whereNull('expires_at') + ->orWhere('expires_at', '>=', $from); + }); + }); + }) + ->sum('quantity'); + + $available = max(0, $item->getAvailableStock() - abs($overlappingClaims)); + } + } elseif (!$item->isBooking()) { + $available = $item->getAvailableStock(); + } + } else { + if ($item->manage_stock) { + $available = $item->getAvailableStock(); + } else { + $available = PHP_INT_MAX; + } + } + + return $available; + } + /** * Check if any single item in pool is a booking product */ @@ -558,49 +617,8 @@ trait MayBePoolProduct $availableItems = []; foreach ($singleItems as $item) { - // Check if item is available - $available = 0; - - if ($from && $until) { - if ($item->isBooking()) { - // For booking items, calculate actual available quantity during the period - if (!$item->manage_stock) { - $available = PHP_INT_MAX; - } else { - // Calculate overlapping claims for this specific period - $overlappingClaims = $item->stocks() - ->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value) - ->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value) - ->where(function ($query) use ($from, $until) { - $query->where(function ($q) use ($from, $until) { - $q->whereBetween('claimed_from', [$from, $until]); - })->orWhere(function ($q) use ($from, $until) { - $q->whereBetween('expires_at', [$from, $until]); - })->orWhere(function ($q) use ($from, $until) { - $q->where('claimed_from', '<=', $from) - ->where('expires_at', '>=', $until); - })->orWhere(function ($q) use ($from, $until) { - $q->whereNull('claimed_from') - ->where(function ($subQ) use ($from, $until) { - $subQ->whereNull('expires_at') - ->orWhere('expires_at', '>=', $from); - }); - }); - }) - ->sum('quantity'); - - $available = max(0, $item->getAvailableStock() - abs($overlappingClaims)); - } - } elseif (!$item->isBooking()) { - $available = $item->getAvailableStock(); - } - } else { - if ($item->manage_stock) { - $available = $item->getAvailableStock(); - } else { - $available = PHP_INT_MAX; - } - } + // Check if item is available using DRY helper method + $available = $this->calculateSingleItemAvailability($item, $from, $until); if ($available > 0) { $price = $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale()); @@ -659,21 +677,21 @@ trait MayBePoolProduct } /** - * Get next available pool price considering which specific price tiers are already in the cart + * Get next available pool item with price considering which specific price tiers are already in the cart * This is smarter than getNextAvailablePoolPrice because it tracks usage by price point * * @param \Blax\Shop\Models\Cart $cart The cart to check * @param bool|null $sales_price Whether to get sale price * @param \DateTimeInterface|null $from Start date for availability check * @param \DateTimeInterface|null $until End date for availability check - * @return float|null + * @return array|null ['price' => float, 'item' => Product, 'price_id' => string|null] */ - public function getNextAvailablePoolPriceConsideringCart( + public function getNextAvailablePoolItemWithPrice( \Blax\Shop\Models\Cart $cart, bool|null $sales_price = null, ?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null - ): ?float { + ): ?array { if (!$this->isPool()) { return null; } @@ -717,53 +735,17 @@ trait MayBePoolProduct // Build available items list $availableItems = []; foreach ($singleItems as $item) { - $available = 0; - - if ($from && $until) { - if ($item->isBooking()) { - if (!$item->manage_stock) { - $available = PHP_INT_MAX; - } else { - // Calculate overlapping claims - $overlappingClaims = $item->stocks() - ->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value) - ->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value) - ->where(function ($query) use ($from, $until) { - $query->where(function ($q) use ($from, $until) { - $q->whereBetween('claimed_from', [$from, $until]); - })->orWhere(function ($q) use ($from, $until) { - $q->whereBetween('expires_at', [$from, $until]); - })->orWhere(function ($q) use ($from, $until) { - $q->where('claimed_from', '<=', $from) - ->where('expires_at', '>=', $until); - })->orWhere(function ($q) use ($from, $until) { - $q->whereNull('claimed_from') - ->where(function ($subQ) use ($from, $until) { - $subQ->whereNull('expires_at') - ->orWhere('expires_at', '>=', $from); - }); - }); - }) - ->sum('quantity'); - - $available = max(0, $item->getAvailableStock() - abs($overlappingClaims)); - } - } elseif (!$item->isBooking()) { - $available = $item->getAvailableStock(); - } - } else { - if ($item->manage_stock) { - $available = $item->getAvailableStock(); - } else { - $available = PHP_INT_MAX; - } - } + // Check if item is available using DRY helper method + $available = $this->calculateSingleItemAvailability($item, $from, $until); if ($available > 0) { - $price = $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale()); + $priceModel = $item->defaultPrice()->first(); + $price = $priceModel?->getCurrentPrice($sales_price ?? $item->isOnSale()); + // If single item has no price, use pool's price as fallback if ($price === null && $this->hasPrice()) { - $price = $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + $priceModel = $this->defaultPrice()->first(); + $price = $priceModel?->getCurrentPrice($sales_price ?? $this->isOnSale()); } if ($price !== null) { @@ -778,6 +760,7 @@ trait MayBePoolProduct 'price' => $price, 'quantity' => $availableAtThisPrice, 'item' => $item, + 'price_id' => $priceModel?->id, ]; } } @@ -786,7 +769,8 @@ trait MayBePoolProduct // Also add pool's direct price if it has one if ($this->hasPrice()) { - $poolPrice = $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + $poolPriceModel = $this->defaultPrice()->first(); + $poolPrice = $poolPriceModel?->getCurrentPrice($sales_price ?? $this->isOnSale()); if ($poolPrice !== null) { $poolPriceRounded = round($poolPrice, 2); $usedAtPoolPrice = $priceUsage[$poolPriceRounded] ?? 0; @@ -797,6 +781,7 @@ trait MayBePoolProduct 'price' => $poolPrice, 'quantity' => PHP_INT_MAX, 'item' => $this, + 'price_id' => $poolPriceModel?->id, ]; } } @@ -806,7 +791,9 @@ trait MayBePoolProduct return null; } - // For AVERAGE strategy, calculate weighted average of available items + // For AVERAGE strategy, we need to return a representative item + // In this case, we'll return the first available item for simplicity + // since all items contribute to the average price equally if ($strategy === \Blax\Shop\Enums\PricingStrategy::AVERAGE) { $totalPrice = 0; $totalQuantity = 0; @@ -815,7 +802,19 @@ trait MayBePoolProduct $totalPrice += $item['price'] * $qty; $totalQuantity += $qty; } - return $totalQuantity > 0 ? $totalPrice / $totalQuantity : null; + $averagePrice = $totalQuantity > 0 ? $totalPrice / $totalQuantity : null; + + if ($averagePrice === null) { + return null; + } + + // Return the first item but with average price + // Note: price_id should still be from the actual item being allocated + return [ + 'price' => $averagePrice, + 'item' => $availableItems[0]['item'], + 'price_id' => $availableItems[0]['price_id'], + ]; } // Sort by strategy @@ -827,8 +826,32 @@ trait MayBePoolProduct }; }); - // Return the first available item's price - return $availableItems[0]['price'] ?? null; + // Return the first available item with its price and price_id + return [ + 'price' => $availableItems[0]['price'], + 'item' => $availableItems[0]['item'], + 'price_id' => $availableItems[0]['price_id'], + ]; + } + + /** + * Get next available pool price considering which specific price tiers are already in the cart + * This method wraps getNextAvailablePoolItemWithPrice for backwards compatibility + * + * @param \Blax\Shop\Models\Cart $cart The cart to check + * @param bool|null $sales_price Whether to get sale price + * @param \DateTimeInterface|null $from Start date for availability check + * @param \DateTimeInterface|null $until End date for availability check + * @return float|null + */ + public function getNextAvailablePoolPriceConsideringCart( + \Blax\Shop\Models\Cart $cart, + bool|null $sales_price = null, + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null + ): ?float { + $result = $this->getNextAvailablePoolItemWithPrice($cart, $sales_price, $from, $until); + return $result['price'] ?? null; } /** diff --git a/tests/Feature/CartCheckoutSessionTest.php b/tests/Feature/CartCheckoutSessionTest.php new file mode 100644 index 0000000..980672a --- /dev/null +++ b/tests/Feature/CartCheckoutSessionTest.php @@ -0,0 +1,411 @@ +user = User::factory()->create(); + $this->cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + } + + /** @test */ + public function it_throws_exception_when_stripe_is_disabled() + { + config(['shop.stripe.enabled' => false]); + + $product = Product::factory()->create(); + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cart->addToCart($product, 1); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Stripe is not enabled'); + + $this->cart->checkoutSession(); + } + + /** @test */ + public function it_builds_checkout_session_with_simple_product_without_stripe_api() + { + // Enable Stripe but don't actually call the API + config(['shop.stripe.enabled' => true]); + config(['shop.currency' => 'usd']); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create([ + 'name' => 'Test Product', + 'short_description' => 'Short desc', + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1500, // $15.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cart->addToCart($product, 2); + + // Mock the Stripe API to avoid actual calls + $this->mockStripeCheckoutSession(); + + $session = $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + ]); + + // Verify the session was created with correct parameters + $this->assertNotNull($session); + $this->assertEquals('mock_session_id', $session->id); + } + + /** @test */ + public function it_uses_short_description_for_product_name_if_available() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create([ + 'name' => 'Very Long Product Name That Would Be Too Long', + 'short_description' => 'Short Name', + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cart->addToCart($product, 1); + + // Capture the session params + $sessionParams = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) { + $sessionParams = $params; + $mockSession = new \stdClass(); + $mockSession->id = 'mock_session_id'; + return $mockSession; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + ]); + + $this->assertNotNull($sessionParams); + $this->assertEquals('Short Name', $sessionParams['line_items'][0]['price_data']['product_data']['name']); + } + + /** @test */ + public function it_includes_booking_dates_in_product_name() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $bookingProduct = Product::factory()->create([ + 'name' => 'Hotel Room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bookingProduct->increaseStock(10); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10000, // $100 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = now()->addDays(1)->startOfDay(); + $until = now()->addDays(3)->startOfDay(); // 2 days + + $this->cart->addToCart($bookingProduct, 1, [], $from, $until); + + // Capture the session params + $sessionParams = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) { + $sessionParams = $params; + $mockSession = new \stdClass(); + $mockSession->id = 'mock_session_id'; + return $mockSession; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + ]); + + $productName = $sessionParams['line_items'][0]['price_data']['product_data']['name']; + + $this->assertStringContainsString('Hotel Room', $productName); + $this->assertStringContainsString('from', $productName); + $this->assertStringContainsString('to', $productName); + } + + /** @test */ + public function it_calculates_correct_unit_amount_in_cents() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create(['name' => 'Test Product']); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2550, // $25.50 + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cart->addToCart($product, 1); + + // Capture the session params + $sessionParams = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) { + $sessionParams = $params; + $mockSession = new \stdClass(); + $mockSession->id = 'mock_session_id'; + return $mockSession; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + ]); + + // Cart stores price as decimal (25.50), Stripe needs cents (2550) + $this->assertEquals(255000, $sessionParams['line_items'][0]['price_data']['unit_amount']); + } + + /** @test */ + public function it_handles_booking_with_fractional_days() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $bookingProduct = Product::factory()->create([ + 'name' => 'Parking Spot', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bookingProduct->increaseStock(10); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, // $10 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + // 4 hours booking (0.1667 days) + $from = now()->addDays(1)->setTime(10, 0); + $until = now()->addDays(1)->setTime(14, 0); + + $this->cart->addToCart($bookingProduct, 1, [], $from, $until); + + // Capture the session params + $sessionParams = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) { + $sessionParams = $params; + $mockSession = new \stdClass(); + $mockSession->id = 'mock_session_id'; + return $mockSession; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + ]); + + // The cart item should have calculated the fractional day price + $cartItem = $this->cart->items->first(); + + // Price should be rounded appropriately and converted to cents + $expectedCents = (int) round($cartItem->price * 100); + $this->assertEquals($expectedCents, $sessionParams['line_items'][0]['price_data']['unit_amount']); + } + + /** @test */ + public function it_creates_separate_line_items_for_multiple_products() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product1 = Product::factory()->create(['name' => 'Product 1']); + $product2 = Product::factory()->create(['name' => 'Product 2']); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cart->addToCart($product1, 2); + $this->cart->addToCart($product2, 1); + + // Capture the session params + $sessionParams = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) { + $sessionParams = $params; + $mockSession = new \stdClass(); + $mockSession->id = 'mock_session_id'; + return $mockSession; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + ]); + + $this->assertCount(2, $sessionParams['line_items']); + $this->assertEquals(2, $sessionParams['line_items'][0]['quantity']); + $this->assertEquals(1, $sessionParams['line_items'][1]['quantity']); + } + + /** @test */ + public function it_uses_configured_currency() + { + config(['shop.stripe.enabled' => true]); + config(['shop.currency' => 'eur']); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create(['name' => 'Product']); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'EUR', + 'is_default' => true, + ]); + + $this->cart->addToCart($product, 1); + + // Capture the session params + $sessionParams = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) { + $sessionParams = $params; + $mockSession = new \stdClass(); + $mockSession->id = 'mock_session_id'; + return $mockSession; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + ]); + + $this->assertEquals('eur', $sessionParams['line_items'][0]['price_data']['currency']); + } + + /** @test */ + public function it_stores_session_id_in_cart_meta() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create(['name' => 'Product']); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cart->addToCart($product, 1); + + $this->mockStripeCheckoutSession(); + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + ]); + + $this->cart->refresh(); + $meta = $this->cart->meta; + + $this->assertNotNull($meta->stripe_session_id ?? null); + $this->assertEquals('mock_session_id', $meta->stripe_session_id); + } + + /** + * Mock Stripe Checkout Session creation to avoid actual API calls + */ + protected function mockStripeCheckoutSession() + { + // Create a simple mock that returns a session object + \Stripe\Checkout\Session::$createCallback = function ($params) { + $mockSession = new \stdClass(); + $mockSession->id = 'mock_session_id'; + $mockSession->url = 'https://checkout.stripe.com/mock'; + return $mockSession; + }; + } +} + +// Add a simple mock capability to Stripe Session class for testing +namespace Stripe\Checkout; + +class Session +{ + public static $createCallback = null; + + public static function create($params) + { + if (self::$createCallback) { + return call_user_func(self::$createCallback, $params); + } + + // If no callback, throw exception (actual Stripe call would be made) + throw new \Exception('Stripe API call attempted without mock. Set createCallback first.'); + } + + public static function resetMock() + { + self::$createCallback = null; + } +} diff --git a/tests/Feature/PoolProductPriceIdTest.php b/tests/Feature/PoolProductPriceIdTest.php new file mode 100644 index 0000000..ae40c98 --- /dev/null +++ b/tests/Feature/PoolProductPriceIdTest.php @@ -0,0 +1,232 @@ +user = User::factory()->create(); + $this->cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + // Create pool product + $this->poolProduct = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create single items with different prices + $this->singleItem1 = Product::factory()->create([ + 'name' => 'Parking Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->singleItem1->increaseStock(1); + + $this->singleItem2 = Product::factory()->create([ + 'name' => 'Parking Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->singleItem2->increaseStock(1); + + // Set prices on single items + $this->price1 = ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // $20/day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->price2 = ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // $50/day + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Link single items to pool + $this->poolProduct->productRelations()->attach($this->singleItem1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $this->poolProduct->productRelations()->attach($this->singleItem2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + } + + /** @test */ + public function it_stores_single_item_price_id_when_adding_pool_to_cart_with_lowest_strategy() + { + // Set pricing strategy to lowest (default) + $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); + + // Add pool to cart - should use the lowest price (singleItem1's price) + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + // Assert the cart item has the price_id from the single item, not the pool + $this->assertNotNull($cartItem->price_id); + $this->assertEquals($this->price1->id, $cartItem->price_id); + $this->assertEquals(2000, $cartItem->price); // $20 + } + + /** @test */ + public function it_stores_correct_price_id_for_second_pool_item_with_progressive_pricing() + { + // Set pricing strategy to lowest + $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); + + // Add first pool item - should use lowest price (singleItem1) + $cartItem1 = $this->cart->addToCart($this->poolProduct, 1); + $this->assertEquals($this->price1->id, $cartItem1->price_id); + $this->assertEquals(2000, $cartItem1->price); + + // Add second pool item - should use next lowest price (singleItem2) + $cartItem2 = $this->cart->addToCart($this->poolProduct, 1); + $this->assertEquals($this->price2->id, $cartItem2->price_id); + $this->assertEquals(5000, $cartItem2->price); + } + + /** @test */ + public function it_stores_single_item_price_id_with_highest_strategy() + { + // Set pricing strategy to highest + $this->poolProduct->setPoolPricingStrategy('highest'); + + // Add pool to cart - should use the highest price (singleItem2's price) + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + // Assert the cart item has the price_id from the single item with highest price + $this->assertNotNull($cartItem->price_id); + $this->assertEquals($this->price2->id, $cartItem->price_id); + $this->assertEquals(5000, $cartItem->price); // $50 + } + + /** @test */ + public function it_stores_allocated_single_item_in_meta() + { + // Set pricing strategy to lowest + $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); + + // Add pool to cart + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + // Check meta contains allocated single item info + $meta = $cartItem->getMeta(); + $this->assertNotNull($meta->allocated_single_item_id ?? null); + $this->assertEquals($this->singleItem1->id, $meta->allocated_single_item_id); + $this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name); + } + + /** @test */ + public function it_stores_different_single_items_in_meta_for_progressive_pricing() + { + // Set pricing strategy to lowest + $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); + + // Add first pool item + $cartItem1 = $this->cart->addToCart($this->poolProduct, 1); + $meta1 = $cartItem1->getMeta(); + $this->assertEquals($this->singleItem1->id, $meta1->allocated_single_item_id); + + // Add second pool item + $cartItem2 = $this->cart->addToCart($this->poolProduct, 1); + $meta2 = $cartItem2->getMeta(); + $this->assertEquals($this->singleItem2->id, $meta2->allocated_single_item_id); + } + + /** @test */ + public function it_uses_pool_price_id_when_pool_has_direct_price_and_no_single_item_prices() + { + // Remove prices from single items + $this->price1->delete(); + $this->price2->delete(); + + // Set a direct price on the pool itself + $poolPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, // $30 + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Add pool to cart - should use pool's direct price as fallback + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + // Assert the cart item has the pool's price_id + $this->assertEquals($poolPrice->id, $cartItem->price_id); + $this->assertEquals(3000, $cartItem->price); + + // Meta should indicate which single item was allocated + // Even though the pool's price is used as fallback, one of the single items is still allocated + $meta = $cartItem->getMeta(); + $this->assertNotNull($meta->allocated_single_item_id ?? null); + $this->assertTrue( + $meta->allocated_single_item_id === $this->singleItem1->id || + $meta->allocated_single_item_id === $this->singleItem2->id, + 'Allocated single item should be one of the pool\'s single items' + ); + } + + /** @test */ + public function it_stores_price_id_with_average_pricing_strategy() + { + // Set pricing strategy to average + $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); + + // Add pool to cart - should use average price but store first item's price_id + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + // Average of 2000 and 5000 = 3500 + $this->assertEquals(3500, $cartItem->price); + + // Should store a price_id (from one of the single items) + $this->assertNotNull($cartItem->price_id); + $this->assertTrue( + $cartItem->price_id === $this->price1->id || $cartItem->price_id === $this->price2->id, + 'Price ID should be from one of the single items' + ); + } + + /** @test */ + public function it_stores_correct_price_id_with_booking_dates() + { + // Set pricing strategy to lowest + $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); + + $from = now()->addDays(1)->startOfDay(); + $until = now()->addDays(3)->startOfDay(); // 2 days + + // Add pool to cart with dates + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + + // Should use lowest price and store its price_id + $this->assertEquals($this->price1->id, $cartItem->price_id); + $this->assertEquals(4000, $cartItem->price); // $20 × 2 days + } +}