From c5004158eb675877fb418ad434828c0fa5b20fa4 Mon Sep 17 00:00:00 2001 From: a6a2f5842 Date: Wed, 3 Dec 2025 13:59:01 +0100 Subject: [PATCH] A types, enums & statuses, U product models, I shopping trait, A booking support, R tests --- .../create_blax_shop_tables.php.stub | 4 +- .../Commands/ShopAddExampleProducts.php | 20 +- src/Enums/BillingScheme.php | 17 + src/Enums/CartStatus.php | 21 + src/Enums/PriceType.php | 17 + src/Enums/ProductAttributeType.php | 21 + src/Enums/ProductStatus.php | 19 + src/Enums/ProductType.php | 25 ++ src/Enums/PurchaseStatus.php | 23 ++ src/Enums/RecurringInterval.php | 23 ++ src/Enums/StockStatus.php | 21 + src/Enums/StockType.php | 27 ++ src/Models/Cart.php | 25 +- src/Models/Product.php | 61 ++- src/Models/ProductPrice.php | 6 + src/Models/ProductPurchase.php | 50 ++- src/Models/ProductStock.php | 30 +- src/Traits/HasShoppingCapabilities.php | 30 +- src/Traits/HasStocks.php | 10 +- tests/Feature/BookingFeatureTest.php | 377 ++++++++++++++++++ tests/Feature/HasShoppingCapabilitiesTest.php | 3 +- tests/Feature/ProductManagementTest.php | 12 +- tests/Feature/ProductPriceTest.php | 13 +- tests/Feature/ProductPurchaseTest.php | 11 +- tests/Feature/ProductScopeTest.php | 11 +- tests/Feature/ProductStockTest.php | 3 +- tests/Feature/PurchaseFlowTest.php | 7 +- tests/Unit/CartTest.php | 15 +- 28 files changed, 828 insertions(+), 74 deletions(-) create mode 100644 src/Enums/BillingScheme.php create mode 100644 src/Enums/CartStatus.php create mode 100644 src/Enums/PriceType.php create mode 100644 src/Enums/ProductAttributeType.php create mode 100644 src/Enums/ProductStatus.php create mode 100644 src/Enums/ProductType.php create mode 100644 src/Enums/PurchaseStatus.php create mode 100644 src/Enums/RecurringInterval.php create mode 100644 src/Enums/StockStatus.php create mode 100644 src/Enums/StockType.php create mode 100644 tests/Feature/BookingFeatureTest.php diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index 4778d4e..3ea6110 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -20,7 +20,7 @@ return new class extends Migration $table->string('sku')->nullable()->unique(); $table->text('short_description')->nullable(); $table->longText('description')->nullable(); - $table->string('type')->default('simple'); // simple, variable, grouped, external + $table->string('type')->default('simple'); // simple, variable, grouped, external, booking $table->string('stripe_product_id')->nullable(); $table->timestamp('sale_start')->nullable(); $table->timestamp('sale_end')->nullable(); @@ -240,6 +240,8 @@ return new class extends Migration $table->decimal('amount', 10, 8)->nullable(); $table->decimal('amount_paid', 10, 8)->default(0); $table->string('charge_id')->nullable(); + $table->timestamp('from')->nullable(); + $table->timestamp('until')->nullable(); $table->json('meta')->nullable(); $table->timestamps(); }); diff --git a/src/Console/Commands/ShopAddExampleProducts.php b/src/Console/Commands/ShopAddExampleProducts.php index 5b7c490..49b6693 100644 --- a/src/Console/Commands/ShopAddExampleProducts.php +++ b/src/Console/Commands/ShopAddExampleProducts.php @@ -2,6 +2,8 @@ namespace Blax\Shop\Console\Commands; +use Blax\Shop\Enums\ProductStatus; +use Blax\Shop\Enums\ProductType; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductAction; use Blax\Shop\Models\ProductAttribute; @@ -131,20 +133,20 @@ class ShopAddExampleProducts extends Command 'slug' => $slug, 'name' => $productName, 'sku' => 'EX-' . strtoupper($this->faker->bothify('??-####')), - 'type' => $type, - 'status' => $this->faker->randomElement(['published', 'published', 'published', 'draft']), + 'type' => ProductType::from($type), + 'status' => $this->faker->randomElement([ProductStatus::PUBLISHED, ProductStatus::PUBLISHED, ProductStatus::PUBLISHED, ProductStatus::DRAFT]), 'is_visible' => true, 'featured' => $this->faker->boolean(20), 'sale_start' => $saleStart, 'sale_end' => $saleEnd, - 'manage_stock' => $type !== 'external', - 'low_stock_threshold' => $type !== 'external' ? 5 : null, + 'manage_stock' => $type !== ProductType::EXTERNAL->value, + 'low_stock_threshold' => $type !== ProductType::EXTERNAL->value ? 5 : null, 'weight' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 0.1, 50), 'length' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), 'width' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), 'height' => $type === 'virtual' ? null : $this->faker->randomFloat(2, 5, 100), - 'virtual' => $type === 'variable' ? $this->faker->boolean(20) : false, - 'downloadable' => $type === 'simple' ? $this->faker->boolean(15) : false, + 'virtual' => $type === ProductType::VARIABLE->value ? $this->faker->boolean(20) : false, + 'downloadable' => $type === ProductType::SIMPLE->value ? $this->faker->boolean(15) : false, 'published_at' => now(), 'sort_order' => $this->faker->numberBetween(0, 100), 'tax_class' => $this->faker->randomElement(['standard', 'reduced', 'zero']), @@ -184,7 +186,7 @@ class ShopAddExampleProducts extends Command $this->addAttributes($product, $type); // Add additional prices (multi-currency or subscription) - if ($type === 'simple' || $type === 'variable') { + if ($type === ProductType::SIMPLE->value || $type === ProductType::VARIABLE->value) { $this->addAdditionalPrices($product, $baseUnitAmount); } @@ -192,12 +194,12 @@ class ShopAddExampleProducts extends Command $this->addExampleActions($product); // For variable products, add variations - if ($type === 'variable') { + if ($type === ProductType::VARIABLE->value) { $this->addVariations($product, $baseUnitAmount); } // For grouped products, add child products - if ($type === 'grouped') { + if ($type === ProductType::GROUPED->value) { $this->addGroupedProducts($product); } diff --git a/src/Enums/BillingScheme.php b/src/Enums/BillingScheme.php new file mode 100644 index 0000000..2e38006 --- /dev/null +++ b/src/Enums/BillingScheme.php @@ -0,0 +1,17 @@ + 'Per Unit', + self::TIERED => 'Tiered', + }; + } +} diff --git a/src/Enums/CartStatus.php b/src/Enums/CartStatus.php new file mode 100644 index 0000000..4fc14fe --- /dev/null +++ b/src/Enums/CartStatus.php @@ -0,0 +1,21 @@ + 'Active', + self::ABANDONED => 'Abandoned', + self::CONVERTED => 'Converted', + self::EXPIRED => 'Expired', + }; + } +} diff --git a/src/Enums/PriceType.php b/src/Enums/PriceType.php new file mode 100644 index 0000000..81fa53b --- /dev/null +++ b/src/Enums/PriceType.php @@ -0,0 +1,17 @@ + 'One Time', + self::RECURRING => 'Recurring', + }; + } +} diff --git a/src/Enums/ProductAttributeType.php b/src/Enums/ProductAttributeType.php new file mode 100644 index 0000000..c37ea04 --- /dev/null +++ b/src/Enums/ProductAttributeType.php @@ -0,0 +1,21 @@ + 'Text', + self::SELECT => 'Select', + self::COLOR => 'Color', + self::IMAGE => 'Image', + }; + } +} diff --git a/src/Enums/ProductStatus.php b/src/Enums/ProductStatus.php new file mode 100644 index 0000000..6ec5310 --- /dev/null +++ b/src/Enums/ProductStatus.php @@ -0,0 +1,19 @@ + 'Draft', + self::PUBLISHED => 'Published', + self::ARCHIVED => 'Archived', + }; + } +} diff --git a/src/Enums/ProductType.php b/src/Enums/ProductType.php new file mode 100644 index 0000000..db0767e --- /dev/null +++ b/src/Enums/ProductType.php @@ -0,0 +1,25 @@ + 'Simple', + self::VARIABLE => 'Variable', + self::GROUPED => 'Grouped', + self::EXTERNAL => 'External', + self::BOOKING => 'Booking', + self::VARIATION => 'Variation', + }; + } +} diff --git a/src/Enums/PurchaseStatus.php b/src/Enums/PurchaseStatus.php new file mode 100644 index 0000000..4a571a3 --- /dev/null +++ b/src/Enums/PurchaseStatus.php @@ -0,0 +1,23 @@ + 'Pending', + self::UNPAID => 'Unpaid', + self::COMPLETED => 'Completed', + self::REFUNDED => 'Refunded', + self::CART => 'Cart', + }; + } +} diff --git a/src/Enums/RecurringInterval.php b/src/Enums/RecurringInterval.php new file mode 100644 index 0000000..0c79a44 --- /dev/null +++ b/src/Enums/RecurringInterval.php @@ -0,0 +1,23 @@ + 'Day', + self::WEEK => 'Week', + self::MONTH => 'Month', + self::QUARTER => 'Quarter', + self::YEAR => 'Year', + }; + } +} diff --git a/src/Enums/StockStatus.php b/src/Enums/StockStatus.php new file mode 100644 index 0000000..9ae6d86 --- /dev/null +++ b/src/Enums/StockStatus.php @@ -0,0 +1,21 @@ + 'Pending', + self::COMPLETED => 'Completed', + self::CANCELLED => 'Cancelled', + self::EXPIRED => 'Expired', + }; + } +} diff --git a/src/Enums/StockType.php b/src/Enums/StockType.php new file mode 100644 index 0000000..66fd166 --- /dev/null +++ b/src/Enums/StockType.php @@ -0,0 +1,27 @@ + 'Reservation', + self::ADJUSTMENT => 'Adjustment', + self::SALE => 'Sale', + self::RETURN => 'Return', + self::INCREASE => 'Increase', + self::DECREASE => 'Decrease', + self::RELEASE => 'Release', + }; + } +} diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 9ccb1d8..cd41388 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -3,6 +3,8 @@ namespace Blax\Shop\Models; use Blax\Shop\Contracts\Cartable; +use Blax\Shop\Enums\CartStatus; +use Blax\Shop\Enums\ProductType; use Blax\Workkit\Traits\HasExpiration; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -27,6 +29,7 @@ class Cart extends Model ]; protected $casts = [ + 'status' => CartStatus::class, 'expires_at' => 'datetime', 'converted_at' => 'datetime', 'last_activity_at' => 'datetime', @@ -174,10 +177,30 @@ class Cart extends Model foreach ($items as $item) { $product = $item->purchasable; $quantity = $item->quantity; + + // Extract booking dates from parameters if this is a booking product + $from = null; + $until = null; + if ($product->type === ProductType::BOOKING && $item->parameters) { + $params = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; + $from = $params['from'] ?? null; + $until = $params['until'] ?? null; + + // Convert to Carbon instances if they're strings + if ($from && is_string($from)) { + $from = \Carbon\Carbon::parse($from); + } + if ($until && is_string($until)) { + $until = \Carbon\Carbon::parse($until); + } + } $purchase = $this->customer->purchase( $product->prices()->first(), - $quantity + $quantity, + null, + $from, + $until ); $purchase->update([ diff --git a/src/Models/Product.php b/src/Models/Product.php index ef56ac7..1fc824e 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -7,6 +7,10 @@ use Blax\Workkit\Traits\HasMetaTranslation; use Blax\Shop\Events\ProductCreated; use Blax\Shop\Events\ProductUpdated; use Blax\Shop\Contracts\Purchasable; +use Blax\Shop\Enums\ProductStatus; +use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\StockStatus; +use Blax\Shop\Enums\StockType; use Blax\Shop\Traits\HasPrices; use Blax\Shop\Traits\HasStocks; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -53,6 +57,8 @@ class Product extends Model implements Purchasable, Cartable 'manage_stock' => 'boolean', 'virtual' => 'boolean', 'downloadable' => 'boolean', + 'type' => ProductType::class, + 'status' => ProductStatus::class, 'meta' => 'object', 'sale_start' => 'datetime', 'sale_end' => 'datetime', @@ -163,7 +169,7 @@ class Product extends Model implements Purchasable, Cartable public function scopePublished($query) { - return $query->where('status', 'published'); + return $query->where('status', ProductStatus::PUBLISHED->value); } public function scopeFeatured($query) @@ -228,7 +234,7 @@ class Product extends Model implements Purchasable, Cartable public function scopeVisible($query) { return $query->where('is_visible', true) - ->where('status', 'published') + ->where('status', ProductStatus::PUBLISHED->value) ->where(function ($q) { $q->whereNull('published_at') ->orWhere('published_at', '<=', now()); @@ -253,7 +259,7 @@ class Product extends Model implements Purchasable, Cartable public function isVisible(): bool { - if (!$this->is_visible || $this->status !== 'published') { + if (!$this->is_visible || $this->status !== ProductStatus::PUBLISHED) { return false; } @@ -331,4 +337,53 @@ class Product extends Model implements Purchasable, Cartable return parent::newInstance($attributes, $exists); } + + /** + * Check if this is a booking product + */ + public function isBooking(): bool + { + return $this->type === ProductType::BOOKING; + } + + /** + * Check stock availability for a booking period + */ + public function isAvailableForBooking(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool + { + if (!$this->manage_stock) { + return true; + } + + // Get stock reservations that overlap with the requested period + $overlappingReservations = $this->stocks() + ->where('type', StockType::RESERVATION->value) + ->where('status', StockStatus::PENDING->value) + ->where(function ($query) use ($from, $until) { + $query->where(function ($q) use ($from, $until) { + // Reservation starts during the requested period + $q->whereBetween('created_at', [$from, $until]); + })->orWhere(function ($q) use ($from, $until) { + // Reservation ends during the requested period + $q->whereBetween('expires_at', [$from, $until]); + })->orWhere(function ($q) use ($from, $until) { + // Reservation encompasses the entire requested period + $q->where('created_at', '<=', $from) + ->where('expires_at', '>=', $until); + }); + }) + ->sum('quantity'); + + $availableStock = $this->getAvailableStock() - abs($overlappingReservations); + + return $availableStock >= $quantity; + } + + /** + * Scope for booking products + */ + public function scopeBookings($query) + { + return $query->where('type', ProductType::BOOKING->value); + } } diff --git a/src/Models/ProductPrice.php b/src/Models/ProductPrice.php index a895797..a6314ce 100644 --- a/src/Models/ProductPrice.php +++ b/src/Models/ProductPrice.php @@ -4,6 +4,9 @@ namespace Blax\Shop\Models; use Blax\Shop\Contracts\Cartable; use Blax\Shop\Contracts\Purchasable; +use Blax\Shop\Enums\BillingScheme; +use Blax\Shop\Enums\PriceType; +use Blax\Shop\Enums\RecurringInterval; use Blax\Workkit\Traits\HasMetaTranslation; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -34,6 +37,9 @@ class ProductPrice extends Model implements Cartable protected $casts = [ 'is_default' => 'boolean', 'active' => 'boolean', + 'type' => PriceType::class, + 'billing_scheme' => BillingScheme::class, + 'interval' => RecurringInterval::class, 'meta' => 'object', 'unit_amount' => 'float', 'sale_unit_amount' => 'float', diff --git a/src/Models/ProductPurchase.php b/src/Models/ProductPurchase.php index 10d1cef..d408a43 100644 --- a/src/Models/ProductPurchase.php +++ b/src/Models/ProductPurchase.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Models; +use Blax\Shop\Enums\PurchaseStatus; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; @@ -21,13 +22,18 @@ class ProductPurchase extends Model 'amount', 'amount_paid', 'charge_id', + 'from', + 'until', 'meta', ]; protected $casts = [ + 'status' => PurchaseStatus::class, 'quantity' => 'integer', 'amount' => 'integer', 'amount_paid' => 'integer', + 'from' => 'datetime', + 'until' => 'datetime', 'meta' => 'object', ]; @@ -67,12 +73,12 @@ class ProductPurchase extends Model public static function scopeInCart($query) { - return $query->where('status', 'cart'); + return $query->where('status', PurchaseStatus::CART->value); } public static function scopeCompleted($query) { - return $query->where('status', 'completed'); + return $query->where('status', PurchaseStatus::COMPLETED->value); } protected static function booted() @@ -86,7 +92,7 @@ class ProductPurchase extends Model ? $productPurchase->purchasable?->product : $product; - if ($productPurchase->status === 'completed' && $product) { + if ($productPurchase->status === PurchaseStatus::COMPLETED && $product) { $product->callActions('purchased', $productPurchase); } }); @@ -102,7 +108,7 @@ class ProductPurchase extends Model : $product; - if ($productPurchase->status === 'completed' && $product) { + if ($productPurchase->status === PurchaseStatus::COMPLETED && $product) { $product->callActions('purchased', $productPurchase); } }); @@ -119,4 +125,40 @@ class ProductPurchase extends Model 'id' // Local key on ProductAction table... ); } + + /** + * Check if this is a booking purchase + */ + public function isBooking(): bool + { + return !is_null($this->from) && !is_null($this->until); + } + + /** + * Check if the booking has ended + */ + public function isBookingEnded(): bool + { + if (!$this->isBooking()) { + return false; + } + + return now()->isAfter($this->until); + } + + /** + * Scope for booking purchases + */ + public function scopeBookings($query) + { + return $query->whereNotNull('from')->whereNotNull('until'); + } + + /** + * Scope for ended bookings + */ + public function scopeEndedBookings($query) + { + return $query->bookings()->where('until', '<', now()); + } } diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php index a14a7e0..38c7567 100644 --- a/src/Models/ProductStock.php +++ b/src/Models/ProductStock.php @@ -2,6 +2,8 @@ namespace Blax\Shop\Models; +use Blax\Shop\Enums\StockStatus; +use Blax\Shop\Enums\StockType; use Blax\Shop\Models\Product; use Blax\Workkit\Traits\HasExpiration; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -27,6 +29,8 @@ class ProductStock extends Model protected $casts = [ 'quantity' => 'integer', + 'type' => StockType::class, + 'status' => StockStatus::class, 'expires_at' => 'datetime', ]; @@ -43,7 +47,7 @@ class ProductStock extends Model }); static::updated(function ($model) { - if ($model->wasChanged('status') && $model->status === 'completed') { + if ($model->wasChanged('status') && $model->status === StockStatus::COMPLETED) { $model->releaseStock(); } }); @@ -61,12 +65,12 @@ class ProductStock extends Model public function scopePending($query) { - return $query->where('status', 'pending'); + return $query->where('status', StockStatus::PENDING->value); } public function scopeReleased($query) { - return $query->where('status', 'completed'); + return $query->where('status', StockStatus::COMPLETED->value); } public function scopeTemporary($query) @@ -82,7 +86,7 @@ class ProductStock extends Model // Backward compatibility accessors public function getReleasedAtAttribute() { - return $this->status === 'completed' ? $this->updated_at : null; + return $this->status === StockStatus::COMPLETED ? $this->updated_at : null; } public function getUntilAtAttribute() @@ -105,8 +109,8 @@ class ProductStock extends Model return self::create([ 'product_id' => $product->id, 'quantity' => $quantity, - 'type' => 'reservation', - 'status' => 'pending', + 'type' => StockType::RESERVATION, + 'status' => StockStatus::PENDING, 'reference_type' => $reference ? get_class($reference) : null, 'reference_id' => $reference?->id, 'expires_at' => $until, @@ -117,12 +121,12 @@ class ProductStock extends Model public function release(): bool { - if ($this->status !== 'pending') { + if ($this->status !== StockStatus::PENDING) { return false; } return DB::transaction(function () { - $this->status = 'completed'; + $this->status = StockStatus::COMPLETED; $this->save(); return true; @@ -142,13 +146,13 @@ class ProductStock extends Model public function isExpired(): bool { return $this->isTemporary() - && $this->status === 'pending' + && $this->status === StockStatus::PENDING && $this->expires_at->isPast(); } public function isActive(): bool { - return $this->status === 'pending'; + return $this->status === StockStatus::PENDING; } protected function logStockChange(): void @@ -180,7 +184,7 @@ class ProductStock extends Model 'product_id' => $this->product_id, 'quantity_change' => $this->quantity, 'quantity_after' => $this->product->stock_quantity, - 'type' => 'release', + 'type' => StockType::RELEASE->value, 'note' => 'Stock released from reservation', 'reference_type' => $this->reference_type, 'reference_id' => $this->reference_id, @@ -205,12 +209,12 @@ class ProductStock extends Model public static function scopeAvailable($query) { - return $query->where('status', 'completed'); + return $query->where('status', StockStatus::COMPLETED->value); } public static function scopeAvailableReservations($query) { - return $query->where('type', 'reservation')->where('status', 'pending'); + return $query->where('type', StockType::RESERVATION->value)->where('status', StockStatus::PENDING->value); } public static function reservations() diff --git a/src/Traits/HasShoppingCapabilities.php b/src/Traits/HasShoppingCapabilities.php index b190c40..3c02d47 100644 --- a/src/Traits/HasShoppingCapabilities.php +++ b/src/Traits/HasShoppingCapabilities.php @@ -3,6 +3,8 @@ namespace Blax\Shop\Traits; use Blax\Shop\Contracts\Purchasable; +use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Exceptions\MultiplePurchaseOptions; use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotPurchasable; @@ -36,7 +38,7 @@ trait HasShoppingCapabilities */ public function completedPurchases(): MorphMany { - return $this->purchases()->where('status', 'completed'); + return $this->purchases()->where('status', PurchaseStatus::COMPLETED->value); } /** @@ -44,6 +46,9 @@ trait HasShoppingCapabilities * * @param Product|Product $product_or_price * @param int $quantity + * @param array|object|null $meta + * @param \DateTimeInterface|null $from Booking start date (for booking products) + * @param \DateTimeInterface|null $until Booking end date (for booking products) * * @return ProductPurchase * @throws \Exception @@ -51,7 +56,9 @@ trait HasShoppingCapabilities public function purchase( ProductPrice|Product $product_or_price, int $quantity = 1, - array|object|null $meta = null + array|object|null $meta = null, + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null ): ProductPurchase { if ($product_or_price instanceof Product) { @@ -98,8 +105,15 @@ trait HasShoppingCapabilities throw new \Exception("Product is not available for purchase"); } - // Decrease stock - if (!$product->decreaseStock($quantity)) { + // Handle booking products + $isBooking = $product->type === ProductType::BOOKING; + + if ($isBooking && (!$from || !$until)) { + throw new \Exception("Booking products require 'from' and 'until' dates"); + } + + // Decrease stock (for bookings, pass the until date) + if (!$product->decreaseStock($quantity, $isBooking ? $until : null)) { throw new \Exception("Unable to decrease stock"); } @@ -110,7 +124,9 @@ trait HasShoppingCapabilities 'purchaser_id' => $this->getKey(), 'purchaser_type' => get_class($this), 'quantity' => $quantity, - 'status' => 'unpaid', + 'status' => PurchaseStatus::UNPAID, + 'from' => $from, + 'until' => $until, 'meta' => $meta, 'amount' => $price->unit_amount * $quantity, ]); @@ -192,7 +208,7 @@ trait HasShoppingCapabilities */ public function refundPurchase(ProductPurchase $purchase, array $options = []): bool { - if ($purchase->status !== 'completed') { + if ($purchase->status !== PurchaseStatus::COMPLETED) { throw new \Exception("Can only refund completed purchases"); } @@ -203,7 +219,7 @@ trait HasShoppingCapabilities // Update purchase $purchase->update([ - 'status' => 'refunded', + 'status' => PurchaseStatus::REFUNDED, ]); // Trigger refund actions diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index b607f0b..33e104e 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -2,6 +2,8 @@ namespace Blax\Shop\Traits; +use Blax\Shop\Enums\StockStatus; +use Blax\Shop\Enums\StockType; use Blax\Shop\Exceptions\NotEnoughStockException; use Carbon\Carbon; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -46,8 +48,8 @@ trait HasStocks $this->stocks()->create([ 'quantity' => -$quantity, - 'type' => 'decrease', - 'status' => 'completed', + 'type' => StockType::DECREASE, + 'status' => StockStatus::COMPLETED, 'expires_at' => $until, ]); @@ -66,8 +68,8 @@ trait HasStocks $this->stocks()->create([ 'quantity' => $quantity, - 'type' => 'increase', - 'status' => 'completed', + 'type' => StockType::INCREASE, + 'status' => StockStatus::COMPLETED, ]); $this->logStockChange($quantity, 'increase'); diff --git a/tests/Feature/BookingFeatureTest.php b/tests/Feature/BookingFeatureTest.php new file mode 100644 index 0000000..ad8d855 --- /dev/null +++ b/tests/Feature/BookingFeatureTest.php @@ -0,0 +1,377 @@ +user = User::factory()->create(); + + // Create a booking product + $this->bookingProduct = Product::factory()->create([ + 'name' => 'Hotel Room', + 'slug' => 'hotel-room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'stock_quantity' => 0, + ]); + + // Initialize stock + $this->bookingProduct->increaseStock(10); + + // Create a price + $this->price = ProductPrice::factory()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 15000, // $150.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + } + + /** @test */ + public function it_can_create_a_booking_product() + { + $this->assertNotNull($this->bookingProduct); + $this->assertEquals(ProductType::BOOKING, $this->bookingProduct->type); + $this->assertTrue($this->bookingProduct->isBooking()); + } + + /** @test */ + public function it_can_purchase_a_booking_with_dates() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $purchase = $this->user->purchase( + $this->price, + 1, + null, + $from, + $until + ); + + $this->assertNotNull($purchase); + $this->assertEquals($this->bookingProduct->id, $purchase->purchasable_id); + $this->assertTrue($purchase->isBooking()); + $this->assertEquals($from->format('Y-m-d H:i:s'), $purchase->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $purchase->until->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_throws_exception_when_booking_without_dates() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Booking products require 'from' and 'until' dates"); + + $this->user->purchase($this->price, 1); + } + + /** @test */ + public function it_decreases_stock_for_booking_duration() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $initialStock = $this->bookingProduct->getAvailableStock(); + + $purchase = $this->user->purchase( + $this->price, + 2, + null, + $from, + $until + ); + + // Stock should be decreased + $this->bookingProduct->refresh(); + $remainingStock = $this->bookingProduct->getAvailableStock(); + $this->assertEquals($initialStock - 2, $remainingStock); + } + + /** @test */ + public function it_releases_stock_after_booking_period() + { + $from = Carbon::now()->subDays(3); + $until = Carbon::now()->subDays(1); // Booking ended yesterday + + $initialStock = $this->bookingProduct->getAvailableStock(); + + $purchase = $this->user->purchase( + $this->price, + 2, + null, + $from, + $until + ); + + // Find the stock reservation + $reservation = $this->bookingProduct->stocks() + ->where('type', 'decrease') + ->where('expires_at', $until) + ->first(); + + $this->assertNotNull($reservation); + $this->assertEquals($until->format('Y-m-d H:i:s'), $reservation->expires_at->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_can_check_booking_availability() + { + $from = Carbon::now()->addDays(5); + $until = Carbon::now()->addDays(7); + + // Should be available (stock is 10) + $this->assertTrue($this->bookingProduct->isAvailableForBooking($from, $until, 5)); + + // Should not be available (requesting more than available) + $this->assertFalse($this->bookingProduct->isAvailableForBooking($from, $until, 15)); + } + + /** @test */ + public function it_can_add_booking_to_cart_with_dates() + { + $cart = $this->user->currentCart(); + + $from = Carbon::now()->addDays(10); + $until = Carbon::now()->addDays(12); + + $cartItem = $cart->addToCart( + $this->bookingProduct, + 1, + [ + 'from' => $from->toDateTimeString(), + 'until' => $until->toDateTimeString(), + ] + ); + + $this->assertNotNull($cartItem); + $this->assertEquals($this->bookingProduct->id, $cartItem->purchasable_id); + $this->assertNotNull($cartItem->parameters); + $this->assertIsArray($cartItem->parameters); + $this->assertEquals($from->toDateTimeString(), $cartItem->parameters['from']); + $this->assertEquals($until->toDateTimeString(), $cartItem->parameters['until']); + } + + /** @test */ + public function it_can_checkout_cart_with_booking_product() + { + $cart = $this->user->currentCart(); + + $from = Carbon::now()->addDays(15); + $until = Carbon::now()->addDays(17); + + $cart->addToCart( + $this->bookingProduct, + 2, + [ + 'from' => $from->toDateTimeString(), + 'until' => $until->toDateTimeString(), + ] + ); + + $initialStock = $this->bookingProduct->getAvailableStock(); + + $cart->checkout(); + + $this->assertTrue($cart->isConverted()); + + // Check that purchase was created with dates + $purchase = ProductPurchase::where('cart_id', $cart->id)->first(); + $this->assertNotNull($purchase); + $this->assertTrue($purchase->isBooking()); + $this->assertEquals($from->format('Y-m-d H:i:s'), $purchase->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $purchase->until->format('Y-m-d H:i:s')); + + // Stock should be decreased + $this->bookingProduct->refresh(); + $this->assertEquals($initialStock - 2, $this->bookingProduct->getAvailableStock()); + } + + /** @test */ + public function it_prevents_overbooking() + { + $from = Carbon::now()->addDays(20); + $until = Carbon::now()->addDays(22); + + // Book 8 units + $this->user->purchase( + $this->price, + 8, + null, + $from, + $until + ); + + // Try to book 5 more units (would exceed stock of 10) + $this->expectException(\Exception::class); + + $this->user->purchase( + $this->price, + 5, + null, + $from, + $until + ); + } + + /** @test */ + public function it_can_scope_booking_purchases() + { + $from = Carbon::now()->addDays(25); + $until = Carbon::now()->addDays(27); + + // Create a regular product purchase + $regularProduct = Product::factory()->create([ + 'type' => ProductType::SIMPLE, + 'manage_stock' => false, + ]); + $regularPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $regularProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => true, + ]); + $this->user->purchase($regularPrice, 1); + + // Create a booking purchase + $this->user->purchase( + $this->price, + 1, + null, + $from, + $until + ); + + $bookingPurchases = ProductPurchase::bookings()->get(); + $this->assertCount(1, $bookingPurchases); + $this->assertTrue($bookingPurchases->first()->isBooking()); + } + + /** @test */ + public function it_can_scope_ended_bookings() + { + $pastFrom = Carbon::now()->subDays(5); + $pastUntil = Carbon::now()->subDays(2); + + $futureFrom = Carbon::now()->addDays(1); + $futureUntil = Carbon::now()->addDays(3); + + // Create past booking + $pastPurchase = $this->user->purchase( + $this->price, + 1, + null, + $pastFrom, + $pastUntil + ); + + // Create future booking + $futurePurchase = $this->user->purchase( + $this->price, + 1, + null, + $futureFrom, + $futureUntil + ); + + $endedBookings = ProductPurchase::endedBookings()->get(); + + $this->assertCount(1, $endedBookings); + $this->assertEquals($pastPurchase->id, $endedBookings->first()->id); + $this->assertTrue($endedBookings->first()->isBookingEnded()); + $this->assertFalse($futurePurchase->isBookingEnded()); + } + + /** @test */ + public function it_can_scope_booking_products() + { + // Create regular product + Product::factory()->create([ + 'type' => ProductType::SIMPLE, + ]); + + $bookingProducts = Product::bookings()->get(); + $this->assertCount(1, $bookingProducts); + $this->assertEquals($this->bookingProduct->id, $bookingProducts->first()->id); + } + + /** @test */ + public function booking_stock_expires_after_until_date() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(2); + + $purchase = $this->user->purchase( + $this->price, + 3, + null, + $from, + $until + ); + + // Find the stock decrease record + $stockRecord = $this->bookingProduct->stocks() + ->where('type', 'decrease') + ->latest() + ->first(); + + $this->assertNotNull($stockRecord); + $this->assertNotNull($stockRecord->expires_at); + $this->assertEquals($until->format('Y-m-d H:i:s'), $stockRecord->expires_at->format('Y-m-d H:i:s')); + } + + /** @test */ + public function multiple_bookings_with_different_dates_work_independently() + { + $booking1From = Carbon::now()->addDays(1); + $booking1Until = Carbon::now()->addDays(3); + + $booking2From = Carbon::now()->addDays(5); + $booking2Until = Carbon::now()->addDays(7); + + $purchase1 = $this->user->purchase( + $this->price, + 2, + null, + $booking1From, + $booking1Until + ); + + $purchase2 = $this->user->purchase( + $this->price, + 3, + null, + $booking2From, + $booking2Until + ); + + $this->assertNotNull($purchase1); + $this->assertNotNull($purchase2); + $this->assertNotEquals($purchase1->from, $purchase2->from); + $this->assertNotEquals($purchase1->until, $purchase2->until); + + // Both should have decreased stock + $this->bookingProduct->refresh(); + $this->assertEquals(5, $this->bookingProduct->getAvailableStock()); // 10 - 2 - 3 + } +} diff --git a/tests/Feature/HasShoppingCapabilitiesTest.php b/tests/Feature/HasShoppingCapabilitiesTest.php index 11d6506..5d53fbe 100644 --- a/tests/Feature/HasShoppingCapabilitiesTest.php +++ b/tests/Feature/HasShoppingCapabilitiesTest.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Tests\Feature; +use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Exceptions\MultiplePurchaseOptions; use Blax\Shop\Exceptions\NotPurchasable; use Blax\Shop\Models\Product; @@ -123,7 +124,7 @@ class HasShoppingCapabilitiesTest extends TestCase $completed = $user->completedPurchases; $this->assertCount(1, $completed); - $this->assertEquals('completed', $completed->first()->status); + $this->assertEquals(PurchaseStatus::COMPLETED, $completed->first()->status); } /** @test */ diff --git a/tests/Feature/ProductManagementTest.php b/tests/Feature/ProductManagementTest.php index 9c24a65..7bcc667 100644 --- a/tests/Feature/ProductManagementTest.php +++ b/tests/Feature/ProductManagementTest.php @@ -2,6 +2,8 @@ namespace Blax\Shop\Tests\Feature; +use Blax\Shop\Enums\ProductStatus; +use Blax\Shop\Enums\ProductType; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductAttribute; @@ -130,8 +132,8 @@ class ProductManagementTest extends TestCase ProductPrice::create([ 'purchasable_id' => $product->id, 'purchasable_type' => get_class($product), - 'type' => 'one-time', - 'price' => 9999, + 'type' => 'one_time', + 'unit_amount' => 9999, 'currency' => 'USD', 'active' => true, ]); @@ -197,7 +199,7 @@ class ProductManagementTest extends TestCase $published = Product::published()->get(); $this->assertCount(1, $published); - $this->assertEquals('published', $published->first()->status); + $this->assertEquals(ProductStatus::PUBLISHED, $published->first()->status); } /** @test */ @@ -243,11 +245,11 @@ class ProductManagementTest extends TestCase public function it_can_have_parent_child_relationships() { $parent = Product::factory()->create([ - 'type' => 'variable', + 'type' => ProductType::VARIABLE, ]); $child = Product::factory()->create([ - 'type' => 'variation', + 'type' => ProductType::VARIABLE, 'parent_id' => $parent->id, ]); diff --git a/tests/Feature/ProductPriceTest.php b/tests/Feature/ProductPriceTest.php index 7f5ca48..6c0443f 100644 --- a/tests/Feature/ProductPriceTest.php +++ b/tests/Feature/ProductPriceTest.php @@ -2,6 +2,9 @@ namespace Blax\Shop\Tests\Feature; +use Blax\Shop\Enums\BillingScheme; +use Blax\Shop\Enums\PriceType; +use Blax\Shop\Enums\RecurringInterval; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPrice; use Blax\Shop\Tests\TestCase; @@ -90,8 +93,8 @@ class ProductPriceTest extends TestCase 'trial_period_days' => 14, ]); - $this->assertEquals('recurring', $price->type); - $this->assertEquals('month', $price->interval); + $this->assertEquals(PriceType::RECURRING, $price->type); + $this->assertEquals(RecurringInterval::MONTH, $price->interval); $this->assertEquals(1, $price->interval_count); $this->assertEquals(14, $price->trial_period_days); } @@ -109,7 +112,7 @@ class ProductPriceTest extends TestCase 'type' => 'one_time', ]); - $this->assertEquals('one_time', $price->type); + $this->assertEquals(PriceType::ONE_TIME, $price->type); $this->assertNull($price->interval); } @@ -274,7 +277,7 @@ class ProductPriceTest extends TestCase 'billing_scheme' => 'per_unit', ]); - $this->assertEquals('tiered', $tieredPrice->billing_scheme); - $this->assertEquals('per_unit', $perUnitPrice->billing_scheme); + $this->assertEquals(BillingScheme::TIERED, $tieredPrice->billing_scheme); + $this->assertEquals(BillingScheme::PER_UNIT, $perUnitPrice->billing_scheme); } } diff --git a/tests/Feature/ProductPurchaseTest.php b/tests/Feature/ProductPurchaseTest.php index 29c4e49..f39423b 100644 --- a/tests/Feature/ProductPurchaseTest.php +++ b/tests/Feature/ProductPurchaseTest.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Tests\Feature; +use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Tests\TestCase; @@ -109,8 +110,8 @@ class ProductPurchaseTest extends TestCase 'status' => 'completed', ]); - $this->assertEquals('unpaid', $unpaidPurchase->status); - $this->assertEquals('completed', $completedPurchase->status); + $this->assertEquals(PurchaseStatus::UNPAID, $unpaidPurchase->status); + $this->assertEquals(PurchaseStatus::COMPLETED, $completedPurchase->status); } /** @test */ @@ -144,7 +145,7 @@ class ProductPurchaseTest extends TestCase $completed = ProductPurchase::completed()->get(); $this->assertCount(1, $completed); - $this->assertEquals('completed', $completed->first()->status); + $this->assertEquals(PurchaseStatus::COMPLETED, $completed->first()->status); } /** @test */ @@ -178,7 +179,7 @@ class ProductPurchaseTest extends TestCase $inCart = ProductPurchase::inCart()->get(); $this->assertCount(1, $inCart); - $this->assertEquals('cart', $inCart->first()->status); + $this->assertEquals(PurchaseStatus::CART, $inCart->first()->status); } /** @test */ @@ -235,7 +236,7 @@ class ProductPurchaseTest extends TestCase ]); $this->assertEquals(5000, $purchase->fresh()->amount_paid); - $this->assertEquals('completed', $purchase->fresh()->status); + $this->assertEquals(PurchaseStatus::COMPLETED, $purchase->fresh()->status); } /** @test */ diff --git a/tests/Feature/ProductScopeTest.php b/tests/Feature/ProductScopeTest.php index 9b832cd..d4b5a39 100644 --- a/tests/Feature/ProductScopeTest.php +++ b/tests/Feature/ProductScopeTest.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Tests\Feature; +use Blax\Shop\Enums\ProductType; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductCategory; use Blax\Shop\Tests\TestCase; @@ -275,12 +276,12 @@ class ProductScopeTest extends TestCase /** @test */ public function it_can_scope_products_by_type() { - Product::factory()->create(['type' => 'simple']); - Product::factory()->create(['type' => 'simple']); - Product::factory()->create(['type' => 'variable']); - Product::factory()->create(['type' => 'variation']); + Product::factory()->create(['type' => ProductType::SIMPLE]); + Product::factory()->create(['type' => ProductType::SIMPLE]); + Product::factory()->create(['type' => ProductType::VARIABLE]); + Product::factory()->create(['type' => ProductType::VARIABLE]); - $simpleProducts = Product::where('type', 'simple')->get(); + $simpleProducts = Product::where('type', ProductType::SIMPLE)->get(); $this->assertCount(2, $simpleProducts); } diff --git a/tests/Feature/ProductStockTest.php b/tests/Feature/ProductStockTest.php index d5fbe43..bd6deac 100644 --- a/tests/Feature/ProductStockTest.php +++ b/tests/Feature/ProductStockTest.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Tests\Feature; +use Blax\Shop\Enums\StockStatus; use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductStock; @@ -216,7 +217,7 @@ class ProductStockTest extends TestCase $stock = $product->stocks()->first(); - $this->assertEquals('completed', $stock->status); + $this->assertEquals(StockStatus::COMPLETED, $stock->status); } /** @test */ diff --git a/tests/Feature/PurchaseFlowTest.php b/tests/Feature/PurchaseFlowTest.php index 2e9ddb3..04e6b2b 100644 --- a/tests/Feature/PurchaseFlowTest.php +++ b/tests/Feature/PurchaseFlowTest.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Tests\Feature; +use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPurchase; @@ -37,7 +38,7 @@ class PurchaseFlowTest extends TestCase $this->assertEquals($product->id, $purchase->purchasable_id); $this->assertEquals($user->id, $purchase->purchaser_id); $this->assertEquals(1, $purchase->quantity); - $this->assertEquals('unpaid', $purchase->status); + $this->assertEquals(PurchaseStatus::UNPAID, $purchase->status); } /** @test */ @@ -126,8 +127,8 @@ class PurchaseFlowTest extends TestCase $purchases = $cart->purchases; $this->assertCount(2, $purchases); - $this->assertEquals('unpaid', $purchases[0]->status); - $this->assertEquals('unpaid', $purchases[1]->status); + $this->assertEquals(PurchaseStatus::UNPAID, $purchases[0]->status); + $this->assertEquals(PurchaseStatus::UNPAID, $purchases[1]->status); } /** @test */ diff --git a/tests/Unit/CartTest.php b/tests/Unit/CartTest.php index 97e12cc..0e978d2 100644 --- a/tests/Unit/CartTest.php +++ b/tests/Unit/CartTest.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Tests\Unit; +use Blax\Shop\Enums\CartStatus; use Blax\Shop\Models\Cart; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPrice; @@ -52,8 +53,8 @@ class CartTest extends TestCase public function cart_respects_sale_prices() { $cart = Cart::create(); - $product = Product::factory()->withPrices(1,50)->create([ - 'sale_start' => now()->subDay(), + $product = Product::factory()->withPrices(1, 50)->create([ + 'sale_start' => now()->subDay(), 'sale_end' => now()->addDay(), ]); @@ -109,7 +110,7 @@ class CartTest extends TestCase public function cart_total_sums_all_items() { $cart = Cart::create(); - + $product1 = Product::factory()->create(); $price1 = ProductPrice::create([ 'purchasable_id' => $product1->id, @@ -225,14 +226,14 @@ class CartTest extends TestCase public function cart_can_have_status() { $cart = Cart::create([ - 'status' => 'pending', + 'status' => CartStatus::ACTIVE, ]); - $this->assertEquals('pending', $cart->status); + $this->assertEquals(CartStatus::ACTIVE, $cart->status); - $cart->update(['status' => 'completed']); + $cart->update(['status' => CartStatus::CONVERTED]); - $this->assertEquals('completed', $cart->fresh()->status); + $this->assertEquals(CartStatus::CONVERTED, $cart->fresh()->status); } /** @test */