diff --git a/composer.json b/composer.json index 67bef97..fb0d04f 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ } }, "require": { - "php": "^8.2|^8.3", + "php": "^8.2|^8.3|^8.4", "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", "illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0", "blax-software/laravel-workkit": "dev-master|*", diff --git a/src/Console/Commands/ReleaseExpiredStocks.php b/src/Console/Commands/ReleaseExpiredStocks.php index 2e0e5a5..d95a986 100644 --- a/src/Console/Commands/ReleaseExpiredStocks.php +++ b/src/Console/Commands/ReleaseExpiredStocks.php @@ -1,5 +1,7 @@ $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName), 'name' => $itemName, - 'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT), + 'sku' => $pool->sku . '-' . str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT), 'type' => ProductType::BOOKING, 'status' => ProductStatus::PUBLISHED, 'is_visible' => false, diff --git a/src/Console/Commands/ShopCleanupCartsCommand.php b/src/Console/Commands/ShopCleanupCartsCommand.php index 2e5cdaa..6c00cab 100644 --- a/src/Console/Commands/ShopCleanupCartsCommand.php +++ b/src/Console/Commands/ShopCleanupCartsCommand.php @@ -1,5 +1,7 @@ |\Illuminate\Support\Collection + */ public function paymentMethods(): array; } diff --git a/src/Contracts/Purchasable.php b/src/Contracts/Purchasable.php index 351bd6b..d45add1 100644 --- a/src/Contracts/Purchasable.php +++ b/src/Contracts/Purchasable.php @@ -1,19 +1,96 @@ price` — the same value + * {@see self::getCurrentPrice()} resolves, exposed for convenience + * in Blade / JSON serialization. + */ public function getPriceAttribute(): ?float; + /** + * Whether the item is currently selling at a discounted price. + * + * Pricing logic uses this to pick between {@see self::getCurrentPrice()} + * and a sale price source. Implementations that don't support sales + * may always return `false`. + */ public function isOnSale(): bool; + /** + * Consume `$quantity` units of inventory. + * + * Returns `true` when stock was successfully reduced (or when the + * implementation doesn't track stock and so always reports success). + * Returns `false` to signal "not enough"; implementations may + * alternatively throw {@see \Blax\Shop\Exceptions\NotEnoughStockException}. + * + * Race-safety: implementations should prefer an atomic conditional + * UPDATE over a `lockForUpdate` dance — see the laravel-shop + * principles doc for the canonical pattern. + */ public function decreaseStock(int $quantity = 1): bool; + /** + * Restore `$quantity` units to inventory (e.g. on a return / refund). + * + * Symmetric to {@see self::decreaseStock()}. Returning `false` means + * the implementation declined to record the change (typically because + * stock management is disabled on this record). + */ public function increaseStock(int $quantity = 1): bool; + /** + * Polymorphic relation to the purchase history. + * + * Implementations return a {@see \Illuminate\Database\Eloquent\Relations\MorphMany} + * pointing at {@see \Blax\Shop\Models\ProductPurchase} via the + * `purchasable_*` columns. The return type is intentionally + * unconstrained on the interface to preserve backward compatibility + * — see the canonical implementations on `Product` and `IsSimplePurchasable`. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphMany<\Blax\Shop\Models\ProductPurchase, $this> + */ public function purchases(); - } diff --git a/src/Enums/BillingScheme.php b/src/Enums/BillingScheme.php index 2e38006..de08710 100644 --- a/src/Enums/BillingScheme.php +++ b/src/Enums/BillingScheme.php @@ -1,5 +1,7 @@ product->id`. + */ class ProductCreated { use Dispatchable, SerializesModels; diff --git a/src/Events/ProductUpdated.php b/src/Events/ProductUpdated.php index 4c8cf59..58f73de 100644 --- a/src/Events/ProductUpdated.php +++ b/src/Events/ProductUpdated.php @@ -1,11 +1,22 @@ where('customer_type', $userModel); } - public static function scopeUnpaid($query) + public function scopeUnpaid($query) { return $query->whereDoesntHave('purchases', function ($q) { $q->whereColumn('total_amount', '!=', 'amount_paid'); @@ -2034,10 +2036,20 @@ class Cart extends Model // Price is already stored in cents, Stripe expects smallest currency unit $unitAmountCents = (int) $item->price; + // Stripe wants lowercase ISO-4217 currency codes. Resolve from + // the cart item's price relation first (the source of truth for + // the line being charged), then the cart's own currency column, + // then the package default — never assume the cart row has one. + $lineCurrency = strtolower( + $item->price()->first()?->currency + ?? $this->currency + ?? config('shop.currency', 'usd') + ); + // Build line item using price_data for dynamic pricing $lineItem = [ 'price_data' => [ - 'currency' => $item->price->currency ?? strtoupper($this->currency), + 'currency' => $lineCurrency, 'product_data' => [ 'name' => $productName, ...($description ? ['description' => $description] : []), @@ -2064,7 +2076,7 @@ class Cart extends Model // Prepare session parameters $sessionParams = [ 'payment_method_types' => ['card'], - 'currency' => strtoupper($this->currency), + 'currency' => strtolower($this->currency ?? config('shop.currency', 'usd')), 'line_items' => $lineItems, 'mode' => 'payment', 'success_url' => $success_url, diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 02090aa..50a09c1 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -1,5 +1,7 @@ order_number, strlen("{$prefix}{$date}")); - $sequence = str_pad($lastNumber + 1, 4, '0', STR_PAD_LEFT); + // str_pad requires a string under strict_types — cast the int explicitly. + $sequence = str_pad((string) ($lastNumber + 1), 4, '0', STR_PAD_LEFT); } else { $sequence = '0001'; } diff --git a/src/Models/OrderNote.php b/src/Models/OrderNote.php index 7622f31..df69098 100644 --- a/src/Models/OrderNote.php +++ b/src/Models/OrderNote.php @@ -1,5 +1,7 @@ $children + * @property-read \Illuminate\Database\Eloquent\Collection $attributes + * @property-read \Illuminate\Database\Eloquent\Collection $actions + * @property-read \Illuminate\Database\Eloquent\Collection $purchases + */ class Product extends Model implements Purchasable, Cartable { use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, ChecksIfBooking; @@ -141,11 +195,23 @@ class Product extends Model implements Purchasable, Cartable }); } - public function parent() + /** + * Parent product when this row is a variant / grouped child / pool single + * item. Top-level products have `parent_id = null`. + * + * @return BelongsTo + */ + public function parent(): BelongsTo { return $this->belongsTo(static::class, 'parent_id'); } + /** + * Variants, grouped children, or pool single items hanging off this + * product. Returns an empty collection for leaf rows. + * + * @return HasMany + */ public function children(): HasMany { return $this->hasMany(static::class, 'parent_id'); @@ -156,7 +222,7 @@ class Product extends Model implements Purchasable, Cartable // Explicit FK so the relation still targets `product_id` when a host // app subclasses Product (e.g. `Book extends Product`). return $this->hasMany( - config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'), + config('shop.models.product_attribute', ProductAttribute::class), 'product_id' ); } @@ -177,12 +243,20 @@ class Product extends Model implements Purchasable, Cartable ); } - public function scopePublished($query) + /** + * @param Builder $query + * @return Builder + */ + public function scopePublished(Builder $query): Builder { return $query->where('status', ProductStatus::PUBLISHED->value); } - public function scopeFeatured($query) + /** + * @param Builder $query + * @return Builder + */ + public function scopeFeatured(Builder $query): Builder { return $query->where('featured', true); } @@ -364,7 +438,14 @@ class Product extends Model implements Purchasable, Cartable return ProductAction::getAvailableActions(); } - public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = []) + /** + * Dispatch every {@see ProductAction} configured on this product for + * `$event`. Returns whatever {@see ProductAction::callForProduct()} + * returns (typically a collection of {@see ProductActionRun} rows). + * + * @param array $additionalData Free-form payload merged into the action context. + */ + public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = []): mixed { return ProductAction::callForProduct( $this, @@ -374,7 +455,14 @@ class Product extends Model implements Purchasable, Cartable ); } - public function scopeVisible($query) + /** + * Visible to customers right now: `is_visible = true`, status PUBLISHED, + * and `published_at` either null or in the past. + * + * @param Builder $query + * @return Builder + */ + public function scopeVisible(Builder $query): Builder { return $query->where('is_visible', true) ->where('status', ProductStatus::PUBLISHED->value) @@ -384,7 +472,15 @@ class Product extends Model implements Purchasable, Cartable }); } - public function scopeSearch($query, string $search) + /** + * Substring match on slug / SKU / name. Cheap LIKE — not a full-text + * index; host apps that need stronger search should add Scout or + * MeiliSearch alongside this scope. + * + * @param Builder $query + * @return Builder + */ + public function scopeSearch(Builder $query, string $search): Builder { return $query->where(function ($q) use ($search) { $q->where('slug', 'like', "%{$search}%") @@ -544,8 +640,11 @@ class Product extends Model implements Purchasable, Cartable /** * Scope for booking products + * + * @param Builder $query + * @return Builder */ - public function scopeBookings($query) + public function scopeBookings(Builder $query): Builder { return $query->where('type', ProductType::BOOKING->value); } diff --git a/src/Models/ProductAction.php b/src/Models/ProductAction.php index 368c16d..bd6fef9 100644 --- a/src/Models/ProductAction.php +++ b/src/Models/ProductAction.php @@ -1,5 +1,7 @@ 'integer', + 'type' => ProductAttributeType::class, + 'meta' => 'array', ]; protected $hidden = [ diff --git a/src/Models/ProductCategory.php b/src/Models/ProductCategory.php index ed30f45..7aff318 100644 --- a/src/Models/ProductCategory.php +++ b/src/Models/ProductCategory.php @@ -1,5 +1,7 @@ $tiers + */ class ProductPrice extends Model implements Cartable { use HasFactory, HasUuids, HasMetaTranslation; @@ -48,17 +81,34 @@ class ProductPrice extends Model implements Cartable 'trial_period_days' => 'integer', ]; - public function purchasable() + /** + * The {@see Purchasable} this price belongs to (usually a {@see Product}). + * + * @return MorphTo + */ + public function purchasable(): MorphTo { return $this->morphTo(); } - public function scopeIsActive($query) + /** + * Filter to only currently-active prices (default scope alternative). + * + * @param Builder $query + * @return Builder + */ + public function scopeIsActive(Builder $query): Builder { return $query->where('active', true); } - public function getCurrentPrice(bool|null $sale_price = null): float + /** + * Resolve the unit price this record currently sells at. + * + * Returns the sale price when `$sale_price` is true *and* a + * `sale_unit_amount` is configured; otherwise the regular `unit_amount`. + */ + public function getCurrentPrice(?bool $sale_price = null): float { if ($sale_price) { return $this->sale_unit_amount ?? $this->unit_amount; diff --git a/src/Models/ProductPriceTier.php b/src/Models/ProductPriceTier.php index b627634..7d9099e 100644 --- a/src/Models/ProductPriceTier.php +++ b/src/Models/ProductPriceTier.php @@ -1,5 +1,7 @@ $actionRuns + */ class ProductPurchase extends Model { use HasBookingLifecycle, HasLoanLifecycle, HasUuids; @@ -45,25 +90,44 @@ class ProductPurchase extends Model $this->setTable(config('shop.tables.product_purchases', 'product_purchases')); } - public function purchasable() + /** + * What was sold/loaned/booked — usually a {@see Product} but anything + * implementing {@see \Blax\Shop\Contracts\Purchasable} qualifies. + * + * @return MorphTo + */ + public function purchasable(): MorphTo { return $this->morphTo('purchasable'); } - public function purchaser() + /** + * Who made the purchase (typically a User), polymorphic. + * + * @return MorphTo + */ + public function purchaser(): MorphTo { return $this->morphTo('purchaser'); } - public function product() + /** + * Convenience shortcut to a {@see Product} when the purchasable side IS + * a product; resolves through `purchasable_id` for the JOIN. + * + * @return BelongsTo + */ + public function product(): BelongsTo { return $this->belongsTo(config('shop.models.product', Product::class)); } /** * The price this purchase bills against (see HasLoanLifecycle::calculateCost). + * + * @return BelongsTo */ - public function price() + public function price(): BelongsTo { return $this->belongsTo( config('shop.models.product_price', ProductPrice::class), @@ -71,25 +135,45 @@ class ProductPurchase extends Model ); } - public function user() + /** + * Resolve the purchaser as a User relation when the polymorphic type + * matches the configured auth model; returns null otherwise so callers + * can branch without an instanceof check on the resolved object. + * + * Note: returns a {@see MorphTo} (same instance as {@see self::purchaser()}) + * so the caller can `->first()` / eager-load uniformly. + */ + public function user(): ?MorphTo { - if ($this->purchasable_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) { - return $this->purchasable(); + if ($this->purchaser_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) { + return $this->purchaser(); } return null; } - public static function scopeFromCart($query, $cartId) + /** + * @param Builder $query + * @return Builder + */ + public function scopeFromCart(Builder $query, string $cartId): Builder { return $query->where('cart_id', $cartId); } - public static function scopeInCart($query) + /** + * @param Builder $query + * @return Builder + */ + public function scopeInCart(Builder $query): Builder { return $query->where('status', PurchaseStatus::CART->value); } - public static function scopeCompleted($query) + /** + * @param Builder $query + * @return Builder + */ + public function scopeCompleted(Builder $query): Builder { return $query->where('status', PurchaseStatus::COMPLETED->value); } @@ -127,7 +211,13 @@ class ProductPurchase extends Model }); } - public function actionRuns() + /** + * Run-log of every {@see ProductAction} fired against the underlying + * product for this purchase (welcome email, fulfilment webhook, etc.). + * + * @return HasManyThrough + */ + public function actionRuns(): HasManyThrough { return $this->hasManyThrough( ProductActionRun::class, diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php index 2e3b2f2..1d60630 100644 --- a/src/Models/ProductStock.php +++ b/src/Models/ProductStock.php @@ -1,5 +1,7 @@ where('status', StockStatus::COMPLETED->value); } @@ -321,7 +323,7 @@ class ProductStock extends Model * Scope: Get active (pending) claimed stock entries * These represent stock currently claimed but not yet released */ - public static function scopeAvailableClaims($query) + public function scopeAvailableClaims($query) { return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value); } @@ -350,7 +352,7 @@ class ProductStock extends Model * * @param \DateTimeInterface $date The date to check availability for */ - public static function scopeAvailableOnDate($query, \DateTimeInterface $date) + public function scopeAvailableOnDate($query, \DateTimeInterface $date) { return $query->where('type', StockType::CLAIMED->value) ->where('status', StockStatus::PENDING->value) diff --git a/src/Services/CartService.php b/src/Services/CartService.php index 6e7643a..4fab22d 100644 --- a/src/Services/CartService.php +++ b/src/Services/CartService.php @@ -1,5 +1,7 @@ stripeService = $stripeService ?? app(StripeService::class); } diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index 5a96543..fa4f78f 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -1,5 +1,7 @@ $query + * @return Builder */ - public function scopeBookings($query) + public function scopeBookings(Builder $query): Builder { return $query->whereNotNull('from')->whereNotNull('until'); } /** * Scope to bookings whose window is in the past. + * + * @param Builder $query + * @return Builder */ - public function scopeEndedBookings($query) + public function scopeEndedBookings(Builder $query): Builder { return $query->bookings()->where('until', '<', now()); } diff --git a/src/Traits/HasBookingPriceCalculation.php b/src/Traits/HasBookingPriceCalculation.php index aaaafe4..09aa4c6 100644 --- a/src/Traits/HasBookingPriceCalculation.php +++ b/src/Traits/HasBookingPriceCalculation.php @@ -1,5 +1,7 @@ |\stdClass|null $meta Carries `returned_at` and `extensions_used` keys. + * @property \Blax\Shop\Enums\PurchaseStatus $status Set to `COMPLETED` on {@see self::markReturned()}. + * @property string|null $price_id FK to {@see ProductPrice} used for cost calculation. + * @property-read ProductPrice|null $price Eager-loadable price relation. + * @property-read Model|null $purchasable The loaned item — typically a {@see IsLoanableProduct}-using model. */ trait HasLoanLifecycle { @@ -154,8 +171,11 @@ trait HasLoanLifecycle /** * Scope: loans currently in the borrower's hands (not returned). + * + * @param Builder $query + * @return Builder */ - public function scopeActiveLoans($query) + public function scopeActiveLoans(Builder $query): Builder { return $query ->where('status', PurchaseStatus::PENDING->value) @@ -164,16 +184,22 @@ trait HasLoanLifecycle /** * Scope: loans that have been handed back. + * + * @param Builder $query + * @return Builder */ - public function scopeReturned($query) + public function scopeReturned(Builder $query): Builder { return $query->whereNotNull('meta->returned_at'); } /** * Scope: loans past their due date and not yet returned. + * + * @param Builder $query + * @return Builder */ - public function scopeOverdue($query) + public function scopeOverdue(Builder $query): Builder { return $query->activeLoans()->where('until', '<', now()); } diff --git a/src/Traits/HasOrders.php b/src/Traits/HasOrders.php index 860a312..a049436 100644 --- a/src/Traits/HasOrders.php +++ b/src/Traits/HasOrders.php @@ -1,5 +1,7 @@ paymentProviderIdentities(); @@ -156,7 +158,7 @@ trait HasPaymentMethods * @param string|null $provider * @return bool */ - public function hasPaymentMethods(string $provider = null): bool + public function hasPaymentMethods(?string $provider = null): bool { return $this->paymentMethods($provider)->isNotEmpty(); } diff --git a/src/Traits/HasPrices.php b/src/Traits/HasPrices.php index 7581405..5704a76 100644 --- a/src/Traits/HasPrices.php +++ b/src/Traits/HasPrices.php @@ -1,5 +1,7 @@ $singleProducts Pool-product relation supplied by {@see MayBePoolProduct}; only consulted in pool aggregation paths. */ trait HasStocks { @@ -48,7 +65,7 @@ trait HasStocks public function stocks(): HasMany { return $this->hasMany( - config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'), + config('shop.models.product_stock', ProductStock::class), 'product_id' ); } @@ -59,7 +76,7 @@ trait HasStocks public function allStocks(): HasMany { return $this->hasMany( - config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'), + config('shop.models.product_stock', ProductStock::class), 'product_id' ) ->withExpired() @@ -131,7 +148,7 @@ trait HasStocks * @return bool True if successful * @throws NotEnoughStockException If insufficient stock available */ - public function decreaseStock(int $quantity = 1, Carbon|null $until = null): bool + public function decreaseStock(int $quantity = 1, ?Carbon $until = null): bool { if (!$this->manage_stock) { return true; @@ -215,12 +232,12 @@ trait HasStocks public function adjustStock( StockType $type, int $quantity, - DateTimeInterface|null $until = null, - DateTimeInterface|null $from = null, + ?DateTimeInterface $until = null, + ?DateTimeInterface $from = null, ?StockStatus $status = null, - string|null $note = null, - Model|null $referencable = null - ) { + ?string $note = null, + ?Model $referencable = null + ): bool|\Blax\Shop\Models\ProductStock { if (!$this->manage_stock) { return false; } @@ -298,7 +315,7 @@ trait HasStocks return null; } - $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); + $stockModel = config('shop.models.product_stock', ProductStock::class); return $stockModel::claim( $this, @@ -451,6 +468,9 @@ trait HasStocks * Includes products with: * - Stock management disabled (always in stock), OR * - Stock management enabled AND available stock > 0 + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder */ public function scopeInStock($query) { @@ -470,6 +490,9 @@ trait HasStocks * - Stock management is enabled * - low_stock_threshold is set * - Available stock <= threshold + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder */ public function scopeLowStock($query) { @@ -506,11 +529,11 @@ trait HasStocks * - Checking what's claimed but not released * - Managing active bookings * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder<\Blax\Shop\Models\ProductStock> */ - public function claims() + public function claims(): \Illuminate\Database\Eloquent\Builder { - $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); + $stockModel = config('shop.models.product_stock', ProductStock::class); return $stockModel::claims() ->willExpire() @@ -675,10 +698,10 @@ trait HasStocks * Gets the availability on the day by time. 00:00 shows the availables at the start of the day. * Every other timestamp shows what total current availability is at that time. * - * @param null|DateTimeInterface $date - * @return array|int + * @param null|DateTimeInterface $date + * @return array|int Map of HH:MM → available units, or PHP_INT_MAX when stock management is disabled. */ - public function dayAvailability(?DateTimeInterface $date = null) + public function dayAvailability(?DateTimeInterface $date = null): array|int { // For pool products, aggregate availability from all single items if (method_exists($this, 'isPool') && $this->isPool()) { @@ -794,7 +817,7 @@ trait HasStocks // Get all date keys from first single (they should all have the same dates) if (!empty($singleAvailabilities)) { $firstAvailability = $singleAvailabilities[0]; - foreach ($firstAvailability['dates'] as $dateKey => $dayData) { + foreach (array_keys($firstAvailability['dates']) as $dateKey) { $dayMin = 0; $dayMax = 0; @@ -826,10 +849,10 @@ trait HasStocks /** * Get day availability for pool products by aggregating all single items * - * @param DateTimeInterface|null $date - * @return array + * @param DateTimeInterface|null $date + * @return array|int Map of HH:MM → available units across all single items, or PHP_INT_MAX when no managed single items exist. */ - protected function getPoolDayAvailability(?DateTimeInterface $date = null): array + protected function getPoolDayAvailability(?DateTimeInterface $date = null): array|int { // Eager load single products if not already loaded if (!$this->relationLoaded('singleProducts')) { @@ -897,16 +920,16 @@ trait HasStocks * - The idea is that users can add items freely and adjust dates later * - Date-based validation happens at checkout, not when adding to cart * - * @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from + * @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from * @return int Available quantity (PHP_INT_MAX if unlimited) */ - public function getHasMore($cart = null): int + public function getHasMore(?\Blax\Shop\Models\Cart $cart = null): int { // Try to get current cart from facade if not provided if ($cart === null) { try { $cart = \Blax\Shop\Facades\Cart::current(); - } catch (\Exception $e) { + } catch (\Exception) { // No cart available, that's fine $cart = null; } @@ -943,10 +966,9 @@ trait HasStocks * Returns total pool capacity minus items already in cart. * Does NOT consider date-based availability - that's validated at checkout. * - * @param \Blax\Shop\Models\Cart|null $cart - * @return int + * @param \Blax\Shop\Models\Cart|null $cart */ - protected function getPoolHasMore($cart = null): int + protected function getPoolHasMore(?\Blax\Shop\Models\Cart $cart = null): int { // Get total pool capacity (NOT date-restricted) if (method_exists($this, 'getPoolTotalCapacity')) { @@ -991,13 +1013,12 @@ trait HasStocks * * @param DateTimeInterface $from * @param DateTimeInterface $until - * @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from - * @return int + * @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from */ public function getAvailableForDateRange( DateTimeInterface $from, DateTimeInterface $until, - $cart = null + ?\Blax\Shop\Models\Cart $cart = null ): int { if ($this->manage_stock === false) { return PHP_INT_MAX; diff --git a/src/Traits/HasStripeAccount.php b/src/Traits/HasStripeAccount.php index 897f8ab..4c9cd26 100644 --- a/src/Traits/HasStripeAccount.php +++ b/src/Traits/HasStripeAccount.php @@ -1,5 +1,7 @@ isPool()) { return $this->getPoolMaxQuantity($from, $until); @@ -41,7 +43,7 @@ trait MayBePoolProduct /** * Get the maximum available quantity for a pool product based on single items */ - public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int + public function getPoolMaxQuantity(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null): int { if (!$this->isPool()) { return $this->getAvailableStock(); diff --git a/tests/Feature/Checkout/CartCheckoutSessionTest.php b/tests/Feature/Checkout/CartCheckoutSessionTest.php index ebae9d1..72ec2fc 100644 --- a/tests/Feature/Checkout/CartCheckoutSessionTest.php +++ b/tests/Feature/Checkout/CartCheckoutSessionTest.php @@ -334,6 +334,47 @@ class CartCheckoutSessionTest extends TestCase $this->assertEquals('mock_session_id', $meta->stripe_session_id); } + #[Test] + public function checkout_session_emits_lowercase_currency_codes(): void + { + // Regression: Cart::checkoutSession() used to call + // `strtoupper($this->currency)`, but Stripe expects lowercase ISO + // codes — host apps were sending 'USD' / 'EUR' to Stripe by mistake. + // The implementation now consistently lowercases the resolved + // currency at both the session level and per line item. + config(['shop.stripe.enabled' => true]); + config(['shop.currency' => 'eur']); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create(['name' => 'P', 'manage_stock' => false]); + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'EUR', // intentionally uppercase on the price model + 'is_default' => true, + ]); + $this->cart->addToCart($product, 1); + + $captured = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$captured) { + $captured = $params; + $session = new \stdClass(); + $session->id = 'mock'; + return $session; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/s', + 'cancel_url' => 'https://example.com/c', + ]); + + // Session-level: lowercase regardless of how the cart row spells it. + $this->assertSame('eur', $captured['currency']); + // Line-item: derives from the price model's currency and lowercases it. + $this->assertSame('eur', $captured['line_items'][0]['price_data']['currency']); + } + /** * Mock Stripe Checkout Session creation to avoid actual API calls */ diff --git a/tests/Feature/Loan/CheckOutToTest.php b/tests/Feature/Loan/CheckOutToTest.php new file mode 100644 index 0000000..26d8c5e --- /dev/null +++ b/tests/Feature/Loan/CheckOutToTest.php @@ -0,0 +1,214 @@ +borrower = User::factory()->create(); + $this->book = LoanableBook::create([ + 'name' => 'Hyperion', + 'sku' => '9780553283686', + ]); + $this->book->increaseStock(3); + } + + #[Test] + public function it_creates_a_pending_purchase_decrements_stock_and_dispatches_loan_created(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + Event::fake([LoanCreated::class]); + + $loan = $this->book->checkOutTo($this->borrower); + + $this->assertInstanceOf(ProductPurchase::class, $loan); + $this->assertTrue($loan->exists); + $this->assertSame(PurchaseStatus::PENDING, $loan->status); + $this->assertSame(1, $loan->quantity); + $this->assertSame(0, (int) $loan->amount); + $this->assertSame(0, (int) $loan->amount_paid); + $this->assertSame( + Carbon::parse('2026-05-14 10:00:00')->toDateTimeString(), + $loan->from->toDateTimeString(), + ); + $this->assertSame( + Carbon::parse('2026-05-28 10:00:00')->toDateTimeString(), + $loan->until->toDateTimeString(), + ); + $this->assertSame(0, (int) ((array) $loan->meta)['extensions_used'] ?? 99); + + $this->assertSame(2, $this->book->fresh()->getAvailableStock()); + + Event::assertDispatched( + LoanCreated::class, + fn (LoanCreated $event) => $event->loan->is($loan), + ); + } + + #[Test] + public function it_honours_the_explicit_weeks_argument(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + + $loan = $this->book->checkOutTo($this->borrower, weeks: 4); + + $this->assertSame( + Carbon::parse('2026-06-11 10:00:00')->toDateTimeString(), + $loan->until->toDateTimeString(), + ); + } + + #[Test] + public function it_falls_back_to_the_shop_loan_default_duration_weeks_config(): void + { + config(['shop.loan.default_duration_weeks' => 3]); + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + + $loan = $this->book->checkOutTo($this->borrower); + + $this->assertSame( + Carbon::parse('2026-06-04 10:00:00')->toDateTimeString(), + $loan->until->toDateTimeString(), + ); + } + + #[Test] + public function it_throws_not_enough_stock_when_no_copies_are_available(): void + { + $only = LoanableBook::create(['name' => 'Solitaire', 'sku' => 'S-1']); + $only->increaseStock(1); + + $only->checkOutTo($this->borrower); + + $this->expectException(NotEnoughStockException::class); + $only->checkOutTo(User::factory()->create()); + } + + #[Test] + public function it_is_atomic_no_purchase_row_remains_when_stock_decrement_fails(): void + { + // Stock is 0; decreaseStock throws inside the transaction. The + // wrapping DB::transaction must roll back, leaving no purchase row. + $empty = LoanableBook::create(['name' => 'Out of Print', 'sku' => 'OOP-1']); + + $baseline = ProductPurchase::query() + ->where('purchasable_id', $empty->id) + ->count(); + + try { + $empty->checkOutTo($this->borrower); + $this->fail('checkOutTo should have thrown NotEnoughStockException.'); + } catch (NotEnoughStockException) { + // expected + } + + $this->assertSame( + $baseline, + ProductPurchase::query()->where('purchasable_id', $empty->id)->count(), + 'A failed checkOutTo must not leave a dangling purchase row.', + ); + } + + #[Test] + public function contention_on_a_single_copy_is_resolved_first_caller_wins(): void + { + // Two borrowers race for the only copy. The first call succeeds; the + // second must fail with NotEnoughStockException — the controller's + // job is then to surface that as a friendly validation error. + $single = LoanableBook::create(['name' => 'Singular', 'sku' => 'SNG-1']); + $single->increaseStock(1); + + $alice = User::factory()->create(); + $bob = User::factory()->create(); + + $single->checkOutTo($alice); + + $this->expectException(NotEnoughStockException::class); + $single->checkOutTo($bob); + } + + #[Test] + public function manage_stock_false_serves_unlimited_concurrent_borrowers(): void + { + // manage_stock=false ⇒ getAvailableStock returns PHP_INT_MAX and + // decreaseStock short-circuits, so checkOutTo never blocks. + $infinite = LoanableBook::create([ + 'name' => 'The Infinite Compendium', + 'sku' => 'INF-1', + 'manage_stock' => false, + ]); + + $borrowers = User::factory()->count(5)->create(); + foreach ($borrowers as $borrower) { + $infinite->checkOutTo($borrower); + } + + $this->assertSame( + 5, + ProductPurchase::query()->where('purchasable_id', $infinite->id)->count(), + ); + } + + #[Test] + public function mark_returned_does_not_restore_stock_intentionally(): void + { + // Locking-in regression test for an opinionated design choice: the + // package's markReturned() flips lifecycle state but leaves stock + // alone. Hosts that model loans as borrow-and-return (rather than + // permanent ownership transfer) must follow up with an explicit + // increaseStock(1) — see moonshiner-library's LoanController. + $loan = $this->book->checkOutTo($this->borrower); + $availableAfterCheckout = $this->book->fresh()->getAvailableStock(); + + $loan->markReturned(); + + $this->assertSame( + $availableAfterCheckout, + $this->book->fresh()->getAvailableStock(), + 'markReturned() must not change stock — hosts opt in to that explicitly.', + ); + } +} + +/** + * Minimal loanable fixture: extending Product picks up the package's + * polymorphism, the IsLoanableProduct trait wires up checkOutTo and the + * total_quantity / available_quantity virtuals. Both base and subclass + * resolve to the `products` table via Product::__construct, so no migration + * is needed. + */ +class LoanableBook extends Product +{ + use IsLoanableProduct; + + protected $guarded = []; +} diff --git a/tests/Feature/Loan/LoanEventsTest.php b/tests/Feature/Loan/LoanEventsTest.php index 63d4a8e..ddbee90 100644 --- a/tests/Feature/Loan/LoanEventsTest.php +++ b/tests/Feature/Loan/LoanEventsTest.php @@ -19,9 +19,11 @@ use Workbench\App\Models\User; /** * Loan lifecycle domain events. * - * LoanCreated — host dispatches it explicitly after creating a ProductPurchase - * for a loanable item (the package can't tell loans apart from - * carts / one-off purchases without ambiguity). + * LoanCreated — auto-dispatched from IsLoanableProduct::checkOutTo() + * (see CheckOutToTest). Hosts that build ProductPurchase + * rows directly — bypassing checkOutTo — must dispatch + * the event themselves; that direct-construction path is + * what this file exercises. * LoanExtended — dispatched from HasLoanLifecycle::extend() * LoanReturned — dispatched from HasLoanLifecycle::markReturned() */ @@ -91,11 +93,12 @@ class LoanEventsTest extends TestCase } #[Test] - public function loan_created_is_a_host_dispatched_event(): void + public function loan_created_can_be_dispatched_manually_when_bypassing_checkOutTo(): void { - // The package does NOT auto-dispatch LoanCreated — it can't reliably - // distinguish loans from other ProductPurchase rows. Test that the - // event class exists and can be dispatched by an integrating host. + // The package auto-dispatches LoanCreated from IsLoanableProduct:: + // checkOutTo() (see CheckOutToTest). Hosts that assemble a + // ProductPurchase row directly — e.g. importing historical loans — + // can still raise the same event so downstream listeners fire. Event::fake([LoanCreated::class]); $loan = $this->loan(); diff --git a/tests/Feature/Pool/PoolProductStockTest.php b/tests/Feature/Pool/PoolProductStockTest.php index b8810eb..33a3831 100644 --- a/tests/Feature/Pool/PoolProductStockTest.php +++ b/tests/Feature/Pool/PoolProductStockTest.php @@ -608,4 +608,37 @@ class PoolProductStockTest extends TestCase // Day 16 onwards should be fully available $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(16)->toDateString()]); } + + #[Test] + public function pool_day_availability_returns_int_max_when_no_managed_singles_exist(): void + { + // Regression: getPoolDayAvailability() declared a `: array` return + // type but legitimately returns PHP_INT_MAX when every single item + // has manage_stock=false (the "all unlimited" path). Under that + // declared type PHP fatals with a TypeError. The return type must + // be `array|int` to match the documented behavior. + $pool = Product::factory()->create([ + 'name' => 'Unlimited Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Attach singles that all have manage_stock=false — the pool path + // that triggers the unlimited fast-return. + for ($i = 0; $i < 2; $i++) { + $single = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Must not TypeError. Calling dayAvailability on a pool with only + // unmanaged singles delegates to getPoolDayAvailability. + $result = $pool->dayAvailability(now()); + + $this->assertSame(PHP_INT_MAX, $result); + } } diff --git a/tests/Feature/Product/ProductAttributeTest.php b/tests/Feature/Product/ProductAttributeTest.php index dc0e122..7e9d3ef 100644 --- a/tests/Feature/Product/ProductAttributeTest.php +++ b/tests/Feature/Product/ProductAttributeTest.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Tests\Feature\Product; +use Blax\Shop\Enums\ProductAttributeType; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductAttribute; use Blax\Shop\Tests\TestCase; @@ -209,6 +210,33 @@ class ProductAttributeTest extends TestCase $this->assertEquals('Brand B', $product2->attributes->first()->value); } + #[Test] + public function type_and_meta_round_trip_through_mass_assignment(): void + { + // Regression: `type` and `meta` were missing from $fillable, so + // create([... 'type' => 'color' ...]) silently dropped them and every + // attribute came back as the default 'text' type. The cast on `type` + // now also resolves it to the ProductAttributeType enum on read. + $product = Product::factory()->create(); + + $color = ProductAttribute::create([ + 'product_id' => $product->id, + 'key' => 'Accent color', + 'value' => '#1e293b', + 'type' => ProductAttributeType::COLOR, + 'meta' => ['display_as' => 'swatch'], + ]); + + $fresh = $color->fresh(); + + $this->assertSame(ProductAttributeType::COLOR, $fresh->type); + $this->assertSame(['display_as' => 'swatch'], $fresh->meta); + $this->assertDatabaseHas('product_attributes', [ + 'id' => $fresh->id, + 'type' => ProductAttributeType::COLOR->value, + ]); + } + #[Test] public function attributes_are_hidden_in_api_responses() { diff --git a/tests/Feature/Product/ProductPurchaseTest.php b/tests/Feature/Product/ProductPurchaseTest.php index 80586df..4648166 100644 --- a/tests/Feature/Product/ProductPurchaseTest.php +++ b/tests/Feature/Product/ProductPurchaseTest.php @@ -61,6 +61,64 @@ class ProductPurchaseTest extends TestCase $this->assertEquals($user->id, $purchase->purchaser->id); } + #[Test] + public function user_relation_resolves_to_the_purchaser_when_it_is_a_user(): void + { + // Regression: ProductPurchase::user() previously inspected + // `purchasable_type` (what was sold), which could never equal the + // configured auth model — so user() always returned null. The + // correct column to inspect is `purchaser_type` (who bought). + config()->set('auth.providers.users.model', User::class); + + $user = User::factory()->create(); + $product = Product::factory()->withPrices()->create([ + 'manage_stock' => false, + ]); + + $purchase = ProductPurchase::create([ + 'purchaser_id' => $user->id, + 'purchaser_type' => User::class, + 'purchasable_id' => $product->id, + 'purchasable_type' => get_class($product), + 'quantity' => 1, + 'amount' => 5000, + 'status' => 'unpaid', + ]); + + $resolved = $purchase->user(); + + $this->assertNotNull($resolved, 'user() must resolve when purchaser is a User'); + $this->assertSame($user->id, $resolved->first()?->id); + } + + #[Test] + public function user_relation_returns_null_when_purchaser_is_not_a_user(): void + { + // The previous bug masked this branch: with the column check fixed, + // a purchase made by a non-user model (e.g. a Product purchasing + // another product in a B2B flow) must still return null. + config()->set('auth.providers.users.model', User::class); + + $product = Product::factory()->withPrices()->create([ + 'manage_stock' => false, + ]); + $otherProduct = Product::factory()->withPrices()->create([ + 'manage_stock' => false, + ]); + + $purchase = ProductPurchase::create([ + 'purchaser_id' => $otherProduct->id, + 'purchaser_type' => get_class($otherProduct), + 'purchasable_id' => $product->id, + 'purchasable_type' => get_class($product), + 'quantity' => 1, + 'amount' => 5000, + 'status' => 'unpaid', + ]); + + $this->assertNull($purchase->user()); + } + #[Test] public function purchase_belongs_to_purchasable() { diff --git a/tests/Unit/Order/OrderTest.php b/tests/Unit/Order/OrderTest.php index 125ad40..63a76dc 100644 --- a/tests/Unit/Order/OrderTest.php +++ b/tests/Unit/Order/OrderTest.php @@ -894,4 +894,29 @@ class OrderTest extends TestCase $this->assertCount(1, $paymentsByType); $this->assertCount(1, $statusChangesByType); } + + #[Test] + public function generate_order_number_increments_sequence_when_prior_orders_exist_today(): void + { + // Regression: Order::generateOrderNumber() called str_pad() with an + // int ($lastNumber + 1) — under strict_types this throws a TypeError + // and even under loose mode it was relying on implicit string + // coercion. The function must always return a well-formed string. + $first = Order::generateOrderNumber(); + + // Create an order with that number so the sequence path is exercised. + Order::factory()->create(['order_number' => $first]); + + $second = Order::generateOrderNumber(); + + $prefix = config('shop.orders.number_prefix', 'ORD-'); + $datePart = now()->format('Ymd'); + + $this->assertIsString($second); + $this->assertStringStartsWith("{$prefix}{$datePart}", $second); + // Ends with a 4-digit zero-padded sequence number, strictly greater + // than the first. + $this->assertMatchesRegularExpression('/\d{4}$/', $second); + $this->assertGreaterThan($first, $second); + } }