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

View File

@ -29,7 +29,7 @@ use Carbon\Carbon;
* Domain status (returned / overdue / active) is derived; see * Domain status (returned / overdue / active) is derived; see
* {@see getDomainStatus()} and the corresponding scopes. * {@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. * `loan()` helper to create a purchase row pre-filled for this lifecycle.
* *
* # Host-model contract * # Host-model contract
@ -43,7 +43,7 @@ use Carbon\Carbon;
* @property \Blax\Shop\Enums\PurchaseStatus $status Set to `COMPLETED` on {@see self::markReturned()}. * @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 string|null $price_id FK to {@see ProductPrice} used for cost calculation.
* @property-read ProductPrice|null $price Eager-loadable price relation. * @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 trait HasLoanLifecycle
{ {

View File

@ -9,6 +9,7 @@ use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Events\LoanCreated; use Blax\Shop\Events\LoanCreated;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotLoanableProductException;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Carbon\Carbon; use Carbon\Carbon;
@ -16,60 +17,85 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
* Drop on a {@see \Blax\Shop\Models\Product} subclass to declare it a * Mixed into {@see \Blax\Shop\Models\Product} so every product can be asked
* loanable item. Provides: * "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, * Plug-n-pray for host apps: declare the constant and you're done.
* status=PUBLISHED, is_visible=true) so callers can omit the e-commerce
* columns and just give the product its domain attributes.
* *
* - A virtual `total_quantity` setter that's translated into a stock * class Book extends \Blax\Shop\Models\Product
* INCREASE entry the moment the row is saved so a single * {
* `Book::create(['title' => …, 'total_quantity' => 3])` produces a * public const DEFAULT_TYPE = ProductType::LOANABLE;
* book with three copies in stock. * }
* *
* - `checkOutTo($borrower, $weeks, $price)` atomic decrement + purchase * The boot hook reads `DEFAULT_TYPE` on the concrete class and, when it's
* creation + LoanCreated event dispatch, all in one call. Replaces the * LOANABLE, applies sensible defaults on `creating` (type, status=PUBLISHED,
* DB::transaction + decreaseStock + purchases()->create + event boilerplate * is_visible=true, manage_stock=true) so callers can omit e-commerce columns
* every host controller would otherwise repeat. * 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 * Pair with {@see HasLoanLifecycle} on ProductPurchase (already mixed in via
* the package's default ProductPurchase model) to get the full borrow * the package's default ProductPurchase model) to get the full borrow
* extend return state machine for free. * extend return state machine for free.
* *
* Example host model: * Host models use the inherited `name` and `sku` columns directly. Anything
* * domain-specific (author, page count, language…) belongs in
* class Book extends \Blax\Shop\Models\Product * {@see \Blax\Shop\Models\ProductAttribute}, and the public API can rename
* { * columns at the Resource layer.
* 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; }
* }
*/ */
trait IsLoanableProduct trait MayBeLoanableProduct
{ {
/** Captured by setTotalQuantityAttribute; consumed in created(). */ /** Captured by setTotalQuantityAttribute; consumed in created(). */
protected int $initialLoanableQuantity = 0; protected int $initialLoanableQuantity = 0;
public function isLoanable(): bool
{
return $this->type === ProductType::LOANABLE;
}
/** /**
* Treat title / isbn / total_quantity as fillable virtual attributes by * `total_quantity` stays universally fillable setting it on a
* default. Hosts that don't need them can override getFillable(). * 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 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 public function getTotalQuantityAttribute(): int
{ {
if (! $this->isLoanable()) {
return (int) $this->getMaxStocksAttribute();
}
if (! $this->exists) { if (! $this->exists) {
return $this->initialLoanableQuantity; 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 public function setTotalQuantityAttribute(int $value): void
@ -83,9 +109,12 @@ trait IsLoanableProduct
return $this->getAvailableStock(); return $this->getAvailableStock();
} }
public static function bootIsLoanableProduct(): void public static function bootMayBeLoanableProduct(): void
{ {
static::creating(function ($product): void { static::creating(function ($product): void {
if (self::resolveDefaultProductType($product) !== ProductType::LOANABLE) {
return;
}
$product->type ??= ProductType::LOANABLE; $product->type ??= ProductType::LOANABLE;
$product->status ??= ProductStatus::PUBLISHED; $product->status ??= ProductStatus::PUBLISHED;
$product->is_visible ??= true; $product->is_visible ??= true;
@ -93,12 +122,19 @@ trait IsLoanableProduct
}); });
static::created(function ($product): void { static::created(function ($product): void {
if ($product->initialLoanableQuantity > 0) { if ($product->isLoanable() && $product->initialLoanableQuantity > 0) {
$product->increaseStock($product->initialLoanableQuantity); $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. * 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 int|null $weeks Loan duration; defaults to shop.loan.default_duration_weeks
* @param ProductPrice|null $price Override price; defaults to product's defaultPrice * @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 * @throws NotEnoughStockException When no copies are available
*/ */
public function checkOutTo( public function checkOutTo(
@ -120,6 +157,10 @@ trait IsLoanableProduct
?int $weeks = null, ?int $weeks = null,
?ProductPrice $price = null, ?ProductPrice $price = null,
): ProductPurchase { ): ProductPurchase {
if (! $this->isLoanable()) {
throw new NotLoanableProductException();
}
$weeks ??= (int) config('shop.loan.default_duration_weeks', 2); $weeks ??= (int) config('shop.loan.default_duration_weeks', 2);
$now = Carbon::now(); $now = Carbon::now();
$price ??= $this->defaultPrice()->first(); $price ??= $this->defaultPrice()->first();

View File

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

View File

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