refactor(cart): invalidate payment on actual changes
This commit is contained in:
parent
3206e293eb
commit
8d6bcdcc43
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue