feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status

This commit is contained in:
2026-09-17 11:34:26 -03:00
parent de08ee4ac7
commit df6892c662
2 changed files with 99 additions and 1 deletions

View File

@@ -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,

View File

@@ -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<string, array{string, string}> */
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');