From df6892c6624790b64e206f86b6eb8c67a3ef1f40 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 17 Sep 2026 11:34:26 -0300 Subject: [PATCH] feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status --- .../Checkout/ReleaseCheckoutService.php | 23 +++++- tests/Feature/Purchase/StorePurchaseTest.php | 77 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 70ce711..e64ef79 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -3,6 +3,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; +use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Exceptions\PurchaseExpiredException; @@ -91,7 +92,16 @@ class ReleaseCheckoutService Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true)) { - $this->reservations->returnToCart($purchase, $cart); + if ($this->hasUnavailableVariants($cart)) { + $this->releasePurchaseReservations($purchase, $targetStatus, $cart); + $cart->update([ + 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, + 'current_stock_reservation_id' => null, + ]); + } else { + $this->reservations->returnToCart($purchase, $cart); + } $purchase->update(['status' => Purchase::STATUS_CANCELLED]); return $this->loadPurchase($purchase); @@ -115,6 +125,17 @@ class ReleaseCheckoutService }); } + private function hasUnavailableVariants(Cart $cart): bool + { + return $cart->items() + ->whereNotNull('variant_id') + ->with(['variant.eventDate', 'variant.eventDates']) + ->lockForUpdate() + ->get() + ->contains(fn (CartItem $item): bool => $item->variant === null + || ! $item->variant->isSellable()); + } + private function releasePurchaseReservations( Purchase $purchase, string $targetStatus, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 9014f75..65ec3f6 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -11,7 +11,9 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Models\EventDate; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\UserPurchaseLimitService; @@ -20,6 +22,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class StorePurchaseTest extends TestCase @@ -642,6 +645,80 @@ class StorePurchaseTest extends TestCase $this->assertSame(1, $activeCart->items()->count()); } + /** @return array */ + public static function unavailableCancellationCases(): array + { + return [ + 'created with disabled variant' => [Purchase::STATUS_CREATED, 'disabled'], + 'pending payment with replaced variant' => [Purchase::STATUS_PENDING_PAYMENT, 'replaced'], + 'pending payment with suspended date' => [Purchase::STATUS_PENDING_PAYMENT, 'suspended'], + ]; + } + + #[DataProvider('unavailableCancellationCases')] + public function test_cancelling_a_purchase_with_unavailable_variants_invalidates_the_whole_cart( + string $purchaseStatus, + string $change, + ): void { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $otherVariant = $this->createVariantForTenant('sonder', 10, '25.00', 'other'); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, 'user_id' => $user->id, + 'status' => Cart::STATUS_ACTIVE, 'origin' => Cart::ORIGIN_USER, + ]); + $cart->addItem($variant->catalog_item_id, $variant->id, 2); + $cart->addItem($otherVariant->catalog_item_id, $otherVariant->id, 1); + $purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, ['cart_id' => $cart->id]); + $purchase->update(['status' => $purchaseStatus]); + $reservedInventoryId = $variant->inventory_id; + + if ($change === 'replaced') { + // A reprogramming can move reservation lines away from the original variant. + $replacementInventory = Inventory::query()->create(['real_stock' => 10, 'reserved_stock' => 2]); + $replacement = Variant::query()->create([ + 'catalog_item_id' => $variant->catalog_item_id, 'inventory_id' => $replacementInventory->id, + ]); + StockReservationLine::query()->where('stock_reservation_id', $purchase->stock_reservation_id) + ->where('inventory_id', $variant->inventory_id) + ->update(['inventory_id' => $replacementInventory->id]); + $variant->inventory->update(['reserved_stock' => 0]); + $variant->update(['replaced_by_variant_id' => $replacement->id, 'sales_disabled_at' => now()]); + $reservedInventoryId = $replacementInventory->id; + } elseif ($change === 'suspended') { + $date = EventDate::query()->create([ + 'tenant_code' => $tenant->codigo, 'date' => '2027-10-09', + 'time_start' => '09:00', 'time_end' => '18:00', 'suspended_at' => now(), + ]); + $variant->eventDates()->sync([$date->id]); + } else { + $variant->update(['sales_disabled_at' => now()]); + } + + $this->actingAs($user, 'sanctum'); + for ($attempt = 0; $attempt < 2; $attempt++) { + $this->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertOk()->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); + } + + $this->assertDatabaseHas('carritos', [ + 'id' => $cart->id, 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, 'current_stock_reservation_id' => null, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $purchase->stock_reservation_id, 'status' => StockReservation::STATUS_RELEASED, + 'release_reason' => 'purchase_cancelled', + ]); + foreach ([$reservedInventoryId, $otherVariant->inventory_id] as $inventoryId) { + $this->assertDatabaseHas('inventories', [ + 'id' => $inventoryId, 'real_stock' => 10, 'reserved_stock' => 0, + ]); + } + $this->getJson('/api/tenants/sonder/cart')->assertOk()->assertJsonCount(0, 'data.items'); + $this->assertSame(Cart::STATUS_ABANDONED, $cart->fresh()->status); + } + public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');