A types, enums & statuses, U product models, I shopping trait, A booking support, R tests

This commit is contained in:
a6a2f5842 2025-12-03 13:59:01 +01:00
parent 2008a16a53
commit c5004158eb
28 changed files with 828 additions and 74 deletions

View File

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

View File

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

View File

@ -0,0 +1,17 @@
<?php
namespace Blax\Shop\Enums;
enum BillingScheme: string
{
case PER_UNIT = 'per_unit';
case TIERED = 'tiered';
public function label(): string
{
return match ($this) {
self::PER_UNIT => 'Per Unit',
self::TIERED => 'Tiered',
};
}
}

21
src/Enums/CartStatus.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Blax\Shop\Enums;
enum CartStatus: string
{
case ACTIVE = 'active';
case ABANDONED = 'abandoned';
case CONVERTED = 'converted';
case EXPIRED = 'expired';
public function label(): string
{
return match ($this) {
self::ACTIVE => 'Active',
self::ABANDONED => 'Abandoned',
self::CONVERTED => 'Converted',
self::EXPIRED => 'Expired',
};
}
}

17
src/Enums/PriceType.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace Blax\Shop\Enums;
enum PriceType: string
{
case ONE_TIME = 'one_time';
case RECURRING = 'recurring';
public function label(): string
{
return match ($this) {
self::ONE_TIME => 'One Time',
self::RECURRING => 'Recurring',
};
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace Blax\Shop\Enums;
enum ProductAttributeType: string
{
case TEXT = 'text';
case SELECT = 'select';
case COLOR = 'color';
case IMAGE = 'image';
public function label(): string
{
return match ($this) {
self::TEXT => 'Text',
self::SELECT => 'Select',
self::COLOR => 'Color',
self::IMAGE => 'Image',
};
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Blax\Shop\Enums;
enum ProductStatus: string
{
case DRAFT = 'draft';
case PUBLISHED = 'published';
case ARCHIVED = 'archived';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::PUBLISHED => 'Published',
self::ARCHIVED => 'Archived',
};
}
}

25
src/Enums/ProductType.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace Blax\Shop\Enums;
enum ProductType: string
{
case SIMPLE = 'simple';
case VARIABLE = 'variable';
case GROUPED = 'grouped';
case EXTERNAL = 'external';
case BOOKING = 'booking';
case VARIATION = 'variation';
public function label(): string
{
return match ($this) {
self::SIMPLE => 'Simple',
self::VARIABLE => 'Variable',
self::GROUPED => 'Grouped',
self::EXTERNAL => 'External',
self::BOOKING => 'Booking',
self::VARIATION => 'Variation',
};
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Blax\Shop\Enums;
enum PurchaseStatus: string
{
case PENDING = 'pending';
case UNPAID = 'unpaid';
case COMPLETED = 'completed';
case REFUNDED = 'refunded';
case CART = 'cart';
public function label(): string
{
return match ($this) {
self::PENDING => 'Pending',
self::UNPAID => 'Unpaid',
self::COMPLETED => 'Completed',
self::REFUNDED => 'Refunded',
self::CART => 'Cart',
};
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Blax\Shop\Enums;
enum RecurringInterval: string
{
case DAY = 'day';
case WEEK = 'week';
case MONTH = 'month';
case YEAR = 'year';
case QUARTER = 'quarter';
public function label(): string
{
return match ($this) {
self::DAY => 'Day',
self::WEEK => 'Week',
self::MONTH => 'Month',
self::QUARTER => 'Quarter',
self::YEAR => 'Year',
};
}
}

21
src/Enums/StockStatus.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Blax\Shop\Enums;
enum StockStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case CANCELLED = 'cancelled';
case EXPIRED = 'expired';
public function label(): string
{
return match ($this) {
self::PENDING => 'Pending',
self::COMPLETED => 'Completed',
self::CANCELLED => 'Cancelled',
self::EXPIRED => 'Expired',
};
}
}

27
src/Enums/StockType.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace Blax\Shop\Enums;
enum StockType: string
{
case RESERVATION = 'reservation';
case ADJUSTMENT = 'adjustment';
case SALE = 'sale';
case RETURN = 'return';
case INCREASE = 'increase';
case DECREASE = 'decrease';
case RELEASE = 'release';
public function label(): string
{
return match ($this) {
self::RESERVATION => 'Reservation',
self::ADJUSTMENT => 'Adjustment',
self::SALE => 'Sale',
self::RETURN => 'Return',
self::INCREASE => 'Increase',
self::DECREASE => 'Decrease',
self::RELEASE => 'Release',
};
}
}

View File

@ -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([

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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