NikolaGavric94/laravel-square

View on GitHub
src/utils/Util.php

Summary

Maintainability
A
2 hrs
Test Coverage
A
93%
<?php

namespace Nikolag\Square\Utils;

use Exception;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Nikolag\Square\Models\Product;
use stdClass;

class Util
{
    /**
     * Calculates order total based on orderCopy (stdClass of Model).
     *
     * @param  stdClass  $orderCopy
     * @return float|int
     */
    public static function calculateTotalOrderCost(stdClass $orderCopy): float|int
    {
        return self::_calculateTotalCost($orderCopy->discounts, $orderCopy->taxes, $orderCopy->products);
    }

    /**
     * Calculate all discounts on order level no matter
     * their scope.
     *
     * @param  Collection  $discounts
     * @param  float  $noDeductiblesCost
     * @param  Collection  $products
     * @return float|int
     */
    private static function _calculateDiscounts(Collection $discounts, float $noDeductiblesCost, Collection $products): float|int
    {
        $totalDiscount = 0;
        if ($discounts->isNotEmpty() && $products->isNotEmpty()) {
            $totalDiscount = $discounts->map(function ($discount) use ($products, $noDeductiblesCost) {
                if ((! $discount->pivot && $discount->scope === Constants::DEDUCTIBLE_SCOPE_PRODUCT) ||
                    ($discount->pivot && $discount->pivot->scope === Constants::DEDUCTIBLE_SCOPE_PRODUCT)) {
                    return self::_calculateProductDiscounts($products, $discount);
                } elseif ((! $discount->pivot && $discount->scope === Constants::DEDUCTIBLE_SCOPE_ORDER) ||
                        ($discount->pivot && $discount->pivot->scope === Constants::DEDUCTIBLE_SCOPE_ORDER)) {
                    return self::_calculateOrderDiscounts($discount, $noDeductiblesCost);
                }

                return 0;
            })->pipe(function ($total) {
                return $total->sum();
            });
        }

        return $totalDiscount;
    }

    /**
     * Function which calculates the net price by removing any additive taxes to the entire order.
     *
     * @param  float  $discountCount
     * @param  Collection  $inclusiveTaxes
     * @return float|int
     */
    private static function _calculateNetPrice(float $discountCost, Collection $inclusiveTaxes): float|int
    {
        // Get all the inclusive taxes
        $inclusiveTaxPercent = $inclusiveTaxes->filter(function ($tax) {
            return $tax->type === Constants::TAX_INCLUSIVE;
        })->map(function ($tax) {
            return $tax->percentage;
        })->pipe(function ($total) {
            return $total->sum();
        }) / 100;

        // Calculate the net price (amount without inclusive tax)
        $netPrice = $discountCost / (1 + $inclusiveTaxPercent);

        return $netPrice;
    }

    /**
     * Function which calculates discounts on order level and where percentage
     * takes over precedence over flat amount.
     *
     * @param  $discount
     * @param  float  $noDeductiblesCost
     * @return float|int
     */
    private static function _calculateOrderDiscounts($discount, float $noDeductiblesCost): float|int
    {
        return ($discount->percentage) ? ($noDeductiblesCost * $discount->percentage / 100) :
            $discount->amount;
    }

    /**
     * Function which calculates discounts on product level and where percentage
     * takes over precedence over flat amount.
     *
     * @param  $products
     * @param  $discount
     * @return float|int
     */
    private static function _calculateProductDiscounts($products, $discount): float|int
    {
        $product = $products->first(function ($product) use ($discount) {
            return $product->pivot->discounts->contains($discount) || $product->discounts->contains($discount);
        });

        if ($product) {
            return ($discount->percentage) ? ($product->price * $product->pivot->quantity * $discount->percentage / 100) :
                $discount->amount;
        } else {
            return 0;
        }
    }

    /**
     * Function which calculates taxes on product level.
     *
     * @param  $products
     * @param  $tax
     * @param  Collection  $inclusiveTaxes
     * @param  Collection  $discounts
     * @return float|int
     */
    private static function _calculateProductTaxes($products, $tax, Collection $inclusiveTaxes, Collection $discounts): float|int
    {
        $product = $products->first(function ($product) use ($tax) {
            return $product->pivot->taxes->contains($tax) || $product->taxes->contains($tax);
        });

        if ($product) {
            // Get the total product cost (price * quantity)
            $totalCost = $product->price * $product->pivot->quantity;

            // Calculate order discounts as this will impact the taxes calculated
            $discountCost = $totalCost - self::_calculateDiscounts($discounts, $totalCost, $products);

            $netPrice = self::_calculateNetPrice($discountCost, $inclusiveTaxes);

            // Calculate and round the product taxes
            $productTaxes = $netPrice * ($tax->percentage / 100);

            return round($productTaxes);
        } else {
            return 0;
        }
    }

    /**
     * Function which calculates taxes on order level.
     *
     * @param  float  $discountCost
     * @param  $tax
     * @param  Collection  $inclusiveTaxes
     * @return float|int
     */
    private static function _calculateOrderTaxes(float $discountCost, $tax, Collection $inclusiveTaxes): float|int
    {
        // Calculate the net price (amount without inclusive tax)
        $netPrice = self::_calculateNetPrice($discountCost, $inclusiveTaxes);

        // Get the order taxes
        $orderTaxes = $netPrice * $tax->percentage / 100;

        return round($orderTaxes);
    }

    /**
     * Calculate all taxes on order level no matter
     * their scope, type of ADDITIVE.
     *
     * @param  Collection  $taxes
     * @param  float  $discountCost
     * @param  Collection  $products
     * @param  Collection  $discounts
     * @return float|int
     */
    private static function _calculateTaxes(Collection $taxes, float $discountCost, Collection $products, Collection $discounts): float|int
    {
        // If there are no taxes or products, return 0
        if ($taxes->isEmpty() || $products->isEmpty()) {
            return 0;
        }

        // Get all the inclusive taxes
        $inclusiveTaxes = $taxes->filter(function ($tax) {
            return $tax->type === Constants::TAX_INCLUSIVE;
        });

        return $taxes->filter(function ($tax) {
            return $tax->type === Constants::TAX_ADDITIVE;
        })->map(function ($taxTwo) use ($products, $discountCost, $discounts, $inclusiveTaxes) {
            $isProductScope = $taxTwo->scope === Constants::DEDUCTIBLE_SCOPE_PRODUCT;
            $isOrderScope = $taxTwo->scope === Constants::DEDUCTIBLE_SCOPE_ORDER;

            if ($taxTwo->pivot) {
                $isProductScope = $taxTwo->pivot->scope === Constants::DEDUCTIBLE_SCOPE_PRODUCT;
                $isOrderScope = $taxTwo->pivot->scope === Constants::DEDUCTIBLE_SCOPE_ORDER;
            }

            // Calculate taxes based on scope
            if ($isProductScope) {
                $calculatedTaxes = self::_calculateProductTaxes($products, $taxTwo, $inclusiveTaxes, $discounts);
            } elseif ($isOrderScope) {
                $calculatedTaxes = self::_calculateOrderTaxes($discountCost, $taxTwo, $inclusiveTaxes);
            } else {
                $calculatedTaxes = 0;
            }

            return $calculatedTaxes;
        })->sum();
    }

    /**
     * Calculate total order cost.
     *
     * @param  Collection  $discounts
     * @param  Collection  $taxes
     * @param  Collection  $products
     * @return float|int
     */
    private static function _calculateTotalCost(Collection $discounts, Collection $taxes, Collection $products): float|int
    {
        $lineItemDiscounts = collect([]);
        $lineItemTaxes = collect([]);
        $orderDiscounts = collect([]);
        $orderTaxes = collect([]);

        // Calculate order level discounts scoped with either ORDER or LINE_ITEM
        if ($discounts->isNotEmpty()) {
            $lineItemDiscounts = self::_filterElements(Constants::DEDUCTIBLE_SCOPE_PRODUCT, $discounts);
            $orderDiscounts = self::_filterElements(Constants::DEDUCTIBLE_SCOPE_ORDER, $discounts);
        }
        $allDiscounts = $lineItemDiscounts->flatten()->merge($orderDiscounts->flatten())->flatten();

        // Calculate order level taxes scoped with either ORDER or LINE_ITEM
        if ($taxes->isNotEmpty()) {
            $lineItemTaxes = self::_filterElements(Constants::DEDUCTIBLE_SCOPE_PRODUCT, $taxes);
            $orderTaxes = self::_filterElements(Constants::DEDUCTIBLE_SCOPE_ORDER, $taxes);
        }
        $allTaxes = $lineItemTaxes->merge($orderTaxes)->flatten();

        // Calculate base total
        if ($products->isEmpty()) {
            throw new Exception('Total cost cannot be calculated without products.');
        }

        $noDeductiblesCost = $products->map(function ($product) {
            return $product->price * $product->pivot->quantity;
        })->pipe(function ($total) {
            return $total->sum();
        });

        // Calculate cost based on discounts
        $discountCost = $noDeductiblesCost - self::_calculateDiscounts($allDiscounts, $noDeductiblesCost, $products);

        // Calculate cost based on taxes
        $finalCost = $discountCost + self::_calculateTaxes($allTaxes, $discountCost, $products, $allDiscounts);

        return $finalCost;
    }

    /**
     * Filter elements based on scope and collection of elements.
     *
     * @param  string  $scope  Scope of elements, can be one of: [Constants::DEDUCTIBLE_SCOPE_ORDER, Constants::DEDUCTIBLE_SCOPE_PRODUCT]
     * @param  Collection  $collection  A collection of elements
     */
    private static function _filterElements(string $scope, Collection $collection): Collection
    {
        return $collection->filter(function ($obj) use ($scope) {
            return ($obj->pivot && $obj->pivot->scope === $scope) || $obj->scope === $scope;
        });
    }

    /**
     * Calculates order total based on Model.
     *
     * @param  Model  $order
     * @return float|int
     */
    public static function calculateTotalOrderCostByModel(Model $order): float|int
    {
        return self::_calculateTotalCost($order->discounts, $order->taxes, $order->products);
    }

    /**
     * Check if source has product.
     *
     * @param  \Illuminate\Database\Eloquent\Collection|Collection  $source
     * @param  int|array|Product|null  $product
     * @return bool
     */
    public static function hasProduct(\Illuminate\Database\Eloquent\Collection|Collection $source, Product|int|array|null $product): bool
    {
        // Check if $product is either int, Model or array
        if (is_a($product, Product::class)) {
            return $source->contains($product);
        } elseif (is_array($product)) {
            if (array_key_exists('id', $product)) {
                return $source->contains(Product::find($product['id']));
            } elseif (array_key_exists('name', $product)) {
                return $source->contains(Product::where('name', $product['name'])->first());
            }
        } elseif (is_int($product)) {
            return $source->contains(Product::find($product));
        }

        return false;
    }

    /**
     * Generate random alphanumeric string of supplied length or 30 by default.
     *
     * @param  int  $length
     * @return string
     *
     * @throws \Exception
     */
    public static function uid(int $length = 30): string
    {
        return bin2hex(random_bytes($length));
    }
}