EscolaLMS/Cart

View on GitHub
src/Services/ProductService.php

Summary

Maintainability
F
4 days
Test Coverage
<?php

namespace EscolaLms\Cart\Services;

use EscolaLms\Cart\Contracts\Productable;
use EscolaLms\Cart\Dtos\PageDto;
use EscolaLms\Cart\Dtos\ProductSearchMyCriteriaDto;
use EscolaLms\Cart\Dtos\ProductsSearchDto;
use EscolaLms\Cart\Enums\ConstantEnum;
use EscolaLms\Cart\Enums\PeriodEnum;
use EscolaLms\Cart\Enums\ProductType;
use EscolaLms\Cart\Enums\SubscriptionStatus;
use EscolaLms\Cart\Events\ProductableAttached;
use EscolaLms\Cart\Events\ProductableDetached;
use EscolaLms\Cart\Events\ProductAttached;
use EscolaLms\Cart\Events\ProductDetached;
use EscolaLms\Cart\Models\Cart;
use EscolaLms\Cart\Models\CartItem;
use EscolaLms\Cart\Models\Product;
use EscolaLms\Cart\Models\ProductProductable;
use EscolaLms\Cart\Models\ProductUser;
use EscolaLms\Cart\Services\Contracts\ProductServiceContract;
use EscolaLms\Core\Dtos\OrderDto;
use EscolaLms\Core\Models\User;
use EscolaLms\Core\Repositories\Criteria\Criterion;
use EscolaLms\Files\Helpers\FileHelper;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use InvalidArgumentException;

class ProductService implements ProductServiceContract
{
    protected array $productables = [];
    protected array $productablesMorphs = [];

    public function registerProductableClass(string $productableClass): void
    {
        if (!is_a($productableClass, Model::class, true)) {
            throw new InvalidArgumentException(__('Productable class must represent Eloquent Model'));
        }
        if (!is_a($productableClass, Productable::class, true)) {
            throw new InvalidArgumentException(__('Class must implement Productable interface'));
        }
        if (!in_array($productableClass, $this->productables)) {
            $this->productables[] = $productableClass;
            assert(is_subclass_of($productableClass, Model::class));
            $this->productablesMorphs[$productableClass::getMorphClassStatic()] = $productableClass;
        }
    }

    public function isProductableClassRegistered(string $productableClass): bool
    {
        if (in_array($productableClass, $this->productables)) {
            return true;
        }
        $model = new $productableClass();
        assert($model instanceof Model);
        return array_key_exists($model->getMorphClass(), $this->productablesMorphs);
    }

    public function listRegisteredProductableClasses(): array
    {
        return $this->productables;
    }

    public function listRegisteredMorphClasses(): array
    {
        return $this->productablesMorphs;
    }

    public function listAllProductables(): Collection
    {
        $collection = Collection::empty();
        foreach ($this->listRegisteredProductableClasses() as $productableClass) {
            /** @var Model&Productable $model */
            $model = new $productableClass();
            $table = $model->getTable();
            $morphClass = $model->getMorphClass();

            $nameColumn = $model->getNameColumn() ? ($table . '.' . $model->getNameColumn()) : ('"' . __('Unknown') . '"');
            $productables = $model::query()->getQuery()->select(
                $table . '.id AS productable_id',
                ($nameColumn . ' AS name'),
                'products.id as single_product_id',
            )->leftJoin(
                'products_productables',
                fn (JoinClause $join) => $join
                    ->on('products_productables.productable_id', '=', $table . '.id')
                    ->where('products_productables.productable_type', '=', $morphClass)
            )->leftJoin(
                'products',
                fn (JoinClause $join) => $join
                    ->on('products.id', '=', 'products_productables.product_id')
                    ->where('products.type', '=', ProductType::SINGLE)
            )->distinct()->get();

            $resultsCollection = $productables->map(function ($row) use ($productableClass, $morphClass) {
                $row->productable_type = $productableClass;
                $row->morph_class = $morphClass;
                return $row;
            });

            $uniqueCollection = [];
            foreach ($resultsCollection as $row) {
                $existingRow = isset($uniqueCollection[$row->productable_id]);
                if (false === $existingRow) {
                    $uniqueCollection[$row->productable_id] = $row;
                } elseif ($row->single_product_id) {
                    $uniqueCollection[$row->productable_id]->single_product_id = $row->single_product_id;
                }
            }

            $collection = $collection->merge($uniqueCollection);
        }
        return $collection;
    }

    public function canonicalProductableClass(string $productableClass): ?string
    {
        if (in_array($productableClass, $this->productables)) {
            return $productableClass;
        }
        $model = new $productableClass();
        assert($model instanceof Model);
        if (array_key_exists($model->getMorphClass(), $this->productablesMorphs)) {
            return $this->productablesMorphs[$model->getMorphClass()];
        }
        throw new InvalidArgumentException(__('Unregistered Productable Class: :class', ['class' => $productableClass]));
    }

    public function findProductable(string $productableClass, $productableId): ?Productable
    {
        return $this->canonicalProductableClass($productableClass)::find($productableId);
    }

    public function findSingleProductForProductable(Productable $productable): ?Product
    {
        /** @var Product|null $product */
        $product = Product::where('type', ProductType::SINGLE)->whereHasProductable($productable)->first();
        return $product;
    }

    public function searchAndPaginateProducts(ProductsSearchDto $searchDto, ?OrderDto $orderDto = null): LengthAwarePaginator
    {
        $query = Product::query();

        if (!is_null($searchDto->getName())) {
            $query->whereRaw('LOWER(name) LIKE (?)', ['%' . Str::lower($searchDto->getName()) . '%']);
        }

        if (!is_null($searchDto->getType())) {
            $query->where('type', '=', $searchDto->getType());
        }

        if (!is_null($searchDto->getFree())) {
            if ($searchDto->getFree()) {
                $query = $query->where('price', '=', 0);
            } else {
                $query = $query->where('price', '!=', 0);
            }
        }

        if (!is_null($searchDto->getPurchasable())) {
            $query = $query->where('purchasable', '=', $searchDto->getPurchasable());
        }

        if (!is_null($searchDto->getProductableType())) {
            $class = $searchDto->getProductableType();
            /** @var Model $model */
            $model = new $class();
            if (!is_null($searchDto->getProductableId())) {
                $query = $query->whereHasProductableClassAndId($model->getMorphClass(), $searchDto->getProductableId());
            } else {
                $query = $query->whereHasProductableClass($model->getMorphClass());
            }
        }

        if (!is_null($searchDto->getTags())) {
            $query->whereHas('tags', fn (Builder $query) => $query->whereIn('title', $searchDto->getTags()));
        }

        if (!is_null($orderDto) && !is_null($orderDto->getOrder())) {
            $query = $query->orderBy($orderDto->getOrderBy(), $orderDto->getOrder());
        }

        return $query->paginate($searchDto->getPerPage() ?? 15);
    }

    public function productIsPurchasableOrOwnedByUser(Product $product, User $user): bool
    {
        return $product->purchasable || $this->productIsOwnedByUser($product, $user);
    }

    public function productIsOwnedByUser(Product $product, User $user, bool $check_productables = false): bool
    {
        return $product->users()->where('users.id', $user->getKey())->exists()
            || ($check_productables && $this->productProductablesAllOwnedByUser($product, $user));
    }

    public function productProductablesAllOwnedByUser(Product $product, User $user): bool
    {
        return Product::where('products.id', $product->getKey())->whereDoesntHaveProductablesNotOwnedByUser($user)->exists();
    }

    public function productIsBuyableByUser(Product $product, User $user, bool $check_productables = false, ?int $quantity = null): bool
    {
        $limit_per_user = $product->limit_per_user;
        $limit_total = $product->limit_total;

        $is_purchasable = $product->purchasable;

        if (is_null($quantity)) {
            $quantity = $this->productQuantityInCart($user, $product) + 1;
        }

        $is_under_limit_per_user = is_null($limit_per_user) || ($product->getOwnedByUserQuantityAttribute($user) + $quantity <= $limit_per_user);
        $is_under_limit_total = is_null($limit_total) || (($product->users_count ?? $product->users()->sum('quantity')) + $quantity <= $limit_total);
        $is_productables_buyable = !$check_productables || $this->productProductablesAllBuyableByUser($product, $user);
        Log::debug(__('Checking if product is buyable'), [
            'user' => $user->getKey(),
            'product' => [
                'id' => $product->getKey(),
                'name' => $product->name,
                'limit_per_user' => $limit_per_user,
                'limit_total' => $limit_total,
            ],
            'owned_quantity' => !is_null($limit_per_user) ? $product->getOwnedByUserQuantityAttribute($user) : 'not counted (unlimited product)',
            'purchasable' => $is_purchasable,
            'limit_per_user' => $is_under_limit_per_user,
            'limit_total' => $is_under_limit_total,
            'productables' => $is_productables_buyable,
        ]);
        return $is_purchasable && $is_under_limit_per_user && $is_under_limit_total && $is_productables_buyable;
    }

    public function productProductablesAllBuyableByUser(Product $product, User $user): bool
    {
        return Product::where('products.id', $product->getKey())->whereDoesntHaveProductablesNotBuyableByUser($user)->exists();
    }

    /**
     * Maps productable to JsonResource
     * Returns (almost) empty JsonResource if productable does not exist in database anymore
     *
     * @see \EscolaLms\Cart\Http\Resources\ProductableGenericResource
     */
    public function mapProductProductableToJsonResource(ProductProductable $productProductable): JsonResource
    {
        $productable = $this->findProductable($productProductable->productable_type, $productProductable->productable_id);
        if ($productable) {
            return $productable->toJsonResourceForShop($productProductable);
        }
        return new JsonResource([
            'id' => null,
            'morph_class' => null,
            'productable_id' => $productProductable->productable_id,
            'productable_type' => $productProductable->productable_type,
            'quantity' => $productProductable->quantity,
            'name' => null,
            'description' => null,
        ]);
    }

    public function create(array $data): Product
    {
        return $this->update(new Product(), $data);
    }

    public function update(Product $product, array $data): Product
    {
        if (ProductType::isSubscriptionType($product->type) && !ProductType::isSubscriptionType($data['type'])) {
            throw new Exception(__('Product with subscription type cannot have type changed'));
        }

        if (
            ProductType::isSubscriptionType($product->type) &&
            (
                $product->subscription_period !== $data['subscription_period']
                || $product->subscription_duration !== $data['subscription_duration']
                || $product->recursive !== $data['recursive']
            )
        ) {
            throw new Exception(__('Subscription fields cannot be edited'));
        }

        if (
            ($product->type === ProductType::SUBSCRIPTION_ALL_IN || (isset($data['type']) && $data['type'] === ProductType::SUBSCRIPTION_ALL_IN))
            && !empty($data['productables'])
        ) {
            throw new Exception(__('Products cannot be assigned to all-in subscription type.'));
        }

        $relatedProducts = $data['related_products'] ?? null;
        unset($data['related_products']);

        $poster = $data['poster'] ?? null;
        unset($data['poster']);

        $productables = $data['productables'] ?? null;
        unset($data['productables']);

        $categories = $data['categories'] ?? null;
        unset($data['categories']);

        $tags = $data['tags'] ?? null;
        unset($data['tags']);

        if (($data['type'] ?? $product->type ?? ProductType::SINGLE)  === ProductType::SINGLE && !empty($productables)) {
            if (count($productables) > 1) {
                throw new Exception(__('Product with type SINGLE can contain only one single Productable'));
            }
            if (count($productables) === 1) {
                $singleProductable = $this->findSingleProductForProductable($this->findProductable($productables[0]['class'], $productables[0]['id']));
                if ($singleProductable && $singleProductable->getKey() !== $product->getKey()) {
                    throw new Exception(
                        __(
                            'Only one Product with type SINGLE can exist for Productable :productable_type:::productable_id',
                            [
                                'productable_type' => $productables[0]['class'],
                                'productable_id' => $productables[0]['id']
                            ]
                        )
                    );
                }
            }
        }

        $product->fill($data);
        $product->save();
        $product->refresh();

        if ($poster) {
            $product->poster_url = FileHelper::getFilePath($poster, ConstantEnum::DIRECTORY . "/{$product->getKey()}/posters");
            $product->save();
        }

        if (!is_null($productables)) {
            $this->saveProductProductables($product, $productables);
        }
        if ($product->type === ProductType::SINGLE && $product->productables->count() > 1) {
            $productables = $product->productables;
            $firstProductable = $productables->shift(1);
            $productables->each(fn (ProductProductable $productProductable) => $productProductable->delete());
        }

        if (!is_null($categories)) {
            $product->categories()->sync($categories);
        }

        if (!is_null($tags)) {
            $product->tags()->delete();

            $tags = array_map(function ($tag) {
                return ['title' => $tag];
            }, $tags);

            $product->tags()->createMany($tags);
        }

        if (!is_null($relatedProducts)) {
            $product->relatedProducts()->sync($relatedProducts);
        }

        return $product;
    }

    private function saveProductProductables(Product $product, array $productables)
    {
        if ($product->type === ProductType::SINGLE && count($productables) > 1) {
            throw new Exception(__('Product with type SINGLE can contain only one single Productable'));
        }

        $productablesCollection = (new Collection($productables))->keyBy('id');

        foreach ($product->productables as $existingProductable) {
            $productableInUpdateData = $productablesCollection->first(
                fn (array $productable) => $productable['id'] === $existingProductable->productable_id
                    && $this->canonicalProductableClass($productable['class']) === $this->canonicalProductableClass($existingProductable->productable_type)
            );

            if (is_null($productableInUpdateData)) {
                $existingProductable->delete();
            } else {
                $existingProductable->quantity = ($product->type === ProductType::SINGLE) ? 1 : ($productableInUpdateData['quantity'] ?? 1);
                if ($existingProductable->isDirty('quantity')) {
                    $existingProductable->save();
                }
                $productablesCollection->forget($productableInUpdateData['id']);
            }
        }

        foreach ($productablesCollection as $newProductable) {
            $class = $this->canonicalProductableClass($newProductable['class']);
            $model = new $class();

            assert($model instanceof Model);

            $product->productables()->save(new ProductProductable([
                'productable_id' => $newProductable['id'],
                'productable_type' => $model->getMorphClass(),
                'quantity' => $product->type === ProductType::SINGLE ? 1 : ($newProductable['quantity'] ?? 1),
            ]));
        }

        $product->load('productables');
    }

    public function attachProductToUser(Product $product, User $user, int $quantity = 1): void
    {
        Log::debug(__('Attaching product to user'), [
            'product' => [
                'id' => $product->getKey(),
                'name' => $product->name,
                'limit_per_user' => $product->limit_per_user,
            ],
            'user' => [
                'id' => $user->getKey(),
                'email' => $user->email,
            ],
        ]);

        if (!is_null($product->limit_per_user) && $product->limit_per_user < $quantity) {
            $quantity = $product->limit_per_user;
        }

        if ($quantity === 0) {
            return;
        }

        $productUserPivot = ProductUser::query()->firstOrCreate(['user_id' => $user->getKey(), 'product_id' => $product->getKey()], ['quantity' => $quantity]);

        if (!$productUserPivot->wasRecentlyCreated) {
            if (!is_null($product->limit_per_user)) {
                if ($product->limit_per_user < ($productUserPivot->quantity + $quantity)) {
                    $quantity = $product->limit_per_user - $productUserPivot->quantity;
                }
                $productUserPivot->quantity += $quantity;
            }

            if (ProductType::isSubscriptionType($product->type)) {
                // @phpstan-ignore-next-line
                $productUserPivot->end_date = PeriodEnum::calculatePeriod($productUserPivot->end_date, $product->subscription_period, $product->subscription_duration);
                $productUserPivot->status = SubscriptionStatus::ACTIVE;
            }

            $productUserPivot->save();
        }

        if (ProductType::isSubscriptionType($product->type) && $productUserPivot->wasRecentlyCreated) {
            $hasSubscription = ProductUser::query()
                ->where('user_id', $user->getKey())
                ->whereRelation('product', 'type', 'in', ProductType::subscriptionTypes())
                ->where('product_id', '!=', $product->getKey())
                ->exists();

            $endDate = PeriodEnum::calculatePeriod(Carbon::now(), $product->subscription_period, $product->subscription_duration);

            if ($product->has_trial && !$hasSubscription) {
                $endDate = PeriodEnum::calculatePeriod(Carbon::now(), $product->trial_period, $product->trial_duration);
            }

            ProductUser::query()->updateOrCreate(
                ['user_id' => $user->getKey(), 'product_id' => $product->getKey()],
                ['quantity' => 1, 'end_date' => $endDate, 'status' => SubscriptionStatus::ACTIVE]
            );
        }

        if ($quantity === 0) {
            return;
        }

        foreach ($product->productables as $productProductable) {
            Log::debug(__('Checking if productable can be processed'));
            if ($this->isProductableClassRegistered($productProductable->productable_type)) {
                $productable = $this->findProductable($productProductable->productable_type, $productProductable->productable_id);
                if (is_null($productable)) {
                    Log::debug([
                        'product' => [
                            'id' => $product->getKey(),
                            'name' => $product->name,
                            'extended_model' => $productProductable->productable_type,
                            'extended_model_id' => $productProductable->productable_id,
                        ],
                        'message' => __('Attached product is not exists')
                    ]);
                    throw new Exception(__('Attached product is not exists'));
                }
                $this->attachProductableToUser($productable, $user, $productProductable->quantity * $quantity, $product);
            }
        }
        event(new ProductAttached($product, $user, $quantity));
    }

    public function detachProductFromUser(Product $product, User $user, int $quantity = 1): void
    {
        $productUserPivot = ProductUser::where(['user_id' => $user->getKey(), 'product_id' => $product->getKey()])->first();

        if (!$productUserPivot) {
            return;
        }

        $new_quantity = $productUserPivot->quantity - $quantity;
        if ($new_quantity < 0) {
            $new_quantity = 0;
            $quantity = $productUserPivot->quantity;
        }

        if ($quantity === 0) {
            return;
        }

        if ($new_quantity === 0) {
            $productUserPivot->delete();
        } else {
            $productUserPivot->quantity = $new_quantity;
            $productUserPivot->save();
        }

        foreach ($product->productables as $productProductable) {
            if ($this->isProductableClassRegistered($productProductable->productable_type)) {
                $productable = $this->findProductable($productProductable->productable_type, $productProductable->productable_id);
                $this->detachProductableFromUser($productable, $user, $productProductable->quantity * $quantity, $product);
            }
        }

        event(new ProductDetached($product, $user, $quantity));
    }

    public function attachProductableToUser(Productable $productable, User $user, int $quantity = 1, ?Product $product = null): void
    {
        Log::debug(__('Attaching productable to user'), [
            'productable' => [
                'id' => $productable->getKey(),
                'name' => $productable->getName(),
            ],
            'user' => [
                'id' => $user->getKey(),
                'email' => $user->email,
            ],
            'quantity' => $quantity,
        ]);
        assert($productable instanceof Model);
        try {
            $productable->attachToUser($user, $quantity, $product);
            Log::debug(
                'Productable (should be) attached to user.',
                [
                    'productable_owned' => $productable->getOwnedByUserAttribute($user),
                    'productable_owned_through_product' => $this->productableIsOwnedByUserThroughProduct($productable, $user),
                ]
            );
        } catch (Exception $ex) {
            Log::error(__('Failed to attach productable to user'), [
                'exception' => $ex->getMessage(),
            ]);
        }
        event(new ProductableAttached($productable, $user, $quantity));
    }

    public function detachProductableFromUser(Productable $productable, User $user, int $quantity = 1, ?Product $product = null): void
    {
        assert($productable instanceof Model);
        $productable->detachFromUser($user, $quantity, $product);
        event(new ProductableDetached($productable, $user, $quantity));
    }

    public function productableIsOwnedByUserThroughProduct(Productable $productable, User $user): bool
    {
        return Product::query()->whereHasProductable($productable)->whereHasUser($user)->exists();
    }

    public function canDetachProductableFromUser(Productable $productable, User $user): bool
    {
        return !$this->productableIsOwnedByUserThroughProduct($productable, $user);
    }

    public function searchMy(ProductSearchMyCriteriaDto $dto, PageDto $pageDto, OrderDto $orderDto): LengthAwarePaginator
    {
        $query = Product::query();

        foreach ($dto->toArray() as $criterion) {
            if ($criterion instanceof Criterion) {
                $query = $criterion->apply($query);
            }
        }

        return $query
            ->orderBy($orderDto->getOrderBy() ?? 'id', $orderDto->getOrder() ?? 'desc')
            ->paginate($pageDto->getPerPage());
    }

    private function productQuantityInCart(User $user, Product $product): int
    {
        /** @var Cart $cart */
        $cart = Cart::where('user_id', $user->getAuthIdentifier())->latest()->firstOrCreate([
            'user_id' => $user->getAuthIdentifier(),
        ]);
        /** @var CartItem|null $cartItem */
        $cartItem = $cart
            ->items()
            ->whereHas('buyable', fn (Builder $query) => $query->where('products.id', '=', $product->getKey()))
            ->first();

        return !is_null($cartItem) ? $cartItem->quantity : 0;
    }

    public function hasActiveSubscriptionAllIn(User $user): ?Product
    {
        /** @var Product $product */
        $product = Product::query()->whereHasUserWithProductType($user, ProductType::SUBSCRIPTION_ALL_IN)->first();

        return $product;
    }

    public function getRecursiveProductUserBeforeExpiredEndDate(Carbon $start, Carbon $end): Collection
    {
        return ProductUser::query()
            ->whereRelation('product', 'recursive', '=', true)
            ->where('status', SubscriptionStatus::ACTIVE)
            ->whereBetween('end_date', [$start, $end])
            ->get();
    }

    public function cancelActiveRecursiveProduct(Product $product, User $user): void
    {
        ProductUser::query()
            ->where('user_id', $user->getKey())
            ->where('product_id', $product->getKey())
            ->where('status', SubscriptionStatus::ACTIVE)
            ->update(['status' => SubscriptionStatus::CANCELLED]);
    }
}