feat: promote IsLoanableProduct to MayBeLoanableProduct on Product

Renames the host-attached IsLoanableProduct trait to MayBeLoanableProduct
and mixes it directly into Product, matching the existing MayBePoolProduct
shape. Hosts opt in with a single DEFAULT_TYPE constant — no more manual
`use ...Trait` ceremony to forget:

    class Book extends Product
    {
        public const DEFAULT_TYPE = ProductType::LOANABLE;
    }

The boot hook reads DEFAULT_TYPE on the concrete class and only applies
the LOANABLE creating-defaults (type, status, is_visible, manage_stock)
when it matches; type-specific helpers (checkOutTo, total_quantity,
available_quantity) early-out via isLoanable() so they're harmless on
non-loanable products. checkOutTo now throws NotLoanableProductException
when called on the wrong type, mirroring NotPoolProductException.

Also fixes total_quantity for the loan lifecycle: previously summed every
INCREASE entry in product_stocks, which inflated the displayed total
after each loan cycle because returns fire increaseStock(). Now reports
physical inventory as availableStock + activeLoans.

README gains a Testing section that surfaces the current phpunit summary
line, plus passing and assertion-count badges linking to it.
This commit is contained in:
Fabian @ Blax Software 2026-05-16 12:17:38 +02:00
parent afdcd8bc75
commit 8eb1802ef8
7 changed files with 118 additions and 46 deletions

View File

@ -3,6 +3,8 @@
# Laravel Shop
[![Tests](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml/badge.svg)](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
[![Tests Count](https://img.shields.io/badge/tests-1228%20passing-success?style=flat-square)](#testing)
[![Assertions](https://img.shields.io/badge/assertions-3291-blue?style=flat-square)](#testing)
[![Latest Version](https://img.shields.io/packagist/v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop)
[![License](https://img.shields.io/packagist/l/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop)
[![PHP Version](https://img.shields.io/packagist/php-v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop)
@ -184,13 +186,24 @@ $isAvailable = $room->availableOnDate(now(), now()->addHour());
## Testing
To run the package tests:
We test this package for many edge cases across every surface — products,
stock, pricing strategies, cart/checkout, loan lifecycle, pool aggregation,
booking, Stripe sync and the event surface — so host applications can lean
on the behaviour with confidence.
```
Tests: 1228, Assertions: 3291
```
CI runs the full suite on every push (see the badge above). To run it
locally:
```bash
./vendor/bin/phpunit
```
The tests use an in-memory SQLite database and Orchestra Testbench.
The tests use an in-memory SQLite database and Orchestra Testbench, so they
run in roughly a minute with no external services required.
## Documentation

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
use Exception;
class NotLoanableProductException extends Exception
{
public function __construct(string $message = "This method is only for loanable products.")
{
parent::__construct($message);
}
}

View File

@ -25,6 +25,7 @@ use Blax\Shop\Traits\HasPrices;
use Blax\Shop\Traits\HasPricingStrategy;
use Blax\Shop\Traits\HasProductRelations;
use Blax\Shop\Traits\HasStocks;
use Blax\Shop\Traits\MayBeLoanableProduct;
use Blax\Shop\Traits\MayBePoolProduct;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -47,6 +48,7 @@ use Illuminate\Support\Facades\Cache;
* - {@see HasCategories} category attachment.
* - {@see HasProductRelations} cross-sells, upsells, pool↔single links.
* - {@see MayBePoolProduct} pool aggregation when `type = POOL`.
* - {@see MayBeLoanableProduct} loan lifecycle when `type = LOANABLE`.
* - {@see ChecksIfBooking} booking-product helpers when `type = BOOKING`.
*
* Host apps can extend this model (`Book extends Product` style) for free
@ -87,7 +89,7 @@ use Illuminate\Support\Facades\Cache;
*/
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, MayBeLoanableProduct, ChecksIfBooking;
protected $fillable = [
'slug',

View File

@ -29,7 +29,7 @@ use Carbon\Carbon;
* Domain status (returned / overdue / active) is derived; see
* {@see getDomainStatus()} and the corresponding scopes.
*
* The product-side counterpart is {@see IsLoanableProduct}, which exposes a
* The product-side counterpart is {@see MayBeLoanableProduct}, which exposes a
* `loan()` helper to create a purchase row pre-filled for this lifecycle.
*
* # Host-model contract
@ -43,7 +43,7 @@ use Carbon\Carbon;
* @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.
* @property-read Model|null $purchasable The loaned item typically a {@see MayBeLoanableProduct}-using model.
*/
trait HasLoanLifecycle
{

View File

@ -9,6 +9,7 @@ use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Events\LoanCreated;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotLoanableProductException;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\ProductPurchase;
use Carbon\Carbon;
@ -16,60 +17,85 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* Drop on a {@see \Blax\Shop\Models\Product} subclass to declare it a
* loanable item. Provides:
* Mixed into {@see \Blax\Shop\Models\Product} so every product can be asked
* "are you loanable?" and, if so, expose the loan-specific helpers below.
* Mirrors the shape of {@see MayBePoolProduct}: the helpers early-out for
* products whose `type` is not LOANABLE.
*
* - Sensible defaults on `creating` (type=LOANABLE, manage_stock=true,
* status=PUBLISHED, is_visible=true) so callers can omit the e-commerce
* columns and just give the product its domain attributes.
* Plug-n-pray for host apps: declare the constant and you're done.
*
* - A virtual `total_quantity` setter that's translated into a stock
* INCREASE entry the moment the row is saved so a single
* `Book::create(['title' => …, 'total_quantity' => 3])` produces a
* book with three copies in stock.
* class Book extends \Blax\Shop\Models\Product
* {
* public const DEFAULT_TYPE = ProductType::LOANABLE;
* }
*
* - `checkOutTo($borrower, $weeks, $price)` atomic decrement + purchase
* creation + LoanCreated event dispatch, all in one call. Replaces the
* DB::transaction + decreaseStock + purchases()->create + event boilerplate
* every host controller would otherwise repeat.
* The boot hook reads `DEFAULT_TYPE` on the concrete class and, when it's
* LOANABLE, applies sensible defaults on `creating` (type, status=PUBLISHED,
* is_visible=true, manage_stock=true) so callers can omit e-commerce columns
* entirely. Hosts that want a loanable Product *instance* without declaring
* the constant can still set `type` explicitly at create time.
*
* The `total_quantity` virtual fillable translates into a stock INCREASE
* entry on `created` when (and only when) the product is loanable:
*
* Book::create(['name' => , 'total_quantity' => 3])
*
* `checkOutTo($borrower, $weeks, $price)` wraps decrement + purchase
* creation + LoanCreated dispatch in a single transaction; throws
* NotLoanableProductException when called on a non-loanable product.
*
* Pair with {@see HasLoanLifecycle} on ProductPurchase (already mixed in via
* the package's default ProductPurchase model) to get the full borrow
* extend return state machine for free.
*
* Example host model:
*
* class Book extends \Blax\Shop\Models\Product
* {
* use \Blax\Shop\Traits\IsLoanableProduct;
*
* public function getTitleAttribute(): ?string { return $this->name; }
* public function setTitleAttribute(?string $v): void { $this->attributes['name'] = $v; }
* public function getIsbnAttribute(): ?string { return $this->sku; }
* public function setIsbnAttribute(?string $v): void { $this->attributes['sku'] = $v; }
* }
* Host models use the inherited `name` and `sku` columns directly. Anything
* domain-specific (author, page count, language…) belongs in
* {@see \Blax\Shop\Models\ProductAttribute}, and the public API can rename
* columns at the Resource layer.
*/
trait IsLoanableProduct
trait MayBeLoanableProduct
{
/** Captured by setTotalQuantityAttribute; consumed in created(). */
protected int $initialLoanableQuantity = 0;
public function isLoanable(): bool
{
return $this->type === ProductType::LOANABLE;
}
/**
* Treat title / isbn / total_quantity as fillable virtual attributes by
* default. Hosts that don't need them can override getFillable().
* `total_quantity` stays universally fillable setting it on a
* non-loanable product is a no-op (the created() hook below only fires
* the stock INCREASE when isLoanable()), so there's no need to gate
* the fillable list per type.
*/
public function getFillable(): array
{
return array_merge(parent::getFillable(), ['title', 'isbn', 'total_quantity']);
return array_merge(parent::getFillable(), ['total_quantity']);
}
/**
* Physical inventory = copies on the shelf + copies currently loaned out.
* We can't use getMaxStocksAttribute() here because it sums every
* INCREASE entry in product_stocks including the increaseStock() call
* a return fires which inflates the displayed total after each loan
* cycle even though no new copies were ever acquired.
*/
public function getTotalQuantityAttribute(): int
{
if (! $this->isLoanable()) {
return (int) $this->getMaxStocksAttribute();
}
if (! $this->exists) {
return $this->initialLoanableQuantity;
}
return (int) $this->getMaxStocksAttribute();
if ($this->manage_stock === false) {
return PHP_INT_MAX;
}
return $this->getAvailableStock() + $this->purchases()->activeLoans()->count();
}
public function setTotalQuantityAttribute(int $value): void
@ -83,9 +109,12 @@ trait IsLoanableProduct
return $this->getAvailableStock();
}
public static function bootIsLoanableProduct(): void
public static function bootMayBeLoanableProduct(): void
{
static::creating(function ($product): void {
if (self::resolveDefaultProductType($product) !== ProductType::LOANABLE) {
return;
}
$product->type ??= ProductType::LOANABLE;
$product->status ??= ProductStatus::PUBLISHED;
$product->is_visible ??= true;
@ -93,12 +122,19 @@ trait IsLoanableProduct
});
static::created(function ($product): void {
if ($product->initialLoanableQuantity > 0) {
if ($product->isLoanable() && $product->initialLoanableQuantity > 0) {
$product->increaseStock($product->initialLoanableQuantity);
}
});
}
private static function resolveDefaultProductType(object $product): ?ProductType
{
$constant = $product::class . '::DEFAULT_TYPE';
return defined($constant) ? constant($constant) : null;
}
/**
* Atomically check out one unit of this product to a borrower.
*
@ -113,6 +149,7 @@ trait IsLoanableProduct
* @param int|null $weeks Loan duration; defaults to shop.loan.default_duration_weeks
* @param ProductPrice|null $price Override price; defaults to product's defaultPrice
*
* @throws NotLoanableProductException When called on a non-loanable product
* @throws NotEnoughStockException When no copies are available
*/
public function checkOutTo(
@ -120,6 +157,10 @@ trait IsLoanableProduct
?int $weeks = null,
?ProductPrice $price = null,
): ProductPurchase {
if (! $this->isLoanable()) {
throw new NotLoanableProductException();
}
$weeks ??= (int) config('shop.loan.default_duration_weeks', 2);
$now = Carbon::now();
$price ??= $this->defaultPrice()->first();

View File

@ -2,13 +2,13 @@
namespace Blax\Shop\Tests\Feature\Loan;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Events\LoanCreated;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Blax\Shop\Traits\IsLoanableProduct;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Coverage for {@see IsLoanableProduct::checkOutTo()} the atomic
* Coverage for {@see MayBeLoanableProduct::checkOutTo()} the atomic
* decreaseStock + purchase row + LoanCreated event path that every host
* controller uses to start a loan. Until this file existed, the trait that
* production code actually depends on had zero direct test coverage; the
@ -200,15 +200,16 @@ class CheckOutToTest extends TestCase
}
/**
* Minimal loanable fixture: extending Product picks up the package's
* polymorphism, the IsLoanableProduct trait wires up checkOutTo and the
* total_quantity / available_quantity virtuals. Both base and subclass
* resolve to the `products` table via Product::__construct, so no migration
* is needed.
* Minimal loanable fixture: extending Product gives us the package's
* polymorphism and the MayBeLoanableProduct helpers (it's mixed into Product
* itself); declaring DEFAULT_TYPE = LOANABLE is the plug-n-pray opt-in that
* flips checkOutTo and the total_quantity / available_quantity virtuals into
* loan mode. Both base and subclass resolve to the `products` table via
* Product::__construct, so no migration is needed.
*/
class LoanableBook extends Product
{
use IsLoanableProduct;
public const DEFAULT_TYPE = ProductType::LOANABLE;
protected $guarded = [];
}

View File

@ -19,7 +19,7 @@ use Workbench\App\Models\User;
/**
* Loan lifecycle domain events.
*
* LoanCreated auto-dispatched from IsLoanableProduct::checkOutTo()
* LoanCreated auto-dispatched from MayBeLoanableProduct::checkOutTo()
* (see CheckOutToTest). Hosts that build ProductPurchase
* rows directly bypassing checkOutTo must dispatch
* the event themselves; that direct-construction path is
@ -95,7 +95,7 @@ class LoanEventsTest extends TestCase
#[Test]
public function loan_created_can_be_dispatched_manually_when_bypassing_checkOutTo(): void
{
// The package auto-dispatches LoanCreated from IsLoanableProduct::
// The package auto-dispatches LoanCreated from MayBeLoanableProduct::
// checkOutTo() (see CheckOutToTest). Hosts that assemble a
// ProductPurchase row directly — e.g. importing historical loans —
// can still raise the same event so downstream listeners fire.