BFI cart stock, A exceptions

This commit is contained in:
Fabian @ Blax Software 2025-12-19 14:26:57 +01:00
parent 317b28af8a
commit 145c629786
22 changed files with 387 additions and 106 deletions

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class CartAlreadyConvertedException extends Exception
{
public function __construct(string $message = "Cart has already been converted/checked out.")
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class CartDatesRequiredException extends Exception
{
public function __construct(string $message = "Both 'from' and 'until' dates must be provided together, or both omitted.")
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class CartEmptyException extends Exception
{
public function __construct(string $message = "Cart is empty.")
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class CartItemMissingInformationException extends Exception
{
public function __construct(string $productName, string $missingFields)
{
parent::__construct("Cart item '{$productName}' is missing required information: {$missingFields}");
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class CartableInterfaceException extends Exception
{
public function __construct(string $message = "Item must implement the Cartable interface.")
{
parent::__construct($message);
}
}

View File

@ -4,6 +4,11 @@ namespace Blax\Shop\Exceptions;
class HasNoDefaultPriceException extends NotPurchasable
{
public static function forProduct(string $productName): self
{
return new self("Product '{$productName}' has no default price.");
}
public static function multiplePricesNoDefault(string $productName, int $priceCount): self
{
return new self(

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class InvalidPricingStrategyException extends Exception
{
public function __construct(string $strategy)
{
parent::__construct("Invalid pricing strategy: {$strategy}");
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class NotPoolProductException extends Exception
{
public function __construct(string $message = "This method is only for pool products.")
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class PoolHasNoItemsException extends Exception
{
public function __construct(string $message = "Pool product has no single items to claim.")
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class PriceCalculationException extends Exception
{
public function __construct(string $productName, ?int $pricePerDay = null, ?int $days = null)
{
$message = "Cart item price calculation resulted in null for '{$productName}'";
if ($pricePerDay !== null && $days !== null) {
$message .= " (pricePerDay: {$pricePerDay}, days: {$days})";
}
parent::__construct($message);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class ProductHasNoPriceException extends Exception
{
public function __construct(string $productName)
{
parent::__construct("Product '{$productName}' has no valid price.");
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class ProductMissingAssociationException extends Exception
{
public function __construct(string $message = "Cannot sync price without associated product.")
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class StripeNotEnabledException extends Exception
{
public function __construct(string $message = "Stripe is not enabled.")
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class UnsupportedPaymentProviderException extends Exception
{
public function __construct(string $provider)
{
parent::__construct("Unsupported payment provider: {$provider}");
}
}

View File

@ -6,8 +6,16 @@ use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Exceptions\CartableInterfaceException;
use Blax\Shop\Exceptions\CartAlreadyConvertedException;
use Blax\Shop\Exceptions\CartDatesRequiredException;
use Blax\Shop\Exceptions\CartEmptyException;
use Blax\Shop\Exceptions\CartItemMissingInformationException;
use Blax\Shop\Exceptions\InvalidDateRangeException;
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\PriceCalculationException;
use Blax\Shop\Exceptions\ProductHasNoPriceException;
use Blax\Shop\Services\CartService;
use Blax\Shop\Traits\ChecksIfBooking;
use Blax\Shop\Traits\HasBookingPriceCalculation;
@ -216,39 +224,60 @@ class Cart extends Model
* @throws NotEnoughAvailableInTimespanException
*/
public function setDates(
\DateTimeInterface|string|int|float $from,
\DateTimeInterface|string|int|float $until,
\DateTimeInterface|string|int|float|null $from,
\DateTimeInterface|string|int|float|null $until,
bool $validateAvailability = true,
bool $overwrite_item_dates = true
): self {
// Parse string dates using Carbon
if (is_string($from) || is_numeric($from)) {
if ($from !== null && (is_string($from) || is_numeric($from))) {
$from = Carbon::parse($from);
}
if (is_string($until) || is_numeric($until)) {
if ($until !== null && (is_string($until) || is_numeric($until))) {
$until = Carbon::parse($until);
}
if ($from >= $until) {
throw new InvalidDateRangeException();
// Always update cart dates with provided values
$updateData = [];
if ($from !== null) {
$updateData['from'] = $from;
}
if ($until !== null) {
$updateData['until'] = $until;
}
if ($validateAvailability) {
// When overwriting item dates, validate against the new cart dates
$this->validateDateAvailability($from, $until, $overwrite_item_dates);
if (!empty($updateData)) {
$this->update($updateData);
$this->refresh();
}
// Update cart with from/until
$this->update([
'from' => $from,
'until' => $until,
]);
// Get the current dates (may include one from database if only one was updated)
$effectiveFrom = $from ?? $this->from;
$effectiveUntil = $until ?? $this->until;
// Update cart items with from/until
$this->applyDatesToItems(
$validateAvailability,
$overwrite_item_dates
);
// Only calculate/validate if BOTH dates are set
if ($effectiveFrom && $effectiveUntil) {
// For calculations, swap if dates are backwards
$calcFrom = $effectiveFrom;
$calcUntil = $effectiveUntil;
if ($effectiveFrom > $effectiveUntil) {
$calcFrom = $effectiveUntil;
$calcUntil = $effectiveFrom;
}
if ($validateAvailability) {
// Validate against the correctly ordered dates
$this->validateDateAvailability($calcFrom, $calcUntil, $overwrite_item_dates);
}
// Update cart items with correctly ordered dates
$this->applyDatesToItems(
$validateAvailability,
$overwrite_item_dates,
$calcFrom,
$calcUntil
);
}
return $this->fresh();
}
@ -259,7 +288,6 @@ class Cart extends Model
* @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string)
* @param bool $validateAvailability Whether to validate product availability for the timespan
* @return $this
* @throws InvalidDateRangeException
* @throws NotEnoughAvailableInTimespanException
*/
public function setFromDate(
@ -271,15 +299,24 @@ class Cart extends Model
$from = Carbon::parse($from);
}
if ($this->until && $from >= $this->until) {
throw new InvalidDateRangeException();
}
if ($validateAvailability && $this->until) {
$this->validateDateAvailability($from, $this->until);
}
// Always update the from date
$this->update(['from' => $from]);
$this->refresh();
// Only calculate if both dates are set
if ($this->until) {
// For calculations, swap if dates are backwards
$calcFrom = $from;
$calcUntil = $this->until;
if ($from > $this->until) {
$calcFrom = $this->until;
$calcUntil = $from;
}
if ($validateAvailability) {
$this->validateDateAvailability($calcFrom, $calcUntil);
}
}
return $this->fresh();
}
@ -290,7 +327,6 @@ class Cart extends Model
* @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string)
* @param bool $validateAvailability Whether to validate product availability for the timespan
* @return $this
* @throws InvalidDateRangeException
* @throws NotEnoughAvailableInTimespanException
*/
public function setUntilDate(\DateTimeInterface|string|int|float $until, bool $validateAvailability = true): self
@ -300,15 +336,24 @@ class Cart extends Model
$until = Carbon::parse($until);
}
if ($this->from && $this->from >= $until) {
throw new InvalidDateRangeException();
}
if ($validateAvailability && $this->from) {
$this->validateDateAvailability($this->from, $until);
}
// Always update the until date
$this->update(['until' => $until]);
$this->refresh();
// Only calculate if both dates are set
if ($this->from) {
// For calculations, swap if dates are backwards
$calcFrom = $this->from;
$calcUntil = $until;
if ($this->from > $until) {
$calcFrom = $until;
$calcUntil = $this->from;
}
if ($validateAvailability) {
$this->validateDateAvailability($calcFrom, $calcUntil);
}
}
return $this->fresh();
}
@ -318,12 +363,22 @@ class Cart extends Model
*
* @param bool $validateAvailability Whether to validate product availability for the timespan
* @param bool $overwrite If true, overwrites existing item dates. If false, only sets null fields.
* @param \DateTimeInterface|null $from Optional from date (uses cart's from if not provided)
* @param \DateTimeInterface|null $until Optional until date (uses cart's until if not provided)
* @return $this
* @throws NotEnoughAvailableInTimespanException
*/
public function applyDatesToItems(bool $validateAvailability = true, bool $overwrite = false): self
{
if (!$this->from || !$this->until) {
public function applyDatesToItems(
bool $validateAvailability = true,
bool $overwrite = false,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null
): self {
// Use provided dates or fall back to cart dates
$fromDate = $from ?? $this->from;
$untilDate = $until ?? $this->until;
if (!$fromDate || !$untilDate) {
return $this;
}
@ -338,23 +393,23 @@ class Cart extends Model
continue;
}
$fromDate = $shouldApplyFrom ? $this->from : $item->from;
$untilDate = $shouldApplyUntil ? $this->until : $item->until;
$itemFrom = $shouldApplyFrom ? $fromDate : $item->from;
$itemUntil = $shouldApplyUntil ? $untilDate : $item->until;
if ($validateAvailability) {
$product = $item->purchasable;
if ($product && !$product->isAvailableForBooking($fromDate, $untilDate, $item->quantity)) {
if ($product && !$product->isAvailableForBooking($itemFrom, $itemUntil, $item->quantity)) {
throw new NotEnoughAvailableInTimespanException(
productName: $product->name ?? 'Product',
requested: $item->quantity,
available: 0, // Could calculate actual available amount
from: $fromDate,
until: $untilDate
from: $itemFrom,
until: $itemUntil
);
}
}
$item->updateDates($fromDate, $untilDate);
$item->updateDates($itemFrom, $itemUntil);
}
}
@ -493,7 +548,7 @@ class Cart extends Model
): CartItem {
// $cartable must implement Cartable
if (! $cartable instanceof Cartable) {
throw new \Exception("Item must implement the Cartable interface.");
throw new CartableInterfaceException();
}
// Extract dates from parameters if not provided directly
@ -511,14 +566,14 @@ class Cart extends Model
if ($from && $until) {
$available = $cartable->getPoolMaxQuantity($from, $until);
if ($available !== PHP_INT_MAX && $quantity > $available) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$available} items available for the requested period. Requested: {$quantity}"
);
}
} else {
$available = $cartable->getPoolMaxQuantity();
if ($available !== PHP_INT_MAX && $quantity > $available) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$available} items available. Requested: {$quantity}"
);
}
@ -541,12 +596,12 @@ class Cart extends Model
if ($from && $until) {
// Validate from is before until
if ($from >= $until) {
throw new \Exception("The 'from' date must be before the 'until' date. Got from: {$from->format('Y-m-d H:i:s')}, until: {$until->format('Y-m-d H:i:s')}");
throw new InvalidDateRangeException("The 'from' date must be before the 'until' date. Got from: {$from->format('Y-m-d H:i:s')}, until: {$until->format('Y-m-d H:i:s')}");
}
// Check booking product availability if dates are provided
if ($cartable->isBooking() && !$cartable->isPool() && !$cartable->isAvailableForBooking($from, $until, $quantity)) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Product '{$cartable->name}' is not available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')})."
);
}
@ -556,14 +611,14 @@ class Cart extends Model
$maxQuantity = $cartable->getPoolMaxQuantity($from, $until);
// Only validate if pool has limited availability AND quantity exceeds it
if ($maxQuantity !== PHP_INT_MAX && $quantity > $maxQuantity) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$maxQuantity} items available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')}). Requested: {$quantity}"
);
}
}
} elseif ($from || $until) {
// If only one date is provided, it's an error
throw new \Exception("Both 'from' and 'until' dates must be provided together, or both omitted.");
throw new CartDatesRequiredException();
} else {
// Even without dates, check pool quantity limits
if ($cartable->isPool()) {
@ -580,7 +635,7 @@ class Cart extends Model
$totalQuantity = $currentQuantityInCart + $quantity;
if ($totalQuantity > $maxQuantity) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$maxQuantity} items available. Already in cart: {$currentQuantityInCart}, Requested: {$quantity}"
);
}
@ -699,7 +754,7 @@ class Cart extends Model
// For pool products, throw specific error when neither pool nor single items have prices
throw \Blax\Shop\Exceptions\HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($cartable->name);
}
throw new \Exception("Product '{$cartable->name}' has no valid price.");
throw new ProductHasNoPriceException($cartable->name);
}
// Calculate days if booking dates provided
@ -714,7 +769,7 @@ class Cart extends Model
// Defensive check - ensure pricePerUnit is not null
if ($pricePerUnit === null) {
throw new \Exception("Cart item price calculation resulted in null for '{$cartable->name}' (pricePerDay: {$pricePerDay}, days: {$days})");
throw new PriceCalculationException($cartable->name, $pricePerDay, $days);
}
// Store the base unit_amount (price for 1 quantity, 1 day) in cents
@ -853,7 +908,7 @@ class Cart extends Model
// Check if cart is already converted
if ($this->isConverted()) {
if ($throws) {
throw new \Exception("Cart has already been converted/checked out");
throw new CartAlreadyConvertedException();
} else {
return false;
}
@ -865,7 +920,7 @@ class Cart extends Model
if ($items->isEmpty()) {
if ($throws) {
throw new \Exception("Cart is empty");
throw new CartEmptyException();
} else {
return false;
}
@ -880,7 +935,7 @@ class Cart extends Model
$missingFields = implode(', ', array_keys($adjustments));
if ($throws) {
throw new \Exception("Cart item '{$productName}' is missing required information: {$missingFields}");
throw new CartItemMissingInformationException($productName, $missingFields);
} else {
return false;
}
@ -895,8 +950,9 @@ class Cart extends Model
continue;
}
$from = $item->from;
$until = $item->until;
// Use effective dates (item-specific or cart fallback)
$from = $item->getEffectiveFromDate();
$until = $item->getEffectiveUntilDate();
// For pool products, check pool availability
if ($product->isPool()) {
@ -915,7 +971,7 @@ class Cart extends Model
if ($available !== PHP_INT_MAX && $totalInCart > $available) {
if ($throws) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Pool product '{$product->name}' has only {$available} items available for the period " .
"{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Cart has: {$totalInCart}"
);
@ -934,7 +990,7 @@ class Cart extends Model
if ($available !== PHP_INT_MAX && $totalInCart > $available) {
if ($throws) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Pool product '{$product->name}' has only {$available} items available. Cart has: {$totalInCart}"
);
} else {
@ -947,7 +1003,7 @@ class Cart extends Model
if ($from && $until) {
if (!$product->isAvailableForBooking($from, $until, $item->quantity)) {
if ($throws) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Booking product '{$product->name}' is not available for the period " .
"{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Requested: {$item->quantity}"
);
@ -961,7 +1017,7 @@ class Cart extends Model
$available = $product->getAvailableStock();
if ($item->quantity > $available) {
if ($throws) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
throw new NotEnoughStockException(
"Product '{$product->name}' has only {$available} items in stock. Requested: {$item->quantity}"
);
} else {

View File

@ -336,7 +336,7 @@ class Product extends Model implements Purchasable, Cartable
return true;
}
// Get stock claims that overlap with the requested period
// Get stock claims (CLAIMED entries) that overlap with the requested period
$overlappingClaims = $this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
@ -362,7 +362,18 @@ class Product extends Model implements Purchasable, Cartable
})
->sum('quantity');
$availableStock = $this->getAvailableStock() - abs($overlappingClaims);
// Also get DECREASE entries with expires_at that overlap (from completed bookings)
// These are booking purchases that reduce stock during the booking period
$overlappingBookings = $this->stocks()
->where('type', StockType::DECREASE->value)
->where('status', StockStatus::COMPLETED->value)
->whereNotNull('expires_at')
->where('expires_at', '>', $from) // Booking hasn't ended before our period starts
->sum('quantity');
// Use base stock and subtract all overlapping reservations
// Note: overlappingBookings is already negative (DECREASE entries), so we add it
$availableStock = $this->getAvailableStock() - abs($overlappingClaims) + $overlappingBookings;
return $availableStock >= $quantity;
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Blax\Shop\Services\PaymentProvider;
use Blax\Shop\Exceptions\UnsupportedPaymentProviderException;
use Blax\Shop\Models\PaymentMethod;
use Blax\Shop\Models\PaymentProviderIdentity;
use Illuminate\Database\Eloquent\Model;
@ -65,7 +66,7 @@ class PaymentProviderService
return $identity;
}
throw new \InvalidArgumentException("Unsupported payment provider: {$provider}");
throw new UnsupportedPaymentProviderException($provider);
}
/**
@ -112,7 +113,7 @@ class PaymentProviderService
return $paymentMethod;
}
throw new \InvalidArgumentException("Unsupported payment provider: {$identity->provider_name}");
throw new UnsupportedPaymentProviderException($identity->provider_name);
}
/**

View File

@ -2,6 +2,9 @@
namespace Blax\Shop\Services;
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
use Blax\Shop\Exceptions\ProductMissingAssociationException;
use Blax\Shop\Exceptions\StripeNotEnabledException;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Illuminate\Support\Facades\Log;
@ -27,7 +30,7 @@ class StripeSyncService
public function syncProduct(Product $product): string
{
if (!config('shop.stripe.enabled')) {
throw new \Exception('Stripe is not enabled');
throw new StripeNotEnabledException();
}
// Check if product already has a Stripe ID
@ -89,7 +92,7 @@ class StripeSyncService
public function syncPrice(ProductPrice $price, ?Product $product = null): string
{
if (!config('shop.stripe.enabled')) {
throw new \Exception('Stripe is not enabled');
throw new StripeNotEnabledException();
}
// Get the product if not provided
@ -98,7 +101,7 @@ class StripeSyncService
}
if (!$product) {
throw new \Exception('Cannot sync price without associated product');
throw new ProductMissingAssociationException();
}
// Ensure product is synced to Stripe
@ -184,7 +187,7 @@ class StripeSyncService
$defaultPrice = $product->defaultPrice()->first();
if (!$defaultPrice) {
throw new \Exception("Product '{$product->name}' has no default price");
throw HasNoDefaultPriceException::forProduct($product->name);
}
$stripePriceId = $this->syncPrice($defaultPrice, $product);

View File

@ -3,6 +3,7 @@
namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
@ -46,31 +47,41 @@ trait HasCart
/**
* Add product to cart
*
* For booking and pool products, stock is NOT claimed at add-to-cart time.
* Instead, availability is validated and stock is claimed at checkout time.
* This allows adding items to cart for future dates even if currently unavailable.
*
* For regular products with manage_stock, stock is claimed immediately to
* prevent overselling.
*
* @param Product|ProductPrice $product_or_price
* @param int $quantity
* @param array $options
* @param array $parameters Optional parameters including 'from' and 'until' for booking dates
* @return CartItem
* @throws \Exception
*/
public function addToCart(Product|ProductPrice $product_or_price, int $quantity = 1, array $parameters = []): CartItem
{
if ($product_or_price instanceof ProductPrice) {
$product = $product_or_price->purchasable;
$product = $product_or_price instanceof ProductPrice
? $product_or_price->purchasable
: $product_or_price;
if ($product instanceof Product) {
if ($product instanceof Product) {
// For booking/pool products, do NOT claim stock at add-to-cart time
// Stock will be validated and claimed at checkout based on the booking dates
$isBookingOrPool = $product->isBooking() || $product->isPool();
if (!$isBookingOrPool) {
// For regular products, claim stock immediately to prevent overselling
$product->claimStock($quantity);
}
}
if ($product_or_price instanceof Product) {
$product_or_price->claimStock($quantity);
// Skip default price validation for pool products without direct prices
// (they inherit pricing from single items and are validated in validatePricing())
$isPoolWithInheritedPricing = $product_or_price->isPool() && !$product_or_price->prices()->exists();
$isPoolWithInheritedPricing = $product->isPool() && !$product->prices()->exists();
if (!$isPoolWithInheritedPricing) {
$default_prices = $product_or_price->defaultPrice()->count();
$default_prices = $product->defaultPrice()->count();
if ($default_prices === 0) {
throw new NotPurchasable("Product has no default price");
@ -103,7 +114,7 @@ trait HasCart
// Validate stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception("Insufficient stock available");
throw new NotEnoughStockException("Insufficient stock available");
}
$meta = (array) $cartItem->meta;

View File

@ -110,7 +110,7 @@ trait HasShoppingCapabilities
throw new \Exception("Booking products require 'from' and 'until' dates");
}
// Decrease stock (for bookings, pass the until date)
// Decrease stock (for bookings, pass the until date as expiry so stock returns after booking ends)
if (!$product->decreaseStock($quantity, $isBooking ? $until : null)) {
throw new \Exception("Unable to decrease stock");
}

View File

@ -8,6 +8,10 @@ use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
use Blax\Shop\Exceptions\InvalidPricingStrategyException;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotPoolProductException;
use Blax\Shop\Exceptions\PoolHasNoItemsException;
trait MayBePoolProduct
{
@ -126,13 +130,13 @@ trait MayBePoolProduct
?string $note = null
): array {
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
throw new NotPoolProductException();
}
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
throw new \Exception('Pool product has no single items to claim');
throw new PoolHasNoItemsException();
}
// Get pricing strategy
@ -159,7 +163,7 @@ trait MayBePoolProduct
}
if (count($availableItems) < $quantity) {
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested");
throw new NotEnoughStockException("Only " . count($availableItems) . " items available, but {$quantity} requested");
}
// Sort by pricing strategy
@ -191,7 +195,7 @@ trait MayBePoolProduct
public function releasePoolStock($reference): int
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
throw new NotPoolProductException();
}
$singleItems = $this->singleProducts;
@ -564,14 +568,14 @@ trait MayBePoolProduct
public function setPoolPricingStrategy(string|PricingStrategy $strategy): void
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
throw new NotPoolProductException();
}
// Handle both string and enum inputs
if (is_string($strategy)) {
$strategyEnum = PricingStrategy::tryFrom($strategy);
if (!$strategyEnum) {
throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}");
throw new InvalidPricingStrategyException($strategy);
}
$strategy = $strategyEnum;
}
@ -874,7 +878,7 @@ trait MayBePoolProduct
public function attachSingleItems(array|int|string $singleItemIds, array $attributes = []): void
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
throw new NotPoolProductException();
}
$ids = is_array($singleItemIds) ? $singleItemIds : [$singleItemIds];
@ -1028,7 +1032,7 @@ trait MayBePoolProduct
public function getPoolAvailabilityCalendar($startDate, $endDate, int $quantity = 1): array
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
throw new NotPoolProductException();
}
$start = $startDate instanceof \DateTimeInterface ? $startDate : \Carbon\Carbon::parse($startDate);
@ -1072,7 +1076,7 @@ trait MayBePoolProduct
public function getSingleItemsAvailability($from = null, $until = null): array
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
throw new NotPoolProductException();
}
$singleItems = $this->singleProducts;
@ -1131,7 +1135,7 @@ trait MayBePoolProduct
public function isPoolAvailable(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
throw new NotPoolProductException();
}
$maxQuantity = $this->getPoolMaxQuantity($from, $until);
@ -1157,7 +1161,7 @@ trait MayBePoolProduct
public function getPoolAvailablePeriods($startDate, $endDate, int $quantity = 1, int $minConsecutiveDays = 1): array
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
throw new NotPoolProductException();
}
$start = $startDate instanceof \DateTimeInterface ? $startDate : \Carbon\Carbon::parse($startDate);

View File

@ -29,14 +29,19 @@ class CartDateManagementTest extends TestCase
}
/** @test */
public function it_throws_exception_when_from_date_is_after_until_date()
public function it_stores_dates_as_provided_even_if_backwards()
{
$cart = Cart::factory()->create();
$from = Carbon::now()->addDays(3);
$until = Carbon::now()->addDays(1);
$this->expectException(InvalidDateRangeException::class);
// Dates are stored as provided (backwards)
$cart->setDates($from, $until, validateAvailability: false);
$cart->refresh();
// Database stores the dates as provided
$this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString());
$this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString());
}
/** @test */
@ -64,25 +69,37 @@ class CartDateManagementTest extends TestCase
}
/** @test */
public function it_throws_exception_when_setting_from_date_after_existing_until_date()
public function it_stores_from_date_even_if_after_existing_until_date()
{
$until = Carbon::now()->addDays(2);
$cart = Cart::factory()->create([
'until' => Carbon::now()->addDays(2),
'until' => $until,
]);
$this->expectException(InvalidDateRangeException::class);
$cart->setFromDate(Carbon::now()->addDays(3), validateAvailability: false);
$from = Carbon::now()->addDays(3);
$cart->setFromDate($from, validateAvailability: false);
$cart->refresh();
// Database stores the dates as provided (backwards order)
$this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString());
$this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString());
}
/** @test */
public function it_throws_exception_when_setting_until_date_before_existing_from_date()
public function it_stores_until_date_even_if_before_existing_from_date()
{
$from = Carbon::now()->addDays(3);
$cart = Cart::factory()->create([
'from' => Carbon::now()->addDays(3),
'from' => $from,
]);
$this->expectException(InvalidDateRangeException::class);
$cart->setUntilDate(Carbon::now()->addDays(2), validateAvailability: false);
$until = Carbon::now()->addDays(2);
$cart->setUntilDate($until, validateAvailability: false);
$cart->refresh();
// Database stores the dates as provided (backwards order)
$this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString());
$this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString());
}
/** @test */