From 8d6bcdcc43c9d075b1d125e449c8a4e1021472ed Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 12:38:21 -0300 Subject: [PATCH] refactor(cart): invalidate payment on actual changes --- ShopIt_API_Postman_Collection.json | 55 +----------- .../Cart/Controllers/CartController.php | 10 --- app/Domains/Cart/Services/CartService.php | 85 ++++++------------- app/Domains/Cart/routes/api.php | 1 - app/Domains/Purchase/documentacion/README.md | 2 +- postman/generate-shopit-collection.php | 2 - tests/Feature/Purchase/StorePurchaseTest.php | 43 +++++++++- 7 files changed, 69 insertions(+), 129 deletions(-) diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index e829712..f388e16 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2", "name": "ShopIt API — Complete", - "description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 123 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.", + "description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 122 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ @@ -573,59 +573,6 @@ }, "response": [] }, - { - "name": "Prepare Checkout Cart Editing Cart", - "request": { - "method": "POST", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/checkout-carts/{cart}/edit`\n\nControlador: `App\\Domains\\Cart\\Controllers\\CartController@prepareCheckoutEditing`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/checkout-carts/{{cart}}/edit", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "checkout-carts", - "{{cart}}", - "edit" - ] - }, - "body": { - "mode": "raw", - "raw": "[]", - "options": { - "raw": { - "language": "json" - } - } - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] - }, { "name": "Update Checkout Cart Item Cart", "request": { diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index 6910224..de4aa2c 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -108,16 +108,6 @@ class CartController extends Controller ]); } - public function prepareCheckoutEditing( - Request $request, - Tenant $tenant, - Cart $cart, - ): CartResource { - return CartResource::make( - $this->cartService->prepareCheckoutEditing($tenant, $request, $cart), - ); - } - public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource { return CartResource::make( diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index bbdd2be..dc76a9c 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -4,6 +4,7 @@ namespace App\Domains\Cart\Services; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; +use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; @@ -98,64 +99,6 @@ class CartService $variantId, $updateVariant, ): Cart { - /** @var Purchase|null $purchase */ - $purchase = Purchase::query() - ->where('cart_id', $cart->getKey()) - ->where('tenant_codigo', $tenant->codigo) - ->where('user_id', $user->getKey()) - ->where('status', Purchase::STATUS_CREATED) - ->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->updateItem( - $cartItemId, - $quantity, - $variantId, - $updateVariant, - $purchase->getKey(), - ); - $purchase->update(['total' => $checkoutCart->getTotalAmount()]); - - return $this->loadCart($checkoutCart); - }); - } - - public function prepareCheckoutEditing( - Tenant $tenant, - Request $request, - Cart $cart, - ): Cart { - $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): Cart { /** @var Purchase|null $purchase */ $purchase = Purchase::query() ->where('cart_id', $cart->getKey()) @@ -192,11 +135,37 @@ class CartService throw new NotFoundHttpException('Checkout cart not found.'); } + /** @var CartItem|null $cartItem */ + $cartItem = $checkoutCart->items() + ->whereKey($cartItemId) + ->lockForUpdate() + ->first(); + + if ($cartItem === null) { + throw new NotFoundHttpException('Checkout item not found.'); + } + + $hasChanges = (int) $cartItem->cantidad !== $quantity + || ($updateVariant && $cartItem->variant_id !== $variantId); + + if (! $hasChanges) { + return $this->loadCart($checkoutCart); + } + + $checkoutCart->updateItem( + $cartItemId, + $quantity, + $variantId, + $updateVariant, + $purchase->getKey(), + ); + $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)), ), diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index df24c14..403171e 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -14,6 +14,5 @@ Route::prefix('tenants/{tenant:codigo}') Route::prefix('tenants/{tenant:codigo}') ->middleware('auth:sanctum') ->group(function (): void { - Route::post('checkout-carts/{cart}/edit', [CartController::class, 'prepareCheckoutEditing']); Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem']); }); diff --git a/app/Domains/Purchase/documentacion/README.md b/app/Domains/Purchase/documentacion/README.md index 420e3e7..a159fbf 100644 --- a/app/Domains/Purchase/documentacion/README.md +++ b/app/Domains/Purchase/documentacion/README.md @@ -24,7 +24,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca Durante `created` y `pending_payment`, `PurchaseResource` publica las líneas del carrito con `items_source=cart`; una compra materializada publica `items_source=purchase`. Los datos descriptivos y económicos del checkout se resuelven siempre desde el catálogo vigente. -Las cantidades y variantes se editan mediante el dominio Cart. Los endpoints autenticados `POST /checkout-carts/{cart}/edit` y `PATCH /checkout-carts/{cart}/items/{cartItem}` validan que el carrito pertenezca al usuario y a una compra editable; Purchase no expone operaciones sobre líneas antes de la confirmación. +Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación. `UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones. diff --git a/postman/generate-shopit-collection.php b/postman/generate-shopit-collection.php index 2ca60a6..043cc47 100644 --- a/postman/generate-shopit-collection.php +++ b/postman/generate-shopit-collection.php @@ -85,7 +85,6 @@ function bodyFor(string $method, string $uri): ?array 'POST api/tenants/{tenant:codigo}/compras/start-checkout' => ['cart_id' => '{{cart_id}}'], 'PATCH api/tenants/{tenant:codigo}/compras/{compra}/customer-data' => ['dni' => '30123456', 'telefono' => '+5491112345678', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}'], 'PATCH api/tenants/{tenant:codigo}/checkout-carts/{cart}/items/{cartItem}' => ['cantidad' => 2], - 'POST api/tenants/{tenant:codigo}/checkout-carts/{cart}/edit' => [], 'POST api/tenants/{tenant:codigo}/compras/{compra}/payment-intent' => ['method' => 'transfer', 'transfer_payer_dni' => '30123456'], 'POST api/tenants/{tenant:codigo}/tickets/pdf' => ['ticket_ids' => [1]], 'POST api/v1/adminapp/login' => ['email' => '{{admin_email}}', 'password' => '{{admin_password}}'], @@ -246,7 +245,6 @@ function requestName(string $method, string $action, bool $multiMethod): string 'index' => 'List', 'store' => 'Create', 'show' => 'Get', 'update' => 'Update', 'destroy' => 'Delete', 'addItem' => 'Add Item', 'updateItemQuantity' => 'Update Item Quantity', 'updateCheckoutItem' => 'Update Checkout Cart Item', - 'prepareCheckoutEditing' => 'Prepare Checkout Cart Editing', 'removeItem' => 'Remove Item', 'search' => 'Search', 'category' => 'Get Category', 'featuredGroupItems' => 'List Featured Group Items', 'variantOptions' => 'Get Variant Options', 'startCheckout' => 'Start Checkout', 'updateCustomerData' => 'Update Customer Data', diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 6b6f337..171e01f 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -631,7 +631,7 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_reopens_a_pending_purchase_before_editing_items(): void + public function test_it_invalidates_a_pending_payment_when_the_checkout_cart_changes(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -645,16 +645,20 @@ class StorePurchaseTest extends TestCase 'qr_order_id' => 'stale-order', 'qr_code' => 'stale-qr', ]); + $itemId = $purchase->cart->items->firstOrFail()->id; $this->actingAs($user, 'sanctum') - ->postJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/edit") + ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ + 'cantidad' => 3, + ]) ->assertOk() - ->assertJsonPath('data.status', 'checkout'); + ->assertJsonPath('data.items.0.cantidad', 3); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, 'status' => Purchase::STATUS_CREATED, 'payment_method' => null, + 'total' => '150.00', ]); $this->assertDatabaseMissing('telepagos_qr', [ 'compra_id' => $purchase->id, @@ -662,6 +666,39 @@ class StorePurchaseTest extends TestCase ]); } + public function test_a_no_op_checkout_cart_update_keeps_the_pending_payment_intact(): 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); + $purchase->update([ + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'payment_method' => 'qr', + ]); + $purchase->telepagosQr()->create([ + 'qr_order_id' => 'current-order', + 'qr_code' => 'current-qr', + ]); + $itemId = $purchase->cart->items->firstOrFail()->id; + + $this->actingAs($user, 'sanctum') + ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ + 'cantidad' => 2, + ]) + ->assertOk(); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'payment_method' => 'qr', + ]); + $this->assertDatabaseHas('telepagos_qr', [ + 'compra_id' => $purchase->id, + 'qr_order_id' => 'current-order', + ]); + } + public function test_start_checkout_rejects_customer_data(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');