feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user