I checkout session, pool cart price_id
This commit is contained in:
parent
abbfbd3649
commit
c43910b927
|
|
@ -64,6 +64,9 @@ return [
|
||||||
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Currency configuration
|
||||||
|
'currency' => env('SHOP_CURRENCY', 'usd'),
|
||||||
|
|
||||||
// Cache configuration
|
// Cache configuration
|
||||||
'cache' => [
|
'cache' => [
|
||||||
'enabled' => env('SHOP_CACHE_ENABLED', true),
|
'enabled' => env('SHOP_CACHE_ENABLED', true),
|
||||||
|
|
|
||||||
|
|
@ -603,15 +603,30 @@ class Cart extends Model
|
||||||
|
|
||||||
// Calculate price per day (base price)
|
// Calculate price per day (base price)
|
||||||
// For pool products, get price based on how many items are already in cart
|
// For pool products, get price based on how many items are already in cart
|
||||||
|
$poolSingleItem = null;
|
||||||
|
$poolPriceId = null;
|
||||||
if ($cartable instanceof Product && $cartable->isPool()) {
|
if ($cartable instanceof Product && $cartable->isPool()) {
|
||||||
// Use smarter pricing that considers which price tiers are used
|
// Use smarter pricing that considers which price tiers are used
|
||||||
$pricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until);
|
$poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);
|
||||||
$regularPricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, false, $from, $until) ?? $pricePerDay;
|
|
||||||
|
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 no price found from pool items, try the pool's direct price as fallback
|
||||||
if ($pricePerDay === null && $cartable->hasPrice()) {
|
if ($pricePerDay === null && $cartable->hasPrice()) {
|
||||||
$pricePerDay = $cartable->defaultPrice()->first()?->getCurrentPrice($cartable->isOnSale());
|
$priceModel = $cartable->defaultPrice()->first();
|
||||||
$regularPricePerDay = $cartable->defaultPrice()->first()?->getCurrentPrice(false) ?? $pricePerDay;
|
$pricePerDay = $priceModel?->getCurrentPrice($cartable->isOnSale());
|
||||||
|
$regularPricePerDay = $priceModel?->getCurrentPrice(false) ?? $pricePerDay;
|
||||||
|
$poolPriceId = $priceModel?->id;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$pricePerDay = $cartable->getCurrentPrice();
|
$pricePerDay = $cartable->getCurrentPrice();
|
||||||
|
|
@ -659,9 +674,14 @@ class Cart extends Model
|
||||||
// Determine price_id for the cart item
|
// Determine price_id for the cart item
|
||||||
$priceId = null;
|
$priceId = null;
|
||||||
if ($cartable instanceof Product) {
|
if ($cartable instanceof Product) {
|
||||||
|
// For pool products, use the single item's price_id
|
||||||
|
if ($cartable->isPool() && $poolPriceId) {
|
||||||
|
$priceId = $poolPriceId;
|
||||||
|
} else {
|
||||||
// Get the default price for the product
|
// Get the default price for the product
|
||||||
$defaultPrice = $cartable->defaultPrice()->first();
|
$defaultPrice = $cartable->defaultPrice()->first();
|
||||||
$priceId = $defaultPrice?->id;
|
$priceId = $defaultPrice?->id;
|
||||||
|
}
|
||||||
} elseif ($cartable instanceof \Blax\Shop\Models\ProductPrice) {
|
} elseif ($cartable instanceof \Blax\Shop\Models\ProductPrice) {
|
||||||
// If adding a ProductPrice directly, use its ID
|
// If adding a ProductPrice directly, use its ID
|
||||||
$priceId = $cartable->id;
|
$priceId = $cartable->id;
|
||||||
|
|
@ -681,6 +701,12 @@ class Cart extends Model
|
||||||
'until' => $until,
|
'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;
|
return $cartItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -870,15 +896,15 @@ class Cart extends Model
|
||||||
*
|
*
|
||||||
* This method:
|
* This method:
|
||||||
* - Validates the cart (doesn't convert it)
|
* - 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
|
* - Creates line items with descriptions including booking dates
|
||||||
* - Returns the Stripe checkout session
|
* - Returns the Stripe checkout session
|
||||||
*
|
*
|
||||||
* @param array $options Optional session parameters (success_url, cancel_url, etc.)
|
* @param array $options Optional session parameters (success_url, cancel_url, etc.)
|
||||||
* @return \Stripe\Checkout\Session
|
* @return mixed Stripe\Checkout\Session instance
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
public function checkoutSession(array $options = []): \Stripe\Checkout\Session
|
public function checkoutSession(array $options = [])
|
||||||
{
|
{
|
||||||
if (!config('shop.stripe.enabled')) {
|
if (!config('shop.stripe.enabled')) {
|
||||||
throw new \Exception('Stripe is not enabled');
|
throw new \Exception('Stripe is not enabled');
|
||||||
|
|
@ -890,56 +916,36 @@ class Cart extends Model
|
||||||
// Validate cart before proceeding (doesn't convert it)
|
// Validate cart before proceeding (doesn't convert it)
|
||||||
$this->validateForCheckout();
|
$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 = [];
|
$lineItems = [];
|
||||||
|
|
||||||
foreach ($this->items as $index => $item) {
|
foreach ($this->items as $item) {
|
||||||
// Use the pre-fetched stripe price ID
|
$product = $item->purchasable;
|
||||||
$stripePriceId = $stripePriceIds[$index];
|
|
||||||
|
|
||||||
// Build line item with description including booking dates if applicable
|
// Get product name (use short_description if available, otherwise name)
|
||||||
$lineItem = [
|
$productName = $product->short_description ?? $product->name ?? 'Product';
|
||||||
'price' => $stripePriceId,
|
|
||||||
'quantity' => $item->quantity,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add description with booking dates if available
|
// Build description with booking dates if available
|
||||||
$description = null;
|
|
||||||
if ($item->from && $item->until) {
|
if ($item->from && $item->until) {
|
||||||
$days = $this->calculateBookingDays($item->from, $item->until);
|
|
||||||
$fromFormatted = $item->from->format('M j, Y H:i');
|
$fromFormatted = $item->from->format('M j, Y H:i');
|
||||||
$untilFormatted = $item->until->format('M j, Y H:i');
|
$untilFormatted = $item->until->format('M j, Y H:i');
|
||||||
$daysText = number_format($days, 2) . ' day' . ($days != 1 ? 's' : '');
|
$productName .= " from {$fromFormatted} to {$untilFormatted}";
|
||||||
$description = "Period: {$fromFormatted} to {$untilFormatted} ({$daysText})";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($description) {
|
// Convert price to cents (Stripe expects smallest currency unit)
|
||||||
$lineItem['description'] = $description;
|
// 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;
|
$lineItems[] = $lineItem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,65 @@ trait MayBePoolProduct
|
||||||
return $released;
|
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
|
* Check if any single item in pool is a booking product
|
||||||
*/
|
*/
|
||||||
|
|
@ -558,49 +617,8 @@ trait MayBePoolProduct
|
||||||
$availableItems = [];
|
$availableItems = [];
|
||||||
|
|
||||||
foreach ($singleItems as $item) {
|
foreach ($singleItems as $item) {
|
||||||
// Check if item is available
|
// Check if item is available using DRY helper method
|
||||||
$available = 0;
|
$available = $this->calculateSingleItemAvailability($item, $from, $until);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($available > 0) {
|
if ($available > 0) {
|
||||||
$price = $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
|
$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
|
* This is smarter than getNextAvailablePoolPrice because it tracks usage by price point
|
||||||
*
|
*
|
||||||
* @param \Blax\Shop\Models\Cart $cart The cart to check
|
* @param \Blax\Shop\Models\Cart $cart The cart to check
|
||||||
* @param bool|null $sales_price Whether to get sale price
|
* @param bool|null $sales_price Whether to get sale price
|
||||||
* @param \DateTimeInterface|null $from Start date for availability check
|
* @param \DateTimeInterface|null $from Start date for availability check
|
||||||
* @param \DateTimeInterface|null $until End 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,
|
\Blax\Shop\Models\Cart $cart,
|
||||||
bool|null $sales_price = null,
|
bool|null $sales_price = null,
|
||||||
?\DateTimeInterface $from = null,
|
?\DateTimeInterface $from = null,
|
||||||
?\DateTimeInterface $until = null
|
?\DateTimeInterface $until = null
|
||||||
): ?float {
|
): ?array {
|
||||||
if (!$this->isPool()) {
|
if (!$this->isPool()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -717,53 +735,17 @@ trait MayBePoolProduct
|
||||||
// Build available items list
|
// Build available items list
|
||||||
$availableItems = [];
|
$availableItems = [];
|
||||||
foreach ($singleItems as $item) {
|
foreach ($singleItems as $item) {
|
||||||
$available = 0;
|
// Check if item is available using DRY helper method
|
||||||
|
$available = $this->calculateSingleItemAvailability($item, $from, $until);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($available > 0) {
|
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()) {
|
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) {
|
if ($price !== null) {
|
||||||
|
|
@ -778,6 +760,7 @@ trait MayBePoolProduct
|
||||||
'price' => $price,
|
'price' => $price,
|
||||||
'quantity' => $availableAtThisPrice,
|
'quantity' => $availableAtThisPrice,
|
||||||
'item' => $item,
|
'item' => $item,
|
||||||
|
'price_id' => $priceModel?->id,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -786,7 +769,8 @@ trait MayBePoolProduct
|
||||||
|
|
||||||
// Also add pool's direct price if it has one
|
// Also add pool's direct price if it has one
|
||||||
if ($this->hasPrice()) {
|
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) {
|
if ($poolPrice !== null) {
|
||||||
$poolPriceRounded = round($poolPrice, 2);
|
$poolPriceRounded = round($poolPrice, 2);
|
||||||
$usedAtPoolPrice = $priceUsage[$poolPriceRounded] ?? 0;
|
$usedAtPoolPrice = $priceUsage[$poolPriceRounded] ?? 0;
|
||||||
|
|
@ -797,6 +781,7 @@ trait MayBePoolProduct
|
||||||
'price' => $poolPrice,
|
'price' => $poolPrice,
|
||||||
'quantity' => PHP_INT_MAX,
|
'quantity' => PHP_INT_MAX,
|
||||||
'item' => $this,
|
'item' => $this,
|
||||||
|
'price_id' => $poolPriceModel?->id,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -806,7 +791,9 @@ trait MayBePoolProduct
|
||||||
return null;
|
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) {
|
if ($strategy === \Blax\Shop\Enums\PricingStrategy::AVERAGE) {
|
||||||
$totalPrice = 0;
|
$totalPrice = 0;
|
||||||
$totalQuantity = 0;
|
$totalQuantity = 0;
|
||||||
|
|
@ -815,7 +802,19 @@ trait MayBePoolProduct
|
||||||
$totalPrice += $item['price'] * $qty;
|
$totalPrice += $item['price'] * $qty;
|
||||||
$totalQuantity += $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
|
// Sort by strategy
|
||||||
|
|
@ -827,8 +826,32 @@ trait MayBePoolProduct
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the first available item's price
|
// Return the first available item with its price and price_id
|
||||||
return $availableItems[0]['price'] ?? null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue