diff --git a/includes/Order/Manager.php b/includes/Order/Manager.php index 345d4c4fe6..f33e3940b2 100644 --- a/includes/Order/Manager.php +++ b/includes/Order/Manager.php @@ -8,6 +8,7 @@ use WC_Order_Refund; use WeDevs\Dokan\Cache; use WeDevs\Dokan\Utilities\OrderUtil; +use WeDevs\Dokan\Vendor\Coupon; use WP_Error; /** @@ -628,7 +629,7 @@ public function create_sub_order( $parent_order, $seller_id, $seller_products ) $this->create_taxes( $order, $parent_order, $seller_products ); // add coupons if any - $this->create_coupons( $order, $parent_order, $seller_products ); + $this->create_coupons( $order, $parent_order ); $order->save(); // need to save order data before passing it to a hook @@ -814,24 +815,18 @@ private function create_shipping( $order, $parent_order ) { * * @param WC_Order $order * @param WC_Order $parent_order - * @param array $products * * @return void */ - private function create_coupons( $order, $parent_order, $products ) { + private function create_coupons( $order, $parent_order ) { if ( dokan()->is_pro_exists() && property_exists( dokan_pro(), 'vendor_discount' ) ) { // remove vendor discount coupon code changes remove_filter( 'woocommerce_order_get_items', [ dokan_pro()->vendor_discount->woocommerce_hooks, 'replace_coupon_name' ], 10 ); } - $used_coupons = $parent_order->get_items( 'coupon' ); - $product_ids = array_map( - function ( $item ) { - return $item->get_product_id(); - }, $products - ); + $parent_coupons = $parent_order->get_items( 'coupon' ); - if ( ! $used_coupons ) { + if ( ! $parent_coupons ) { return; } @@ -841,32 +836,45 @@ function ( $item ) { return; } - foreach ( $used_coupons as $item ) { - /** - * @var WC_Order_Item_Coupon $item - */ - $coupon = new \WC_Coupon( $item->get_code() ); - - if ( - apply_filters( 'dokan_should_copy_coupon_to_sub_order', true, $coupon, $item, $order ) && - ( - array_intersect( $product_ids, $coupon->get_product_ids() ) || - apply_filters( 'dokan_is_order_have_admin_coupon', false, $coupon, [ $seller_id ], $product_ids ) - ) - ) { - $new_item = new WC_Order_Item_Coupon(); - $new_item->set_props( - [ - 'code' => $item->get_code(), - 'discount' => $item->get_discount(), - 'discount_tax' => $item->get_discount_tax(), - ] - ); - - $new_item->add_meta_data( 'coupon_data', $coupon->get_data() ); + $parent_coupons = array_map( + function ( $coupon ) { + /** @var WC_Order_Item_Coupon $coupon */ + return $coupon->get_code(); + }, + $parent_coupons + ); - $order->add_item( $new_item ); + $order_items = $order->get_items(); + $used_coupons_data = []; + foreach ( $order_items as $order_item ) { + $item_coupons = $order_item->get_meta( Coupon::DOKAN_COUPON_META_KEY, true ); + if ( ! is_array( $item_coupons ) ) { + continue; } + foreach ( $item_coupons as $code => $item ) { + if ( ! isset( $used_coupons_data[ $code ] ) ) { + $used_coupons_data[ $code ] = 0; + } + if ( in_array( $code, $parent_coupons, true ) ) { + $used_coupons_data[ $code ] += $item['discount']; + } + } + } + + foreach ( $used_coupons_data as $code => $total_discount ) { + $coupon = new \WC_Coupon( $code ); + $coupon_item = new WC_Order_Item_Coupon(); + $coupon_item->set_props( + [ + 'code' => $code, + 'discount' => $total_discount, + 'discount_tax' => 0, + ] + ); + $coupon_info = $coupon->get_short_info(); + $coupon_item->add_meta_data( 'coupon_info', $coupon_info ); + $coupon_item->add_meta_data( 'coupon_data', $coupon->get_data() ); + $order->add_item( $coupon_item ); } } diff --git a/includes/Vendor/Coupon.php b/includes/Vendor/Coupon.php new file mode 100644 index 0000000000..8e14f63274 --- /dev/null +++ b/includes/Vendor/Coupon.php @@ -0,0 +1,321 @@ +get_code(); + $order = wc_get_order( $data->get_order_id() ); + if ( ! $order ) { + return $check; + } + $order_items = $order->get_items(); + + $product_ids = []; + + foreach ( $order_items as $order_item ) { + $item = $order_item->get_meta( self::DOKAN_COUPON_META_KEY, true ); + + if ( isset( $item[ $removed_coupon ] ) ) { + unset( $item[ $removed_coupon ] ); + wc_update_order_item_meta( $order_item->get_id(), self::DOKAN_COUPON_META_KEY, $item ); + $product_ids[] = $order_item->get_product_id(); + } + } + // Remove coupon with child orders + if ( $order->get_meta( 'has_sub_order' ) ) { + $this->remove_coupon_into_child_orders( $order, $removed_coupon ); + } else { + // Remove coupon from child order items + $parent_order = wc_get_order( $order->get_parent_id() ); + if ( ! empty( $product_ids ) && $parent_order ) { + $order_items = $parent_order->get_items(); + foreach ( $order_items as $order_item ) { + /** @var WC_Order_Item_Product $order_item */ + if ( in_array( $order_item->get_product_id(), $product_ids, true ) ) { + $item = $order_item->get_meta( self::DOKAN_COUPON_META_KEY, true ); + if ( isset( $item[ $removed_coupon ] ) ) { + unset( $item[ $removed_coupon ] ); + wc_update_order_item_meta( $order_item->get_id(), self::DOKAN_COUPON_META_KEY, $item ); + } + } + } + } + } + } + + return $check; + } + + /** + * Intercepts coupon application to handle line-item coupon discounts. + * + * WooCommerce removes the coupon from the order and recalculates totals. For reference, see: + * @see https://github.com/woocommerce/woocommerce/blob/8abd6e97ca598381cb07287a2e7b735799cb55d5/plugins/woocommerce/includes/abstracts/abstract-wc-order.php#L1339 + * + * WooCommerce does not provide a direct hook to retrieve coupon amounts per line item. However, + * the `get_discounts` method of the `WC_Discounts` class allows access to this information. + * This implementation utilizes the following steps to calculate line-item discounts: + * + * 1. Remove the interfering WC hook used by Dokan hook. + * 2. Reapply the coupon to the order or cart. + * 3. Trigger the Dokan action to apply the coupon to the order or cart. + * 4. Reattach the interfering WC hook used by Dokan hook. + * + * @param int $apply_quantity The number of items to which the coupon applies. + * @param object $item The cart or order item object. + * @param WC_Coupon $coupon The coupon being applied. + * @param WC_Discounts $discounts The discount object managing the coupon. + * + * @return int + */ + public function intercept_wc_coupon( int $apply_quantity, $item, WC_Coupon $coupon, WC_Discounts $discounts ): int { + remove_filter( 'woocommerce_coupon_get_apply_quantity', [ $this, 'intercept_wc_coupon' ], 15 ); + + $discounts_clone = clone $discounts; + $discounts_clone->apply_coupon( $coupon ); + + do_action( 'dokan_wc_coupon_applied', $coupon, $discounts_clone, $item, $apply_quantity, $this ); + + add_filter( 'woocommerce_coupon_get_apply_quantity', [ $this, 'intercept_wc_coupon' ], 15, 4 ); + + return $apply_quantity; + } + + /** + * Save coupon discount data for an item. + * + * @param WC_Coupon $coupon The coupon being applied. + * @param WC_Discounts $discounts Discount object. + * @param object $item The cart or order item object. + * + * @return void + * @throws Exception + */ + public function save_item_coupon_discount( WC_Coupon $coupon, WC_Discounts $discounts, $item ): void { + $object_to_apply_coupon = $discounts->get_object(); + + if ( $object_to_apply_coupon instanceof WC_Cart ) { + $this->save_coupon_data_to_cart_item( $coupon, $discounts, $item ); + } elseif ( $object_to_apply_coupon instanceof WC_Order ) { + $order = wc_get_order( $object_to_apply_coupon->get_id() ); + $this->save_coupon_data_to_order_item( $coupon, $discounts, $item, $order ); + } + } + + /** + * Save coupon data to a cart item. + * + * @param WC_Coupon $coupon The coupon being applied. + * @param WC_Discounts $discounts Discount object. + * @param object $item The cart item object. + * + * @return void + */ + protected function save_coupon_data_to_cart_item( WC_Coupon $coupon, WC_Discounts $discounts, $item ): void { + $cart = WC()->cart; + $coupon_codes = $cart->get_applied_coupons(); + $item_key = $item->object['key']; + $item_object = $cart->cart_contents[ $item_key ]; + + $coupon_info = $item_object[ self::DOKAN_COUPON_META_KEY ] ?? []; + $coupon_info = array_filter( + $coupon_info, + function ( $coupon_temp ) use ( $coupon_codes ) { + return in_array( $coupon_temp['coupon_code'], $coupon_codes, true ); + } + ); + + $item_wise_discounts = $discounts->get_discounts(); + + if ( isset( $item_wise_discounts[ $coupon->get_code() ][ $item_key ] ) ) { + $discount_amount = $item_wise_discounts[ $coupon->get_code() ][ $item_key ]; + + $coupon_info[ $coupon->get_code() ] = [ + 'discount' => $discount_amount, + 'coupon_code' => $coupon->get_code(), + 'per_qty_amount' => $item_object['quantity'] > 0 ? ( $discount_amount / $item_object['quantity'] ) : 0, + 'quantity' => $item_object['quantity'], + ]; + } + + $item_object[ self::DOKAN_COUPON_META_KEY ] = $coupon_info; + $cart->cart_contents[ $item_object['key'] ] = $item_object; + } + + /** + * Save coupon data to an order item. + * + * @param WC_Coupon $coupon The coupon being applied. + * @param WC_Discounts $discounts Discount object. + * @param object $item The order item object. + * @param WC_Order $order The order object. + * + * @return void + * @throws Exception + */ + protected function save_coupon_data_to_order_item( WC_Coupon $coupon, WC_Discounts $discounts, $item, WC_Order $order ): void { + $coupon_codes = $order->get_coupon_codes(); + + /** @var WC_Order_Item_Product $order_item */ + $order_item = $item->object; + $item_key = $order_item->get_id(); + + $coupon_info = $order_item->get_meta( self::DOKAN_COUPON_META_KEY ); + + if ( ! is_array( $coupon_info ) ) { + $coupon_info = []; + } + + $coupon_info = array_filter( + $coupon_info, + function ( $coupon_temp ) use ( $coupon_codes ) { + return in_array( $coupon_temp['coupon_code'], $coupon_codes, true ); + } + ); + + $item_wise_discounts = $discounts->get_discounts(); + + if ( isset( $item_wise_discounts[ $coupon->get_code() ][ $item_key ] ) ) { + $discount_amount = $item_wise_discounts[ $coupon->get_code() ][ $item_key ]; + + $coupon_info[ $coupon->get_code() ] = [ + 'discount' => $discount_amount, + 'coupon_code' => $coupon->get_code(), + 'per_qty_amount' => $order_item->get_quantity() > 0 ? $discount_amount / $order_item->get_quantity() : 0, + 'quantity' => $order_item->get_quantity(), + ]; + } + + wc_update_order_item_meta( $order_item->get_id(), self::DOKAN_COUPON_META_KEY, $coupon_info ); + + // apply coupon sub order + if ( $order->get_meta( 'has_sub_order' ) ) { + $this->process_coupon_into_child_orders( $order, $coupon ); + } + } + + /** + * Process coupon for child orders. + * + * @param WC_Order $order + * @param WC_Coupon $coupon + * + * @return void + * @throws Exception + */ + public function process_coupon_into_child_orders( WC_Order $order, WC_Coupon $coupon ): void { + $sub_orders = dokan()->order->get_child_orders( $order->get_id() ); + foreach ( $sub_orders as $sub_order ) { + // Check if the coupon is already applied + $used_coupons = $sub_order->get_coupon_codes(); + $coupon_code = $coupon->get_code(); + if ( in_array( $coupon_code, $used_coupons, true ) ) { + continue; + } + // Add the coupon to the order + $sub_order->apply_coupon( $coupon_code ); + $sub_order->save(); + } + } + + /** + * @param WC_Order $order + * @param string $removed_coupon + * @return void + */ + private function remove_coupon_into_child_orders( WC_Order $order, string $removed_coupon ) { + $sub_orders = dokan()->order->get_child_orders( $order->get_id() ); + foreach ( $sub_orders as $sub_order ) { + $used_coupons = $sub_order->get_coupon_codes(); + if ( in_array( $removed_coupon, $used_coupons, true ) ) { + $sub_order->remove_coupon( $removed_coupon ); + $sub_order->save(); + } + } + } + + /** + * Add coupon info to an order item during checkout. + * + * @param WC_Order_Item_Product $item The order item object. + * @param string $cart_item_key The cart item key. + * @param array $values Cart item values. + */ + public function add_coupon_info_to_order_item( $item, $cart_item_key, $values ): void { + if ( ! empty( $values[ self::DOKAN_COUPON_META_KEY ] ) ) { + $total_discount = 0; + $limit_reached = false; + $coupon_info = $values[ self::DOKAN_COUPON_META_KEY ]; + + foreach ( $coupon_info as $key => $coupon ) { + $total_discount += $coupon['discount']; + $product = wc_get_product( $item->get_product_id() ); + $total_product_price = $product->get_price() * $values['quantity']; + + if ( $limit_reached ) { + $coupon_info[ $key ]['discount'] = 0; + } + + if ( $total_discount > $total_product_price && $limit_reached === false ) { + $remain_discount = $total_discount - $total_product_price; + $adjusted_discount = max( $coupon['discount'] - $remain_discount, 0 ); + $coupon_info[ $key ]['discount'] = $adjusted_discount; + $limit_reached = true; + } + } + + $item->add_meta_data( self::DOKAN_COUPON_META_KEY, $coupon_info, true ); + } + } + + /** + * Remove coupon info from a cart item when a coupon is removed. + * + * @param string $coupon_code The coupon code being removed. + */ + public function remove_coupon_info_from_cart_item( string $coupon_code ): void { + $cart = WC()->cart; + $cart_contents = $cart->cart_contents; + + foreach ( $cart_contents as $cart_item_key => $cart_item ) { + $coupon_info = $cart_item[ self::DOKAN_COUPON_META_KEY ] ?? []; + + if ( isset( $coupon_info[ $coupon_code ] ) ) { + unset( $coupon_info[ $coupon_code ] ); + $cart_contents[ $cart_item_key ][ self::DOKAN_COUPON_META_KEY ] = $coupon_info; + } + } + + $cart->set_cart_contents( $cart_contents ); + } +} diff --git a/includes/Vendor/Hooks.php b/includes/Vendor/Hooks.php index 95d5830e19..e30e339755 100644 --- a/includes/Vendor/Hooks.php +++ b/includes/Vendor/Hooks.php @@ -17,5 +17,8 @@ public function __construct() { // init Vendor Settings Manager new SettingsApi\Manager(); + + // vendor coupon distribution + new Coupon(); } } diff --git a/tests/php/src/Coupon/CouponTest.php b/tests/php/src/Coupon/CouponTest.php new file mode 100644 index 0000000000..210e7ee49f --- /dev/null +++ b/tests/php/src/Coupon/CouponTest.php @@ -0,0 +1,241 @@ +factory()->seller->create(); + $seller_id2 = $this->factory()->seller->create(); + + // create product category and assign to product + $category_id1 = $this->factory()->term->create( [ 'taxonomy' => 'product_cat' ] ); + + $product_id1 = $this->factory()->product + ->set_seller_id( $seller_id1 ) + ->create( + [ + 'regular_price' => 50, + 'price' => 50, + ] + ); + $product_id2 = $this->factory()->product + ->set_seller_id( $seller_id2 ) + ->create( + [ + 'regular_price' => 30, + 'price' => 30, + ] + ); + + $customer_id = $this->factory()->customer->create( [ 'email' => 'customer@gmail.com' ] ); + + $order_items = [ + 'status' => 'wc-completed', + 'customer_id' => $customer_id, + 'meta_data' => [], + 'line_items' => [ + [ + 'product_id' => $product_id1, // price 50 + 'quantity' => 2, + ], + [ + 'product_id' => $product_id2, // price 30 + 'quantity' => 1, + ], + ], + ]; + + $product = wc_get_product( $product_id1 ); + $product->set_category_ids( [ $category_id1 ] ); + $product->save(); + + return [ + 'discount_type_percentage' => [ + [ + 'coupons' => [ + [ + 'code' => 'percent-10', + 'status' => 'publish', + 'meta' => [ + 'discount_type' => 'percent', + 'coupon_amount' => 20, + ], + ], + ], + 'order_items' => $order_items, + ], + [ + 'discount' => 26, // 20% of 130 = 26 + ], + ], + 'discount_type_fixed_product' => [ + [ + 'coupons' => [ + [ + 'code' => 'fixed-product-5', + 'status' => 'publish', + 'meta' => [ + 'discount_type' => 'fixed_product', + 'coupon_amount' => 5, + ], + ], + ], + 'order_items' => $order_items, + ], + [ + 'discount' => 15, // Order items quantity 3, discount 5 = 3 * 5 = 15 + ], + ], + 'minimum_amount' => [ + [ + 'coupons' => [ + [ + 'code' => 'min-10', + 'status' => 'publish', + 'meta' => [ + 'discount_type' => 'percent', + 'coupon_amount' => 10, + 'minimum_amount' => 150, + ], + ], + ], + 'order_items' => $order_items, + ], + [ + 'discount' => 0, // Order total 130, minimum amount 150, so no discount + ], + ], + 'product_ids' => [ + [ + 'coupons' => [ + [ + 'code' => 'adm-10', + 'status' => 'publish', + 'meta' => [ + 'discount_type' => 'percent', + 'coupon_amount' => 10, + 'product_ids' => [ $product_id1 ], + ], + ], + ], + 'order_items' => $order_items, + ], + [ + 'discount' => 10, // Product id 1, discount 10% + ], + ], + 'product_categories' => [ + [ + 'coupons' => [ + [ + 'code' => 'cat-10', + 'status' => 'publish', + 'meta' => [ + 'discount_type' => 'percent', + 'coupon_amount' => 10, + 'product_categories' => [ $category_id1 ], + ], + ], + ], + 'order_items' => $order_items, + ], + [ + 'discount' => 10, // Product category 1, discount 10% + ], + ], + 'excluded_product_categories' => [ + [ + 'coupons' => [ + [ + 'code' => 'ex-cat-10', + 'status' => 'publish', + 'meta' => [ + 'discount_type' => 'percent', + 'coupon_amount' => 10, + 'excluded_product_categories' => [ $category_id1 ], + ], + ], + ], + 'order_items' => $order_items, + ], + [ + 'discount' => 13, // Excluded product category 1, discount 10% + ], + ], + 'email_restrictions' => [ + [ + 'coupons' => [ + [ + 'code' => 'email-10', + 'status' => 'publish', + 'meta' => [ + 'discount_type' => 'fixed_product', + 'coupon_amount' => 10, + 'customer_email' => [ 'customer@gmail.com' ], + ], + ], + ], + 'order_items' => $order_items, + ], + [ + 'discount' => 0, // Customer email is matched, discount 10 + ], + ], + 'exclude_product_ids' => [ + [ + 'coupons' => [ + [ + 'code' => 'ex-prod-10', + 'status' => 'publish', + 'meta' => [ + 'discount_type' => 'percent', + 'coupon_amount' => 10, + 'exclude_product_ids' => [ $product_id1 ], + ], + ], + ], + 'order_items' => $order_items, + ], + [ + 'discount' => 3, // Excluded product id 1, discount 10% + ], + ], + ]; + } + + /** + * Test coupon with all products. + * + * @dataProvider data_provider + */ + public function test_coupon( $input, $expected ) { + $order_factory = $this->factory()->order + ->set_item_shipping( + [ + 'name' => 'Flat Rate', + 'amount' => 10, + ] + ); + + foreach ( $input['coupons'] as $coupon ) { + $coupon_item = $this->factory()->coupon->create_and_get( $coupon ); + $order_factory->set_item_coupon( $coupon_item ); + } + + $order_id = $order_factory->create( $input['order_items'] ); + + $order = wc_get_order( $order_id ); + $this->assertEquals( $expected['discount'], $order->get_discount_total() ); + } +}