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:
parent
7415da3531
commit
afdcd8bc75
|
|
@ -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|*",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Blax\Shop\Models\ProductStock;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Blax\Shop\Facades\Shop;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Blax\Shop\Models\ProductAction;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Blax\Shop\Models\ProductAction;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Console\Commands;
|
||||
|
||||
use Blax\Shop\Models\ProductAction;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum BillingScheme: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum CartStatus: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum PriceType: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum PricingStrategy: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum ProductAttributeType: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum ProductRelationType: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum ProductStatus: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum ProductType: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum PurchaseStatus: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
enum RecurringInterval: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Enums;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Events;
|
||||
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Events;
|
||||
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Events;
|
||||
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
class HasNoDefaultPriceException extends NotPurchasable
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
class HasNoPriceException extends NotPurchasable
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
class InvalidBookingConfigurationException extends NotPurchasable
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
class InvalidPoolConfigurationException extends NotPurchasable
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Http\Controllers\Api;
|
||||
|
||||
use Blax\Shop\Models\ProductCategory;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Http\Controllers\Api;
|
||||
|
||||
use Blax\Shop\Models\PaymentMethod;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Http\Controllers\Api;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Http\Controllers;
|
||||
|
||||
use Blax\Shop\Models\Cart;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Http\Controllers;
|
||||
|
||||
use Blax\Shop\Enums\CartStatus;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Http\Resources;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Http\Resources;
|
||||
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Blax\Shop\Exceptions\InvalidDateRangeException;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Blax\Shop\Database\Factories\PaymentMethodFactory;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Blax\Shop\Database\Factories\PaymentProviderIdentityFactory;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Blax\Shop\Database\Factories\ProductPriceTierFactory;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Services;
|
||||
|
||||
use Blax\Shop\Models\Cart;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Services;
|
||||
|
||||
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop;
|
||||
|
||||
use Blax\Shop\Console\Commands\ShopCleanupCartsCommand;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Models\ProductCategory;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Enums\OrderStatus;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Enums\PricingStrategy;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Enums\ProductRelationType;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Contracts\Purchasable;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 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()},
|
||||
* {@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`.
|
||||
*
|
||||
* Key Features:
|
||||
* - Basic stock operations (increase, decrease, adjust)
|
||||
* - Stock claims for bookings/reservations
|
||||
* - Date-based availability checking
|
||||
* - Low stock detection
|
||||
* - Stock movement logging
|
||||
* # Stock calculation
|
||||
*
|
||||
* 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
|
||||
* - **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.
|
||||
*
|
||||
* 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
|
||||
* # 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()
|
||||
|
|
@ -676,9 +699,9 @@ trait HasStocks
|
|||
* Every other timestamp shows what total current availability is at that time.
|
||||
*
|
||||
* @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
|
||||
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;
|
||||
|
||||
|
|
@ -827,9 +850,9 @@ trait HasStocks
|
|||
* Get day availability for pool products by aggregating all single items
|
||||
*
|
||||
* @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
|
||||
if (!$this->relationLoaded('singleProducts')) {
|
||||
|
|
@ -900,13 +923,13 @@ trait HasStocks
|
|||
* @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;
|
||||
}
|
||||
|
|
@ -944,9 +967,8 @@ trait HasStocks
|
|||
* Does NOT consider date-based availability - that's validated at checkout.
|
||||
*
|
||||
* @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)
|
||||
if (method_exists($this, 'getPoolTotalCapacity')) {
|
||||
|
|
@ -992,12 +1014,11 @@ trait HasStocks
|
|||
* @param DateTimeInterface $from
|
||||
* @param DateTimeInterface $until
|
||||
* @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from
|
||||
* @return int
|
||||
*/
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue