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": {
"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|*",

View File

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

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\ProductStatus;
@ -673,7 +675,7 @@ class ShopAddExampleProducts extends Command
$parking = Product::create([
'slug' => $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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,30 @@
<?php
declare(strict_types=1);
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
{
}

View File

@ -1,10 +1,48 @@
<?php
declare(strict_types=1);
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
{
/**
* 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;
/**
* 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;
}

View File

@ -1,19 +1,96 @@
<?php
declare(strict_types=1);
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
{
/**
* 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;
/**
* Eloquent attribute accessor for `$model->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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,21 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
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
{
use Dispatchable, SerializesModels;

View File

@ -1,11 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
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
{
use Dispatchable, SerializesModels;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,28 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
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
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
use Exception;

View File

@ -1,7 +1,31 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
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
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
use Exception;

View File

@ -1,7 +1,34 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
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
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
use Exception;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable;
@ -946,7 +948,7 @@ class Cart extends Model
->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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable;
@ -24,13 +26,65 @@ use Blax\Shop\Traits\HasPricingStrategy;
use Blax\Shop\Traits\HasProductRelations;
use Blax\Shop\Traits\HasStocks;
use Blax\Shop\Traits\MayBePoolProduct;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
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
{
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');
}
/**
* 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
{
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<static> $query
* @return Builder<static>
*/
public function scopePublished(Builder $query): Builder
{
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);
}
@ -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<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(
$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)
->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) {
$q->where('slug', 'like', "%{$search}%")
@ -544,8 +640,11 @@ class Product extends Model implements Purchasable, Cartable
/**
* 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);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable;
@ -8,11 +10,42 @@ use Blax\Shop\Enums\BillingScheme;
use Blax\Shop\Enums\PriceType;
use Blax\Shop\Enums\RecurringInterval;
use Blax\Workkit\Traits\HasMetaTranslation;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
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
{
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<Model, $this>
*/
public function purchasable(): 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);
}
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;

View File

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

View File

@ -1,13 +1,58 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Traits\HasBookingLifecycle;
use Blax\Shop\Traits\HasLoanLifecycle;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
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
{
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<Model, $this>
*/
public function purchasable(): MorphTo
{
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');
}
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));
}
/**
* The price this purchase bills against (see HasLoanLifecycle::calculateCost).
*
* @return BelongsTo<ProductPrice, $this>
*/
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<self> $query
* @return Builder<self>
*/
public function scopeFromCart(Builder $query, string $cartId): Builder
{
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);
}
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);
}
@ -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(
ProductActionRun::class,

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Blax\Shop\Enums\StockStatus;
@ -312,7 +314,7 @@ class ProductStock extends Model
* Scope: Get completed/available stock entries
* 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);
}
@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Traits;
use Illuminate\Database\Eloquent\Builder;
/**
* 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
* product-side concept is the BOOKING product type plus
* {@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
{
@ -34,16 +43,22 @@ trait HasBookingLifecycle
/**
* 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');
}
/**
* 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());
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,16 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Traits;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Events\LoanExtended;
use Blax\Shop\Events\LoanReturned;
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.
@ -27,6 +31,19 @@ use Illuminate\Support\Carbon;
*
* The product-side counterpart is {@see IsLoanableProduct}, which exposes a
* `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
{
@ -154,8 +171,11 @@ trait HasLoanLifecycle
/**
* 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
->where('status', PurchaseStatus::PENDING->value)
@ -164,16 +184,22 @@ trait HasLoanLifecycle
/**
* 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');
}
/**
* 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());
}

View File

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

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Traits;
use Blax\Shop\Models\PaymentMethod;
@ -69,7 +71,7 @@ trait HasPaymentMethods
* @param bool $activeOnly Only return active payment methods
* @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();
@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Traits;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\ProductStock;
use Carbon\Carbon;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Model;
@ -12,29 +15,43 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
/**
* HasStocks Trait
*
* Provides stock management functionality to Product models.
*
* Key Features:
* - Basic stock operations (increase, decrease, adjust)
* - Stock claims for bookings/reservations
* - Date-based availability checking
* - Low stock detection
* - Stock movement logging
*
* Usage:
* - Add 'manage_stock' boolean column to products table
* - Set manage_stock = true to enable stock tracking
* - Use increaseStock/decreaseStock for inventory changes
* - Use claimStock for reservations/bookings
* - Use availableOnDate for date-based availability
*
* Stock Calculation:
* - Physical Stock = Sum of all COMPLETED entries
* - Available Stock = Physical Stock (accounts for pending claims via their DECREASE entries)
* - Claimed Stock = Sum of PENDING claims
* - Available on Date = Available Stock + All Claims - Claims Active on Date
* HasStocks stock management surface for Product-shaped models.
*
* Provides:
* - Basic stock operations: {@see self::increaseStock()}, {@see self::decreaseStock()},
* {@see self::adjustStock()}.
* - Reservation / booking claims via {@see self::claimStock()}.
* - Date-based availability: {@see self::availableOnDate()},
* {@see self::getAvailableForDateRange()}, {@see self::calendarAvailability()}.
* - Low-stock detection: {@see self::isLowStock()} and the
* {@see self::scopeLowStock()} query scope.
* - Audit log writes via {@see self::logStockChange()} `product_stock_logs`.
*
* # Stock calculation
*
* - **Physical stock**: sum of all COMPLETED entries (positive INCREASE +
* RETURN, negative DECREASE) that haven't expired.
* - **Available stock**: physical stock, with active CLAIMED entries netted
* out (their DECREASE side reduces availability while the PENDING claim
* sits open).
* - **Claimed stock**: sum of PENDING `CLAIMED` entries (returned as a
* positive number by the getters).
* - **Available on a given date**: physical stock at that date, minus claims
* 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
{
@ -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<static> $query
* @return \Illuminate\Database\Eloquent\Builder<static>
*/
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<static> $query
* @return \Illuminate\Database\Eloquent\Builder<static>
*/
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<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
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<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
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;

View File

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

View File

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

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