Files
shopit-back/app/Domains/Catalog/Services/StockReservationService.php
ncoronel 414df97e9d Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

    Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations

commit ebcbbb1774
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 3dd378ed06
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 4f705175bd
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit 82570fe941
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit d2a2b323e0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit ec67623b7b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit f6f138e180
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit d612b7a118
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit 49f42d4507
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 1881cc1d4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 00ec37d8a0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit d9f374c718
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit bbfdf8f342
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit 8a0f29bdae
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 198ed400a9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 23d83166e6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit cb090402d0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit c120b6f7c0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 1106fa28b4
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 896fb81bcf
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 8545fbc645
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit dd6dcb708a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 624b246ff5
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 007b95b904
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit 0b3c8c93be
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit ede718e448
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit e63a03601a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 389373ad8c
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit ace02a3133
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit d69a6210f6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit 3c0b43fea3
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 9b0008626b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 61861e331a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 2cc9d7dd97
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit f982bcead1
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 7a85e1731d
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 6db99e775a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit e5f7ba3615
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit 24bfef431b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate
2026-08-26 10:30:16 -03:00

494 lines
20 KiB
PHP

<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StockReservationService
{
public const REASON_CART_EMPTY = 'cart_empty';
public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
public const REASON_MANUAL_RELEASE = 'manual_release';
public function __construct(
private readonly CatalogInventoryService $inventory,
) {}
public function syncCart(Cart $cart): ?StockReservation
{
return DB::transaction(function () use ($cart): ?StockReservation {
/** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadSelections($items);
$requirements = $this->requirementsForItems($items);
$reservation = $lockedCart->current_stock_reservation_id === null
? null
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
if ($reservation !== null) {
$this->assertUsableCartReservation($reservation);
}
if ($requirements === []) {
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
$this->finalizeLocked(
$reservation,
StockReservation::STATUS_RELEASED,
self::REASON_CART_EMPTY,
);
}
$lockedCart->update(['current_stock_reservation_id' => null]);
$cart->current_stock_reservation_id = null;
return null;
}
if ($reservation === null) {
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
}
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
}
$currentLines = StockReservationLine::query()
->where('stock_reservation_id', $reservation->getKey())
->orderBy('inventory_id')
->lockForUpdate()
->get()
->keyBy('inventory_id');
$inventoryIds = collect(array_keys($requirements))
->merge($currentLines->keys())
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
$inventories = Inventory::query()
->whereKey($inventoryIds)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($inventoryIds as $inventoryId) {
$inventory = $inventories->get($inventoryId)
?? throw new \InvalidArgumentException('No se encontró el inventario requerido.');
$previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0);
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
$delta = $required - $previous;
if ($delta > 0
&& $requirements[$inventoryId]['tracks_inventory']
&& $inventory->availableStock() < $delta) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.');
}
if ($delta < 0 && $inventory->reserved_stock < abs($delta)) {
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
}
}
foreach ($inventoryIds as $inventoryId) {
/** @var Inventory $inventory */
$inventory = $inventories->get($inventoryId);
$line = $currentLines->get($inventoryId);
$previous = (int) ($line?->quantity ?? 0);
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
$delta = $required - $previous;
if ($delta > 0) {
$inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']);
} elseif ($delta < 0) {
$inventory->release(abs($delta));
}
if ($required === 0) {
$line?->delete();
continue;
}
StockReservationLine::query()->updateOrCreate(
[
'stock_reservation_id' => $reservation->getKey(),
'inventory_id' => $inventoryId,
],
[
'quantity' => $required,
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
],
);
}
$reservation->update([
'expires_at' => $this->expiration(),
'release_reason' => null,
]);
$cart->current_stock_reservation_id = $reservation->getKey();
return $reservation->fresh('lines');
});
}
public function attachToPurchase(
Cart $cart,
Purchase $purchase,
Carbon $expiresAt,
): StockReservation {
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
/** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
/** @var Purchase $lockedPurchase */
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($lockedCart->current_stock_reservation_id === null) {
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
$this->assertUsableCartReservation($reservation);
$linkedPurchase = Purchase::query()
->where('stock_reservation_id', $reservation->getKey())
->whereKeyNot($lockedPurchase->getKey())
->exists();
if ($linkedPurchase) {
throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.');
}
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
$reservation->update(['expires_at' => $expiresAt]);
$purchase->stock_reservation_id = $reservation->getKey();
$cart->current_stock_reservation_id = $reservation->getKey();
return $reservation->fresh('lines');
});
}
public function commit(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id);
if ($reservation->status === StockReservation::STATUS_COMMITTED) {
return;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.');
}
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
throw new StockReservationExpiredException;
}
$lines = $this->lockLines($reservation);
if ($lines->isEmpty()) {
throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.');
}
$inventories = $this->lockInventories($lines);
foreach ($lines as $line) {
$inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
if ($inventory->reserved_stock < $line->quantity
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
}
}
foreach ($lines as $line) {
$inventories->get($line->inventory_id)->buy(
(int) $line->quantity,
(bool) $line->tracks_inventory,
);
}
$reservation->update([
'status' => StockReservation::STATUS_COMMITTED,
'expires_at' => null,
'committed_at' => now(),
'released_at' => null,
'expired_at' => null,
'release_reason' => null,
]);
});
}
public function releaseForPurchase(
Purchase $purchase,
string $status = StockReservation::STATUS_RELEASED,
?string $reason = null,
): void {
DB::transaction(function () use ($purchase, $status, $reason): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
return;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
if ($reservation !== null) {
$this->finalizeLocked($reservation, $status, $reason);
}
});
}
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
{
return DB::transaction(function () use ($purchase, $cart): StockReservation {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($purchase->stock_reservation_id === null
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($purchase->stock_reservation_id);
$this->assertUsableCartReservation($reservation);
$purchase->update(['stock_reservation_id' => null]);
$cart->update(['current_purchase_id' => null]);
$reservation->update(['expires_at' => $this->expiration()]);
return $reservation->fresh('lines');
});
}
public function assertCartReservationUsable(Cart $cart): void
{
DB::transaction(function () use ($cart): void {
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($cart->current_stock_reservation_id === null) {
return;
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($cart->current_stock_reservation_id);
$this->assertUsableCartReservation($reservation);
});
}
public function releaseCurrentCartReservation(
Cart $cart,
string $reason = self::REASON_CART_CHANGED,
): void {
DB::transaction(function () use ($cart, $reason): void {
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($cart->current_stock_reservation_id === null) {
return;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
if ($reservation !== null) {
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
}
$cart->update(['current_stock_reservation_id' => null]);
});
}
public function expire(StockReservation $reservation): void
{
DB::transaction(function () use ($reservation): void {
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null
|| $reservation->expires_at->isFuture()) {
return;
}
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
});
}
public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void
{
DB::transaction(function () use ($purchase, $expiresAt): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($purchase->stock_reservation_id);
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.');
}
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
throw new StockReservationExpiredException;
}
$reservation->update(['expires_at' => $expiresAt]);
});
}
/**
* @param Collection<int, CartItem> $items
* @return array<int, array{quantity: int, tracks_inventory: bool}>
*/
private function requirementsForItems(Collection $items): array
{
$requirements = [];
foreach ($items as $item) {
$selection = $item->selectedItem();
if ($selection === null) {
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
}
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
if (isset($requirements[$inventoryId])) {
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
$requirements[$inventoryId]['tracks_inventory'] =
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
continue;
}
$requirements[$inventoryId] = $requirement;
}
}
ksort($requirements);
return $requirements;
}
/** @param Collection<int, CartItem> $items */
private function loadSelections(Collection $items): void
{
$items->load([
'catalogItem.inventory',
'catalogItem.bundleComponents.catalogItem.inventory',
'catalogItem.bundleComponents.variant.inventory',
'catalogItem.bundleComponents.variant.catalogItem',
'variant.inventory',
'variant.catalogItem',
]);
}
/** @return Collection<int, StockReservationLine> */
private function lockLines(StockReservation $reservation): Collection
{
return StockReservationLine::query()
->where('stock_reservation_id', $reservation->getKey())
->orderBy('inventory_id')
->lockForUpdate()
->get();
}
/**
* @param Collection<int, StockReservationLine> $lines
* @return Collection<int, Inventory>
*/
private function lockInventories(Collection $lines): Collection
{
return Inventory::query()
->whereKey($lines->pluck('inventory_id'))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
}
private function finalizeLocked(
StockReservation $reservation,
string $status,
?string $reason,
): void {
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
return;
}
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
}
$lines = $this->lockLines($reservation);
$inventories = $this->lockInventories($lines);
foreach ($lines as $line) {
$inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
$inventory->release((int) $line->quantity);
}
$now = now();
$reservation->update([
'status' => $status,
'expires_at' => null,
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
]);
if ($status === StockReservation::STATUS_RELEASED) {
Cart::query()
->where('current_stock_reservation_id', $reservation->getKey())
->update(['current_stock_reservation_id' => null]);
}
}
private function assertUsableCartReservation(StockReservation $reservation): void
{
if ($reservation->status === StockReservation::STATUS_EXPIRED
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
throw new StockReservationExpiredException;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null) {
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
}
}
private function expiration(): Carbon
{
return now()->addMinutes(
max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)),
);
}
}