R loan purchase to physical loan, A physical loan stuff

This commit is contained in:
Fabian @ Blax Software 2026-05-17 15:20:58 +02:00
parent da6d89f668
commit 1ed301b759
10 changed files with 370 additions and 208 deletions

View File

@ -5,32 +5,37 @@ declare(strict_types=1);
namespace Blax\Shop\Enums; namespace Blax\Shop\Enums;
/** /**
* StockType Enum * StockType the kind of movement a {@see \Blax\Shop\Models\ProductStock}
* * row represents.
* Defines the types of stock movements that can occur. *
* * Two-axis classification:
* Types: *
* - CLAIMED: Stock claimed for reservation/booking (creates PENDING entry) * 1. Sign of the stock movement
* Used for temporary allocations that can be released * INCREASE / RETURN positive (stock added)
* Examples: hotel bookings, equipment rentals, cart reservations * DECREASE negative (stock removed)
* * CLAIMED positive quantity but stored as a PENDING
* - RETURN: Stock returned to inventory (e.g., customer returns) * reservation that nets to negative against
* Creates a positive adjustment to physical stock * available stock until the claim is released
* * PHYSICALLY_CLAIMED same as CLAIMED, but never auto-released by
* - INCREASE: Stock added to inventory (e.g., new purchases, restocking) * {@see \Blax\Shop\Models\ProductStock::releaseExpired()}.
* Creates a positive adjustment to physical stock * Used for loans: the borrower physically has the
* * item until they return it the expires_at column
* - DECREASE: Stock removed from inventory (e.g., sales, damage, loss) * carries the due date for overdue tracking, not a
* Creates a negative adjustment to physical stock * release deadline.
* *
* Usage Flow: * 2. Release model
* 1. INCREASE/DECREASE: Direct physical stock changes (COMPLETED status) * INCREASE / DECREASE / RETURN COMPLETED at write time, permanent
* 2. CLAIMED: Temporary allocation (PENDING status, can be released) * CLAIMED PENDING, auto-released at expires_at
* 3. RETURN: Special case of INCREASE for returned items * PHYSICALLY_CLAIMED PENDING, manual release only
*
* The two claim types share availability semantics both subtract from
* `available` and both contribute to `currently_claimed` so most queries
* filter by {@see self::claimTypeValues()} rather than a single case.
*/ */
enum StockType: string enum StockType: string
{ {
case CLAIMED = 'claimed'; case CLAIMED = 'claimed';
case PHYSICALLY_CLAIMED = 'physically_claimed';
case RETURN = 'return'; case RETURN = 'return';
case INCREASE = 'increase'; case INCREASE = 'increase';
case DECREASE = 'decrease'; case DECREASE = 'decrease';
@ -39,9 +44,38 @@ enum StockType: string
{ {
return match ($this) { return match ($this) {
self::CLAIMED => 'Claimed', self::CLAIMED => 'Claimed',
self::PHYSICALLY_CLAIMED => 'Physically claimed',
self::RETURN => 'Return', self::RETURN => 'Return',
self::INCREASE => 'Increase', self::INCREASE => 'Increase',
self::DECREASE => 'Decrease', self::DECREASE => 'Decrease',
}; };
} }
/**
* The claim-style types both reserve stock against availability and
* keep a PENDING row in the ledger until released. CLAIMED auto-releases
* at `expires_at`; PHYSICALLY_CLAIMED is manual-release only.
*
* @return array<int, self>
*/
public static function claimTypes(): array
{
return [self::CLAIMED, self::PHYSICALLY_CLAIMED];
}
/**
* Same as {@see self::claimTypes()} but as string values, for use in
* `whereIn(...)` SQL clauses.
*
* @return array<int, string>
*/
public static function claimTypeValues(): array
{
return [self::CLAIMED->value, self::PHYSICALLY_CLAIMED->value];
}
public function isClaim(): bool
{
return in_array($this, self::claimTypes(), strict: true);
}
} }

View File

@ -159,15 +159,31 @@ class ProductStock extends Model
* @param string|null $note Optional note about the claim * @param string|null $note Optional note about the claim
* @return self|null The created claim entry, or null if insufficient stock * @return self|null The created claim entry, or null if insufficient stock
*/ */
/**
* Claim stock for a product (reservation/booking/loan).
*
* Creates a two-row entry:
* 1. DECREASE row (negative quantity, COMPLETED) removes from
* `available` immediately.
* 2. Claim row (positive quantity, PENDING) tracks the reservation
* until released. `$type` controls whether the claim auto-releases
* at `expires_at` (CLAIMED) or stays manual-release only
* (PHYSICALLY_CLAIMED, used for loans).
*
* @param StockType $type Either CLAIMED (default booking-style,
* {@see self::releaseExpired()} eligible) or PHYSICALLY_CLAIMED
* (loan-style, manual release only).
*/
public static function claim( public static function claim(
Product $product, Product $product,
int $quantity, int $quantity,
$reference = null, $reference = null,
?\DateTimeInterface $from = null, ?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null, ?\DateTimeInterface $until = null,
?string $note = null ?string $note = null,
StockType $type = StockType::CLAIMED,
): ?self { ): ?self {
return DB::transaction(function () use ($product, $quantity, $reference, $from, $until, $note) { return DB::transaction(function () use ($product, $quantity, $reference, $from, $until, $note, $type) {
// When claiming for a future booking, check availability at the start date // When claiming for a future booking, check availability at the start date
// Otherwise claims for different time periods would incorrectly conflict // Otherwise claims for different time periods would incorrectly conflict
$checkDate = $from ?? now(); $checkDate = $from ?? now();
@ -193,7 +209,7 @@ class ProductStock extends Model
return self::create([ return self::create([
'product_id' => $product->id, 'product_id' => $product->id,
'quantity' => $quantity, 'quantity' => $quantity,
'type' => StockType::CLAIMED, 'type' => $type,
'status' => StockStatus::PENDING, 'status' => StockStatus::PENDING,
'reference_type' => $reference ? get_class($reference) : null, 'reference_type' => $reference ? get_class($reference) : null,
'reference_id' => $reference?->id, 'reference_id' => $reference?->id,
@ -304,7 +320,14 @@ class ProductStock extends Model
*/ */
public static function releaseExpired(): int public static function releaseExpired(): int
{ {
$expired = self::expired()->get(); // Auto-release only the CLAIMED type. PHYSICALLY_CLAIMED rows carry
// an `expires_at` to drive overdue tracking on the loan side, but
// they must NOT be auto-released — the borrower physically has the
// item until they bring it back, regardless of how overdue the
// due date is.
$expired = self::expired()
->where('type', StockType::CLAIMED->value)
->get();
$count = 0; $count = 0;
foreach ($expired as $stock) { foreach ($expired as $stock) {
@ -331,7 +354,9 @@ class ProductStock extends Model
*/ */
public function scopeAvailableClaims($query) public function scopeAvailableClaims($query)
{ {
return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value); return $query
->whereIn('type', StockType::claimTypeValues())
->where('status', StockStatus::PENDING->value);
} }
/** /**
@ -360,7 +385,7 @@ class ProductStock extends Model
*/ */
public function scopeAvailableOnDate($query, \DateTimeInterface $date) public function scopeAvailableOnDate($query, \DateTimeInterface $date)
{ {
return $query->where('type', StockType::CLAIMED->value) return $query->whereIn('type', StockType::claimTypeValues())
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)
->where(function ($q) use ($date) { ->where(function ($q) use ($date) {
$q->where(function ($subQuery) use ($date) { $q->where(function ($subQuery) use ($date) {

View File

@ -5,11 +5,15 @@ 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\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
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 Blax\Shop\Models\ProductStock;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon; use Carbon\Carbon;
/** /**
@ -145,30 +149,86 @@ trait HasLoanLifecycle
$this->meta = $meta; $this->meta = $meta;
$this->save(); $this->save();
// Push the paired physical claim's expires_at to match the new due
// date so calendar/overdue checks at the stock level stay in sync
// with the purchase's `until`. Loans created before this rewiring
// (no linked claim) silently skip.
if ($this->until !== null) {
$this->loanClaimQuery()
->whereNotNull('expires_at')
->update(['expires_at' => $this->until]);
}
event(new LoanExtended($this, $weeks)); event(new LoanExtended($this, $weeks));
return $this; return $this;
} }
/** /**
* Mark the item returned: stamp meta.returned_at with the given moment * Mark the item returned: stamp meta.returned_at, flip status to
* (or now()) and flip status to completed so the row reads as final. * COMPLETED, and release the paired PHYSICALLY_CLAIMED stock entry
* (which automatically creates the offsetting RETURN row via
* {@see ProductStock::release()}). All three operations happen inside
* a single transaction so physical inventory is consistent at every
* observable instant.
*
* Idempotent: a second call on an already-returned loan is a no-op
* the returned_at timestamp is not restamped and the claim is not
* re-released.
*/ */
public function markReturned(?\DateTimeInterface $at = null): self public function markReturned(?\DateTimeInterface $at = null): self
{ {
if ($this->isReturned()) {
return $this;
}
$at ??= now(); $at ??= now();
$meta = (array) ($this->meta ?? []); DB::transaction(function () use ($at) {
$meta['returned_at'] = Carbon::instance($at)->toIso8601String(); $meta = (array) ($this->meta ?? []);
$this->meta = $meta; $meta['returned_at'] = Carbon::instance($at)->toIso8601String();
$this->status = PurchaseStatus::COMPLETED; $this->meta = $meta;
$this->save(); $this->status = PurchaseStatus::COMPLETED;
$this->save();
// Release the paired physical claim. Each release() call
// creates a RETURN entry that offsets the DECREASE written
// alongside the original claim at checkout, so available stock
// naturally restores. For loans created before this rewiring
// (no linked claim), this short-circuits and the host is still
// responsible for the increaseStock(1) — but new code paths
// won't need to do anything.
$this->loanClaimQuery()->each(fn (ProductStock $claim) => $claim->release());
});
event(new LoanReturned($this)); event(new LoanReturned($this));
return $this; return $this;
} }
/**
* Builder for the PHYSICALLY_CLAIMED stock row(s) created alongside
* this loan at checkout. Used by {@see self::markReturned()} (to
* release the claim) and {@see self::extend()} (to push expires_at).
*
* Polymorphic lookup uses the purchase's primary key directly the
* reference_type was stored as the concrete ProductPurchase class at
* checkout time, but we look up by id alone since UUIDs are unique
* across the table. Filters to PENDING + PHYSICALLY_CLAIMED so any
* already-released or unrelated rows are skipped.
*
* @return \Illuminate\Database\Eloquent\Builder<ProductStock>
*/
protected function loanClaimQuery(): \Illuminate\Database\Eloquent\Builder
{
$stockModel = config('shop.models.product_stock', ProductStock::class);
return $stockModel::query()
->where('reference_id', $this->getKey())
->where('type', StockType::PHYSICALLY_CLAIMED->value)
->where('status', StockStatus::PENDING->value);
}
/** /**
* Scope: loans currently in the borrower's hands (not returned). * Scope: loans currently in the borrower's hands (not returned).
* *

View File

@ -100,7 +100,7 @@ trait HasStocks
{ {
return $this->stocks() return $this->stocks()
->available() ->available()
->where('type', '!=', StockType::CLAIMED->value) ->whereNotIn('type', StockType::claimTypeValues())
->willExpire() ->willExpire()
->sum('quantity') ?? 0; ->sum('quantity') ?? 0;
} }
@ -148,29 +148,27 @@ trait HasStocks
* regardless of whether they're temporarily out (on loan, claimed by a * regardless of whether they're temporarily out (on loan, claimed by a
* cart/booking) or sitting on the shelf. * cart/booking) or sitting on the shelf.
* *
* available + currentlyClaimed + activeLoans * physical = available + currentlyClaimed
* *
* Why three terms? * Why only two terms? Both CLAIMED and PHYSICALLY_CLAIMED rows count
* - available units on the shelf, free for new use. * toward `currentlyClaimed` (see {@see StockType::claimTypeValues()}),
* - currentlyClaimed units held by a cart, booking, or other * so cart reservations, bookings, AND loans all flow through the same
* reservation that hasn't been finalised * claim machinery. Loans no longer need a separate `activeLoans` term
* (will come back if the claim expires). * the PHYSICALLY_CLAIMED stock row IS the active loan.
* - activeLoans units checked out via a PENDING
* {@see \Blax\Shop\Models\ProductPurchase}
* row that hasn't been returned yet
* (loaned items still belong to the library).
* *
* Worked examples: * Worked examples:
* - Tomato shop: bought 10, sold 3 DECREASE -3 is permanent (no * - Tomato shop: bought 10, sold 3 DECREASE -3 is permanent (no
* claim/loan to offset). Physical = 7. Available = 7. * claim/loan to offset). Physical = 7. Available = 7.
* - Library: bought 5, loaned 1 DECREASE -1 + active loan +1. * - Library: bought 5, loaned 1 one PHYSICALLY_CLAIMED row.
* Physical = 4 + 0 + 1 = 5. Available = 4. * Available = 4, currentlyClaimed = 1, physical = 5.
* - Hotel: 1 room, future booking claim sits in the future, no * - Hotel: 1 room, future booking CLAIMED row, claimed_from
* current claim yet. Physical = 1 + 0 + 0 = 1. * in the future. Available = 1 today, currentlyClaimed = 0 today,
* physical = 1.
* *
* Distinct from {@see self::getMaxStocksAttribute} (which sums every * Distinct from {@see self::getMaxStocksAttribute} (which sums every
* INCREASE/RETURN row ever written and so inflates after every loan * INCREASE/RETURN row ever written and so inflates after every loan
* return), and from {@see \Blax\Shop\Traits\MayBeLoanableProduct::getTotalQuantityAttribute} * return cycle) and from
* {@see \Blax\Shop\Traits\MayBeLoanableProduct::getTotalQuantityAttribute}
* (which is loanable-only). This one works for every Product type. * (which is loanable-only). This one works for every Product type.
*/ */
public function getPhysicalStockAttribute(): int public function getPhysicalStockAttribute(): int
@ -179,25 +177,7 @@ trait HasStocks
return PHP_INT_MAX; return PHP_INT_MAX;
} }
$available = $this->getAvailableStock(); return $this->getAvailableStock() + $this->getCurrentlyClaimedStock();
$currentClaims = $this->getCurrentlyClaimedStock();
// Query loans by purchasable_id only — the morphMany on $this->purchases()
// narrows by purchasable_type using static::class, which silently
// misses rows written under a subclass type (e.g. App\Models\Book)
// when the caller resolved the product via the base Product class
// (as `shop:stocks:availability` does). Product UUIDs are unique
// across the table so dropping the type filter is safe.
$purchaseModel = config(
'shop.models.product_purchase',
\Blax\Shop\Models\ProductPurchase::class,
);
$activeLoans = (int) $purchaseModel::query()
->where('purchasable_id', $this->getKey())
->activeLoans()
->sum('quantity');
return $available + $currentClaims + $activeLoans;
} }
/** /**
@ -354,14 +334,16 @@ trait HasStocks
return false; return false;
} }
// For CLAIMED type, delegate to claimStock which handles the two-entry pattern // For claim-style types, delegate to claimStock which handles the
if ($type === StockType::CLAIMED) { // two-entry pattern (DECREASE + PENDING claim row).
if ($type->isClaim()) {
return $this->claimStock( return $this->claimStock(
quantity: $quantity, quantity: $quantity,
reference: $referencable, reference: $referencable,
from: $from, from: $from,
until: $until, until: $until,
note: $note note: $note,
type: $type
); );
} }
@ -395,24 +377,22 @@ trait HasStocks
} }
/** /**
* Claim stock for temporary use (reservation/booking) * Claim stock for temporary use (reservation/booking/loan).
* *
* This is different from decreaseStock - it: * Different from decreaseStock it:
* 1. Removes stock from available inventory (via DECREASE entry) * 1. Removes stock from available inventory (via DECREASE entry)
* 2. Tracks it as a claim (via CLAIMED entry with PENDING status) * 2. Tracks it as a claim (via CLAIMED/PHYSICALLY_CLAIMED entry with PENDING status)
* 3. Can be released back later (changes CLAIMED to COMPLETED) * 3. Can be released later (status PENDING COMPLETED + paired RETURN)
* 4. Supports date ranges for bookings (claimed_from to expires_at) * 4. Supports date ranges (claimed_from expires_at)
* *
* Use cases: * Use cases:
* - Hotel room bookings (claimed_from = check-in, expires_at = check-out) * - Hotel room bookings CLAIMED, expires_at = check-out (auto-releases).
* - Equipment rentals (claimed_from = rental start, expires_at = return date) * - Cart reservations CLAIMED, expires_at = cart expiry (auto-releases).
* - Cart reservations (no claimed_from, expires_at = cart expiry) * - Library loans PHYSICALLY_CLAIMED, expires_at = due date
* * (overdue tracking only does not auto-release).
* @param int $quantity Amount to claim *
* @param mixed $reference Optional reference model (Order, Booking, Cart, etc.) * @param StockType $type CLAIMED (default, auto-release) or
* @param DateTimeInterface|null $from When claim starts (null = immediately) * PHYSICALLY_CLAIMED (manual release only used by loans).
* @param DateTimeInterface|null $until When claim expires (null = permanent)
* @param string|null $note Optional note about the claim
* @return \Blax\Shop\Models\ProductStock|null The claim entry, or null if insufficient stock * @return \Blax\Shop\Models\ProductStock|null The claim entry, or null if insufficient stock
*/ */
public function claimStock( public function claimStock(
@ -420,7 +400,8 @@ trait HasStocks
$reference = null, $reference = null,
?DateTimeInterface $from = null, ?DateTimeInterface $from = null,
?DateTimeInterface $until = null, ?DateTimeInterface $until = null,
?string $note = null ?string $note = null,
StockType $type = StockType::CLAIMED,
): ?\Blax\Shop\Models\ProductStock { ): ?\Blax\Shop\Models\ProductStock {
if (!$this->manage_stock) { if (!$this->manage_stock) {
@ -437,7 +418,8 @@ trait HasStocks
$reference, $reference,
$from, $from,
$until, $until,
$note $note,
$type,
); );
if ($claim) { if ($claim) {
@ -478,7 +460,7 @@ trait HasStocks
$baseStock = $this->stocks() $baseStock = $this->stocks()
->withoutGlobalScope('willExpire') ->withoutGlobalScope('willExpire')
->where('status', StockStatus::COMPLETED->value) ->where('status', StockStatus::COMPLETED->value)
->where('type', '!=', StockType::CLAIMED->value) ->whereNotIn('type', StockType::claimTypeValues())
->where(function ($query) use ($date) { ->where(function ($query) use ($date) {
$query->whereNull('expires_at') $query->whereNull('expires_at')
->orWhere('expires_at', '>', $date); ->orWhere('expires_at', '>', $date);
@ -488,16 +470,20 @@ trait HasStocks
// Add back claims that should not reduce availability at the given date // Add back claims that should not reduce availability at the given date
$inactiveClaims = $this->stocks() $inactiveClaims = $this->stocks()
->withoutGlobalScope('willExpire') ->withoutGlobalScope('willExpire')
->where('type', StockType::CLAIMED->value) ->whereIn('type', StockType::claimTypeValues())
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)
->where(function ($query) use ($date) { ->where(function ($query) use ($date) {
$query->where(function ($q) use ($date) { $query->where(function ($q) use ($date) {
// Claim has not started yet // Claim has not started yet — applies to both claim types.
$q->whereNotNull('claimed_from') $q->whereNotNull('claimed_from')
->where('claimed_from', '>', $date); ->where('claimed_from', '>', $date);
})->orWhere(function ($q) use ($date) { })->orWhere(function ($q) use ($date) {
// Claim expired before the date // Claim expired before the date — only for CLAIMED, which
$q->whereNotNull('expires_at') // auto-releases at expires_at. PHYSICALLY_CLAIMED (loans)
// stays reserved until manually returned; expires_at is
// informational/overdue-tracking only.
$q->where('type', StockType::CLAIMED->value)
->whereNotNull('expires_at')
->where('expires_at', '<=', $date); ->where('expires_at', '<=', $date);
}); });
}) })
@ -518,7 +504,7 @@ trait HasStocks
public function getCurrentlyClaimedStock(): int public function getCurrentlyClaimedStock(): int
{ {
return abs($this->stocks() return abs($this->stocks()
->where('type', StockType::CLAIMED->value) ->whereIn('type', StockType::claimTypeValues())
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)
->willExpire() ->willExpire()
->where(function ($query) { ->where(function ($query) {
@ -538,7 +524,7 @@ trait HasStocks
public function getActiveAndPlannedClaimedStock(): int public function getActiveAndPlannedClaimedStock(): int
{ {
return abs($this->stocks() return abs($this->stocks()
->where('type', StockType::CLAIMED->value) ->whereIn('type', StockType::claimTypeValues())
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)
->willExpire() ->willExpire()
->sum('quantity')); ->sum('quantity'));
@ -553,7 +539,7 @@ trait HasStocks
public function getFutureClaimedStock(?DateTimeInterface $from = null): int public function getFutureClaimedStock(?DateTimeInterface $from = null): int
{ {
$query = $this->stocks() $query = $this->stocks()
->where('type', StockType::CLAIMED->value) ->whereIn('type', StockType::claimTypeValues())
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)
->willExpire(); ->willExpire();
@ -677,7 +663,7 @@ trait HasStocks
$nextClaimEnd = $this->stocks() $nextClaimEnd = $this->stocks()
->withoutGlobalScope('willExpire') ->withoutGlobalScope('willExpire')
->where('type', StockType::CLAIMED->value) ->whereIn('type', StockType::claimTypeValues())
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)
->whereNotNull('expires_at') ->whereNotNull('expires_at')
->where('expires_at', '>', now()) ->where('expires_at', '>', now())
@ -749,7 +735,7 @@ trait HasStocks
$baseStock = $this->stocks() $baseStock = $this->stocks()
->withoutGlobalScope('willExpire') ->withoutGlobalScope('willExpire')
->where('status', StockStatus::COMPLETED->value) ->where('status', StockStatus::COMPLETED->value)
->where('type', '!=', StockType::CLAIMED->value) ->whereNotIn('type', StockType::claimTypeValues())
->where('created_at', '<=', $dateDayEnd) ->where('created_at', '<=', $dateDayEnd)
->where(function ($query) use ($date) { ->where(function ($query) use ($date) {
$query->whereNull('expires_at') $query->whereNull('expires_at')
@ -758,16 +744,19 @@ trait HasStocks
->sum('quantity'); ->sum('quantity');
// Add back claims that should not reduce availability at the given date. // Add back claims that should not reduce availability at the given date.
// Same asymmetry as getAvailableStock: PHYSICALLY_CLAIMED rows stay
// reserved past expires_at (overdue loan still in borrower's hands).
$inactiveClaims = $this->stocks() $inactiveClaims = $this->stocks()
->withoutGlobalScope('willExpire') ->withoutGlobalScope('willExpire')
->where('type', StockType::CLAIMED->value) ->whereIn('type', StockType::claimTypeValues())
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)
->where(function ($query) use ($date) { ->where(function ($query) use ($date) {
$query->where(function ($q) use ($date) { $query->where(function ($q) use ($date) {
$q->whereNotNull('claimed_from') $q->whereNotNull('claimed_from')
->where('claimed_from', '>', $date); ->where('claimed_from', '>', $date);
})->orWhere(function ($q) use ($date) { })->orWhere(function ($q) use ($date) {
$q->whereNotNull('expires_at') $q->where('type', StockType::CLAIMED->value)
->whereNotNull('expires_at')
->where('expires_at', '<=', $date); ->where('expires_at', '<=', $date);
}); });
}) })
@ -814,10 +803,10 @@ trait HasStocks
// Group conditions with OR to keep them within the product_id scope // Group conditions with OR to keep them within the product_id scope
$query->where(function ($q) { $query->where(function ($q) {
$q->where('status', StockStatus::COMPLETED->value) $q->where('status', StockStatus::COMPLETED->value)
->where('type', '!=', StockType::CLAIMED->value); ->whereNotIn('type', StockType::claimTypeValues());
})->orWhere(function ($q) { })->orWhere(function ($q) {
$q->where('status', StockStatus::PENDING->value) $q->where('status', StockStatus::PENDING->value)
->where('type', StockType::CLAIMED->value); ->whereIn('type', StockType::claimTypeValues());
}); });
}) })
->get(); ->get();
@ -861,7 +850,8 @@ trait HasStocks
foreach ($events as $eventTime) { foreach ($events as $eventTime) {
$available = 0; $available = 0;
foreach ($allStocks as $stock) { foreach ($allStocks as $stock) {
if ($stock->status === StockStatus::COMPLETED && $stock->type !== StockType::CLAIMED) { $isClaim = $stock->type instanceof StockType && $stock->type->isClaim();
if ($stock->status === StockStatus::COMPLETED && ! $isClaim) {
// A COMPLETED entry only contributes from the day it // A COMPLETED entry only contributes from the day it
// was created — without this gate, a DECREASE from a // was created — without this gate, a DECREASE from a
// loan placed today would retroactively reduce // loan placed today would retroactively reduce
@ -874,10 +864,19 @@ trait HasStocks
if ($hasStarted && $notExpired) { if ($hasStarted && $notExpired) {
$available += $stock->quantity; $available += $stock->quantity;
} }
} elseif ($stock->status === StockStatus::PENDING && $stock->type === StockType::CLAIMED) { } elseif ($stock->status === StockStatus::PENDING && $isClaim) {
// Add back if NOT active at this timestamp // Add back if NOT active at this timestamp. For
// CLAIMED (auto-release booking) we also add back
// past-expires_at — the booking window is over so the
// unit is free again. PHYSICALLY_CLAIMED (loans)
// stays reserved past expires_at because the
// borrower physically has the item until they return
// it — the calendar should show "unavailable" even
// for overdue loans.
$isNotStarted = $stock->claimed_from && $stock->claimed_from > $eventTime; $isNotStarted = $stock->claimed_from && $stock->claimed_from > $eventTime;
$isExpired = $stock->expires_at && $stock->expires_at <= $eventTime; $isExpired = $stock->type === StockType::CLAIMED
&& $stock->expires_at
&& $stock->expires_at <= $eventTime;
if ($isNotStarted || $isExpired) { if ($isNotStarted || $isExpired) {
$available += $stock->quantity; $available += $stock->quantity;
} }

View File

@ -163,12 +163,11 @@ trait MayBeLoanableProduct
$weeks ??= (int) config('shop.loan.default_duration_weeks', 2); $weeks ??= (int) config('shop.loan.default_duration_weeks', 2);
$now = Carbon::now(); $now = Carbon::now();
$until = $now->copy()->addWeeks($weeks);
$price ??= $this->defaultPrice()->first(); $price ??= $this->defaultPrice()->first();
$purchase = DB::transaction(function () use ($borrower, $weeks, $price, $now): ProductPurchase { $purchase = DB::transaction(function () use ($borrower, $price, $now, $until): ProductPurchase {
$this->decreaseStock(1); $purchase = $this->purchases()->create([
return $this->purchases()->create([
'purchaser_id' => $borrower->getKey(), 'purchaser_id' => $borrower->getKey(),
'purchaser_type' => $borrower::class, 'purchaser_type' => $borrower::class,
'price_id' => $price?->id, 'price_id' => $price?->id,
@ -177,9 +176,27 @@ trait MayBeLoanableProduct
'amount_paid' => 0, 'amount_paid' => 0,
'status' => PurchaseStatus::PENDING, 'status' => PurchaseStatus::PENDING,
'from' => $now, 'from' => $now,
'until' => $now->copy()->addWeeks($weeks), 'until' => $until,
'meta' => ['extensions_used' => 0], 'meta' => ['extensions_used' => 0],
]); ]);
// Loan model = booking-style claim that doesn't auto-release.
// The PHYSICALLY_CLAIMED row drives availability/calendar and
// carries the due date in `expires_at` for overdue tracking,
// while ProductStock::releaseExpired() pointedly skips this
// type — the borrower physically has the item until they bring
// it back, regardless of how overdue they get. markReturned()
// releases the claim, which creates the offsetting RETURN entry.
$this->claimStock(
quantity: 1,
reference: $purchase,
from: $now,
until: $until,
note: 'Loan to ' . class_basename($borrower::class) . ' #' . substr((string) $borrower->getKey(), 0, 8),
type: \Blax\Shop\Enums\StockType::PHYSICALLY_CLAIMED,
);
return $purchase;
}); });
event(new LoanCreated($purchase)); event(new LoanCreated($purchase));

View File

@ -179,24 +179,48 @@ class CheckOutToTest extends TestCase
} }
#[Test] #[Test]
public function mark_returned_does_not_restore_stock_intentionally(): void public function mark_returned_releases_the_paired_physical_claim_and_restores_stock(): void
{ {
// Locking-in regression test for an opinionated design choice: the // Replaces the prior "must not restore stock" assertion. Loans are
// package's markReturned() flips lifecycle state but leaves stock // now modelled as PHYSICALLY_CLAIMED stock entries; markReturned()
// alone. Hosts that model loans as borrow-and-return (rather than // releases that claim, which creates the offsetting RETURN row via
// permanent ownership transfer) must follow up with an explicit // ProductStock::release() — so available stock comes back to where
// increaseStock(1) — see moonshiner-library's LoanController. // it was before the loan with no host bookkeeping required.
$availableBefore = $this->book->fresh()->getAvailableStock();
$loan = $this->book->checkOutTo($this->borrower); $loan = $this->book->checkOutTo($this->borrower);
$availableAfterCheckout = $this->book->fresh()->getAvailableStock(); $this->assertSame(
$availableBefore - 1,
$this->book->fresh()->getAvailableStock(),
'checkout drops available by quantity',
);
$loan->markReturned(); $loan->markReturned();
$this->assertSame( $this->assertSame(
$availableAfterCheckout, $availableBefore,
$this->book->fresh()->getAvailableStock(), $this->book->fresh()->getAvailableStock(),
'markReturned() must not change stock — hosts opt in to that explicitly.', 'markReturned() restores available via the released claim',
); );
} }
#[Test]
public function mark_returned_is_idempotent_and_does_not_double_restore_stock(): void
{
// A retried markReturned() call (network flake, double-click on a
// librarian button, etc.) must not inflate stock past the catalogue
// size by re-releasing an already-released claim.
$availableBefore = $this->book->fresh()->getAvailableStock();
$loan = $this->book->checkOutTo($this->borrower);
$loan->markReturned();
$afterFirst = $this->book->fresh()->getAvailableStock();
$loan->markReturned();
$afterSecond = $this->book->fresh()->getAvailableStock();
$this->assertSame($availableBefore, $afterFirst);
$this->assertSame($afterFirst, $afterSecond, 'second call must be a no-op');
}
} }
/** /**

View File

@ -194,12 +194,12 @@ class LoanLifecycleTest extends TestCase
/* ───────────────────── edge cases ───────────────────── */ /* ───────────────────── edge cases ───────────────────── */
#[Test] #[Test]
public function mark_returned_called_twice_keeps_the_first_returned_at_timestamp(): void public function mark_returned_is_idempotent_first_write_wins(): void
{ {
// markReturned is idempotent-ish: calling it again overwrites the // markReturned() now no-ops on already-returned loans so a retried
// returned_at timestamp. We document that behaviour explicitly so a // call (network flake, double-submit) can't re-release the paired
// future refactor knows whether to keep it. If you want first-write- // claim and inflate available stock past the catalogue size. The
// wins, change markReturned() to no-op when already returned. // first returned_at timestamp is the canonical one.
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
$loan = $this->checkout(); $loan = $this->checkout();
@ -209,10 +209,11 @@ class LoanLifecycleTest extends TestCase
Carbon::setTestNow(Carbon::parse('2026-05-20 10:00:00')); Carbon::setTestNow(Carbon::parse('2026-05-20 10:00:00'));
$loan->markReturned(); $loan->markReturned();
$this->assertNotSame($firstReturnedAt, $loan->returnedAt(), 'second call overwrites'); $this->assertSame($firstReturnedAt, $loan->returnedAt(), 'second call does not restamp');
$this->assertSame( $this->assertSame(
Carbon::parse('2026-05-20 10:00:00')->toIso8601String(), Carbon::parse('2026-05-14 10:00:00')->toIso8601String(),
$loan->returnedAt(), $loan->returnedAt(),
'first write wins',
); );
} }

View File

@ -95,11 +95,14 @@ class LoanShopCommandsTest extends TestCase
$book = $this->newBook('Hyperion', 'CMD-HYP'); $book = $this->newBook('Hyperion', 'CMD-HYP');
$book->increaseStock(3); $book->increaseStock(3);
// Borrow twice, return one. Ledger = seed + 2 decreases + 1 increase. // Borrow twice, return one. With the claim-based loan model the
// ledger carries: seed INCREASE, two claim cycles each writing a
// DECREASE + PHYSICALLY_CLAIMED row, plus one RETURN from the
// released claim — six rows total, but the operator only needs to
// see the standard increase/decrease verbs that the command renders.
$loan = $book->checkOutTo($this->borrower); $loan = $book->checkOutTo($this->borrower);
$book->checkOutTo(User::factory()->create()); $book->checkOutTo(User::factory()->create());
$loan->markReturned(); $loan->markReturned();
$book->increaseStock(1);
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP']); $output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP']);
@ -119,25 +122,20 @@ class LoanShopCommandsTest extends TestCase
public function assigned_for_loanable_product_must_not_inflate_after_a_return_cycle(): void public function assigned_for_loanable_product_must_not_inflate_after_a_return_cycle(): void
{ {
// Regression test for the bug where shop:stocks rendered Assigned=4 // Regression test for the bug where shop:stocks rendered Assigned=4
// for a 3-copy book that had been borrowed-and-returned once. The // for a 3-copy book that had been borrowed-and-returned once. With
// sequence is: INCREASE +3 (seed), DECREASE -1 (loan), INCREASE +1 // the new claim-based loan model the issue disappears at the source:
// (host-driven return restock). getMaxStocksAttribute sums every // checkout writes a DECREASE + PHYSICALLY_CLAIMED pair, the return
// positive entry — so it returned 3 + 1 = 4 — which the command then // releases the claim (status flip + RETURN row), and the net effect
// rendered verbatim. Fix: consult `total_quantity`, which is loan-aware // on every accessor reads exactly 3 copies — no inflation possible.
// (available stock + active loans = physical inventory we own).
$book = $this->newBook('Hyperion', 'CMD-HYP-RC'); $book = $this->newBook('Hyperion', 'CMD-HYP-RC');
$book->increaseStock(3); $book->increaseStock(3);
$loan = $book->checkOutTo($this->borrower); $loan = $book->checkOutTo($this->borrower);
$loan->markReturned(); $loan->markReturned();
$book->increaseStock(1);
// Sanity: model accessors disagree — getMaxStocksAttribute is inflated
// (this is the documented limitation of the underlying calc), while
// total_quantity reports the truth. The command must use total_quantity.
$fresh = $book->fresh(); $fresh = $book->fresh();
$this->assertSame(4, $fresh->getMaxStocksAttribute(), 'inflated by design'); $this->assertSame(3, (int) $fresh->total_quantity, 'loan-aware accessor reads true count');
$this->assertSame(3, (int) $fresh->total_quantity, 'loan-aware accessor'); $this->assertSame(3, $fresh->getPhysicalStock(), 'physical reads true count');
// Detail view: ASSIGNED row must be 3, not 4. // Detail view: ASSIGNED row must be 3, not 4.
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP-RC']); $output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP-RC']);
@ -228,8 +226,7 @@ class LoanShopCommandsTest extends TestCase
$book = $this->newBook('Singular', 'CMD-SIN'); $book = $this->newBook('Singular', 'CMD-SIN');
$book->increaseStock(1); $book->increaseStock(1);
$loan = $book->checkOutTo($this->borrower); $loan = $book->checkOutTo($this->borrower);
$loan->markReturned(); $loan->markReturned(); // releases the claim → restores available
$book->increaseStock(1);
$output = $this->runOk(ShopAvailabilityCommand::class, [ $output = $this->runOk(ShopAvailabilityCommand::class, [
'product' => 'CMD-SIN', 'product' => 'CMD-SIN',

View File

@ -5,9 +5,10 @@ namespace Blax\Shop\Tests\Feature\Loan;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;
use Blax\Shop\Events\StockDecreased; use Blax\Shop\Events\StockClaimed;
use Blax\Shop\Events\StockDepleted; use Blax\Shop\Events\StockDepleted;
use Blax\Shop\Events\StockIncreased; use Blax\Shop\Events\StockIncreased;
use Blax\Shop\Events\StockReleased;
use Blax\Shop\Events\StockReplenished; use Blax\Shop\Events\StockReplenished;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock; use Blax\Shop\Models\ProductStock;
@ -44,36 +45,43 @@ class LoanStockEventsTest extends TestCase
} }
#[Test] #[Test]
public function checkOutTo_dispatches_stock_decreased_with_correct_payload(): void public function checkOutTo_dispatches_stock_claimed_with_a_physically_claimed_row(): void
{ {
Event::fake([StockDecreased::class]); // Loans go through the claim machinery (PHYSICALLY_CLAIMED type), so
// the canonical "stock has moved" event is StockClaimed — not
// StockDecreased. The bookkeeping DECREASE row that claimStock writes
// internally bypasses the public decreaseStock() path (and so does
// not fire StockDecreased), matching how bookings have always worked.
Event::fake([StockClaimed::class]);
$this->book->checkOutTo($this->borrower); $loan = $this->book->checkOutTo($this->borrower);
Event::assertDispatched( Event::assertDispatched(
StockDecreased::class, StockClaimed::class,
fn (StockDecreased $e) => $e->product->is($this->book) fn (StockClaimed $e) => $e->product->is($this->book)
&& $e->availableAfter === 2
&& $e->entry instanceof ProductStock && $e->entry instanceof ProductStock
&& (int) $e->entry->quantity === -1 && (int) $e->entry->quantity === 1
&& $e->entry->type === StockType::DECREASE && $e->entry->type === StockType::PHYSICALLY_CLAIMED
&& $e->entry->status === StockStatus::COMPLETED, && $e->entry->status === StockStatus::PENDING
&& (string) $e->entry->reference_id === (string) $loan->id,
); );
} }
#[Test] #[Test]
public function checking_out_the_last_copy_dispatches_stock_depleted(): void public function checking_out_the_last_copy_dispatches_stock_depleted(): void
{ {
// 3 copies on the shelf — borrow all three; the third call crosses the // 3 copies on the shelf — borrow all three. The third call crosses
// last-copy boundary so StockDepleted must fire alongside StockDecreased. // the last-copy boundary; claimStock's dispatchStockTransitions fires
// StockDepleted regardless of whether the decrement came from a
// direct decreaseStock or a claim.
$this->book->checkOutTo(User::factory()->create()); $this->book->checkOutTo(User::factory()->create());
$this->book->checkOutTo(User::factory()->create()); $this->book->checkOutTo(User::factory()->create());
Event::fake([StockDepleted::class, StockDecreased::class]); Event::fake([StockDepleted::class, StockClaimed::class]);
$this->book->checkOutTo($this->borrower); $this->book->checkOutTo($this->borrower);
Event::assertDispatched(StockDecreased::class); Event::assertDispatched(StockClaimed::class);
Event::assertDispatched( Event::assertDispatched(
StockDepleted::class, StockDepleted::class,
fn (StockDepleted $e) => $e->product->is($this->book), fn (StockDepleted $e) => $e->product->is($this->book),
@ -91,24 +99,26 @@ class LoanStockEventsTest extends TestCase
} }
#[Test] #[Test]
public function restocking_after_a_full_loan_dispatches_stock_replenished(): void public function returning_a_fully_loaned_book_dispatches_replenished_and_released(): void
{ {
// Single-copy book, borrow it (depletes to 0), then a host-driven // Single-copy book, borrow it (depletes to 0), then return it.
// increaseStock(1) on the return path must cross 0→>0 and fire // markReturned() releases the paired claim, which creates the
// StockReplenished. Mirrors what moonshiner-library does in // offsetting RETURN entry via the package's release() helper — so
// LoanController::returnLoan after $loan->markReturned(). // StockIncreased + StockReleased both fire, and the 0→1 boundary
// crossing additionally triggers StockReplenished. No host call
// to increaseStock() needed.
$single = EventLoanBook::create(['name' => 'Solitaire', 'sku' => 'SOL-EV-1']); $single = EventLoanBook::create(['name' => 'Solitaire', 'sku' => 'SOL-EV-1']);
$single->increaseStock(1); $single->increaseStock(1);
$loan = $single->checkOutTo($this->borrower); $loan = $single->checkOutTo($this->borrower);
$loan->markReturned();
$this->assertSame(0, $single->fresh()->getAvailableStock()); $this->assertSame(0, $single->fresh()->getAvailableStock());
Event::fake([StockReplenished::class, StockIncreased::class]); Event::fake([StockReplenished::class, StockIncreased::class, StockReleased::class]);
$single->increaseStock(1); $loan->markReturned();
Event::assertDispatched(StockIncreased::class); Event::assertDispatched(StockIncreased::class);
Event::assertDispatched(StockReleased::class);
Event::assertDispatched( Event::assertDispatched(
StockReplenished::class, StockReplenished::class,
fn (StockReplenished $e) => $e->product->is($single) && $e->availableAfter === 1, fn (StockReplenished $e) => $e->product->is($single) && $e->availableAfter === 1,
@ -116,16 +126,15 @@ class LoanStockEventsTest extends TestCase
} }
#[Test] #[Test]
public function restocking_when_other_copies_are_free_does_not_dispatch_replenished(): void public function returning_when_other_copies_are_free_does_not_dispatch_replenished(): void
{ {
// 3-copy book, borrow 1 → 2 available. Returning that copy goes 2→3, // 3-copy book, borrow 1 → 2 available. Returning goes 2→3, NOT a
// NOT a 0→>0 transition, so StockReplenished must stay silent. // 0→>0 transition, so StockReplenished must stay silent.
$loan = $this->book->checkOutTo($this->borrower); $loan = $this->book->checkOutTo($this->borrower);
$loan->markReturned();
Event::fake([StockReplenished::class]); Event::fake([StockReplenished::class]);
$this->book->increaseStock(1); $loan->markReturned();
Event::assertNotDispatched(StockReplenished::class); Event::assertNotDispatched(StockReplenished::class);
} }
@ -133,11 +142,14 @@ class LoanStockEventsTest extends TestCase
#[Test] #[Test]
public function event_wiring_holds_across_a_full_borrow_return_cycle(): void public function event_wiring_holds_across_a_full_borrow_return_cycle(): void
{ {
// Full sequence: borrow → return-restock. We assert the relative count // Full sequence: borrow → return. checkOutTo fires StockClaimed (no
// and payload of each event in one go so a future refactor that splits // StockDecreased — claim machinery bypasses it). markReturned fires
// the path can't pass the per-step tests while breaking the rollup. // StockReleased + StockIncreased (the release writes a RETURN entry
// through increaseStock). The 3→2 and 2→3 transitions are boundary-
// free so StockDepleted/StockReplenished stay quiet.
Event::fake([ Event::fake([
StockDecreased::class, StockClaimed::class,
StockReleased::class,
StockIncreased::class, StockIncreased::class,
StockDepleted::class, StockDepleted::class,
StockReplenished::class, StockReplenished::class,
@ -145,9 +157,9 @@ class LoanStockEventsTest extends TestCase
$loan = $this->book->checkOutTo($this->borrower); $loan = $this->book->checkOutTo($this->borrower);
$loan->markReturned(); $loan->markReturned();
$this->book->increaseStock(1);
Event::assertDispatchedTimes(StockDecreased::class, 1); Event::assertDispatchedTimes(StockClaimed::class, 1);
Event::assertDispatchedTimes(StockReleased::class, 1);
Event::assertDispatchedTimes(StockIncreased::class, 1); Event::assertDispatchedTimes(StockIncreased::class, 1);
Event::assertNotDispatched(StockDepleted::class, '3→2 is not a depletion'); Event::assertNotDispatched(StockDepleted::class, '3→2 is not a depletion');
Event::assertNotDispatched(StockReplenished::class, '2→3 is not a replenishment'); Event::assertNotDispatched(StockReplenished::class, '2→3 is not a replenishment');

View File

@ -125,10 +125,10 @@ class PhysicalStockTest extends TestCase
$loan = $book->checkOutTo($borrower); $loan = $book->checkOutTo($borrower);
$this->assertSame(5, $book->fresh()->getPhysicalStock()); $this->assertSame(5, $book->fresh()->getPhysicalStock());
// Host-driven return: mark + restock (mirrors moonshiner's // markReturned() now does the full job — releases the paired
// LoanController::returnLoan). // physical claim, which writes the offsetting RETURN entry. No
// host-side increaseStock(1) needed.
$loan->markReturned(); $loan->markReturned();
$book->increaseStock(1);
$fresh = $book->fresh(); $fresh = $book->fresh();
$this->assertSame(5, $fresh->getAvailableStock()); $this->assertSame(5, $fresh->getAvailableStock());
@ -216,45 +216,38 @@ class PhysicalStockTest extends TestCase
#[Test] #[Test]
public function physical_does_not_inflate_after_a_library_loan_return_cycle(): void public function physical_does_not_inflate_after_a_library_loan_return_cycle(): void
{ {
// Regression: getMaxStocksAttribute sums every INCREASE row — including // Regression: getMaxStocksAttribute sums every INCREASE row — the
// the +1 from a loan return — so for a borrow-and-return cycle it // claim/release machinery writes a RETURN row at return time, which
// overstates "Assigned" as 6 on a 5-copy book. physical_stock uses the // is INCREASE-like and so still inflates "Assigned". physical_stock
// available+claims+loans formula instead and stays correctly at 5. // uses the available+claims formula instead and stays correctly at 5
// through any number of cycles.
$book = $this->book(5); $book = $this->book(5);
$loan = $book->checkOutTo(User::factory()->create()); $loan = $book->checkOutTo(User::factory()->create());
$loan->markReturned(); $loan->markReturned();
$book->increaseStock(1);
$fresh = $book->fresh(); $fresh = $book->fresh();
$this->assertSame(6, $fresh->getMaxStocksAttribute(), 'documented limitation: max inflates per cycle'); $this->assertGreaterThan(5, $fresh->getMaxStocksAttribute(), 'documented limitation: max inflates per cycle');
$this->assertSame(5, $fresh->getPhysicalStock(), 'physical stays at the real owned count'); $this->assertSame(5, $fresh->getPhysicalStock(), 'physical stays at the real owned count');
} }
#[Test] #[Test]
public function loan_quantity_above_one_aggregates_into_physical(): void public function multi_unit_physical_claim_aggregates_into_physical(): void
{ {
// Defensive coverage: real-world loans are always quantity=1, but the // Defensive coverage for the claim-based loan model: a
// formula sums purchase.quantity rather than counting rows, so a // PHYSICALLY_CLAIMED row with quantity > 1 should contribute its
// hypothetical multi-unit loan would still account correctly. // full quantity to physical (10 = 7 on shelf + 3 claimed).
$book = $this->book(10); $book = $this->book(10);
$book->decreaseStock(3); // simulate the stock-side of a 3-unit loan
$borrower = User::factory()->create(); $book->claimStock(
ProductPurchase::create([ quantity: 3,
'purchasable_id' => $book->id, from: now(),
'purchasable_type' => Product::class, until: now()->addWeeks(2),
'purchaser_id' => $borrower->id, type: \Blax\Shop\Enums\StockType::PHYSICALLY_CLAIMED,
'purchaser_type' => User::class, );
'quantity' => 3,
'amount' => 0,
'amount_paid' => 0,
'status' => PurchaseStatus::PENDING,
'from' => now(),
'until' => now()->addWeeks(2),
'meta' => ['extensions_used' => 0],
]);
$fresh = $book->fresh(); $fresh = $book->fresh();
$this->assertSame(7, $fresh->getAvailableStock()); $this->assertSame(7, $fresh->getAvailableStock());
$this->assertSame(10, $fresh->getPhysicalStock(), '7 on shelf + 3 on loan = 10 owned'); $this->assertSame(3, $fresh->getCurrentlyClaimedStock());
$this->assertSame(10, $fresh->getPhysicalStock(), '7 on shelf + 3 physically claimed = 10 owned');
} }
} }