diff --git a/README.md b/README.md index 3b792dc..4ee076f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,16 @@ Generate orders with random dates between `--date-start` and `--date-end`. Generate orders with a specific status. - `wp wc generate orders --status=completed` +Apply coupons to a percentage of generated orders (0.0-1.0). If no coupons exist, 6 will be created automatically (3 fixed cart, 3 percentage). Note: `--coupons` flag is equivalent to `--coupon-ratio=1.0`. + +**Important:** Decimal ratios are converted to percentages using integer rounding. For example, `0.505` becomes 50% (not 50.5%) because the random generation uses integer comparison. Use whole percentages like `0.50` for precise 50% ratios. +- `wp wc generate orders --coupon-ratio=0.5` + +Refund a percentage of completed orders (0.0-1.0). Refunds will be split evenly between partial and full, and 25% of partial refunds will receive a second partial refund. + +**Note:** The same decimal ratio behavior applies to refund ratios as described above for coupon ratios. +- `wp wc generate orders --status=completed --refund-ratio=0.3` + #### Order Attribution Order Attribution represents the origin of data for an order. By default, random values are generated and assigned to the order. Orders with a creation date before 2024-01-09 will not have attribution metadata added, as the feature was not available in WooCommerce at that time. @@ -52,6 +62,9 @@ Generate coupons with a minimum discount amount. Generate coupons with a maximum discount amount. - `wp wc generate coupons --max=50` +Generate coupons with a specific discount type. Options are `fixed_cart` or `percent`. If not specified, defaults to WooCommerce default (fixed_cart). +- `wp wc generate coupons --discount_type=percent --min=5 --max=25` + ### Customers Generate customers based on the number of customers parameter. diff --git a/includes/CLI.php b/includes/CLI.php index de66ce3..823b3fe 100644 --- a/includes/CLI.php +++ b/includes/CLI.php @@ -316,7 +316,19 @@ function () use ( $progress ) { array( 'name' => 'coupons', 'type' => 'flag', - 'description' => 'Create and apply a coupon to each generated order.', + 'description' => 'Create and apply a coupon to each generated order. Equivalent to --coupon-ratio=1.0.', + 'optional' => true, + ), + array( + 'name' => 'coupon-ratio', + 'type' => 'assoc', + 'description' => 'Decimal ratio (0.0-1.0) of orders that should have coupons applied. If no coupons exist, 6 will be created (3 fixed value, 3 percentage). Note: Decimal values are converted to percentages using integer rounding (e.g., 0.505 becomes 50%).', + 'optional' => true, + ), + array( + 'name' => 'refund-ratio', + 'type' => 'assoc', + 'description' => 'Decimal ratio (0.0-1.0) of completed orders that should be refunded (wholly or partially). Note: Decimal values are converted to percentages using integer rounding (e.g., 0.505 becomes 50%).', 'optional' => true, ), array( @@ -381,8 +393,15 @@ function () use ( $progress ) { 'optional' => true, 'default' => 100, ), + array( + 'name' => 'discount_type', + 'type' => 'assoc', + 'description' => 'The type of discount for the coupon. If not specified, defaults to WooCommerce default (fixed_cart).', + 'optional' => true, + 'options' => array( 'fixed_cart', 'percent' ), + ), ), - 'longdesc' => "## EXAMPLES\n\nwc generate coupons 10\n\nwc generate coupons 50 --min=1 --max=50", + 'longdesc' => "## EXAMPLES\n\nwc generate coupons 10\n\nwc generate coupons 50 --min=1 --max=50\n\nwc generate coupons 20 --discount_type=percent --min=5 --max=25", ) ); WP_CLI::add_command( 'wc generate terms', array( 'WC\SmoothGenerator\CLI', 'terms' ), array( diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index 59ca84e..aea0425 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -24,13 +24,16 @@ class Coupon extends Generator { public static function generate( $save = true, $assoc_args = array() ) { parent::maybe_initialize_generators(); - $defaults = array( - 'min' => 5, - 'max' => 100, - ); + $defaults = array( + 'min' => 5, + 'max' => 100, + 'discount_type' => 'fixed_cart', + ); + + $args = wp_parse_args( $assoc_args, $defaults ); list( 'min' => $min, 'max' => $max ) = filter_var_array( - wp_parse_args( $assoc_args, $defaults ), + $args, array( 'min' => array( 'filter' => FILTER_VALIDATE_INT, @@ -68,6 +71,15 @@ public static function generate( $save = true, $assoc_args = array() ) { ); } + // Validate discount_type if provided + $discount_type = ! empty( $args['discount_type'] ) ? $args['discount_type'] : ''; + if ( ! empty( $discount_type ) && ! in_array( $discount_type, array( 'fixed_cart', 'percent' ), true ) ) { + return new \WP_Error( + 'smoothgenerator_coupon_invalid_discount_type', + 'The discount_type must be either "fixed_cart" or "percent".' + ); + } + $code = substr( self::$faker->promotionCode( 1 ), 0, -1 ); // Omit the random digit. $amount = self::$faker->numberBetween( $min, $max ); $coupon_code = sprintf( @@ -76,11 +88,18 @@ public static function generate( $save = true, $assoc_args = array() ) { $amount ); - $coupon = new \WC_Coupon( $coupon_code ); - $coupon->set_props( array( + $props = array( 'code' => $coupon_code, 'amount' => $amount, - ) ); + ); + + // Only set discount_type if explicitly provided + if ( ! empty( $discount_type ) ) { + $props['discount_type'] = $discount_type; + } + + $coupon = new \WC_Coupon( $coupon_code ); + $coupon->set_props( $props ); if ( $save ) { $data_store = WC_Data_Store::load( 'coupon' ); @@ -125,5 +144,39 @@ public static function batch( $amount, array $args = array() ) { return $coupon_ids; } + + /** + * Get a random existing coupon. + * + * @param array|null $cached_coupon_ids Optional array of coupon IDs to use instead of querying. + * @return \WC_Coupon|false Coupon object or false if none available. + */ + public static function get_random( $cached_coupon_ids = null ) { + // Use cached IDs if provided (batch mode optimization) + if ( null !== $cached_coupon_ids && ! empty( $cached_coupon_ids ) ) { + $coupon_ids = $cached_coupon_ids; + } else { + // Fallback to querying for coupon IDs + // Note: Using posts_per_page=-1 loads all coupons into memory for random selection. + // For stores with thousands of coupons, consider using direct SQL with RAND() for better performance. + // This approach was chosen for consistency with WordPress APIs and to avoid raw SQL queries. + $coupon_ids = get_posts( + array( + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + ) + ); + } + + if ( empty( $coupon_ids ) ) { + return false; + } + + $random_coupon_id = $coupon_ids[ array_rand( $coupon_ids ) ]; + + return new \WC_Coupon( $random_coupon_id ); + } } diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index b8fbb64..0154d3a 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -12,6 +12,48 @@ */ class Order extends Generator { + /** + * Probability (percentage) that a partial refund will receive a second refund. + */ + const SECOND_REFUND_PROBABILITY = 25; + + /** + * Maximum ratio of order total that can be refunded in a partial refund. + * Ensures partial refunds don't exceed 50% of order total. + */ + const MAX_PARTIAL_REFUND_RATIO = 0.5; + + /** + * Maximum days after order completion for first refund (2 months). + */ + const FIRST_REFUND_MAX_DAYS = 60; + + /** + * Maximum days after first refund for second refund (1 month). + */ + const SECOND_REFUND_MAX_DAYS = 30; + + /* + * Cached product IDs for batch operations. + * + * @var array|null + */ + protected static $batch_product_ids = null; + + /** + * Cached coupon IDs for batch operations. + * + * @var array|null + */ + protected static $batch_coupon_ids = null; + + /** + * Cached customer IDs for batch operations. + * + * @var array|null + */ + protected static $batch_customer_ids = null; + /** * Return a new order. * @@ -25,10 +67,16 @@ public static function generate( $save = true, $assoc_args = array() ) { $order = new \WC_Order(); $customer = self::get_customer(); if ( ! $customer instanceof \WC_Customer ) { + error_log( 'Order generation failed: Could not generate or retrieve customer' ); return false; } $products = self::get_random_products( 1, 10 ); + if ( empty( $products ) ) { + error_log( 'Order generation failed: No products available to add to order' ); + return false; + } + foreach ( $products as $product ) { $quantity = self::$faker->numberBetween( 1, 10 ); $order->add_product( $product, $quantity ); @@ -89,15 +137,47 @@ public static function generate( $save = true, $assoc_args = array() ) { $order->set_date_created( $date ); + // Handle legacy --coupons flag $include_coupon = ! empty( $assoc_args['coupons'] ); + + // Handle --coupon-ratio parameter + if ( isset( $assoc_args['coupon-ratio'] ) ) { + $coupon_ratio = floatval( $assoc_args['coupon-ratio'] ); + + // Validate ratio is between 0.0 and 1.0 + if ( $coupon_ratio < 0.0 || $coupon_ratio > 1.0 ) { + $coupon_ratio = max( 0.0, min( 1.0, $coupon_ratio ) ); + } + + // Apply coupon based on ratio + if ( $coupon_ratio >= 1.0 ) { + $include_coupon = true; + } elseif ( $coupon_ratio > 0 && wp_rand( 1, 100 ) <= ( $coupon_ratio * 100 ) ) { + $include_coupon = true; + } else { + $include_coupon = false; + } + } + if ( $include_coupon ) { - $coupon = Coupon::generate( true ); - $order->apply_coupon( $coupon ); + $coupon = self::get_or_create_coupon(); + if ( $coupon ) { + $apply_result = $order->apply_coupon( $coupon ); + if ( is_wp_error( $apply_result ) ) { + error_log( 'Coupon application failed: ' . $apply_result->get_error_message() . ' (Coupon: ' . $coupon->get_code() . ')' ); + } else { + // Recalculate totals after applying coupon + $order->calculate_totals( true ); + } + } } - // Orders created before 2024-01-09 represents orders created before the attribution feature was added. + // Orders created before 2024-01-09 represents orders created before the attribution feature was added. if ( ! ( strtotime( $date ) < strtotime( '2024-01-09' ) ) ) { - OrderAttribution::add_order_attribution_meta( $order, $assoc_args ); + $attribution_result = OrderAttribution::add_order_attribution_meta( $order, $assoc_args ); + if ( $attribution_result && is_wp_error( $attribution_result ) ) { + error_log( 'Order attribution meta addition failed: ' . $attribution_result->get_error_message() ); + } } // Set paid and completed dates based on order status. @@ -113,7 +193,41 @@ public static function generate( $save = true, $assoc_args = array() ) { } if ( $save ) { - $order->save(); + $save_result = $order->save(); + if ( is_wp_error( $save_result ) ) { + error_log( 'Order save failed: ' . $save_result->get_error_message() ); + return false; + } + + // Handle --refund-ratio parameter for completed orders + if ( isset( $assoc_args['refund-ratio'] ) && 'completed' === $status ) { + $refund_ratio = floatval( $assoc_args['refund-ratio'] ); + + // Validate ratio is between 0.0 and 1.0 + if ( $refund_ratio < 0.0 || $refund_ratio > 1.0 ) { + $refund_ratio = max( 0.0, min( 1.0, $refund_ratio ) ); + } + + $should_refund = false; + + if ( $refund_ratio >= 1.0 ) { + // Always refund if ratio is 1.0 or higher + $should_refund = true; + } elseif ( $refund_ratio > 0 && wp_rand( 1, 100 ) <= ( $refund_ratio * 100 ) ) { + // Use random chance for ratios between 0 and 1 + $should_refund = true; + } + + if ( $should_refund ) { + // Create first refund with date within 2 months of completion + $first_refund = self::create_refund( $order ); + + // Some partial refunds get a second refund (always partial) + if ( $first_refund && is_object( $first_refund ) && wp_rand( 1, 100 ) <= self::SECOND_REFUND_PROBABILITY ) { + self::create_refund( $order, true, $first_refund ); + } + } + } } /** @@ -139,16 +253,27 @@ public static function generate( $save = true, $assoc_args = array() ) { public static function batch( $amount, array $args = array() ) { $amount = self::validate_batch_amount( $amount ); if ( is_wp_error( $amount ) ) { + error_log( 'Batch generation failed: ' . $amount->get_error_message() ); return $amount; } + // Initialize batch cache to avoid repeated queries + self::init_batch_cache( $args ); + $order_ids = array(); for ( $i = 1; $i <= $amount; $i ++ ) { - $order = self::generate( true, $args ); + $order = self::generate( true, $args ); + if ( ! $order ) { + error_log( "Batch generation failed: Order {$i} of {$amount} could not be generated" ); + continue; + } $order_ids[] = $order->get_id(); } + // Clear batch cache after generation + self::clear_batch_cache(); + return $order_ids; } @@ -164,14 +289,24 @@ public static function get_customer() { $existing = (bool) wp_rand( 0, 1 ); if ( $existing ) { - $total_users = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ); - $offset = wp_rand( 0, $total_users ); - $user_id = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->users} ORDER BY rand() LIMIT $offset, 1" ); // phpcs:ignore + // Use cached customer IDs if available (batch mode) + if ( null !== self::$batch_customer_ids && ! empty( self::$batch_customer_ids ) ) { + $user_id = self::$batch_customer_ids[ array_rand( self::$batch_customer_ids ) ]; + } else { + // Fallback to direct query for single order generation + $total_users = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ); + $offset = wp_rand( 0, $total_users ); + $user_id = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->users} ORDER BY rand() LIMIT $offset, 1" ); // phpcs:ignore + } return new \WC_Customer( $user_id ); } $customer = Customer::generate( ! $guest ); + if ( ! $customer instanceof \WC_Customer ) { + error_log( 'Customer generation failed: Customer::generate() returned invalid result' ); + } + return $customer; } @@ -238,41 +373,519 @@ protected static function get_random_products( int $min_amount = 1, int $max_amo $products = array(); - $num_existing_products = (int) $wpdb->get_var( - "SELECT COUNT( DISTINCT ID ) - FROM {$wpdb->posts} - WHERE 1=1 - AND post_type='product' - AND post_status='publish'" - ); + // Use cached product IDs if available (batch mode) + if ( null !== self::$batch_product_ids && ! empty( self::$batch_product_ids ) ) { + $num_existing_products = count( self::$batch_product_ids ); + $num_products_to_get = wp_rand( $min_amount, $max_amount ); - $num_products_to_get = wp_rand( $min_amount, $max_amount ); + if ( $num_products_to_get > $num_existing_products ) { + $num_products_to_get = $num_existing_products; + } - if ( $num_products_to_get > $num_existing_products ) { - $num_products_to_get = $num_existing_products; - } + // Safety check: ensure we have products to select + if ( $num_products_to_get <= 0 ) { + return $products; + } - $query = new \WC_Product_Query( array( - 'limit' => $num_products_to_get, - 'return' => 'ids', - 'orderby' => 'rand', - ) ); + // Get random product IDs from cache + $random_keys = array_rand( self::$batch_product_ids, $num_products_to_get ); + if ( ! is_array( $random_keys ) ) { + $random_keys = array( $random_keys ); + } - foreach ( $query->get_products() as $product_id ) { + $product_ids = array(); + foreach ( $random_keys as $key ) { + $product_ids[] = self::$batch_product_ids[ $key ]; + } + } else { + // Fallback to direct query for single order generation + $num_existing_products = (int) $wpdb->get_var( + "SELECT COUNT( DISTINCT ID ) + FROM {$wpdb->posts} + WHERE 1=1 + AND post_type='product' + AND post_status='publish'" + ); + + $num_products_to_get = wp_rand( $min_amount, $max_amount ); + + if ( $num_products_to_get > $num_existing_products ) { + $num_products_to_get = $num_existing_products; + } + + $query = new \WC_Product_Query( array( + 'limit' => $num_products_to_get, + 'return' => 'ids', + 'orderby' => 'rand', + ) ); + + $product_ids = $query->get_products(); + } + + foreach ( $product_ids as $product_id ) { $product = wc_get_product( $product_id ); + if ( ! $product ) { + error_log( "Failed to retrieve product with ID: {$product_id}" ); + continue; + } + if ( $product->is_type( 'variable' ) ) { $available_variations = $product->get_available_variations(); if ( empty( $available_variations ) ) { continue; } $index = self::$faker->numberBetween( 0, count( $available_variations ) - 1 ); - $products[] = new \WC_Product_Variation( $available_variations[ $index ]['variation_id'] ); + $variation = new \WC_Product_Variation( $available_variations[ $index ]['variation_id'] ); + if ( $variation && $variation->exists() ) { + $products[] = $variation; + } } else { - $products[] = new \WC_Product( $product_id ); + $products[] = $product; } } return $products; } + + /** + * Get a random existing coupon or create coupons if none exist. + * If no coupons exist, creates 6 coupons: 3 fixed value and 3 percentage. + * + * @return \WC_Coupon|false Coupon object or false if none available. + */ + protected static function get_or_create_coupon() { + // Try to get a random existing coupon (pass cached IDs if available) + $coupon = Coupon::get_random( self::$batch_coupon_ids ); + + // If no coupons exist, create 6 (3 fixed, 3 percentage) + if ( false === $coupon ) { + if ( class_exists( 'WP_CLI' ) ) { + \WP_CLI::log( 'No coupons found. Creating 6 coupons (3 fixed cart $5-$50, 3 percentage 5%-25%)...' ); + } + + // Create 3 fixed cart coupons ($5-$50) + $fixed_result = Coupon::batch( 3, array( 'min' => 5, 'max' => 50, 'discount_type' => 'fixed_cart' ) ); + + // Create 3 percentage coupons (5%-25%) + $percent_result = Coupon::batch( 3, array( 'min' => 5, 'max' => 25, 'discount_type' => 'percent' ) ); + + // If coupon creation failed, return false + if ( is_wp_error( $fixed_result ) || is_wp_error( $percent_result ) ) { + $error_message = 'Coupon creation failed: '; + if ( is_wp_error( $fixed_result ) ) { + $error_message .= 'Fixed coupons error: ' . $fixed_result->get_error_message() . ' '; + } + if ( is_wp_error( $percent_result ) ) { + $error_message .= 'Percentage coupons error: ' . $percent_result->get_error_message(); + } + error_log( $error_message ); + return false; + } + + // Update batch cache with newly created coupon IDs + if ( null !== self::$batch_coupon_ids ) { + self::$batch_coupon_ids = array_merge( self::$batch_coupon_ids, $fixed_result, $percent_result ); + } + + // Now get a random coupon from the ones we just created + $coupon = Coupon::get_random( self::$batch_coupon_ids ); + } + + return $coupon; + } + + /** + * Create a refund for an order (either full or partial). + * + * @param \WC_Order $order The order to refund. + * @param bool $force_partial Force partial refund only. + * @param \WC_Order_Refund|null $previous_refund Previous refund to base date on (for second refunds). + * @return \WC_Order_Refund|false Refund object on success, false on failure. + */ + protected static function create_refund( $order, $force_partial = false, $previous_refund = null ) { + if ( ! $order instanceof \WC_Order ) { + error_log( "Error: Order is not an instance of \WC_Order: " . print_r( $order, true ) ); + return false; + } + + // Check if order already has refunds + $existing_refunds = $order->get_refunds(); + if ( ! empty( $existing_refunds ) ) { + $force_partial = true; + } + + // Calculate already refunded quantities + $refunded_qty_by_item = self::calculate_refunded_quantities( $existing_refunds ); + + // Determine refund type (full or partial) + $is_full_refund = $force_partial ? false : (bool) wp_rand( 0, 1 ); + + // Build refund line items + $line_items = $is_full_refund + ? self::build_full_refund_items( $order, $refunded_qty_by_item ) + : self::build_partial_refund_items( $order, $refunded_qty_by_item ); + + // Ensure we have items to refund + if ( empty( $line_items ) ) { + error_log( sprintf( + 'Refund skipped for order %d: No line items to refund. Order has %d items.', + $order->get_id(), + count( $order->get_items( array( 'line_item', 'fee' ) ) ) + ) ); + return false; + } + + // Calculate refund totals + $totals = self::calculate_refund_totals( $line_items ); + $refund_amount = $totals['amount']; + $total_items = $totals['total_items']; + $total_qty = $totals['total_qty']; + + // For full refunds, use order's actual remaining total to avoid rounding discrepancies + if ( $is_full_refund ) { + $refund_amount = round( $order->get_total() - $order->get_total_refunded(), 2 ); + } + + // For partial refunds, ensure refund is < 50% of order total + if ( ! $is_full_refund ) { + $max_partial_refund = $order->get_total() * self::MAX_PARTIAL_REFUND_RATIO; + + // Remove items until refund is under threshold + while ( $refund_amount >= $max_partial_refund && count( $line_items ) > 1 ) { + unset( $line_items[ array_rand( $line_items ) ] ); + $totals = self::calculate_refund_totals( $line_items ); + $refund_amount = $totals['amount']; + $total_items = $totals['total_items']; + $total_qty = $totals['total_qty']; + } + } + + // Cap refund amount to maximum available + $max_refund = round( $order->get_total() - $order->get_total_refunded(), 2 ); + if ( $refund_amount > $max_refund ) { + $refund_amount = $max_refund; + } + + // Validate refund amount + if ( $refund_amount <= 0 ) { + error_log( sprintf( + 'Refund skipped for order %d: Invalid refund amount (%s). Order total: %s, Already refunded: %s', + $order->get_id(), + $refund_amount, + $order->get_total(), + $order->get_total_refunded() + ) ); + return false; + } + + // Create refund reason + $reason = $is_full_refund + ? 'Full refund' + : sprintf( + 'Partial refund - %d %s, %d %s', + $total_items, + $total_items === 1 ? 'product' : 'products', + $total_qty, + $total_qty === 1 ? 'item' : 'items' + ); + + // Calculate refund date + $refund_date = self::calculate_refund_date( $order, $previous_refund ); + + // Create the refund + $refund = wc_create_refund( + array( + 'order_id' => $order->get_id(), + 'amount' => $refund_amount, + 'reason' => $reason, + 'line_items' => $line_items, + 'date_created' => $refund_date, + ) + ); + + if ( is_wp_error( $refund ) ) { + error_log( sprintf( + "Refund creation failed for order %d:\nError: %s\nCalculated Amount: %s\nOrder Total: %s\nOrder Refunded Total: %s\nReason: %s\nLine Items: %s", + $order->get_id(), + $refund->get_error_message(), + $refund_amount, + $order->get_total(), + $order->get_total_refunded(), + $reason, + print_r( $line_items, true ) + ) ); + return false; + } + + // Update order status to refunded if it's a full refund + if ( $is_full_refund ) { + $order->set_status( 'refunded' ); + $order->save(); + } + + return $refund; + } + + /** + * Calculate already refunded quantities per item from existing refunds. + * + * @param array $existing_refunds Array of existing refund objects. + * @return array Associative array of item_id => refunded_quantity. + */ + protected static function calculate_refunded_quantities( $existing_refunds ) { + $refunded_qty_by_item = array(); + + foreach ( $existing_refunds as $existing_refund ) { + foreach ( $existing_refund->get_items( array( 'line_item', 'fee' ) ) as $refund_item ) { + $item_id = $refund_item->get_meta( '_refunded_item_id' ); + if ( ! $item_id ) { + continue; + } + if ( ! isset( $refunded_qty_by_item[ $item_id ] ) ) { + $refunded_qty_by_item[ $item_id ] = 0; + } + $refunded_qty_by_item[ $item_id ] += abs( $refund_item->get_quantity() ); + } + } + + return $refunded_qty_by_item; + } + + /** + * Build a refund line item with proper tax and total calculations. + * + * @param \WC_Order_Item $item Order item to refund. + * @param int $refund_qty Quantity to refund. + * @param int $original_qty Original quantity of the item. + * @return array Refund line item data. + */ + protected static function build_refund_line_item( $item, $refund_qty, $original_qty ) { + $taxes = $item->get_taxes(); + $refund_tax = array(); + + // Prorate tax based on refund quantity + if ( ! empty( $taxes['total'] ) && $original_qty > 0 ) { + foreach ( $taxes['total'] as $tax_id => $tax_amount ) { + $tax_per_unit = $tax_amount / $original_qty; + $refund_tax[ $tax_id ] = ( $tax_per_unit * $refund_qty ) * -1; + } + } + + // Prorate the refund total based on refund quantity + $total_per_unit = $original_qty > 0 ? $item->get_total() / $original_qty : 0; + $refund_total = $total_per_unit * $refund_qty; + + return array( + 'qty' => $refund_qty, + 'refund_total' => $refund_total * -1, + 'refund_tax' => $refund_tax, + ); + } + + /** + * Build line items for a full refund. + * + * @param \WC_Order $order Order to refund. + * @param array $refunded_qty_by_item Already refunded quantities. + * @return array Refund line items. + */ + protected static function build_full_refund_items( $order, $refunded_qty_by_item ) { + $line_items = array(); + + foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) { + $original_qty = $item->get_quantity(); + $refunded_qty = isset( $refunded_qty_by_item[ $item_id ] ) ? $refunded_qty_by_item[ $item_id ] : 0; + $remaining_qty = $original_qty - $refunded_qty; + + // Skip if nothing left to refund or invalid quantity + if ( $remaining_qty <= 0 || $original_qty <= 0 ) { + continue; + } + + $line_items[ $item_id ] = self::build_refund_line_item( $item, $remaining_qty, $original_qty ); + } + + return $line_items; + } + + /** + * Build line items for a partial refund. + * + * @param \WC_Order $order Order to refund. + * @param array $refunded_qty_by_item Already refunded quantities. + * @return array Refund line items. + */ + protected static function build_partial_refund_items( $order, $refunded_qty_by_item ) { + $items = $order->get_items( array( 'line_item', 'fee' ) ); + $line_items = array(); + + // Decide whether to refund full items or partial quantities + $refund_full_items = (bool) wp_rand( 0, 1 ); + + if ( $refund_full_items && count( $items ) > 2 ) { + // Refund a random subset of items completely (requires at least 3 items) + $items_array = array_values( $items ); + $num_to_refund = wp_rand( 1, count( $items_array ) - 1 ); + $items_to_refund = array_rand( $items_array, $num_to_refund ); + + // Ensure $items_to_refund is always an array for consistent iteration + if ( ! is_array( $items_to_refund ) ) { + $items_to_refund = array( $items_to_refund ); + } + + foreach ( $items_to_refund as $index ) { + $item = $items_array[ $index ]; + $item_id = $item->get_id(); + $original_qty = $item->get_quantity(); + $refunded_qty = isset( $refunded_qty_by_item[ $item_id ] ) ? $refunded_qty_by_item[ $item_id ] : 0; + $remaining_qty = $original_qty - $refunded_qty; + + // Skip if nothing left to refund or invalid quantity + if ( $remaining_qty <= 0 || $original_qty <= 0 ) { + continue; + } + + $line_items[ $item_id ] = self::build_refund_line_item( $item, $remaining_qty, $original_qty ); + } + } else { + // Refund partial quantities of items + foreach ( $items as $item_id => $item ) { + $original_qty = $item->get_quantity(); + $refunded_qty = isset( $refunded_qty_by_item[ $item_id ] ) ? $refunded_qty_by_item[ $item_id ] : 0; + $remaining_qty = $original_qty - $refunded_qty; + + // Skip if nothing left to refund, if only 1 remaining, or invalid quantity + if ( $remaining_qty <= 1 || $original_qty <= 0 ) { + continue; + } + + // Only refund line items with remaining quantity > 1 + if ( 'line_item' === $item->get_type() ) { + $refund_qty = wp_rand( 1, $remaining_qty - 1 ); + $line_items[ $item_id ] = self::build_refund_line_item( $item, $refund_qty, $original_qty ); + break; // Only refund one item partially + } + } + + // If no items were added, refund one complete remaining item + if ( empty( $line_items ) && count( $items ) > 0 ) { + $items_array = array_values( $items ); + shuffle( $items_array ); + + foreach ( $items_array as $item ) { + $item_id = $item->get_id(); + $original_qty = $item->get_quantity(); + $refunded_qty = isset( $refunded_qty_by_item[ $item_id ] ) ? $refunded_qty_by_item[ $item_id ] : 0; + $remaining_qty = $original_qty - $refunded_qty; + + // Skip if nothing left to refund or invalid quantity + if ( $remaining_qty <= 0 || $original_qty <= 0 ) { + continue; + } + + $line_items[ $item_id ] = self::build_refund_line_item( $item, $remaining_qty, $original_qty ); + break; // Only refund one item + } + } + } + + return $line_items; + } + + /** + * Calculate total refund amount and item counts from line items. + * + * @param array $line_items Refund line items. + * @return array Array containing 'amount', 'total_items', and 'total_qty'. + */ + protected static function calculate_refund_totals( $line_items ) { + $refund_amount = 0; + $total_items = 0; + $total_qty = 0; + + foreach ( $line_items as $item_data ) { + // Add item total: refund amounts are stored as negative, convert to positive for total calculation + $refund_amount += abs( $item_data['refund_total'] ); + $total_items++; + $total_qty += $item_data['qty']; + + // Add tax amounts + if ( ! empty( $item_data['refund_tax'] ) ) { + foreach ( $item_data['refund_tax'] as $tax_amount ) { + $refund_amount += abs( $tax_amount ); + } + } + } + + return array( + 'amount' => round( $refund_amount, 2 ), + 'total_items' => $total_items, + 'total_qty' => $total_qty, + ); + } + + /** + * Calculate a realistic refund date based on order completion or previous refund. + * + * @param \WC_Order $order Order being refunded. + * @param \WC_Order_Refund|null $previous_refund Previous refund (for second refunds). + * @return string Refund date in 'Y-m-d H:i:s' format. + */ + protected static function calculate_refund_date( $order, $previous_refund = null ) { + if ( $previous_refund ) { + // Second refund: within 1 month of first refund + $base_date = $previous_refund->get_date_created(); + $max_days = self::SECOND_REFUND_MAX_DAYS; + } else { + // First refund: within 2 months of order completion + $base_date = $order->get_date_completed(); + $max_days = self::FIRST_REFUND_MAX_DAYS; + } + + $random_days = wp_rand( 0, $max_days ); + return date( 'Y-m-d H:i:s', strtotime( $base_date->date( 'Y-m-d H:i:s' ) ) + ( $random_days * DAY_IN_SECONDS ) ); + } + + /** + * Initialize batch cache by pre-loading IDs for products, coupons, and customers. + * This significantly improves performance when generating multiple orders by avoiding + * repeated database queries. + * + * @param array $args Arguments passed to batch generation (used to determine what to cache). + * @return void + */ + protected static function init_batch_cache( $args ) { + global $wpdb; + + // Load all product IDs once + self::$batch_product_ids = $wpdb->get_col( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type='product' + AND post_status='publish'" + ); + + // Load coupon IDs if coupon ratio is set + if ( isset( $args['coupon-ratio'] ) || isset( $args['coupons'] ) ) { + self::$batch_coupon_ids = $wpdb->get_col( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type='shop_coupon' + AND post_status='publish'" + ); + } + + // Load customer IDs + self::$batch_customer_ids = $wpdb->get_col( "SELECT ID FROM {$wpdb->users}" ); + } + + /** + * Clear batch cache after batch generation is complete. + * + * @return void + */ + protected static function clear_batch_cache() { + self::$batch_product_ids = null; + self::$batch_coupon_ids = null; + self::$batch_customer_ids = null; + } }