diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php index 09f7de0..1aae879 100644 --- a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -100,7 +100,7 @@ class CompleteCheckoutService return; } - if ($cart === null || $cart->status !== 'checkout') { + if ($cart === null || ! in_array($cart->status, ['active', 'checkout'], true)) { throw ValidationException::withMessages([ 'items' => __('api.purchase.inconsistent_reservation'), ]); diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 39f9524..84ab282 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -14,22 +14,16 @@ class ReleaseCheckoutService { public function __construct( private readonly StockReservationService $reservations, - private readonly SourceCartService $sourceCart, ) {} public function cancel(Purchase $purchase): Purchase { - return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: true); - } - - public function cancelWithoutRestoringCart(Purchase $purchase): Purchase - { - return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: false); + return $this->release($purchase, Purchase::STATUS_CANCELLED); } public function expire(Purchase $purchase): Purchase { - return $this->release($purchase, Purchase::STATUS_EXPIRED, restoreCart: false); + return $this->release($purchase, Purchase::STATUS_EXPIRED); } public function expireOverdue(): int @@ -66,15 +60,11 @@ class ReleaseCheckoutService return $expiredCount; } - private function release(Purchase $purchase, string $targetStatus, bool $restoreCart): Purchase + private function release(Purchase $purchase, string $targetStatus): Purchase { - return DB::transaction(function () use ($purchase, $targetStatus, $restoreCart): Purchase { + return DB::transaction(function () use ($purchase, $targetStatus): Purchase { $purchase = $this->lockPurchase($purchase); - if ($targetStatus !== Purchase::STATUS_EXPIRED && ! $restoreCart) { - $this->purchaseState->assertNotExpired($purchase); - } - if ($purchase->status === Purchase::STATUS_PAID) { if ($targetStatus === Purchase::STATUS_EXPIRED) { return $this->loadPurchase($purchase); @@ -85,7 +75,7 @@ class ReleaseCheckoutService ]); } - if ($this->isAlreadyReleased($purchase) && ! ($restoreCart && $purchase->status === Purchase::STATUS_EXPIRED)) { + if ($this->isAlreadyReleased($purchase)) { return $this->loadPurchase($purchase); } @@ -96,8 +86,7 @@ class ReleaseCheckoutService return $this->loadPurchase($purchase); } - $reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase); - $this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus); + $this->releasePurchaseReservations($purchase, $targetStatus); $purchase->update(['status' => $targetStatus]); @@ -105,19 +94,20 @@ class ReleaseCheckoutService }); } - private function releaseCartReservations( - Purchase $purchase, - bool $reservationReturnedToCart, - string $targetStatus, - ): void { - if ($reservationReturnedToCart) { + private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void + { + $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + if ($cart === null) { + return; + } + + if ($cart->status === 'active') { $this->reservations->detachFromPurchase($purchase); return; } - $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); - if ($cart === null) { + if ($cart->status !== 'checkout') { return; } diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php index 6c75efd..b29a2f3 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -3,86 +3,10 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; -use App\Domains\Cart\Models\CartItem; -use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; class SourceCartService { - public function __construct( - private readonly StockReservationService $reservations, - ) {} - - public function restore(Purchase $purchase): bool - { - $sourceCart = $this->findSourceCart($purchase); - - if ($sourceCart === null) { - return false; - } - - if ($sourceCart->origin === Cart::ORIGIN_DIRECT_CHECKOUT) { - return false; - } - - $this->restoreReservations($sourceCart); - - /** @var Cart|null $activeCart */ - $activeCart = Cart::query() - ->where('tenant_codigo', $purchase->tenant_codigo) - ->where('user_id', $purchase->user_id) - ->where('status', 'active') - ->where('id', '!=', $sourceCart->getKey()) - ->lockForUpdate() - ->first(); - - if ($activeCart !== null) { - $this->mergeIntoActiveCart($sourceCart, $activeCart); - - $sourceCart->update([ - 'status' => 'converted', - 'guest_token' => null, - ]); - - if (! $sourceCart->trashed()) { - $sourceCart->delete(); - } - - return true; - } - - if ($sourceCart->trashed()) { - $sourceCart->restore(); - } - - $sourceCart->update([ - 'status' => 'active', - 'user_id' => $purchase->user_id, - 'guest_token' => null, - ]); - - return true; - } - - private function restoreReservations(Cart $cart): void - { - $items = $cart->items()->orderBy('id')->lockForUpdate()->get(); - $items->load([ - 'catalogItem.inventory', - 'catalogItem.bundleComponents.catalogItem.inventory', - 'catalogItem.bundleComponents.variant.inventory', - 'variant.inventory', - 'variant.catalogItem', - ]); - - foreach ($items as $item) { - $selection = $item->selectedItem(); - if ($selection !== null) { - $this->reservations->restore($item, $selection); - } - } - } - public function finalize(Purchase $purchase): void { $sourceCart = $this->findSourceCart($purchase); @@ -110,30 +34,4 @@ class SourceCartService ->lockForUpdate() ->first(); } - - private function mergeIntoActiveCart(Cart $sourceCart, Cart $activeCart): void - { - $sourceItems = $sourceCart->items()->lockForUpdate()->get(); - - foreach ($sourceItems as $sourceItem) { - /** @var CartItem|null $activeItem */ - $activeItem = $activeCart->items() - ->where('catalog_item_id', $sourceItem->catalog_item_id) - ->where('variant_id', $sourceItem->variant_id) - ->lockForUpdate() - ->first(); - - if ($activeItem === null) { - $activeItem = $activeCart->items()->create([ - 'catalog_item_id' => $sourceItem->catalog_item_id, - 'variant_id' => $sourceItem->variant_id, - 'cantidad' => $sourceItem->cantidad, - ]); - } else { - $activeItem->increment('cantidad', (int) $sourceItem->cantidad); - } - - $this->reservations->transfer($sourceItem, $activeItem); - } - } } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index c2f723d..3e20932 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -274,13 +274,6 @@ class StartCheckoutService ); } - // The purchase owns the reservation until checkout finishes. The cart is - // retained so it can be restored if the purchase is cancelled or expires. - $cart->update([ - 'status' => 'checkout', - 'guest_token' => null, - ]); - return $this->loadPurchase($purchase); } @@ -299,6 +292,14 @@ class StartCheckoutService ]); } + if ($cart->purchases() + ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) + ->exists()) { + throw ValidationException::withMessages([ + 'cart_id' => __('api.purchase.checkout_in_progress'), + ]); + } + return $cart; } diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 411f1b2..90ad1bc 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -68,11 +68,6 @@ class CheckoutService return $this->releaser->cancel($purchase); } - public function cancelPurchaseWithoutRestoringCart(Purchase $purchase): Purchase - { - return $this->releaser->cancelWithoutRestoringCart($purchase); - } - public function expirePurchase(Purchase $purchase): Purchase { return $this->releaser->expire($purchase); diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index 6baa6fb..1e2b5ec 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -86,7 +86,9 @@ class UserPurchaseLimitService $excludedCartId !== null, fn ($query) => $query->whereKeyNot($excludedCartId), )) - ->whereHas('stockReservations', fn ($query) => $query->where('status', 'active')) + ->whereHas('stockReservations', fn ($query) => $query + ->where('status', 'active') + ->whereNull('purchase_id')) ->sum('cantidad'); if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) { @@ -152,7 +154,9 @@ class UserPurchaseLimitService ->whereHas('cart', fn ($query) => $query ->where('user_id', $userId) ->where('status', 'active')) - ->whereHas('stockReservations', fn ($query) => $query->where('status', 'active')) + ->whereHas('stockReservations', fn ($query) => $query + ->where('status', 'active') + ->whereNull('purchase_id')) ->groupBy('catalog_item_id') ->pluck('quantity', 'catalog_item_id'); diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index 75ae593..5a008d3 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -79,7 +79,7 @@ class AdminAppSaleService $sale = $this->findForTenant($tenant, $saleId); return $this->saleForResponse( - $this->checkoutService->cancelPurchaseWithoutRestoringCart($sale) + $this->checkoutService->cancelPurchase($sale) ); } diff --git a/lang/en/api.php b/lang/en/api.php index f0356ed..e4780e6 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -55,6 +55,7 @@ return [ 'catalog_item_missing' => 'One or more catalog items could not be loaded.', 'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.', 'inactive_cart' => 'The selected cart is no longer active.', + 'checkout_in_progress' => 'The cart already has a purchase in progress.', 'not_available_for_payment' => 'The purchase is no longer available for payment.', 'not_available_for_review' => 'The purchase is no longer available for review.', ], diff --git a/lang/es/api.php b/lang/es/api.php index bfa17c4..bbf2cd9 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -55,6 +55,7 @@ return [ 'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.', 'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.', 'inactive_cart' => 'El carrito seleccionado ya no está activo.', + 'checkout_in_progress' => 'El carrito ya tiene una compra en curso.', 'not_available_for_payment' => 'La compra ya no está disponible para el pago.', 'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.", ], diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 12901e4..5048121 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -126,7 +126,8 @@ class StorePurchaseTest extends TestCase $this->assertDatabaseHas('carritos', [ 'id' => $cartId, 'user_id' => $user->id, - 'status' => 'checkout', + 'status' => 'active', + 'origin' => Cart::ORIGIN_USER, 'deleted_at' => null, ]); $this->assertDatabaseHas('carrito_items', [ @@ -141,6 +142,21 @@ class StorePurchaseTest extends TestCase 'reserved_stock' => 2, ]); + $this->actingAs($user, 'sanctum') + ->getJson('/api/tenants/sonder/cart') + ->assertOk() + ->assertJsonPath('data.id', $cartId) + ->assertJsonPath('data.status', 'active') + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.cantidad', 2); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'cart_id' => $cartId, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cart_id'); + $catalogItem->update([ 'nombre' => 'Updated Product', 'precio' => '75.00', @@ -423,18 +439,26 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_restores_the_source_cart_when_checkout_is_cancelled(): void + public function test_it_keeps_the_source_cart_active_and_only_cancels_the_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3); + $activeCart = Cart::query() + ->where('user_id', $user->id) + ->where('status', 'active') + ->firstOrFail(); + $cartItemId = $purchase->cart->items()->firstOrFail()->id; $this->assertDatabaseHas('carritos', [ 'id' => $purchase->cart_id, - 'status' => 'checkout', + 'status' => 'active', + 'origin' => Cart::ORIGIN_USER, 'deleted_at' => null, ]); + $this->assertDatabaseCount('carrito_items', 1); + $this->assertSame(1, $activeCart->items()->count()); $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") @@ -442,29 +466,35 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); $this->assertDatabaseHas('carritos', [ - 'id' => $purchase->cart_id, + 'id' => $activeCart->id, 'user_id' => $user->id, 'status' => 'active', 'deleted_at' => null, ]); - $this->assertDatabaseHas('carrito_items', [ - 'cart_id' => $purchase->cart_id, - 'catalog_item_id' => $variant->catalog_item_id, - 'variant_id' => $variant->id, - 'cantidad' => 3, - ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'reserved_stock' => 3, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'cart_item_id' => $cartItemId, + 'purchase_id' => null, + 'quantity' => 3, + 'status' => 'active', + ]); + $this->assertSame(1, $activeCart->items()->count()); } - public function test_it_restores_and_reserves_the_source_cart_when_an_expired_checkout_is_modified(): void + public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3); + $activeCart = Cart::query() + ->where('user_id', $user->id) + ->where('status', 'active') + ->firstOrFail(); + $cartItemId = $purchase->cart->items()->firstOrFail()->id; $purchase->update(['expires_at' => now()->subMinute()]); $this->artisan('reservations:expire')->assertSuccessful(); @@ -475,64 +505,21 @@ class StorePurchaseTest extends TestCase ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, - 'reserved_stock' => 0, + 'reserved_stock' => 3, ]); - - $this->actingAs($user, 'sanctum') - ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") - ->assertOk() - ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); - $this->assertDatabaseHas('carritos', [ - 'id' => $purchase->cart_id, + 'id' => $activeCart->id, 'user_id' => $user->id, 'status' => 'active', 'deleted_at' => null, ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $variant->inventory_id, - 'reserved_stock' => 3, - ]); - } - - public function test_it_merges_the_checkout_cart_when_the_user_created_another_active_cart(): void - { - $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $user = User::factory()->create(); - $variant = $this->createVariantForTenant('sonder', 10, '50.00'); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - - $activeCartId = $this->actingAs($user, 'sanctum') - ->postJson('/api/tenants/sonder/cart/items', [ - 'catalog_item_id' => $variant->catalog_item_id, - 'variant_id' => $variant->id, - 'cantidad' => 1, - ]) - ->assertOk() - ->json('data.id'); - - $this->assertNotSame($purchase->cart_id, $activeCartId); - - $this->actingAs($user, 'sanctum') - ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") - ->assertOk(); - - $this->assertDatabaseHas('carritos', [ - 'id' => $activeCartId, - 'user_id' => $user->id, + $this->assertDatabaseHas('stock_reservations', [ + 'cart_item_id' => $cartItemId, + 'purchase_id' => null, + 'quantity' => 3, 'status' => 'active', - 'deleted_at' => null, - ]); - $this->assertDatabaseHas('carrito_items', [ - 'cart_id' => $activeCartId, - 'catalog_item_id' => $variant->catalog_item_id, - 'variant_id' => $variant->id, - 'cantidad' => 3, - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $variant->inventory_id, - 'reserved_stock' => 3, ]); + $this->assertSame(1, $activeCart->items()->count()); } public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void @@ -783,12 +770,16 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_expires_an_abandoned_purchase_and_releases_its_checkout_cart(): void + public function test_it_expires_an_abandoned_purchase_without_mutating_its_active_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3); + $activeCart = Cart::query() + ->where('user_id', $user->id) + ->where('status', 'active') + ->firstOrFail(); $this->assertNotNull($purchase->expires_at); $this->assertDatabaseHas('inventories', [ @@ -812,21 +803,23 @@ class StorePurchaseTest extends TestCase 'cantidad' => 3, ]); $this->assertDatabaseHas('stock_reservations', [ - 'purchase_id' => $purchase->id, - 'quantity' => 0, - 'status' => 'expired', + 'purchase_id' => null, + 'quantity' => 3, + 'status' => 'active', ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'real_stock' => 10, - 'reserved_stock' => 0, + 'reserved_stock' => 3, 'sold_units' => 0, ]); - $this->assertSoftDeleted('carritos', [ - 'id' => $purchase->cart_id, + $this->assertDatabaseHas('carritos', [ + 'id' => $activeCart->id, 'user_id' => $user->id, - 'status' => 'converted', + 'status' => 'active', + 'deleted_at' => null, ]); + $this->assertSame(1, $activeCart->items()->count()); $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 0') @@ -838,9 +831,10 @@ class StorePurchaseTest extends TestCase { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); + $secondUser = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $inconsistentPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1); - $validPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1); + $validPurchase = $this->createCheckoutPurchase($secondUser, 'sonder', $variant, 1); $inconsistentPurchase->items()->create([ 'source_catalog_item_id' => $variant->catalog_item_id, @@ -956,12 +950,10 @@ class StorePurchaseTest extends TestCase public function test_created_and_pending_payment_purchase_details_ignore_later_cart_changes(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $user = User::factory()->create([ - 'email' => 'buyer@example.com', - ]); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); foreach ([Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT] as $status) { + $user = User::factory()->create(); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); $purchase->cart->items()->update(['cantidad' => 3]); $purchase->update(['status' => $status]);