From a19ace3115e1fe604d62be874978ee81fdf2a890 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 2 Sep 2026 15:52:18 -0300 Subject: [PATCH] fix(purchase): handle cancellation of expired purchases and update test cases --- .../Checkout/ReleaseCheckoutService.php | 13 +++++++++++++ tests/Feature/Purchase/StorePurchaseTest.php | 18 ++++++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index a63ef14..70ce711 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -43,6 +43,11 @@ class ReleaseCheckoutService ): Purchase { $purchase = $this->lockPurchase($purchase); + if ($purchase->status === Purchase::STATUS_EXPIRED + && $targetStatus === Purchase::STATUS_CANCELLED) { + return $this->loadPurchase($purchase); + } + if ($purchase->status === Purchase::STATUS_EXPIRED && $targetStatus !== Purchase::STATUS_EXPIRED) { throw new PurchaseExpiredException; @@ -72,6 +77,14 @@ class ReleaseCheckoutService $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + // Leaving checkout is allowed at the exact instant the reservation + // expires. Finish the expiration while holding the purchase lock so + // the request is idempotent with the scheduled expiration job. + if ($targetStatus === Purchase::STATUS_CANCELLED + && $this->hasOverdueActiveReservation($purchase)) { + $targetStatus = Purchase::STATUS_EXPIRED; + } + if ($targetStatus === Purchase::STATUS_CANCELLED && $cart?->status === 'active' && in_array($purchase->status, [ diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 8a667e5..e7fd951 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -10,6 +10,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy; 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\Variant; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; @@ -714,11 +715,11 @@ class StorePurchaseTest extends TestCase $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") - ->assertUnprocessable() - ->assertJsonPath('code', 'purchase.expired'); + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_EXPIRED); } - public function test_an_overdue_purchase_cannot_be_cancelled_before_the_expiration_job_runs(): void + public function test_leaving_an_overdue_purchase_expires_it_before_the_expiration_job_runs(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -728,20 +729,17 @@ class StorePurchaseTest extends TestCase $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") - ->assertUnprocessable() - ->assertExactJson([ - 'code' => 'stock_reservation.expired', - 'message' => __('api.cart.reservation_expired'), - ]); + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_EXPIRED); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, - 'status' => Purchase::STATUS_CREATED, + 'status' => Purchase::STATUS_EXPIRED, 'stock_reservation_id' => $purchase->stock_reservation_id, ]); $this->assertDatabaseHas('stock_reservations', [ 'id' => $purchase->stock_reservation_id, - 'status' => 'active', + 'status' => StockReservation::STATUS_EXPIRED, ]); }