I checkout session, pool cart price_id

This commit is contained in:
Fabian @ Blax Software 2025-12-17 18:33:34 +01:00
parent abbfbd3649
commit c43910b927
5 changed files with 821 additions and 146 deletions

View File

@ -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),

View File

@ -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;
}

View File

@ -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;
}
/**

View File

@ -0,0 +1,411 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class CartCheckoutSessionTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Cart $cart;
protected function setUp(): void
{
parent::setUp();
$this->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;
}
}

View File

@ -0,0 +1,232 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Workbench\App\Models\User;
class PoolProductPriceIdTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected Product $poolProduct;
protected Product $singleItem1;
protected Product $singleItem2;
protected ProductPrice $price1;
protected ProductPrice $price2;
protected function setUp(): void
{
parent::setUp();
$this->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
}
}