resolveIdentity($request); if ($resolvedIdentity === null) { return $this->makeEmptyCart($tenant); } $cart = $this->resolveCart($tenant, $resolvedIdentity['identity']); if ($cart === null) { return $this->makeEmptyCart($tenant); } return $this->loadCart($cart, $tenant); } /** * @return array{cart: Cart, guest_token: ?string} */ public function addItem( Tenant $tenant, Request $request, int $catalogItemId, ?int $variantId, int $quantity, ): array { $resolvedIdentity = $this->resolveIdentity($request, true); $identity = $resolvedIdentity['identity']; $cart = $this->findOrCreateCart($tenant, $identity); $cart->addItem($catalogItemId, $variantId, $quantity); return [ 'cart' => $this->loadCart($cart, $tenant), 'guest_token' => $resolvedIdentity['generated_guest_token'], ]; } public function updateItem( Tenant $tenant, Request $request, int $cartItemId, int $quantity, ?int $variantId, bool $updateVariant, ): Cart { if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) { throw ValidationException::withMessages([ 'variant_id' => __('api.cart.variant_change_disabled'), ]); } if (! $updateVariant && ! $tenant->cart_editing_policy->allowsQuantityChanges()) { throw ValidationException::withMessages([ 'cantidad' => __('api.cart.editing_disabled'), ]); } $identity = $this->requireIdentity($request); $cart = $this->findCartOrFail($tenant, $identity); $cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant); return $this->loadCart($cart, $tenant); } public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart { if (! $tenant->cart_editing_policy->allowsRemoval()) { throw ValidationException::withMessages([ 'cart_item' => __('api.cart.editing_disabled'), ]); } $identity = $this->requireIdentity($request); $cart = $this->findCartOrFail($tenant, $identity); $cart->removeItem($cartItemId); return $this->loadCart($cart, $tenant); } public function makeGuestTokenCookie(string $guestToken): Cookie { $secure = (bool) config('session.secure'); $sameSite = config('session.same_site'); $partitioned = (bool) config('session.partitioned') || ($secure && strtolower((string) $sameSite) === 'none'); return Cookie::create( name: 'guest_token', value: $guestToken, expire: now()->addDays(180), path: '/', domain: config('session.domain'), secure: $secure, httpOnly: true, raw: false, sameSite: $sameSite, partitioned: $partitioned, ); } protected function makeEmptyCart(Tenant $tenant): Cart { $cart = new Cart([ 'tenant_codigo' => $tenant->codigo, 'status' => Cart::STATUS_ACTIVE, ]); $cart->setRelation('items', collect()); return $cart; } protected function loadCart(Cart $cart, Tenant $tenant): Cart { $relations = [ 'items.catalogItem.attachments', 'items.catalogItem.inventory', 'items.catalogItem.itemAttributes.attribute', 'items.variant.attachments', 'items.variant.inventory', 'items.variant.definitions.itemAttribute.attribute.options', 'items.variant.eventDates', 'items.variant.eventDate', ]; if ($tenant->cart_editing_policy->allowsVariantChanges()) { $relations = [ ...$relations, 'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'), 'items.catalogItem.variants.inventory', 'items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'), 'items.catalogItem.variants.definitions.itemAttribute.attribute.options', 'items.catalogItem.variants.eventDates', 'items.catalogItem.variants.eventDate', ]; } return $cart->fresh()->load($relations); } /** * @return array{identity: array{user_id: ?int, guest_token: ?string}, generated_guest_token: ?string}|null */ protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array { $user = $request->user() ?? Auth::guard('sanctum')->user(); if ($user instanceof User) { return [ 'identity' => [ 'user_id' => $user->getKey(), 'guest_token' => null, ], 'generated_guest_token' => null, ]; } $guestToken = $request->cookie('guest_token'); if (is_string($guestToken) && $guestToken !== '') { return [ 'identity' => [ 'user_id' => null, 'guest_token' => $guestToken, ], 'generated_guest_token' => null, ]; } if (! $generateGuestToken) { return null; } $generatedGuestToken = (string) Str::uuid(); return [ 'identity' => [ 'user_id' => null, 'guest_token' => $generatedGuestToken, ], 'generated_guest_token' => $generatedGuestToken, ]; } /** * @return array{user_id: ?int, guest_token: ?string} */ protected function requireIdentity(Request $request): array { $resolvedIdentity = $this->resolveIdentity($request); if ($resolvedIdentity === null) { throw new NotFoundHttpException('Cart not found.'); } return $resolvedIdentity['identity']; } /** * @param array{user_id: ?int, guest_token: ?string} $identity */ protected function findCart(Tenant $tenant, array $identity): ?Cart { return Cart::query() ->where('tenant_codigo', $tenant->codigo) ->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(); } /** * @param array{user_id: ?int, guest_token: ?string} $identity */ protected function findCartOrFail(Tenant $tenant, array $identity): Cart { $cart = $this->resolveCart($tenant, $identity, replaceExpired: false); if ($cart === null) { throw new NotFoundHttpException('Cart not found.'); } return $cart; } /** * @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' => Cart::STATUS_ACTIVE, 'origin' => Cart::ORIGIN_USER, ]; if ($identity['user_id'] !== null) { $attributes['user_id'] = $identity['user_id']; } else { $attributes['guest_token'] = $identity['guest_token']; } return Cart::query()->firstOrCreate($attributes); } }