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": {
|
"require": {
|
||||||
"php": "^8.2|^8.3",
|
"php": "^8.2|^8.3|^8.4",
|
||||||
"illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
"illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
"illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
"illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
"blax-software/laravel-workkit": "dev-master|*",
|
"blax-software/laravel-workkit": "dev-master|*",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductStock;
|
use Blax\Shop\Models\ProductStock;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Blax\Shop\Enums\ProductStatus;
|
use Blax\Shop\Enums\ProductStatus;
|
||||||
|
|
@ -673,7 +675,7 @@ class ShopAddExampleProducts extends Command
|
||||||
$parking = Product::create([
|
$parking = Product::create([
|
||||||
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName),
|
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName),
|
||||||
'name' => $itemName,
|
'name' => $itemName,
|
||||||
'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT),
|
'sku' => $pool->sku . '-' . str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT),
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
'status' => ProductStatus::PUBLISHED,
|
'status' => ProductStatus::PUBLISHED,
|
||||||
'is_visible' => false,
|
'is_visible' => false,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Blax\Shop\Facades\Shop;
|
use Blax\Shop\Facades\Shop;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductAction;
|
use Blax\Shop\Models\ProductAction;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductAction;
|
use Blax\Shop\Models\ProductAction;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Console\Commands;
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductAction;
|
use Blax\Shop\Models\ProductAction;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,30 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Contracts;
|
namespace Blax\Shop\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker contract for anything that can be added to a {@see \Blax\Shop\Models\Cart}.
|
||||||
|
*
|
||||||
|
* The contract is intentionally empty — implementing `Cartable` declares
|
||||||
|
* intent. {@see \Blax\Shop\Models\Cart::addToCart()} checks
|
||||||
|
* `$item instanceof Cartable` and rejects models that haven't opted in,
|
||||||
|
* to prevent accidentally cart-ing rows that have no domain meaning as a
|
||||||
|
* purchase line (a `User` or `Address`, say).
|
||||||
|
*
|
||||||
|
* Implementors typically also implement {@see Purchasable} so the cart
|
||||||
|
* can resolve a price, but the two are independent — a `ProductPrice`
|
||||||
|
* row is `Cartable` only (the {@see Purchasable} half lives on the
|
||||||
|
* parent `Product`).
|
||||||
|
*
|
||||||
|
* Pair this with {@see \Blax\Shop\Traits\IsSimplePurchasable} on a plain
|
||||||
|
* Eloquent model for a no-subclass integration, or extend
|
||||||
|
* {@see \Blax\Shop\Models\Product} for the full e-commerce surface.
|
||||||
|
*
|
||||||
|
* @see \Blax\Shop\Models\Cart::addToCart()
|
||||||
|
* @see \Blax\Shop\Exceptions\CartableInterfaceException Thrown when a non-Cartable model is passed to the cart.
|
||||||
|
*/
|
||||||
interface Cartable
|
interface Cartable
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,48 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Contracts;
|
namespace Blax\Shop\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for billable parties (typically the customer / authenticated user)
|
||||||
|
* that own one or more stored payment methods on a payment provider.
|
||||||
|
*
|
||||||
|
* A `Chargable` actor is the *who* in "who pays for this {@see Purchasable}".
|
||||||
|
* Orders and recurring charges resolve the actor's preferred provider +
|
||||||
|
* method through this contract before handing off to the payment service.
|
||||||
|
*
|
||||||
|
* Reference implementation: pair {@see \Blax\Shop\Traits\HasPaymentMethods}
|
||||||
|
* onto a `User` model — the trait satisfies both contract methods (the
|
||||||
|
* Collection it returns degrades to an array via Eloquent's serialization,
|
||||||
|
* which is what the array return type on this interface captures).
|
||||||
|
*
|
||||||
|
* Note: this contract is deliberately small. Anything beyond "which method
|
||||||
|
* do I bill against" lives on {@see \Blax\Shop\Models\PaymentMethod} or
|
||||||
|
* the provider service ({@see \Blax\Shop\Services\PaymentProvider\PaymentProviderService}).
|
||||||
|
*/
|
||||||
interface Chargable
|
interface Chargable
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Return the provider key of the actor's default payment method
|
||||||
|
* (e.g. `'stripe'`), or `null` when none is configured yet.
|
||||||
|
*
|
||||||
|
* The order pipeline uses this to pick the right provider service
|
||||||
|
* when no method is supplied explicitly at checkout.
|
||||||
|
*/
|
||||||
public function getDefaultPaymentMethod(): ?string;
|
public function getDefaultPaymentMethod(): ?string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the actor's payment methods.
|
||||||
|
*
|
||||||
|
* Implementations may return an indexed array of method
|
||||||
|
* descriptors or any iterable that array-casts cleanly — the
|
||||||
|
* canonical implementation on {@see \Blax\Shop\Traits\HasPaymentMethods}
|
||||||
|
* returns an {@see \Illuminate\Support\Collection} of
|
||||||
|
* {@see \Blax\Shop\Models\PaymentMethod}, which satisfies this
|
||||||
|
* contract via Collection-to-array coercion.
|
||||||
|
*
|
||||||
|
* @return array<int, mixed>|\Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
public function paymentMethods(): array;
|
public function paymentMethods(): array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,96 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Contracts;
|
namespace Blax\Shop\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for models that can be priced, stocked, and recorded as a purchase.
|
||||||
|
*
|
||||||
|
* Where {@see Cartable} is a pure marker, `Purchasable` is the *behavioural*
|
||||||
|
* surface the cart, checkout, and order machinery rely on:
|
||||||
|
*
|
||||||
|
* - **Pricing** — {@see self::getCurrentPrice()} and
|
||||||
|
* {@see self::getPriceAttribute()} resolve the unit price the cart
|
||||||
|
* will charge. Sale-aware implementations toggle on
|
||||||
|
* {@see self::isOnSale()}.
|
||||||
|
* - **Inventory** — {@see self::decreaseStock()} runs when a unit is
|
||||||
|
* consumed (cart checkout, loan checkout); {@see self::increaseStock()}
|
||||||
|
* when one is returned. Both return `bool` so the caller can detect
|
||||||
|
* a sold-out condition without an exception.
|
||||||
|
* - **Audit trail** — {@see self::purchases()} exposes the historic
|
||||||
|
* record of consumption events as a polymorphic relation against
|
||||||
|
* {@see \Blax\Shop\Models\ProductPurchase}.
|
||||||
|
*
|
||||||
|
* Two reference implementations ship in the package:
|
||||||
|
*
|
||||||
|
* - {@see \Blax\Shop\Models\Product} — the full e-commerce model.
|
||||||
|
* - {@see \Blax\Shop\Traits\IsSimplePurchasable} — drop-in trait for
|
||||||
|
* host models that want the contract without subclassing `Product`.
|
||||||
|
*
|
||||||
|
* @see \Blax\Shop\Models\ProductPurchase
|
||||||
|
* @see \Blax\Shop\Traits\HasStocks Reference inventory implementation.
|
||||||
|
*/
|
||||||
interface Purchasable
|
interface Purchasable
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Resolve the unit price this item should currently be charged at,
|
||||||
|
* in the package's monetary unit (integer cents floated for math).
|
||||||
|
*
|
||||||
|
* Return `null` when no price is configured; callers (cart, order
|
||||||
|
* total) treat `null` as "free" or as an error depending on context.
|
||||||
|
*/
|
||||||
public function getCurrentPrice(): ?float;
|
public function getCurrentPrice(): ?float;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eloquent attribute accessor for `$model->price` — the same value
|
||||||
|
* {@see self::getCurrentPrice()} resolves, exposed for convenience
|
||||||
|
* in Blade / JSON serialization.
|
||||||
|
*/
|
||||||
public function getPriceAttribute(): ?float;
|
public function getPriceAttribute(): ?float;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the item is currently selling at a discounted price.
|
||||||
|
*
|
||||||
|
* Pricing logic uses this to pick between {@see self::getCurrentPrice()}
|
||||||
|
* and a sale price source. Implementations that don't support sales
|
||||||
|
* may always return `false`.
|
||||||
|
*/
|
||||||
public function isOnSale(): bool;
|
public function isOnSale(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume `$quantity` units of inventory.
|
||||||
|
*
|
||||||
|
* Returns `true` when stock was successfully reduced (or when the
|
||||||
|
* implementation doesn't track stock and so always reports success).
|
||||||
|
* Returns `false` to signal "not enough"; implementations may
|
||||||
|
* alternatively throw {@see \Blax\Shop\Exceptions\NotEnoughStockException}.
|
||||||
|
*
|
||||||
|
* Race-safety: implementations should prefer an atomic conditional
|
||||||
|
* UPDATE over a `lockForUpdate` dance — see the laravel-shop
|
||||||
|
* principles doc for the canonical pattern.
|
||||||
|
*/
|
||||||
public function decreaseStock(int $quantity = 1): bool;
|
public function decreaseStock(int $quantity = 1): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore `$quantity` units to inventory (e.g. on a return / refund).
|
||||||
|
*
|
||||||
|
* Symmetric to {@see self::decreaseStock()}. Returning `false` means
|
||||||
|
* the implementation declined to record the change (typically because
|
||||||
|
* stock management is disabled on this record).
|
||||||
|
*/
|
||||||
public function increaseStock(int $quantity = 1): bool;
|
public function increaseStock(int $quantity = 1): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymorphic relation to the purchase history.
|
||||||
|
*
|
||||||
|
* Implementations return a {@see \Illuminate\Database\Eloquent\Relations\MorphMany}
|
||||||
|
* pointing at {@see \Blax\Shop\Models\ProductPurchase} via the
|
||||||
|
* `purchasable_*` columns. The return type is intentionally
|
||||||
|
* unconstrained on the interface to preserve backward compatibility
|
||||||
|
* — see the canonical implementations on `Product` and `IsSimplePurchasable`.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Relations\MorphMany<\Blax\Shop\Models\ProductPurchase, $this>
|
||||||
|
*/
|
||||||
public function purchases();
|
public function purchases();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum BillingScheme: string
|
enum BillingScheme: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum CartStatus: string
|
enum CartStatus: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum PriceType: string
|
enum PriceType: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum PricingStrategy: string
|
enum PricingStrategy: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum ProductAttributeType: string
|
enum ProductAttributeType: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum ProductRelationType: string
|
enum ProductRelationType: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum ProductStatus: string
|
enum ProductStatus: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum ProductType: string
|
enum ProductType: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum PurchaseStatus: string
|
enum PurchaseStatus: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
enum RecurringInterval: string
|
enum RecurringInterval: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Events;
|
namespace Blax\Shop\Events;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductPurchase;
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Events;
|
namespace Blax\Shop\Events;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductPurchase;
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Events;
|
namespace Blax\Shop\Events;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductPurchase;
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Events;
|
namespace Blax\Shop\Events;
|
||||||
|
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched automatically by {@see Product} via the model's
|
||||||
|
* `$dispatchesEvents` map after a new product row is inserted.
|
||||||
|
*
|
||||||
|
* Listeners typically use this for downstream sync (push to Stripe, build a
|
||||||
|
* search index entry, warm a cache). The model is passed already persisted
|
||||||
|
* — listeners can read `$event->product->id`.
|
||||||
|
*/
|
||||||
class ProductCreated
|
class ProductCreated
|
||||||
{
|
{
|
||||||
use Dispatchable, SerializesModels;
|
use Dispatchable, SerializesModels;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Events;
|
namespace Blax\Shop\Events;
|
||||||
|
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched automatically by {@see Product} via the model's
|
||||||
|
* `$dispatchesEvents` map whenever an existing product row is saved
|
||||||
|
* (after the `updated` model event fires).
|
||||||
|
*
|
||||||
|
* Pairs with {@see ProductCreated} for any sink that needs to react to
|
||||||
|
* the full product write surface — search reindex, cache invalidation,
|
||||||
|
* Stripe price sync, etc.
|
||||||
|
*/
|
||||||
class ProductUpdated
|
class ProductUpdated
|
||||||
{
|
{
|
||||||
use Dispatchable, SerializesModels;
|
use Dispatchable, SerializesModels;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
class HasNoDefaultPriceException extends NotPurchasable
|
class HasNoDefaultPriceException extends NotPurchasable
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
class HasNoPriceException extends NotPurchasable
|
class HasNoPriceException extends NotPurchasable
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
class InvalidBookingConfigurationException extends NotPurchasable
|
class InvalidBookingConfigurationException extends NotPurchasable
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
class InvalidPoolConfigurationException extends NotPurchasable
|
class InvalidPoolConfigurationException extends NotPurchasable
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,28 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
class MultiplePurchaseOptions extends Exception {}
|
/**
|
||||||
|
* Thrown when a single purchase path cannot be auto-selected because the
|
||||||
|
* product exposes multiple options (e.g. several variants, several
|
||||||
|
* default prices, several configurations) and the caller did not specify
|
||||||
|
* which one to use.
|
||||||
|
*
|
||||||
|
* Typical resolution at the call site: surface the available options to
|
||||||
|
* the user / API consumer and re-issue the request with an explicit
|
||||||
|
* choice, rather than letting the package guess.
|
||||||
|
*/
|
||||||
|
class MultiplePurchaseOptions extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'Multiple purchase options are available — caller must pick one explicitly.',
|
||||||
|
int $code = 0,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,31 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
class NotEnoughStockException extends Exception {}
|
/**
|
||||||
|
* Thrown when a stock-consuming operation cannot proceed because the
|
||||||
|
* available quantity is below the requested amount.
|
||||||
|
*
|
||||||
|
* Raised from {@see \Blax\Shop\Traits\HasStocks::decreaseStock()},
|
||||||
|
* {@see \Blax\Shop\Traits\HasStocks::adjustStock()}, and
|
||||||
|
* {@see \Blax\Shop\Models\ProductStock::claim()}.
|
||||||
|
*
|
||||||
|
* The message defaults to a generic phrase so the exception can be raised
|
||||||
|
* with no arguments from helper paths; pass a richer message at the call
|
||||||
|
* site when the calling context can describe the product, requested
|
||||||
|
* quantity, or surrounding operation.
|
||||||
|
*/
|
||||||
|
class NotEnoughStockException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'Not enough stock available for the requested operation.',
|
||||||
|
int $code = 0,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,34 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
class NotPurchasable extends Exception {}
|
/**
|
||||||
|
* Base exception for any condition that prevents an item from being
|
||||||
|
* legally added to a cart or charged.
|
||||||
|
*
|
||||||
|
* Subclasses describe the specific failure mode while sharing a single
|
||||||
|
* catchable parent — callers that just want to translate any
|
||||||
|
* "not-purchasable" outcome to a 422 response can `catch (NotPurchasable
|
||||||
|
* $e)` without enumerating every concrete case.
|
||||||
|
*
|
||||||
|
* Concrete subclasses shipped by the package:
|
||||||
|
*
|
||||||
|
* - {@see HasNoPriceException} — no price record exists.
|
||||||
|
* - {@see HasNoDefaultPriceException} — prices exist but none is marked default.
|
||||||
|
* - {@see InvalidBookingConfigurationException} — booking product is misconfigured.
|
||||||
|
* - {@see InvalidPoolConfigurationException} — pool product is misconfigured.
|
||||||
|
*/
|
||||||
|
class NotPurchasable extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'This item cannot be purchased in its current state.',
|
||||||
|
int $code = 0,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Exceptions;
|
namespace Blax\Shop\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Facades;
|
namespace Blax\Shop\Facades;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Facades;
|
namespace Blax\Shop\Facades;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Http\Controllers\Api;
|
namespace Blax\Shop\Http\Controllers\Api;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductCategory;
|
use Blax\Shop\Models\ProductCategory;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Http\Controllers\Api;
|
namespace Blax\Shop\Http\Controllers\Api;
|
||||||
|
|
||||||
use Blax\Shop\Models\PaymentMethod;
|
use Blax\Shop\Models\PaymentMethod;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Http\Controllers\Api;
|
namespace Blax\Shop\Http\Controllers\Api;
|
||||||
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Http\Controllers;
|
namespace Blax\Shop\Http\Controllers;
|
||||||
|
|
||||||
use Blax\Shop\Models\Cart;
|
use Blax\Shop\Models\Cart;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Http\Controllers;
|
namespace Blax\Shop\Http\Controllers;
|
||||||
|
|
||||||
use Blax\Shop\Enums\CartStatus;
|
use Blax\Shop\Enums\CartStatus;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Http\Resources;
|
namespace Blax\Shop\Http\Resources;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Http\Resources;
|
namespace Blax\Shop\Http\Resources;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductPurchase;
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Contracts\Cartable;
|
use Blax\Shop\Contracts\Cartable;
|
||||||
|
|
@ -946,7 +948,7 @@ class Cart extends Model
|
||||||
->where('customer_type', $userModel);
|
->where('customer_type', $userModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function scopeUnpaid($query)
|
public function scopeUnpaid($query)
|
||||||
{
|
{
|
||||||
return $query->whereDoesntHave('purchases', function ($q) {
|
return $query->whereDoesntHave('purchases', function ($q) {
|
||||||
$q->whereColumn('total_amount', '!=', 'amount_paid');
|
$q->whereColumn('total_amount', '!=', 'amount_paid');
|
||||||
|
|
@ -2034,10 +2036,20 @@ class Cart extends Model
|
||||||
// Price is already stored in cents, Stripe expects smallest currency unit
|
// Price is already stored in cents, Stripe expects smallest currency unit
|
||||||
$unitAmountCents = (int) $item->price;
|
$unitAmountCents = (int) $item->price;
|
||||||
|
|
||||||
|
// Stripe wants lowercase ISO-4217 currency codes. Resolve from
|
||||||
|
// the cart item's price relation first (the source of truth for
|
||||||
|
// the line being charged), then the cart's own currency column,
|
||||||
|
// then the package default — never assume the cart row has one.
|
||||||
|
$lineCurrency = strtolower(
|
||||||
|
$item->price()->first()?->currency
|
||||||
|
?? $this->currency
|
||||||
|
?? config('shop.currency', 'usd')
|
||||||
|
);
|
||||||
|
|
||||||
// Build line item using price_data for dynamic pricing
|
// Build line item using price_data for dynamic pricing
|
||||||
$lineItem = [
|
$lineItem = [
|
||||||
'price_data' => [
|
'price_data' => [
|
||||||
'currency' => $item->price->currency ?? strtoupper($this->currency),
|
'currency' => $lineCurrency,
|
||||||
'product_data' => [
|
'product_data' => [
|
||||||
'name' => $productName,
|
'name' => $productName,
|
||||||
...($description ? ['description' => $description] : []),
|
...($description ? ['description' => $description] : []),
|
||||||
|
|
@ -2064,7 +2076,7 @@ class Cart extends Model
|
||||||
// Prepare session parameters
|
// Prepare session parameters
|
||||||
$sessionParams = [
|
$sessionParams = [
|
||||||
'payment_method_types' => ['card'],
|
'payment_method_types' => ['card'],
|
||||||
'currency' => strtoupper($this->currency),
|
'currency' => strtolower($this->currency ?? config('shop.currency', 'usd')),
|
||||||
'line_items' => $lineItems,
|
'line_items' => $lineItems,
|
||||||
'mode' => 'payment',
|
'mode' => 'payment',
|
||||||
'success_url' => $success_url,
|
'success_url' => $success_url,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Exceptions\InvalidDateRangeException;
|
use Blax\Shop\Exceptions\InvalidDateRangeException;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Enums\OrderStatus;
|
use Blax\Shop\Enums\OrderStatus;
|
||||||
|
|
@ -179,7 +181,8 @@ class Order extends Model
|
||||||
if ($lastOrder) {
|
if ($lastOrder) {
|
||||||
// Extract the sequence number and increment
|
// Extract the sequence number and increment
|
||||||
$lastNumber = (int) substr($lastOrder->order_number, strlen("{$prefix}{$date}"));
|
$lastNumber = (int) substr($lastOrder->order_number, strlen("{$prefix}{$date}"));
|
||||||
$sequence = str_pad($lastNumber + 1, 4, '0', STR_PAD_LEFT);
|
// str_pad requires a string under strict_types — cast the int explicitly.
|
||||||
|
$sequence = str_pad((string) ($lastNumber + 1), 4, '0', STR_PAD_LEFT);
|
||||||
} else {
|
} else {
|
||||||
$sequence = '0001';
|
$sequence = '0001';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Database\Factories\PaymentMethodFactory;
|
use Blax\Shop\Database\Factories\PaymentMethodFactory;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Database\Factories\PaymentProviderIdentityFactory;
|
use Blax\Shop\Database\Factories\PaymentProviderIdentityFactory;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Contracts\Cartable;
|
use Blax\Shop\Contracts\Cartable;
|
||||||
|
|
@ -24,13 +26,65 @@ use Blax\Shop\Traits\HasPricingStrategy;
|
||||||
use Blax\Shop\Traits\HasProductRelations;
|
use Blax\Shop\Traits\HasProductRelations;
|
||||||
use Blax\Shop\Traits\HasStocks;
|
use Blax\Shop\Traits\HasStocks;
|
||||||
use Blax\Shop\Traits\MayBePoolProduct;
|
use Blax\Shop\Traits\MayBePoolProduct;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core product entity — sellable, stockable, priceable.
|
||||||
|
*
|
||||||
|
* Product is the polymorphic root for the whole package. Specialized
|
||||||
|
* behaviour comes from composed traits:
|
||||||
|
*
|
||||||
|
* - {@see HasStocks} — inventory and reservations.
|
||||||
|
* - {@see HasPrices} — price list and default-price resolution.
|
||||||
|
* - {@see HasPricingStrategy} — average / lowest / highest tier strategy.
|
||||||
|
* - {@see HasCategories} — category attachment.
|
||||||
|
* - {@see HasProductRelations} — cross-sells, upsells, pool↔single links.
|
||||||
|
* - {@see MayBePoolProduct} — pool aggregation when `type = POOL`.
|
||||||
|
* - {@see ChecksIfBooking} — booking-product helpers when `type = BOOKING`.
|
||||||
|
*
|
||||||
|
* Host apps can extend this model (`Book extends Product` style) for free —
|
||||||
|
* every relation declares its FK explicitly so subclasses don't break.
|
||||||
|
*
|
||||||
|
* @property string $id
|
||||||
|
* @property string $slug
|
||||||
|
* @property string|null $sku
|
||||||
|
* @property \Blax\Shop\Enums\ProductType $type
|
||||||
|
* @property string|null $stripe_product_id
|
||||||
|
* @property \Illuminate\Support\Carbon|null $sale_start
|
||||||
|
* @property \Illuminate\Support\Carbon|null $sale_end
|
||||||
|
* @property bool $manage_stock
|
||||||
|
* @property int|null $low_stock_threshold
|
||||||
|
* @property float|null $weight
|
||||||
|
* @property float|null $length
|
||||||
|
* @property float|null $width
|
||||||
|
* @property float|null $height
|
||||||
|
* @property bool $virtual
|
||||||
|
* @property bool $downloadable
|
||||||
|
* @property string|null $parent_id
|
||||||
|
* @property bool $featured
|
||||||
|
* @property bool $is_visible
|
||||||
|
* @property \Blax\Shop\Enums\ProductStatus $status
|
||||||
|
* @property \Illuminate\Support\Carbon|null $published_at
|
||||||
|
* @property \stdClass $meta
|
||||||
|
* @property string|null $tax_class
|
||||||
|
* @property int $sort_order
|
||||||
|
* @property string $name
|
||||||
|
* @property string|null $description
|
||||||
|
* @property string|null $short_description
|
||||||
|
*
|
||||||
|
* @property-read Product|null $parent
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, Product> $children
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductAttribute> $attributes
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductAction> $actions
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductPurchase> $purchases
|
||||||
|
*/
|
||||||
class Product extends Model implements Purchasable, Cartable
|
class Product extends Model implements Purchasable, Cartable
|
||||||
{
|
{
|
||||||
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, ChecksIfBooking;
|
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, ChecksIfBooking;
|
||||||
|
|
@ -141,11 +195,23 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function parent()
|
/**
|
||||||
|
* Parent product when this row is a variant / grouped child / pool single
|
||||||
|
* item. Top-level products have `parent_id = null`.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<static, $this>
|
||||||
|
*/
|
||||||
|
public function parent(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(static::class, 'parent_id');
|
return $this->belongsTo(static::class, 'parent_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variants, grouped children, or pool single items hanging off this
|
||||||
|
* product. Returns an empty collection for leaf rows.
|
||||||
|
*
|
||||||
|
* @return HasMany<static, $this>
|
||||||
|
*/
|
||||||
public function children(): HasMany
|
public function children(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(static::class, 'parent_id');
|
return $this->hasMany(static::class, 'parent_id');
|
||||||
|
|
@ -156,7 +222,7 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
// Explicit FK so the relation still targets `product_id` when a host
|
// Explicit FK so the relation still targets `product_id` when a host
|
||||||
// app subclasses Product (e.g. `Book extends Product`).
|
// app subclasses Product (e.g. `Book extends Product`).
|
||||||
return $this->hasMany(
|
return $this->hasMany(
|
||||||
config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'),
|
config('shop.models.product_attribute', ProductAttribute::class),
|
||||||
'product_id'
|
'product_id'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -177,12 +243,20 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopePublished($query)
|
/**
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', ProductStatus::PUBLISHED->value);
|
return $query->where('status', ProductStatus::PUBLISHED->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeFeatured($query)
|
/**
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopeFeatured(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('featured', true);
|
return $query->where('featured', true);
|
||||||
}
|
}
|
||||||
|
|
@ -364,7 +438,14 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
return ProductAction::getAvailableActions();
|
return ProductAction::getAvailableActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = [])
|
/**
|
||||||
|
* Dispatch every {@see ProductAction} configured on this product for
|
||||||
|
* `$event`. Returns whatever {@see ProductAction::callForProduct()}
|
||||||
|
* returns (typically a collection of {@see ProductActionRun} rows).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $additionalData Free-form payload merged into the action context.
|
||||||
|
*/
|
||||||
|
public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = []): mixed
|
||||||
{
|
{
|
||||||
return ProductAction::callForProduct(
|
return ProductAction::callForProduct(
|
||||||
$this,
|
$this,
|
||||||
|
|
@ -374,7 +455,14 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeVisible($query)
|
/**
|
||||||
|
* Visible to customers right now: `is_visible = true`, status PUBLISHED,
|
||||||
|
* and `published_at` either null or in the past.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopeVisible(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('is_visible', true)
|
return $query->where('is_visible', true)
|
||||||
->where('status', ProductStatus::PUBLISHED->value)
|
->where('status', ProductStatus::PUBLISHED->value)
|
||||||
|
|
@ -384,7 +472,15 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeSearch($query, string $search)
|
/**
|
||||||
|
* Substring match on slug / SKU / name. Cheap LIKE — not a full-text
|
||||||
|
* index; host apps that need stronger search should add Scout or
|
||||||
|
* MeiliSearch alongside this scope.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopeSearch(Builder $query, string $search): Builder
|
||||||
{
|
{
|
||||||
return $query->where(function ($q) use ($search) {
|
return $query->where(function ($q) use ($search) {
|
||||||
$q->where('slug', 'like', "%{$search}%")
|
$q->where('slug', 'like', "%{$search}%")
|
||||||
|
|
@ -544,8 +640,11 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope for booking products
|
* Scope for booking products
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
*/
|
*/
|
||||||
public function scopeBookings($query)
|
public function scopeBookings(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('type', ProductType::BOOKING->value);
|
return $query->where('type', ProductType::BOOKING->value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductAttributeType;
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -15,11 +18,15 @@ class ProductAttribute extends Model
|
||||||
'product_id',
|
'product_id',
|
||||||
'key',
|
'key',
|
||||||
'value',
|
'value',
|
||||||
|
'type',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
|
'meta',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'sort_order' => 'integer',
|
'sort_order' => 'integer',
|
||||||
|
'type' => ProductAttributeType::class,
|
||||||
|
'meta' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Contracts\Cartable;
|
use Blax\Shop\Contracts\Cartable;
|
||||||
|
|
@ -8,11 +10,42 @@ use Blax\Shop\Enums\BillingScheme;
|
||||||
use Blax\Shop\Enums\PriceType;
|
use Blax\Shop\Enums\PriceType;
|
||||||
use Blax\Shop\Enums\RecurringInterval;
|
use Blax\Shop\Enums\RecurringInterval;
|
||||||
use Blax\Workkit\Traits\HasMetaTranslation;
|
use Blax\Workkit\Traits\HasMetaTranslation;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A price record attached to a {@see Purchasable} (usually a {@see Product},
|
||||||
|
* but anything can carry prices via the polymorphic `purchasable_*` columns).
|
||||||
|
*
|
||||||
|
* A single purchasable can carry several prices — one default, plus
|
||||||
|
* alternative tiers (bulk, region, customer-segment). Pricing follows the
|
||||||
|
* package-wide rule: amounts are integer cents, currency lives in a
|
||||||
|
* separate `currency` column, never inferred from the amount.
|
||||||
|
*
|
||||||
|
* @property string $id
|
||||||
|
* @property string $purchasable_type
|
||||||
|
* @property string $purchasable_id
|
||||||
|
* @property string|null $stripe_price_id
|
||||||
|
* @property string|null $name
|
||||||
|
* @property \Blax\Shop\Enums\PriceType $type
|
||||||
|
* @property string $currency ISO 4217.
|
||||||
|
* @property float $unit_amount Per-unit price in the smallest currency unit (cents). Cast to float for math.
|
||||||
|
* @property float|null $sale_unit_amount Sale price; defaults to `unit_amount` when unset.
|
||||||
|
* @property bool $is_default
|
||||||
|
* @property bool $active
|
||||||
|
* @property \Blax\Shop\Enums\BillingScheme $billing_scheme
|
||||||
|
* @property \Blax\Shop\Enums\RecurringInterval|null $interval
|
||||||
|
* @property int|null $interval_count
|
||||||
|
* @property int|null $trial_period_days
|
||||||
|
* @property \stdClass $meta
|
||||||
|
*
|
||||||
|
* @property-read Model $purchasable
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductPriceTier> $tiers
|
||||||
|
*/
|
||||||
class ProductPrice extends Model implements Cartable
|
class ProductPrice extends Model implements Cartable
|
||||||
{
|
{
|
||||||
use HasFactory, HasUuids, HasMetaTranslation;
|
use HasFactory, HasUuids, HasMetaTranslation;
|
||||||
|
|
@ -48,17 +81,34 @@ class ProductPrice extends Model implements Cartable
|
||||||
'trial_period_days' => 'integer',
|
'trial_period_days' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function purchasable()
|
/**
|
||||||
|
* The {@see Purchasable} this price belongs to (usually a {@see Product}).
|
||||||
|
*
|
||||||
|
* @return MorphTo<Model, $this>
|
||||||
|
*/
|
||||||
|
public function purchasable(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->morphTo();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeIsActive($query)
|
/**
|
||||||
|
* Filter to only currently-active prices (default scope alternative).
|
||||||
|
*
|
||||||
|
* @param Builder<self> $query
|
||||||
|
* @return Builder<self>
|
||||||
|
*/
|
||||||
|
public function scopeIsActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('active', true);
|
return $query->where('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCurrentPrice(bool|null $sale_price = null): float
|
/**
|
||||||
|
* Resolve the unit price this record currently sells at.
|
||||||
|
*
|
||||||
|
* Returns the sale price when `$sale_price` is true *and* a
|
||||||
|
* `sale_unit_amount` is configured; otherwise the regular `unit_amount`.
|
||||||
|
*/
|
||||||
|
public function getCurrentPrice(?bool $sale_price = null): float
|
||||||
{
|
{
|
||||||
if ($sale_price) {
|
if ($sale_price) {
|
||||||
return $this->sale_unit_amount ?? $this->unit_amount;
|
return $this->sale_unit_amount ?? $this->unit_amount;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Database\Factories\ProductPriceTierFactory;
|
use Blax\Shop\Database\Factories\ProductPriceTierFactory;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,58 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Enums\PurchaseStatus;
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
use Blax\Shop\Traits\HasBookingLifecycle;
|
use Blax\Shop\Traits\HasBookingLifecycle;
|
||||||
use Blax\Shop\Traits\HasLoanLifecycle;
|
use Blax\Shop\Traits\HasLoanLifecycle;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted record of "this purchasable was sold/loaned/booked to this purchaser".
|
||||||
|
*
|
||||||
|
* Single source of truth for any consumption event in the package:
|
||||||
|
*
|
||||||
|
* - **E-commerce purchase**: status flows `CART → PENDING → COMPLETED`,
|
||||||
|
* `from`/`until` left null.
|
||||||
|
* - **Booking**: status `COMPLETED`, `from`/`until` carry the reserved window
|
||||||
|
* (see {@see HasBookingLifecycle}).
|
||||||
|
* - **Loan**: status `PENDING` until returned, then `COMPLETED`; `from`/`until`
|
||||||
|
* are check-out and due dates (see {@see HasLoanLifecycle}).
|
||||||
|
*
|
||||||
|
* The polymorphic `purchasable_*` columns point at what was sold (typically a
|
||||||
|
* {@see Product} but can be any {@see \Blax\Shop\Contracts\Purchasable}). The
|
||||||
|
* polymorphic `purchaser_*` columns point at who bought it (typically a User,
|
||||||
|
* but any model).
|
||||||
|
*
|
||||||
|
* @property string $id
|
||||||
|
* @property \Blax\Shop\Enums\PurchaseStatus $status
|
||||||
|
* @property string|null $cart_id
|
||||||
|
* @property string|null $price_id
|
||||||
|
* @property string $purchasable_id
|
||||||
|
* @property string $purchasable_type
|
||||||
|
* @property string $purchaser_id
|
||||||
|
* @property string $purchaser_type
|
||||||
|
* @property int $quantity
|
||||||
|
* @property int $amount Total amount charged, in cents.
|
||||||
|
* @property int $amount_paid Amount captured so far, in cents.
|
||||||
|
* @property string|null $charge_id
|
||||||
|
* @property \Illuminate\Support\Carbon|null $from Booking start / loan check-out.
|
||||||
|
* @property \Illuminate\Support\Carbon|null $until Booking end / loan due date.
|
||||||
|
* @property \stdClass $meta
|
||||||
|
*
|
||||||
|
* @property-read Model $purchasable
|
||||||
|
* @property-read Model $purchaser
|
||||||
|
* @property-read Product|null $product
|
||||||
|
* @property-read ProductPrice|null $price
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductActionRun> $actionRuns
|
||||||
|
*/
|
||||||
class ProductPurchase extends Model
|
class ProductPurchase extends Model
|
||||||
{
|
{
|
||||||
use HasBookingLifecycle, HasLoanLifecycle, HasUuids;
|
use HasBookingLifecycle, HasLoanLifecycle, HasUuids;
|
||||||
|
|
@ -45,25 +90,44 @@ class ProductPurchase extends Model
|
||||||
$this->setTable(config('shop.tables.product_purchases', 'product_purchases'));
|
$this->setTable(config('shop.tables.product_purchases', 'product_purchases'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function purchasable()
|
/**
|
||||||
|
* What was sold/loaned/booked — usually a {@see Product} but anything
|
||||||
|
* implementing {@see \Blax\Shop\Contracts\Purchasable} qualifies.
|
||||||
|
*
|
||||||
|
* @return MorphTo<Model, $this>
|
||||||
|
*/
|
||||||
|
public function purchasable(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo('purchasable');
|
return $this->morphTo('purchasable');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function purchaser()
|
/**
|
||||||
|
* Who made the purchase (typically a User), polymorphic.
|
||||||
|
*
|
||||||
|
* @return MorphTo<Model, $this>
|
||||||
|
*/
|
||||||
|
public function purchaser(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo('purchaser');
|
return $this->morphTo('purchaser');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function product()
|
/**
|
||||||
|
* Convenience shortcut to a {@see Product} when the purchasable side IS
|
||||||
|
* a product; resolves through `purchasable_id` for the JOIN.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<Product, $this>
|
||||||
|
*/
|
||||||
|
public function product(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(config('shop.models.product', Product::class));
|
return $this->belongsTo(config('shop.models.product', Product::class));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The price this purchase bills against (see HasLoanLifecycle::calculateCost).
|
* The price this purchase bills against (see HasLoanLifecycle::calculateCost).
|
||||||
|
*
|
||||||
|
* @return BelongsTo<ProductPrice, $this>
|
||||||
*/
|
*/
|
||||||
public function price()
|
public function price(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(
|
return $this->belongsTo(
|
||||||
config('shop.models.product_price', ProductPrice::class),
|
config('shop.models.product_price', ProductPrice::class),
|
||||||
|
|
@ -71,25 +135,45 @@ class ProductPurchase extends Model
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user()
|
/**
|
||||||
|
* Resolve the purchaser as a User relation when the polymorphic type
|
||||||
|
* matches the configured auth model; returns null otherwise so callers
|
||||||
|
* can branch without an instanceof check on the resolved object.
|
||||||
|
*
|
||||||
|
* Note: returns a {@see MorphTo} (same instance as {@see self::purchaser()})
|
||||||
|
* so the caller can `->first()` / eager-load uniformly.
|
||||||
|
*/
|
||||||
|
public function user(): ?MorphTo
|
||||||
{
|
{
|
||||||
if ($this->purchasable_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) {
|
if ($this->purchaser_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) {
|
||||||
return $this->purchasable();
|
return $this->purchaser();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function scopeFromCart($query, $cartId)
|
/**
|
||||||
|
* @param Builder<self> $query
|
||||||
|
* @return Builder<self>
|
||||||
|
*/
|
||||||
|
public function scopeFromCart(Builder $query, string $cartId): Builder
|
||||||
{
|
{
|
||||||
return $query->where('cart_id', $cartId);
|
return $query->where('cart_id', $cartId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function scopeInCart($query)
|
/**
|
||||||
|
* @param Builder<self> $query
|
||||||
|
* @return Builder<self>
|
||||||
|
*/
|
||||||
|
public function scopeInCart(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', PurchaseStatus::CART->value);
|
return $query->where('status', PurchaseStatus::CART->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function scopeCompleted($query)
|
/**
|
||||||
|
* @param Builder<self> $query
|
||||||
|
* @return Builder<self>
|
||||||
|
*/
|
||||||
|
public function scopeCompleted(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', PurchaseStatus::COMPLETED->value);
|
return $query->where('status', PurchaseStatus::COMPLETED->value);
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +211,13 @@ class ProductPurchase extends Model
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function actionRuns()
|
/**
|
||||||
|
* Run-log of every {@see ProductAction} fired against the underlying
|
||||||
|
* product for this purchase (welcome email, fulfilment webhook, etc.).
|
||||||
|
*
|
||||||
|
* @return HasManyThrough<ProductActionRun, ProductAction, $this>
|
||||||
|
*/
|
||||||
|
public function actionRuns(): HasManyThrough
|
||||||
{
|
{
|
||||||
return $this->hasManyThrough(
|
return $this->hasManyThrough(
|
||||||
ProductActionRun::class,
|
ProductActionRun::class,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Enums\StockStatus;
|
use Blax\Shop\Enums\StockStatus;
|
||||||
|
|
@ -312,7 +314,7 @@ class ProductStock extends Model
|
||||||
* Scope: Get completed/available stock entries
|
* Scope: Get completed/available stock entries
|
||||||
* These are physical stock changes (INCREASE/DECREASE) that have been finalized
|
* These are physical stock changes (INCREASE/DECREASE) that have been finalized
|
||||||
*/
|
*/
|
||||||
public static function scopeAvailable($query)
|
public function scopeAvailable($query)
|
||||||
{
|
{
|
||||||
return $query->where('status', StockStatus::COMPLETED->value);
|
return $query->where('status', StockStatus::COMPLETED->value);
|
||||||
}
|
}
|
||||||
|
|
@ -321,7 +323,7 @@ class ProductStock extends Model
|
||||||
* Scope: Get active (pending) claimed stock entries
|
* Scope: Get active (pending) claimed stock entries
|
||||||
* These represent stock currently claimed but not yet released
|
* These represent stock currently claimed but not yet released
|
||||||
*/
|
*/
|
||||||
public static function scopeAvailableClaims($query)
|
public function scopeAvailableClaims($query)
|
||||||
{
|
{
|
||||||
return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value);
|
return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value);
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +352,7 @@ class ProductStock extends Model
|
||||||
*
|
*
|
||||||
* @param \DateTimeInterface $date The date to check availability for
|
* @param \DateTimeInterface $date The date to check availability for
|
||||||
*/
|
*/
|
||||||
public static function scopeAvailableOnDate($query, \DateTimeInterface $date)
|
public function scopeAvailableOnDate($query, \DateTimeInterface $date)
|
||||||
{
|
{
|
||||||
return $query->where('type', StockType::CLAIMED->value)
|
return $query->where('type', StockType::CLAIMED->value)
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Services;
|
namespace Blax\Shop\Services;
|
||||||
|
|
||||||
use Blax\Shop\Models\Cart;
|
use Blax\Shop\Models\Cart;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class PaymentProviderService
|
||||||
{
|
{
|
||||||
protected StripeService $stripeService;
|
protected StripeService $stripeService;
|
||||||
|
|
||||||
public function __construct(StripeService $stripeService = null)
|
public function __construct(?StripeService $stripeService = null)
|
||||||
{
|
{
|
||||||
$this->stripeService = $stripeService ?? app(StripeService::class);
|
$this->stripeService = $stripeService ?? app(StripeService::class);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Services;
|
namespace Blax\Shop\Services;
|
||||||
|
|
||||||
use Blax\Shop\Enums\OrderStatus;
|
use Blax\Shop\Enums\OrderStatus;
|
||||||
|
|
@ -11,7 +13,7 @@ use Blax\Shop\Models\ProductCategory;
|
||||||
use Blax\Shop\Models\ProductPurchase;
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
class ShopService
|
class ShopService
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Services;
|
namespace Blax\Shop\Services;
|
||||||
|
|
||||||
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
|
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop;
|
namespace Blax\Shop;
|
||||||
|
|
||||||
use Blax\Shop\Console\Commands\ShopCleanupCartsCommand;
|
use Blax\Shop\Console\Commands\ShopCleanupCartsCommand;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Enums\ProductType;
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Booking lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row.
|
* Booking lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row.
|
||||||
*
|
*
|
||||||
|
|
@ -9,6 +13,11 @@ namespace Blax\Shop\Traits;
|
||||||
* time-bounded reservation. The trait is purchase-side — the corresponding
|
* time-bounded reservation. The trait is purchase-side — the corresponding
|
||||||
* product-side concept is the BOOKING product type plus
|
* product-side concept is the BOOKING product type plus
|
||||||
* {@see ChecksIfBooking}.
|
* {@see ChecksIfBooking}.
|
||||||
|
*
|
||||||
|
* # Host-model contract
|
||||||
|
*
|
||||||
|
* @property \Illuminate\Support\Carbon|null $from Reservation window start.
|
||||||
|
* @property \Illuminate\Support\Carbon|null $until Reservation window end.
|
||||||
*/
|
*/
|
||||||
trait HasBookingLifecycle
|
trait HasBookingLifecycle
|
||||||
{
|
{
|
||||||
|
|
@ -34,16 +43,22 @@ trait HasBookingLifecycle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope to date-bounded bookings only.
|
* Scope to date-bounded bookings only.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
*/
|
*/
|
||||||
public function scopeBookings($query)
|
public function scopeBookings(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereNotNull('from')->whereNotNull('until');
|
return $query->whereNotNull('from')->whereNotNull('until');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope to bookings whose window is in the past.
|
* Scope to bookings whose window is in the past.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
*/
|
*/
|
||||||
public function scopeEndedBookings($query)
|
public function scopeEndedBookings(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->bookings()->where('until', '<', now());
|
return $query->bookings()->where('until', '<', now());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Models\ProductCategory;
|
use Blax\Shop\Models\ProductCategory;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Enums\PurchaseStatus;
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
use Blax\Shop\Events\LoanExtended;
|
use Blax\Shop\Events\LoanExtended;
|
||||||
use Blax\Shop\Events\LoanReturned;
|
use Blax\Shop\Events\LoanReturned;
|
||||||
use Blax\Shop\Models\ProductPrice;
|
use Blax\Shop\Models\ProductPrice;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loan / rental lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row.
|
* Loan / rental lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row.
|
||||||
|
|
@ -27,6 +31,19 @@ use Illuminate\Support\Carbon;
|
||||||
*
|
*
|
||||||
* The product-side counterpart is {@see IsLoanableProduct}, which exposes a
|
* The product-side counterpart is {@see IsLoanableProduct}, which exposes a
|
||||||
* `loan()` helper to create a purchase row pre-filled for this lifecycle.
|
* `loan()` helper to create a purchase row pre-filled for this lifecycle.
|
||||||
|
*
|
||||||
|
* # Host-model contract
|
||||||
|
*
|
||||||
|
* Designed for {@see \Blax\Shop\Models\ProductPurchase}; expects these
|
||||||
|
* columns and accessors on the host:
|
||||||
|
*
|
||||||
|
* @property \Illuminate\Support\Carbon|null $from Loan check-out timestamp.
|
||||||
|
* @property \Illuminate\Support\Carbon|null $until Loan due timestamp (mutated by {@see self::extend()}).
|
||||||
|
* @property array<string, mixed>|\stdClass|null $meta Carries `returned_at` and `extensions_used` keys.
|
||||||
|
* @property \Blax\Shop\Enums\PurchaseStatus $status Set to `COMPLETED` on {@see self::markReturned()}.
|
||||||
|
* @property string|null $price_id FK to {@see ProductPrice} used for cost calculation.
|
||||||
|
* @property-read ProductPrice|null $price Eager-loadable price relation.
|
||||||
|
* @property-read Model|null $purchasable The loaned item — typically a {@see IsLoanableProduct}-using model.
|
||||||
*/
|
*/
|
||||||
trait HasLoanLifecycle
|
trait HasLoanLifecycle
|
||||||
{
|
{
|
||||||
|
|
@ -154,8 +171,11 @@ trait HasLoanLifecycle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope: loans currently in the borrower's hands (not returned).
|
* Scope: loans currently in the borrower's hands (not returned).
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
*/
|
*/
|
||||||
public function scopeActiveLoans($query)
|
public function scopeActiveLoans(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query
|
return $query
|
||||||
->where('status', PurchaseStatus::PENDING->value)
|
->where('status', PurchaseStatus::PENDING->value)
|
||||||
|
|
@ -164,16 +184,22 @@ trait HasLoanLifecycle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope: loans that have been handed back.
|
* Scope: loans that have been handed back.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
*/
|
*/
|
||||||
public function scopeReturned($query)
|
public function scopeReturned(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereNotNull('meta->returned_at');
|
return $query->whereNotNull('meta->returned_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope: loans past their due date and not yet returned.
|
* Scope: loans past their due date and not yet returned.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
*/
|
*/
|
||||||
public function scopeOverdue($query)
|
public function scopeOverdue(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->activeLoans()->where('until', '<', now());
|
return $query->activeLoans()->where('until', '<', now());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Enums\OrderStatus;
|
use Blax\Shop\Enums\OrderStatus;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Models\PaymentMethod;
|
use Blax\Shop\Models\PaymentMethod;
|
||||||
|
|
@ -69,7 +71,7 @@ trait HasPaymentMethods
|
||||||
* @param bool $activeOnly Only return active payment methods
|
* @param bool $activeOnly Only return active payment methods
|
||||||
* @return Collection
|
* @return Collection
|
||||||
*/
|
*/
|
||||||
public function paymentMethods(string $provider = null, bool $activeOnly = true): Collection
|
public function paymentMethods(?string $provider = null, bool $activeOnly = true): Collection
|
||||||
{
|
{
|
||||||
$identities = $this->paymentProviderIdentities();
|
$identities = $this->paymentProviderIdentities();
|
||||||
|
|
||||||
|
|
@ -156,7 +158,7 @@ trait HasPaymentMethods
|
||||||
* @param string|null $provider
|
* @param string|null $provider
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function hasPaymentMethods(string $provider = null): bool
|
public function hasPaymentMethods(?string $provider = null): bool
|
||||||
{
|
{
|
||||||
return $this->paymentMethods($provider)->isNotEmpty();
|
return $this->paymentMethods($provider)->isNotEmpty();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Enums\PricingStrategy;
|
use Blax\Shop\Enums\PricingStrategy;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Enums\ProductRelationType;
|
use Blax\Shop\Enums\ProductRelationType;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Contracts\Purchasable;
|
use Blax\Shop\Contracts\Purchasable;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Enums\StockStatus;
|
use Blax\Shop\Enums\StockStatus;
|
||||||
use Blax\Shop\Enums\StockType;
|
use Blax\Shop\Enums\StockType;
|
||||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||||
|
use Blax\Shop\Models\ProductStock;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -12,29 +15,43 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HasStocks Trait
|
* HasStocks — stock management surface for Product-shaped models.
|
||||||
*
|
*
|
||||||
* Provides stock management functionality to Product models.
|
* Provides:
|
||||||
|
* - Basic stock operations: {@see self::increaseStock()}, {@see self::decreaseStock()},
|
||||||
|
* {@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:
|
* # Stock calculation
|
||||||
* - Basic stock operations (increase, decrease, adjust)
|
|
||||||
* - Stock claims for bookings/reservations
|
|
||||||
* - Date-based availability checking
|
|
||||||
* - Low stock detection
|
|
||||||
* - Stock movement logging
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* - **Physical stock**: sum of all COMPLETED entries (positive INCREASE +
|
||||||
* - Add 'manage_stock' boolean column to products table
|
* RETURN, negative DECREASE) that haven't expired.
|
||||||
* - Set manage_stock = true to enable stock tracking
|
* - **Available stock**: physical stock, with active CLAIMED entries netted
|
||||||
* - Use increaseStock/decreaseStock for inventory changes
|
* out (their DECREASE side reduces availability while the PENDING claim
|
||||||
* - Use claimStock for reservations/bookings
|
* sits open).
|
||||||
* - Use availableOnDate for date-based availability
|
* - **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:
|
* # Host-model contract
|
||||||
* - Physical Stock = Sum of all COMPLETED entries
|
*
|
||||||
* - Available Stock = Physical Stock (accounts for pending claims via their DECREASE entries)
|
* The trait is designed to be applied to {@see \Blax\Shop\Models\Product} and
|
||||||
* - Claimed Stock = Sum of PENDING claims
|
* its subclasses. It reads the columns declared below and the
|
||||||
* - Available on Date = Available Stock + All Claims - Claims Active on Date
|
* `singleProducts` relation supplied by {@see MayBePoolProduct} (when the
|
||||||
|
* host opts into pool support). Host models that don't manage stock should
|
||||||
|
* set `manage_stock = false`; in that mode every read returns
|
||||||
|
* `PHP_INT_MAX` and every mutation is a no-op returning `true`.
|
||||||
|
*
|
||||||
|
* @property string|int $id Primary key on the host model — used for cross-table FK writes.
|
||||||
|
* @property bool $manage_stock When `false`, stock methods short-circuit (treated as infinite supply).
|
||||||
|
* @property int|null $low_stock_threshold Threshold for {@see self::isLowStock()} / {@see self::scopeLowStock()}; null disables low-stock detection.
|
||||||
|
* @property \Illuminate\Database\Eloquent\Collection<int, \Blax\Shop\Models\Product> $singleProducts Pool-product relation supplied by {@see MayBePoolProduct}; only consulted in pool aggregation paths.
|
||||||
*/
|
*/
|
||||||
trait HasStocks
|
trait HasStocks
|
||||||
{
|
{
|
||||||
|
|
@ -48,7 +65,7 @@ trait HasStocks
|
||||||
public function stocks(): HasMany
|
public function stocks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(
|
return $this->hasMany(
|
||||||
config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'),
|
config('shop.models.product_stock', ProductStock::class),
|
||||||
'product_id'
|
'product_id'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +76,7 @@ trait HasStocks
|
||||||
public function allStocks(): HasMany
|
public function allStocks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(
|
return $this->hasMany(
|
||||||
config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'),
|
config('shop.models.product_stock', ProductStock::class),
|
||||||
'product_id'
|
'product_id'
|
||||||
)
|
)
|
||||||
->withExpired()
|
->withExpired()
|
||||||
|
|
@ -131,7 +148,7 @@ trait HasStocks
|
||||||
* @return bool True if successful
|
* @return bool True if successful
|
||||||
* @throws NotEnoughStockException If insufficient stock available
|
* @throws NotEnoughStockException If insufficient stock available
|
||||||
*/
|
*/
|
||||||
public function decreaseStock(int $quantity = 1, Carbon|null $until = null): bool
|
public function decreaseStock(int $quantity = 1, ?Carbon $until = null): bool
|
||||||
{
|
{
|
||||||
if (!$this->manage_stock) {
|
if (!$this->manage_stock) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -215,12 +232,12 @@ trait HasStocks
|
||||||
public function adjustStock(
|
public function adjustStock(
|
||||||
StockType $type,
|
StockType $type,
|
||||||
int $quantity,
|
int $quantity,
|
||||||
DateTimeInterface|null $until = null,
|
?DateTimeInterface $until = null,
|
||||||
DateTimeInterface|null $from = null,
|
?DateTimeInterface $from = null,
|
||||||
?StockStatus $status = null,
|
?StockStatus $status = null,
|
||||||
string|null $note = null,
|
?string $note = null,
|
||||||
Model|null $referencable = null
|
?Model $referencable = null
|
||||||
) {
|
): bool|\Blax\Shop\Models\ProductStock {
|
||||||
if (!$this->manage_stock) {
|
if (!$this->manage_stock) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +315,7 @@ trait HasStocks
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
$stockModel = config('shop.models.product_stock', ProductStock::class);
|
||||||
|
|
||||||
return $stockModel::claim(
|
return $stockModel::claim(
|
||||||
$this,
|
$this,
|
||||||
|
|
@ -451,6 +468,9 @@ trait HasStocks
|
||||||
* Includes products with:
|
* Includes products with:
|
||||||
* - Stock management disabled (always in stock), OR
|
* - Stock management disabled (always in stock), OR
|
||||||
* - Stock management enabled AND available stock > 0
|
* - Stock management enabled AND available stock > 0
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Database\Eloquent\Builder<static> $query
|
||||||
|
* @return \Illuminate\Database\Eloquent\Builder<static>
|
||||||
*/
|
*/
|
||||||
public function scopeInStock($query)
|
public function scopeInStock($query)
|
||||||
{
|
{
|
||||||
|
|
@ -470,6 +490,9 @@ trait HasStocks
|
||||||
* - Stock management is enabled
|
* - Stock management is enabled
|
||||||
* - low_stock_threshold is set
|
* - low_stock_threshold is set
|
||||||
* - Available stock <= threshold
|
* - Available stock <= threshold
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Database\Eloquent\Builder<static> $query
|
||||||
|
* @return \Illuminate\Database\Eloquent\Builder<static>
|
||||||
*/
|
*/
|
||||||
public function scopeLowStock($query)
|
public function scopeLowStock($query)
|
||||||
{
|
{
|
||||||
|
|
@ -506,11 +529,11 @@ trait HasStocks
|
||||||
* - Checking what's claimed but not released
|
* - Checking what's claimed but not released
|
||||||
* - Managing active bookings
|
* - Managing active bookings
|
||||||
*
|
*
|
||||||
* @return \Illuminate\Database\Eloquent\Builder
|
* @return \Illuminate\Database\Eloquent\Builder<\Blax\Shop\Models\ProductStock>
|
||||||
*/
|
*/
|
||||||
public function claims()
|
public function claims(): \Illuminate\Database\Eloquent\Builder
|
||||||
{
|
{
|
||||||
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
$stockModel = config('shop.models.product_stock', ProductStock::class);
|
||||||
|
|
||||||
return $stockModel::claims()
|
return $stockModel::claims()
|
||||||
->willExpire()
|
->willExpire()
|
||||||
|
|
@ -675,10 +698,10 @@ trait HasStocks
|
||||||
* Gets the availability on the day by time. 00:00 shows the availables at the start of the day.
|
* Gets the availability on the day by time. 00:00 shows the availables at the start of the day.
|
||||||
* Every other timestamp shows what total current availability is at that time.
|
* Every other timestamp shows what total current availability is at that time.
|
||||||
*
|
*
|
||||||
* @param null|DateTimeInterface $date
|
* @param null|DateTimeInterface $date
|
||||||
* @return array|int
|
* @return array<string, int>|int Map of HH:MM → available units, or PHP_INT_MAX when stock management is disabled.
|
||||||
*/
|
*/
|
||||||
public function dayAvailability(?DateTimeInterface $date = null)
|
public function dayAvailability(?DateTimeInterface $date = null): array|int
|
||||||
{
|
{
|
||||||
// For pool products, aggregate availability from all single items
|
// For pool products, aggregate availability from all single items
|
||||||
if (method_exists($this, 'isPool') && $this->isPool()) {
|
if (method_exists($this, 'isPool') && $this->isPool()) {
|
||||||
|
|
@ -794,7 +817,7 @@ trait HasStocks
|
||||||
// Get all date keys from first single (they should all have the same dates)
|
// Get all date keys from first single (they should all have the same dates)
|
||||||
if (!empty($singleAvailabilities)) {
|
if (!empty($singleAvailabilities)) {
|
||||||
$firstAvailability = $singleAvailabilities[0];
|
$firstAvailability = $singleAvailabilities[0];
|
||||||
foreach ($firstAvailability['dates'] as $dateKey => $dayData) {
|
foreach (array_keys($firstAvailability['dates']) as $dateKey) {
|
||||||
$dayMin = 0;
|
$dayMin = 0;
|
||||||
$dayMax = 0;
|
$dayMax = 0;
|
||||||
|
|
||||||
|
|
@ -826,10 +849,10 @@ trait HasStocks
|
||||||
/**
|
/**
|
||||||
* Get day availability for pool products by aggregating all single items
|
* Get day availability for pool products by aggregating all single items
|
||||||
*
|
*
|
||||||
* @param DateTimeInterface|null $date
|
* @param DateTimeInterface|null $date
|
||||||
* @return array
|
* @return array<string, int>|int Map of HH:MM → available units across all single items, or PHP_INT_MAX when no managed single items exist.
|
||||||
*/
|
*/
|
||||||
protected function getPoolDayAvailability(?DateTimeInterface $date = null): array
|
protected function getPoolDayAvailability(?DateTimeInterface $date = null): array|int
|
||||||
{
|
{
|
||||||
// Eager load single products if not already loaded
|
// Eager load single products if not already loaded
|
||||||
if (!$this->relationLoaded('singleProducts')) {
|
if (!$this->relationLoaded('singleProducts')) {
|
||||||
|
|
@ -897,16 +920,16 @@ trait HasStocks
|
||||||
* - The idea is that users can add items freely and adjust dates later
|
* - The idea is that users can add items freely and adjust dates later
|
||||||
* - Date-based validation happens at checkout, not when adding to cart
|
* - Date-based validation happens at checkout, not when adding to cart
|
||||||
*
|
*
|
||||||
* @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from
|
* @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from
|
||||||
* @return int Available quantity (PHP_INT_MAX if unlimited)
|
* @return int Available quantity (PHP_INT_MAX if unlimited)
|
||||||
*/
|
*/
|
||||||
public function getHasMore($cart = null): int
|
public function getHasMore(?\Blax\Shop\Models\Cart $cart = null): int
|
||||||
{
|
{
|
||||||
// Try to get current cart from facade if not provided
|
// Try to get current cart from facade if not provided
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
try {
|
try {
|
||||||
$cart = \Blax\Shop\Facades\Cart::current();
|
$cart = \Blax\Shop\Facades\Cart::current();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception) {
|
||||||
// No cart available, that's fine
|
// No cart available, that's fine
|
||||||
$cart = null;
|
$cart = null;
|
||||||
}
|
}
|
||||||
|
|
@ -943,10 +966,9 @@ trait HasStocks
|
||||||
* Returns total pool capacity minus items already in cart.
|
* Returns total pool capacity minus items already in cart.
|
||||||
* Does NOT consider date-based availability - that's validated at checkout.
|
* Does NOT consider date-based availability - that's validated at checkout.
|
||||||
*
|
*
|
||||||
* @param \Blax\Shop\Models\Cart|null $cart
|
* @param \Blax\Shop\Models\Cart|null $cart
|
||||||
* @return int
|
|
||||||
*/
|
*/
|
||||||
protected function getPoolHasMore($cart = null): int
|
protected function getPoolHasMore(?\Blax\Shop\Models\Cart $cart = null): int
|
||||||
{
|
{
|
||||||
// Get total pool capacity (NOT date-restricted)
|
// Get total pool capacity (NOT date-restricted)
|
||||||
if (method_exists($this, 'getPoolTotalCapacity')) {
|
if (method_exists($this, 'getPoolTotalCapacity')) {
|
||||||
|
|
@ -991,13 +1013,12 @@ trait HasStocks
|
||||||
*
|
*
|
||||||
* @param DateTimeInterface $from
|
* @param DateTimeInterface $from
|
||||||
* @param DateTimeInterface $until
|
* @param DateTimeInterface $until
|
||||||
* @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from
|
* @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from
|
||||||
* @return int
|
|
||||||
*/
|
*/
|
||||||
public function getAvailableForDateRange(
|
public function getAvailableForDateRange(
|
||||||
DateTimeInterface $from,
|
DateTimeInterface $from,
|
||||||
DateTimeInterface $until,
|
DateTimeInterface $until,
|
||||||
$cart = null
|
?\Blax\Shop\Models\Cart $cart = null
|
||||||
): int {
|
): int {
|
||||||
if ($this->manage_stock === false) {
|
if ($this->manage_stock === false) {
|
||||||
return PHP_INT_MAX;
|
return PHP_INT_MAX;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Enums\ProductStatus;
|
use Blax\Shop\Enums\ProductStatus;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue