diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index 24a7f51..43137f0 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -118,6 +118,37 @@ class StockReservationService ]); } + public function restore(CartItem $cartItem, CatalogItem|Variant $selection): void + { + DB::transaction(function () use ($cartItem, $selection): void { + $requirements = $this->inventory->requirementsFor( + $selection, + (int) $cartItem->cantidad, + ); + $activeReservations = StockReservation::query() + ->where('cart_item_id', $cartItem->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->lockForUpdate() + ->get() + ->keyBy('inventory_id'); + + $hasCompleteReservation = collect($requirements)->every( + fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity, + ); + + if ($hasCompleteReservation) { + return; + } + + if ($activeReservations->isNotEmpty()) { + throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.'); + } + + $this->inventory->reserve($selection, (int) $cartItem->cantidad); + $this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad); + }); + } + public function syncPurchaseExpiration(Purchase $purchase): void { StockReservation::query() diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 4c3eaff..a4266fb 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Services\PurchaseStateGuard; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; @@ -72,7 +71,7 @@ class ReleaseCheckoutService return DB::transaction(function () use ($purchase, $targetStatus, $restoreCart): Purchase { $purchase = $this->lockPurchase($purchase); - if ($targetStatus !== Purchase::STATUS_EXPIRED) { + if ($targetStatus !== Purchase::STATUS_EXPIRED && ! $restoreCart) { $this->purchaseState->assertNotExpired($purchase); } @@ -86,7 +85,7 @@ class ReleaseCheckoutService ]); } - if ($this->isAlreadyReleased($purchase)) { + if ($this->isAlreadyReleased($purchase) && ! ($restoreCart && $purchase->status === Purchase::STATUS_EXPIRED)) { return $this->loadPurchase($purchase); } diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php index d70df8d..875378e 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -25,6 +25,8 @@ class SourceCartService return false; } + $this->restoreReservations($sourceCart); + /** @var Cart|null $activeCart */ $activeCart = Cart::query() ->where('tenant_codigo', $purchase->tenant_codigo) @@ -62,6 +64,25 @@ class SourceCartService return true; } + private function restoreReservations(Cart $cart): void + { + $items = $cart->items()->orderBy('id')->lockForUpdate()->get(); + $items->load([ + 'catalogItem.inventory', + 'catalogItem.bundleComponents.catalogItem.inventory', + 'catalogItem.bundleComponents.variant.inventory', + 'variant.inventory', + 'variant.catalogItem', + ]); + + foreach ($items as $item) { + $selection = $item->selectedItem(); + if ($selection !== null) { + $this->reservations->restore($item, $selection); + } + } + } + public function syncItemQuantity( Purchase $purchase, PurchaseItem $purchaseItem, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index b9072a4..d236352 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -448,6 +448,42 @@ class StorePurchaseTest extends TestCase ]); } + public function test_it_restores_and_reserves_the_source_cart_when_an_expired_checkout_is_modified(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3); + $purchase->update(['expires_at' => now()->subMinute()]); + + $this->artisan('reservations:expire')->assertSuccessful(); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_EXPIRED, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 0, + ]); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); + + $this->assertDatabaseHas('carritos', [ + 'id' => $purchase->cart_id, + 'user_id' => $user->id, + 'status' => 'active', + 'deleted_at' => null, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 3, + ]); + } + public function test_it_merges_the_checkout_cart_when_the_user_created_another_active_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');