feat: Enhance traits with strict types and improve method signatures

- Added strict types declaration to multiple traits for better type safety.
- Updated method signatures in traits to use nullable types where applicable.
- Improved documentation for traits, including host-model contracts and method descriptions.
- Added new tests to ensure correct behavior of loan checkout and stock management.
- Fixed regression in order number generation to ensure proper string formatting.
- Ensured that currency codes sent to Stripe are consistently lowercased.
This commit is contained in:
Fabian @ Blax Software 2026-05-15 20:26:24 +02:00
parent 7415da3531
commit afdcd8bc75
109 changed files with 1228 additions and 106 deletions

View File

@ -18,7 +18,7 @@
} }
}, },
"require": { "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/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^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|*", "blax-software/laravel-workkit": "dev-master|*",

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductStock; use Blax\Shop\Models\ProductStock;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductStatus;
@ -673,7 +675,7 @@ class ShopAddExampleProducts extends Command
$parking = Product::create([ $parking = Product::create([
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName), 'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName),
'name' => $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, 'type' => ProductType::BOOKING,
'status' => ProductStatus::PUBLISHED, 'status' => ProductStatus::PUBLISHED,
'is_visible' => false, 'is_visible' => false,

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Blax\Shop\Facades\Shop; use Blax\Shop\Facades\Shop;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction; use Blax\Shop\Models\ProductAction;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction; use Blax\Shop\Models\ProductAction;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands; namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction; use Blax\Shop\Models\ProductAction;

View File

@ -1,7 +1,30 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Contracts; namespace Blax\Shop\Contracts;
/**
* Marker contract for anything that can be added to a {@see \Blax\Shop\Models\Cart}.
*
* The contract is intentionally empty implementing `Cartable` declares
* intent. {@see \Blax\Shop\Models\Cart::addToCart()} checks
* `$item instanceof Cartable` and rejects models that haven't opted in,
* to prevent accidentally cart-ing rows that have no domain meaning as a
* purchase line (a `User` or `Address`, say).
*
* Implementors typically also implement {@see Purchasable} so the cart
* can resolve a price, but the two are independent a `ProductPrice`
* row is `Cartable` only (the {@see Purchasable} half lives on the
* parent `Product`).
*
* Pair this with {@see \Blax\Shop\Traits\IsSimplePurchasable} on a plain
* Eloquent model for a no-subclass integration, or extend
* {@see \Blax\Shop\Models\Product} for the full e-commerce surface.
*
* @see \Blax\Shop\Models\Cart::addToCart()
* @see \Blax\Shop\Exceptions\CartableInterfaceException Thrown when a non-Cartable model is passed to the cart.
*/
interface Cartable interface Cartable
{ {
} }

View File

@ -1,10 +1,48 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Contracts; namespace Blax\Shop\Contracts;
/**
* Contract for billable parties (typically the customer / authenticated user)
* that own one or more stored payment methods on a payment provider.
*
* A `Chargable` actor is the *who* in "who pays for this {@see Purchasable}".
* Orders and recurring charges resolve the actor's preferred provider +
* method through this contract before handing off to the payment service.
*
* Reference implementation: pair {@see \Blax\Shop\Traits\HasPaymentMethods}
* onto a `User` model the trait satisfies both contract methods (the
* Collection it returns degrades to an array via Eloquent's serialization,
* which is what the array return type on this interface captures).
*
* Note: this contract is deliberately small. Anything beyond "which method
* do I bill against" lives on {@see \Blax\Shop\Models\PaymentMethod} or
* the provider service ({@see \Blax\Shop\Services\PaymentProvider\PaymentProviderService}).
*/
interface Chargable interface Chargable
{ {
/**
* Return the provider key of the actor's default payment method
* (e.g. `'stripe'`), or `null` when none is configured yet.
*
* The order pipeline uses this to pick the right provider service
* when no method is supplied explicitly at checkout.
*/
public function getDefaultPaymentMethod(): ?string; public function getDefaultPaymentMethod(): ?string;
/**
* Return the actor's payment methods.
*
* Implementations may return an indexed array of method
* descriptors or any iterable that array-casts cleanly the
* canonical implementation on {@see \Blax\Shop\Traits\HasPaymentMethods}
* returns an {@see \Illuminate\Support\Collection} of
* {@see \Blax\Shop\Models\PaymentMethod}, which satisfies this
* contract via Collection-to-array coercion.
*
* @return array<int, mixed>|\Illuminate\Support\Collection
*/
public function paymentMethods(): array; public function paymentMethods(): array;
} }

View File

@ -1,19 +1,96 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Contracts; namespace Blax\Shop\Contracts;
/**
* Contract for models that can be priced, stocked, and recorded as a purchase.
*
* Where {@see Cartable} is a pure marker, `Purchasable` is the *behavioural*
* surface the cart, checkout, and order machinery rely on:
*
* - **Pricing** {@see self::getCurrentPrice()} and
* {@see self::getPriceAttribute()} resolve the unit price the cart
* will charge. Sale-aware implementations toggle on
* {@see self::isOnSale()}.
* - **Inventory** {@see self::decreaseStock()} runs when a unit is
* consumed (cart checkout, loan checkout); {@see self::increaseStock()}
* when one is returned. Both return `bool` so the caller can detect
* a sold-out condition without an exception.
* - **Audit trail** {@see self::purchases()} exposes the historic
* record of consumption events as a polymorphic relation against
* {@see \Blax\Shop\Models\ProductPurchase}.
*
* Two reference implementations ship in the package:
*
* - {@see \Blax\Shop\Models\Product} the full e-commerce model.
* - {@see \Blax\Shop\Traits\IsSimplePurchasable} drop-in trait for
* host models that want the contract without subclassing `Product`.
*
* @see \Blax\Shop\Models\ProductPurchase
* @see \Blax\Shop\Traits\HasStocks Reference inventory implementation.
*/
interface Purchasable interface Purchasable
{ {
/**
* Resolve the unit price this item should currently be charged at,
* in the package's monetary unit (integer cents floated for math).
*
* Return `null` when no price is configured; callers (cart, order
* total) treat `null` as "free" or as an error depending on context.
*/
public function getCurrentPrice(): ?float; public function getCurrentPrice(): ?float;
/**
* Eloquent attribute accessor for `$model->price` the same value
* {@see self::getCurrentPrice()} resolves, exposed for convenience
* in Blade / JSON serialization.
*/
public function getPriceAttribute(): ?float; 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; 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; 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; 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(); public function purchases();
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum BillingScheme: string enum BillingScheme: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum CartStatus: string enum CartStatus: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
/** /**

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum PriceType: string enum PriceType: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum PricingStrategy: string enum PricingStrategy: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum ProductAttributeType: string enum ProductAttributeType: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum ProductRelationType: string enum ProductRelationType: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum ProductStatus: string enum ProductStatus: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum ProductType: string enum ProductType: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum PurchaseStatus: string enum PurchaseStatus: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
enum RecurringInterval: string enum RecurringInterval: string

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
/** /**

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
/** /**

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Events; namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Events; namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Events; namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;

View File

@ -1,11 +1,21 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Events; namespace Blax\Shop\Events;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* Dispatched automatically by {@see Product} via the model's
* `$dispatchesEvents` map after a new product row is inserted.
*
* Listeners typically use this for downstream sync (push to Stripe, build a
* search index entry, warm a cache). The model is passed already persisted
* listeners can read `$event->product->id`.
*/
class ProductCreated class ProductCreated
{ {
use Dispatchable, SerializesModels; use Dispatchable, SerializesModels;

View File

@ -1,11 +1,22 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Events; namespace Blax\Shop\Events;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* Dispatched automatically by {@see Product} via the model's
* `$dispatchesEvents` map whenever an existing product row is saved
* (after the `updated` model event fires).
*
* Pairs with {@see ProductCreated} for any sink that needs to react to
* the full product write surface search reindex, cache invalidation,
* Stripe price sync, etc.
*/
class ProductUpdated class ProductUpdated
{ {
use Dispatchable, SerializesModels; use Dispatchable, SerializesModels;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
class HasNoDefaultPriceException extends NotPurchasable class HasNoDefaultPriceException extends NotPurchasable

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
class HasNoPriceException extends NotPurchasable class HasNoPriceException extends NotPurchasable

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
class InvalidBookingConfigurationException extends NotPurchasable class InvalidBookingConfigurationException extends NotPurchasable

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
class InvalidPoolConfigurationException extends NotPurchasable class InvalidPoolConfigurationException extends NotPurchasable

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,7 +1,28 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;
class MultiplePurchaseOptions extends Exception {} /**
* Thrown when a single purchase path cannot be auto-selected because the
* product exposes multiple options (e.g. several variants, several
* default prices, several configurations) and the caller did not specify
* which one to use.
*
* Typical resolution at the call site: surface the available options to
* the user / API consumer and re-issue the request with an explicit
* choice, rather than letting the package guess.
*/
class MultiplePurchaseOptions extends Exception
{
public function __construct(
string $message = 'Multiple purchase options are available — caller must pick one explicitly.',
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,7 +1,31 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;
class NotEnoughStockException extends Exception {} /**
* Thrown when a stock-consuming operation cannot proceed because the
* available quantity is below the requested amount.
*
* Raised from {@see \Blax\Shop\Traits\HasStocks::decreaseStock()},
* {@see \Blax\Shop\Traits\HasStocks::adjustStock()}, and
* {@see \Blax\Shop\Models\ProductStock::claim()}.
*
* The message defaults to a generic phrase so the exception can be raised
* with no arguments from helper paths; pass a richer message at the call
* site when the calling context can describe the product, requested
* quantity, or surrounding operation.
*/
class NotEnoughStockException extends Exception
{
public function __construct(
string $message = 'Not enough stock available for the requested operation.',
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,7 +1,34 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;
class NotPurchasable extends Exception {} /**
* Base exception for any condition that prevents an item from being
* legally added to a cart or charged.
*
* Subclasses describe the specific failure mode while sharing a single
* catchable parent callers that just want to translate any
* "not-purchasable" outcome to a 422 response can `catch (NotPurchasable
* $e)` without enumerating every concrete case.
*
* Concrete subclasses shipped by the package:
*
* - {@see HasNoPriceException} no price record exists.
* - {@see HasNoDefaultPriceException} prices exist but none is marked default.
* - {@see InvalidBookingConfigurationException} booking product is misconfigured.
* - {@see InvalidPoolConfigurationException} pool product is misconfigured.
*/
class NotPurchasable extends Exception
{
public function __construct(
string $message = 'This item cannot be purchased in its current state.',
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions; namespace Blax\Shop\Exceptions;
use Exception; use Exception;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Facades; namespace Blax\Shop\Facades;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Facades; namespace Blax\Shop\Facades;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Http\Controllers\Api; namespace Blax\Shop\Http\Controllers\Api;
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Http\Controllers\Api; namespace Blax\Shop\Http\Controllers\Api;
use Blax\Shop\Models\PaymentMethod; use Blax\Shop\Models\PaymentMethod;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Http\Controllers\Api; namespace Blax\Shop\Http\Controllers\Api;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Http\Controllers; namespace Blax\Shop\Http\Controllers;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Http\Controllers; namespace Blax\Shop\Http\Controllers;
use Blax\Shop\Enums\CartStatus; use Blax\Shop\Enums\CartStatus;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Http\Resources; namespace Blax\Shop\Http\Resources;
/** /**

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Http\Resources; namespace Blax\Shop\Http\Resources;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable; use Blax\Shop\Contracts\Cartable;
@ -946,7 +948,7 @@ class Cart extends Model
->where('customer_type', $userModel); ->where('customer_type', $userModel);
} }
public static function scopeUnpaid($query) public function scopeUnpaid($query)
{ {
return $query->whereDoesntHave('purchases', function ($q) { return $query->whereDoesntHave('purchases', function ($q) {
$q->whereColumn('total_amount', '!=', 'amount_paid'); $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 // Price is already stored in cents, Stripe expects smallest currency unit
$unitAmountCents = (int) $item->price; $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 // Build line item using price_data for dynamic pricing
$lineItem = [ $lineItem = [
'price_data' => [ 'price_data' => [
'currency' => $item->price->currency ?? strtoupper($this->currency), 'currency' => $lineCurrency,
'product_data' => [ 'product_data' => [
'name' => $productName, 'name' => $productName,
...($description ? ['description' => $description] : []), ...($description ? ['description' => $description] : []),
@ -2064,7 +2076,7 @@ class Cart extends Model
// Prepare session parameters // Prepare session parameters
$sessionParams = [ $sessionParams = [
'payment_method_types' => ['card'], 'payment_method_types' => ['card'],
'currency' => strtoupper($this->currency), 'currency' => strtolower($this->currency ?? config('shop.currency', 'usd')),
'line_items' => $lineItems, 'line_items' => $lineItems,
'mode' => 'payment', 'mode' => 'payment',
'success_url' => $success_url, 'success_url' => $success_url,

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Exceptions\InvalidDateRangeException; use Blax\Shop\Exceptions\InvalidDateRangeException;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\OrderStatus;
@ -179,7 +181,8 @@ class Order extends Model
if ($lastOrder) { if ($lastOrder) {
// Extract the sequence number and increment // Extract the sequence number and increment
$lastNumber = (int) substr($lastOrder->order_number, strlen("{$prefix}{$date}")); $lastNumber = (int) substr($lastOrder->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 { } else {
$sequence = '0001'; $sequence = '0001';
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Database\Factories\PaymentMethodFactory; use Blax\Shop\Database\Factories\PaymentMethodFactory;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Database\Factories\PaymentProviderIdentityFactory; use Blax\Shop\Database\Factories\PaymentProviderIdentityFactory;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable; use Blax\Shop\Contracts\Cartable;
@ -24,13 +26,65 @@ use Blax\Shop\Traits\HasPricingStrategy;
use Blax\Shop\Traits\HasProductRelations; use Blax\Shop\Traits\HasProductRelations;
use Blax\Shop\Traits\HasStocks; use Blax\Shop\Traits\HasStocks;
use Blax\Shop\Traits\MayBePoolProduct; use Blax\Shop\Traits\MayBePoolProduct;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
/**
* Core product entity sellable, stockable, priceable.
*
* Product is the polymorphic root for the whole package. Specialized
* behaviour comes from composed traits:
*
* - {@see HasStocks} inventory and reservations.
* - {@see HasPrices} price list and default-price resolution.
* - {@see HasPricingStrategy} average / lowest / highest tier strategy.
* - {@see HasCategories} category attachment.
* - {@see HasProductRelations} cross-sells, upsells, pool↔single links.
* - {@see MayBePoolProduct} pool aggregation when `type = POOL`.
* - {@see ChecksIfBooking} booking-product helpers when `type = BOOKING`.
*
* Host apps can extend this model (`Book extends Product` style) for free
* every relation declares its FK explicitly so subclasses don't break.
*
* @property string $id
* @property string $slug
* @property string|null $sku
* @property \Blax\Shop\Enums\ProductType $type
* @property string|null $stripe_product_id
* @property \Illuminate\Support\Carbon|null $sale_start
* @property \Illuminate\Support\Carbon|null $sale_end
* @property bool $manage_stock
* @property int|null $low_stock_threshold
* @property float|null $weight
* @property float|null $length
* @property float|null $width
* @property float|null $height
* @property bool $virtual
* @property bool $downloadable
* @property string|null $parent_id
* @property bool $featured
* @property bool $is_visible
* @property \Blax\Shop\Enums\ProductStatus $status
* @property \Illuminate\Support\Carbon|null $published_at
* @property \stdClass $meta
* @property string|null $tax_class
* @property int $sort_order
* @property string $name
* @property string|null $description
* @property string|null $short_description
*
* @property-read Product|null $parent
* @property-read \Illuminate\Database\Eloquent\Collection<int, Product> $children
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductAttribute> $attributes
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductAction> $actions
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductPurchase> $purchases
*/
class Product extends Model implements Purchasable, Cartable class Product extends Model implements Purchasable, Cartable
{ {
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, ChecksIfBooking; 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<static, $this>
*/
public function parent(): BelongsTo
{ {
return $this->belongsTo(static::class, 'parent_id'); 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<static, $this>
*/
public function children(): HasMany public function children(): HasMany
{ {
return $this->hasMany(static::class, 'parent_id'); 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 // Explicit FK so the relation still targets `product_id` when a host
// app subclasses Product (e.g. `Book extends Product`). // app subclasses Product (e.g. `Book extends Product`).
return $this->hasMany( return $this->hasMany(
config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'), config('shop.models.product_attribute', ProductAttribute::class),
'product_id' 'product_id'
); );
} }
@ -177,12 +243,20 @@ class Product extends Model implements Purchasable, Cartable
); );
} }
public function scopePublished($query) /**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopePublished(Builder $query): Builder
{ {
return $query->where('status', ProductStatus::PUBLISHED->value); return $query->where('status', ProductStatus::PUBLISHED->value);
} }
public function scopeFeatured($query) /**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeFeatured(Builder $query): Builder
{ {
return $query->where('featured', true); return $query->where('featured', true);
} }
@ -364,7 +438,14 @@ class Product extends Model implements Purchasable, Cartable
return ProductAction::getAvailableActions(); 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<string, mixed> $additionalData Free-form payload merged into the action context.
*/
public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = []): mixed
{ {
return ProductAction::callForProduct( return ProductAction::callForProduct(
$this, $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<static> $query
* @return Builder<static>
*/
public function scopeVisible(Builder $query): Builder
{ {
return $query->where('is_visible', true) return $query->where('is_visible', true)
->where('status', ProductStatus::PUBLISHED->value) ->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<static> $query
* @return Builder<static>
*/
public function scopeSearch(Builder $query, string $search): Builder
{ {
return $query->where(function ($q) use ($search) { return $query->where(function ($q) use ($search) {
$q->where('slug', 'like', "%{$search}%") $q->where('slug', 'like', "%{$search}%")
@ -544,8 +640,11 @@ class Product extends Model implements Purchasable, Cartable
/** /**
* Scope for booking products * Scope for booking products
*
* @param Builder<static> $query
* @return Builder<static>
*/ */
public function scopeBookings($query) public function scopeBookings(Builder $query): Builder
{ {
return $query->where('type', ProductType::BOOKING->value); return $query->where('type', ProductType::BOOKING->value);
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@ -1,7 +1,10 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Enums\ProductAttributeType;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -15,11 +18,15 @@ class ProductAttribute extends Model
'product_id', 'product_id',
'key', 'key',
'value', 'value',
'type',
'sort_order', 'sort_order',
'meta',
]; ];
protected $casts = [ protected $casts = [
'sort_order' => 'integer', 'sort_order' => 'integer',
'type' => ProductAttributeType::class,
'meta' => 'array',
]; ];
protected $hidden = [ protected $hidden = [

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable; use Blax\Shop\Contracts\Cartable;
@ -8,11 +10,42 @@ use Blax\Shop\Enums\BillingScheme;
use Blax\Shop\Enums\PriceType; use Blax\Shop\Enums\PriceType;
use Blax\Shop\Enums\RecurringInterval; use Blax\Shop\Enums\RecurringInterval;
use Blax\Workkit\Traits\HasMetaTranslation; use Blax\Workkit\Traits\HasMetaTranslation;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* A price record attached to a {@see Purchasable} (usually a {@see Product},
* but anything can carry prices via the polymorphic `purchasable_*` columns).
*
* A single purchasable can carry several prices one default, plus
* alternative tiers (bulk, region, customer-segment). Pricing follows the
* package-wide rule: amounts are integer cents, currency lives in a
* separate `currency` column, never inferred from the amount.
*
* @property string $id
* @property string $purchasable_type
* @property string $purchasable_id
* @property string|null $stripe_price_id
* @property string|null $name
* @property \Blax\Shop\Enums\PriceType $type
* @property string $currency ISO 4217.
* @property float $unit_amount Per-unit price in the smallest currency unit (cents). Cast to float for math.
* @property float|null $sale_unit_amount Sale price; defaults to `unit_amount` when unset.
* @property bool $is_default
* @property bool $active
* @property \Blax\Shop\Enums\BillingScheme $billing_scheme
* @property \Blax\Shop\Enums\RecurringInterval|null $interval
* @property int|null $interval_count
* @property int|null $trial_period_days
* @property \stdClass $meta
*
* @property-read Model $purchasable
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductPriceTier> $tiers
*/
class ProductPrice extends Model implements Cartable class ProductPrice extends Model implements Cartable
{ {
use HasFactory, HasUuids, HasMetaTranslation; use HasFactory, HasUuids, HasMetaTranslation;
@ -48,17 +81,34 @@ class ProductPrice extends Model implements Cartable
'trial_period_days' => 'integer', 'trial_period_days' => 'integer',
]; ];
public function purchasable() /**
* The {@see Purchasable} this price belongs to (usually a {@see Product}).
*
* @return MorphTo<Model, $this>
*/
public function purchasable(): MorphTo
{ {
return $this->morphTo(); return $this->morphTo();
} }
public function scopeIsActive($query) /**
* Filter to only currently-active prices (default scope alternative).
*
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeIsActive(Builder $query): Builder
{ {
return $query->where('active', true); 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) { if ($sale_price) {
return $this->sale_unit_amount ?? $this->unit_amount; return $this->sale_unit_amount ?? $this->unit_amount;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Database\Factories\ProductPriceTierFactory; use Blax\Shop\Database\Factories\ProductPriceTierFactory;

View File

@ -1,13 +1,58 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Traits\HasBookingLifecycle; use Blax\Shop\Traits\HasBookingLifecycle;
use Blax\Shop\Traits\HasLoanLifecycle; use Blax\Shop\Traits\HasLoanLifecycle;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Persisted record of "this purchasable was sold/loaned/booked to this purchaser".
*
* Single source of truth for any consumption event in the package:
*
* - **E-commerce purchase**: status flows `CART → PENDING → COMPLETED`,
* `from`/`until` left null.
* - **Booking**: status `COMPLETED`, `from`/`until` carry the reserved window
* (see {@see HasBookingLifecycle}).
* - **Loan**: status `PENDING` until returned, then `COMPLETED`; `from`/`until`
* are check-out and due dates (see {@see HasLoanLifecycle}).
*
* The polymorphic `purchasable_*` columns point at what was sold (typically a
* {@see Product} but can be any {@see \Blax\Shop\Contracts\Purchasable}). The
* polymorphic `purchaser_*` columns point at who bought it (typically a User,
* but any model).
*
* @property string $id
* @property \Blax\Shop\Enums\PurchaseStatus $status
* @property string|null $cart_id
* @property string|null $price_id
* @property string $purchasable_id
* @property string $purchasable_type
* @property string $purchaser_id
* @property string $purchaser_type
* @property int $quantity
* @property int $amount Total amount charged, in cents.
* @property int $amount_paid Amount captured so far, in cents.
* @property string|null $charge_id
* @property \Illuminate\Support\Carbon|null $from Booking start / loan check-out.
* @property \Illuminate\Support\Carbon|null $until Booking end / loan due date.
* @property \stdClass $meta
*
* @property-read Model $purchasable
* @property-read Model $purchaser
* @property-read Product|null $product
* @property-read ProductPrice|null $price
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductActionRun> $actionRuns
*/
class ProductPurchase extends Model class ProductPurchase extends Model
{ {
use HasBookingLifecycle, HasLoanLifecycle, HasUuids; use HasBookingLifecycle, HasLoanLifecycle, HasUuids;
@ -45,25 +90,44 @@ class ProductPurchase extends Model
$this->setTable(config('shop.tables.product_purchases', 'product_purchases')); $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<Model, $this>
*/
public function purchasable(): MorphTo
{ {
return $this->morphTo('purchasable'); return $this->morphTo('purchasable');
} }
public function purchaser() /**
* Who made the purchase (typically a User), polymorphic.
*
* @return MorphTo<Model, $this>
*/
public function purchaser(): MorphTo
{ {
return $this->morphTo('purchaser'); 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<Product, $this>
*/
public function product(): BelongsTo
{ {
return $this->belongsTo(config('shop.models.product', Product::class)); return $this->belongsTo(config('shop.models.product', Product::class));
} }
/** /**
* The price this purchase bills against (see HasLoanLifecycle::calculateCost). * The price this purchase bills against (see HasLoanLifecycle::calculateCost).
*
* @return BelongsTo<ProductPrice, $this>
*/ */
public function price() public function price(): BelongsTo
{ {
return $this->belongsTo( return $this->belongsTo(
config('shop.models.product_price', ProductPrice::class), 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)) { if ($this->purchaser_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) {
return $this->purchasable(); return $this->purchaser();
} }
return null; return null;
} }
public static function scopeFromCart($query, $cartId) /**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeFromCart(Builder $query, string $cartId): Builder
{ {
return $query->where('cart_id', $cartId); return $query->where('cart_id', $cartId);
} }
public static function scopeInCart($query) /**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeInCart(Builder $query): Builder
{ {
return $query->where('status', PurchaseStatus::CART->value); return $query->where('status', PurchaseStatus::CART->value);
} }
public static function scopeCompleted($query) /**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeCompleted(Builder $query): Builder
{ {
return $query->where('status', PurchaseStatus::COMPLETED->value); 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<ProductActionRun, ProductAction, $this>
*/
public function actionRuns(): HasManyThrough
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
ProductActionRun::class, ProductActionRun::class,

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
@ -312,7 +314,7 @@ class ProductStock extends Model
* Scope: Get completed/available stock entries * Scope: Get completed/available stock entries
* These are physical stock changes (INCREASE/DECREASE) that have been finalized * These are physical stock changes (INCREASE/DECREASE) that have been finalized
*/ */
public static function scopeAvailable($query) public function scopeAvailable($query)
{ {
return $query->where('status', StockStatus::COMPLETED->value); return $query->where('status', StockStatus::COMPLETED->value);
} }
@ -321,7 +323,7 @@ class ProductStock extends Model
* Scope: Get active (pending) claimed stock entries * Scope: Get active (pending) claimed stock entries
* These represent stock currently claimed but not yet released * 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); 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 * @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) return $query->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Services; namespace Blax\Shop\Services;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -14,7 +14,7 @@ class PaymentProviderService
{ {
protected StripeService $stripeService; protected StripeService $stripeService;
public function __construct(StripeService $stripeService = null) public function __construct(?StripeService $stripeService = null)
{ {
$this->stripeService = $stripeService ?? app(StripeService::class); $this->stripeService = $stripeService ?? app(StripeService::class);
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Services; namespace Blax\Shop\Services;
use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\OrderStatus;
@ -11,7 +13,7 @@ use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon; use Carbon\Carbon;
class ShopService class ShopService
{ {

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Services; namespace Blax\Shop\Services;
use Blax\Shop\Exceptions\HasNoDefaultPriceException; use Blax\Shop\Exceptions\HasNoDefaultPriceException;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop; namespace Blax\Shop;
use Blax\Shop\Console\Commands\ShopCleanupCartsCommand; use Blax\Shop\Console\Commands\ShopCleanupCartsCommand;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,7 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Illuminate\Database\Eloquent\Builder;
/** /**
* Booking lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row. * Booking lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row.
* *
@ -9,6 +13,11 @@ namespace Blax\Shop\Traits;
* time-bounded reservation. The trait is purchase-side the corresponding * time-bounded reservation. The trait is purchase-side the corresponding
* product-side concept is the BOOKING product type plus * product-side concept is the BOOKING product type plus
* {@see ChecksIfBooking}. * {@see ChecksIfBooking}.
*
* # Host-model contract
*
* @property \Illuminate\Support\Carbon|null $from Reservation window start.
* @property \Illuminate\Support\Carbon|null $until Reservation window end.
*/ */
trait HasBookingLifecycle trait HasBookingLifecycle
{ {
@ -34,16 +43,22 @@ trait HasBookingLifecycle
/** /**
* Scope to date-bounded bookings only. * Scope to date-bounded bookings only.
*
* @param Builder<static> $query
* @return Builder<static>
*/ */
public function scopeBookings($query) public function scopeBookings(Builder $query): Builder
{ {
return $query->whereNotNull('from')->whereNotNull('until'); return $query->whereNotNull('from')->whereNotNull('until');
} }
/** /**
* Scope to bookings whose window is in the past. * Scope to bookings whose window is in the past.
*
* @param Builder<static> $query
* @return Builder<static>
*/ */
public function scopeEndedBookings($query) public function scopeEndedBookings(Builder $query): Builder
{ {
return $query->bookings()->where('until', '<', now()); return $query->bookings()->where('until', '<', now());
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Carbon\Carbon; use Carbon\Carbon;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\MultiplePurchaseOptions; use Blax\Shop\Exceptions\MultiplePurchaseOptions;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;

View File

@ -1,12 +1,16 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Events\LoanExtended; use Blax\Shop\Events\LoanExtended;
use Blax\Shop\Events\LoanReturned; use Blax\Shop\Events\LoanReturned;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
use Illuminate\Support\Carbon; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
/** /**
* Loan / rental lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row. * Loan / rental lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row.
@ -27,6 +31,19 @@ use Illuminate\Support\Carbon;
* *
* The product-side counterpart is {@see IsLoanableProduct}, which exposes a * The product-side counterpart is {@see IsLoanableProduct}, which exposes a
* `loan()` helper to create a purchase row pre-filled for this lifecycle. * `loan()` helper to create a purchase row pre-filled for this lifecycle.
*
* # Host-model contract
*
* Designed for {@see \Blax\Shop\Models\ProductPurchase}; expects these
* columns and accessors on the host:
*
* @property \Illuminate\Support\Carbon|null $from Loan check-out timestamp.
* @property \Illuminate\Support\Carbon|null $until Loan due timestamp (mutated by {@see self::extend()}).
* @property array<string, mixed>|\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 trait HasLoanLifecycle
{ {
@ -154,8 +171,11 @@ trait HasLoanLifecycle
/** /**
* Scope: loans currently in the borrower's hands (not returned). * Scope: loans currently in the borrower's hands (not returned).
*
* @param Builder<static> $query
* @return Builder<static>
*/ */
public function scopeActiveLoans($query) public function scopeActiveLoans(Builder $query): Builder
{ {
return $query return $query
->where('status', PurchaseStatus::PENDING->value) ->where('status', PurchaseStatus::PENDING->value)
@ -164,16 +184,22 @@ trait HasLoanLifecycle
/** /**
* Scope: loans that have been handed back. * Scope: loans that have been handed back.
*
* @param Builder<static> $query
* @return Builder<static>
*/ */
public function scopeReturned($query) public function scopeReturned(Builder $query): Builder
{ {
return $query->whereNotNull('meta->returned_at'); return $query->whereNotNull('meta->returned_at');
} }
/** /**
* Scope: loans past their due date and not yet returned. * Scope: loans past their due date and not yet returned.
*
* @param Builder<static> $query
* @return Builder<static>
*/ */
public function scopeOverdue($query) public function scopeOverdue(Builder $query): Builder
{ {
return $query->activeLoans()->where('until', '<', now()); return $query->activeLoans()->where('until', '<', now());
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\OrderStatus;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Models\PaymentMethod; use Blax\Shop\Models\PaymentMethod;
@ -69,7 +71,7 @@ trait HasPaymentMethods
* @param bool $activeOnly Only return active payment methods * @param bool $activeOnly Only return active payment methods
* @return Collection * @return Collection
*/ */
public function paymentMethods(string $provider = null, bool $activeOnly = true): Collection public function paymentMethods(?string $provider = null, bool $activeOnly = true): Collection
{ {
$identities = $this->paymentProviderIdentities(); $identities = $this->paymentProviderIdentities();
@ -156,7 +158,7 @@ trait HasPaymentMethods
* @param string|null $provider * @param string|null $provider
* @return bool * @return bool
*/ */
public function hasPaymentMethods(string $provider = null): bool public function hasPaymentMethods(?string $provider = null): bool
{ {
return $this->paymentMethods($provider)->isNotEmpty(); return $this->paymentMethods($provider)->isNotEmpty();
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Enums\PricingStrategy; use Blax\Shop\Enums\PricingStrategy;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Contracts\Purchasable; use Blax\Shop\Contracts\Purchasable;

View File

@ -1,10 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\ProductStock;
use Carbon\Carbon; use Carbon\Carbon;
use DateTimeInterface; use DateTimeInterface;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -12,29 +15,43 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
* HasStocks Trait * HasStocks stock management surface for Product-shaped models.
* *
* Provides stock management functionality to Product models. * Provides:
* * - Basic stock operations: {@see self::increaseStock()}, {@see self::decreaseStock()},
* Key Features: * {@see self::adjustStock()}.
* - Basic stock operations (increase, decrease, adjust) * - Reservation / booking claims via {@see self::claimStock()}.
* - Stock claims for bookings/reservations * - Date-based availability: {@see self::availableOnDate()},
* - Date-based availability checking * {@see self::getAvailableForDateRange()}, {@see self::calendarAvailability()}.
* - Low stock detection * - Low-stock detection: {@see self::isLowStock()} and the
* - Stock movement logging * {@see self::scopeLowStock()} query scope.
* * - Audit log writes via {@see self::logStockChange()} `product_stock_logs`.
* Usage: *
* - Add 'manage_stock' boolean column to products table * # Stock calculation
* - Set manage_stock = true to enable stock tracking *
* - Use increaseStock/decreaseStock for inventory changes * - **Physical stock**: sum of all COMPLETED entries (positive INCREASE +
* - Use claimStock for reservations/bookings * RETURN, negative DECREASE) that haven't expired.
* - Use availableOnDate for date-based availability * - **Available stock**: physical stock, with active CLAIMED entries netted
* * out (their DECREASE side reduces availability while the PENDING claim
* Stock Calculation: * sits open).
* - Physical Stock = Sum of all COMPLETED entries * - **Claimed stock**: sum of PENDING `CLAIMED` entries (returned as a
* - Available Stock = Physical Stock (accounts for pending claims via their DECREASE entries) * positive number by the getters).
* - Claimed Stock = Sum of PENDING claims * - **Available on a given date**: physical stock at that date, minus claims
* - Available on Date = Available Stock + All Claims - Claims Active on Date * whose window covers that date.
*
* # Host-model contract
*
* The trait is designed to be applied to {@see \Blax\Shop\Models\Product} and
* its subclasses. It reads the columns declared below and the
* `singleProducts` relation supplied by {@see MayBePoolProduct} (when the
* host opts into pool support). Host models that don't manage stock should
* set `manage_stock = false`; in that mode every read returns
* `PHP_INT_MAX` and every mutation is a no-op returning `true`.
*
* @property string|int $id Primary key on the host model used for cross-table FK writes.
* @property bool $manage_stock When `false`, stock methods short-circuit (treated as infinite supply).
* @property int|null $low_stock_threshold Threshold for {@see self::isLowStock()} / {@see self::scopeLowStock()}; null disables low-stock detection.
* @property \Illuminate\Database\Eloquent\Collection<int, \Blax\Shop\Models\Product> $singleProducts Pool-product relation supplied by {@see MayBePoolProduct}; only consulted in pool aggregation paths.
*/ */
trait HasStocks trait HasStocks
{ {
@ -48,7 +65,7 @@ trait HasStocks
public function stocks(): HasMany public function stocks(): HasMany
{ {
return $this->hasMany( return $this->hasMany(
config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'), config('shop.models.product_stock', ProductStock::class),
'product_id' 'product_id'
); );
} }
@ -59,7 +76,7 @@ trait HasStocks
public function allStocks(): HasMany public function allStocks(): HasMany
{ {
return $this->hasMany( return $this->hasMany(
config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'), config('shop.models.product_stock', ProductStock::class),
'product_id' 'product_id'
) )
->withExpired() ->withExpired()
@ -131,7 +148,7 @@ trait HasStocks
* @return bool True if successful * @return bool True if successful
* @throws NotEnoughStockException If insufficient stock available * @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) { if (!$this->manage_stock) {
return true; return true;
@ -215,12 +232,12 @@ trait HasStocks
public function adjustStock( public function adjustStock(
StockType $type, StockType $type,
int $quantity, int $quantity,
DateTimeInterface|null $until = null, ?DateTimeInterface $until = null,
DateTimeInterface|null $from = null, ?DateTimeInterface $from = null,
?StockStatus $status = null, ?StockStatus $status = null,
string|null $note = null, ?string $note = null,
Model|null $referencable = null ?Model $referencable = null
) { ): bool|\Blax\Shop\Models\ProductStock {
if (!$this->manage_stock) { if (!$this->manage_stock) {
return false; return false;
} }
@ -298,7 +315,7 @@ trait HasStocks
return null; return null;
} }
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); $stockModel = config('shop.models.product_stock', ProductStock::class);
return $stockModel::claim( return $stockModel::claim(
$this, $this,
@ -451,6 +468,9 @@ trait HasStocks
* Includes products with: * Includes products with:
* - Stock management disabled (always in stock), OR * - Stock management disabled (always in stock), OR
* - Stock management enabled AND available stock > 0 * - Stock management enabled AND available stock > 0
*
* @param \Illuminate\Database\Eloquent\Builder<static> $query
* @return \Illuminate\Database\Eloquent\Builder<static>
*/ */
public function scopeInStock($query) public function scopeInStock($query)
{ {
@ -470,6 +490,9 @@ trait HasStocks
* - Stock management is enabled * - Stock management is enabled
* - low_stock_threshold is set * - low_stock_threshold is set
* - Available stock <= threshold * - Available stock <= threshold
*
* @param \Illuminate\Database\Eloquent\Builder<static> $query
* @return \Illuminate\Database\Eloquent\Builder<static>
*/ */
public function scopeLowStock($query) public function scopeLowStock($query)
{ {
@ -506,11 +529,11 @@ trait HasStocks
* - Checking what's claimed but not released * - Checking what's claimed but not released
* - Managing active bookings * - 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() return $stockModel::claims()
->willExpire() ->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. * 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. * Every other timestamp shows what total current availability is at that time.
* *
* @param null|DateTimeInterface $date * @param null|DateTimeInterface $date
* @return array|int * @return array<string, int>|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 // For pool products, aggregate availability from all single items
if (method_exists($this, 'isPool') && $this->isPool()) { 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) // Get all date keys from first single (they should all have the same dates)
if (!empty($singleAvailabilities)) { if (!empty($singleAvailabilities)) {
$firstAvailability = $singleAvailabilities[0]; $firstAvailability = $singleAvailabilities[0];
foreach ($firstAvailability['dates'] as $dateKey => $dayData) { foreach (array_keys($firstAvailability['dates']) as $dateKey) {
$dayMin = 0; $dayMin = 0;
$dayMax = 0; $dayMax = 0;
@ -826,10 +849,10 @@ trait HasStocks
/** /**
* Get day availability for pool products by aggregating all single items * Get day availability for pool products by aggregating all single items
* *
* @param DateTimeInterface|null $date * @param DateTimeInterface|null $date
* @return array * @return array<string, int>|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 // Eager load single products if not already loaded
if (!$this->relationLoaded('singleProducts')) { if (!$this->relationLoaded('singleProducts')) {
@ -897,16 +920,16 @@ trait HasStocks
* - The idea is that users can add items freely and adjust dates later * - The idea is that users can add items freely and adjust dates later
* - Date-based validation happens at checkout, not when adding to cart * - 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) * @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 // Try to get current cart from facade if not provided
if ($cart === null) { if ($cart === null) {
try { try {
$cart = \Blax\Shop\Facades\Cart::current(); $cart = \Blax\Shop\Facades\Cart::current();
} catch (\Exception $e) { } catch (\Exception) {
// No cart available, that's fine // No cart available, that's fine
$cart = null; $cart = null;
} }
@ -943,10 +966,9 @@ trait HasStocks
* Returns total pool capacity minus items already in cart. * Returns total pool capacity minus items already in cart.
* Does NOT consider date-based availability - that's validated at checkout. * Does NOT consider date-based availability - that's validated at checkout.
* *
* @param \Blax\Shop\Models\Cart|null $cart * @param \Blax\Shop\Models\Cart|null $cart
* @return int
*/ */
protected function getPoolHasMore($cart = null): int protected function getPoolHasMore(?\Blax\Shop\Models\Cart $cart = null): int
{ {
// Get total pool capacity (NOT date-restricted) // Get total pool capacity (NOT date-restricted)
if (method_exists($this, 'getPoolTotalCapacity')) { if (method_exists($this, 'getPoolTotalCapacity')) {
@ -991,13 +1013,12 @@ trait HasStocks
* *
* @param DateTimeInterface $from * @param DateTimeInterface $from
* @param DateTimeInterface $until * @param DateTimeInterface $until
* @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
*/ */
public function getAvailableForDateRange( public function getAvailableForDateRange(
DateTimeInterface $from, DateTimeInterface $from,
DateTimeInterface $until, DateTimeInterface $until,
$cart = null ?\Blax\Shop\Models\Cart $cart = null
): int { ): int {
if ($this->manage_stock === false) { if ($this->manage_stock === false) {
return PHP_INT_MAX; return PHP_INT_MAX;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\MultiplePurchaseOptions; use Blax\Shop\Exceptions\MultiplePurchaseOptions;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductStatus;

Some files were not shown because too many files have changed in this diff Show More