diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index de4aa2c..d532a93 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -117,4 +117,23 @@ class CartController extends Controller 'message' => __('api.cart.item_removed'), ]); } + + public function removeCheckoutItem( + Request $request, + Tenant $tenant, + Cart $cart, + CartItem $cartItem, + ): CartResource { + return CartResource::make( + $this->cartService->removeCheckoutItem( + $tenant, + $request, + $cart, + $cartItem->getKey(), + ), + )->additional([ + 'code' => 'cart.item_removed', + 'message' => __('api.cart.item_removed'), + ]); + } } diff --git a/app/Domains/Cart/Resources/CartItemResource.php b/app/Domains/Cart/Resources/CartItemResource.php index 4d960d6..47992f3 100644 --- a/app/Domains/Cart/Resources/CartItemResource.php +++ b/app/Domains/Cart/Resources/CartItemResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Cart\Resources; +use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\Variant; @@ -22,9 +23,13 @@ class CartItemResource extends JsonResource $selectedItem = $this->selectedItem(); $imageUrl = null; $tenant = $request->route('tenant'); + $checkoutCart = $request->route('cart'); $displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images; $includeVariants = $tenant instanceof Tenant - && $tenant->cart_editing_policy->allowsVariantChanges(); + && ($checkoutCart instanceof Cart + ? $tenant->checkout_editing_policy + : $tenant->cart_editing_policy) + ->allowsVariantChanges(); if ($displayImage && $selectedItem?->relationLoaded('attachments')) { $imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440); diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index 7ac1fbb..014a111 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -161,7 +161,26 @@ class CartService || ($updateVariant && $cartItem->variant_id !== $variantId); if (! $hasChanges) { - return $this->loadCart($checkoutCart); + return $this->loadCart($checkoutCart, $tenant, true); + } + + if ( + (int) $cartItem->cantidad !== $quantity + && ! $tenant->checkout_editing_policy->allowsQuantityChanges() + ) { + throw ValidationException::withMessages([ + 'cantidad' => __('api.cart.editing_disabled'), + ]); + } + + if ( + $updateVariant + && $cartItem->variant_id !== $variantId + && ! $tenant->checkout_editing_policy->allowsVariantChanges() + ) { + throw ValidationException::withMessages([ + 'variant_id' => __('api.cart.variant_change_disabled'), + ]); } $checkoutCart->updateItem( @@ -184,7 +203,7 @@ class CartService ]); $this->reservations->syncPurchaseExpiration($purchase); - return $this->loadCart($checkoutCart); + return $this->loadCart($checkoutCart, $tenant, true); }); } @@ -203,6 +222,78 @@ class CartService return $this->loadCart($cart, $tenant); } + public function removeCheckoutItem( + Tenant $tenant, + Request $request, + Cart $cart, + int $cartItemId, + ): Cart { + if (! $tenant->checkout_editing_policy->allowsRemoval()) { + throw ValidationException::withMessages([ + 'cart_item' => __('api.cart.editing_disabled'), + ]); + } + + $user = $request->user() ?? Auth::guard('sanctum')->user(); + + if (! $user instanceof User) { + throw new NotFoundHttpException('Checkout cart not found.'); + } + + return DB::transaction(function () use ($tenant, $user, $cart, $cartItemId): Cart { + /** @var Purchase|null $purchase */ + $purchase = Purchase::query() + ->where('cart_id', $cart->getKey()) + ->where('tenant_codigo', $tenant->codigo) + ->where('user_id', $user->getKey()) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ]) + ->whereDoesntHave('items') + ->lockForUpdate() + ->first(); + + if ($purchase === null) { + throw new NotFoundHttpException('Checkout cart not found.'); + } + + if ($purchase->expires_at !== null && $purchase->expires_at->isPast()) { + throw ValidationException::withMessages([ + 'cart' => __('api.purchase.not_editable'), + ]); + } + + /** @var Cart|null $checkoutCart */ + $checkoutCart = Cart::query() + ->whereKey($cart->getKey()) + ->where('tenant_codigo', $tenant->codigo) + ->where('user_id', $user->getKey()) + ->where('status', 'checkout') + ->lockForUpdate() + ->first(); + + if ($checkoutCart === null) { + throw new NotFoundHttpException('Checkout cart not found.'); + } + + $checkoutCart->removeItem($cartItemId); + $purchase->telepagosQr()->delete(); + $purchase->update([ + 'status' => Purchase::STATUS_CREATED, + 'payment_method' => null, + 'transfer_payer_dni' => null, + 'total' => $checkoutCart->getTotalAmount(), + 'expires_at' => now()->addMinutes( + max(1, (int) config('purchase.checkout_expiration_minutes', 30)), + ), + ]); + $this->reservations->syncPurchaseExpiration($purchase); + + return $this->loadCart($checkoutCart, $tenant, true); + }); + } + public function makeGuestTokenCookie(string $guestToken): Cookie { return cookie( @@ -230,7 +321,7 @@ class CartService return $cart; } - protected function loadCart(Cart $cart, Tenant $tenant): Cart + protected function loadCart(Cart $cart, Tenant $tenant, bool $isCheckout = false): Cart { $relations = [ 'items.catalogItem.attachments', @@ -243,7 +334,11 @@ class CartService 'items.variant.eventDate', ]; - if ($tenant->cart_editing_policy->allowsVariantChanges()) { + $editingPolicy = $isCheckout + ? $tenant->checkout_editing_policy + : $tenant->cart_editing_policy; + + if ($editingPolicy->allowsVariantChanges()) { $relations = [ ...$relations, 'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'), diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index 403171e..2e03f58 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -15,4 +15,5 @@ Route::prefix('tenants/{tenant:codigo}') ->middleware('auth:sanctum') ->group(function (): void { Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem']); + Route::delete('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'removeCheckoutItem']); }); diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index c927ac6..930ef48 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Controllers; use App\Domains\Integration\Services\TelepagosIntegrationService; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Requests\PaymentIntentRequest; use App\Domains\Purchase\Requests\StartCheckoutRequest; use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest; @@ -112,6 +113,20 @@ class PurchaseController extends Controller ); } + public function removeItem( + Request $request, + Tenant $tenant, + Purchase $compra, + PurchaseItem $item, + CheckoutService $checkoutService, + ): PurchaseResource { + $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); + + return PurchaseResource::make( + $checkoutService->removeItem($compra, $item), + ); + } + public function paymentIntent( PaymentIntentRequest $request, Tenant $tenant, diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index 024483d..d96a4b3 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -30,7 +30,7 @@ class PurchaseItemResource extends JsonResource ? $this->sourceCatalogItem : null; $includeVariants = $tenant instanceof Tenant - && $tenant->cart_editing_policy->allowsVariantChanges() + && $tenant->checkout_editing_policy->allowsVariantChanges() && $catalogItem !== null && $catalogItem->relationLoaded('variants'); @@ -67,6 +67,9 @@ class PurchaseItemResource extends JsonResource $imageUrl = $displayImage ? $this->resolveImageUrl($selectedItem, $catalogItem) : null; + $includeVariants = $tenant instanceof Tenant + && $tenant->checkout_editing_policy->allowsVariantChanges() + && $catalogItem?->relationLoaded('variants'); return [ 'id' => $this->id, @@ -82,6 +85,16 @@ class PurchaseItemResource extends JsonResource 'imagen' => $imageUrl, 'attributes' => $variant === null ? [] : $this->resolveAttributes($variant), ], + 'variants' => $this->when( + $includeVariants, + fn () => $catalogItem + ->visibleVariants($this->variant_id) + ->map(fn (Variant $availableVariant): array => $this->variantData( + $catalogItem, + $availableVariant, + )) + ->values(), + ), ]; } diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php index 6178673..4b6aa33 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -61,13 +61,13 @@ class EditCheckoutService $tenant = $purchase->tenant()->firstOrFail(); $finalQuantity = $quantity ?? (int) $purchaseItem->cantidad; - if ($quantity !== null && ! $tenant->cart_editing_policy->allowsQuantityChanges()) { + if ($quantity !== null && ! $tenant->checkout_editing_policy->allowsQuantityChanges()) { throw ValidationException::withMessages([ 'quantity' => __('api.cart.editing_disabled'), ]); } - if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) { + if ($updateVariant && ! $tenant->checkout_editing_policy->allowsVariantChanges()) { throw ValidationException::withMessages([ 'variant_id' => __('api.purchase.variant_change_disabled'), ]); @@ -184,7 +184,7 @@ class EditCheckoutService $purchase = $this->lockPurchase($purchase); $this->assertEditable($purchase); - if (! $purchase->tenant()->firstOrFail()->cart_editing_policy->allowsModification()) { + if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsModification()) { throw ValidationException::withMessages([ 'purchase' => __('api.cart.editing_disabled'), ]); @@ -204,6 +204,31 @@ class EditCheckoutService }); } + public function removeItem(Purchase $purchase, PurchaseItem $purchaseItem): Purchase + { + return DB::transaction(function () use ($purchase, $purchaseItem): Purchase { + $purchase = $this->lockPurchase($purchase); + $this->assertEditable($purchase); + + if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsRemoval()) { + throw ValidationException::withMessages([ + 'item' => __('api.cart.editing_disabled'), + ]); + } + + $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); + $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem); + $this->inventory->release($selection, (int) $purchaseItem->cantidad); + $this->sourceCart->removeItem($purchase, $purchaseItem); + $purchaseItem->delete(); + $purchase->update([ + 'total' => $purchase->calculateCurrentTotalAmount(), + ]); + + return $this->loadPurchase($purchase); + }); + } + private function adjustReservation( Purchase $purchase, PurchaseItem $purchaseItem, diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php index 1bcef7f..7739899 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -23,7 +23,7 @@ class PurchaseResponseLoader ]); } - if (! $purchase->tenant->cart_editing_policy->allowsVariantChanges()) { + if (! $purchase->tenant->checkout_editing_policy->allowsVariantChanges()) { return $purchase; } @@ -35,6 +35,13 @@ class PurchaseResponseLoader 'items.sourceCatalogItem.variants.definitions.itemAttribute.attribute.options', 'items.sourceCatalogItem.variants.eventDates', 'items.sourceCatalogItem.variants.eventDate', + 'cart.items.catalogItem.itemAttributes.attribute', + 'cart.items.catalogItem.variants' => fn ($query) => $query->orderBy('id'), + 'cart.items.catalogItem.variants.inventory', + 'cart.items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'), + 'cart.items.catalogItem.variants.definitions.itemAttribute.attribute.options', + 'cart.items.catalogItem.variants.eventDates', + 'cart.items.catalogItem.variants.eventDate', ]); return $purchase; diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php index 9eeea13..d70df8d 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -130,6 +130,20 @@ class SourceCartService ]); } + public function removeItem(Purchase $purchase, PurchaseItem $purchaseItem): void + { + $sourceCart = $this->findSourceCart($purchase); + + if ($sourceCart === null) { + return; + } + + $sourceCart->items() + ->where('catalog_item_id', $purchaseItem->source_catalog_item_id) + ->where('variant_id', $purchaseItem->source_variant_id) + ->delete(); + } + public function finalize(Purchase $purchase): void { $sourceCart = $this->findSourceCart($purchase); diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 8c9f4e9..fb55849 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService; use App\Domains\Purchase\Services\Checkout\EditCheckoutService; use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; @@ -69,6 +70,11 @@ class CheckoutService return $this->editor->prepareItemEditing($purchase); } + public function removeItem(Purchase $purchase, PurchaseItem $purchaseItem): Purchase + { + return $this->editor->removeItem($purchase, $purchaseItem); + } + public function confirmPurchase(Purchase $purchase): void { $this->completer->confirm($purchase); diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php index 2fb537e..cd6ce4d 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -9,6 +9,7 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func Route::get('compras/{compra}', [PurchaseController::class, 'show']); Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']); Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, 'updateItem']); + Route::delete('compras/{compra}/items/{item}', [PurchaseController::class, 'removeItem']); Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']); Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']); Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 9605bfc..870d356 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -565,7 +565,8 @@ class StorePurchaseTest extends TestCase public function test_it_updates_a_checkout_cart_item_quantity_and_its_stock_reservation(): void { - $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant->update(['checkout_editing_policy' => 'full']); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); @@ -609,10 +610,61 @@ class StorePurchaseTest extends TestCase ]); } + public function test_checkout_cart_editing_uses_checkout_policy_independently_from_cart_policy(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant->update([ + 'cart_editing_policy' => 'full', + 'checkout_editing_policy' => 'disabled', + ]); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + $itemId = $purchase->cart->items->firstOrFail()->id; + + $this->actingAs($user, 'sanctum') + ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ + 'cantidad' => 4, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cantidad'); + + $this->assertDatabaseHas('carrito_items', [ + 'id' => $itemId, + 'cantidad' => 2, + ]); + } + + public function test_it_removes_an_item_from_the_checkout_cart_when_the_policy_allows_it(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant->update(['checkout_editing_policy' => 'quantity_and_remove']); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + $itemId = $purchase->cart->items->firstOrFail()->id; + + $this->actingAs($user, 'sanctum') + ->deleteJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}") + ->assertOk() + ->assertJsonPath('data.items', []) + ->assertJsonPath('data.subtotal', '0.00'); + + $this->assertDatabaseMissing('carrito_items', ['id' => $itemId]); + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'total' => '0.00', + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 0, + ]); + } + public function test_it_changes_a_checkout_item_variant_and_moves_its_reservation(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['cart_editing_policy' => 'full']); + $tenant->update(['checkout_editing_policy' => 'full']); $user = User::factory()->create(); $firstVariant = $this->createVariantForTenant('sonder', 10, '50.00'); $secondInventory = Inventory::query()->create(['real_stock' => 10]); @@ -659,7 +711,7 @@ class StorePurchaseTest extends TestCase public function test_it_rejects_checkout_variant_changes_when_the_policy_does_not_allow_them(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['cart_editing_policy' => 'quantity_and_remove']); + $tenant->update(['checkout_editing_policy' => 'quantity_and_remove']); $user = User::factory()->create(); $firstVariant = $this->createVariantForTenant('sonder', 10, '50.00'); $secondInventory = Inventory::query()->create(['real_stock' => 10]); @@ -694,7 +746,7 @@ class StorePurchaseTest extends TestCase public function test_checkout_variant_change_rolls_back_when_the_target_has_insufficient_stock(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['cart_editing_policy' => 'full']); + $tenant->update(['checkout_editing_policy' => 'full']); $user = User::factory()->create(); $firstVariant = $this->createVariantForTenant('sonder', 10, '50.00'); $secondInventory = Inventory::query()->create(['real_stock' => 1]); @@ -736,7 +788,7 @@ class StorePurchaseTest extends TestCase public function test_changing_to_an_existing_checkout_variant_merges_purchase_and_cart_rows(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['cart_editing_policy' => 'full']); + $tenant->update(['checkout_editing_policy' => 'full']); $user = User::factory()->create(); $firstVariant = $this->createVariantForTenant('sonder', 10, '50.00'); $secondInventory = Inventory::query()->create(['real_stock' => 10]); @@ -787,7 +839,8 @@ class StorePurchaseTest extends TestCase public function test_it_rejects_a_quantity_update_above_the_user_purchase_limit(): void { - $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant->update(['checkout_editing_policy' => 'full']); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $variant->catalogItem->update(['max_units_per_user' => 3]); @@ -813,7 +866,8 @@ class StorePurchaseTest extends TestCase public function test_it_invalidates_a_pending_payment_when_the_checkout_cart_changes(): void { - $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant->update(['checkout_editing_policy' => 'full']); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);