From c51e24311f071cc87ccb0398b4ce2dfd63ce9771 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 26 Aug 2026 10:30:16 -0300 Subject: [PATCH] Squashed commit of the following: commit cd99a6489be225c522596c09c4426e63df7373b9 Merge: ebcbbb1 e63a036 Author: ncoronel Date: Wed Aug 26 10:12:44 2026 -0300 Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations commit ebcbbb17743a6aedecddab45ed63b7a0a981ab99 Author: ncoronel Date: Tue Aug 25 16:33:39 2026 -0300 fix(cart): reconcile expired cart mutations commit 3dd378ed0667448f08c1055d3e03853cc4cb51e1 Author: ncoronel Date: Tue Aug 25 16:10:15 2026 -0300 docs(stock): describe associated entity expiration commit 4f705175bddd0f8d733969d17b1dfe1e9a93ef01 Author: ncoronel Date: Tue Aug 25 16:10:14 2026 -0300 test(stock): cover propagated reservation expiration commit 82570fe94101e8547fad96e130222a03c60ce33f Author: ncoronel Date: Tue Aug 25 16:09:52 2026 -0300 refactor(stock): propagate reservation expiration commit d2a2b323e09a188b7519d7441d09725459ec6936 Author: ncoronel Date: Tue Aug 25 15:58:47 2026 -0300 docs(stock): document terminal reservation recovery commit ec67623b7b9035d8f4e2d6365d75eee4f3673466 Author: ncoronel Date: Tue Aug 25 15:58:47 2026 -0300 test(stock): cover terminal reservation lifecycle commit f6f138e180f82d883dac6cd4d8a9bd9ba15add49 Author: ncoronel Date: Tue Aug 25 15:58:41 2026 -0300 refactor(stock): make expired reservations terminal commit d612b7a118b6f24f3ee78f9dd9f12eb4ca5ce148 Author: ncoronel Date: Tue Aug 25 15:28:30 2026 -0300 docs(stock): describe unified expiration ownership commit 49f42d450747e51d08cfd1cb3ba65953e72a2d21 Author: ncoronel Date: Tue Aug 25 15:28:23 2026 -0300 test(stock): cover authoritative reservation expiration commit 1881cc1d4b5f8a3874c5e7a6bf04f35ccc589085 Author: ncoronel Date: Tue Aug 25 15:28:17 2026 -0300 refactor(stock): make reservation expiration authoritative commit 00ec37d8a05143b921435c7eb0c93f656f8f2691 Author: ncoronel Date: Tue Aug 25 15:05:34 2026 -0300 docs(stock): document centralized reservation flow commit d9f374c718bafaf23e24fe60de66d31056ad59e2 Author: ncoronel Date: Tue Aug 25 15:05:29 2026 -0300 test(stock): cover aggregate reservation lifecycle commit bbfdf8f3429b7811dc250a8c3e2b7b21a961c0ea Author: ncoronel Date: Tue Aug 25 15:05:23 2026 -0300 refactor(stock): centralize reservation aggregate commit 8a0f29bdaea0288ac74676b7252209f39add39e2 Author: ncoronel Date: Tue Aug 25 16:33:39 2026 -0300 fix(cart): reconcile expired cart mutations commit 198ed400a9c227923b1043e1ac8a388045cb0d09 Author: ncoronel Date: Tue Aug 25 16:10:15 2026 -0300 docs(stock): describe associated entity expiration commit 23d83166e61c37ebcad109864190b2e983480fa7 Author: ncoronel Date: Tue Aug 25 16:10:14 2026 -0300 test(stock): cover propagated reservation expiration commit cb090402d0764c6aff1cfed49854b0d23e55627f Author: ncoronel Date: Tue Aug 25 16:09:52 2026 -0300 refactor(stock): propagate reservation expiration commit c120b6f7c0f037b959dceb220f1b20ab97a0b36a Author: ncoronel Date: Tue Aug 25 15:58:47 2026 -0300 docs(stock): document terminal reservation recovery commit 1106fa28b4ce015666935b43bfa52d02a05beea9 Author: ncoronel Date: Tue Aug 25 15:58:47 2026 -0300 test(stock): cover terminal reservation lifecycle commit 896fb81bcfaa6b6a06fc6c80b80ea3816988b83c Author: ncoronel Date: Tue Aug 25 15:58:41 2026 -0300 refactor(stock): make expired reservations terminal commit 8545fbc645062c7d3b61c871e71e73956158f013 Author: ncoronel Date: Tue Aug 25 15:28:30 2026 -0300 docs(stock): describe unified expiration ownership commit dd6dcb708ab099cd97517429914bb39a4c12147e Author: ncoronel Date: Tue Aug 25 15:28:23 2026 -0300 test(stock): cover authoritative reservation expiration commit 624b246ff5a98d019ebffb5ab2a27afbfacf6cf9 Author: ncoronel Date: Tue Aug 25 15:28:17 2026 -0300 refactor(stock): make reservation expiration authoritative commit 007b95b9046f5d5a9154ac8fb90af3d79b3def9d Author: ncoronel Date: Tue Aug 25 15:05:34 2026 -0300 docs(stock): document centralized reservation flow commit 0b3c8c93be9b69bf9b153f9a0c705886f8c1529e Author: ncoronel Date: Tue Aug 25 15:05:29 2026 -0300 test(stock): cover aggregate reservation lifecycle commit ede718e44808353bb2a49e1c9318ba7abd56af2d Author: ncoronel Date: Tue Aug 25 15:05:23 2026 -0300 refactor(stock): centralize reservation aggregate commit e63a03601a3e46df0b54c642b3a7f82483d2d479 Author: ncoronel Date: Tue Aug 25 16:33:39 2026 -0300 fix(cart): reconcile expired cart mutations commit 389373ad8cad749f0cf15650f74e7f4863119f75 Author: ncoronel Date: Tue Aug 25 16:10:15 2026 -0300 docs(stock): describe associated entity expiration commit ace02a31335315d71626769a2263b74819c3f883 Author: ncoronel Date: Tue Aug 25 16:10:14 2026 -0300 test(stock): cover propagated reservation expiration commit d69a6210f68871dc1a2258a93fc5d5262cab0563 Author: ncoronel Date: Tue Aug 25 16:09:52 2026 -0300 refactor(stock): propagate reservation expiration commit 3c0b43fea38c788706d525274cf60891927796a4 Author: ncoronel Date: Tue Aug 25 15:58:47 2026 -0300 docs(stock): document terminal reservation recovery commit 9b0008626bc5aadcd8e054e12728a702e7a80d7d Author: ncoronel Date: Tue Aug 25 15:58:47 2026 -0300 test(stock): cover terminal reservation lifecycle commit 61861e331af9160390061b1df2405b57aab44200 Author: ncoronel Date: Tue Aug 25 15:58:41 2026 -0300 refactor(stock): make expired reservations terminal commit 2cc9d7dd97fc6c2eb99af5cef157adf03f4373d4 Author: ncoronel Date: Tue Aug 25 15:28:30 2026 -0300 docs(stock): describe unified expiration ownership commit f982bcead10b1cdecf3635220a7b23776621959d Author: ncoronel Date: Tue Aug 25 15:28:23 2026 -0300 test(stock): cover authoritative reservation expiration commit 7a85e1731defb6acdbb4e0c36cd6df4db20a4766 Author: ncoronel Date: Tue Aug 25 15:28:17 2026 -0300 refactor(stock): make reservation expiration authoritative commit 6db99e775a84380d49d974a13ee3df3215d69ec4 Author: ncoronel Date: Tue Aug 25 15:05:34 2026 -0300 docs(stock): document centralized reservation flow commit e5f7ba36155a24c87fcdef7bcf3235f3fec80cb2 Author: ncoronel Date: Tue Aug 25 15:05:29 2026 -0300 test(stock): cover aggregate reservation lifecycle commit 24bfef431baa8d257ac86140c29fcf383f01347c Author: ncoronel Date: Tue Aug 25 15:05:23 2026 -0300 refactor(stock): centralize reservation aggregate --- app/Domains/Cart/Models/Cart.php | 65 +- app/Domains/Cart/Models/CartItem.php | 8 - app/Domains/Cart/Services/CartService.php | 78 ++- .../ExpireCartReservationsService.php | 123 ---- app/Domains/Cart/documentacion/README.md | 7 +- .../StockReservationExpiredException.php | 13 + app/Domains/Catalog/Models/Inventory.php | 6 +- .../Catalog/Models/StockReservation.php | 34 +- .../Catalog/Models/StockReservationLine.php | 38 ++ .../Services/CatalogInventoryService.php | 18 + .../ExpireStockReservationsService.php | 190 +++++- .../Services/StockReservationService.php | 626 ++++++++++++------ app/Domains/Catalog/documentacion/README.md | 5 +- .../InvitationPurchaseProvisioner.php | 28 +- .../Controllers/PurchaseController.php | 19 +- app/Domains/Purchase/Models/Purchase.php | 10 +- .../Purchase/Resources/PurchaseResource.php | 2 +- .../Checkout/CompleteCheckoutService.php | 25 +- .../Checkout/PurchaseResponseLoader.php | 2 +- .../Checkout/ReleaseCheckoutService.php | 169 ++--- .../Checkout/StartCheckoutService.php | 69 +- .../Purchase/Services/CheckoutService.php | 10 +- .../Purchase/Services/PurchaseStateGuard.php | 30 +- .../TenantTransactionResetService.php | 24 +- .../Services/UserPurchaseLimitService.php | 8 +- app/Domains/Purchase/documentacion/README.md | 10 +- bootstrap/app.php | 11 + ...5_020000_centralize_stock_reservations.php | 180 +++++ ...k_reservation_expiration_authoritative.php | 51 ++ ...0_propagate_expired_stock_reservations.php | 30 + lang/en/api.php | 1 + lang/es/api.php | 1 + routes/console.php | 4 +- tests/Feature/Cart/CartControllerTest.php | 249 ++++++- .../Integration/TelepagosWebhookTest.php | 20 +- tests/Feature/Purchase/StorePurchaseTest.php | 283 +++++--- .../Sale/AdminAppSaleControllerTest.php | 4 +- .../DesfilePuraTendenciaSeederTest.php | 7 +- .../ExpireStockReservationsServiceTest.php | 133 ++-- .../Unit/Purchase/PurchaseStateGuardTest.php | 12 +- 40 files changed, 1839 insertions(+), 764 deletions(-) delete mode 100644 app/Domains/Cart/Services/ExpireCartReservationsService.php create mode 100644 app/Domains/Catalog/Exceptions/StockReservationExpiredException.php create mode 100644 app/Domains/Catalog/Models/StockReservationLine.php create mode 100644 database/migrations/2026_08_25_020000_centralize_stock_reservations.php create mode 100644 database/migrations/2026_08_25_160000_make_stock_reservation_expiration_authoritative.php create mode 100644 database/migrations/2026_08_25_170000_propagate_expired_stock_reservations.php diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 705036f..9c6ea42 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Catalog\Services\StockReservationService; @@ -28,6 +29,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 'status', 'origin', 'current_purchase_id', + 'current_stock_reservation_id', ])] class Cart extends Model { @@ -36,6 +38,16 @@ class Cart extends Model protected $table = 'carritos'; + public const STATUS_ACTIVE = 'active'; + + public const STATUS_CHECKOUT = 'checkout'; + + public const STATUS_CONVERTED = 'converted'; + + public const STATUS_EXPIRED = 'expired'; + + public const STATUS_ABANDONED = 'abandoned'; + public const ORIGIN_USER = 'user'; public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout'; @@ -45,6 +57,7 @@ class Cart extends Model return [ 'user_id' => 'integer', 'current_purchase_id' => 'integer', + 'current_stock_reservation_id' => 'integer', ]; } @@ -84,6 +97,15 @@ class Cart extends Model return $this->belongsTo(Purchase::class, 'current_purchase_id'); } + /** @return BelongsTo */ + public function currentStockReservation(): BelongsTo + { + return $this->belongsTo( + StockReservation::class, + 'current_stock_reservation_id', + ); + } + public function getTotalAmount(): float { $items = $this->relationLoaded('items') @@ -107,6 +129,7 @@ class Cart extends Model return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem { $this->invalidateCurrentCheckout(); + app(StockReservationService::class)->assertCartReservationUsable($this); $selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true); $cartQuantity = (int) $this->items() ->where('catalog_item_id', $catalogItemId) @@ -140,12 +163,11 @@ class Cart extends Model 'cantidad' => $quantity, ]); } else { - app(StockReservationService::class)->ensure($item, $selectedItem); $item->cantidad += $quantity; $item->save(); } - app(StockReservationService::class)->reserve($item, $selectedItem, $quantity); + app(StockReservationService::class)->syncCart($this); return $item->fresh(); }); @@ -172,6 +194,7 @@ class Cart extends Model $excludedPurchaseId, ): CartItem { $this->invalidateCurrentCheckout(); + app(StockReservationService::class)->assertCartReservationUsable($this); /** @var CartItem $item */ $item = $this->items() @@ -205,7 +228,6 @@ class Cart extends Model $nextAvailableQuantity, ); - app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad); $availableQuantity = $inventoryService->availableQuantity($nextSelection); if ($availableQuantity !== null && $availableQuantity < $quantity) { @@ -222,11 +244,10 @@ class Cart extends Model ->first(); if ($targetItem !== null) { - app(StockReservationService::class)->ensure($targetItem, $nextSelection); $targetItem->cantidad += $quantity; $targetItem->save(); - app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity); $item->delete(); + app(StockReservationService::class)->syncCart($this); return $targetItem->fresh(); } @@ -234,7 +255,7 @@ class Cart extends Model $item->variant_id = $variantId; $item->cantidad = $quantity; $item->save(); - app(StockReservationService::class)->reserve($item, $nextSelection, $quantity); + app(StockReservationService::class)->syncCart($this); return $item->fresh(); } @@ -263,16 +284,9 @@ class Cart extends Model ]); } - if ($delta > 0) { - app(StockReservationService::class)->reserve($item, $currentSelection, $delta); - } - - if ($delta < 0) { - app(StockReservationService::class)->release($item, $currentSelection, abs($delta)); - } - $item->cantidad = $quantity; $item->save(); + app(StockReservationService::class)->syncCart($this); return $item->fresh(); }); @@ -282,6 +296,7 @@ class Cart extends Model { DB::transaction(function () use ($cartItemId): void { $this->invalidateCurrentCheckout(); + app(StockReservationService::class)->assertCartReservationUsable($this); /** @var CartItem $item */ $item = $this->items() @@ -289,17 +304,8 @@ class Cart extends Model ->lockForUpdate() ->firstOrFail(); - $selectedItem = $this->resolveScopedItem( - $item->catalog_item_id, - $item->variant_id, - true, - ); - app(StockReservationService::class)->release( - $item, - $selectedItem, - $item->cantidad, - ); $item->delete(); + app(StockReservationService::class)->syncCart($this); }); } @@ -341,13 +347,20 @@ class Cart extends Model Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true)) { + app(StockReservationService::class)->returnToCart($currentPurchase, $cart); $currentPurchase->update([ 'status' => Purchase::STATUS_SUPERSEDED, - 'expires_at' => null, ]); + + $this->current_purchase_id = null; + + return; } - app(StockReservationService::class)->detachFromPurchase($currentPurchase); + app(StockReservationService::class)->releaseForPurchase( + $currentPurchase, + reason: StockReservationService::REASON_PURCHASE_SUPERSEDED, + ); self::query() ->whereKey($cart->getKey()) ->where('current_purchase_id', $currentPurchase->getKey()) diff --git a/app/Domains/Cart/Models/CartItem.php b/app/Domains/Cart/Models/CartItem.php index 8ece5b4..3841533 100644 --- a/app/Domains/Cart/Models/CartItem.php +++ b/app/Domains/Cart/Models/CartItem.php @@ -3,13 +3,11 @@ 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', @@ -57,10 +55,4 @@ 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 7974306..9c5f873 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -4,9 +4,12 @@ namespace App\Domains\Cart\Services; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Exceptions\StockReservationExpiredException; +use App\Domains\Catalog\Services\ExpireStockReservationsService; 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; @@ -22,7 +25,7 @@ class CartService return $this->makeEmptyCart($tenant); } - $cart = $this->findCart($tenant, $resolvedIdentity['identity']); + $cart = $this->resolveCart($tenant, $resolvedIdentity['identity']); if ($cart === null) { return $this->makeEmptyCart($tenant); @@ -119,7 +122,7 @@ class CartService { $cart = new Cart([ 'tenant_codigo' => $tenant->codigo, - 'status' => 'active', + 'status' => Cart::STATUS_ACTIVE, ]); $cart->setRelation('items', collect()); @@ -220,12 +223,14 @@ class CartService { return Cart::query() ->where('tenant_codigo', $tenant->codigo) - ->where('status', 'active') + ->where('origin', Cart::ORIGIN_USER) + ->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED]) ->when( $identity['user_id'] !== null, fn ($query) => $query->where('user_id', $identity['user_id']), fn ($query) => $query->where('guest_token', $identity['guest_token']), ) + ->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE]) ->first(); } @@ -234,7 +239,7 @@ class CartService */ protected function findCartOrFail(Tenant $tenant, array $identity): Cart { - $cart = $this->findCart($tenant, $identity); + $cart = $this->resolveCart($tenant, $identity, replaceExpired: false); if ($cart === null) { throw new NotFoundHttpException('Cart not found.'); @@ -247,10 +252,73 @@ class CartService * @param array{user_id: ?int, guest_token: ?string} $identity */ protected function findOrCreateCart(Tenant $tenant, array $identity): Cart + { + return $this->resolveCart($tenant, $identity) + ?? $this->createCart($tenant, $identity); + } + + /** + * @param array{user_id: ?int, guest_token: ?string} $identity + */ + protected function resolveCart( + Tenant $tenant, + array $identity, + bool $replaceExpired = true, + ): ?Cart { + $cart = $this->findCart($tenant, $identity); + + if ($cart?->status === Cart::STATUS_ACTIVE + && $cart->current_stock_reservation_id !== null + && app(ExpireStockReservationsService::class) + ->expireIfOverdue($cart->current_stock_reservation_id)) { + $cart = $this->findCart($tenant, $identity); + } + + if ($cart?->status === Cart::STATUS_EXPIRED) { + if (! $replaceExpired) { + throw new StockReservationExpiredException; + } + + return $this->replaceExpiredCart($cart, $tenant, $identity); + } + + if ($cart !== null) { + return $cart; + } + + return null; + } + + /** + * @param array{user_id: ?int, guest_token: ?string} $identity + */ + protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart + { + return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart { + /** @var Cart|null $lockedCart */ + $lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey()); + + if ($lockedCart?->status === Cart::STATUS_EXPIRED) { + $lockedCart->update([ + 'status' => Cart::STATUS_ABANDONED, + 'current_purchase_id' => null, + ]); + } + + return $this->findCart($tenant, $identity) + ?? $this->createCart($tenant, $identity); + }); + } + + /** + * @param array{user_id: ?int, guest_token: ?string} $identity + */ + protected function createCart(Tenant $tenant, array $identity): Cart { $attributes = [ 'tenant_codigo' => $tenant->codigo, - 'status' => 'active', + 'status' => Cart::STATUS_ACTIVE, + 'origin' => Cart::ORIGIN_USER, ]; if ($identity['user_id'] !== null) { diff --git a/app/Domains/Cart/Services/ExpireCartReservationsService.php b/app/Domains/Cart/Services/ExpireCartReservationsService.php deleted file mode 100644 index d879994..0000000 --- a/app/Domains/Cart/Services/ExpireCartReservationsService.php +++ /dev/null @@ -1,123 +0,0 @@ -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/documentacion/README.md b/app/Domains/Cart/documentacion/README.md index e9c8e3b..3cde77a 100644 --- a/app/Domains/Cart/documentacion/README.md +++ b/app/Domains/Cart/documentacion/README.md @@ -6,13 +6,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. +- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`. - `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 @@ -34,4 +33,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y 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. Cada intento registra sus resultados o su error en el log diario `storage/logs/commands/commands-AAAA-MM-DD.log`. +Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas. + +El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido. diff --git a/app/Domains/Catalog/Exceptions/StockReservationExpiredException.php b/app/Domains/Catalog/Exceptions/StockReservationExpiredException.php new file mode 100644 index 0000000..37acd7a --- /dev/null +++ b/app/Domains/Catalog/Exceptions/StockReservationExpiredException.php @@ -0,0 +1,13 @@ +hasOne(Variant::class); } - /** @return HasMany */ - public function stockReservations(): HasMany + /** @return HasMany */ + public function stockReservationLines(): HasMany { - return $this->hasMany(StockReservation::class); + return $this->hasMany(StockReservationLine::class); } public function availableStock(): int diff --git a/app/Domains/Catalog/Models/StockReservation.php b/app/Domains/Catalog/Models/StockReservation.php index 3e4dad3..8519895 100644 --- a/app/Domains/Catalog/Models/StockReservation.php +++ b/app/Domains/Catalog/Models/StockReservation.php @@ -2,21 +2,20 @@ namespace App\Domains\Catalog\Models; -use App\Domains\Cart\Models\CartItem; +use App\Domains\Cart\Models\Cart; use App\Domains\Purchase\Models\Purchase; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable([ - 'inventory_id', - 'cart_item_id', - 'purchase_id', - 'quantity', 'status', 'expires_at', 'committed_at', 'released_at', + 'expired_at', + 'release_reason', ])] class StockReservation extends Model { @@ -31,31 +30,28 @@ class StockReservation extends Model protected function casts(): array { return [ - 'inventory_id' => 'integer', - 'cart_item_id' => 'integer', - 'purchase_id' => 'integer', - 'quantity' => 'integer', 'expires_at' => 'datetime', 'committed_at' => 'datetime', 'released_at' => 'datetime', + 'expired_at' => 'datetime', ]; } - /** @return BelongsTo */ - public function inventory(): BelongsTo + /** @return HasMany */ + public function lines(): HasMany { - return $this->belongsTo(Inventory::class); + return $this->hasMany(StockReservationLine::class); } - /** @return BelongsTo */ - public function cartItem(): BelongsTo + /** @return HasOne */ + public function currentCart(): HasOne { - return $this->belongsTo(CartItem::class); + return $this->hasOne(Cart::class, 'current_stock_reservation_id'); } - /** @return BelongsTo */ - public function purchase(): BelongsTo + /** @return HasOne */ + public function purchase(): HasOne { - return $this->belongsTo(Purchase::class); + return $this->hasOne(Purchase::class); } } diff --git a/app/Domains/Catalog/Models/StockReservationLine.php b/app/Domains/Catalog/Models/StockReservationLine.php new file mode 100644 index 0000000..763254b --- /dev/null +++ b/app/Domains/Catalog/Models/StockReservationLine.php @@ -0,0 +1,38 @@ + 'integer', + 'inventory_id' => 'integer', + 'quantity' => 'integer', + 'tracks_inventory' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function reservation(): BelongsTo + { + return $this->belongsTo(StockReservation::class, 'stock_reservation_id'); + } + + /** @return BelongsTo */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } +} diff --git a/app/Domains/Catalog/Services/CatalogInventoryService.php b/app/Domains/Catalog/Services/CatalogInventoryService.php index d384eb8..30e433a 100644 --- a/app/Domains/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Catalog/Services/CatalogInventoryService.php @@ -25,6 +25,24 @@ class CatalogInventoryService ); } + /** + * @return array + */ + public function detailedRequirementsFor(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): array => [ + ...$requirement, + 'quantity' => $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 index ab29513..58bf5a8 100644 --- a/app/Domains/Catalog/Services/ExpireStockReservationsService.php +++ b/app/Domains/Catalog/Services/ExpireStockReservationsService.php @@ -2,50 +2,184 @@ namespace App\Domains\Catalog\Services; -use App\Domains\Cart\Services\ExpireCartReservationsService; -use App\Domains\Purchase\Services\CheckoutService; +use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use RuntimeException; use Throwable; class ExpireStockReservationsService { + private const BATCH_SIZE = 500; + public function __construct( - private readonly CheckoutService $checkout, - private readonly ExpireCartReservationsService $carts, + private readonly ReleaseCheckoutService $purchases, + private readonly StockReservationService $reservations, ) {} /** - * @return array{purchases: int, cart_items: int} + * @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int} */ public function expireOverdue(): array { - $expiredPurchases = null; - $expiredCartItems = null; + $summary = [ + 'purchases' => 0, + 'cart_reservations' => 0, + 'orphan_reservations' => 0, + 'failed' => 0, + ]; + $lastReservationId = 0; - try { - $expiredPurchases = $this->checkout->expireOverduePurchases(); - $expiredCartItems = $this->carts->expireOverdue(); + do { + $reservationIds = StockReservation::query() + ->where('status', StockReservation::STATUS_ACTIVE) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->where('id', '>', $lastReservationId) + ->orderBy('id') + ->limit(self::BATCH_SIZE) + ->pluck('id'); - Log::channel('commands')->info('Stock reservation cleanup completed.', [ - 'command' => 'reservations:expire', - 'expired_purchases' => $expiredPurchases, - 'expired_cart_items' => $expiredCartItems, - 'total_expired' => $expiredPurchases + $expiredCartItems, - ]); + foreach ($reservationIds as $reservationId) { + $lastReservationId = (int) $reservationId; - return [ - 'purchases' => $expiredPurchases, - 'cart_items' => $expiredCartItems, - ]; - } catch (Throwable $exception) { - Log::channel('commands')->error('Stock reservation cleanup failed.', [ - 'command' => 'reservations:expire', - 'expired_purchases' => $expiredPurchases, - 'expired_cart_items' => $expiredCartItems, - 'exception' => $exception, - ]); + try { + $owner = $this->expireReservation($lastReservationId); + if ($owner !== null) { + $summary[$owner]++; + } + } catch (Throwable $exception) { + $summary['failed']++; + Log::channel('commands')->error('Failed to expire overdue stock reservation.', [ + 'command' => 'reservations:expire', + 'stock_reservation_id' => $lastReservationId, + 'exception' => $exception, + ]); + } + } + } while ($reservationIds->count() === self::BATCH_SIZE); - throw $exception; + Log::channel('commands')->info('Stock reservation cleanup completed.', [ + 'command' => 'reservations:expire', + 'expired_purchases' => $summary['purchases'], + 'expired_cart_reservations' => $summary['cart_reservations'], + 'expired_orphan_reservations' => $summary['orphan_reservations'], + 'failed_reservations' => $summary['failed'], + 'total_expired' => $summary['purchases'] + + $summary['cart_reservations'] + + $summary['orphan_reservations'], + ]); + + return $summary; + } + + public function expireIfOverdue(int $reservationId): bool + { + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->find($reservationId); + if ($reservation?->status === StockReservation::STATUS_EXPIRED) { + return true; } + if (! $this->isOverdue($reservation)) { + return false; + } + + return $this->expireReservation($reservationId) !== null; + } + + /** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */ + private function expireReservation(int $reservationId): ?string + { + $purchaseId = Purchase::query() + ->where('stock_reservation_id', $reservationId) + ->value('id'); + if ($purchaseId !== null) { + return $this->expirePurchase((int) $purchaseId); + } + + $cartId = Cart::query() + ->where('current_stock_reservation_id', $reservationId) + ->where('status', 'active') + ->value('id'); + if ($cartId !== null) { + return $this->expireCart((int) $cartId, $reservationId); + } + + return $this->expireOrphan($reservationId); + } + + /** @return 'purchases'|null */ + private function expirePurchase(int $purchaseId): ?string + { + /** @var Purchase|null $purchase */ + $purchase = Purchase::query()->find($purchaseId); + if ($purchase === null) { + return null; + } + + $purchase = $this->purchases->expire($purchase); + if ($purchase->status === Purchase::STATUS_EXPIRED) { + return 'purchases'; + } + + $reservation = $purchase->stockReservation; + if ($this->isOverdue($reservation)) { + throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.'); + } + + return null; + } + + /** @return 'cart_reservations'|null */ + private function expireCart(int $cartId, int $reservationId): ?string + { + return DB::transaction(function () use ($cartId, $reservationId): ?string { + /** @var Cart|null $cart */ + $cart = Cart::query() + ->whereKey($cartId) + ->where('current_stock_reservation_id', $reservationId) + ->where('status', Cart::STATUS_ACTIVE) + ->lockForUpdate() + ->first(); + if ($cart === null) { + return null; + } + + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->find($reservationId); + if (! $this->isOverdue($reservation)) { + return null; + } + + $this->reservations->expire($reservation); + $cart->update(['status' => Cart::STATUS_EXPIRED]); + + return 'cart_reservations'; + }); + } + + /** @return 'orphan_reservations'|null */ + private function expireOrphan(int $reservationId): ?string + { + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->find($reservationId); + if (! $this->isOverdue($reservation)) { + return null; + } + + $this->reservations->expire($reservation); + + return 'orphan_reservations'; + } + + private function isOverdue(?StockReservation $reservation): bool + { + return $reservation !== null + && $reservation->status === StockReservation::STATUS_ACTIVE + && $reservation->expires_at !== null + && ! $reservation->expires_at->isFuture(); } } diff --git a/app/Domains/Catalog/Services/StockReservationService.php b/app/Domains/Catalog/Services/StockReservationService.php index 1b3f9d1..c52f4ea 100644 --- a/app/Domains/Catalog/Services/StockReservationService.php +++ b/app/Domains/Catalog/Services/StockReservationService.php @@ -2,262 +2,486 @@ namespace App\Domains\Catalog\Services; +use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; -use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Exceptions\StockReservationExpiredException; +use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; -use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Purchase\Models\Purchase; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class StockReservationService { + public const REASON_CART_EMPTY = 'cart_empty'; + + public const REASON_CART_CHANGED = 'cart_changed'; + + public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded'; + + public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled'; + + public const REASON_PAYMENT_REJECTED = 'payment_rejected'; + + public const REASON_MANUAL_RELEASE = 'manual_release'; + public function __construct( private readonly CatalogInventoryService $inventory, ) {} - public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void + public function syncCart(Cart $cart): ?StockReservation { - DB::transaction(function () use ($cartItem, $selection, $quantity): void { - $this->inventory->reserve($selection, $quantity); - $this->recordIncrease($cartItem, $selection, $quantity); - }); - } + return DB::transaction(function () use ($cart): ?StockReservation { + /** @var Cart $lockedCart */ + $lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + $items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get(); + $this->loadSelections($items); + $requirements = $this->requirementsForItems($items); - 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); - }); - } + $reservation = $lockedCart->current_stock_reservation_id === null + ? null + : StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id); - public function commit( - CartItem $cartItem, - CatalogItem|Variant $selection, - Purchase $purchase, - ): void { - DB::transaction(function () use ($cartItem, $selection, $purchase): void { - $this->ensure($cartItem, $selection); - $this->inventory->commit($selection, (int) $cartItem->cantidad); + if ($reservation !== null) { + $this->assertUsableCartReservation($reservation); + } - $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->purchase_id !== $purchase->getKey() - || $reservation->quantity !== $quantity - ) { - throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.'); + if ($requirements === []) { + if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) { + $this->finalizeLocked( + $reservation, + StockReservation::STATUS_RELEASED, + self::REASON_CART_EMPTY, + ); } - $reservation->update([ - 'status' => StockReservation::STATUS_COMMITTED, - 'committed_at' => now(), - 'expires_at' => null, - ]); + $lockedCart->update(['current_stock_reservation_id' => null]); + $cart->current_stock_reservation_id = null; + + return 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, + $reservation = StockReservation::query()->create([ 'status' => StockReservation::STATUS_ACTIVE, 'expires_at' => $this->expiration(), ]); - - continue; + $lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]); } - 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(), - ]); + if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) { + throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.'); } - } - } - 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 restore(CartItem $cartItem, CatalogItem|Variant $selection): void - { - DB::transaction(function () use ($cartItem, $selection): void { - $requirements = $this->inventory->requirementsFor( - $selection, - (int) $cartItem->cantidad, - ); - $activeReservations = StockReservation::query() - ->where('cart_item_id', $cartItem->getKey()) - ->where('status', StockReservation::STATUS_ACTIVE) + $currentLines = StockReservationLine::query() + ->where('stock_reservation_id', $reservation->getKey()) + ->orderBy('inventory_id') ->lockForUpdate() ->get() ->keyBy('inventory_id'); - - $hasCompleteReservation = collect($requirements)->every( - fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity, - ); - - if ($hasCompleteReservation) { - return; - } - - if ($activeReservations->isNotEmpty()) { - throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.'); - } - - $this->inventory->reserve($selection, (int) $cartItem->cantidad); - $this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad); - }); - } - - 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') + $inventoryIds = collect(array_keys($requirements)) + ->merge($currentLines->keys()) + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values(); + $inventories = Inventory::query() + ->whereKey($inventoryIds) + ->orderBy('id') ->lockForUpdate() - ->get(); + ->get() + ->keyBy('id'); - foreach ($sourceReservations as $sourceReservation) { - $targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id); + foreach ($inventoryIds as $inventoryId) { + $inventory = $inventories->get($inventoryId) + ?? throw new \InvalidArgumentException('No se encontró el inventario requerido.'); + $previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0); + $required = (int) ($requirements[$inventoryId]['quantity'] ?? 0); + $delta = $required - $previous; - 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(), - ]); + if ($delta > 0 + && $requirements[$inventoryId]['tracks_inventory'] + && $inventory->availableStock() < $delta) { + throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.'); + } + + if ($delta < 0 && $inventory->reserved_stock < abs($delta)) { + throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.'); + } + } + + foreach ($inventoryIds as $inventoryId) { + /** @var Inventory $inventory */ + $inventory = $inventories->get($inventoryId); + $line = $currentLines->get($inventoryId); + $previous = (int) ($line?->quantity ?? 0); + $required = (int) ($requirements[$inventoryId]['quantity'] ?? 0); + $delta = $required - $previous; + + if ($delta > 0) { + $inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']); + } elseif ($delta < 0) { + $inventory->release(abs($delta)); + } + + if ($required === 0) { + $line?->delete(); continue; } - $targetReservation->update([ - 'quantity' => $targetReservation->quantity + $sourceReservation->quantity, - 'status' => StockReservation::STATUS_ACTIVE, - 'expires_at' => $this->expiration(), - ]); - $sourceReservation->delete(); + StockReservationLine::query()->updateOrCreate( + [ + 'stock_reservation_id' => $reservation->getKey(), + 'inventory_id' => $inventoryId, + ], + [ + 'quantity' => $required, + 'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'], + ], + ); + } + + $reservation->update([ + 'expires_at' => $this->expiration(), + 'release_reason' => null, + ]); + $cart->current_stock_reservation_id = $reservation->getKey(); + + return $reservation->fresh('lines'); + }); + } + + public function attachToPurchase( + Cart $cart, + Purchase $purchase, + Carbon $expiresAt, + ): StockReservation { + return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation { + /** @var Cart $lockedCart */ + $lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + /** @var Purchase $lockedPurchase */ + $lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + + if ($lockedCart->current_stock_reservation_id === null) { + throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.'); + } + + /** @var StockReservation $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id); + $this->assertUsableCartReservation($reservation); + + $linkedPurchase = Purchase::query() + ->where('stock_reservation_id', $reservation->getKey()) + ->whereKeyNot($lockedPurchase->getKey()) + ->exists(); + if ($linkedPurchase) { + throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.'); + } + + $lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]); + $reservation->update(['expires_at' => $expiresAt]); + $purchase->stock_reservation_id = $reservation->getKey(); + $cart->current_stock_reservation_id = $reservation->getKey(); + + return $reservation->fresh('lines'); + }); + } + + public function commit(Purchase $purchase): void + { + DB::transaction(function () use ($purchase): void { + /** @var Purchase $purchase */ + $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + if ($purchase->stock_reservation_id === null) { + throw new \InvalidArgumentException('La compra no tiene una reserva de stock.'); + } + + /** @var StockReservation $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id); + if ($reservation->status === StockReservation::STATUS_COMMITTED) { + return; + } + if ($reservation->status !== StockReservation::STATUS_ACTIVE) { + throw new \InvalidArgumentException('La reserva de stock no está activa.'); + } + if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) { + throw new StockReservationExpiredException; + } + + $lines = $this->lockLines($reservation); + if ($lines->isEmpty()) { + throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.'); + } + + $inventories = $this->lockInventories($lines); + foreach ($lines as $line) { + $inventory = $inventories->get($line->inventory_id) + ?? throw new \InvalidArgumentException('No se encontró el inventario reservado.'); + if ($inventory->reserved_stock < $line->quantity + || ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) { + throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.'); + } + } + + foreach ($lines as $line) { + $inventories->get($line->inventory_id)->buy( + (int) $line->quantity, + (bool) $line->tracks_inventory, + ); + } + + $reservation->update([ + 'status' => StockReservation::STATUS_COMMITTED, + 'expires_at' => null, + 'committed_at' => now(), + 'released_at' => null, + 'expired_at' => null, + 'release_reason' => null, + ]); + }); + } + + public function releaseForPurchase( + Purchase $purchase, + string $status = StockReservation::STATUS_RELEASED, + ?string $reason = null, + ): void { + DB::transaction(function () use ($purchase, $status, $reason): void { + /** @var Purchase $purchase */ + $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + if ($purchase->stock_reservation_id === null) { + return; + } + + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id); + if ($reservation !== null) { + $this->finalizeLocked($reservation, $status, $reason); } }); } - private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void + public function returnToCart(Purchase $purchase, Cart $cart): StockReservation { - foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) { - $reservation = $this->lockReservation($cartItem, $inventoryId); + return DB::transaction(function () use ($purchase, $cart): StockReservation { + /** @var Purchase $purchase */ + $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + /** @var Cart $cart */ + $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); - 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; + if ($purchase->stock_reservation_id === null + || $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) { + throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.'); } - $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(), - ]); - } + /** @var StockReservation $reservation */ + $reservation = StockReservation::query() + ->lockForUpdate() + ->findOrFail($purchase->stock_reservation_id); + $this->assertUsableCartReservation($reservation); + + $purchase->update(['stock_reservation_id' => null]); + $cart->update(['current_purchase_id' => null]); + $reservation->update(['expires_at' => $this->expiration()]); + + return $reservation->fresh('lines'); + }); } - private function recordDecrease( - CartItem $cartItem, - CatalogItem|Variant $selection, - int $quantity, - string $releasedStatus, + public function assertCartReservationUsable(Cart $cart): void + { + DB::transaction(function () use ($cart): void { + /** @var Cart $cart */ + $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + if ($cart->current_stock_reservation_id === null) { + return; + } + + /** @var StockReservation $reservation */ + $reservation = StockReservation::query() + ->lockForUpdate() + ->findOrFail($cart->current_stock_reservation_id); + $this->assertUsableCartReservation($reservation); + }); + } + + public function releaseCurrentCartReservation( + Cart $cart, + string $reason = self::REASON_CART_CHANGED, ): 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.'); + DB::transaction(function () use ($cart, $reason): void { + /** @var Cart $cart */ + $cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey()); + if ($cart->current_stock_reservation_id === null) { + return; } - $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, - ]); + /** @var StockReservation|null $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id); + if ($reservation !== null) { + $this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason); + } + $cart->update(['current_stock_reservation_id' => null]); + }); + } + + public function expire(StockReservation $reservation): void + { + DB::transaction(function () use ($reservation): void { + /** @var StockReservation $reservation */ + $reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey()); + if ($reservation->status !== StockReservation::STATUS_ACTIVE + || $reservation->expires_at === null + || $reservation->expires_at->isFuture()) { + return; + } + + $this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null); + }); + } + + public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void + { + DB::transaction(function () use ($purchase, $expiresAt): void { + /** @var Purchase $purchase */ + $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + if ($purchase->stock_reservation_id === null) { + throw new \InvalidArgumentException('La compra no tiene una reserva de stock.'); + } + + /** @var StockReservation $reservation */ + $reservation = StockReservation::query() + ->lockForUpdate() + ->findOrFail($purchase->stock_reservation_id); + if ($reservation->status !== StockReservation::STATUS_ACTIVE) { + throw new \InvalidArgumentException('La reserva de stock no está activa.'); + } + if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) { + throw new StockReservationExpiredException; + } + + $reservation->update(['expires_at' => $expiresAt]); + }); + } + + /** + * @param Collection $items + * @return array + */ + private function requirementsForItems(Collection $items): array + { + $requirements = []; + foreach ($items as $item) { + $selection = $item->selectedItem(); + if ($selection === null) { + throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.'); + } + + foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) { + if (isset($requirements[$inventoryId])) { + $requirements[$inventoryId]['quantity'] += $requirement['quantity']; + $requirements[$inventoryId]['tracks_inventory'] = + $requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory']; + + continue; + } + + $requirements[$inventoryId] = $requirement; + } + } + + ksort($requirements); + + return $requirements; + } + + /** @param Collection $items */ + private function loadSelections(Collection $items): void + { + $items->load([ + 'catalogItem.inventory', + 'catalogItem.bundleComponents.catalogItem.inventory', + 'catalogItem.bundleComponents.variant.inventory', + 'catalogItem.bundleComponents.variant.catalogItem', + 'variant.inventory', + 'variant.catalogItem', + ]); + } + + /** @return Collection */ + private function lockLines(StockReservation $reservation): Collection + { + return StockReservationLine::query() + ->where('stock_reservation_id', $reservation->getKey()) + ->orderBy('inventory_id') + ->lockForUpdate() + ->get(); + } + + /** + * @param Collection $lines + * @return Collection + */ + private function lockInventories(Collection $lines): Collection + { + return Inventory::query() + ->whereKey($lines->pluck('inventory_id')) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + } + + private function finalizeLocked( + StockReservation $reservation, + string $status, + ?string $reason, + ): void { + if ($reservation->status !== StockReservation::STATUS_ACTIVE) { + return; + } + if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) { + throw new \InvalidArgumentException('El estado final de la reserva no es válido.'); + } + + $lines = $this->lockLines($reservation); + $inventories = $this->lockInventories($lines); + foreach ($lines as $line) { + $inventory = $inventories->get($line->inventory_id) + ?? throw new \InvalidArgumentException('No se encontró el inventario reservado.'); + $inventory->release((int) $line->quantity); + } + + $now = now(); + $reservation->update([ + 'status' => $status, + 'expires_at' => null, + 'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null, + 'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null, + 'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null, + ]); + + if ($status === StockReservation::STATUS_RELEASED) { + Cart::query() + ->where('current_stock_reservation_id', $reservation->getKey()) + ->update(['current_stock_reservation_id' => null]); } } - private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation + private function assertUsableCartReservation(StockReservation $reservation): void { - return StockReservation::query() - ->where('cart_item_id', $cartItem->getKey()) - ->where('inventory_id', $inventoryId) - ->lockForUpdate() - ->first(); + if ($reservation->status === StockReservation::STATUS_EXPIRED + || ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) { + throw new StockReservationExpiredException; + } + + if ($reservation->status !== StockReservation::STATUS_ACTIVE + || $reservation->expires_at === null) { + throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.'); + } } private function expiration(): Carbon diff --git a/app/Domains/Catalog/documentacion/README.md b/app/Domains/Catalog/documentacion/README.md index baa2bb3..c484772 100644 --- a/app/Domains/Catalog/documentacion/README.md +++ b/app/Domains/Catalog/documentacion/README.md @@ -9,7 +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`. +- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock. - `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. @@ -18,7 +18,8 @@ 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`. +- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`. +- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario. - `FeaturedGroupService`: pagina los ítems destacados para la tienda. - `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets. diff --git a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php index 6e76244..19341b8 100644 --- a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php +++ b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php @@ -144,7 +144,6 @@ class InvitationPurchaseProvisioner if ($purchaseId !== null) { DB::table('compras')->where('id', $purchaseId)->update([ 'status' => 'paid', - 'expires_at' => null, 'total' => 0, 'updated_at' => $now, ]); @@ -158,7 +157,6 @@ class InvitationPurchaseProvisioner 'cart_id' => null, 'status' => 'paid', 'payment_method' => self::PAYMENT_METHOD, - 'expires_at' => null, 'total' => 0, 'dni' => null, 'transfer_payer_dni' => null, @@ -392,15 +390,27 @@ class InvitationPurchaseProvisioner 'sold_units' => $inventory->sold_units + 1, ]); - DB::table('stock_reservations')->insert([ + $reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id'); + if ($reservationId === null) { + $reservationId = DB::table('stock_reservations')->insertGetId([ + 'status' => 'committed', + 'committed_at' => $now, + 'released_at' => null, + 'expired_at' => null, + 'release_reason' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + DB::table('compras')->where('id', $purchaseId)->update([ + 'stock_reservation_id' => $reservationId, + ]); + } + + DB::table('stock_reservation_lines')->insert([ + 'stock_reservation_id' => $reservationId, 'inventory_id' => $inventory->id, - 'cart_item_id' => null, - 'purchase_id' => $purchaseId, 'quantity' => 1, - 'status' => 'committed', - 'expires_at' => null, - 'committed_at' => $now, - 'released_at' => null, + 'tracks_inventory' => true, 'created_at' => $now, 'updated_at' => $now, ]); diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 5db012f..118baa5 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -36,6 +36,7 @@ class PurchaseController extends Controller return PurchaseResource::collection( Purchase::query() + ->with('stockReservation') ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $request->user()->id) ->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses)) @@ -101,7 +102,13 @@ class PurchaseController extends Controller ? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni')) : null; - $updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool { + $updated = DB::transaction(function () use ( + $checkoutService, + $compra, + $method, + $purchaseState, + $transferPayerDni, + ): bool { /** @var Purchase|null $purchase */ $purchase = Purchase::query() ->whereKey($compra->getKey()) @@ -128,9 +135,6 @@ class PurchaseController extends Controller $purchaseUpdate = [ 'payment_method' => $method, 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'expires_at' => now()->addMinutes( - max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)) - ), 'total' => $purchase->calculateCurrentTotalAmount(), ]; @@ -138,6 +142,12 @@ class PurchaseController extends Controller $purchaseUpdate['transfer_payer_dni'] = $transferPayerDni; } $purchase->update($purchaseUpdate); + $checkoutService->refreshReservationExpiration( + $purchase, + now()->addMinutes( + max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)), + ), + ); return true; }); @@ -150,7 +160,6 @@ class PurchaseController extends Controller $compra->refresh(); $totalAmount = (float) $compra->total; - $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 447db78..d3e2bc8 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -19,11 +19,11 @@ use Illuminate\Support\Facades\DB; #[Fillable([ 'cart_id', + 'stock_reservation_id', 'tenant_codigo', 'user_id', 'status', 'payment_method', - 'expires_at', 'total', 'dni', 'transfer_payer_dni', @@ -77,8 +77,8 @@ class Purchase extends Model { return [ 'cart_id' => 'integer', + 'stock_reservation_id' => 'integer', 'user_id' => 'integer', - 'expires_at' => 'datetime', 'total' => 'decimal:2', ]; } @@ -123,10 +123,10 @@ class Purchase extends Model return $this->hasMany(Ticket::class, 'source_purchase_id'); } - /** @return HasMany */ - public function stockReservations(): HasMany + /** @return BelongsTo */ + public function stockReservation(): BelongsTo { - return $this->hasMany(StockReservation::class); + return $this->belongsTo(StockReservation::class); } /** diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 1c69313..81d0ca3 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -48,7 +48,7 @@ class PurchaseResource extends JsonResource 'created_at' => $this->created_at, 'status' => $this->status, 'payment_method' => $this->payment_method, - 'expires_at' => $this->expires_at, + 'expires_at' => $this->stockReservation?->expires_at, 'dni' => $this->dni, 'transfer_payer_dni' => $this->transfer_payer_dni, 'telefono' => $this->telefono, diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php index e8cd277..d827020 100644 --- a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -61,10 +61,7 @@ class CompleteCheckoutService $this->purchaseState->lockCurrentCart($purchase); - if ( - $purchase->status !== Purchase::STATUS_PENDING_PAYMENT - || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) - ) { + if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.not_available_for_review'), ]); @@ -72,9 +69,8 @@ class CompleteCheckoutService $purchase->update([ 'status' => Purchase::STATUS_IN_REVIEW, - 'expires_at' => null, ]); - $this->reservations->syncPurchaseExpiration($purchase); + $this->reservations->refreshForPurchase($purchase, null); return $this->loadPurchase($purchase); }); @@ -161,13 +157,14 @@ class CompleteCheckoutService ]); } - try { - $this->reservations->commit($cartItem, $selection, $purchase); - } catch (\InvalidArgumentException) { - throw ValidationException::withMessages([ - 'items' => __('api.purchase.inconsistent_reservation'), - ]); - } + } + + try { + $this->reservations->commit($purchase); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); } $this->sourceCart->finalize($purchase); @@ -194,7 +191,7 @@ class CompleteCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $purchase->load(['items.imageAttachment', 'stockReservation']); } private function itemKey(int $catalogItemId, ?int $variantId): string diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php index 223a8ba..0976c2e 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -8,6 +8,6 @@ class PurchaseResponseLoader { public function load(Purchase $purchase): Purchase { - return $purchase->load(['tenant', 'items.imageAttachment']); + return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']); } } diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index cbb17a8..a63ef14 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -5,11 +5,10 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; +use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Models\Purchase; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; -use Throwable; class ReleaseCheckoutService { @@ -32,40 +31,6 @@ class ReleaseCheckoutService return $this->release($purchase, Purchase::STATUS_EXPIRED); } - public function expireOverdue(): int - { - $expiredCount = 0; - - Purchase::query() - ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) - ->whereNotNull('expires_at') - ->where('expires_at', '<=', now()) - ->orderBy('id') - ->eachById(function (Purchase $purchase) use (&$expiredCount): void { - try { - $purchase = $this->expire($purchase); - } catch (Throwable $exception) { - Log::channel('commands')->error('Failed to expire overdue purchase.', [ - 'command' => 'reservations:expire', - 'purchase_id' => $purchase->getKey(), - 'tenant_codigo' => $purchase->tenant_codigo, - 'cart_id' => $purchase->cart_id, - 'status' => $purchase->status, - 'expires_at' => $purchase->expires_at, - 'exception' => $exception, - ]); - - return; - } - - if ($purchase->status === Purchase::STATUS_EXPIRED) { - $expiredCount++; - } - }); - - return $expiredCount; - } - private function release( Purchase $purchase, string $targetStatus, @@ -78,6 +43,11 @@ class ReleaseCheckoutService ): Purchase { $purchase = $this->lockPurchase($purchase); + if ($purchase->status === Purchase::STATUS_EXPIRED + && $targetStatus !== Purchase::STATUS_EXPIRED) { + throw new PurchaseExpiredException; + } + if ($purchase->status === Purchase::STATUS_PAID) { if ($targetStatus === Purchase::STATUS_EXPIRED) { return $this->loadPurchase($purchase); @@ -100,14 +70,31 @@ class ReleaseCheckoutService return $this->loadPurchase($purchase); } + $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + + if ($targetStatus === Purchase::STATUS_CANCELLED + && $cart?->status === 'active' + && in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true)) { + $this->reservations->returnToCart($purchase, $cart); + $purchase->update(['status' => Purchase::STATUS_CANCELLED]); + + return $this->loadPurchase($purchase); + } + if ( $targetStatus === Purchase::STATUS_EXPIRED - && ($purchase->expires_at === null || $purchase->expires_at->isFuture()) + && (! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) || ! $this->hasOverdueActiveReservation($purchase)) ) { return $this->loadPurchase($purchase); } - $this->releasePurchaseReservations($purchase, $targetStatus); + $this->releasePurchaseReservations($purchase, $targetStatus, $cart); $purchase->update(['status' => $targetStatus]); @@ -115,60 +102,67 @@ class ReleaseCheckoutService }); } - private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void - { - $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + private function releasePurchaseReservations( + Purchase $purchase, + string $targetStatus, + ?Cart $cart, + ): void { + try { + $this->reservations->releaseForPurchase( + $purchase, + $targetStatus === Purchase::STATUS_EXPIRED + ? StockReservation::STATUS_EXPIRED + : StockReservation::STATUS_RELEASED, + $targetStatus === Purchase::STATUS_CANCELLED + ? StockReservationService::REASON_PURCHASE_CANCELLED + : ($targetStatus === Purchase::STATUS_REJECTED + ? StockReservationService::REASON_PAYMENT_REJECTED + : null), + ); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + if ($cart === null) { return; } - if ($cart->status === 'active') { - $this->reservations->detachFromPurchase($purchase); + if ($targetStatus === Purchase::STATUS_EXPIRED + && in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) { Cart::query() ->whereKey($cart->getKey()) ->where('current_purchase_id', $purchase->getKey()) - ->update(['current_purchase_id' => null]); - - return; - } - - if ($cart->status !== 'checkout') { - 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'), + ->where('current_stock_reservation_id', $purchase->stock_reservation_id) + ->update([ + 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, ]); - } + + return; + } + + if ($cart->status === Cart::STATUS_ACTIVE) { + $cartUpdate = [ + 'current_purchase_id' => null, + 'current_stock_reservation_id' => null, + ]; + + Cart::query() + ->whereKey($cart->getKey()) + ->where('current_purchase_id', $purchase->getKey()) + ->update($cartUpdate); + + return; + } + + if ($cart->status !== Cart::STATUS_CHECKOUT) { + return; } if (! $cart->trashed()) { - $cart->update(['status' => 'converted']); + $cart->update(['status' => Cart::STATUS_CONVERTED]); $cart->delete(); } } @@ -218,6 +212,17 @@ class ReleaseCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $purchase->load(['items.imageAttachment', 'stockReservation']); + } + + private function hasOverdueActiveReservation(Purchase $purchase): bool + { + /** @var StockReservation|null $reservation */ + $reservation = $purchase->stockReservation()->lockForUpdate()->first(); + + return $reservation !== null + && $reservation->status === StockReservation::STATUS_ACTIVE + && $reservation->expires_at !== null + && ! $reservation->expires_at->isFuture(); } } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index b29d157..b34fd41 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; +use App\Domains\Catalog\Exceptions\StockReservationExpiredException; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; @@ -12,6 +13,7 @@ use App\Domains\Purchase\Exceptions\InsufficientStockException; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Tenant\Models\Tenant; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -169,21 +171,31 @@ class StartCheckoutService 'cantidad' => $line['quantity'], ]); - try { - $this->reservations->reserve($cartItem, $line['selection'], $line['quantity']); - } catch (\InvalidArgumentException) { - $availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0; - - throw new InsufficientStockException([ - $this->unavailableItem($line, $availableQuantity), - ]); - } - $cartItem->setRelation('catalogItem', $line['catalog_item']); $cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null); $cartItems->push($cartItem); } + try { + $this->reservations->syncCart($cart); + } catch (\InvalidArgumentException) { + $unavailable = $resolvedLines + ->map(function (array $line): ?array { + $availableQuantity = $this->inventory->availableQuantity($line['selection']); + + return $availableQuantity !== null && $availableQuantity < $line['quantity'] + ? $this->unavailableItem($line, $availableQuantity) + : null; + }) + ->filter() + ->values() + ->all(); + + throw new InsufficientStockException($unavailable !== [] ? $unavailable : [ + $this->unavailableItem($resolvedLines->first(), 0), + ]); + } + $purchase = $this->createPurchase( $tenant, $userId, @@ -194,19 +206,12 @@ class StartCheckoutService $cart->getKey(), ); $cart->update(['current_purchase_id' => $purchase->getKey()]); + $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); $this->loadCartItems($cartItems); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); - foreach ($cartItems as $index => $cartItem) { - $this->reservations->attachToPurchase( - $cartItem, - $resolvedLines->get($index)['selection'], - $purchase, - ); - } - return $this->loadPurchase($purchase); } @@ -257,6 +262,7 @@ class StartCheckoutService $this->verifyTenantItems($tenant, $cartItems); $this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey()); $cart->setRelation('items', $cartItems); + $this->reservations->syncCart($cart); $purchase = $this->createPurchase( $tenant, @@ -266,16 +272,9 @@ class StartCheckoutService $cart->getKey(), ); $cart->update(['current_purchase_id' => $purchase->getKey()]); + $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); - foreach ($cartItems as $cartItem) { - $this->reservations->attachToPurchase( - $cartItem, - $cartItem->selectedItem(), - $purchase, - ); - } - return $this->loadPurchase($purchase); } @@ -316,9 +315,9 @@ class StartCheckoutService Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true)) { + $this->reservations->returnToCart($currentPurchase, $cart); $currentPurchase->update([ 'status' => Purchase::STATUS_SUPERSEDED, - 'expires_at' => null, ]); } @@ -331,7 +330,11 @@ class StartCheckoutService throw new NotFoundHttpException('Cart not found for tenant.'); } - if ($cart->status !== 'active') { + if ($cart->status === Cart::STATUS_EXPIRED) { + throw new StockReservationExpiredException; + } + + if ($cart->status !== Cart::STATUS_ACTIVE) { throw ValidationException::withMessages([ 'cart_id' => __('api.purchase.inactive_cart'), ]); @@ -405,13 +408,17 @@ class StartCheckoutService 'user_id' => $userId, 'status' => Purchase::STATUS_CREATED, 'payment_method' => null, - 'expires_at' => now()->addMinutes( - max(1, (int) config('purchase.checkout_expiration_minutes', 30)), - ), 'total' => $total, ]); } + private function checkoutExpiration(): Carbon + { + return now()->addMinutes( + max(1, (int) config('purchase.checkout_expiration_minutes', 30)), + ); + } + /** @param Collection $cartItems */ private function loadCartItems(Collection $cartItems): void { diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 1a40c86..c7e7bea 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -9,6 +9,7 @@ use App\Domains\Purchase\Services\Checkout\EditCheckoutService; use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; use App\Domains\Purchase\Services\Checkout\StartCheckoutService; use App\Domains\Tenant\Models\Tenant; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; /** @@ -78,13 +79,8 @@ class CheckoutService return $this->releaser->expire($purchase); } - public function expireOverduePurchases(): int + public function refreshReservationExpiration(Purchase $purchase, ?Carbon $expiresAt): void { - return $this->releaser->expireOverdue(); - } - - public function syncReservationExpiration(Purchase $purchase): void - { - $this->reservations->syncPurchaseExpiration($purchase); + $this->reservations->refreshForPurchase($purchase, $expiresAt); } } diff --git a/app/Domains/Purchase/Services/PurchaseStateGuard.php b/app/Domains/Purchase/Services/PurchaseStateGuard.php index 2c9d4f9..9e43305 100644 --- a/app/Domains/Purchase/Services/PurchaseStateGuard.php +++ b/app/Domains/Purchase/Services/PurchaseStateGuard.php @@ -3,6 +3,7 @@ namespace App\Domains\Purchase\Services; use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Models\Purchase; use Illuminate\Validation\ValidationException; @@ -11,15 +12,32 @@ class PurchaseStateGuard { public function assertNotExpired(Purchase $purchase): void { - $hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED; - $hasExpiredByTime = in_array($purchase->status, [ + if ($purchase->status === Purchase::STATUS_EXPIRED) { + throw new PurchaseExpiredException; + } + + if (! in_array($purchase->status, [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, - ], true) - && $purchase->expires_at !== null - && $purchase->expires_at->isPast(); + ], true)) { + return; + } - if ($hasExpiredStatus || $hasExpiredByTime) { + /** @var StockReservation|null $reservation */ + $reservation = $purchase->relationLoaded('stockReservation') + ? $purchase->getRelation('stockReservation') + : ($purchase->exists + ? $purchase->stockReservation()->first() + : null); + + if ($reservation !== null && ( + $reservation->status === StockReservation::STATUS_EXPIRED + || ( + $reservation->status === StockReservation::STATUS_ACTIVE + && $reservation->expires_at !== null + && ! $reservation->expires_at->isFuture() + ) + )) { throw new PurchaseExpiredException; } } diff --git a/app/Domains/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Purchase/Services/TenantTransactionResetService.php index 5b5880c..be6b7c2 100644 --- a/app/Domains/Purchase/Services/TenantTransactionResetService.php +++ b/app/Domains/Purchase/Services/TenantTransactionResetService.php @@ -135,11 +135,25 @@ class TenantTransactionResetService */ private function reservationQuery(array $scope): Builder { + $reservationIds = DB::table('carritos') + ->whereIn('id', $scope['cart_ids']) + ->whereNotNull('current_stock_reservation_id') + ->pluck('current_stock_reservation_id') + ->merge( + DB::table('compras') + ->whereIn('id', $scope['purchase_ids']) + ->whereNotNull('stock_reservation_id') + ->pluck('stock_reservation_id'), + ) + ->merge( + DB::table('stock_reservation_lines') + ->whereIn('inventory_id', $scope['inventory_ids']) + ->pluck('stock_reservation_id'), + ) + ->unique() + ->values(); + return DB::table('stock_reservations') - ->where(function (Builder $query) use ($scope): void { - $query->whereIn('inventory_id', $scope['inventory_ids']) - ->orWhereIn('purchase_id', $scope['purchase_ids']) - ->orWhereIn('cart_item_id', $scope['cart_item_ids']); - }); + ->whereIn('id', $reservationIds); } } diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index 0db5a15..e7d844a 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -88,9 +88,9 @@ class UserPurchaseLimitService $excludedCartId !== null, fn ($query) => $query->whereKeyNot($excludedCartId), )) - ->whereHas('stockReservations', fn ($query) => $query + ->whereHas('cart.currentStockReservation', fn ($query) => $query ->where('status', 'active') - ->whereNull('purchase_id')) + ->whereDoesntHave('purchase')) ->sum('cantidad'); if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) { @@ -161,9 +161,9 @@ class UserPurchaseLimitService ->whereHas('cart', fn ($query) => $query ->where('user_id', $userId) ->where('status', 'active')) - ->whereHas('stockReservations', fn ($query) => $query + ->whereHas('cart.currentStockReservation', fn ($query) => $query ->where('status', 'active') - ->whereNull('purchase_id')) + ->whereDoesntHave('purchase')) ->groupBy('catalog_item_id') ->pluck('quantity', 'catalog_item_id'); diff --git a/app/Domains/Purchase/documentacion/README.md b/app/Domains/Purchase/documentacion/README.md index 46838f0..7410245 100644 --- a/app/Domains/Purchase/documentacion/README.md +++ b/app/Domains/Purchase/documentacion/README.md @@ -6,7 +6,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca ## Modelo -- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`. +- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout. No guarda un vencimiento propio: expira como consecuencia del vencimiento de su reserva. - `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,16 +15,16 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca `CheckoutService` es la fachada estable. Delega en: -- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`. +- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, refresca el vencimiento de la reserva agregada y crea los snapshots `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. +- `ReleaseCheckoutService`: cancela o vence una compra y aplica sus efectos comerciales; el scanner unificado del dominio Catalog detecta las reservas pendientes de vencimiento. - `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra. - `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots. -Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y deja de vencer. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión. +Al iniciar checkout o elegir un medio de pago se refresca directamente `StockReservation.expires_at`, que es la única fuente de verdad y se expone como `expires_at` en la respuesta pública de la compra. El refresco sólo se permite mientras la reserva siga vigente; una fecha vencida bloquea todas las mutaciones aun antes de que corra el scheduler. Al materializar la expiración, la compra pagable, su carrito y la reserva pasan a `expired` dentro de la misma transacción. Al cancelar o reemplazar una compra recuperable, ésta se desvincula y el carrito conserva la misma reserva activa. Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y ese vencimiento se limpia. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión. -Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación. +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, devuelve la misma reserva activa al carrito y sincroniza sus líneas con el contenido actualizado; Purchase no expone operaciones sobre líneas antes de la confirmación. `UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones. diff --git a/bootstrap/app.php b/bootstrap/app.php index 6159d8d..8102d3b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ $exception->getMessage(), ], 422); }); + $exceptions->render(function (StockReservationExpiredException $exception, Request $request) { + if (! $request->is('api/*')) { + return null; + } + + return response()->json([ + 'code' => 'stock_reservation.expired', + 'message' => $exception->getMessage(), + ], 422); + }); $exceptions->render(function (ModelNotFoundException $exception, Request $request) { if (! $request->is('api/*')) { return null; diff --git a/database/migrations/2026_08_25_020000_centralize_stock_reservations.php b/database/migrations/2026_08_25_020000_centralize_stock_reservations.php new file mode 100644 index 0000000..655724f --- /dev/null +++ b/database/migrations/2026_08_25_020000_centralize_stock_reservations.php @@ -0,0 +1,180 @@ +id(); + $table->string('status')->default('active'); + $table->dateTime('expires_at')->nullable(); + $table->dateTime('committed_at')->nullable(); + $table->dateTime('released_at')->nullable(); + $table->dateTime('expired_at')->nullable(); + $table->string('release_reason')->nullable(); + $table->timestamps(); + + $table->index(['status', 'expires_at']); + }); + + Schema::table('stock_reservation_lines', function (Blueprint $table): void { + $table->foreignId('stock_reservation_id')->nullable()->after('id'); + $table->boolean('tracks_inventory')->default(true)->after('quantity'); + }); + + Schema::table('carritos', function (Blueprint $table): void { + $table->foreignId('current_stock_reservation_id')->nullable()->after('current_purchase_id'); + }); + + Schema::table('compras', function (Blueprint $table): void { + $table->foreignId('stock_reservation_id')->nullable()->after('cart_id'); + }); + + $cartIdsByItem = DB::table('carrito_items')->pluck('cart_id', 'id'); + $unlimitedInventoryIds = DB::table('catalog_items') + ->where('inventory_policy', 'unlimited') + ->whereNotNull('inventory_id') + ->pluck('inventory_id') + ->merge( + DB::table('variantes') + ->join('catalog_items', 'catalog_items.id', '=', 'variantes.catalog_item_id') + ->where('catalog_items.inventory_policy', 'unlimited') + ->pluck('variantes.inventory_id'), + ) + ->map(fn ($id): int => (int) $id) + ->unique(); + $legacyRows = DB::table('stock_reservation_lines')->orderBy('id')->get(); + $groups = $legacyRows->groupBy(function (object $row) use ($cartIdsByItem): string { + if ($row->purchase_id !== null) { + return 'purchase:'.$row->purchase_id; + } + + $cartId = $row->cart_item_id === null ? null : $cartIdsByItem->get($row->cart_item_id); + + return $cartId === null ? 'legacy:'.$row->id : 'cart:'.$cartId; + }); + + foreach ($groups as $key => $rows) { + $statuses = $rows->pluck('status'); + $status = $statuses->contains('active') + ? 'active' + : ($statuses->contains('committed') + ? 'committed' + : ($statuses->contains('expired') ? 'expired' : 'released')); + $first = $rows->first(); + $reservationId = DB::table('stock_reservations')->insertGetId([ + 'status' => $status, + 'expires_at' => $status === 'active' ? $rows->pluck('expires_at')->filter()->max() : null, + 'committed_at' => $status === 'committed' ? $rows->pluck('committed_at')->filter()->max() : null, + 'released_at' => $status === 'released' ? $rows->pluck('released_at')->filter()->max() : null, + 'expired_at' => $status === 'expired' ? $rows->pluck('released_at')->filter()->max() : null, + 'release_reason' => null, + 'created_at' => $first->created_at, + 'updated_at' => $rows->pluck('updated_at')->filter()->max() ?? $first->updated_at, + ]); + + foreach ($rows->groupBy('inventory_id') as $inventoryRows) { + $line = $inventoryRows->first(); + DB::table('stock_reservation_lines')->where('id', $line->id)->update([ + 'stock_reservation_id' => $reservationId, + 'quantity' => $inventoryRows->sum('quantity'), + 'tracks_inventory' => ! $unlimitedInventoryIds->contains((int) $line->inventory_id), + ]); + DB::table('stock_reservation_lines') + ->whereIn('id', $inventoryRows->pluck('id')->skip(1)) + ->delete(); + } + + if (str_starts_with($key, 'purchase:')) { + $purchaseId = (int) substr($key, strlen('purchase:')); + DB::table('compras')->where('id', $purchaseId)->update([ + 'stock_reservation_id' => $reservationId, + ]); + $cartId = DB::table('compras')->where('id', $purchaseId)->value('cart_id'); + $isCurrent = $cartId !== null + && (int) DB::table('carritos')->where('id', $cartId)->value('current_purchase_id') === $purchaseId; + + if ($status === 'active' && $isCurrent) { + DB::table('carritos')->where('id', $cartId)->update([ + 'current_stock_reservation_id' => $reservationId, + ]); + } + } elseif (str_starts_with($key, 'cart:') && $status === 'active') { + DB::table('carritos')->where('id', (int) substr($key, strlen('cart:')))->update([ + 'current_stock_reservation_id' => $reservationId, + ]); + } + } + + if (DB::getDriverName() !== 'sqlite') { + Schema::table('stock_reservation_lines', function (Blueprint $table): void { + $table->dropForeign('stock_reservations_cart_item_id_foreign'); + $table->dropForeign('stock_reservations_purchase_id_foreign'); + $table->dropUnique('stock_reservations_cart_item_id_inventory_id_unique'); + $table->dropIndex('stock_reservations_purchase_id_status_index'); + $table->dropIndex('stock_reservations_status_expires_at_index'); + }); + } + + Schema::table('stock_reservation_lines', function (Blueprint $table): void { + $table->unsignedBigInteger('stock_reservation_id')->nullable(false)->change(); + $table->dropColumn([ + 'cart_item_id', + 'purchase_id', + 'status', + 'expires_at', + 'committed_at', + 'released_at', + ]); + $table->foreign('stock_reservation_id', 'reservation_lines_reservation_fk') + ->references('id') + ->on('stock_reservations') + ->cascadeOnDelete(); + $table->unique( + ['stock_reservation_id', 'inventory_id'], + 'reservation_lines_reservation_inventory_unique', + ); + }); + + Schema::table('carritos', function (Blueprint $table): void { + $table->foreign('current_stock_reservation_id', 'carts_current_stock_reservation_fk') + ->references('id') + ->on('stock_reservations') + ->nullOnDelete(); + $table->unique('current_stock_reservation_id', 'carts_current_stock_reservation_unique'); + }); + + Schema::table('compras', function (Blueprint $table): void { + $table->foreign('stock_reservation_id', 'purchases_stock_reservation_fk') + ->references('id') + ->on('stock_reservations') + ->nullOnDelete(); + $table->unique('stock_reservation_id', 'purchases_stock_reservation_unique'); + }); + } + + public function down(): void + { + Schema::table('compras', function (Blueprint $table): void { + $table->dropUnique('purchases_stock_reservation_unique'); + $table->dropForeign('purchases_stock_reservation_fk'); + $table->dropColumn('stock_reservation_id'); + }); + + Schema::table('carritos', function (Blueprint $table): void { + $table->dropUnique('carts_current_stock_reservation_unique'); + $table->dropForeign('carts_current_stock_reservation_fk'); + $table->dropColumn('current_stock_reservation_id'); + }); + + Schema::dropIfExists('stock_reservation_lines'); + Schema::dropIfExists('stock_reservations'); + } +}; diff --git a/database/migrations/2026_08_25_160000_make_stock_reservation_expiration_authoritative.php b/database/migrations/2026_08_25_160000_make_stock_reservation_expiration_authoritative.php new file mode 100644 index 0000000..4bdd362 --- /dev/null +++ b/database/migrations/2026_08_25_160000_make_stock_reservation_expiration_authoritative.php @@ -0,0 +1,51 @@ +whereNotNull('stock_reservation_id') + ->orderBy('id') + ->chunkById(500, function ($purchases): void { + foreach ($purchases as $purchase) { + DB::table('stock_reservations') + ->where('id', $purchase->stock_reservation_id) + ->where('status', 'active') + ->update(['expires_at' => $purchase->expires_at]); + } + }); + + Schema::table('compras', function (Blueprint $table): void { + $table->dropIndex(['expires_at']); + $table->dropColumn('expires_at'); + }); + } + + public function down(): void + { + Schema::table('compras', function (Blueprint $table): void { + $table->timestamp('expires_at')->nullable()->index()->after('payment_method'); + }); + + DB::table('compras') + ->whereNotNull('stock_reservation_id') + ->orderBy('id') + ->chunkById(500, function ($purchases): void { + foreach ($purchases as $purchase) { + DB::table('compras') + ->where('id', $purchase->id) + ->update([ + 'expires_at' => DB::table('stock_reservations') + ->where('id', $purchase->stock_reservation_id) + ->value('expires_at'), + ]); + } + }); + } +}; diff --git a/database/migrations/2026_08_25_170000_propagate_expired_stock_reservations.php b/database/migrations/2026_08_25_170000_propagate_expired_stock_reservations.php new file mode 100644 index 0000000..bd323b9 --- /dev/null +++ b/database/migrations/2026_08_25_170000_propagate_expired_stock_reservations.php @@ -0,0 +1,30 @@ + $query + ->select('id') + ->from('stock_reservations') + ->where('status', 'expired'); + + DB::table('compras') + ->whereIn('status', ['created', 'pending_payment']) + ->whereIn('stock_reservation_id', $expiredReservationIds) + ->update(['status' => 'expired']); + + DB::table('carritos') + ->whereIn('status', ['active', 'checkout']) + ->whereIn('current_stock_reservation_id', $expiredReservationIds) + ->update(['status' => 'expired']); + } + + public function down(): void + { + // Terminal business states cannot be reversed without inventing their prior state. + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index 1ba97e4..ddb6021 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -34,6 +34,7 @@ return [ 'bundle_variant_forbidden' => 'A bundle cannot have a variant.', 'empty_bundle' => 'The bundle has no components.', 'variant_required' => 'You must select a variant for this item.', + 'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.', ], 'purchase' => [ 'expired' => 'The purchase has expired. Please start a new purchase.', diff --git a/lang/es/api.php b/lang/es/api.php index b1ee881..7c8cdf3 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -34,6 +34,7 @@ return [ 'bundle_variant_forbidden' => 'Un bundle no admite una variante.', 'empty_bundle' => 'El bundle no tiene componentes.', 'variant_required' => 'Debe seleccionar una variante para este ítem.', + 'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.', ], 'purchase' => [ 'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.", diff --git a/routes/console.php b/routes/console.php index fd48c29..e9597ab 100644 --- a/routes/console.php +++ b/routes/console.php @@ -15,7 +15,9 @@ Artisan::command('reservations:expire', function (): void { $expired = app(ExpireStockReservationsService::class)->expireOverdue(); $this->info("Expired purchases: {$expired['purchases']}"); - $this->info("Expired cart items: {$expired['cart_items']}"); + $this->info("Expired cart reservations: {$expired['cart_reservations']}"); + $this->info("Expired orphan reservations: {$expired['orphan_reservations']}"); + $this->info("Failed reservations: {$expired['failed']}"); })->purpose('Release expired stock reservations from purchases and abandoned carts'); Schedule::command('reservations:expire') diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 69e738d..1a5696a 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -5,10 +5,12 @@ namespace Tests\Feature\Cart; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; +use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\CatalogService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; @@ -28,6 +30,81 @@ class CartControllerTest extends TestCase ])); $this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type')); $this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id')); + $this->assertTrue(Schema::hasColumns('stock_reservations', [ + 'status', + 'expires_at', + 'committed_at', + 'released_at', + 'expired_at', + 'release_reason', + ])); + $this->assertFalse(Schema::hasColumn('stock_reservations', 'quantity')); + $this->assertTrue(Schema::hasColumns('stock_reservation_lines', [ + 'stock_reservation_id', + 'inventory_id', + 'quantity', + 'tracks_inventory', + ])); + $this->assertTrue(Schema::hasColumn('carritos', 'current_stock_reservation_id')); + $this->assertTrue(Schema::hasColumn('compras', 'stock_reservation_id')); + $this->assertFalse(Schema::hasColumn('compras', 'expires_at')); + } + + public function test_it_aggregates_shared_inventory_into_one_cart_reservation_line(): void + { + $tenant = $this->createTenant('acme'); + $firstItem = $this->createDirectItem($tenant, 10, '10.00'); + $secondItem = app(CatalogService::class)->create([ + 'tenant_code' => $tenant->codigo, + 'type' => 'bundle', + 'slug' => 'second-shared-item', + 'nombre' => 'Second shared item', + 'precio' => '20.00', + 'components' => [[ + 'catalog_item_id' => $firstItem->id, + 'quantity' => 1, + ]], + ]); + + $firstResponse = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $firstItem->id, + 'cantidad' => 2, + ])->assertOk(); + $guestToken = $firstResponse->getCookie('guest_token', false)?->getValue(); + + $this->call( + 'POST', + '/api/tenants/acme/cart/items', + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode([ + 'catalog_item_id' => $secondItem->id, + 'cantidad' => 3, + ]), + )->assertOk(); + + $reservationId = (int) $firstResponse->json('data.id'); + $reservationId = (int) Cart::query() + ->findOrFail($reservationId) + ->current_stock_reservation_id; + + $this->assertDatabaseCount('stock_reservations', 1); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservationId, + 'status' => 'active', + ]); + $this->assertDatabaseHas('stock_reservation_lines', [ + 'stock_reservation_id' => $reservationId, + 'inventory_id' => $firstItem->inventory_id, + 'quantity' => 5, + ]); + $this->assertDatabaseCount('stock_reservation_lines', 1); + $this->assertDatabaseHas('inventories', [ + 'id' => $firstItem->inventory_id, + 'reserved_stock' => 5, + ]); } public function test_it_adds_a_catalog_item_without_a_variant(): void @@ -74,9 +151,11 @@ class CartControllerTest extends TestCase 'id' => $item->inventory_id, 'reserved_stock' => 2, ]); - $this->assertDatabaseHas('stock_reservations', [ + $this->assertDatabaseHas('stock_reservation_lines', [ 'inventory_id' => $item->inventory_id, 'quantity' => 2, + ]); + $this->assertDatabaseHas('stock_reservations', [ 'status' => 'active', ]); } @@ -96,38 +175,44 @@ class CartControllerTest extends TestCase ])->assertOk(); $this->assertDatabaseHas('stock_reservations', [ - 'inventory_id' => $item->inventory_id, - 'quantity' => 2, 'status' => 'active', 'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(), ]); + $this->assertDatabaseHas('stock_reservation_lines', [ + 'inventory_id' => $item->inventory_id, + 'quantity' => 2, + ]); $this->travelBack(); } - public function test_it_expires_abandoned_cart_reservations_and_removes_empty_carts(): void + public function test_it_expires_a_cart_reservation_and_automatically_replaces_the_cart(): void { config()->set('catalog.stock_reservation_expiration_minutes', 30); $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); $item = $this->createDirectItem($tenant, 10, '49.90'); - $response = $this->postJson('/api/tenants/acme/cart/items', [ - 'catalog_item_id' => $item->id, - 'cantidad' => 2, - ])->assertOk(); + $response = $this->actingAs($user, 'sanctum') + ->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'); + $cart = Cart::query()->findOrFail($cartId); + $reservationId = $cart->current_stock_reservation_id; $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 0') - ->expectsOutput('Expired cart items: 0') + ->expectsOutput('Expired cart reservations: 0') ->assertSuccessful(); $this->travel(31)->minutes(); $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 0') - ->expectsOutput('Expired cart items: 1') + ->expectsOutput('Expired cart reservations: 1') ->assertSuccessful(); $this->assertDatabaseHas('inventories', [ @@ -135,28 +220,141 @@ class CartControllerTest extends TestCase 'real_stock' => 10, 'reserved_stock' => 0, ]); - $this->assertDatabaseMissing('carrito_items', ['id' => $cartItemId]); - $this->assertSoftDeleted('carritos', [ + $this->assertDatabaseHas('carrito_items', ['id' => $cartItemId]); + $this->assertDatabaseHas('carritos', [ 'id' => $cartId, - 'status' => 'expired', + 'status' => Cart::STATUS_EXPIRED, + 'current_stock_reservation_id' => $reservationId, + 'deleted_at' => null, ]); $this->assertDatabaseHas('stock_reservations', [ - 'inventory_id' => $item->inventory_id, - 'cart_item_id' => null, - 'purchase_id' => null, - 'quantity' => 0, + 'id' => $reservationId, 'status' => 'expired', 'expires_at' => null, ]); + $this->assertDatabaseHas('stock_reservation_lines', [ + 'inventory_id' => $item->inventory_id, + 'quantity' => 2, + ]); + + $currentCart = $this->getJson('/api/tenants/acme/cart') + ->assertOk() + ->assertJsonPath('data.status', Cart::STATUS_ACTIVE) + ->assertJsonPath('data.items', []) + ->assertJsonMissingPath('data.stock_reservation') + ->assertJsonMissingPath('data.current_stock_reservation_id'); + $newCartId = $currentCart->json('data.id'); + + $this->assertNotSame($cartId, $newCartId); + $this->assertDatabaseHas('carritos', [ + 'id' => $cartId, + 'status' => Cart::STATUS_ABANDONED, + 'current_stock_reservation_id' => $reservationId, + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $newCartId, + 'status' => Cart::STATUS_ACTIVE, + 'current_stock_reservation_id' => null, + ]); + + $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.id', $newCartId) + ->assertJsonPath('data.items.0.cantidad', 1); $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 0') - ->expectsOutput('Expired cart items: 0') + ->expectsOutput('Expired cart reservations: 0') ->assertSuccessful(); $this->travelBack(); } + public function test_it_replaces_an_overdue_cart_before_the_expiration_job_runs(): void + { + config()->set('catalog.stock_reservation_expiration_minutes', 30); + $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); + $item = $this->createDirectItem($tenant, 10, '49.90'); + + $original = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ])->assertOk(); + $originalCartId = $original->json('data.id'); + + $this->travel(31)->minutes(); + + $replacement = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.status', Cart::STATUS_ACTIVE) + ->assertJsonPath('data.items.0.cantidad', 1); + + $this->assertNotSame($originalCartId, $replacement->json('data.id')); + $this->assertDatabaseHas('carritos', [ + 'id' => $originalCartId, + 'status' => Cart::STATUS_ABANDONED, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $item->inventory_id, + 'reserved_stock' => 1, + ]); + + $this->travelBack(); + } + + public function test_expired_cart_mutations_return_the_expiration_error_instead_of_not_found(): void + { + config()->set('catalog.stock_reservation_expiration_minutes', 30); + $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); + [$item, $firstVariant] = $this->createVariantItem($tenant, 10, '49.90'); + $secondInventory = Inventory::query()->create(['real_stock' => 10]); + $secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]); + + $cartItemId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $firstVariant->id, + 'cantidad' => 2, + ]) + ->assertOk() + ->json('data.items.0.id'); + + $this->travel(31)->minutes(); + + $expectedError = [ + 'code' => 'stock_reservation.expired', + 'message' => __('api.cart.reservation_expired'), + ]; + + $this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [ + 'cantidad' => 3, + ]) + ->assertUnprocessable() + ->assertExactJson($expectedError); + + $this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [ + 'cantidad' => 2, + 'variant_id' => $secondVariant->id, + ]) + ->assertUnprocessable() + ->assertExactJson($expectedError); + + $this->deleteJson("/api/tenants/acme/cart/items/{$cartItemId}") + ->assertUnprocessable() + ->assertExactJson($expectedError); + + $this->travelBack(); + } + public function test_it_filters_item_images_when_the_tenant_disables_them(): void { $tenant = $this->createTenant('acme'); @@ -212,10 +410,11 @@ class CartControllerTest extends TestCase 'id' => $variant->inventory_id, 'reserved_stock' => 5, ]); - $this->assertDatabaseHas('stock_reservations', [ - 'cart_item_id' => $response->json('data.items.0.id'), + $this->assertDatabaseHas('stock_reservation_lines', [ 'inventory_id' => $variant->inventory_id, 'quantity' => 5, + ]); + $this->assertDatabaseHas('stock_reservations', [ 'status' => 'active', ]); } @@ -229,6 +428,7 @@ class CartControllerTest extends TestCase $this->createPurchaseItem($tenant, $user, $item, 1); $this->actingAs($user, 'sanctum') + ->withHeader('Accept-Language', 'es') ->postJson('/api/tenants/acme/cart/items', [ 'catalog_item_id' => $item->id, 'cantidad' => 2, @@ -237,6 +437,7 @@ class CartControllerTest extends TestCase ->assertJsonPath('data.items.0.cantidad', 2); $this->actingAs($user, 'sanctum') + ->withHeader('Accept-Language', 'es') ->postJson('/api/tenants/acme/cart/items', [ 'catalog_item_id' => $item->id, 'cantidad' => 2, @@ -381,10 +582,12 @@ class CartControllerTest extends TestCase 'reserved_stock' => 0, ]); $this->assertDatabaseHas('stock_reservations', [ - 'cart_item_id' => null, - 'inventory_id' => $variant->inventory_id, - 'quantity' => 0, 'status' => 'released', + 'release_reason' => 'cart_empty', + ]); + $this->assertDatabaseHas('stock_reservation_lines', [ + 'inventory_id' => $variant->inventory_id, + 'quantity' => 5, ]); } diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 3260e38..e588810 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -62,6 +62,10 @@ class TelepagosWebhookTest extends TestCase public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void { + config()->set('purchase.payment_expiration_minutes.transfer', 60); + $now = now()->startOfSecond(); + $this->travelTo($now); + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->configureTelepagosIntegration($tenant); $user = User::factory()->create(); @@ -99,6 +103,13 @@ class TelepagosWebhookTest extends TestCase 'payment_method' => 'transfer', 'status' => Purchase::STATUS_PENDING_PAYMENT, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $purchase->stock_reservation_id, + 'status' => 'active', + 'expires_at' => $now->copy()->addMinutes(60)->toDateTimeString(), + ]); + + $this->travelBack(); } public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void @@ -198,11 +209,16 @@ class TelepagosWebhookTest extends TestCase 'compra_id' => $newerPurchase->id, 'cantidad' => 2, ]); + $newerReservationId = $newerPurchase->fresh()->stock_reservation_id; + $this->assertNotNull($newerReservationId); $this->assertDatabaseHas('stock_reservations', [ - 'purchase_id' => $newerPurchase->id, + 'id' => $newerReservationId, + 'status' => 'active', + ]); + $this->assertDatabaseHas('stock_reservation_lines', [ + 'stock_reservation_id' => $newerReservationId, 'inventory_id' => $variant->inventory_id, 'quantity' => 2, - 'status' => 'active', ]); $this->assertSoftDeleted('carritos', [ diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 91f521a..054221d 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -32,6 +32,82 @@ class StorePurchaseTest extends TestCase Queue::fake(); } + public function test_checkout_keeps_the_cart_reservation_and_refreshes_its_expiration(): void + { + config()->set('catalog.stock_reservation_expiration_minutes', 5); + config()->set('purchase.checkout_expiration_minutes', 30); + $now = now()->startOfSecond(); + $this->travelTo($now); + + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($variant->catalog_item_id, $variant->id, 2); + $reservationId = $cart->fresh()->current_stock_reservation_id; + + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservationId, + 'status' => 'active', + 'expires_at' => $now->copy()->addMinutes(5)->toDateTimeString(), + ]); + + $this->travel(2)->minutes(); + $purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, [ + 'cart_id' => $cart->id, + ]); + + $this->assertSame($reservationId, $purchase->stock_reservation_id); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservationId, + 'status' => 'active', + 'expires_at' => $now->copy()->addMinutes(32)->toDateTimeString(), + ]); + + $this->travelBack(); + } + + public function test_checkout_cannot_replace_an_overdue_cart_reservation(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($variant->catalog_item_id, $variant->id, 2); + $reservationId = $cart->fresh()->current_stock_reservation_id; + $expiredAt = now()->subMinute()->startOfSecond(); + $cart->currentStockReservation()->update(['expires_at' => $expiredAt]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'cart_id' => $cart->id, + ]) + ->assertUnprocessable() + ->assertExactJson([ + 'code' => 'stock_reservation.expired', + 'message' => __('api.cart.reservation_expired'), + ]); + + $this->assertDatabaseCount('compras', 0); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservationId, + 'status' => 'active', + 'expires_at' => $expiredAt->toDateTimeString(), + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $cart->id, + 'current_stock_reservation_id' => $reservationId, + ]); + } + public function test_it_starts_checkout_from_cart_with_purchase_item_snapshots(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); @@ -119,12 +195,7 @@ class StorePurchaseTest extends TestCase 'precio_unitario' => '50.00', 'total' => '100.00', ]); - $this->assertDatabaseHas('stock_reservations', [ - 'inventory_id' => $inventory->id, - 'purchase_id' => $purchaseId, - 'quantity' => 2, - 'status' => 'active', - ]); + $this->assertPurchaseReservation($purchaseId, $inventory->id, 2, 'active'); $this->assertDatabaseHas('carritos', [ 'id' => $cartId, 'user_id' => $user->id, @@ -168,12 +239,7 @@ class StorePurchaseTest extends TestCase 'id' => $cartId, 'current_purchase_id' => $replacementPurchaseId, ]); - $this->assertDatabaseHas('stock_reservations', [ - 'inventory_id' => $inventory->id, - 'purchase_id' => $replacementPurchaseId, - 'quantity' => 2, - 'status' => 'active', - ]); + $this->assertPurchaseReservation($replacementPurchaseId, $inventory->id, 2, 'active'); $this->assertDatabaseHas('inventories', [ 'id' => $inventory->id, 'reserved_stock' => 2, @@ -227,12 +293,7 @@ class StorePurchaseTest extends TestCase 'precio_unitario' => '50.00', 'total' => '150.00', ]); - $this->assertDatabaseHas('stock_reservations', [ - 'inventory_id' => $variant->inventory_id, - 'purchase_id' => $response->json('data.id'), - 'quantity' => 3, - 'status' => 'active', - ]); + $this->assertPurchaseReservation($response->json('data.id'), $variant->inventory_id, 3, 'active'); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'real_stock' => 10, @@ -244,10 +305,7 @@ class StorePurchaseTest extends TestCase ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); - $this->assertDatabaseHas('stock_reservations', [ - 'purchase_id' => $response->json('data.id'), - 'status' => 'released', - ]); + $this->assertPurchaseReservation($response->json('data.id'), $variant->inventory_id, 3, 'released'); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'real_stock' => 10, @@ -296,18 +354,8 @@ class StorePurchaseTest extends TestCase 'origin' => Cart::ORIGIN_DIRECT_CHECKOUT, ]); $this->assertDatabaseCount('compra_items', 2); - $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->assertPurchaseReservation($purchaseId, $firstVariant->inventory_id, 1, 'active'); + $this->assertPurchaseReservation($purchaseId, $secondVariant->inventory_id, 1, 'active'); $this->assertDatabaseHas('inventories', [ 'id' => $firstVariant->inventory_id, 'reserved_stock' => 1, @@ -430,7 +478,7 @@ class StorePurchaseTest extends TestCase $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $firstVariant = $this->createVariantForTenant('sonder', 20, '50.00'); - $firstVariant->catalogItem->update(['max_units_per_user' => 3]); + $firstVariant->catalogItem->update(['max_units_per_user' => 4]); $secondInventory = Inventory::query()->create(['real_stock' => 20]); $secondVariant = Variant::query()->create([ 'catalog_item_id' => $firstVariant->catalog_item_id, @@ -443,6 +491,7 @@ class StorePurchaseTest extends TestCase ]); $cart->addItem($firstVariant->catalog_item_id, $firstVariant->id, 2); $cart->addItem($secondVariant->catalog_item_id, $secondVariant->id, 2); + $firstVariant->catalogItem->update(['max_units_per_user' => 3]); $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/compras/start-checkout', [ @@ -468,6 +517,7 @@ class StorePurchaseTest extends TestCase $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3); + $reservationId = $purchase->stock_reservation_id; $activeCart = Cart::query() ->where('user_id', $user->id) ->where('status', 'active') @@ -500,27 +550,36 @@ class StorePurchaseTest extends TestCase 'id' => $variant->inventory_id, 'reserved_stock' => 3, ]); + $activeCart->refresh(); + $this->assertSame($reservationId, $activeCart->current_stock_reservation_id); + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'stock_reservation_id' => null, + ]); $this->assertDatabaseHas('stock_reservations', [ - 'cart_item_id' => $cartItemId, - 'purchase_id' => null, - 'quantity' => 3, + 'id' => $activeCart->current_stock_reservation_id, 'status' => 'active', ]); + $this->assertDatabaseHas('stock_reservation_lines', [ + 'stock_reservation_id' => $activeCart->current_stock_reservation_id, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 3, + ]); $this->assertSame(1, $activeCart->items()->count()); } - public function test_it_reassigns_a_terminal_purchase_reservation_and_rejects_a_late_confirmation(): void + public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $previousPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); $cart = $previousPurchase->cart; + $previousReservationId = $previousPurchase->stock_reservation_id; $previousPurchase->update([ 'status' => Purchase::STATUS_PENDING_PAYMENT, 'payment_method' => 'transfer', - 'expires_at' => now()->addMinutes(30), ]); $currentPurchase = app(CheckoutService::class)->startCheckout( @@ -532,17 +591,22 @@ class StorePurchaseTest extends TestCase $this->assertDatabaseHas('compras', [ 'id' => $previousPurchase->id, 'status' => Purchase::STATUS_SUPERSEDED, - 'expires_at' => null, ]); $this->assertDatabaseHas('carritos', [ 'id' => $cart->id, 'current_purchase_id' => $currentPurchase->id, ]); - $this->assertDatabaseHas('stock_reservations', [ - 'purchase_id' => $currentPurchase->id, - 'quantity' => 2, - 'status' => 'active', + $this->assertSame($previousReservationId, $currentPurchase->stock_reservation_id); + $this->assertDatabaseHas('compras', [ + 'id' => $previousPurchase->id, + 'stock_reservation_id' => null, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $previousReservationId, + 'status' => 'active', + 'release_reason' => null, + ]); + $this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active'); try { app(CheckoutService::class)->confirmPaidPurchase($previousPurchase->fresh()); @@ -556,11 +620,7 @@ class StorePurchaseTest extends TestCase 'reserved_stock' => 2, 'sold_units' => 0, ]); - $this->assertDatabaseHas('stock_reservations', [ - 'purchase_id' => $currentPurchase->id, - 'quantity' => 2, - 'status' => 'active', - ]); + $this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active'); } public function test_removing_a_checkout_item_supersedes_the_purchase_and_restores_the_user_quota(): void @@ -589,11 +649,12 @@ class StorePurchaseTest extends TestCase 'current_purchase_id' => null, ]); $this->assertDatabaseHas('stock_reservations', [ - 'inventory_id' => $variant->inventory_id, - 'cart_item_id' => null, - 'purchase_id' => null, - 'quantity' => 0, 'status' => 'released', + 'release_reason' => 'cart_empty', + ]); + $this->assertDatabaseHas('stock_reservation_lines', [ + 'inventory_id' => $variant->inventory_id, + 'quantity' => 2, ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -607,7 +668,7 @@ class StorePurchaseTest extends TestCase $this->assertSame(3, $remaining); } - public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void + public function test_it_expires_the_purchase_and_preserves_the_expired_cart_items(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -618,7 +679,7 @@ class StorePurchaseTest extends TestCase ->where('status', 'active') ->firstOrFail(); $cartItemId = $purchase->cart->items()->firstOrFail()->id; - $purchase->update(['expires_at' => now()->subMinute()]); + $purchase->stockReservation()->update(['expires_at' => now()->subMinute()]); $this->artisan('reservations:expire')->assertSuccessful(); @@ -628,22 +689,60 @@ class StorePurchaseTest extends TestCase ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, - 'reserved_stock' => 3, + 'reserved_stock' => 0, ]); $this->assertDatabaseHas('carritos', [ 'id' => $activeCart->id, 'user_id' => $user->id, - 'status' => 'active', + 'status' => Cart::STATUS_EXPIRED, 'current_purchase_id' => null, + 'current_stock_reservation_id' => $purchase->stock_reservation_id, 'deleted_at' => null, ]); + $this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 3, 'expired'); + $this->assertSame(1, $activeCart->items()->count()); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'cart_id' => $activeCart->id, + ]) + ->assertUnprocessable() + ->assertExactJson([ + 'code' => 'stock_reservation.expired', + 'message' => __('api.cart.reservation_expired'), + ]); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertUnprocessable() + ->assertJsonPath('code', 'purchase.expired'); + } + + public function test_an_overdue_purchase_cannot_be_cancelled_before_the_expiration_job_runs(): 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, 1); + $purchase->stockReservation()->update(['expires_at' => now()->subMinute()]); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertUnprocessable() + ->assertExactJson([ + 'code' => 'stock_reservation.expired', + 'message' => __('api.cart.reservation_expired'), + ]); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_CREATED, + 'stock_reservation_id' => $purchase->stock_reservation_id, + ]); $this->assertDatabaseHas('stock_reservations', [ - 'cart_item_id' => $cartItemId, - 'purchase_id' => null, - 'quantity' => 3, + 'id' => $purchase->stock_reservation_id, 'status' => 'active', ]); - $this->assertSame(1, $activeCart->items()->count()); } public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void @@ -848,7 +947,6 @@ class StorePurchaseTest extends TestCase $purchase->update([ 'payment_method' => 'transfer', 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'expires_at' => now()->addMinutes(30), ]); $url = "/api/tenants/sonder/compras/{$purchase->id}/review"; @@ -862,6 +960,10 @@ class StorePurchaseTest extends TestCase $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, 'status' => Purchase::STATUS_IN_REVIEW, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $purchase->stock_reservation_id, + 'status' => 'active', 'expires_at' => null, ]); @@ -912,10 +1014,7 @@ class StorePurchaseTest extends TestCase 'current_purchase_id' => null, 'deleted_at' => null, ]); - $this->assertDatabaseHas('stock_reservations', [ - 'purchase_id' => $purchase->id, - 'status' => 'active', - ]); + $this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 2, 'active'); } public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void @@ -936,7 +1035,7 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_expires_an_abandoned_purchase_without_mutating_its_active_cart(): void + public function test_it_expires_a_purchase_and_its_associated_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -947,7 +1046,7 @@ class StorePurchaseTest extends TestCase ->where('status', 'active') ->firstOrFail(); - $this->assertNotNull($purchase->expires_at); + $this->assertNotNull($purchase->stockReservation->expires_at); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'reserved_stock' => 3, @@ -957,7 +1056,7 @@ class StorePurchaseTest extends TestCase $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 1') - ->expectsOutput('Expired cart items: 0') + ->expectsOutput('Expired cart reservations: 0') ->assertSuccessful(); $this->assertDatabaseHas('compras', [ @@ -968,28 +1067,26 @@ class StorePurchaseTest extends TestCase 'compra_id' => $purchase->id, 'cantidad' => 3, ]); - $this->assertDatabaseHas('stock_reservations', [ - 'purchase_id' => null, - 'quantity' => 3, - 'status' => 'active', - ]); + $this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 3, 'expired'); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'real_stock' => 10, - 'reserved_stock' => 3, + 'reserved_stock' => 0, 'sold_units' => 0, ]); $this->assertDatabaseHas('carritos', [ 'id' => $activeCart->id, 'user_id' => $user->id, - 'status' => 'active', + 'status' => Cart::STATUS_EXPIRED, + 'current_purchase_id' => null, + 'current_stock_reservation_id' => $purchase->stock_reservation_id, 'deleted_at' => null, ]); $this->assertSame(1, $activeCart->items()->count()); $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 0') - ->expectsOutput('Expired cart items: 0') + ->expectsOutput('Expired cart reservations: 0') ->assertSuccessful(); } @@ -1006,6 +1103,7 @@ class StorePurchaseTest extends TestCase 'source_catalog_item_id' => $variant->catalog_item_id, 'source_variant_id' => $variant->id, 'nombre' => 'Inconsistent item', + 'item_nombre' => 'Inconsistent item', 'slug' => 'inconsistent-item', 'cantidad' => 1, 'precio_unitario' => '50.00', @@ -1018,7 +1116,7 @@ class StorePurchaseTest extends TestCase $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 2') - ->expectsOutput('Expired cart items: 0') + ->expectsOutput('Expired cart reservations: 0') ->assertSuccessful(); $this->assertDatabaseHas('compras', [ @@ -1160,12 +1258,7 @@ 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->assertPurchaseReservation($purchase->id, $variant->inventory_id, 2, 'committed'); $this->assertSoftDeleted('carritos', [ 'id' => $purchase->cart_id, @@ -1440,6 +1533,26 @@ class StorePurchaseTest extends TestCase ]); } + protected function assertPurchaseReservation( + int $purchaseId, + int $inventoryId, + int $quantity, + string $status, + ): void { + $reservationId = Purchase::query()->findOrFail($purchaseId)->stock_reservation_id; + + $this->assertNotNull($reservationId); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservationId, + 'status' => $status, + ]); + $this->assertDatabaseHas('stock_reservation_lines', [ + 'stock_reservation_id' => $reservationId, + 'inventory_id' => $inventoryId, + 'quantity' => $quantity, + ]); + } + protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant { $hdrKey = (string) Str::uuid(); diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 5d0d971..52d4ac1 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -520,8 +520,10 @@ class AdminAppSaleControllerTest extends TestCase ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); $this->assertSoftDeleted('carritos', ['id' => $sourceCartId]); + $reservationId = $purchase->fresh()->stock_reservation_id; + $this->assertNotNull($reservationId); $this->assertDatabaseHas('stock_reservations', [ - 'purchase_id' => $purchase->id, + 'id' => $reservationId, 'status' => 'released', ]); } diff --git a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php index 22fa727..b3611d7 100644 --- a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php +++ b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php @@ -301,10 +301,13 @@ class DesfilePuraTendenciaSeederTest extends TestCase ->where('user_id', $user->id) ->where('source_catalog_item_id', $catalogItemId) ->count()); - $this->assertSame(48, DB::table('stock_reservations') - ->where('purchase_id', $purchase->id) + $this->assertSame(1, DB::table('stock_reservations') + ->where('id', $purchase->stock_reservation_id) ->where('status', 'committed') ->count()); + $this->assertSame(48, DB::table('stock_reservation_lines') + ->where('stock_reservation_id', $purchase->stock_reservation_id) + ->count()); foreach ([ ['sector' => 'A', 'fila' => '1', 'tipo' => 'NORMAL', 'count' => 16], diff --git a/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php b/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php index 4fa57b9..1abef5c 100644 --- a/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php +++ b/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php @@ -2,77 +2,102 @@ namespace Tests\Unit\Catalog; -use App\Domains\Cart\Services\ExpireCartReservationsService; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Services\ExpireStockReservationsService; -use App\Domains\Purchase\Services\CheckoutService; -use Illuminate\Support\Facades\Log; -use Psr\Log\LoggerInterface; +use App\Domains\Catalog\Services\StockReservationService; +use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; +use Illuminate\Foundation\Testing\RefreshDatabase; use RuntimeException; use Tests\TestCase; class ExpireStockReservationsServiceTest extends TestCase { - public function test_it_expires_purchases_before_abandoned_cart_items(): void + use RefreshDatabase; + + public function test_it_expires_an_orphan_reservation_and_releases_its_inventory(): void { - $checkout = \Mockery::mock(CheckoutService::class); - $checkout->shouldReceive('expireOverduePurchases') - ->once() - ->ordered() - ->andReturn(2); + $inventory = Inventory::query()->create([ + 'real_stock' => 10, + 'reserved_stock' => 2, + ]); + $reservation = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinute(), + ]); + StockReservationLine::query()->create([ + 'stock_reservation_id' => $reservation->id, + 'inventory_id' => $inventory->id, + 'quantity' => 2, + 'tracks_inventory' => true, + ]); - $carts = \Mockery::mock(ExpireCartReservationsService::class); - $carts->shouldReceive('expireOverdue') - ->once() - ->ordered() - ->andReturn(3); - - $logger = \Mockery::mock(LoggerInterface::class); - Log::shouldReceive('channel') - ->once() - ->with('commands') - ->andReturn($logger); - $logger->shouldReceive('info') - ->once() - ->with('Stock reservation cleanup completed.', [ - 'command' => 'reservations:expire', - 'expired_purchases' => 2, - 'expired_cart_items' => 3, - 'total_expired' => 5, - ]); - - $result = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue(); + $result = app(ExpireStockReservationsService::class)->expireOverdue(); $this->assertSame([ - 'purchases' => 2, - 'cart_items' => 3, + 'purchases' => 0, + 'cart_reservations' => 0, + 'orphan_reservations' => 1, + 'failed' => 0, ], $result); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservation->id, + 'status' => StockReservation::STATUS_EXPIRED, + 'expires_at' => null, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $inventory->id, + 'reserved_stock' => 0, + ]); } - public function test_it_logs_failed_cleanup_attempts_and_rethrows_the_error(): void + public function test_one_failed_reservation_does_not_stop_the_remaining_batch(): void { - $exception = new RuntimeException('Unable to clean carts.'); - $checkout = \Mockery::mock(CheckoutService::class); - $checkout->shouldReceive('expireOverduePurchases')->once()->andReturn(2); + $first = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinutes(2), + ]); + $second = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinute(), + ]); - $carts = \Mockery::mock(ExpireCartReservationsService::class); - $carts->shouldReceive('expireOverdue')->once()->andThrow($exception); + $reservations = \Mockery::mock(StockReservationService::class); + $reservations->shouldReceive('expire') + ->twice() + ->andReturnUsing(function (StockReservation $reservation) use ($first): void { + if ($reservation->is($first)) { + throw new RuntimeException('Broken reservation.'); + } - $logger = \Mockery::mock(LoggerInterface::class); - Log::shouldReceive('channel') - ->once() - ->with('commands') - ->andReturn($logger); - $logger->shouldReceive('error') - ->once() - ->with('Stock reservation cleanup failed.', [ - 'command' => 'reservations:expire', - 'expired_purchases' => 2, - 'expired_cart_items' => null, - 'exception' => $exception, - ]); + $reservation->update([ + 'status' => StockReservation::STATUS_EXPIRED, + 'expires_at' => null, + 'expired_at' => now(), + ]); + }); - $this->expectExceptionObject($exception); + $service = new ExpireStockReservationsService( + \Mockery::mock(ReleaseCheckoutService::class), + $reservations, + ); - (new ExpireStockReservationsService($checkout, $carts))->expireOverdue(); + $result = $service->expireOverdue(); + + $this->assertSame([ + 'purchases' => 0, + 'cart_reservations' => 0, + 'orphan_reservations' => 1, + 'failed' => 1, + ], $result); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $first->id, + 'status' => StockReservation::STATUS_ACTIVE, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $second->id, + 'status' => StockReservation::STATUS_EXPIRED, + ]); } } diff --git a/tests/Unit/Purchase/PurchaseStateGuardTest.php b/tests/Unit/Purchase/PurchaseStateGuardTest.php index f083fdd..9cead5d 100644 --- a/tests/Unit/Purchase/PurchaseStateGuardTest.php +++ b/tests/Unit/Purchase/PurchaseStateGuardTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Purchase; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\PurchaseStateGuard; @@ -22,7 +23,6 @@ class PurchaseStateGuardTest extends TestCase { $purchase = (new Purchase)->forceFill([ 'status' => Purchase::STATUS_EXPIRED, - 'expires_at' => null, ]); $this->expectException(PurchaseExpiredException::class); @@ -34,8 +34,11 @@ class PurchaseStateGuardTest extends TestCase { $purchase = (new Purchase)->forceFill([ 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'expires_at' => now()->subMinute(), ]); + $purchase->setRelation('stockReservation', (new StockReservation)->forceFill([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinute(), + ])); $this->expectException(PurchaseExpiredException::class); @@ -46,8 +49,11 @@ class PurchaseStateGuardTest extends TestCase { $purchase = (new Purchase)->forceFill([ 'status' => Purchase::STATUS_PAID, - 'expires_at' => now()->subMinute(), ]); + $purchase->setRelation('stockReservation', (new StockReservation)->forceFill([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinute(), + ])); $this->guard->assertNotExpired($purchase);