From dc0fbe06b8a9d56f623352b53864a92822185090 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 21 Aug 2026 14:06:11 -0300 Subject: [PATCH] fix(cart): invalidate checkout on mutation --- app/Domains/Cart/Models/Cart.php | 58 +++++++++++++++++++- tests/Feature/Purchase/StorePurchaseTest.php | 45 +++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 5dd2216..705036f 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -106,7 +106,7 @@ class Cart extends Model } return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem { - self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail(); + $this->invalidateCurrentCheckout(); $selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true); $cartQuantity = (int) $this->items() ->where('catalog_item_id', $catalogItemId) @@ -171,6 +171,8 @@ class Cart extends Model $updateVariant, $excludedPurchaseId, ): CartItem { + $this->invalidateCurrentCheckout(); + /** @var CartItem $item */ $item = $this->items() ->where('id', $cartItemId) @@ -279,6 +281,8 @@ class Cart extends Model public function removeItem(int $cartItemId): void { DB::transaction(function () use ($cartItemId): void { + $this->invalidateCurrentCheckout(); + /** @var CartItem $item */ $item = $this->items() ->where('id', $cartItemId) @@ -299,6 +303,58 @@ class Cart extends Model }); } + private function invalidateCurrentCheckout(): void + { + $candidatePurchaseId = self::query() + ->whereKey($this->getKey()) + ->value('current_purchase_id'); + $currentPurchase = $candidatePurchaseId === null + ? null + : Purchase::query()->lockForUpdate()->find($candidatePurchaseId); + + /** @var self $cart */ + $cart = self::query()->lockForUpdate()->findOrFail($this->getKey()); + + if ($cart->current_purchase_id !== $candidatePurchaseId) { + if ($cart->current_purchase_id !== null) { + throw ValidationException::withMessages([ + 'cart' => __('api.purchase.checkout_in_progress'), + ]); + } + + $this->current_purchase_id = null; + + return; + } + + if ($currentPurchase === null) { + return; + } + + if ($currentPurchase->status === Purchase::STATUS_PAID) { + throw ValidationException::withMessages([ + 'cart' => __('api.cart.editing_disabled'), + ]); + } + + if (in_array($currentPurchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true)) { + $currentPurchase->update([ + 'status' => Purchase::STATUS_SUPERSEDED, + 'expires_at' => null, + ]); + } + + app(StockReservationService::class)->detachFromPurchase($currentPurchase); + self::query() + ->whereKey($cart->getKey()) + ->where('current_purchase_id', $currentPurchase->getKey()) + ->update(['current_purchase_id' => null]); + $this->current_purchase_id = null; + } + protected function resolveScopedItem( int $catalogItemId, ?int $variantId, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 787ae17..28b61d2 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -13,6 +13,7 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; +use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -562,6 +563,50 @@ class StorePurchaseTest extends TestCase ]); } + public function test_removing_a_checkout_item_supersedes_the_purchase_and_restores_the_user_quota(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $variant->catalogItem->update(['max_units_per_user' => 3]); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + $cart = $purchase->cart; + $cartItem = $cart->items()->firstOrFail(); + + $cart->removeItem($cartItem->id); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_SUPERSEDED, + ]); + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $variant->catalog_item_id, + 'cantidad' => 2, + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $cart->id, + 'current_purchase_id' => null, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $variant->inventory_id, + 'cart_item_id' => null, + 'purchase_id' => null, + 'quantity' => 0, + 'status' => 'released', + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 0, + ]); + + $remaining = app(UserPurchaseLimitService::class) + ->remainingByCatalogItem(collect([$variant->catalogItem->fresh()]), $user->id) + ->get($variant->catalog_item_id); + + $this->assertSame(3, $remaining); + } + public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');