diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index 983e8e9..d5954ae 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -260,6 +260,8 @@ return new class extends Migration $table->timestamp('last_activity_at')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamp('converted_at')->nullable(); + $table->timestamp('from_date')->nullable(); // Default start date for booking items + $table->timestamp('until_date')->nullable(); // Default end date for booking items $table->json('meta')->nullable(); $table->timestamps(); $table->softDeletes(); @@ -276,6 +278,7 @@ return new class extends Migration $table->uuid('cart_id'); $table->uuidMorphs('purchasable'); $table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete(); + $table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete(); $table->integer('quantity')->default(1); $table->decimal('price', 10, 2)->default(0); $table->decimal('regular_price', 10, 2)->nullable(); diff --git a/src/Exceptions/InvalidDateRangeException.php b/src/Exceptions/InvalidDateRangeException.php new file mode 100644 index 0000000..2bacc7f --- /dev/null +++ b/src/Exceptions/InvalidDateRangeException.php @@ -0,0 +1,16 @@ + 'datetime', 'last_activity_at' => 'datetime', 'meta' => 'object', + 'from_date' => 'datetime', + 'until_date' => 'datetime', + ]; + + protected $appends = [ + 'is_full_booking', + 'is_ready_to_checkout', ]; public function __construct(array $attributes = []) @@ -75,6 +86,61 @@ class Cart extends Model return $this->items->sum('quantity'); } + /** + * Check if all cart items are booking products + */ + public function getIsFullBookingAttribute(): bool + { + if ($this->items->isEmpty()) { + return false; + } + + return $this->items->every(fn($item) => $item->is_booking); + } + + /** + * Get count of booking items in the cart + */ + public function bookingItems(): int + { + return $this->items->filter(fn($item) => $item->is_booking)->count(); + } + + /** + * Get array of stripe_price_id from each cart item's price. + * Returns array with nulls for items without stripe_price_id. + * + * @return array + */ + public function stripePriceIds(): array + { + return $this->items->map(function ($item) { + if (!$item->price_id) { + return null; + } + + // Use the relationship method, not property access + $price = $item->price()->first(); + return $price ? $price->stripe_price_id : null; + })->toArray(); + } + + /** + * Check if cart is ready for checkout. + * + * Returns true if all cart items are ready for checkout. + * + * @return bool + */ + public function getIsReadyToCheckoutAttribute(): bool + { + if ($this->items->isEmpty()) { + return false; + } + + return $this->items->every(fn($item) => $item->is_ready_to_checkout); + } + /** * Get all cart items that require adjustments before checkout. * @@ -115,6 +181,155 @@ class Cart extends Model return $this->getItemsRequiringAdjustments()->isEmpty(); } + /** + * Set the default date range for the cart. + * Items without specific dates will use these as fallback. + * + * @param \DateTimeInterface $from Start date + * @param \DateTimeInterface $until End date + * @param bool $validateAvailability Whether to validate product availability for the timespan + * @return $this + * @throws InvalidDateRangeException + * @throws NotEnoughAvailableInTimespanException + */ + public function setDates(\DateTimeInterface $from, \DateTimeInterface $until, bool $validateAvailability = true): self + { + if ($from >= $until) { + throw new InvalidDateRangeException(); + } + + if ($validateAvailability) { + $this->validateDateAvailability($from, $until); + } + + $this->update([ + 'from_date' => $from, + 'until_date' => $until, + ]); + + return $this->fresh(); + } + + /** + * Set the 'from' date for the cart. + * + * @param \DateTimeInterface $from Start date + * @param bool $validateAvailability Whether to validate product availability for the timespan + * @return $this + * @throws InvalidDateRangeException + * @throws NotEnoughAvailableInTimespanException + */ + public function setFromDate(\DateTimeInterface $from, bool $validateAvailability = true): self + { + if ($this->until_date && $from >= $this->until_date) { + throw new InvalidDateRangeException(); + } + + if ($validateAvailability && $this->until_date) { + $this->validateDateAvailability($from, $this->until_date); + } + + $this->update(['from_date' => $from]); + + return $this->fresh(); + } + + /** + * Set the 'until' date for the cart. + * + * @param \DateTimeInterface $until End date + * @param bool $validateAvailability Whether to validate product availability for the timespan + * @return $this + * @throws InvalidDateRangeException + * @throws NotEnoughAvailableInTimespanException + */ + public function setUntilDate(\DateTimeInterface $until, bool $validateAvailability = true): self + { + if ($this->from_date && $this->from_date >= $until) { + throw new InvalidDateRangeException(); + } + + if ($validateAvailability && $this->from_date) { + $this->validateDateAvailability($this->from_date, $until); + } + + $this->update(['until_date' => $until]); + + return $this->fresh(); + } + + /** + * Apply cart dates to all items that don't have their own dates set. + * + * @param bool $validateAvailability Whether to validate product availability for the timespan + * @return $this + * @throws NotEnoughAvailableInTimespanException + */ + public function applyDatesToItems(bool $validateAvailability = true): self + { + if (!$this->from_date || !$this->until_date) { + return $this; + } + + foreach ($this->items as $item) { + // Only apply to items without dates that are booking products + if ($item->is_booking && (!$item->from || !$item->until)) { + if ($validateAvailability) { + $product = $item->purchasable; + if ($product && !$product->isAvailableForBooking($this->from_date, $this->until_date, $item->quantity)) { + throw new NotEnoughAvailableInTimespanException( + productName: $product->name ?? 'Product', + requested: $item->quantity, + available: 0, // Could calculate actual available amount + from: $this->from_date, + until: $this->until_date + ); + } + } + + $item->updateDates($this->from_date, $this->until_date); + } + } + + return $this->fresh(); + } + + /** + * Validate that all booking items in the cart are available for the given timespan. + * + * @param \DateTimeInterface $from Start date + * @param \DateTimeInterface $until End date + * @return void + * @throws NotEnoughAvailableInTimespanException + */ + protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until): void + { + foreach ($this->items as $item) { + if (!$item->is_booking) { + continue; + } + + $product = $item->purchasable; + if (!$product) { + continue; + } + + // Use item's specific dates if set, otherwise use the dates being validated + $checkFrom = $item->from ?? $from; + $checkUntil = $item->until ?? $until; + + if (!$product->isAvailableForBooking($checkFrom, $checkUntil, $item->quantity)) { + throw new NotEnoughAvailableInTimespanException( + productName: $product->name ?? 'Product', + requested: $item->quantity, + available: 0, // Could calculate actual available amount + from: $checkFrom, + until: $checkUntil + ); + } + } + } + public function getUnpaidAmount(): float { $paidAmount = $this->purchases() @@ -252,7 +467,7 @@ class Cart extends Model // Validate pricing before adding to cart $cartable->validatePricing(throwExceptions: true); - // Validate dates if both are provided (optional for cart, required at checkout) + // Validate dates if both are provided if ($from && $until) { // Validate from is before until if ($from >= $until) { @@ -269,7 +484,7 @@ class Cart extends Model // Check pool product availability if dates are provided if ($cartable->isPool()) { $maxQuantity = $cartable->getPoolMaxQuantity($from, $until); - // Only validate if pool has limited availability + // Only validate if pool has limited availability AND quantity exceeds it if ($maxQuantity !== PHP_INT_MAX && $quantity > $maxQuantity) { throw new \Blax\Shop\Exceptions\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}" @@ -417,10 +632,22 @@ class Cart extends Model return $existingItem->fresh(); } + // 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; + } elseif ($cartable instanceof \Blax\Shop\Models\ProductPrice) { + // If adding a ProductPrice directly, use its ID + $priceId = $cartable->id; + } + // Create new cart item $cartItem = $this->items()->create([ 'purchasable_id' => $cartable->getKey(), 'purchasable_type' => get_class($cartable), + 'price_id' => $priceId, 'quantity' => $quantity, 'price' => $pricePerUnit, // Price per unit for the period 'regular_price' => $regularPricePerUnit, @@ -639,30 +866,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 $item) { - $purchasable = $item->purchasable; - - // Get the price model - if ($purchasable instanceof Product) { - $price = $purchasable->defaultPrice()->first(); - $product = $purchasable; - } elseif ($purchasable instanceof \Blax\Shop\Models\ProductPrice) { - $price = $purchasable; - $product = $purchasable->purchasable; - } else { - throw new \Exception("Item has no valid price"); - } - - if (!$price) { - $name = $purchasable->name ?? 'Unknown item'; - throw new \Exception("Item '{$name}' has no default price"); - } - - // Sync product and price to Stripe - $stripePriceId = $syncService->syncPrice($price, $product); + foreach ($this->items as $index => $item) { + // Use the pre-fetched stripe price ID + $stripePriceId = $stripePriceIds[$index]; // Build line item with description including booking dates if applicable $lineItem = [ diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 03b2175..d54c03e 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Models; +use Blax\Shop\Exceptions\InvalidDateRangeException; use Blax\Workkit\Traits\HasMeta; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; @@ -15,6 +16,7 @@ class CartItem extends Model 'cart_id', 'purchasable_id', 'purchasable_type', + 'price_id', 'quantity', 'price', 'regular_price', @@ -37,6 +39,11 @@ class CartItem extends Model 'until' => 'datetime', ]; + protected $appends = [ + 'is_booking', + 'is_ready_to_checkout', + ]; + public function __construct(array $attributes = []) { parent::__construct($attributes); @@ -66,6 +73,11 @@ class CartItem extends Model return $this->belongsTo(config('shop.models.cart'), 'cart_id'); } + public function price(): BelongsTo + { + return $this->belongsTo(config('shop.models.product_price', ProductPrice::class), 'price_id'); + } + public function purchasable() { return $this->morphTo('purchasable'); @@ -104,6 +116,153 @@ class CartItem extends Model return $query->where('product_id', $productId); } + /** + * Check if this cart item is for a booking product + */ + public function getIsBookingAttribute(): bool + { + if (!$this->price_id) { + // Fallback: check purchasable directly if no price_id + if ($this->purchasable_type === config('shop.models.product', Product::class)) { + $product = $this->purchasable; + return $product && $product->isBooking(); + } + return false; + } + + // Use the relationship method, not property access + $price = $this->price()->first(); + if (!$price) { + return false; + } + + $product = $price->purchasable; + if (!$product || !($product instanceof Product)) { + return false; + } + + return $product->isBooking(); + } + + /** + * Check if this cart item is ready for checkout. + * Uses effective dates (item's own dates or cart's dates as fallback). + * + * Returns true if: + * - For booking products: has valid dates and stock is available + * - For pool products with booking items: has valid dates and stock is available + * - For other products: stock is available + * + * @return bool + */ + public function getIsReadyToCheckoutAttribute(): bool + { + // Only check if purchasable is a Product + if ($this->purchasable_type !== config('shop.models.product', Product::class)) { + return true; // Non-product items are always ready + } + + $product = $this->purchasable; + + if (!$product) { + return false; + } + + // Check if dates are required (for booking products or pools with booking items) + $requiresDates = $product->isBooking() || + ($product->isPool() && $product->hasBookingSingleItems()); + + if ($requiresDates) { + // Get effective dates (item-specific or cart fallback) + $effectiveFrom = $this->getEffectiveFromDate(); + $effectiveUntil = $this->getEffectiveUntilDate(); + + // Must have both dates (either from item or cart) + if (is_null($effectiveFrom) || is_null($effectiveUntil)) { + return false; + } + + // Dates must be valid (from < until) + if ($effectiveFrom >= $effectiveUntil) { + return false; + } + + // Check stock availability for the booking period + if ($product->isBooking()) { + if (!$product->isAvailableForBooking($effectiveFrom, $effectiveUntil, $this->quantity)) { + return false; + } + } + + // Check pool availability with dates + if ($product->isPool()) { + $available = $product->getPoolMaxQuantity($effectiveFrom, $effectiveUntil); + + // Get current quantity in cart for this product (excluding this item) + $cartQuantity = 0; + if ($this->cart) { + $cartQuantity = $this->cart->items() + ->where('purchasable_id', $product->getKey()) + ->where('purchasable_type', get_class($product)) + ->where('id', '!=', $this->id) + ->sum('quantity'); + } + + if ($available !== PHP_INT_MAX && ($cartQuantity + $this->quantity) > $available) { + return false; + } + } + } else { + // For non-booking products, just check stock availability + if ($product->isPool()) { + $available = $product->getPoolMaxQuantity(); + + // Get current quantity in cart for this product (excluding this item) + $cartQuantity = 0; + if ($this->cart) { + $cartQuantity = $this->cart->items() + ->where('purchasable_id', $product->getKey()) + ->where('purchasable_type', get_class($product)) + ->where('id', '!=', $this->id) + ->sum('quantity'); + } + + if ($available !== PHP_INT_MAX && ($cartQuantity + $this->quantity) > $available) { + return false; + } + } elseif ($product->manage_stock) { + // Check regular stock - sum all stocks for this product + $totalStock = $product->stocks()->sum('quantity'); + + // If no stock records exist and manage_stock is true, product is not ready + // (stock records must be created explicitly) + if ($totalStock === 0 && $product->stocks()->count() > 0) { + // Has stock records but quantity is 0 + return false; + } + + // If stock records exist, check cart quantity against stock + if ($product->stocks()->count() > 0) { + // Get current quantity in cart for this product (including ALL items of this product) + $cartQuantity = 0; + if ($this->cart) { + $cartQuantity = $this->cart->items() + ->where('purchasable_id', $product->getKey()) + ->where('purchasable_type', get_class($product)) + ->sum('quantity'); + } + + if ($cartQuantity > $totalStock) { + return false; + } + } + // If no stock records exist, assume product is available (legacy behavior) + } + } + + return true; + } + /** * Get required adjustments for this cart item before checkout. * @@ -169,18 +328,63 @@ class CartItem extends Model return $adjustments; } + /** + * Get the effective 'from' date for this cart item. + * Returns the item's specific date if set, otherwise falls back to the cart's from_date. + * + * @return \Carbon\Carbon|null + */ + public function getEffectiveFromDate(): ?\Carbon\Carbon + { + if ($this->from) { + return $this->from; + } + + return $this->cart?->from_date; + } + + /** + * Get the effective 'until' date for this cart item. + * Returns the item's specific date if set, otherwise falls back to the cart's until_date. + * + * @return \Carbon\Carbon|null + */ + public function getEffectiveUntilDate(): ?\Carbon\Carbon + { + if ($this->until) { + return $this->until; + } + + return $this->cart?->until_date; + } + + /** + * Check if this item has effective dates (either its own or from cart). + * + * @return bool + */ + public function hasEffectiveDates(): bool + { + return $this->getEffectiveFromDate() !== null && $this->getEffectiveUntilDate() !== null; + } + /** * Update the booking dates for this cart item. * Automatically recalculates price based on the new date range. * - * @param \DateTimeInterface $from Start date - * @param \DateTimeInterface $until End date + * NOTE: This method allows setting any dates, even if they're not available. + * Use the is_ready_to_checkout attribute to check if the dates are valid. + * + * @param \DateTimeInterface|null $from Start date + * @param \DateTimeInterface|null $until End date * @return $this * @throws \Exception If dates are invalid */ - public function updateDates(\DateTimeInterface $from, \DateTimeInterface $until): self - { - if ($from >= $until) { + public function updateDates( + \DateTimeInterface|null $from = null, + \DateTimeInterface|null $until = null + ): self { + if ($from >= $until && $until) { throw new \Exception("The 'from' date must be before the 'until' date."); } @@ -209,6 +413,7 @@ class CartItem extends Model 'subtotal' => $pricePerUnit * $this->quantity, ]); + // Note: is_ready_to_checkout will automatically reflect if these dates are available return $this->fresh(); } @@ -217,21 +422,26 @@ class CartItem extends Model * * @param \DateTimeInterface $from Start date * @return $this + * @throws InvalidDateRangeException */ public function setFromDate(\DateTimeInterface $from): self { if ($this->until && $from >= $this->until) { - throw new \Exception("The 'from' date must be before the 'until' date."); + throw new InvalidDateRangeException(); } + // Refresh to get current state before checking + $this->refresh(); + $this->update(['from' => $from]); + $this->refresh(); // If both dates are now set, recalculate pricing if ($this->until) { - return $this->updateDates($from, $this->until); + return $this->updateDates($this->from, $this->until); } - return $this->fresh(); + return $this; } /** @@ -239,20 +449,25 @@ class CartItem extends Model * * @param \DateTimeInterface $until End date * @return $this + * @throws InvalidDateRangeException */ public function setUntilDate(\DateTimeInterface $until): self { if ($this->from && $this->from >= $until) { - throw new \Exception("The 'until' date must be after the 'from' date."); + throw new InvalidDateRangeException(); } + // Refresh to get current state before checking + $this->refresh(); + $this->update(['until' => $until]); + $this->refresh(); // If both dates are now set, recalculate pricing if ($this->from) { - return $this->updateDates($this->from, $until); + return $this->updateDates($this->from, $this->until); } - return $this->fresh(); + return $this; } } diff --git a/tests/Feature/CartDateManagementTest.php b/tests/Feature/CartDateManagementTest.php new file mode 100644 index 0000000..cf55494 --- /dev/null +++ b/tests/Feature/CartDateManagementTest.php @@ -0,0 +1,434 @@ +create(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart->setDates($from, $until, validateAvailability: false); + + $cart->refresh(); + $this->assertEquals($from->toDateTimeString(), $cart->from_date->toDateTimeString()); + $this->assertEquals($until->toDateTimeString(), $cart->until_date->toDateTimeString()); + } + + /** @test */ + public function it_throws_exception_when_from_date_is_after_until_date() + { + $cart = Cart::factory()->create(); + $from = Carbon::now()->addDays(3); + $until = Carbon::now()->addDays(1); + + $this->expectException(InvalidDateRangeException::class); + $cart->setDates($from, $until, validateAvailability: false); + } + + /** @test */ + public function it_can_set_from_date_individually() + { + $cart = Cart::factory()->create(); + $from = Carbon::now()->addDays(1); + + $cart->setFromDate($from, validateAvailability: false); + + $cart->refresh(); + $this->assertEquals($from->toDateTimeString(), $cart->from_date->toDateTimeString()); + } + + /** @test */ + public function it_can_set_until_date_individually() + { + $cart = Cart::factory()->create(); + $until = Carbon::now()->addDays(3); + + $cart->setUntilDate($until, validateAvailability: false); + + $cart->refresh(); + $this->assertEquals($until->toDateTimeString(), $cart->until_date->toDateTimeString()); + } + + /** @test */ + public function it_throws_exception_when_setting_from_date_after_existing_until_date() + { + $cart = Cart::factory()->create([ + 'until_date' => Carbon::now()->addDays(2), + ]); + + $this->expectException(InvalidDateRangeException::class); + $cart->setFromDate(Carbon::now()->addDays(3), validateAvailability: false); + } + + /** @test */ + public function it_throws_exception_when_setting_until_date_before_existing_from_date() + { + $cart = Cart::factory()->create([ + 'from_date' => Carbon::now()->addDays(3), + ]); + + $this->expectException(InvalidDateRangeException::class); + $cart->setUntilDate(Carbon::now()->addDays(2), validateAvailability: false); + } + + /** @test */ + public function cart_item_uses_own_dates_when_set() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create([ + 'from_date' => Carbon::now()->addDays(1), + 'until_date' => Carbon::now()->addDays(3), + ]); + + $itemFromDate = Carbon::now()->addDays(5); + $itemUntilDate = Carbon::now()->addDays(7); + + $item = $cart->addToCart($product, 1); + $item->updateDates($itemFromDate, $itemUntilDate); + + $this->assertEquals($itemFromDate->toDateString(), $item->getEffectiveFromDate()->toDateString()); + $this->assertEquals($itemUntilDate->toDateString(), $item->getEffectiveUntilDate()->toDateString()); + } + + /** @test */ + public function cart_item_falls_back_to_cart_dates_when_no_own_dates() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cartFromDate = Carbon::now()->addDays(1); + $cartUntilDate = Carbon::now()->addDays(3); + + $cart = Cart::factory()->create([ + 'from_date' => $cartFromDate, + 'until_date' => $cartUntilDate, + ]); + + $item = $cart->addToCart($product, 1); + + $this->assertEquals($cartFromDate->toDateString(), $item->getEffectiveFromDate()->toDateString()); + $this->assertEquals($cartUntilDate->toDateString(), $item->getEffectiveUntilDate()->toDateString()); + } + + /** @test */ + public function cart_item_returns_null_when_no_dates_available() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create(); + $item = $cart->addToCart($product, 1); + + $this->assertNull($item->getEffectiveFromDate()); + $this->assertNull($item->getEffectiveUntilDate()); + $this->assertFalse($item->hasEffectiveDates()); + } + + /** @test */ + public function cart_item_has_effective_dates_returns_true_when_dates_are_set() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create([ + 'from_date' => Carbon::now()->addDays(1), + 'until_date' => Carbon::now()->addDays(3), + ]); + + $item = $cart->addToCart($product, 1); + + $this->assertTrue($item->hasEffectiveDates()); + } + + /** @test */ + public function apply_dates_to_items_sets_dates_on_items_without_dates() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create(); + $item = $cart->addToCart($product, 1); + + $this->assertNull($item->from); + $this->assertNull($item->until); + + $fromDate = Carbon::now()->addDays(1); + $untilDate = Carbon::now()->addDays(3); + + $cart->setDates($fromDate, $untilDate, validateAvailability: false); + $cart->applyDatesToItems(validateAvailability: false); + + $item->refresh(); + $this->assertNotNull($item->from); + $this->assertNotNull($item->until); + $this->assertEquals($fromDate->toDateString(), $item->from->toDateString()); + $this->assertEquals($untilDate->toDateString(), $item->until->toDateString()); + } + + /** @test */ + public function apply_dates_to_items_does_not_override_existing_item_dates() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create(); + $item = $cart->addToCart($product, 1); + + $itemFromDate = Carbon::now()->addDays(5); + $itemUntilDate = Carbon::now()->addDays(7); + $item->updateDates($itemFromDate, $itemUntilDate); + + $cartFromDate = Carbon::now()->addDays(1); + $cartUntilDate = Carbon::now()->addDays(3); + + $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false); + $cart->applyDatesToItems(validateAvailability: false); + + $item->refresh(); + // Item dates should remain unchanged + $this->assertEquals($itemFromDate->toDateString(), $item->from->toDateString()); + $this->assertEquals($itemUntilDate->toDateString(), $item->until->toDateString()); + } + + /** @test */ + public function is_ready_to_checkout_uses_cart_fallback_dates() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create([ + 'from_date' => Carbon::now()->addDays(1), + 'until_date' => Carbon::now()->addDays(3), + ]); + + $item = $cart->addToCart($product, 1); + + // Item should be ready because it uses cart dates + $this->assertTrue($item->is_ready_to_checkout); + } + + /** @test */ + public function cart_item_set_from_date_throws_invalid_date_range_exception() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create(); + $item = $cart->addToCart($product, 1); + + $item->setUntilDate(Carbon::now()->addDays(2)); + + $this->expectException(InvalidDateRangeException::class); + $item->setFromDate(Carbon::now()->addDays(3)); + } + + /** @test */ + public function cart_item_set_until_date_throws_invalid_date_range_exception() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create(); + $item = $cart->addToCart($product, 1); + + $item->setFromDate(Carbon::now()->addDays(3)); + + $this->expectException(InvalidDateRangeException::class); + $item->setUntilDate(Carbon::now()->addDays(2)); + } + + /** @test */ + public function validate_date_availability_throws_exception_when_product_not_available() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'stock_quantity' => 1, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create(); + $item = $cart->addToCart($product, 1); + + // Set item dates that consume the stock + $item->updateDates(Carbon::now()->addDays(1), Carbon::now()->addDays(3)); + + // Try to set cart dates that overlap - should throw exception + $this->expectException(NotEnoughAvailableInTimespanException::class); + $cart->setDates(Carbon::now()->addDays(2), Carbon::now()->addDays(4), validateAvailability: true); + } + + /** @test */ + public function apply_dates_to_items_throws_exception_when_product_not_available() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'stock_quantity' => 1, + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create([ + 'from_date' => Carbon::now()->addDays(1), + 'until_date' => Carbon::now()->addDays(3), + ]); + + // Add item that would exceed available stock + $item = $cart->addToCart($product, 2); + + // Should throw exception because only 1 available but requesting 2 + $this->expectException(NotEnoughAvailableInTimespanException::class); + $cart->applyDatesToItems(validateAvailability: true); + } + + /** @test */ + public function can_skip_validation_when_setting_dates() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'stock_quantity' => 0, // No stock available + ]); + + $price = ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'is_default' => true, + + ]); + + $cart = Cart::factory()->create(); + $item = $cart->addToCart($product, 1); + + // Should not throw exception when validation is disabled + $cart->setDates( + Carbon::now()->addDays(1), + Carbon::now()->addDays(3), + validateAvailability: false + ); + + $this->assertNotNull($cart->from_date); + $this->assertNotNull($cart->until_date); + } +} diff --git a/tests/Feature/CartItemAttributesTest.php b/tests/Feature/CartItemAttributesTest.php new file mode 100644 index 0000000..1242517 --- /dev/null +++ b/tests/Feature/CartItemAttributesTest.php @@ -0,0 +1,486 @@ +withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($bookingProduct, quantity: 1); + + $this->assertTrue($cartItem->is_booking); + } + + /** @test */ + public function cart_item_has_is_booking_false_for_regular_products() + { + $regularProduct = Product::factory() + ->withPrices(unit_amount: 50.00) + ->create(['type' => ProductType::SIMPLE]); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($regularProduct, quantity: 1); + + $this->assertFalse($cartItem->is_booking); + } + + /** @test */ + public function cart_item_is_booking_works_via_price_id() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($bookingProduct, quantity: 1); + + // Verify price_id was set + $this->assertNotNull($cartItem->price_id); + + // Reload and check is_booking still works + $reloadedItem = CartItem::find($cartItem->id); + $this->assertTrue($reloadedItem->is_booking); + } + + /** @test */ + public function cart_is_full_booking_is_true_when_all_items_are_bookings() + { + $booking1 = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::BOOKING]); + + $booking2 = Product::factory() + ->withPrices(unit_amount: 150.00) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $cart->addToCart($booking1, quantity: 1); + $cart->addToCart($booking2, quantity: 1); + + $this->assertTrue($cart->is_full_booking); + } + + /** @test */ + public function cart_is_full_booking_is_false_when_mixed_products() + { + $booking = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::BOOKING]); + + $regular = Product::factory() + ->withPrices(unit_amount: 50.00) + ->create(['type' => ProductType::SIMPLE]); + + $cart = Cart::create(); + $cart->addToCart($booking, quantity: 1); + $cart->addToCart($regular, quantity: 1); + + $this->assertFalse($cart->is_full_booking); + } + + /** @test */ + public function cart_is_full_booking_is_false_when_empty() + { + $cart = Cart::create(); + + $this->assertFalse($cart->is_full_booking); + } + + /** @test */ + public function cart_booking_items_returns_correct_count() + { + $booking1 = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::BOOKING]); + + $booking2 = Product::factory() + ->withPrices(unit_amount: 150.00) + ->create(['type' => ProductType::BOOKING]); + + $regular = Product::factory() + ->withPrices(unit_amount: 50.00) + ->create(['type' => ProductType::SIMPLE]); + + $cart = Cart::create(); + $cart->addToCart($booking1, quantity: 1); + $cart->addToCart($booking2, quantity: 1); + $cart->addToCart($regular, quantity: 1); + + $this->assertEquals(2, $cart->bookingItems()); + } + + /** @test */ + public function cart_booking_items_returns_zero_when_no_bookings() + { + $regular = Product::factory() + ->withPrices(unit_amount: 50.00) + ->create(['type' => ProductType::SIMPLE]); + + $cart = Cart::create(); + $cart->addToCart($regular, quantity: 1); + + $this->assertEquals(0, $cart->bookingItems()); + } + + /** @test */ + public function price_id_is_automatically_assigned_when_adding_product_to_cart() + { + $product = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($product, quantity: 1); + + $this->assertNotNull($cartItem->price_id); + + // Access the relationship using the method, not property + $this->assertInstanceOf(ProductPrice::class, $cartItem->price()->first()); + } + + /** @test */ + public function price_id_is_assigned_when_adding_product_price_to_cart() + { + $product = Product::factory()->create(); + $price = ProductPrice::factory()->create([ + 'purchasable_type' => get_class($product), + 'purchasable_id' => $product->id, + 'unit_amount' => 100.00, + 'is_default' => true, + ]); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($price, quantity: 1); + + $this->assertEquals($price->id, $cartItem->price_id); + + // Access the relationship using the method, not property + $this->assertInstanceOf(ProductPrice::class, $cartItem->price()->first()); + } + + /** @test */ + public function cart_stripe_price_ids_returns_array_of_stripe_price_ids() + { + $product1 = Product::factory()->create(); + $price1 = ProductPrice::factory()->create([ + 'purchasable_type' => get_class($product1), + 'purchasable_id' => $product1->id, + 'stripe_price_id' => 'price_123', + 'unit_amount' => 100.00, + 'is_default' => true, + ]); + + $product2 = Product::factory()->create(); + $price2 = ProductPrice::factory()->create([ + 'purchasable_type' => get_class($product2), + 'purchasable_id' => $product2->id, + 'stripe_price_id' => 'price_456', + 'unit_amount' => 200.00, + 'is_default' => true, + ]); + + $cart = Cart::create(); + $cart->addToCart($product1, quantity: 1); + $cart->addToCart($product2, quantity: 1); + + $stripePriceIds = $cart->stripePriceIds(); + + $this->assertCount(2, $stripePriceIds); + $this->assertContains('price_123', $stripePriceIds); + $this->assertContains('price_456', $stripePriceIds); + } + + /** @test */ + public function cart_stripe_price_ids_returns_nulls_for_items_without_stripe_price_id() + { + $product1 = Product::factory()->create(); + $price1 = ProductPrice::factory()->create([ + 'purchasable_type' => get_class($product1), + 'purchasable_id' => $product1->id, + 'stripe_price_id' => 'price_123', + 'unit_amount' => 100.00, + 'is_default' => true, + ]); + + $product2 = Product::factory()->create(); + $price2 = ProductPrice::factory()->create([ + 'purchasable_type' => get_class($product2), + 'purchasable_id' => $product2->id, + 'stripe_price_id' => null, + 'unit_amount' => 200.00, + 'is_default' => true, + ]); + + $cart = Cart::create(); + $cart->addToCart($product1, quantity: 1); + $cart->addToCart($product2, quantity: 1); + + $stripePriceIds = $cart->stripePriceIds(); + + $this->assertCount(2, $stripePriceIds); + $this->assertEquals('price_123', $stripePriceIds[0]); + $this->assertNull($stripePriceIds[1]); + } + + /** @test */ + public function cart_item_is_ready_to_checkout_is_true_for_regular_products() + { + $product = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::SIMPLE]); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($product, quantity: 1); + + $this->assertTrue($cartItem->is_ready_to_checkout); + } + + /** @test */ + public function cart_item_is_ready_to_checkout_is_false_for_booking_without_dates() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($bookingProduct, quantity: 1); + + $this->assertFalse($cartItem->is_ready_to_checkout); + } + + /** @test */ + public function cart_item_is_ready_to_checkout_is_true_for_booking_with_valid_dates() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->withStocks(quantity: 10) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cartItem = $cart->addToCart($bookingProduct, quantity: 1, from: $from, until: $until); + + $this->assertTrue($cartItem->is_ready_to_checkout); + } + + /** @test */ + public function cart_item_is_ready_to_checkout_is_false_for_booking_with_invalid_date_range() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->withStocks(quantity: 10) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($bookingProduct, quantity: 1); + + // Manually set invalid dates (from >= until) + $cartItem->update([ + 'from' => Carbon::now()->addDays(3), + 'until' => Carbon::now()->addDays(1), // until before from + ]); + + $this->assertFalse($cartItem->fresh()->is_ready_to_checkout); + } + + /** @test */ + public function cart_is_ready_to_checkout_is_true_when_all_items_are_ready() + { + $product1 = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::SIMPLE]); + + $product2 = Product::factory() + ->withPrices(unit_amount: 150.00) + ->create(['type' => ProductType::SIMPLE]); + + $cart = Cart::create(); + $cart->addToCart($product1, quantity: 1); + $cart->addToCart($product2, quantity: 1); + + $this->assertTrue($cart->is_ready_to_checkout); + } + + /** @test */ + public function cart_is_ready_to_checkout_is_false_when_at_least_one_item_not_ready() + { + $regularProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->create(['type' => ProductType::SIMPLE]); + + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 150.00) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $cart->addToCart($regularProduct, quantity: 1); + $cart->addToCart($bookingProduct, quantity: 1); // No dates + + $this->assertFalse($cart->is_ready_to_checkout); + } + + /** @test */ + public function cart_allows_adding_items_without_dates_that_require_them() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->withStocks(quantity: 10) // Has stock + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + + // Add without dates - should be allowed + $cartItem = $cart->addToCart($bookingProduct, quantity: 1); + + $this->assertInstanceOf(CartItem::class, $cartItem); + + // But is_ready_to_checkout should be false (missing dates) + $this->assertFalse($cartItem->is_ready_to_checkout); + } + + /** @test */ + public function update_dates_allows_setting_any_dates() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->withStocks(quantity: 10) // Has stock + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $cartItem = $cart->addToCart($bookingProduct, quantity: 1); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Can set dates anytime + $cartItem->updateDates($from, $until); + + $this->assertNotNull($cartItem->from); + $this->assertNotNull($cartItem->until); + + // Should be ready to checkout now (has dates and stock) + $this->assertTrue($cartItem->fresh()->is_ready_to_checkout); + } + + /** @test */ + public function cart_calculates_correctly_when_dates_are_adjusted() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->withStocks(quantity: 10) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); // 2 days + + $cartItem = $cart->addToCart($bookingProduct, quantity: 1, from: $from, until: $until); + + // Initial price for 2 days + $this->assertEquals(200.00, $cartItem->price); + $this->assertEquals(200.00, $cartItem->subtotal); + + // Adjust dates to 5 days + $newUntil = Carbon::now()->addDays(6); + $cartItem->updateDates($from, $newUntil); + + // Price should be recalculated for 5 days + $this->assertEquals(500.00, $cartItem->fresh()->price); + $this->assertEquals(500.00, $cartItem->fresh()->subtotal); + } + + /** @test */ + public function set_from_date_recalculates_pricing_when_both_dates_set() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->withStocks(quantity: 10) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(4); // 3 days + + $cartItem = $cart->addToCart($bookingProduct, quantity: 1, from: $from, until: $until); + + // Initial price for 3 days + $this->assertEquals(300.00, $cartItem->price); + + // Adjust from date to make it span more days (move 1 day earlier) + $newFrom = $from->copy()->subDays(1); + $cartItem->setFromDate($newFrom); + + // Price should be recalculated for 4 days + $this->assertEquals(400.00, $cartItem->fresh()->price); + } + + /** @test */ + public function set_until_date_recalculates_pricing_when_both_dates_set() + { + $bookingProduct = Product::factory() + ->withPrices(unit_amount: 100.00) + ->withStocks(quantity: 10) + ->create(['type' => ProductType::BOOKING]); + + $cart = Cart::create(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); // 2 days + + $cartItem = $cart->addToCart($bookingProduct, quantity: 1, from: $from, until: $until); + + // Initial price for 2 days + $this->assertEquals(200.00, $cartItem->price); + + // Adjust until date to make it 4 days + $newUntil = Carbon::now()->addDays(5); + $cartItem->setUntilDate($newUntil); + + // Price should be recalculated for 4 days + $this->assertEquals(400.00, $cartItem->fresh()->price); + } + + /** @test */ + public function is_ready_to_checkout_checks_stock_for_regular_products_with_stock_management() + { + $product = Product::factory() + ->withPrices(unit_amount: 100.00) + ->withStocks(quantity: 5) + ->create([ + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + + $cart = Cart::create(); + + // Add 3 items - should be ready + $cartItem1 = $cart->addToCart($product, quantity: 3); + $this->assertTrue($cartItem1->is_ready_to_checkout); + + // Add 5 more items - now exceeds stock + $cartItem2 = $cart->addToCart($product, quantity: 5); + + // Both items should now show as not ready (total exceeds stock) + $this->assertFalse($cartItem1->fresh()->is_ready_to_checkout); + $this->assertFalse($cartItem2->fresh()->is_ready_to_checkout); + } +}