diff --git a/.env.example b/.env.example index 1f4f388..defb4fe 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30 PURCHASE_QR_EXPIRATION_MINUTES=15 PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30 PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440 +STOCK_RESERVATION_EXPIRATION_MINUTES=30 FRONTEND_URLS=http://localhost:4200 APP_LOCALE=es diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index 7bb4ba5..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": [ @@ -572,6 +572,60 @@ } }, "response": [] + }, + { + "name": "Update Checkout Cart Item Cart", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/checkout-carts/{cart}/items/{cartItem}`\n\nControlador: `App\\Domains\\Cart\\Controllers\\CartController@updateCheckoutItem`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/tenants/{{tenant_code}}/checkout-carts/{{cart}}/items/{{cart_item_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "tenants", + "{{tenant_code}}", + "checkout-carts", + "{{cart}}", + "items", + "{{cart_item_id}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cantidad\": 2\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] } ] }, @@ -1115,99 +1169,6 @@ }, "response": [] }, - { - "name": "Prepare Item Editing Purchase", - "request": { - "method": "POST", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/compras/{compra}/edit-items`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@prepareItemEditing`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/edit-items", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "compras", - "{{purchase_id}}", - "edit-items" - ] - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] - }, - { - "name": "Update Item Quantity Purchase", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/compras/{compra}/items/{item}`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@updateItemQuantity`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/items/{{purchase_item_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "compras", - "{{purchase_id}}", - "items", - "{{purchase_item_id}}" - ] - }, - "body": { - "mode": "raw", - "raw": "{\n \"quantity\": 2\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] - }, { "name": "Create Payment Intent Purchase", "request": { diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index 368a77c..de4aa2c 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -2,6 +2,7 @@ namespace App\Domains\Cart\Controllers; +use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Requests\AddCartItemRequest; use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest; @@ -77,6 +78,36 @@ class CartController extends Controller ]); } + public function updateCheckoutItem( + UpdateCartItemQuantityRequest $request, + Tenant $tenant, + Cart $cart, + CartItem $cartItem, + ): CartResource { + $updatesVariant = $request->exists('variant_id'); + + return CartResource::make( + $this->cartService->updateCheckoutItem( + $tenant, + $request, + $cart, + $cartItem->getKey(), + (int) $request->validated('cantidad'), + $updatesVariant + ? ($request->validated('variant_id') !== null + ? (int) $request->validated('variant_id') + : null) + : $cartItem->variant_id, + $updatesVariant, + ), + )->additional([ + 'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated', + 'message' => $updatesVariant + ? __('api.cart.item_updated') + : __('api.cart.quantity_updated'), + ]); + } + public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource { return CartResource::make( diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index cfddda5..6032b94 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -7,6 +7,8 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; +use App\Domains\Catalog\Services\StockReservationService; +use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -24,6 +26,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 'user_id', 'guest_token', 'status', + 'origin', ])] class Cart extends Model { @@ -32,6 +35,10 @@ class Cart extends Model protected $table = 'carritos'; + public const ORIGIN_USER = 'user'; + + public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout'; + protected function casts(): array { return [ @@ -63,6 +70,12 @@ class Cart extends Model return $this->hasMany(CartItem::class, 'cart_id'); } + /** @return HasMany */ + public function purchases(): HasMany + { + return $this->hasMany(Purchase::class, 'cart_id'); + } + public function getTotalAmount(): float { $items = $this->relationLoaded('items') @@ -114,11 +127,12 @@ class Cart extends Model 'cantidad' => $quantity, ]); } else { + app(StockReservationService::class)->ensure($item, $selectedItem); $item->cantidad += $quantity; $item->save(); } - $inventoryService->reserve($selectedItem, $quantity); + app(StockReservationService::class)->reserve($item, $selectedItem, $quantity); return $item->fresh(); }); @@ -129,6 +143,7 @@ class Cart extends Model int $quantity, ?int $variantId = null, bool $updateVariant = false, + ?int $excludedPurchaseId = null, ): CartItem { if ($quantity <= 0) { throw ValidationException::withMessages([ @@ -141,6 +156,7 @@ class Cart extends Model $quantity, $variantId, $updateVariant, + $excludedPurchaseId, ): CartItem { /** @var CartItem $item */ $item = $this->items() @@ -168,9 +184,10 @@ class Cart extends Model $this->assertUserPurchaseLimit( $nextSelection, $otherVariantsQuantity + $quantity, + $excludedPurchaseId, ); - $inventoryService->release($currentSelection, $item->cantidad); + app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad); $availableQuantity = $inventoryService->availableQuantity($nextSelection); if ($availableQuantity !== null && $availableQuantity < $quantity) { @@ -186,11 +203,11 @@ class Cart extends Model ->lockForUpdate() ->first(); - $inventoryService->reserve($nextSelection, $quantity); - if ($targetItem !== null) { + app(StockReservationService::class)->ensure($targetItem, $nextSelection); $targetItem->cantidad += $quantity; $targetItem->save(); + app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity); $item->delete(); return $targetItem->fresh(); @@ -199,6 +216,7 @@ class Cart extends Model $item->variant_id = $variantId; $item->cantidad = $quantity; $item->save(); + app(StockReservationService::class)->reserve($item, $nextSelection, $quantity); return $item->fresh(); } @@ -213,6 +231,7 @@ class Cart extends Model $this->assertUserPurchaseLimit( $currentSelection, $otherVariantsQuantity + $quantity, + $excludedPurchaseId, ); } @@ -225,17 +244,17 @@ class Cart extends Model ]); } - $item->cantidad = $quantity; - $item->save(); - if ($delta > 0) { - $inventoryService->reserve($currentSelection, $delta); + app(StockReservationService::class)->reserve($item, $currentSelection, $delta); } if ($delta < 0) { - $inventoryService->release($currentSelection, abs($delta)); + app(StockReservationService::class)->release($item, $currentSelection, abs($delta)); } + $item->cantidad = $quantity; + $item->save(); + return $item->fresh(); }); } @@ -254,7 +273,8 @@ class Cart extends Model $item->variant_id, true, ); - app(CatalogInventoryService::class)->release( + app(StockReservationService::class)->release( + $item, $selectedItem, $item->cantidad, ); @@ -334,6 +354,7 @@ class Cart extends Model private function assertUserPurchaseLimit( CatalogItem|Variant $selectedItem, int $cartQuantity, + ?int $excludedPurchaseId = null, ): void { if ($this->user_id === null) { return; @@ -347,6 +368,7 @@ class Cart extends Model $catalogItem, $this->user_id, $cartQuantity, + $excludedPurchaseId, field: 'cantidad', ); } diff --git a/app/Domains/Cart/Models/CartItem.php b/app/Domains/Cart/Models/CartItem.php index 3841533..8ece5b4 100644 --- a/app/Domains/Cart/Models/CartItem.php +++ b/app/Domains/Cart/Models/CartItem.php @@ -3,11 +3,13 @@ namespace App\Domains\Cart\Models; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\Variant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'cart_id', @@ -55,4 +57,10 @@ class CartItem extends Model { return $this->variant ?? $this->catalogItem; } + + /** @return HasMany */ + public function stockReservations(): HasMany + { + return $this->hasMany(StockReservation::class); + } } diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index 954f784..dc76a9c 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -4,15 +4,24 @@ 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; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class CartService { + public function __construct( + private readonly StockReservationService $reservations, + ) {} + public function show(Tenant $tenant, Request $request): Cart { $resolvedIdentity = $this->resolveIdentity($request); @@ -66,6 +75,107 @@ class CartService return $this->loadCart($cart); } + public function updateCheckoutItem( + Tenant $tenant, + Request $request, + Cart $cart, + int $cartItemId, + int $quantity, + ?int $variantId, + bool $updateVariant, + ): 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, + $cartItemId, + $quantity, + $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()) + ->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.'); + } + + /** @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)), + ), + ]); + $this->reservations->syncPurchaseExpiration($purchase); + + return $this->loadCart($checkoutCart); + }); + } + public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart { $identity = $this->requireIdentity($request); diff --git a/app/Domains/Cart/Services/ExpireCartReservationsService.php b/app/Domains/Cart/Services/ExpireCartReservationsService.php new file mode 100644 index 0000000..d879994 --- /dev/null +++ b/app/Domains/Cart/Services/ExpireCartReservationsService.php @@ -0,0 +1,123 @@ +where('status', StockReservation::STATUS_ACTIVE) + ->whereNull('purchase_id') + ->whereNotNull('cart_item_id') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->where('cart_item_id', '>', $lastCartItemId) + ->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active')) + ->select('cart_item_id') + ->distinct() + ->orderBy('cart_item_id') + ->limit(500) + ->pluck('cart_item_id'); + + foreach ($cartItemIds as $cartItemId) { + $lastCartItemId = (int) $cartItemId; + + if ($this->expireCartItem($lastCartItemId)) { + $expiredItems++; + } + } + } while ($cartItemIds->count() === 500); + + return $expiredItems; + } + + private function expireCartItem(int $cartItemId): bool + { + /** @var CartItem|null $candidate */ + $candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId); + if ($candidate === null) { + return false; + } + + return DB::transaction(function () use ($candidate, $cartItemId): bool { + /** @var Cart|null $cart */ + $cart = Cart::query() + ->whereKey($candidate->cart_id) + ->where('status', 'active') + ->lockForUpdate() + ->first(); + + if ($cart === null) { + return false; + } + + /** @var CartItem|null $cartItem */ + $cartItem = $cart->items() + ->whereKey($cartItemId) + ->lockForUpdate() + ->first(); + + if ($cartItem === null) { + return false; + } + + $reservations = StockReservation::query() + ->where('cart_item_id', $cartItem->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->orderBy('inventory_id') + ->lockForUpdate() + ->get(); + + if ( + $reservations->isEmpty() + || $reservations->contains( + fn (StockReservation $reservation): bool => $reservation->purchase_id !== null + || $reservation->expires_at === null + || $reservation->expires_at->isFuture(), + ) + ) { + return false; + } + + $inventories = Inventory::query() + ->whereKey($reservations->pluck('inventory_id')) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + + foreach ($reservations as $reservation) { + $inventory = $inventories->get($reservation->inventory_id) + ?? throw new \InvalidArgumentException('No se encontro el inventario reservado.'); + + $inventory->release((int) $reservation->quantity); + $reservation->update([ + 'quantity' => 0, + 'status' => StockReservation::STATUS_EXPIRED, + 'expires_at' => null, + 'released_at' => now(), + ]); + } + + $cartItem->delete(); + + if (! $cart->items()->exists()) { + $cart->update(['status' => 'expired']); + $cart->delete(); + } + + return true; + }); + } +} diff --git a/app/Domains/Cart/Services/GuestCartMergeService.php b/app/Domains/Cart/Services/GuestCartMergeService.php index 5504a4e..0e600fa 100644 --- a/app/Domains/Cart/Services/GuestCartMergeService.php +++ b/app/Domains/Cart/Services/GuestCartMergeService.php @@ -34,6 +34,10 @@ class GuestCartMergeService ->first(); if ($userCart !== null) { + $userCart->items() + ->orderBy('id') + ->pluck('id') + ->each(fn (int $itemId) => $userCart->removeItem($itemId)); $userCart->update([ 'status' => 'converted', ]); diff --git a/app/Domains/Cart/documentacion/README.md b/app/Domains/Cart/documentacion/README.md index 3f2edf4..8dad58e 100644 --- a/app/Domains/Cart/documentacion/README.md +++ b/app/Domains/Cart/documentacion/README.md @@ -7,11 +7,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios ## Modelo - `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems. -- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva. +- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo. ## Servicios - `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado. +- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos. - `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica. ## Endpoints @@ -30,3 +31,7 @@ Bajo `/tenants/{tenant:codigo}`: ## Dependencias y reglas Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual. + +Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra. + +El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index 28488a7..403171e 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -10,3 +10,9 @@ Route::prefix('tenants/{tenant:codigo}') Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']); Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']); }); + +Route::prefix('tenants/{tenant:codigo}') + ->middleware('auth:sanctum') + ->group(function (): void { + Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem']); + }); diff --git a/app/Domains/Catalog/Models/Inventory.php b/app/Domains/Catalog/Models/Inventory.php index 4bd24e7..c15debe 100644 --- a/app/Domains/Catalog/Models/Inventory.php +++ b/app/Domains/Catalog/Models/Inventory.php @@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable([ @@ -47,6 +48,12 @@ class Inventory extends Model return $this->hasOne(Variant::class); } + /** @return HasMany */ + public function stockReservations(): HasMany + { + return $this->hasMany(StockReservation::class); + } + public function availableStock(): int { return max(0, $this->real_stock - $this->reserved_stock); diff --git a/app/Domains/Catalog/Models/StockReservation.php b/app/Domains/Catalog/Models/StockReservation.php new file mode 100644 index 0000000..3e4dad3 --- /dev/null +++ b/app/Domains/Catalog/Models/StockReservation.php @@ -0,0 +1,61 @@ + 'integer', + 'cart_item_id' => 'integer', + 'purchase_id' => 'integer', + 'quantity' => 'integer', + 'expires_at' => 'datetime', + 'committed_at' => 'datetime', + 'released_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } + + /** @return BelongsTo */ + public function cartItem(): BelongsTo + { + return $this->belongsTo(CartItem::class); + } + + /** @return BelongsTo */ + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class); + } +} diff --git a/app/Domains/Catalog/Services/CatalogInventoryService.php b/app/Domains/Catalog/Services/CatalogInventoryService.php index f1c01be..d384eb8 100644 --- a/app/Domains/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Catalog/Services/CatalogInventoryService.php @@ -12,6 +12,19 @@ use Illuminate\Support\Facades\DB; class CatalogInventoryService { + /** @return array */ + public function requirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array + { + if ($quantity <= 0) { + throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.'); + } + + return array_map( + fn (array $requirement): int => $requirement['quantity'] * $quantity, + $this->inventoryRequirements($selection), + ); + } + public function availableQuantity(CatalogItem|Variant $selection): ?int { if ($selection instanceof CatalogItem diff --git a/app/Domains/Catalog/Services/ExpireStockReservationsService.php b/app/Domains/Catalog/Services/ExpireStockReservationsService.php new file mode 100644 index 0000000..741f5a1 --- /dev/null +++ b/app/Domains/Catalog/Services/ExpireStockReservationsService.php @@ -0,0 +1,25 @@ + $this->checkout->expireOverduePurchases(), + 'cart_items' => $this->carts->expireOverdue(), + ]; + } +} diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php new file mode 100644 index 0000000..24a7f51 --- /dev/null +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -0,0 +1,230 @@ +inventory->reserve($selection, $quantity); + $this->recordIncrease($cartItem, $selection, $quantity); + }); + } + + public function release( + CartItem $cartItem, + CatalogItem|Variant $selection, + int $quantity, + string $releasedStatus = StockReservation::STATUS_RELEASED, + ): void { + DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void { + $this->ensure($cartItem, $selection); + $this->inventory->release($selection, $quantity); + $this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus); + }); + } + + public function commit(CartItem $cartItem, CatalogItem|Variant $selection): void + { + DB::transaction(function () use ($cartItem, $selection): void { + $this->ensure($cartItem, $selection); + $this->inventory->commit($selection, (int) $cartItem->cantidad); + + $requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad); + foreach ($requirements as $inventoryId => $quantity) { + $reservation = $this->lockReservation($cartItem, $inventoryId); + if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) { + throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.'); + } + + $reservation->update([ + 'status' => StockReservation::STATUS_COMMITTED, + 'committed_at' => now(), + 'expires_at' => null, + ]); + } + }); + } + + public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void + { + $requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad); + + foreach ($requirements as $inventoryId => $quantity) { + $reservation = $this->lockReservation($cartItem, $inventoryId); + + if ($reservation === null) { + StockReservation::query()->create([ + 'inventory_id' => $inventoryId, + 'cart_item_id' => $cartItem->getKey(), + 'quantity' => $quantity, + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => $this->expiration(), + ]); + + continue; + } + + if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) { + $reservation->update([ + 'quantity' => $quantity, + 'status' => StockReservation::STATUS_ACTIVE, + 'committed_at' => null, + 'released_at' => null, + 'expires_at' => $this->expiration(), + ]); + } + } + } + + public function attachToPurchase( + CartItem $cartItem, + CatalogItem|Variant $selection, + Purchase $purchase, + ): void { + DB::transaction(function () use ($cartItem, $selection, $purchase): void { + $this->ensure($cartItem, $selection); + StockReservation::query() + ->where('cart_item_id', $cartItem->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->update([ + 'purchase_id' => $purchase->getKey(), + 'expires_at' => $purchase->expires_at, + ]); + }); + } + + public function detachFromPurchase(Purchase $purchase): void + { + StockReservation::query() + ->where('purchase_id', $purchase->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->update([ + 'purchase_id' => null, + 'expires_at' => $this->expiration(), + ]); + } + + public function syncPurchaseExpiration(Purchase $purchase): void + { + StockReservation::query() + ->where('purchase_id', $purchase->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->update(['expires_at' => $purchase->expires_at]); + } + + public function transfer(CartItem $source, CartItem $target): void + { + DB::transaction(function () use ($source, $target): void { + $sourceReservations = StockReservation::query() + ->where('cart_item_id', $source->getKey()) + ->where('status', StockReservation::STATUS_ACTIVE) + ->orderBy('inventory_id') + ->lockForUpdate() + ->get(); + + foreach ($sourceReservations as $sourceReservation) { + $targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id); + + if ($targetReservation === null) { + $sourceItemQuantity = (int) $source->cantidad; + $targetItemQuantity = (int) $target->fresh()->cantidad; + $perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity); + $sourceReservation->update([ + 'cart_item_id' => $target->getKey(), + 'purchase_id' => null, + 'quantity' => $perItemQuantity * $targetItemQuantity, + 'expires_at' => $this->expiration(), + ]); + + continue; + } + + $targetReservation->update([ + 'quantity' => $targetReservation->quantity + $sourceReservation->quantity, + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => $this->expiration(), + ]); + $sourceReservation->delete(); + } + }); + } + + private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void + { + foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) { + $reservation = $this->lockReservation($cartItem, $inventoryId); + + if ($reservation === null) { + StockReservation::query()->create([ + 'inventory_id' => $inventoryId, + 'cart_item_id' => $cartItem->getKey(), + 'quantity' => $requiredQuantity, + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => $this->expiration(), + ]); + + continue; + } + + $reservation->update([ + 'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity, + 'status' => StockReservation::STATUS_ACTIVE, + 'committed_at' => null, + 'released_at' => null, + 'expires_at' => $this->expiration(), + ]); + } + } + + private function recordDecrease( + CartItem $cartItem, + CatalogItem|Variant $selection, + int $quantity, + string $releasedStatus, + ): void { + foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) { + $reservation = $this->lockReservation($cartItem, $inventoryId); + if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) { + throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.'); + } + + $remaining = $reservation->quantity - $requiredQuantity; + $reservation->update([ + 'quantity' => $remaining, + 'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE, + 'released_at' => $remaining === 0 ? now() : null, + 'expires_at' => $remaining === 0 ? null : $reservation->expires_at, + ]); + } + } + + private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation + { + return StockReservation::query() + ->where('cart_item_id', $cartItem->getKey()) + ->where('inventory_id', $inventoryId) + ->lockForUpdate() + ->first(); + } + + private function expiration(): Carbon + { + return now()->addMinutes( + max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)), + ); + } +} diff --git a/app/Domains/Catalog/documentacion/README.md b/app/Domains/Catalog/documentacion/README.md index 9ecf3aa..baa2bb3 100644 --- a/app/Domains/Catalog/documentacion/README.md +++ b/app/Domains/Catalog/documentacion/README.md @@ -9,6 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí - `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados. - `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables. - `Inventory` administra stock disponible, reservado y comprado. +- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`. - `Category` soporta jerarquía y categorías globales o propias del tenant. - `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas. - `BundleComponent` representa los componentes de un paquete. @@ -17,6 +18,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí - `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación. - `CatalogInventoryService`: consulta, reserva, libera y confirma inventario. +- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`. - `FeaturedGroupService`: pagina los ítems destacados para la tienda. - `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets. diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index fbb56f4..fcda49d 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -4,11 +4,9 @@ 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; -use App\Domains\Purchase\Requests\UpdatePurchaseItemQuantityRequest; use App\Domains\Purchase\Resources\PurchaseResource; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; @@ -56,7 +54,16 @@ class PurchaseController extends Controller { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); - $compra->loadMissing('items')->loadCount('tickets'); + $compra->loadMissing([ + 'items', + 'cart.items.catalogItem.inventory', + 'cart.items.catalogItem.attachments', + 'cart.items.variant.inventory', + 'cart.items.variant.attachments', + 'cart.items.variant.definitions.itemAttribute.attribute', + 'cart.items.variant.eventDates', + 'cart.items.variant.eventDate', + ])->loadCount('tickets'); $compra->items->load('imageAttachment'); return PurchaseResource::make($compra); @@ -75,39 +82,12 @@ class PurchaseController extends Controller ); } - public function updateItemQuantity( - UpdatePurchaseItemQuantityRequest $request, - Tenant $tenant, - Purchase $compra, - PurchaseItem $item, - CheckoutService $checkoutService, - ): PurchaseResource { - $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); - - return PurchaseResource::make( - $checkoutService->updateItemQuantity( - $compra, - $item, - (int) $request->validated('quantity'), - ), - ); - } - - public function prepareItemEditing( - Request $request, + public function paymentIntent( + PaymentIntentRequest $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService, - ): PurchaseResource { - $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); - - return PurchaseResource::make( - $checkoutService->prepareItemEditing($compra), - ); - } - - public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse - { + ): JsonResponse { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $method = $request->validated('method'); $totalAmount = $compra->calculateCurrentTotalAmount(); @@ -148,13 +128,14 @@ class PurchaseController extends Controller return true; }); - if ($updated === 0) { + if (! $updated) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.not_available_for_payment'), ]); } $compra->refresh(); + $checkoutService->syncReservationExpiration($compra); if ($method === 'transfer') { $telepagosService = new TelepagosIntegrationService; diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index a5dcb11..7bb3d94 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Models; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Tenant\Models\Tenant; @@ -116,6 +117,12 @@ class Purchase extends Model return $this->hasMany(Ticket::class, 'source_purchase_id'); } + /** @return HasMany */ + public function stockReservations(): HasMany + { + return $this->hasMany(StockReservation::class); + } + /** * @return HasOne */ @@ -143,11 +150,16 @@ class Purchase extends Model public function calculateCurrentTotalAmount(): float { - if ($this->relationLoaded('items')) { + if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) { return (float) $this->getRelation('items')->sum('total'); } - return (float) $this->items()->sum('total'); + $itemsTotal = (float) $this->items()->sum('total'); + if ($itemsTotal > 0 || $this->items()->exists()) { + return $itemsTotal; + } + + return (float) ($this->cart?->getTotalAmount() ?? $this->total ?? 0); } protected function valueChangeTenantCode(): string diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 7f82f2c..2894caa 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -23,18 +23,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'discount_total', 'tax_total', 'total', - 'reservation_status', ])] class PurchaseItem extends Model { use HasFactory; - public const RESERVATION_ACTIVE = 'active'; - - public const RESERVATION_COMMITTED = 'committed'; - - public const RESERVATION_RELEASED = 'released'; - protected $table = 'compra_items'; protected function casts(): array diff --git a/app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php b/app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php deleted file mode 100644 index 9810352..0000000 --- a/app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php +++ /dev/null @@ -1,23 +0,0 @@ -user() !== null; - } - - /** - * @return array - */ - public function rules(): array - { - return [ - 'quantity' => ['required', 'integer', 'min:1', 'max:100'], - ]; - } -} diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index 5fb2b90..bd0d700 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -57,22 +57,12 @@ class PurchaseItemResource extends JsonResource 'quantity' => $quantity, 'unit_price' => $this->formatMoney($unitPrice), 'line_total' => $this->formatMoney($lineTotal), - 'catalog_item_id' => $this->catalog_item_id, - 'variant_id' => $this->variant_id, - 'product' => $catalogItem === null ? null : [ - 'id' => $catalogItem->id, - 'nombre' => $catalogItem->nombre, - 'descripcion' => $catalogItem->descripcion, - 'slug' => $catalogItem->slug, - 'imagen' => $imageUrl, - ], - 'variant' => $variant === null ? null : [ - 'id' => $variant->id, - 'attributes' => $this->resolveAttributes($variant), - ], + 'source_catalog_item_id' => $this->catalog_item_id, + 'source_variant_id' => $this->variant_id, 'item_details' => $selectedItem === null ? null : [ 'nombre' => $selectedItem->getName(), 'descripcion' => $selectedItem->getDescription(), + 'slug' => $catalogItem?->slug, 'imagen' => $imageUrl, 'attributes' => $variant === null ? [] : $this->resolveAttributes($variant), ], diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index b383adc..090b52f 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Purchase\Resources; +use App\Domains\Cart\Models\CartItem; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Http\Request; @@ -17,26 +18,37 @@ class PurchaseResource extends JsonResource */ public function toArray(Request $request): array { - $items = $this->resource->relationLoaded('items') + $purchaseItems = $this->resource->relationLoaded('items') ? $this->resource->getRelation('items') : collect(); + $cartItems = $purchaseItems->isEmpty() + && $this->resource->relationLoaded('cart') + && $this->resource->getRelation('cart')?->relationLoaded('items') + ? $this->resource->getRelation('cart')->getRelation('items') + : collect(); + $items = $purchaseItems->isNotEmpty() ? $purchaseItems : $cartItems; + $itemsSource = $purchaseItems->isNotEmpty() + ? 'purchase' + : ($cartItems->isNotEmpty() ? 'cart' : null); $ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes()) ? (int) $this->resource->getAttribute('tickets_count') : null; $subtotal = $items->isNotEmpty() ? $items->reduce( - fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item), + fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item), 0.0, ) : (float) ($this->total ?? 0); - $total = $items->isNotEmpty() - ? $items->reduce( - fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item), - 0.0, - ) - : (float) ($this->total ?? 0); + $total = $this->status === Purchase::STATUS_PAID && $this->total !== null + ? (float) $this->total + : ($items->isNotEmpty() + ? $items->reduce( + fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item), + 0.0, + ) + : (float) ($this->total ?? 0)); return [ 'id' => $this->id, @@ -52,7 +64,7 @@ class PurchaseResource extends JsonResource 'telefono' => $this->telefono, 'nombre_apellido' => $this->nombre_apellido, 'email' => $this->email, - 'items_source' => $items->isNotEmpty() ? 'purchase' : null, + 'items_source' => $itemsSource, 'items' => PurchaseItemResource::collection($items), 'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount), 'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0), @@ -61,13 +73,21 @@ class PurchaseResource extends JsonResource ]; } - protected function resolveItemSubtotal(PurchaseItem $item): float + protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float { + if ($item instanceof CartItem) { + return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad; + } + return (float) $item->precio_unitario * $item->cantidad; } - protected function resolveItemTotal(PurchaseItem $item): float + protected function resolveItemTotal(PurchaseItem|CartItem $item): float { + if ($item instanceof CartItem) { + return $this->resolveItemSubtotal($item); + } + return (float) ($item->total ?? 0); } diff --git a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php index db246ed..cfa9233 100644 --- a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php +++ b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; -use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -79,13 +78,4 @@ class CatalogSelectionResolver return $variant; } - - public function resolvePurchaseItem(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant - { - return $this->resolve( - $tenant, - (int) $item->source_catalog_item_id, - $item->source_variant_id === null ? null : (int) $item->source_variant_id, - ); - } } diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php index d5c2baa..adbfd58 100644 --- a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -2,18 +2,19 @@ namespace App\Domains\Purchase\Services\Checkout; -use App\Domains\Catalog\Services\CatalogInventoryService; +use App\Domains\Cart\Models\CartItem; +use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; class CompleteCheckoutService { public function __construct( - private readonly CatalogInventoryService $inventory, - private readonly CatalogSelectionResolver $selections, + private readonly StockReservationService $reservations, private readonly SourceCartService $sourceCart, + private readonly PurchaseItemSnapshotFactory $snapshots, ) {} public function complete(Purchase $purchase): Purchase @@ -59,6 +60,7 @@ class CompleteCheckoutService } $purchase->update(['expires_at' => null]); + $this->reservations->syncPurchaseExpiration($purchase); return $this->loadPurchase($purchase); }); @@ -83,25 +85,51 @@ class CompleteCheckoutService ]); } - $items = $purchase->items() - ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) - ->lockForUpdate() - ->get(); + if ($purchase->items()->exists()) { + $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + if ($cart?->status === 'converted' && $cart->trashed()) { + return; + } - foreach ($items as $item) { - $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } - try { - $this->inventory->commit($selection, (int) $item->cantidad); - } catch (\InvalidArgumentException) { + $cart = $purchase->cart()->lockForUpdate()->first(); + if ($cart === null || $cart->status !== 'checkout') { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + + $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); + if ($cartItems->isEmpty()) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + + $this->loadCartItems($cartItems); + $purchase->items()->createMany( + $this->snapshots->fromCartItems($cartItems), + ); + + foreach ($cartItems as $cartItem) { + $selection = $cartItem->selectedItem(); + if ($selection === null) { throw ValidationException::withMessages([ 'items' => __('api.purchase.inconsistent_reservation'), ]); } - $item->update([ - 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, - ]); + try { + $this->reservations->commit($cartItem, $selection); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } } $this->sourceCart->finalize($purchase); @@ -126,6 +154,30 @@ class CompleteCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $purchase->load([ + 'items.imageAttachment', + 'cart.items.catalogItem.inventory', + 'cart.items.catalogItem.attachments', + 'cart.items.variant.inventory', + 'cart.items.variant.attachments', + 'cart.items.variant.definitions.itemAttribute.attribute', + 'cart.items.variant.eventDates', + 'cart.items.variant.eventDate', + ]); + } + + /** @param Collection $cartItems */ + private function loadCartItems(Collection $cartItems): void + { + $cartItems->load([ + 'catalogItem.inventory', + 'catalogItem.attachments', + 'variant.inventory', + 'variant.attachments', + 'variant.catalogItem', + 'variant.definitions.itemAttribute.attribute', + 'variant.eventDates', + 'variant.eventDate', + ]); } } diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php index 9491b38..f6f6d0d 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -2,24 +2,12 @@ namespace App\Domains\Purchase\Services\Checkout; -use App\Domains\Catalog\Models\Variant; -use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; -use App\Domains\Purchase\Services\UserPurchaseLimitService; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class EditCheckoutService { - public function __construct( - private readonly CatalogInventoryService $inventory, - private readonly UserPurchaseLimitService $purchaseLimits, - private readonly CatalogSelectionResolver $selections, - private readonly SourceCartService $sourceCart, - ) {} - /** @param array $customerData */ public function updateCustomer(Purchase $purchase, array $customerData): Purchase { @@ -33,115 +21,6 @@ class EditCheckoutService }); } - public function updateItemQuantity( - Purchase $purchase, - PurchaseItem $purchaseItem, - int $quantity, - ): Purchase { - return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase { - $purchase = $this->lockPurchase($purchase); - - if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { - throw ValidationException::withMessages([ - 'purchase' => __('api.purchase.not_editable'), - ]); - } - - $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); - $difference = $quantity - (int) $purchaseItem->cantidad; - - if ($difference !== 0) { - $this->adjustReservation($purchase, $purchaseItem, $quantity, $difference); - - $purchaseItem->update([ - 'cantidad' => $quantity, - 'total' => (float) $purchaseItem->precio_unitario * $quantity, - ]); - $this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $quantity); - } - - $purchase->update([ - 'total' => $purchase->calculateCurrentTotalAmount(), - ]); - - return $this->loadPurchase($purchase); - }); - } - - public function prepareItemEditing(Purchase $purchase): Purchase - { - return DB::transaction(function () use ($purchase): Purchase { - $purchase = $this->lockPurchase($purchase); - $this->assertEditable($purchase); - - $purchase->telepagosQr()->delete(); - $purchase->update([ - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'transfer_payer_dni' => null, - 'expires_at' => now()->addMinutes( - max(1, (int) config('purchase.checkout_expiration_minutes', 30)), - ), - ]); - - return $this->loadPurchase($purchase); - }); - } - - private function adjustReservation( - Purchase $purchase, - PurchaseItem $purchaseItem, - int $quantity, - int $difference, - ): void { - $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem); - - try { - if ($difference > 0) { - $otherItemQuantity = (int) $purchase->items() - ->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id) - ->whereKeyNot($purchaseItem->getKey()) - ->sum('cantidad'); - $catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection; - - $this->purchaseLimits->assertCanPurchase( - $catalogItem, - (int) $purchase->user_id, - $otherItemQuantity + $quantity, - $purchase->getKey(), - ); - $this->inventory->reserve($selection, $difference); - } else { - $this->inventory->release($selection, abs($difference)); - } - } catch (\InvalidArgumentException) { - throw ValidationException::withMessages([ - 'quantity' => __('api.purchase.insufficient_stock'), - ]); - } - } - - private function lockPurchaseItem(Purchase $purchase, PurchaseItem $item): PurchaseItem - { - /** @var PurchaseItem|null $lockedItem */ - $lockedItem = $purchase->items() - ->whereKey($item->getKey()) - ->lockForUpdate() - ->first(); - - if ($lockedItem === null) { - throw new NotFoundHttpException('Purchase item not found.'); - } - - if ($lockedItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) { - throw ValidationException::withMessages([ - 'item' => __('api.purchase.item_not_editable'), - ]); - } - - return $lockedItem; - } - private function assertEditable(Purchase $purchase): void { if ( @@ -170,6 +49,15 @@ class EditCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $purchase->load([ + 'items.imageAttachment', + 'cart.items.catalogItem.inventory', + 'cart.items.catalogItem.attachments', + 'cart.items.variant.inventory', + 'cart.items.variant.attachments', + 'cart.items.variant.definitions.itemAttribute.attribute', + 'cart.items.variant.eventDates', + 'cart.items.variant.eventDate', + ]); } } diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php index e1711a2..582dfc1 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Attachable\Models\Attachment; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Support\Collection; class PurchaseItemSnapshotFactory @@ -38,7 +37,6 @@ class PurchaseItemSnapshotFactory 'discount_total' => null, 'tax_total' => null, 'total' => $unitPrice * $quantity, - 'reservation_status' => PurchaseItem::RESERVATION_ACTIVE, ]; }) ->all(); diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index aa0ce9f..dcf7c68 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -2,17 +2,16 @@ namespace App\Domains\Purchase\Services\Checkout; -use App\Domains\Catalog\Services\CatalogInventoryService; +use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; class ReleaseCheckoutService { public function __construct( - private readonly CatalogInventoryService $inventory, - private readonly CatalogSelectionResolver $selections, + private readonly StockReservationService $reservations, private readonly SourceCartService $sourceCart, ) {} @@ -77,38 +76,71 @@ class ReleaseCheckoutService return $this->loadPurchase($purchase); } - $items = $purchase->items() - ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) - ->lockForUpdate() - ->get(); - $reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase); - - foreach ($items as $item) { - if (! $reservationReturnedToCart) { - $this->releaseInventory($purchase, $item); - } - - $item->update([ - 'reservation_status' => PurchaseItem::RESERVATION_RELEASED, + if ($purchase->items()->exists()) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), ]); } + $reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase); + $this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus); + $purchase->update(['status' => $targetStatus]); return $this->loadPurchase($purchase); }); } - private function releaseInventory(Purchase $purchase, PurchaseItem $item): void - { - $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); + private function releaseCartReservations( + Purchase $purchase, + bool $reservationReturnedToCart, + string $targetStatus, + ): void { + if ($reservationReturnedToCart) { + $this->reservations->detachFromPurchase($purchase); - try { - $this->inventory->release($selection, (int) $item->cantidad); - } catch (\InvalidArgumentException) { - throw ValidationException::withMessages([ - 'items' => __('api.purchase.inconsistent_reservation'), - ]); + return; + } + + $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + if ($cart === null) { + return; + } + + $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); + $cartItems->load([ + 'catalogItem.inventory', + 'catalogItem.bundleComponents.catalogItem.inventory', + 'catalogItem.bundleComponents.variant.inventory', + 'variant.inventory', + 'variant.catalogItem', + ]); + + foreach ($cartItems as $cartItem) { + $selection = $cartItem->selectedItem(); + if ($selection === null) { + continue; + } + + try { + $this->reservations->release( + $cartItem, + $selection, + (int) $cartItem->cantidad, + $targetStatus === Purchase::STATUS_EXPIRED + ? StockReservation::STATUS_EXPIRED + : StockReservation::STATUS_RELEASED, + ); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + } + + if (! $cart->trashed()) { + $cart->update(['status' => 'converted']); + $cart->delete(); } } @@ -129,6 +161,15 @@ class ReleaseCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $purchase->load([ + 'items.imageAttachment', + 'cart.items.catalogItem.inventory', + 'cart.items.catalogItem.attachments', + 'cart.items.variant.inventory', + 'cart.items.variant.attachments', + 'cart.items.variant.definitions.itemAttribute.attribute', + 'cart.items.variant.eventDates', + 'cart.items.variant.eventDate', + ]); } } diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php index 45624cd..f21078d 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -4,11 +4,15 @@ 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; -use App\Domains\Purchase\Models\PurchaseItem; class SourceCartService { + public function __construct( + private readonly StockReservationService $reservations, + ) {} + public function restore(Purchase $purchase): bool { $sourceCart = $this->findSourceCart($purchase); @@ -17,6 +21,10 @@ class SourceCartService return false; } + if ($sourceCart->origin === Cart::ORIGIN_DIRECT_CHECKOUT) { + return false; + } + /** @var Cart|null $activeCart */ $activeCart = Cart::query() ->where('tenant_codigo', $purchase->tenant_codigo) @@ -54,23 +62,6 @@ class SourceCartService return true; } - public function syncItemQuantity( - Purchase $purchase, - PurchaseItem $purchaseItem, - int $quantity, - ): 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) - ->update(['cantidad' => $quantity]); - } - public function finalize(Purchase $purchase): void { $sourceCart = $this->findSourceCart($purchase); @@ -112,7 +103,7 @@ class SourceCartService ->first(); if ($activeItem === null) { - $activeCart->items()->create([ + $activeItem = $activeCart->items()->create([ 'catalog_item_id' => $sourceItem->catalog_item_id, 'variant_id' => $sourceItem->variant_id, 'cantidad' => $sourceItem->cantidad, @@ -120,6 +111,8 @@ class SourceCartService } 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 60a7ccb..f59b812 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -7,6 +7,7 @@ use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; +use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Exceptions\InsufficientStockException; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\UserPurchaseLimitService; @@ -20,9 +21,9 @@ class StartCheckoutService { public function __construct( private readonly CatalogInventoryService $inventory, + private readonly StockReservationService $reservations, private readonly UserPurchaseLimitService $purchaseLimits, private readonly CatalogSelectionResolver $selections, - private readonly PurchaseItemSnapshotFactory $snapshots, private readonly InsufficientStockMessageBuilder $stockMessages, ) {} @@ -144,9 +145,24 @@ class StartCheckoutService throw new InsufficientStockException($unavailableItems); } + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $userId, + 'guest_token' => null, + 'status' => 'checkout', + 'origin' => Cart::ORIGIN_DIRECT_CHECKOUT, + ]); + + $cartItems = collect(); foreach ($resolvedLines as $line) { + $cartItem = $cart->items()->create([ + 'catalog_item_id' => $line['catalog_item_id'], + 'variant_id' => $line['variant_id'], + 'cantidad' => $line['quantity'], + ]); + try { - $this->inventory->reserve($line['selection'], $line['quantity']); + $this->reservations->reserve($cartItem, $line['selection'], $line['quantity']); } catch (\InvalidArgumentException) { $availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0; @@ -154,6 +170,10 @@ class StartCheckoutService $this->unavailableItem($line, $availableQuantity), ]); } + + $cartItem->setRelation('catalogItem', $line['catalog_item']); + $cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null); + $cartItems->push($cartItem); } $purchase = $this->createPurchase( @@ -163,19 +183,16 @@ class StartCheckoutService (float) $resolvedLines->sum( fn (array $line): float => $line['selection']->getPrice() * $line['quantity'], ), - null, + $cart->getKey(), ); - $directCartItems = $resolvedLines->map(fn (array $line): CartItem => $this->makeDirectCartItem( - $line['selection'], - $line['catalog_item_id'], - $line['variant_id'], - $line['quantity'], - )); - - $purchase->items()->createMany( - $this->snapshots->fromCartItems($directCartItems), - ); + foreach ($cartItems as $index => $cartItem) { + $this->reservations->attachToPurchase( + $cartItem, + $resolvedLines->get($index)['selection'], + $purchase, + ); + } return $this->loadPurchase($purchase); } @@ -235,7 +252,13 @@ class StartCheckoutService $cart->getTotalAmount(), $cart->getKey(), ); - $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); + foreach ($cartItems as $cartItem) { + $this->reservations->attachToPurchase( + $cartItem, + $cartItem->selectedItem(), + $purchase, + ); + } // The purchase owns the reservation until checkout finishes. The cart is // retained so it can be restored if the purchase is cancelled or expires. @@ -336,35 +359,6 @@ class StartCheckoutService ]); } - private function makeDirectCartItem( - CatalogItem|Variant $selection, - int $catalogItemId, - ?int $variantId, - int $quantity, - ): CartItem { - $catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection; - $catalogItem->loadMissing(['inventory', 'attachments']); - - if ($selection instanceof Variant) { - $selection->loadMissing([ - 'inventory', - 'attachments', - 'catalogItem', - 'definitions.itemAttribute.attribute', - ]); - } - - $item = new CartItem([ - 'catalog_item_id' => $catalogItemId, - 'variant_id' => $variantId, - 'cantidad' => $quantity, - ]); - $item->setRelation('catalogItem', $catalogItem); - $item->setRelation('variant', $selection instanceof Variant ? $selection : null); - - return $item; - } - /** @param Collection $cartItems */ private function loadCartItems(Collection $cartItems): void { @@ -382,6 +376,15 @@ class StartCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $purchase->load([ + 'items.imageAttachment', + 'cart.items.catalogItem.inventory', + 'cart.items.catalogItem.attachments', + 'cart.items.variant.inventory', + 'cart.items.variant.attachments', + 'cart.items.variant.definitions.itemAttribute.attribute', + 'cart.items.variant.eventDates', + 'cart.items.variant.eventDate', + ]); } } diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index b575acd..411f1b2 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -2,8 +2,8 @@ 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; @@ -23,6 +23,7 @@ class CheckoutService private readonly EditCheckoutService $editor, private readonly CompleteCheckoutService $completer, private readonly ReleaseCheckoutService $releaser, + private readonly StockReservationService $reservations, ) {} /** @param array $purchaseData */ @@ -47,19 +48,6 @@ class CheckoutService return $this->editor->updateCustomer($purchase, $customerData); } - public function updateItemQuantity( - Purchase $purchase, - PurchaseItem $purchaseItem, - int $quantity, - ): Purchase { - return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity); - } - - public function prepareItemEditing(Purchase $purchase): Purchase - { - return $this->editor->prepareItemEditing($purchase); - } - public function confirmPurchase(Purchase $purchase): void { $this->completer->confirm($purchase); @@ -94,4 +82,9 @@ class CheckoutService { return $this->releaser->expireOverdue(); } + + public function syncReservationExpiration(Purchase $purchase): void + { + $this->reservations->syncPurchaseExpiration($purchase); + } } diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index 8b3cc58..c45d096 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -2,6 +2,7 @@ namespace App\Domains\Purchase\Services; +use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; @@ -52,7 +53,24 @@ class UserPurchaseLimitService }) ->sum('cantidad'); - if ($purchasedQuantity + $requestedQuantity > $limit) { + $checkoutQuantity = (int) CartItem::query() + ->where('catalog_item_id', $catalogItem->getKey()) + ->whereHas('cart.purchases', function ($query) use ($userId, $excludedPurchaseId): void { + $query + ->where('user_id', $userId) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ]) + ->whereDoesntHave('items') + ->when( + $excludedPurchaseId !== null, + fn ($query) => $query->whereKeyNot($excludedPurchaseId), + ); + }) + ->sum('cantidad'); + + if ($purchasedQuantity + $checkoutQuantity + $requestedQuantity > $limit) { throw ValidationException::withMessages([ $field => __('api.purchase_limit.exceeded', ['max' => $limit]), ]); diff --git a/app/Domains/Purchase/documentacion/README.md b/app/Domains/Purchase/documentacion/README.md index 35d53d0..a159fbf 100644 --- a/app/Domains/Purchase/documentacion/README.md +++ b/app/Domains/Purchase/documentacion/README.md @@ -2,12 +2,12 @@ ## Propósito -Implementa el ciclo de compra y checkout: crea una compra desde el carrito, toma una instantánea de sus ítems, reserva inventario, permite ediciones, inicia el pago y confirma, cancela o vence la operación. +Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un carrito, mantiene sus líneas vivas contra catálogo durante el checkout, inicia el pago y materializa el snapshot definitivo al confirmar, o cancela y vence la operación. ## Modelo - `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`. -- `PurchaseItem`: snapshot del producto o variante, cantidad, precio y total al comprar. +- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra. - `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor. - `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional. @@ -15,13 +15,17 @@ Implementa el ciclo de compra y checkout: crea una compra desde el carrito, toma `CheckoutService` es la fachada estable. Delega en: -- `StartCheckoutService`: inicia la compra desde el carrito. -- `EditCheckoutService`: modifica cliente o cantidades antes del cierre. -- `CompleteCheckoutService`: completa, envía a revisión o confirma el pago. +- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`. +- `EditCheckoutService`: modifica los datos del comprador antes del cierre. +- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago. - `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes. -- `SourceCartService`: sincroniza, restaura o finaliza el carrito fuente. +- `SourceCartService`: restaura o finaliza el carrito fuente. - `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots. +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. 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. ## Endpoints diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php index 41cc919..f8e8d5d 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -7,8 +7,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func Route::get('compras', [PurchaseController::class, 'index']); Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']); Route::get('compras/{compra}', [PurchaseController::class, 'show']); - Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']); - Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, 'updateItemQuantity']); 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/config/catalog.php b/config/catalog.php new file mode 100644 index 0000000..4bab7dc --- /dev/null +++ b/config/catalog.php @@ -0,0 +1,5 @@ + (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30), +]; diff --git a/database/migrations/2026_08_19_000100_create_stock_reservations_table.php b/database/migrations/2026_08_19_000100_create_stock_reservations_table.php new file mode 100644 index 0000000..6529cf6 --- /dev/null +++ b/database/migrations/2026_08_19_000100_create_stock_reservations_table.php @@ -0,0 +1,59 @@ +string('origin')->default('user')->after('status'); + }); + + Schema::create('stock_reservations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('inventory_id')->constrained('inventories')->restrictOnDelete(); + $table->foreignId('cart_item_id')->nullable()->constrained('carrito_items')->nullOnDelete(); + $table->foreignId('purchase_id')->nullable()->constrained('compras')->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->string('status')->default('active'); + $table->dateTime('expires_at')->nullable(); + $table->dateTime('committed_at')->nullable(); + $table->dateTime('released_at')->nullable(); + $table->timestamps(); + + $table->unique(['cart_item_id', 'inventory_id']); + $table->index(['purchase_id', 'status']); + $table->index(['status', 'expires_at']); + }); + + CartItem::query() + ->whereHas('cart', fn ($query) => $query->where('status', 'active')) + ->with([ + 'catalogItem.inventory', + 'catalogItem.bundleComponents.catalogItem.inventory', + 'catalogItem.bundleComponents.variant.inventory', + 'variant.inventory', + 'variant.catalogItem', + ]) + ->eachById(function (CartItem $cartItem): void { + $selection = $cartItem->selectedItem(); + if ($selection !== null) { + app(StockReservationService::class)->ensure($cartItem, $selection); + } + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_reservations'); + + Schema::table('carritos', function (Blueprint $table): void { + $table->dropColumn('origin'); + }); + } +}; diff --git a/database/migrations/2026_08_19_020000_drop_reservation_status_from_compra_items_table.php b/database/migrations/2026_08_19_020000_drop_reservation_status_from_compra_items_table.php new file mode 100644 index 0000000..4ea3563 --- /dev/null +++ b/database/migrations/2026_08_19_020000_drop_reservation_status_from_compra_items_table.php @@ -0,0 +1,24 @@ +dropColumn('reservation_status'); + }); + } + + public function down(): void + { + Schema::table('compra_items', function (Blueprint $table): void { + $table->string('reservation_status') + ->default('committed') + ->after('total'); + }); + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index fb1dbcf..f047b0e 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -37,7 +37,6 @@ return [ 'source_required' => 'A cart or direct item is required.', 'payment_method_required' => 'The purchase payment method must be selected before finalizing.', 'not_editable' => 'The purchase is no longer editable.', - 'item_not_editable' => 'The purchase item is no longer editable.', 'insufficient_stock' => 'There is not enough stock available.', 'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.', 'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.', diff --git a/lang/es/api.php b/lang/es/api.php index f6ecbe7..52f6313 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -37,7 +37,6 @@ return [ 'source_required' => 'Se requiere un carrito o un producto directo.', 'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.', 'not_editable' => 'La compra ya no se puede modificar.', - 'item_not_editable' => 'El producto de la compra ya no se puede modificar.', 'insufficient_stock' => 'No hay suficiente stock disponible.', 'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.', 'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.', diff --git a/postman/generate-shopit-collection.php b/postman/generate-shopit-collection.php index bee6476..043cc47 100644 --- a/postman/generate-shopit-collection.php +++ b/postman/generate-shopit-collection.php @@ -8,7 +8,6 @@ declare(strict_types=1); * Run from the backend root with: * php postman/generate-shopit-collection.php */ - $root = dirname(__DIR__); chdir($root); @@ -38,7 +37,7 @@ function jsonBody(array $payload): array } /** @param array $fields - * @return array + * @return array */ function formDataBody(array $fields, array $fileFields = []): array { @@ -85,7 +84,7 @@ function bodyFor(string $method, string $uri): ?array 'POST api/tenants/{tenant:codigo}/catalog-items/{catalogItem}/variant-options' => ['selected_values' => ['color' => 'azul'], 'cart_item_id' => '{{cart_item_id}}'], '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}/compras/{compra}/items/{item}' => ['quantity' => 2], + 'PATCH api/tenants/{tenant:codigo}/checkout-carts/{cart}/items/{cartItem}' => ['cantidad' => 2], '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}}'], @@ -245,10 +244,11 @@ function requestName(string $method, string $action, bool $multiMethod): string $verbs = [ 'index' => 'List', 'store' => 'Create', 'show' => 'Get', 'update' => 'Update', 'destroy' => 'Delete', 'addItem' => 'Add Item', 'updateItemQuantity' => 'Update Item Quantity', + 'updateCheckoutItem' => 'Update Checkout Cart Item', 'removeItem' => 'Remove Item', 'search' => 'Search', 'category' => 'Get Category', 'featuredGroupItems' => 'List Featured Group Items', 'variantOptions' => 'Get Variant Options', 'startCheckout' => 'Start Checkout', 'updateCustomerData' => 'Update Customer Data', - 'prepareItemEditing' => 'Prepare Item Editing', 'paymentIntent' => 'Create Payment Intent', + 'paymentIntent' => 'Create Payment Intent', 'submitForReview' => 'Submit for Review', 'cancel' => 'Cancel', 'complete' => 'Complete', 'downloadPdf' => 'Download PDF', 'downloadModificationsPdf' => 'Download Modifications PDF', 'modifications' => 'List Modifications', 'tickets' => 'List Tickets', 'confirm' => 'Confirm', diff --git a/routes/console.php b/routes/console.php index 0c301ca..ffb9927 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,6 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); -Artisan::command('purchases:expire', function (): void { - $expiredCount = app(CheckoutService::class) - ->expireOverduePurchases(); +Artisan::command('reservations:expire', function (): void { + $expired = app(ExpireStockReservationsService::class)->expireOverdue(); - $this->info("Expired purchases: {$expiredCount}"); -})->purpose('Release stock reservations from expired purchases'); + $this->info("Expired purchases: {$expired['purchases']}"); + $this->info("Expired cart items: {$expired['cart_items']}"); +})->purpose('Release expired stock reservations from purchases and abandoned carts'); -Schedule::command('purchases:expire') +Schedule::command('reservations:expire') ->everyMinute() ->withoutOverlapping(); diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 54398d2..d69c1b7 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -73,6 +73,87 @@ class CartControllerTest extends TestCase 'id' => $item->inventory_id, 'reserved_stock' => 2, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $item->inventory_id, + 'quantity' => 2, + 'status' => 'active', + ]); + } + + public function test_it_sets_the_configured_expiration_when_creating_a_stock_reservation(): void + { + config()->set('catalog.stock_reservation_expiration_minutes', 45); + $now = now()->startOfSecond(); + $this->travelTo($now); + + $tenant = $this->createTenant('acme'); + $item = $this->createDirectItem($tenant, 10, '49.90'); + + $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ])->assertOk(); + + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $item->inventory_id, + 'quantity' => 2, + 'status' => 'active', + 'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(), + ]); + + $this->travelBack(); + } + + public function test_it_expires_abandoned_cart_reservations_and_removes_empty_carts(): void + { + config()->set('catalog.stock_reservation_expiration_minutes', 30); + $tenant = $this->createTenant('acme'); + $item = $this->createDirectItem($tenant, 10, '49.90'); + + $response = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ])->assertOk(); + $cartId = $response->json('data.id'); + $cartItemId = $response->json('data.items.0.id'); + + $this->artisan('reservations:expire') + ->expectsOutput('Expired purchases: 0') + ->expectsOutput('Expired cart items: 0') + ->assertSuccessful(); + + $this->travel(31)->minutes(); + + $this->artisan('reservations:expire') + ->expectsOutput('Expired purchases: 0') + ->expectsOutput('Expired cart items: 1') + ->assertSuccessful(); + + $this->assertDatabaseHas('inventories', [ + 'id' => $item->inventory_id, + 'real_stock' => 10, + 'reserved_stock' => 0, + ]); + $this->assertDatabaseMissing('carrito_items', ['id' => $cartItemId]); + $this->assertSoftDeleted('carritos', [ + 'id' => $cartId, + 'status' => 'expired', + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $item->inventory_id, + 'cart_item_id' => null, + 'purchase_id' => null, + 'quantity' => 0, + 'status' => 'expired', + 'expires_at' => null, + ]); + + $this->artisan('reservations:expire') + ->expectsOutput('Expired purchases: 0') + ->expectsOutput('Expired cart items: 0') + ->assertSuccessful(); + + $this->travelBack(); } public function test_it_filters_item_images_when_the_tenant_disables_them(): void @@ -129,6 +210,12 @@ class CartControllerTest extends TestCase 'id' => $variant->inventory_id, 'reserved_stock' => 5, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'cart_item_id' => $response->json('data.items.0.id'), + 'inventory_id' => $variant->inventory_id, + 'quantity' => 5, + 'status' => 'active', + ]); } public function test_authenticated_cart_respects_previous_purchases_and_repeated_additions(): void @@ -283,6 +370,12 @@ class CartControllerTest extends TestCase 'id' => $variant->inventory_id, 'reserved_stock' => 0, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'cart_item_id' => null, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 0, + 'status' => 'released', + ]); } public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void @@ -506,7 +599,6 @@ class CartControllerTest extends TestCase 'cantidad' => $quantity, 'precio_unitario' => $item->precio, 'total' => (float) $item->precio * $quantity, - 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, ]); } diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index b0f3f80..c17fdf5 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -193,12 +193,14 @@ class TelepagosWebhookTest extends TestCase 'sold_units' => 1, ]); - $this->assertDatabaseHas('compra_items', [ + $this->assertDatabaseMissing('compra_items', [ 'compra_id' => $newerPurchase->id, - 'source_catalog_item_id' => $variant->catalog_item_id, - 'source_variant_id' => $variant->id, - 'cantidad' => 2, - 'reservation_status' => 'active', + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => $newerPurchase->id, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 2, + 'status' => 'active', ]); $this->assertSoftDeleted('carritos', [ diff --git a/tests/Feature/Purchase/PurchaseCatalogItemTest.php b/tests/Feature/Purchase/PurchaseCatalogItemTest.php index 79627bd..8d1ac8e 100644 --- a/tests/Feature/Purchase/PurchaseCatalogItemTest.php +++ b/tests/Feature/Purchase/PurchaseCatalogItemTest.php @@ -71,6 +71,7 @@ class PurchaseCatalogItemTest extends TestCase $this->assertFalse(Schema::hasColumn('compra_items', 'variant_id')); $this->assertFalse(Schema::hasColumn('compra_items', 'buyable_type')); $this->assertFalse(Schema::hasColumn('compra_items', 'buyable_id')); + $this->assertFalse(Schema::hasColumn('compra_items', 'reservation_status')); $this->assertDatabaseHas('compra_items', [ 'compra_id' => $purchase->id, 'source_catalog_item_id' => $catalogItem->id, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 601567a..af4c3ad 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase Queue::fake(); } - public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void + public function test_it_starts_checkout_from_cart_without_materializing_purchase_items(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $tenant->update([ @@ -90,7 +90,7 @@ class StorePurchaseTest extends TestCase $response->assertJsonPath('data.email', null); $response->assertJsonPath('data.tenant_codigo', 'sonder'); $response->assertJsonPath('data.status', Purchase::STATUS_CREATED); - $response->assertJsonPath('data.items_source', 'purchase'); + $response->assertJsonPath('data.items_source', 'cart'); $response->assertJsonCount(1, 'data.items'); $response->assertJsonPath('data.subtotal', '100.00'); $response->assertJsonPath('data.total', '100.00'); @@ -109,12 +109,12 @@ class StorePurchaseTest extends TestCase 'status' => Purchase::STATUS_CREATED, 'total' => 100, ]); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $purchaseId, - 'source_catalog_item_id' => $catalogItem->id, - 'source_variant_id' => $variant->id, - 'cantidad' => 2, - 'reservation_status' => 'active', + $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $inventory->id, + 'purchase_id' => $purchaseId, + 'quantity' => 2, + 'status' => 'active', ]); $this->assertDatabaseHas('carritos', [ 'id' => $cartId, @@ -133,9 +133,22 @@ class StorePurchaseTest extends TestCase 'real_stock' => 10, 'reserved_stock' => 2, ]); + + $catalogItem->update([ + 'nombre' => 'Updated Product', + 'precio' => '75.00', + ]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/sonder/compras/{$purchaseId}") + ->assertOk() + ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items.0.item_details.nombre', 'Updated Product') + ->assertJsonPath('data.items.0.unit_price', '75.00') + ->assertJsonPath('data.total', '150.00'); } - public function test_it_creates_a_direct_purchase_without_creating_or_changing_a_cart(): void + public function test_it_creates_a_direct_purchase_with_a_technical_checkout_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -152,18 +165,23 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.cart_id', null) - ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items_source', 'cart') ->assertJsonPath('data.items.0.quantity', 3) ->assertJsonPath('data.total', '150.00'); - $this->assertDatabaseCount('carritos', 0); - $this->assertDatabaseHas('compra_items', [ + $this->assertDatabaseHas('carritos', [ + 'id' => $response->json('data.cart_id'), + 'status' => 'checkout', + 'origin' => Cart::ORIGIN_DIRECT_CHECKOUT, + ]); + $this->assertDatabaseMissing('compra_items', [ 'compra_id' => $response->json('data.id'), - 'source_catalog_item_id' => $variant->catalog_item_id, - 'source_variant_id' => $variant->id, - 'cantidad' => 3, - 'reservation_status' => 'active', + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $variant->inventory_id, + 'purchase_id' => $response->json('data.id'), + 'quantity' => 3, + 'status' => 'active', ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -176,9 +194,9 @@ class StorePurchaseTest extends TestCase ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $response->json('data.id'), - 'reservation_status' => 'released', + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => $response->json('data.id'), + 'status' => 'released', ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -215,7 +233,7 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.cart_id', null) + ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(2, 'data.items') ->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id) ->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id) @@ -223,18 +241,22 @@ class StorePurchaseTest extends TestCase $purchaseId = $response->json('data.id'); - $this->assertDatabaseCount('carritos', 0); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $purchaseId, - 'source_variant_id' => $firstVariant->id, - 'cantidad' => 1, - 'reservation_status' => 'active', + $this->assertDatabaseHas('carritos', [ + 'id' => $response->json('data.cart_id'), + 'origin' => Cart::ORIGIN_DIRECT_CHECKOUT, ]); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $purchaseId, - 'source_variant_id' => $secondVariant->id, - 'cantidad' => 1, - 'reservation_status' => 'active', + $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $firstVariant->inventory_id, + 'purchase_id' => $purchaseId, + 'quantity' => 1, + 'status' => 'active', + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $secondVariant->inventory_id, + 'purchase_id' => $purchaseId, + 'quantity' => 1, + 'status' => 'active', ]); $this->assertDatabaseHas('inventories', [ 'id' => $firstVariant->inventory_id, @@ -462,7 +484,7 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_creates_purchase_items_before_checkout_and_updates_customer_data(): void + public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -479,7 +501,7 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.dni', null) @@ -537,23 +559,21 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_updates_a_created_purchase_item_quantity_and_its_stock_reservation(): void + public function test_it_updates_a_checkout_cart_item_quantity_and_its_stock_reservation(): 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); - $itemId = $purchase->items->firstOrFail()->id; + $itemId = $purchase->cart->items->firstOrFail()->id; $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ - 'quantity' => 4, + ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ + 'cantidad' => 4, ]) ->assertOk() - ->assertJsonPath('data.items.0.quantity', 4) - ->assertJsonPath('data.items.0.line_total', '200.00') - ->assertJsonPath('data.subtotal', '200.00') - ->assertJsonPath('data.total', '200.00'); + ->assertJsonPath('data.items.0.cantidad', 4) + ->assertJsonPath('data.subtotal', '200.00'); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -567,12 +587,17 @@ class StorePurchaseTest extends TestCase ]); $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ - 'quantity' => 1, + ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ + 'cantidad' => 1, ]) ->assertOk() - ->assertJsonPath('data.items.0.quantity', 1) - ->assertJsonPath('data.total', '50.00'); + ->assertJsonPath('data.items.0.cantidad', 1) + ->assertJsonPath('data.subtotal', '50.00'); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'total' => '50.00', + ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -587,16 +612,16 @@ class StorePurchaseTest extends TestCase $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $variant->catalogItem->update(['max_units_per_user' => 3]); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $itemId = $purchase->items->firstOrFail()->id; + $itemId = $purchase->cart->items->firstOrFail()->id; $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ - 'quantity' => 4, + ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ + 'cantidad' => 4, ]) ->assertUnprocessable() - ->assertJsonValidationErrors('quantity'); + ->assertJsonValidationErrors('cantidad'); - $this->assertDatabaseHas('compra_items', [ + $this->assertDatabaseHas('carrito_items', [ 'id' => $itemId, 'cantidad' => 2, ]); @@ -606,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(); @@ -620,17 +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/compras/{$purchase->id}/edit-items") + ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ + 'cantidad' => 3, + ]) ->assertOk() - ->assertJsonPath('data.status', Purchase::STATUS_CREATED) - ->assertJsonPath('data.payment_method', null); + ->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, @@ -638,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'); @@ -789,17 +850,20 @@ class StorePurchaseTest extends TestCase $this->travel(31)->minutes(); - $this->artisan('purchases:expire') + $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 1') + ->expectsOutput('Expired cart items: 0') ->assertSuccessful(); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, 'status' => Purchase::STATUS_EXPIRED, ]); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $purchase->id, - 'reservation_status' => 'released', + $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchase->id]); + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => null, + 'quantity' => 3, + 'status' => 'active', ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -814,12 +878,13 @@ class StorePurchaseTest extends TestCase 'deleted_at' => null, ]); - $this->artisan('purchases:expire') + $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 0') + ->expectsOutput('Expired cart items: 0') ->assertSuccessful(); } - public function test_purchase_detail_uses_purchase_items_for_created_purchase(): void + public function test_purchase_detail_uses_cart_items_for_created_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -832,7 +897,7 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_CREATED) - ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') @@ -863,7 +928,7 @@ class StorePurchaseTest extends TestCase 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); - $purchase->items()->firstOrFail()->update(['image_attachment_id' => $image->id]); + $variant->catalogItem->attachments()->attach($image->id, ['orden' => 0]); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}") @@ -871,7 +936,7 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.items.0.item_details.imagen', null); } - public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void + public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -890,7 +955,7 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT) - ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') @@ -925,6 +990,12 @@ class StorePurchaseTest extends TestCase 'reserved_stock' => 0, 'sold_units' => 2, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => $purchase->id, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 2, + 'status' => 'committed', + ]); $this->assertSoftDeleted('carritos', [ 'id' => $purchase->cart_id, @@ -956,7 +1027,9 @@ class StorePurchaseTest extends TestCase $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $purchase->update(['cart_id' => null]); + $purchase->update(['payment_method' => 'transfer']); + app(CheckoutService::class)->confirmPurchase($purchase); + $purchase->refresh()->update(['cart_id' => null]); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}") diff --git a/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php b/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php new file mode 100644 index 0000000..5cc11d2 --- /dev/null +++ b/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php @@ -0,0 +1,33 @@ +shouldReceive('expireOverduePurchases') + ->once() + ->ordered() + ->andReturn(2); + + $carts = \Mockery::mock(ExpireCartReservationsService::class); + $carts->shouldReceive('expireOverdue') + ->once() + ->ordered() + ->andReturn(3); + + $result = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue(); + + $this->assertSame([ + 'purchases' => 2, + 'cart_items' => 3, + ], $result); + } +}