diff --git a/packages/Webkul/CartRule/src/Helpers/CartRule.php b/packages/Webkul/CartRule/src/Helpers/CartRule.php index 08c6b05b4..2ea493156 100644 --- a/packages/Webkul/CartRule/src/Helpers/CartRule.php +++ b/packages/Webkul/CartRule/src/Helpers/CartRule.php @@ -134,11 +134,14 @@ class CartRule */ public function getCartRules() { - static $cartRules; - - if ($cartRules) { - return $cartRules; + $staticCartRules = new class() { + public static $cartRules; + public static $cartID; + }; + if ($staticCartRules::$cartID === cart()->getCart()->id && $staticCartRules::$cartRules) { + return $staticCartRules::$cartRules; } + $staticCartRules::$cartID = cart()->getCart()->id; $customerGroupId = null; @@ -166,6 +169,7 @@ class CartRule ->orderBy('sort_order', 'asc'); })->findWhere(['status' => 1]); + $staticCartRules::$cartRules = $cartRules; return $cartRules; } @@ -246,6 +250,10 @@ class CartRule continue; } + if ($rule->coupon_code) { + $item->coupon_code = $rule->coupon_code; + } + $quantity = $rule->discount_quantity ? min($item->quantity, $rule->discount_quantity) : $item->quantity; $discountAmount = $baseDiscountAmount = 0; @@ -254,9 +262,9 @@ class CartRule case 'by_percent': $rulePercent = min(100, $rule->discount_amount); - $discountAmount = ($quantity * $item->price - $item->discount_amount) * ($rulePercent / 100); + $discountAmount = ($quantity * $item->price + $item->tax_amount - $item->discount_amount) * ($rulePercent / 100); - $baseDiscountAmount = ($quantity * $item->base_price - $item->base_discount_amount) * ($rulePercent / 100); + $baseDiscountAmount = ($quantity * $item->base_price + $item->base_tax_amount - $item->base_discount_amount) * ($rulePercent / 100); if (! $rule->discount_quantity || $rule->discount_quantity > $quantity) { $discountPercent = min(100, $item->discount_percent + $rulePercent); @@ -316,8 +324,16 @@ class CartRule break; } - $item->discount_amount = min($item->discount_amount + $discountAmount, $item->price * $quantity + $item->tax_amount); - $item->base_discount_amount = min($item->base_discount_amount + $baseDiscountAmount, $item->base_price * $quantity + $item->base_tax_amount); + $item->discount_amount = round( + min( + $item->discount_amount + $discountAmount, + $item->price * $quantity + $item->tax_amount + ),2); + $item->base_discount_amount = round( + min( + $item->base_discount_amount + $baseDiscountAmount, + $item->base_price * $quantity + $item->base_tax_amount + ), 2); $appliedRuleIds[$rule->id] = $rule->id; diff --git a/packages/Webkul/CartRule/src/Listeners/Order.php b/packages/Webkul/CartRule/src/Listeners/Order.php index e51b8e0fa..b52e1e8b0 100755 --- a/packages/Webkul/CartRule/src/Listeners/Order.php +++ b/packages/Webkul/CartRule/src/Listeners/Order.php @@ -19,14 +19,14 @@ class Order /** * CartRuleCustomerRepository object * - * @var Webkul\CartRule\Repositories\\CartRuleCustomerRepository + * @var \Webkul\CartRule\Repositories\CartRuleCustomerRepository */ protected $cartRuleCustomerRepository; /** * CartRuleCouponRepository object * - * @var Webkul\CartRule\Repositories\\CartRuleCouponRepository + * @var \Webkul\CartRule\Repositories\CartRuleCouponRepository */ protected $cartRuleCouponRepository; @@ -64,7 +64,7 @@ class Order /** * Save cart rule and cart rule coupon properties after place order - * + * * @param \Webkul\Sales\Contracts\Order $order * @return void */ @@ -112,7 +112,7 @@ class Order } $coupon = $this->cartRuleCouponRepository->findOneByField('code', $order->coupon_code); - + if ($coupon) { $this->cartRuleCouponRepository->update(['times_used' => $coupon->times_used + 1], $coupon->id); diff --git a/packages/Webkul/CartRule/src/Repositories/CartRuleRepository.php b/packages/Webkul/CartRule/src/Repositories/CartRuleRepository.php index 0f277afcb..cd7507a5d 100755 --- a/packages/Webkul/CartRule/src/Repositories/CartRuleRepository.php +++ b/packages/Webkul/CartRule/src/Repositories/CartRuleRepository.php @@ -37,7 +37,7 @@ class CartRuleRepository extends Repository /** * CartRuleCouponRepository object * - * @var Webkul\CartRule\Repositories\CartRuleCouponRepository + * @var \Webkul\CartRule\Repositories\CartRuleCouponRepository */ protected $cartRuleCouponRepository; diff --git a/packages/Webkul/Checkout/src/Cart.php b/packages/Webkul/Checkout/src/Cart.php index a2a343a16..947d68ef1 100755 --- a/packages/Webkul/Checkout/src/Cart.php +++ b/packages/Webkul/Checkout/src/Cart.php @@ -436,20 +436,19 @@ class Cart * * @return \Webkul\Checkout\Contracts\Cart|null */ - public function getCart() + public function getCart(): ?\Webkul\Checkout\Contracts\Cart { - $cart = null; - if ($this->getCurrentCustomer()->check()) { - $cart = $this->cartRepository->findOneWhere([ + return $this->cartRepository->findOneWhere([ 'customer_id' => $this->getCurrentCustomer()->user()->id, 'is_active' => 1, ]); + } elseif (session()->has('cart')) { - $cart = $this->cartRepository->find(session()->get('cart')->id); + return $this->cartRepository->find(session()->get('cart')->id); } - return $cart && $cart->is_active ? $cart : null; + return null; } /** @@ -483,7 +482,8 @@ class Cart * * @param array $data * - * @return void is the cart valid + * @return bool + * @throws \Prettus\Validator\Exceptions\ValidatorException */ public function saveCustomerAddress($data): bool { @@ -633,6 +633,7 @@ class Cart } else { foreach ($cart->items as $item) { $response = $item->product->getTypeInstance()->validateCartItem($item); + // ToDo: refactoring of all validateCartItem functions, at the moment they return nothing if ($response) { return; @@ -741,7 +742,7 @@ class Cart * @param \Webkul\Checkout\Contracts\CartItem $item * @return \Webkul\Checkout\Contracts\CartItem */ - protected function setItemTaxToZero(CartItem $item): CartItem + protected function setItemTaxToZero(\Webkul\Checkout\Contracts\CartItem $item): \Webkul\Checkout\Contracts\CartItem { $item->tax_percent = 0; $item->tax_amount = 0; @@ -755,7 +756,7 @@ class Cart * * @return bool */ - public function hasError() + public function hasError(): bool { if (! $this->getCart()) { return true; @@ -773,7 +774,7 @@ class Cart * * @return bool */ - public function isItemsHaveSufficientQuantity() + public function isItemsHaveSufficientQuantity(): bool { foreach ($this->getCart()->items as $item) { if (! $this->isItemHaveQuantity($item)) { @@ -790,7 +791,7 @@ class Cart * @param \Webkul\Checkout\Contracts\CartItem $item * @return bool */ - public function isItemHaveQuantity($item) + public function isItemHaveQuantity($item): bool { return $item->product->getTypeInstance()->isItemHaveQuantity($item); } @@ -800,7 +801,7 @@ class Cart * * @return void */ - public function deActivateCart() + public function deActivateCart(): void { if ($cart = $this->getCart()) { $this->cartRepository->update(['is_active' => false], $cart->id); @@ -816,7 +817,7 @@ class Cart * * @return array */ - public function prepareDataForOrder() + public function prepareDataForOrder(): array { $data = $this->toArray(); @@ -875,7 +876,7 @@ class Cart * @param array $data * @return array */ - public function prepareDataForOrderItem($data) + public function prepareDataForOrderItem($data): array { $finalData = [ 'product' => $this->productRepository->find($data['product_id']), @@ -1033,9 +1034,9 @@ class Cart * When logged in as guest or the customer profile is not complete, we use the * billing address to fill the order customer_ data. * - * @param \Webkul\Checkout\Models\Cart $cart + * @param \Webkul\Checkout\Contracts\Cart $cart */ - private function assignCustomerFields(\Webkul\Checkout\Models\Cart $cart): void + private function assignCustomerFields(\Webkul\Checkout\Contracts\Cart $cart): void { if ($this->getCurrentCustomer()->check() && ($user = $this->getCurrentCustomer()->user()) diff --git a/packages/Webkul/Core/src/Database/Factories/CartRuleFactory.php b/packages/Webkul/Core/src/Database/Factories/CartRuleFactory.php new file mode 100644 index 000000000..330c3caa6 --- /dev/null +++ b/packages/Webkul/Core/src/Database/Factories/CartRuleFactory.php @@ -0,0 +1,30 @@ +define(CartRule::class, function (Faker $faker) { + return [ + 'name' => $faker->uuid, + 'description' => $faker->sentence(), + 'starts_from' => null, + 'ends_till' => null, + 'coupon_type' => '1', + 'use_auto_generation' => '0', + 'usage_per_customer' => '100', + 'uses_per_coupon' => '100', + 'times_used' => '0', + 'condition_type' => '2', + 'end_other_rules' => '0', + 'uses_attribute_conditions' => '0', + 'discount_quantity' => '0', + 'discount_step' => '0', + 'apply_to_shipping' => '0', + 'free_shipping' => '0', + 'sort_order' => '0', + 'status' => '1', + 'conditions' => null, + ]; +}); \ No newline at end of file diff --git a/packages/Webkul/Tax/src/Helpers/Tax.php b/packages/Webkul/Tax/src/Helpers/Tax.php index 0cef200e9..0c91acb6f 100644 --- a/packages/Webkul/Tax/src/Helpers/Tax.php +++ b/packages/Webkul/Tax/src/Helpers/Tax.php @@ -12,15 +12,17 @@ class Tax /** * Returns an array with tax rates and tax amount - * @param object $that - * @param bool $asBase + * + * @param \Webkul\Checkout\Contracts\Cart $cart + * @param bool $asBase + * * @return array */ - public static function getTaxRatesWithAmount(object $that, bool $asBase = false): array + public static function getTaxRatesWithAmount(\Webkul\Checkout\Contracts\Cart $cart, bool $asBase = false): array { $taxes = []; - foreach ($that->items as $item) { + foreach ($cart->items as $item) { $taxRate = (string) round((float) $item->tax_percent, self::TAX_RATE_PRECISION); if (! array_key_exists($taxRate, $taxes)) { @@ -40,13 +42,15 @@ class Tax /** * Returns the total tax amount - * @param object $that - * @param bool $asBase + * + * @param \Webkul\Checkout\Contracts\Cart $cart + * @param bool $asBase + * * @return float */ - public static function getTaxTotal(object $that, bool $asBase = false): float + public static function getTaxTotal(\Webkul\Checkout\Contracts\Cart $cart, bool $asBase = false): float { - $taxes = self::getTaxRatesWithAmount($that, $asBase); + $taxes = self::getTaxRatesWithAmount($cart, $asBase); $result = 0; diff --git a/tests/unit/CartRule/CartRuleCest.php b/tests/unit/CartRule/CartRuleCest.php new file mode 100644 index 000000000..cf8e4b683 --- /dev/null +++ b/tests/unit/CartRule/CartRuleCest.php @@ -0,0 +1,1079 @@ +cartRule = $cartRule; + $this->coupon = $coupon; + } +} + +class expectedCartItem +{ + public const ITEM_DISCOUNT_AMOUNT_PRECISION = 2; + public const ITEM_TAX_AMOUNT_PRECISION = 4; + + public $cart_id; + public $product_id; + public $quantity = 1; + public $price = 0.0; + public $base_price = 0.0; + public $total = 0.0; + public $base_total = 0.0; + public $tax_percent = 0.0; + public $tax_amount = 0.0; + public $base_tax_amount = 0.0; + public $coupon_code = null; + public $discount_percent = 0.0; + public $discount_amount = 0.0; + public $base_discount_amount = 0.0; + public $applied_cart_rule_ids = ''; + + public function __construct(int $cartId, int $productId) + { + $this->cart_id = $cartId; + $this->product_id = $productId; + } + + public function calcTotals(): void + { + $this->total = $this->quantity * $this->price; + $this->base_total = $this->quantity * $this->price; + } + + public function calcTaxAmounts(): void + { + $this->tax_amount = round( + $this->quantity * $this->price * $this->tax_percent / 100, + self::ITEM_TAX_AMOUNT_PRECISION + ); + $this->base_tax_amount = round( + $this->quantity * $this->price * $this->tax_percent / 100, + self::ITEM_TAX_AMOUNT_PRECISION + ); + } + + public function calcFixedDiscountAmounts(float $discount, float $baseDiscount, string $code, int $cartRuleId): void + { + $this->discount_amount = $this->quantity * $discount; + $this->base_discount_amount = $this->quantity * $baseDiscount; + $this->coupon_code = $code; + $this->applied_cart_rule_ids = (string)$cartRuleId; + } + + public function calcPercentageDiscountAmounts(float $discount, string $code, int $cartRuleId): void + { + $this->discount_percent = $discount; + $this->discount_amount = round( + ($this->total + $this->tax_amount) * $this->discount_percent / 100, + self::ITEM_DISCOUNT_AMOUNT_PRECISION + ); + $this->base_discount_amount = round( + ($this->base_total + $this->base_tax_amount) * $this->discount_percent / 100, + self::ITEM_DISCOUNT_AMOUNT_PRECISION + ); + $this->coupon_code = $code; + $this->applied_cart_rule_ids = (string)$cartRuleId; + } +} + +class expectedCart +{ + public const CART_TOTAL_PRECISION = 2; + + public $customer_id; + public $id; + public $items_count = 0; + public $items_qty = 0.0; + public $sub_total = 0.0; + public $tax_total = 0.0; + public $discount_amount = 0.0; + public $grand_total = 0.0; + public $base_sub_total = 0.0; + public $base_tax_total = 0.0; + public $base_discount_amount = 0.0; + public $base_grand_total = 0.0; + public $coupon_code = null; + public $applied_cart_rule_ids = ''; + + public function __construct(int $cartId, int $customerId) + { + $this->id = $cartId; + $this->customer_id = $customerId; + } + + public function applyCoupon(int $cartRuleId, string $couponCode): void + { + $this->coupon_code = $couponCode; + $this->applied_cart_rule_ids = (string)$cartRuleId; + } + + public function finalizeTotals(): void + { + $this->sub_total = round($this->sub_total, self::CART_TOTAL_PRECISION); + $this->tax_total = round($this->tax_total, self::CART_TOTAL_PRECISION); + $this->grand_total = round($this->sub_total + $this->tax_total - $this->discount_amount, self::CART_TOTAL_PRECISION); + + $this->base_sub_total = round($this->base_sub_total, self::CART_TOTAL_PRECISION); + $this->base_tax_total = round($this->base_tax_total, self::CART_TOTAL_PRECISION); + $this->base_grand_total = round($this->base_sub_total + $this->base_tax_total - $this->base_discount_amount, self::CART_TOTAL_PRECISION); + } + + public function toArray(): array + { + return (array)$this; + } +} + +class expectedOrder +{ + public $status; + public $customer_email; + public $customer_first_name; + public $customer_vat_id; + public $coupon_code; + public $total_item_count; + public $total_qty_ordered; + public $grand_total; + public $base_grand_total; + public $sub_total; + public $base_sub_total; + public $discount_amount; + public $base_discount_amount; + public $tax_amount; + public $base_tax_amount; + public $customer_id; + public $cart_id; + public $applied_cart_rule_ids; + public $shipping_method; + public $shipping_amount; + public $base_shipping_amount; + public $shipping_discount_amount; + + public function __construct(expectedCart $expectedCart, Customer $customer, int $cartId) + { + $this->status = 'pending'; + $this->customer_email = $customer->email; + $this->customer_first_name = $customer->first_name; + $this->customer_vat_id = $customer->vat_id; + $this->coupon_code = $expectedCart->coupon_code; + $this->total_item_count = $expectedCart->items_count; + $this->total_qty_ordered = $expectedCart->items_qty; + $this->grand_total = $expectedCart->grand_total; + $this->base_grand_total = $expectedCart->base_grand_total; + $this->sub_total = $expectedCart->sub_total; + $this->base_sub_total = $expectedCart->base_sub_total; + $this->discount_amount = $expectedCart->discount_amount; + $this->base_discount_amount = $expectedCart->base_discount_amount; + $this->tax_amount = $expectedCart->tax_total; + $this->base_tax_amount = $expectedCart->base_tax_total; + $this->customer_id = $customer->id; + $this->cart_id = $cartId; + $this->applied_cart_rule_ids = $expectedCart->applied_cart_rule_ids; + $this->shipping_method = null; + $this->shipping_amount = null; + $this->base_shipping_amount = null; + $this->shipping_discount_amount = null; + } +} + +class CartRuleCest +{ + private $products; + private $sessionToken; + + public const PRODUCT_PRICE = 13.57; + public const REDUCED_PRODUCT_PRICE = 7.21; + public const TAX_RATE = 18.5; + public const REDUCED_TAX_RATE = 5.5; + + public const DISCOUNT_AMOUNT_FIX = 3.37; + public const DISCOUNT_AMOUNT_PERCENT = 7.5; + public const DISCOUNT_AMOUNT_FIX_FULL = 999999.99; + public const DISCOUNT_AMOUNT_CART = 8.33; + + public const ACTION_TYPE_FIXED = "by_fixed"; + public const ACTION_TYPE_PERCENTAGE = "by_percent"; + public const ACTION_TYPE_CART_FIXED = "cart_fixed"; + + public const PRODUCT_FREE = 0; + public const PRODUCT_NOT_FREE = 1; + public const PRODUCT_NOT_FREE_REDUCED_TAX = 2; + + public const TAX_CATEGORY = 0; + public const TAX_REDUCED_CATEGORY = 1; + + public const COUPON_FIXED = 0; + public const COUPON_FIXED_FULL = 1; + public const COUPON_PERCENTAGE = 2; + public const COUPON_PERCENTAGE_FULL = 3; + public const COUPON_FIXED_CART = 4; + + + protected function getCartWithCouponScenarios(): array + { + return [ + [ + 'name' => 'check cart coupon', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_FIXED_CART, + 'products' => [ + ], + ], + 'checkOrder' => true, + ], + // ohne coupon + [ + 'name' => 'PRODUCT_FREE no coupon', + 'productSequence' => [ + self::PRODUCT_FREE, + ], + 'withCoupon' => false, + 'checkOrder' => false, + ], + [ + 'name' => 'PRODUCT_NOT_FREE no coupon', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => false, + 'checkOrder' => false, + ], + // fixer Coupon für ein Produkt (Warenkorb wird nicht 0) + [ + 'name' => 'PRODUCT_NOT_FREE fix coupon', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_FIXED, + 'products' => [ + self::PRODUCT_NOT_FREE, + ], + ], + 'checkOrder' => false, + ], + [ + 'name' => 'check fix coupon on product with quantity=2', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_FIXED, + 'products' => [ + self::PRODUCT_NOT_FREE, + ], + ], + 'checkOrder' => false, + ], + [ + 'name' => 'check fix coupon applied to two products', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_FIXED, + 'products' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + ], + ], + 'checkOrder' => true, + ], + // prozenturaler Coupon für ein Produkt (Warenkorb wird nicht 0) + [ + 'name' => 'PRODUCT_NOT_FREE percentage coupon', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_PERCENTAGE, + 'products' => [ + self::PRODUCT_NOT_FREE, + ], + ], + 'checkOrder' => false, + ], + [ + 'name' => 'check percentage coupon on product with quantity=2', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_PERCENTAGE, + 'products' => [ + self::PRODUCT_NOT_FREE, + ], + ], + 'checkOrder' => false, + ], + [ + 'name' => 'check percentage coupon applied to two products', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_PERCENTAGE, + 'products' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + ], + ], + 'checkOrder' => true, + ], + // fixer Coupon für ein Produkt (Warenkorb wird 0) + [ + 'name' => 'PRODUCT_NON_SUB_NOT_FREE fix coupon to zero', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_FIXED_FULL, + 'products' => [ + self::PRODUCT_NOT_FREE, + ], + ], + 'checkOrder' => false, + ], + [ + 'name' => 'check fix coupon to zero on product with quantity=2', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_FIXED_FULL, + 'products' => [ + self::PRODUCT_NOT_FREE, + ], + ], + 'checkOrder' => false, + ], + [ + 'name' => 'check fix coupon to zero applied to two products', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_FIXED_FULL, + 'products' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + ], + ], + 'checkOrder' => true, + ], + // prozenturaler Coupon für ein Produkt (Warenkorb wird 0) + [ + 'name' => 'PRODUCT_NOT_FREE percentage coupon to zero', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_PERCENTAGE_FULL, + 'products' => [ + self::PRODUCT_NOT_FREE, + ], + ], + 'checkOrder' => false, + ], + [ + 'name' => 'check percentage coupon to zero on product with quantity=2', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_PERCENTAGE_FULL, + 'products' => [ + self::PRODUCT_NOT_FREE, + ], + ], + 'checkOrder' => false, + ], + [ + 'name' => 'check percentage coupon to zero applied to two products', + 'productSequence' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + self::PRODUCT_NOT_FREE, + ], + 'withCoupon' => true, + 'couponScenario' => [ + 'scenario' => self::COUPON_PERCENTAGE_FULL, + 'products' => [ + self::PRODUCT_NOT_FREE, + self::PRODUCT_NOT_FREE_REDUCED_TAX, + ], + ], + 'checkOrder' => true, + ], + ]; + } + + /** + * @param \UnitTester $I + * @param \Codeception\Example $scenario + * + * @dataProvider getCartWithCouponScenarios + * @group slow_unit + * @throws \Exception + */ + public function checkCartWithCoupon(UnitTester $I, Example $scenario): void + { + $faker = Factory::create(); + + config(['app.default_country' => 'DE']); + + $customer = $I->have(Customer::class); + auth()->guard('customer')->loginUsingId($customer->id); + Event::dispatch('customer.after.login', $customer['email']); + + $this->sessionToken = $faker->uuid; + session(['_token' => $this->sessionToken]); + + $taxCategories = $this->generateTaxCategories($I); + $this->products = $this->generateProducts($I, $scenario['productSequence'], $taxCategories); + + $cartRuleWithCoupon = null; + if ($scenario['withCoupon']) { + $cartRuleWithCoupon = $this->generateCartRuleWithCoupon($I, $scenario['couponScenario']); + } + + foreach ($scenario['productSequence'] as $productIndex) { + $data = [ + '_token' => session('_token'), + 'product_id' => $this->products[$productIndex]->id, + 'quantity' => 1, + ]; + + cart()->addProduct($this->products[$productIndex]->id, $data); + } + + if ($scenario['withCoupon']) { + $expectedCartCoupon = $cartRuleWithCoupon->coupon->code; + $I->comment('I try to use coupon code ' . $expectedCartCoupon); + cart()->setCouponCode($expectedCartCoupon)->collectTotals(); + } else { + $I->comment('I have no coupon'); + $expectedCartCoupon = null; + } + + $cart = cart()->getCart(); + $I->assertEquals($expectedCartCoupon, $cart->coupon_code); + + $expectedCartItems = $this->getExpectedCartItems($scenario, $cartRuleWithCoupon, $cart->id); + $expectedCartItems = $this->checkMaxDiscount($expectedCartItems); + + foreach ($expectedCartItems as $expectedCartItem) { + $I->seeRecord('cart_items', $expectedCartItem); + } + + $expectedCart = $this->getExpectedCart($cart->id, $expectedCartItems, $cartRuleWithCoupon); + $I->seeRecord(\Webkul\Checkout\Models\Cart::class, $expectedCart->toArray()); + + if ($scenario['checkOrder']) { + $I->wantTo('create and check order from cart'); + + $customerAddress = $I->have(CustomerAddress::class, [ + 'first_name' => $customer->first_name, + 'last_name' => $customer->last_name, + 'country' => 'DE', + ]); + + $billing = [ + 'address1' => $customerAddress->address1, + 'use_for_shipping' => true, + 'first_name' => $customerAddress->first_name, + 'last_name' => $customerAddress->last_name, + 'email' => $customer->email, + 'company_name' => $customerAddress->company_name, + 'city' => $customerAddress->city, + 'postcode' => $customerAddress->postcode, + 'country' => $customerAddress->country, + 'state' => $customerAddress->state, + 'phone' => $customerAddress->phone, + ]; + + $shipping = [ + 'address1' => '', + 'first_name' => $customerAddress->first_name, + 'last_name' => $customerAddress->last_name, + 'email' => $customer->email, + ]; + + cart()->saveCustomerAddress([ + 'billing' => $billing, + 'shipping' => $shipping, + ]); + + cart()->saveShippingMethod('free_free'); + cart()->savePaymentMethod(['method' => 'mollie_creditcard']); + $I->assertFalse(cart()->hasError()); + $orderItemRepository = new OrderItemRepository(app()); + $downloadableLinkRepository = new ProductDownloadableLinkRepository(app()); + $downloadableLinkPurchasedRepository = + new DownloadableLinkPurchasedRepository($downloadableLinkRepository, app()); + $orderRepository = new OrderRepository($orderItemRepository, $downloadableLinkPurchasedRepository, app()); + + $orderRepository->create(cart()->prepareDataForOrder()); + $expectedOrder = new expectedOrder($expectedCart, $customer, $cart->id); + $I->seeRecord('orders', $expectedOrder); + + auth()->guard('customer')->logout(); + } + } + + /** + * @param \Codeception\Example $scenario + * @param \Tests\Unit\Category\cartRuleWithCoupon $cartRuleWithCoupon + * @param int $cartID + * + * @return array + */ + private function getExpectedCartItems( + Example $scenario, + ?cartRuleWithCoupon $cartRuleWithCoupon, + int $cartID + ): array { + $cartItems = []; + + foreach ($scenario['productSequence'] as $key => $item) { + $pos = $this->array_find( + 'product_id', + $this->products[$scenario['productSequence'][$key]]->id, + $cartItems, + true + ); + + if ($pos === null) { + $cartItem = new expectedCartItem( + $cartID, + $this->products[$scenario['productSequence'][$key]]->id + ); + + } else { + $cartItem = $cartItems[$pos]; + $cartItem->quantity++; + } + + switch ($item) { + case self::PRODUCT_FREE: + $cartItem->tax_percent = self::TAX_RATE; + break; + + case self::PRODUCT_NOT_FREE: + $cartItem->price = self::PRODUCT_PRICE; + $cartItem->base_price = self::PRODUCT_PRICE; + $cartItem->tax_percent = self::TAX_RATE; + + $cartItem->calcTotals(); + $cartItem->calcTaxAmounts(); + break; + + case self::PRODUCT_NOT_FREE_REDUCED_TAX: + $cartItem->price = self::REDUCED_PRODUCT_PRICE; + $cartItem->base_price = self::REDUCED_PRODUCT_PRICE; + $cartItem->tax_percent = self::REDUCED_TAX_RATE; + + $cartItem->calcTotals(); + $cartItem->calcTaxAmounts(); + break; + } + + if ($scenario['withCoupon']) { + switch ($scenario['couponScenario']['scenario']) { + case self::COUPON_FIXED: + foreach ($scenario['couponScenario']['products'] as $couponItem) { + if ($item === $couponItem) { + $cartItem->calcFixedDiscountAmounts( + self::DISCOUNT_AMOUNT_FIX, + self::DISCOUNT_AMOUNT_FIX, + $cartRuleWithCoupon->coupon->code, + $cartRuleWithCoupon->cartRule->id + ); + continue; + } + } + break; + + case self::COUPON_FIXED_FULL: + foreach ($scenario['couponScenario']['products'] as $couponItem) { + if ($item === $couponItem) { + $cartItem->calcFixedDiscountAmounts( + self::DISCOUNT_AMOUNT_FIX_FULL, + self::DISCOUNT_AMOUNT_FIX_FULL, + $cartRuleWithCoupon->coupon->code, + $cartRuleWithCoupon->cartRule->id + ); + continue; + } + } + break; + + case self::COUPON_PERCENTAGE: + foreach ($scenario['couponScenario']['products'] as $couponItem) { + if ($item === $couponItem) { + $cartItem->calcPercentageDiscountAmounts( + self::DISCOUNT_AMOUNT_PERCENT, + $cartRuleWithCoupon->coupon->code, + $cartRuleWithCoupon->cartRule->id + ); + continue; + } + } + break; + + case self::COUPON_PERCENTAGE_FULL: + foreach ($scenario['couponScenario']['products'] as $couponItem) { + if ($item === $couponItem) { + $cartItem->calcPercentageDiscountAmounts( + 100.0, + $cartRuleWithCoupon->coupon->code, + $cartRuleWithCoupon->cartRule->id + ); + continue; + } + } + break; + } + } + + if ($pos === null) { + $cartItems[] = $cartItem; + + } else { + $cartItems[$pos] = $cartItem; + } + } + + if ($scenario['withCoupon'] && $scenario['couponScenario']['scenario'] === self::COUPON_FIXED_CART) { + $totals = $this->calcTotals($cartItems); + $cartItems = $this->splitDiscountToItems($cartItems, $cartRuleWithCoupon, $totals); + } + + return $cartItems; + } + + private function calcTotals(array $cartItems): array + { + $result = [ + 'subTotal' => 0.0, + 'baseSubTotal' => 0.0, + ]; + foreach ($cartItems as $expectedCartItem) { + $result['subTotal'] += $expectedCartItem->total; + $result['baseSubTotal'] += $expectedCartItem->base_total; + } + $result['subTotal'] = round($result['subTotal'], expectedCart::CART_TOTAL_PRECISION); + $result['baseSubTotal'] = round($result['baseSubTotal'], expectedCart::CART_TOTAL_PRECISION); + + return $result; + } + + private function splitDiscountToItems( + array $cartItems, + cartRuleWithCoupon $cartRuleWithCoupon, + array $totals + ): array { + $discountAmount = self::DISCOUNT_AMOUNT_CART; + $baseDiscountAmount = self::DISCOUNT_AMOUNT_CART; + // split coupon amount to cart items + $length = count($cartItems) - 1; + for ($i = 0; $i < $length; $i++) { + $cartItems[$i]->discount_amount = round( + self::DISCOUNT_AMOUNT_CART * $cartItems[$i]->total / $totals['subTotal'], + expectedCartItem::ITEM_DISCOUNT_AMOUNT_PRECISION + ); + $discountAmount -= $cartItems[$i]->discount_amount; + + $cartItems[$i]->base_discount_amount = round( + self::DISCOUNT_AMOUNT_CART * $cartItems[$i]->base_total / $totals['baseSubTotal'], + expectedCartItem::ITEM_DISCOUNT_AMOUNT_PRECISION + ); + $baseDiscountAmount -= $cartItems[$i]->discount_amount; + + $cartItems[$i]->coupon_code = $cartRuleWithCoupon->coupon->code; + $cartItems[$i]->applied_cart_rule_ids = (string)$cartRuleWithCoupon->cartRule->id; + } + + $cartItems[$length]->discount_amount = $discountAmount; + $cartItems[$length]->base_discount_amount = $baseDiscountAmount; + + $cartItems[$length]->coupon_code = $cartRuleWithCoupon->coupon->code; + $cartItems[$length]->applied_cart_rule_ids = (string)$cartRuleWithCoupon->cartRule->id; + + return $cartItems; + } + + /** + * @param array $expectedCartItems + * + * @return array + */ + private function checkMaxDiscount(array $expectedCartItems): array + { + foreach ($expectedCartItems as $key => $cartItem) { + $itemGrandTotal = round($cartItem->total + $cartItem->tax_amount, + expectedCartItem::ITEM_DISCOUNT_AMOUNT_PRECISION); + if ($cartItem->discount_amount > $itemGrandTotal) { + $expectedCartItems[$key]->discount_amount = $itemGrandTotal; + } + + $itemBaseGrandTotal = round($cartItem->base_total + $cartItem->base_tax_amount, + expectedCartItem::ITEM_DISCOUNT_AMOUNT_PRECISION); + if ($cartItem->base_discount_amount > $itemBaseGrandTotal) { + $expectedCartItems[$key]->base_discount_amount = $itemBaseGrandTotal; + } + } + + return $expectedCartItems; + } + + /** + * @param int $cartId + * @param array $expectedCartItems + * + * @param \Tests\Unit\Category\cartRuleWithCoupon $cartRuleWithCoupon + * + * @return \Tests\Unit\Category\expectedCart + */ + private function getExpectedCart( + int $cartId, + array $expectedCartItems, + ?cartRuleWithCoupon $cartRuleWithCoupon + ): expectedCart { + $cart = new expectedCart( + $cartId, + auth()->guard('customer')->user()->id + ); + + if ($cartRuleWithCoupon) { + $cart->applyCoupon( + $cartRuleWithCoupon->cartRule->id, + $cartRuleWithCoupon->coupon->code + ); + } + + foreach ($expectedCartItems as $cartItem) { + $cart->items_count++; + $cart->items_qty += $cartItem->quantity; + + $cart->sub_total += $cartItem->total; + $cart->tax_total += $cartItem->tax_amount; + $cart->discount_amount += $cartItem->discount_amount; + + $cart->base_sub_total += $cartItem->base_total; + $cart->base_tax_total += $cartItem->base_tax_amount; + $cart->base_discount_amount += $cartItem->base_discount_amount; + } + + $cart->finalizeTotals(); + + return $cart; + } + + /** + * @param \UnitTester $I + * + * @return array + */ + private function generateTaxCategories(UnitTester $I): array + { + $result = []; + $country = strtoupper(Config::get('app.default_country')) ?? 'DE'; + foreach ($this->getTaxRateSpecifications() as $taxSpec => $taxRate) { + $tax = $I->have(TaxRate::class, [ + 'country' => $country, + 'tax_rate' => $taxRate, + ]); + + $taxCategorie = $I->have(TaxCategory::class); + + $I->have(TaxMap::class, [ + 'tax_rate_id' => $tax->id, + 'tax_category_id' => $taxCategorie->id, + ]); + + $result[$taxSpec] = $taxCategorie->id; + } + + return $result; + } + + /** + * @param \UnitTester $I + * @param array $scenario + * @param array $taxCategories + * + * @return array + * @throws \Exception + */ + private function generateProducts(UnitTester $I, array $scenario, array $taxCategories): array + { + $products = []; + $productSpecs = $this->getProductSpecifications(); + + foreach ($scenario as $item) { + $productConfig = $this->makeProductConfig($productSpecs[$item], $taxCategories); + $products[$item] = $I->haveProduct($productSpecs[$item]['productType'], $productConfig); + } + + return $products; + } + + /** + * @param \UnitTester $I + * @param array $couponConfig + * + * @return \Tests\Unit\Category\cartRuleWithCoupon + */ + private function generateCartRuleWithCoupon(UnitTester $I, array $couponConfig): cartRuleWithCoupon + { + $faker = Factory::create(); + + $couponSpecifications = $this->getCouponSpecifications(); + $ruleConfig = $this->makeRuleConfig( + $couponSpecifications[$couponConfig['scenario']], + $this->products, + $couponConfig['products'] + ); + $cartRule = $I->have(CartRule::class, $ruleConfig); + + DB::table('cart_rule_channels')->insert([ + 'cart_rule_id' => $cartRule->id, + 'channel_id' => core()->getCurrentChannel()->id, + ]); + + $guestCustomerGroup = $I->grabRecord('customer_groups', ['code' => 'guest']); + DB::table('cart_rule_customer_groups')->insert([ + 'cart_rule_id' => $cartRule->id, + 'customer_group_id' => $guestCustomerGroup['id'], + ]); + + $generalCustomerGroup = $I->grabRecord('customer_groups', ['code' => 'general']); + DB::table('cart_rule_customer_groups')->insert([ + 'cart_rule_id' => $cartRule->id, + 'customer_group_id' => $generalCustomerGroup['id'], + ]); + + $coupon = $I->have(CartRuleCoupon::class, [ + 'cart_rule_id' => $cartRule->id, + ]); + + return new cartRuleWithCoupon( + $cartRule, + $coupon + ); + + } + + /** + * @param array $productSpec + * @param array $taxCategories + * + * @return array + */ + private function makeProductConfig(array $productSpec, array $taxCategories): array + { + $result = [ + 'productInventory' => [ + 'qty' => 100, + ], + 'attributeValues' => [ + 'price' => 0.0, + 'tax_category_id' => $taxCategories[self::TAX_CATEGORY], + ], + ]; + + if ($productSpec['reducedTax']) { + if (array_key_exists(self::TAX_REDUCED_CATEGORY, $taxCategories)) { + $result['attributeValues']['tax_category_id'] = $taxCategories[self::TAX_REDUCED_CATEGORY]; + } + } + + if (!$productSpec['freeOfCharge']) { + if ($productSpec['reducedTax']) { + $result['attributeValues']['price'] = self::REDUCED_PRODUCT_PRICE; + } else { + $result['attributeValues']['price'] = self::PRODUCT_PRICE; + } + } + + return $result; + } + + /** + * @param array $ruleSpec + * @param array $products + * @param array $couponableProducts + * + * @return array + */ + private function makeRuleConfig(array $ruleSpec, array $products, array $couponableProducts): array + { + foreach ($couponableProducts as $item) { + $conditions[] = [ + 'value' => $products[$item]->sku, + 'operator' => '==', + 'attribute' => 'product|sku', + 'attribute_type' => 'text', + ]; + } + + $result = [ + 'action_type' => $ruleSpec['actionType'], + 'discount_amount' => $ruleSpec['discountAmount'], + 'conditions' => $conditions ?? null, + ]; + + return $result; + } + + /** + * @return array + */ + private function getProductSpecifications(): array + { + return [ + [ + 'productScenario' => self::PRODUCT_FREE, + 'productType' => Laravel5Helper::SIMPLE_PRODUCT, + 'freeOfCharge' => true, + 'reducedTax' => false, + ], + [ + 'productScenario' => self::PRODUCT_NOT_FREE, + 'productType' => Laravel5Helper::SIMPLE_PRODUCT, + 'freeOfCharge' => false, + 'reducedTax' => false, + ], + [ + 'productScenario' => self::PRODUCT_NOT_FREE_REDUCED_TAX, + 'productType' => Laravel5Helper::SIMPLE_PRODUCT, + 'freeOfCharge' => false, + 'reducedTax' => true, + ], + ]; + } + + /** + * @return array + */ + private function getCouponSpecifications(): array + { + return [ + [ + 'couponScenario' => self::COUPON_FIXED, + 'actionType' => self::ACTION_TYPE_FIXED, + 'discountAmount' => self::DISCOUNT_AMOUNT_FIX, + ], + [ + 'couponScenario' => self::COUPON_FIXED_FULL, + 'actionType' => self::ACTION_TYPE_FIXED, + 'discountAmount' => self::DISCOUNT_AMOUNT_FIX_FULL, + ], + [ + 'couponScenario' => self::COUPON_PERCENTAGE, + 'actionType' => self::ACTION_TYPE_PERCENTAGE, + 'discountAmount' => self::DISCOUNT_AMOUNT_PERCENT, + ], + [ + 'couponScenario' => self::COUPON_PERCENTAGE_FULL, + 'actionType' => self::ACTION_TYPE_PERCENTAGE, + 'discountAmount' => 100.0, + ], + [ + 'couponScenario' => self::COUPON_FIXED_CART, + 'actionType' => self::ACTION_TYPE_CART_FIXED, + 'discountAmount' => self::DISCOUNT_AMOUNT_CART, + ], + ]; + } + + /** + * @return array + */ + private function getTaxRateSpecifications(): array + { + return [ + self::TAX_CATEGORY => self::TAX_RATE, + self::TAX_REDUCED_CATEGORY => self::REDUCED_TAX_RATE, + ]; + } + + /** + * @param string $param + * @param $needleValue + * @param array $data + * + * @return int|null + */ + private function array_find(string $param, $needleValue, array $data): ?int + { + foreach ($data as $pos => $object) { + if ($object->$param === $needleValue) { + return $pos; + } + } + + return null; + } + + +} \ No newline at end of file