From 161c8024a5173d8c1a40e1277951cabd1212feff Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:13:56 +0200 Subject: [PATCH 01/40] Add --coupon-ratio parameter to order generator --- includes/CLI.php | 6 +++ includes/Generator/Order.php | 86 +++++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/includes/CLI.php b/includes/CLI.php index de66ce3..be8a61c 100644 --- a/includes/CLI.php +++ b/includes/CLI.php @@ -319,6 +319,12 @@ function () use ( $progress ) { 'description' => 'Create and apply a coupon to each generated order.', '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).', + 'optional' => true, + ), array( 'name' => 'skip-order-attribution', 'type' => 'flag', diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index b8fbb64..0e69902 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -89,10 +89,25 @@ 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 ( ! empty( $assoc_args['coupon-ratio'] ) ) { + $coupon_ratio = floatval( $assoc_args['coupon-ratio'] ); + // Apply coupon based on ratio + if ( $coupon_ratio > 0 && ( $coupon_ratio >= 1.0 || ( mt_rand() / mt_getrandmax() ) < $coupon_ratio ) ) { + $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 ) { + $order->apply_coupon( $coupon ); + } } // Orders created before 2024-01-09 represents orders created before the attribution feature was added. @@ -275,4 +290,71 @@ protected static function get_random_products( int $min_amount = 1, int $max_amo 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|null Coupon object or null if none available. + */ + protected static function get_or_create_coupon() { + global $wpdb; + + // Check if any coupons exist + $coupon_count = (int) $wpdb->get_var( + "SELECT COUNT(*) + FROM {$wpdb->posts} + WHERE post_type = 'shop_coupon' + AND post_status = 'publish'" + ); + + // If no coupons exist, create 6 (3 fixed, 3 percentage) + if ( $coupon_count === 0 ) { + // Create 3 fixed value coupons + for ( $i = 0; $i < 3; $i++ ) { + $coupon = new \WC_Coupon(); + $amount = self::$faker->numberBetween( 5, 50 ); + $code = 'fixed' . $amount . '-' . self::$faker->lexify( '???' ); + + $coupon->set_code( $code ); + $coupon->set_discount_type( 'fixed_cart' ); + $coupon->set_amount( $amount ); + $coupon->save(); + } + + // Create 3 percentage coupons + for ( $i = 0; $i < 3; $i++ ) { + $coupon = new \WC_Coupon(); + $amount = self::$faker->numberBetween( 5, 25 ); + $code = 'percent' . $amount . '-' . self::$faker->lexify( '???' ); + + $coupon->set_code( $code ); + $coupon->set_discount_type( 'percent' ); + $coupon->set_amount( $amount ); + $coupon->save(); + } + + $coupon_count = 6; + } + + // Get a random coupon + $offset = wp_rand( 0, $coupon_count - 1 ); + $coupon_id = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT ID + FROM {$wpdb->posts} + WHERE post_type = 'shop_coupon' + AND post_status = 'publish' + ORDER BY ID + LIMIT %d, 1", + $offset + ) + ); + + if ( $coupon_id ) { + return new \WC_Coupon( $coupon_id ); + } + + return null; + } } From df0fda0ff0fbd40500553de392332510a1cb388b Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:14:32 +0200 Subject: [PATCH 02/40] Add --refund-ratio parameter to order generator --- includes/CLI.php | 6 ++++ includes/Generator/Order.php | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/includes/CLI.php b/includes/CLI.php index be8a61c..de0c072 100644 --- a/includes/CLI.php +++ b/includes/CLI.php @@ -325,6 +325,12 @@ function () use ( $progress ) { '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).', '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).', + 'optional' => true, + ), array( 'name' => 'skip-order-attribution', 'type' => 'flag', diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 0e69902..2cadada 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -129,6 +129,15 @@ public static function generate( $save = true, $assoc_args = array() ) { if ( $save ) { $order->save(); + + // Handle --refund-ratio parameter for completed orders + if ( ! empty( $assoc_args['refund-ratio'] ) && 'completed' === $status ) { + $refund_ratio = floatval( $assoc_args['refund-ratio'] ); + // Apply refund based on ratio + if ( $refund_ratio > 0 && ( $refund_ratio >= 1.0 || ( mt_rand() / mt_getrandmax() ) < $refund_ratio ) ) { + self::create_refund( $order ); + } + } } /** @@ -357,4 +366,52 @@ protected static function get_or_create_coupon() { return null; } + + /** + * Create a refund for an order (either full or partial). + * + * @param \WC_Order $order The order to refund. + * @return \WC_Order_Refund|null The refund object or null on failure. + */ + protected static function create_refund( $order ) { + if ( ! $order instanceof \WC_Order ) { + return null; + } + + $order_total = $order->get_total(); + + // 50% chance of full refund, 50% chance of partial refund + $is_full_refund = (bool) wp_rand( 0, 1 ); + + if ( $is_full_refund ) { + // Full refund + $refund_amount = $order_total; + } else { + // Partial refund (between 20% and 80% of order total) + $refund_percentage = self::$faker->numberBetween( 20, 80 ) / 100; + $refund_amount = round( $order_total * $refund_percentage, 2 ); + } + + // Create the refund + $refund = wc_create_refund( + array( + 'order_id' => $order->get_id(), + 'amount' => $refund_amount, + 'reason' => $is_full_refund ? 'Full refund' : 'Partial refund', + 'line_items' => array(), + ) + ); + + if ( is_wp_error( $refund ) ) { + return null; + } + + // Update order status to refunded if it's a full refund + if ( $is_full_refund ) { + $order->set_status( 'refunded' ); + $order->save(); + } + + return $refund; + } } From 4d3eb9d9564d5a2f006fd3c66e966d7ae0e339de Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:16:19 +0200 Subject: [PATCH 03/40] Improve refund logic to properly handle line items and fees --- includes/Generator/Order.php | 101 +++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 9 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 2cadada..9c2d3bb 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -378,27 +378,110 @@ protected static function create_refund( $order ) { return null; } - $order_total = $order->get_total(); - // 50% chance of full refund, 50% chance of partial refund $is_full_refund = (bool) wp_rand( 0, 1 ); + $line_items = array(); + if ( $is_full_refund ) { - // Full refund - $refund_amount = $order_total; + // Full refund - include all line items and fees + foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) { + $line_items[ $item_id ] = array( + 'qty' => $item->get_quantity(), + 'refund_total' => $item->get_total() * -1, + 'refund_tax' => array_map( + function( $tax ) { + return $tax * -1; + }, + $item->get_taxes()['total'] + ), + ); + } } else { - // Partial refund (between 20% and 80% of order total) - $refund_percentage = self::$faker->numberBetween( 20, 80 ) / 100; - $refund_amount = round( $order_total * $refund_percentage, 2 ); + // Partial refund - randomly select items or partial quantities + $items = $order->get_items( array( 'line_item', 'fee' ) ); + + // Decide whether to refund full items or partial quantities + $refund_full_items = (bool) wp_rand( 0, 1 ); + + if ( $refund_full_items && count( $items ) > 1 ) { + // Refund a random subset of items completely + $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 ); + + // array_rand returns int if count is 1, array otherwise + 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(); + + $line_items[ $item_id ] = array( + 'qty' => $item->get_quantity(), + 'refund_total' => $item->get_total() * -1, + 'refund_tax' => array_map( + function( $tax ) { + return $tax * -1; + }, + $item->get_taxes()['total'] + ), + ); + } + } else { + // Refund partial quantities of items + foreach ( $items as $item_id => $item ) { + $quantity = $item->get_quantity(); + + // Only refund line items with quantity > 1 + if ( 'line_item' === $item->get_type() && $quantity > 1 ) { + // Refund between 1 and quantity-1 items + $refund_qty = wp_rand( 1, $quantity - 1 ); + $refund_amount = ( $item->get_total() / $quantity ) * $refund_qty; + + $line_items[ $item_id ] = array( + 'qty' => $refund_qty, + 'refund_total' => $refund_amount * -1, + 'refund_tax' => array_map( + function( $tax ) use ( $quantity, $refund_qty ) { + return ( $tax / $quantity ) * $refund_qty * -1; + }, + $item->get_taxes()['total'] + ), + ); + break; // Only refund one item partially + } + } + + // If no items were added (all quantities were 1), refund one complete item + if ( empty( $line_items ) && count( $items ) > 0 ) { + $items_array = array_values( $items ); + $item = $items_array[ array_rand( $items_array ) ]; + $item_id = $item->get_id(); + + $line_items[ $item_id ] = array( + 'qty' => $item->get_quantity(), + 'refund_total' => $item->get_total() * -1, + 'refund_tax' => array_map( + function( $tax ) { + return $tax * -1; + }, + $item->get_taxes()['total'] + ), + ); + } + } } // Create the refund $refund = wc_create_refund( array( 'order_id' => $order->get_id(), - 'amount' => $refund_amount, + 'amount' => null, // Let WooCommerce calculate the amount from line items 'reason' => $is_full_refund ? 'Full refund' : 'Partial refund', - 'line_items' => array(), + 'line_items' => $line_items, ) ); From 4dd4a966154d4a71ba38f4d68e784aade43e5b83 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:23:08 +0200 Subject: [PATCH 04/40] Fix refund tax calculation to properly handle tax rate IDs --- includes/Generator/Order.php | 67 +++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 9c2d3bb..1aa1b84 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -386,15 +386,19 @@ protected static function create_refund( $order ) { if ( $is_full_refund ) { // Full refund - include all line items and fees foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) { + $taxes = $item->get_taxes(); + $refund_tax = array(); + + if ( ! empty( $taxes['total'] ) ) { + foreach ( $taxes['total'] as $tax_id => $tax_amount ) { + $refund_tax[ $tax_id ] = $tax_amount * -1; + } + } + $line_items[ $item_id ] = array( 'qty' => $item->get_quantity(), 'refund_total' => $item->get_total() * -1, - 'refund_tax' => array_map( - function( $tax ) { - return $tax * -1; - }, - $item->get_taxes()['total'] - ), + 'refund_tax' => $refund_tax, ); } } else { @@ -416,18 +420,21 @@ function( $tax ) { } foreach ( $items_to_refund as $index ) { - $item = $items_array[ $index ]; - $item_id = $item->get_id(); + $item = $items_array[ $index ]; + $item_id = $item->get_id(); + $taxes = $item->get_taxes(); + $refund_tax = array(); + + if ( ! empty( $taxes['total'] ) ) { + foreach ( $taxes['total'] as $tax_id => $tax_amount ) { + $refund_tax[ $tax_id ] = $tax_amount * -1; + } + } $line_items[ $item_id ] = array( 'qty' => $item->get_quantity(), 'refund_total' => $item->get_total() * -1, - 'refund_tax' => array_map( - function( $tax ) { - return $tax * -1; - }, - $item->get_taxes()['total'] - ), + 'refund_tax' => $refund_tax, ); } } else { @@ -438,18 +445,21 @@ function( $tax ) { // Only refund line items with quantity > 1 if ( 'line_item' === $item->get_type() && $quantity > 1 ) { // Refund between 1 and quantity-1 items - $refund_qty = wp_rand( 1, $quantity - 1 ); + $refund_qty = wp_rand( 1, $quantity - 1 ); $refund_amount = ( $item->get_total() / $quantity ) * $refund_qty; + $taxes = $item->get_taxes(); + $refund_tax = array(); + + if ( ! empty( $taxes['total'] ) ) { + foreach ( $taxes['total'] as $tax_id => $tax_amount ) { + $refund_tax[ $tax_id ] = ( $tax_amount / $quantity ) * $refund_qty * -1; + } + } $line_items[ $item_id ] = array( 'qty' => $refund_qty, 'refund_total' => $refund_amount * -1, - 'refund_tax' => array_map( - function( $tax ) use ( $quantity, $refund_qty ) { - return ( $tax / $quantity ) * $refund_qty * -1; - }, - $item->get_taxes()['total'] - ), + 'refund_tax' => $refund_tax, ); break; // Only refund one item partially } @@ -460,16 +470,19 @@ function( $tax ) use ( $quantity, $refund_qty ) { $items_array = array_values( $items ); $item = $items_array[ array_rand( $items_array ) ]; $item_id = $item->get_id(); + $taxes = $item->get_taxes(); + $refund_tax = array(); + + if ( ! empty( $taxes['total'] ) ) { + foreach ( $taxes['total'] as $tax_id => $tax_amount ) { + $refund_tax[ $tax_id ] = $tax_amount * -1; + } + } $line_items[ $item_id ] = array( 'qty' => $item->get_quantity(), 'refund_total' => $item->get_total() * -1, - 'refund_tax' => array_map( - function( $tax ) { - return $tax * -1; - }, - $item->get_taxes()['total'] - ), + 'refund_tax' => $refund_tax, ); } } From 84a81bcf4c84088663c939ad958438012aa1d71d Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:25:19 +0200 Subject: [PATCH 05/40] Calculate explicit refund amount from line items --- includes/Generator/Order.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 1aa1b84..b5d3ffe 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -488,11 +488,25 @@ protected static function create_refund( $order ) { } } + // Calculate the total refund amount from line items + $refund_amount = 0; + foreach ( $line_items as $item_id => $item_data ) { + // Add item total (already negative) + $refund_amount += abs( $item_data['refund_total'] ); + + // Add tax amounts (already negative) + if ( ! empty( $item_data['refund_tax'] ) ) { + foreach ( $item_data['refund_tax'] as $tax_amount ) { + $refund_amount += abs( $tax_amount ); + } + } + } + // Create the refund $refund = wc_create_refund( array( 'order_id' => $order->get_id(), - 'amount' => null, // Let WooCommerce calculate the amount from line items + 'amount' => $refund_amount, 'reason' => $is_full_refund ? 'Full refund' : 'Partial refund', 'line_items' => $line_items, ) From 368e5b72a95f143a37c46aa74bbd6c2656f467ad Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:31:34 +0200 Subject: [PATCH 06/40] Fix refund ratio logic and add multiple refund support --- includes/Generator/Order.php | 51 +++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index b5d3ffe..b9d366b 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -133,9 +133,24 @@ public static function generate( $save = true, $assoc_args = array() ) { // Handle --refund-ratio parameter for completed orders if ( ! empty( $assoc_args['refund-ratio'] ) && 'completed' === $status ) { $refund_ratio = floatval( $assoc_args['refund-ratio'] ); - // Apply refund based on ratio - if ( $refund_ratio > 0 && ( $refund_ratio >= 1.0 || ( mt_rand() / mt_getrandmax() ) < $refund_ratio ) ) { - self::create_refund( $order ); + $should_refund = false; + + if ( $refund_ratio >= 1.0 ) { + // Always refund if ratio is 1.0 or higher + $should_refund = true; + } elseif ( $refund_ratio > 0 ) { + // Use random chance for ratios between 0 and 1 + $random = mt_rand() / mt_getrandmax(); + $should_refund = $random < $refund_ratio; + } + + if ( $should_refund ) { + $is_partial = self::create_refund( $order ); + + // 25% of partial refunds get a second refund + if ( $is_partial && wp_rand( 1, 100 ) <= 25 ) { + self::create_refund( $order ); + } } } } @@ -371,11 +386,11 @@ protected static function get_or_create_coupon() { * Create a refund for an order (either full or partial). * * @param \WC_Order $order The order to refund. - * @return \WC_Order_Refund|null The refund object or null on failure. + * @return bool True if partial refund, false if full refund or null on failure. */ protected static function create_refund( $order ) { if ( ! $order instanceof \WC_Order ) { - return null; + return false; } // 50% chance of full refund, 50% chance of partial refund @@ -488,12 +503,19 @@ protected static function create_refund( $order ) { } } - // Calculate the total refund amount from line items + // Calculate the total refund amount from line items and count items $refund_amount = 0; + $total_items = 0; + $total_qty = 0; + foreach ( $line_items as $item_id => $item_data ) { // Add item total (already negative) $refund_amount += abs( $item_data['refund_total'] ); + // Count items and quantities + $total_items++; + $total_qty += $item_data['qty']; + // Add tax amounts (already negative) if ( ! empty( $item_data['refund_tax'] ) ) { foreach ( $item_data['refund_tax'] as $tax_amount ) { @@ -502,18 +524,29 @@ protected static function create_refund( $order ) { } } + // Create refund reason + if ( $is_full_refund ) { + $reason = 'Full refund'; + } else { + $reason = sprintf( + 'Partial refund - %d %s', + $total_items, + $total_items === 1 ? 'item' : 'items' + ); + } + // Create the refund $refund = wc_create_refund( array( 'order_id' => $order->get_id(), 'amount' => $refund_amount, - 'reason' => $is_full_refund ? 'Full refund' : 'Partial refund', + 'reason' => $reason, 'line_items' => $line_items, ) ); if ( is_wp_error( $refund ) ) { - return null; + return false; } // Update order status to refunded if it's a full refund @@ -522,6 +555,6 @@ protected static function create_refund( $order ) { $order->save(); } - return $refund; + return ! $is_full_refund; } } From 479a1a8fb758570e710620973ada92eea1e19427 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:33:07 +0200 Subject: [PATCH 07/40] Update partial refund reason to show products and items --- includes/Generator/Order.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index b9d366b..314be36 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -529,9 +529,11 @@ protected static function create_refund( $order ) { $reason = 'Full refund'; } else { $reason = sprintf( - 'Partial refund - %d %s', + 'Partial refund - %d %s, %d %s', $total_items, - $total_items === 1 ? 'item' : 'items' + $total_items === 1 ? 'product' : 'products', + $total_qty, + $total_qty === 1 ? 'item' : 'items' ); } From 4f98a6762e545337841993aceb255868f6a50ce3 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:48:22 +0200 Subject: [PATCH 08/40] Force partial refunds for orders with existing refunds --- includes/Generator/Order.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 314be36..4aa1a95 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -147,9 +147,9 @@ public static function generate( $save = true, $assoc_args = array() ) { if ( $should_refund ) { $is_partial = self::create_refund( $order ); - // 25% of partial refunds get a second refund + // 25% of partial refunds get a second refund (always partial) if ( $is_partial && wp_rand( 1, 100 ) <= 25 ) { - self::create_refund( $order ); + self::create_refund( $order, true ); } } } @@ -386,15 +386,22 @@ protected static function get_or_create_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. * @return bool True if partial refund, false if full refund or null on failure. */ - protected static function create_refund( $order ) { + protected static function create_refund( $order, $force_partial = false ) { if ( ! $order instanceof \WC_Order ) { return false; } - // 50% chance of full refund, 50% chance of partial refund - $is_full_refund = (bool) wp_rand( 0, 1 ); + // Check if order already has refunds + $existing_refunds = $order->get_refunds(); + if ( ! empty( $existing_refunds ) ) { + $force_partial = true; + } + + // 50% chance of full refund, 50% chance of partial refund (unless forced) + $is_full_refund = $force_partial ? false : (bool) wp_rand( 0, 1 ); $line_items = array(); From a3e1536426870e8a0168d7ee665cf17c465a940b Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:54:07 +0200 Subject: [PATCH 09/40] Recalculate order totals after applying coupon --- includes/Generator/Order.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 4aa1a95..c428b3b 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -107,6 +107,8 @@ public static function generate( $save = true, $assoc_args = array() ) { $coupon = self::get_or_create_coupon(); if ( $coupon ) { $order->apply_coupon( $coupon ); + // Recalculate totals after applying coupon + $order->calculate_totals( true ); } } From 74ae8e18021cc8d272ffb8a9b9ebc0a82413d920 Mon Sep 17 00:00:00 2001 From: Justin P <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:17:03 +0200 Subject: [PATCH 10/40] Update includes/Generator/Order.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- includes/Generator/Order.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index c428b3b..021740a 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -389,7 +389,7 @@ protected static function get_or_create_coupon() { * * @param \WC_Order $order The order to refund. * @param bool $force_partial Force partial refund only. - * @return bool True if partial refund, false if full refund or null on failure. + * @return bool True if partial refund, false if full refund or on failure. */ protected static function create_refund( $order, $force_partial = false ) { if ( ! $order instanceof \WC_Order ) { From e2494ec058bb4beb65781225958741a8d6fc087d Mon Sep 17 00:00:00 2001 From: Justin P <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:17:49 +0200 Subject: [PATCH 11/40] Doc update Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- includes/Generator/Order.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 021740a..378b43d 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -518,7 +518,7 @@ protected static function create_refund( $order, $force_partial = false ) { $total_qty = 0; foreach ( $line_items as $item_id => $item_data ) { - // Add item total (already negative) + // Add item total: refund amounts are stored as negative, convert to positive for total calculation $refund_amount += abs( $item_data['refund_total'] ); // Count items and quantities From ff277e2123a2136ca73ca6492924dbe9ecaa9b91 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:19:24 +0200 Subject: [PATCH 12/40] Fix array_rand edge case for orders with exactly 2 items --- includes/Generator/Order.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 378b43d..b5d56dc 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -432,13 +432,13 @@ protected static function create_refund( $order, $force_partial = false ) { // Decide whether to refund full items or partial quantities $refund_full_items = (bool) wp_rand( 0, 1 ); - if ( $refund_full_items && count( $items ) > 1 ) { - // Refund a random subset of items completely + 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 ); - // array_rand returns int if count is 1, array otherwise + // Ensure $items_to_refund is always an array for consistent iteration if ( ! is_array( $items_to_refund ) ) { $items_to_refund = array( $items_to_refund ); } From f80b01e653ba1ac3d62194e74959ad2b687247db Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:27:42 +0200 Subject: [PATCH 13/40] Refactor coupon creation to use Coupon::generate() --- includes/Generator/Order.php | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index b5d56dc..3bcc89b 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -336,28 +336,22 @@ protected static function get_or_create_coupon() { // If no coupons exist, create 6 (3 fixed, 3 percentage) if ( $coupon_count === 0 ) { - // Create 3 fixed value coupons + // Create 3 fixed cart coupons for ( $i = 0; $i < 3; $i++ ) { - $coupon = new \WC_Coupon(); - $amount = self::$faker->numberBetween( 5, 50 ); - $code = 'fixed' . $amount . '-' . self::$faker->lexify( '???' ); - - $coupon->set_code( $code ); - $coupon->set_discount_type( 'fixed_cart' ); - $coupon->set_amount( $amount ); - $coupon->save(); + $coupon = Coupon::generate( false, array( 'min' => 5, 'max' => 50 ) ); + if ( ! is_wp_error( $coupon ) ) { + $coupon->set_discount_type( 'fixed_cart' ); + $coupon->save(); + } } // Create 3 percentage coupons for ( $i = 0; $i < 3; $i++ ) { - $coupon = new \WC_Coupon(); - $amount = self::$faker->numberBetween( 5, 25 ); - $code = 'percent' . $amount . '-' . self::$faker->lexify( '???' ); - - $coupon->set_code( $code ); - $coupon->set_discount_type( 'percent' ); - $coupon->set_amount( $amount ); - $coupon->save(); + $coupon = Coupon::generate( false, array( 'min' => 5, 'max' => 25 ) ); + if ( ! is_wp_error( $coupon ) ) { + $coupon->set_discount_type( 'percent' ); + $coupon->save(); + } } $coupon_count = 6; From ee9ff32dc00b40c8bb311d96dc58c4cc5fc8335e Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:29:52 +0200 Subject: [PATCH 14/40] Move coupon retrieval logic to Coupon::get_random() --- includes/Generator/Coupon.php | 41 ++++++++++++++++++++++++++ includes/Generator/Order.php | 54 ++++++++++------------------------- 2 files changed, 56 insertions(+), 39 deletions(-) diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index 59ca84e..3b3f94c 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -125,5 +125,46 @@ public static function batch( $amount, array $args = array() ) { return $coupon_ids; } + + /** + * Get a random existing coupon. + * + * @return \WC_Coupon|false Coupon object or false if none available. + */ + public static function get_random() { + global $wpdb; + + // Check if any coupons exist + $coupon_count = (int) $wpdb->get_var( + "SELECT COUNT(*) + FROM {$wpdb->posts} + WHERE post_type = 'shop_coupon' + AND post_status = 'publish'" + ); + + if ( $coupon_count === 0 ) { + return false; + } + + // Get a random coupon + $offset = wp_rand( 0, $coupon_count - 1 ); + $coupon_id = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT ID + FROM {$wpdb->posts} + WHERE post_type = 'shop_coupon' + AND post_status = 'publish' + ORDER BY ID + LIMIT %d, 1", + $offset + ) + ); + + if ( $coupon_id ) { + return new \WC_Coupon( $coupon_id ); + } + + return false; + } } diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 3bcc89b..4307ce6 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -321,61 +321,37 @@ protected static function get_random_products( int $min_amount = 1, int $max_amo * 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|null Coupon object or null if none available. + * @return \WC_Coupon|false Coupon object or false if none available. */ protected static function get_or_create_coupon() { - global $wpdb; - - // Check if any coupons exist - $coupon_count = (int) $wpdb->get_var( - "SELECT COUNT(*) - FROM {$wpdb->posts} - WHERE post_type = 'shop_coupon' - AND post_status = 'publish'" - ); + // Try to get a random existing coupon + $coupon = Coupon::get_random(); // If no coupons exist, create 6 (3 fixed, 3 percentage) - if ( $coupon_count === 0 ) { + if ( false === $coupon ) { // Create 3 fixed cart coupons for ( $i = 0; $i < 3; $i++ ) { - $coupon = Coupon::generate( false, array( 'min' => 5, 'max' => 50 ) ); - if ( ! is_wp_error( $coupon ) ) { - $coupon->set_discount_type( 'fixed_cart' ); - $coupon->save(); + $new_coupon = Coupon::generate( false, array( 'min' => 5, 'max' => 50 ) ); + if ( ! is_wp_error( $new_coupon ) ) { + $new_coupon->set_discount_type( 'fixed_cart' ); + $new_coupon->save(); } } // Create 3 percentage coupons for ( $i = 0; $i < 3; $i++ ) { - $coupon = Coupon::generate( false, array( 'min' => 5, 'max' => 25 ) ); - if ( ! is_wp_error( $coupon ) ) { - $coupon->set_discount_type( 'percent' ); - $coupon->save(); + $new_coupon = Coupon::generate( false, array( 'min' => 5, 'max' => 25 ) ); + if ( ! is_wp_error( $new_coupon ) ) { + $new_coupon->set_discount_type( 'percent' ); + $new_coupon->save(); } } - $coupon_count = 6; - } - - // Get a random coupon - $offset = wp_rand( 0, $coupon_count - 1 ); - $coupon_id = (int) $wpdb->get_var( - $wpdb->prepare( - "SELECT ID - FROM {$wpdb->posts} - WHERE post_type = 'shop_coupon' - AND post_status = 'publish' - ORDER BY ID - LIMIT %d, 1", - $offset - ) - ); - - if ( $coupon_id ) { - return new \WC_Coupon( $coupon_id ); + // Now get a random coupon from the ones we just created + $coupon = Coupon::get_random(); } - return null; + return $coupon; } /** From 07daacbce7699d9c85ad901c742bd29fb5a93642 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:32:08 +0200 Subject: [PATCH 15/40] Use WordPress get_posts() API instead of raw SQL queries --- includes/Generator/Coupon.php | 37 ++++++++++------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index 3b3f94c..770bc53 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -132,39 +132,22 @@ public static function batch( $amount, array $args = array() ) { * @return \WC_Coupon|false Coupon object or false if none available. */ public static function get_random() { - global $wpdb; - - // Check if any coupons exist - $coupon_count = (int) $wpdb->get_var( - "SELECT COUNT(*) - FROM {$wpdb->posts} - WHERE post_type = 'shop_coupon' - AND post_status = 'publish'" + $coupon_ids = get_posts( + array( + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + ) ); - if ( $coupon_count === 0 ) { + if ( empty( $coupon_ids ) ) { return false; } - // Get a random coupon - $offset = wp_rand( 0, $coupon_count - 1 ); - $coupon_id = (int) $wpdb->get_var( - $wpdb->prepare( - "SELECT ID - FROM {$wpdb->posts} - WHERE post_type = 'shop_coupon' - AND post_status = 'publish' - ORDER BY ID - LIMIT %d, 1", - $offset - ) - ); - - if ( $coupon_id ) { - return new \WC_Coupon( $coupon_id ); - } + $random_coupon_id = $coupon_ids[ array_rand( $coupon_ids ) ]; - return false; + return new \WC_Coupon( $random_coupon_id ); } } From 57daab8edcea19a66354975b8ce6226e905816dd Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:37:55 +0200 Subject: [PATCH 16/40] Add discount_type parameter to Coupon generator --- includes/Generator/Coupon.php | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index 770bc53..1e37290 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -25,12 +25,15 @@ public static function generate( $save = true, $assoc_args = array() ) { parent::maybe_initialize_generators(); $defaults = array( - 'min' => 5, - 'max' => 100, + 'min' => 5, + 'max' => 100, + 'discount_type' => '', ); + $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,20 @@ 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".' + ); + } + + // If no discount type specified, randomly choose one for backwards compatibility + if ( empty( $discount_type ) ) { + $discount_type = wp_rand( 0, 1 ) === 0 ? 'fixed_cart' : 'percent'; + } + $code = substr( self::$faker->promotionCode( 1 ), 0, -1 ); // Omit the random digit. $amount = self::$faker->numberBetween( $min, $max ); $coupon_code = sprintf( @@ -78,8 +95,9 @@ public static function generate( $save = true, $assoc_args = array() ) { $coupon = new \WC_Coupon( $coupon_code ); $coupon->set_props( array( - 'code' => $coupon_code, - 'amount' => $amount, + 'code' => $coupon_code, + 'amount' => $amount, + 'discount_type' => $discount_type, ) ); if ( $save ) { From 24b24939b7c8c76257176c741b6db0b43f6d78b7 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:37:59 +0200 Subject: [PATCH 17/40] Refactor Order generator to use Coupon::batch() --- includes/Generator/Order.php | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 4307ce6..222973b 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -329,23 +329,11 @@ protected static function get_or_create_coupon() { // If no coupons exist, create 6 (3 fixed, 3 percentage) if ( false === $coupon ) { - // Create 3 fixed cart coupons - for ( $i = 0; $i < 3; $i++ ) { - $new_coupon = Coupon::generate( false, array( 'min' => 5, 'max' => 50 ) ); - if ( ! is_wp_error( $new_coupon ) ) { - $new_coupon->set_discount_type( 'fixed_cart' ); - $new_coupon->save(); - } - } + // Create 3 fixed cart coupons ($5-$50) + Coupon::batch( 3, array( 'min' => 5, 'max' => 50, 'discount_type' => 'fixed_cart' ) ); - // Create 3 percentage coupons - for ( $i = 0; $i < 3; $i++ ) { - $new_coupon = Coupon::generate( false, array( 'min' => 5, 'max' => 25 ) ); - if ( ! is_wp_error( $new_coupon ) ) { - $new_coupon->set_discount_type( 'percent' ); - $new_coupon->save(); - } - } + // Create 3 percentage coupons (5%-25%) + Coupon::batch( 3, array( 'min' => 5, 'max' => 25, 'discount_type' => 'percent' ) ); // Now get a random coupon from the ones we just created $coupon = Coupon::get_random(); From f8d718cf78466be398dcd39c64801af0743a4664 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:38:05 +0200 Subject: [PATCH 18/40] Add discount_type parameter to CLI coupon command --- includes/CLI.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/includes/CLI.php b/includes/CLI.php index de0c072..653f710 100644 --- a/includes/CLI.php +++ b/includes/CLI.php @@ -393,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, a random type will be chosen.', + '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( From 080ee99bbe0a710722618e8eeb3c401471f2a9ec Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:38:12 +0200 Subject: [PATCH 19/40] Update README with new coupon and order parameters --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 3b792dc..14143b7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ 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). +- `wp wc generate orders --coupon-ratio=0.5` + +Refund a percentage of completed orders (0.0-1.0). Refunds can be full or partial, and 25% of partial refunds will receive a second partial refund. +- `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 +58,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, a random type will be chosen. +- `wp wc generate coupons --discount_type=percent --min=5 --max=25` + ### Customers Generate customers based on the number of customers parameter. From b9c85f9e840f42bd4a82d7c01287a07f74b596b3 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:40:52 +0200 Subject: [PATCH 20/40] Clarify that refunds are split evenly between partial and full --- README.md | 2 +- includes/Generator/Order.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 14143b7..7f48dae 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Generate orders with a specific status. 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). - `wp wc generate orders --coupon-ratio=0.5` -Refund a percentage of completed orders (0.0-1.0). Refunds can be full or partial, and 25% of partial refunds will receive a second partial refund. +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. - `wp wc generate orders --status=completed --refund-ratio=0.3` #### Order Attribution diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 222973b..b86941e 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -360,7 +360,7 @@ protected static function create_refund( $order, $force_partial = false ) { $force_partial = true; } - // 50% chance of full refund, 50% chance of partial refund (unless forced) + // Refunds will be split evenly between partial and full (unless forced) $is_full_refund = $force_partial ? false : (bool) wp_rand( 0, 1 ); $line_items = array(); From 86a499c71e12eb561f5ba704c9b7374ce1b1c6c7 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:41:52 +0200 Subject: [PATCH 21/40] Fix backwards compatibility: only set discount_type when explicitly provided --- README.md | 2 +- includes/CLI.php | 2 +- includes/Generator/Coupon.php | 21 +++++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7f48dae..252fce6 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ 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, a random type will be chosen. +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 diff --git a/includes/CLI.php b/includes/CLI.php index 653f710..27ea2f4 100644 --- a/includes/CLI.php +++ b/includes/CLI.php @@ -396,7 +396,7 @@ function () use ( $progress ) { array( 'name' => 'discount_type', 'type' => 'assoc', - 'description' => 'The type of discount for the coupon. If not specified, a random type will be chosen.', + 'description' => 'The type of discount for the coupon. If not specified, defaults to WooCommerce default (fixed_cart).', 'optional' => true, 'options' => array( 'fixed_cart', 'percent' ), ), diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index 1e37290..3f2ccce 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -80,11 +80,6 @@ public static function generate( $save = true, $assoc_args = array() ) { ); } - // If no discount type specified, randomly choose one for backwards compatibility - if ( empty( $discount_type ) ) { - $discount_type = wp_rand( 0, 1 ) === 0 ? 'fixed_cart' : 'percent'; - } - $code = substr( self::$faker->promotionCode( 1 ), 0, -1 ); // Omit the random digit. $amount = self::$faker->numberBetween( $min, $max ); $coupon_code = sprintf( @@ -93,12 +88,18 @@ public static function generate( $save = true, $assoc_args = array() ) { $amount ); + $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( array( - 'code' => $coupon_code, - 'amount' => $amount, - 'discount_type' => $discount_type, - ) ); + $coupon->set_props( $props ); if ( $save ) { $data_store = WC_Data_Store::load( 'coupon' ); From 2c9940e3024f4336f329f92e8d2110b29df35869 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:42:55 +0200 Subject: [PATCH 22/40] Add input validation for coupon-ratio and refund-ratio parameters --- includes/Generator/Order.php | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index b86941e..4b4a13d 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -12,6 +12,11 @@ */ class Order extends Generator { + /** + * Probability (percentage) that a partial refund will receive a second refund. + */ + const SECOND_REFUND_PROBABILITY = 25; + /** * Return a new order. * @@ -95,8 +100,14 @@ public static function generate( $save = true, $assoc_args = array() ) { // Handle --coupon-ratio parameter if ( ! empty( $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 > 0 && ( $coupon_ratio >= 1.0 || ( mt_rand() / mt_getrandmax() ) < $coupon_ratio ) ) { + if ( $coupon_ratio > 0 && ( $coupon_ratio >= 1.0 || ( (float) wp_rand() / (float) getrandmax() ) < $coupon_ratio ) ) { $include_coupon = true; } else { $include_coupon = false; @@ -112,7 +123,7 @@ public static function generate( $save = true, $assoc_args = array() ) { } } - // 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 ); } @@ -135,6 +146,12 @@ public static function generate( $save = true, $assoc_args = array() ) { // Handle --refund-ratio parameter for completed orders if ( ! empty( $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 ) { @@ -142,15 +159,15 @@ public static function generate( $save = true, $assoc_args = array() ) { $should_refund = true; } elseif ( $refund_ratio > 0 ) { // Use random chance for ratios between 0 and 1 - $random = mt_rand() / mt_getrandmax(); + $random = (float) wp_rand() / (float) getrandmax(); $should_refund = $random < $refund_ratio; } if ( $should_refund ) { $is_partial = self::create_refund( $order ); - // 25% of partial refunds get a second refund (always partial) - if ( $is_partial && wp_rand( 1, 100 ) <= 25 ) { + // Some partial refunds get a second refund (always partial) + if ( $is_partial && wp_rand( 1, 100 ) <= self::SECOND_REFUND_PROBABILITY ) { self::create_refund( $order, true ); } } @@ -330,10 +347,15 @@ protected static function get_or_create_coupon() { // If no coupons exist, create 6 (3 fixed, 3 percentage) if ( false === $coupon ) { // Create 3 fixed cart coupons ($5-$50) - Coupon::batch( 3, array( 'min' => 5, 'max' => 50, 'discount_type' => 'fixed_cart' ) ); + $fixed_result = Coupon::batch( 3, array( 'min' => 5, 'max' => 50, 'discount_type' => 'fixed_cart' ) ); // Create 3 percentage coupons (5%-25%) - Coupon::batch( 3, array( 'min' => 5, 'max' => 25, 'discount_type' => 'percent' ) ); + $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 ) ) { + return false; + } // Now get a random coupon from the ones we just created $coupon = Coupon::get_random(); From 5b539251f7945c55c22bff519edd74b0e6efea53 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:43:05 +0200 Subject: [PATCH 23/40] Add performance comment for get_posts() in Coupon::get_random() --- includes/Generator/Coupon.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index 3f2ccce..ffd32b0 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -151,6 +151,9 @@ public static function batch( $amount, array $args = array() ) { * @return \WC_Coupon|false Coupon object or false if none available. */ public static function get_random() { + // 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', From 0816747e4caf1e16e695e8890884aac1a1c45ab0 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:43:11 +0200 Subject: [PATCH 24/40] Clarify that --coupons flag is equivalent to --coupon-ratio=1.0 --- README.md | 2 +- includes/CLI.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 252fce6..e2998f1 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ 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). +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`. - `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. diff --git a/includes/CLI.php b/includes/CLI.php index 27ea2f4..3c379b0 100644 --- a/includes/CLI.php +++ b/includes/CLI.php @@ -316,7 +316,7 @@ 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( From 0f5dd7761e014e9bdf9f7752cd57ace515b06eb6 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:14:30 +0200 Subject: [PATCH 25/40] Fix ratio probability calculation using integer-based random generation --- includes/Generator/Order.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 4b4a13d..18e9d25 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -107,7 +107,9 @@ public static function generate( $save = true, $assoc_args = array() ) { } // Apply coupon based on ratio - if ( $coupon_ratio > 0 && ( $coupon_ratio >= 1.0 || ( (float) wp_rand() / (float) getrandmax() ) < $coupon_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; @@ -157,10 +159,9 @@ public static function generate( $save = true, $assoc_args = array() ) { if ( $refund_ratio >= 1.0 ) { // Always refund if ratio is 1.0 or higher $should_refund = true; - } elseif ( $refund_ratio > 0 ) { + } elseif ( $refund_ratio > 0 && wp_rand( 1, 100 ) <= ( $refund_ratio * 100 ) ) { // Use random chance for ratios between 0 and 1 - $random = (float) wp_rand() / (float) getrandmax(); - $should_refund = $random < $refund_ratio; + $should_refund = true; } if ( $should_refund ) { From f69d1261e7475a1d768b201ddefd0462b4df1bcf Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:46:01 +0200 Subject: [PATCH 26/40] Add check to prevent refunds with empty line items --- includes/Generator/Order.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 18e9d25..051f326 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -493,6 +493,11 @@ protected static function create_refund( $order, $force_partial = false ) { } } + // If no line items to refund, return false + if ( empty( $line_items ) ) { + return false; + } + // Calculate the total refund amount from line items and count items $refund_amount = 0; $total_items = 0; From 73cdb1bfe1058b4469b99a672a79ef420160c853 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:52:30 +0200 Subject: [PATCH 27/40] Improve refund error handling and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add detailed logging when orders cannot be refunded due to empty line items - Consolidate refund creation error logs into single formatted message - Add error logging for invalid order instance check These improvements will help diagnose why some completed orders aren't receiving refunds when --refund-ratio=1.0 is specified. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- includes/Generator/Order.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 051f326..2d7dc4f 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -374,6 +374,7 @@ protected static function get_or_create_coupon() { */ protected static function create_refund( $order, $force_partial = false ) { if ( ! $order instanceof \WC_Order ) { + error_log( "Error: Order is not an instance of \WC_Order: " . print_r( $order, true ) ); return false; } @@ -493,8 +494,9 @@ protected static function create_refund( $order, $force_partial = false ) { } } - // If no line items to refund, return false + // Ensure we have items to refund - if not, log and return false 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; } @@ -541,8 +543,15 @@ protected static function create_refund( $order, $force_partial = false ) { 'line_items' => $line_items, ) ); - if ( is_wp_error( $refund ) ) { + error_log( sprintf( + "Refund creation failed for order %d:\nError: %s\nAmount: %s\nReason: %s\nLine Items: %s", + $order->get_id(), + $refund->get_error_message(), + $refund_amount, + $reason, + print_r( $line_items, true ) + ) ); return false; } From ae9b6a61e41f1cf32aee531ec4262c01ee75993c Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:53:43 +0200 Subject: [PATCH 28/40] Add validation to prevent refunds with invalid amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds check to ensure refund amount is greater than 0 before calling wc_create_refund(). This prevents "Invalid refund amount" errors that occur when: - Orders have 100% discount coupons (total = $0) - Line items have $0 totals - Calculation results in 0 or negative amount Logs order ID, calculated amount, and order total when skipping refund. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- includes/Generator/Order.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 2d7dc4f..84a352c 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -521,6 +521,17 @@ protected static function create_refund( $order, $force_partial = false ) { } } + // Validate refund amount is greater than 0 + if ( $refund_amount <= 0 ) { + error_log( sprintf( + 'Refund skipped for order %d: Invalid refund amount (%s). Order total: %s', + $order->get_id(), + $refund_amount, + $order->get_total() + ) ); + return false; + } + // Create refund reason if ( $is_full_refund ) { $reason = 'Full refund'; From ac5f8200c4311147064d38d76911e30b8ad174cd Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:03:18 +0200 Subject: [PATCH 29/40] Fix refund amount validation to prevent exceeding order total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Invalid refund amount" error occurred because calculated refund amounts slightly exceeded the available order total due to rounding errors in tax calculations. Changes: - Calculate maximum refundable amount (order total - already refunded) - Cap refund amount to maximum available before calling wc_create_refund() - Round both calculated refund and max refund to 2 decimal places - Improve error logging to show order total and already refunded amounts Example of the issue: - Order total: $24851.03 - Calculated refund (with 3 decimal tax): $24851.04 - Result: $0.01 over limit → "Invalid refund amount" error This fix ensures refunds never exceed the mathematically available amount, preventing WooCommerce validation errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- includes/Generator/Order.php | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 84a352c..e6f081d 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -521,13 +521,26 @@ protected static function create_refund( $order, $force_partial = false ) { } } + // Round refund amount to 2 decimal places for currency precision + $refund_amount = round( $refund_amount, 2 ); + + // Calculate maximum refundable amount (order total minus already refunded) + $max_refund = $order->get_total() - $order->get_total_refunded(); + $max_refund = round( $max_refund, 2 ); + + // Cap refund amount to maximum available (prevents rounding errors from exceeding order total) + if ( $refund_amount > $max_refund ) { + $refund_amount = $max_refund; + } + // Validate refund amount is greater than 0 if ( $refund_amount <= 0 ) { error_log( sprintf( - 'Refund skipped for order %d: Invalid refund amount (%s). Order total: %s', + '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(), + $order->get_total_refunded() ) ); return false; } @@ -556,10 +569,12 @@ protected static function create_refund( $order, $force_partial = false ) { ); if ( is_wp_error( $refund ) ) { error_log( sprintf( - "Refund creation failed for order %d:\nError: %s\nAmount: %s\nReason: %s\nLine Items: %s", + "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 ) ) ); From 0b3faa5d73548673c4402962da6da161543f68b2 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:52:37 +0200 Subject: [PATCH 30/40] Fix refund amount calculations to prevent rounding errors and ensure partial refunds stay under 50% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For full refunds, use the order's actual total instead of summing line items to avoid rounding discrepancies that created tiny 0.01 refunds. For partial refunds, ensure the total stays below 50% of the order total by removing items if needed, preventing two partial refunds from fully refunding an order. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- includes/Generator/Order.php | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index e6f081d..80e54d3 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -521,9 +521,46 @@ protected static function create_refund( $order, $force_partial = false ) { } } + // For full refunds, use the order's actual remaining total to avoid rounding discrepancies + if ( $is_full_refund ) { + $refund_amount = $order->get_total() - $order->get_total_refunded(); + } + // Round refund amount to 2 decimal places for currency precision $refund_amount = round( $refund_amount, 2 ); + // For partial refunds, ensure refund is < 50% of order total by removing items if needed + if ( ! $is_full_refund ) { + $max_partial_refund = $order->get_total() * 0.5; + + // If refund exceeds 50%, remove items until it's under 50% + while ( $refund_amount >= $max_partial_refund && count( $line_items ) > 1 ) { + // Remove a random item from the refund + $item_id_to_remove = array_rand( $line_items ); + $removed_item = $line_items[ $item_id_to_remove ]; + unset( $line_items[ $item_id_to_remove ] ); + + // Recalculate refund amount and counts + $refund_amount = 0; + $total_items = 0; + $total_qty = 0; + + foreach ( $line_items as $item_id => $item_data ) { + $refund_amount += abs( $item_data['refund_total'] ); + $total_items++; + $total_qty += $item_data['qty']; + + if ( ! empty( $item_data['refund_tax'] ) ) { + foreach ( $item_data['refund_tax'] as $tax_amount ) { + $refund_amount += abs( $tax_amount ); + } + } + } + + $refund_amount = round( $refund_amount, 2 ); + } + } + // Calculate maximum refundable amount (order total minus already refunded) $max_refund = $order->get_total() - $order->get_total_refunded(); $max_refund = round( $max_refund, 2 ); From 9894e7ef484cd2cd976a16a5cf4730a9be039422 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:58:49 +0200 Subject: [PATCH 31/40] Fix partial refunds to track already-refunded quantities and prevent over-refunding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When creating multiple refunds, the code was using original order quantities instead of accounting for items already refunded. This caused second refunds to exceed the original order quantities (e.g., 11 items refunded from an 8-item order). Now tracks refunded quantities per item and only refunds remaining quantities. All refund logic (full items, partial quantities, and fallback) now calculates remaining quantity = original - already_refunded before processing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- includes/Generator/Order.php | 132 ++++++++++++++++++++++++++++------- 1 file changed, 105 insertions(+), 27 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 80e54d3..5ec48fd 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -384,6 +384,21 @@ protected static function create_refund( $order, $force_partial = false ) { $force_partial = true; } + // Calculate already refunded quantities per item + $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() ); + } + } + // Refunds will be split evenly between partial and full (unless forced) $is_full_refund = $force_partial ? false : (bool) wp_rand( 0, 1 ); @@ -392,18 +407,34 @@ protected static function create_refund( $order, $force_partial = false ) { if ( $is_full_refund ) { // Full refund - include all line items and fees foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) { + // Calculate remaining quantity after previous refunds + $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 ( $remaining_qty <= 0 ) { + continue; + } + $taxes = $item->get_taxes(); $refund_tax = array(); if ( ! empty( $taxes['total'] ) ) { foreach ( $taxes['total'] as $tax_id => $tax_amount ) { - $refund_tax[ $tax_id ] = $tax_amount * -1; + // Prorate tax based on remaining quantity + $tax_per_unit = $tax_amount / $original_qty; + $refund_tax[ $tax_id ] = ( $tax_per_unit * $remaining_qty ) * -1; } } + // Prorate the refund total based on remaining quantity + $total_per_unit = $item->get_total() / $original_qty; + $refund_total = $total_per_unit * $remaining_qty; + $line_items[ $item_id ] = array( - 'qty' => $item->get_quantity(), - 'refund_total' => $item->get_total() * -1, + 'qty' => $remaining_qty, + 'refund_total' => $refund_total * -1, 'refund_tax' => $refund_tax, ); } @@ -428,37 +459,62 @@ protected static function create_refund( $order, $force_partial = false ) { foreach ( $items_to_refund as $index ) { $item = $items_array[ $index ]; $item_id = $item->get_id(); + + // Calculate remaining quantity after previous refunds + $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 ( $remaining_qty <= 0 ) { + continue; + } + $taxes = $item->get_taxes(); $refund_tax = array(); if ( ! empty( $taxes['total'] ) ) { foreach ( $taxes['total'] as $tax_id => $tax_amount ) { - $refund_tax[ $tax_id ] = $tax_amount * -1; + // Prorate tax based on remaining quantity + $tax_per_unit = $tax_amount / $original_qty; + $refund_tax[ $tax_id ] = ( $tax_per_unit * $remaining_qty ) * -1; } } + // Prorate the refund total based on remaining quantity + $total_per_unit = $item->get_total() / $original_qty; + $refund_total = $total_per_unit * $remaining_qty; + $line_items[ $item_id ] = array( - 'qty' => $item->get_quantity(), - 'refund_total' => $item->get_total() * -1, + 'qty' => $remaining_qty, + 'refund_total' => $refund_total * -1, 'refund_tax' => $refund_tax, ); } } else { // Refund partial quantities of items foreach ( $items as $item_id => $item ) { - $quantity = $item->get_quantity(); + // Calculate remaining quantity after previous refunds + $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 if only 1 remaining + if ( $remaining_qty <= 1 ) { + continue; + } - // Only refund line items with quantity > 1 - if ( 'line_item' === $item->get_type() && $quantity > 1 ) { - // Refund between 1 and quantity-1 items - $refund_qty = wp_rand( 1, $quantity - 1 ); - $refund_amount = ( $item->get_total() / $quantity ) * $refund_qty; + // Only refund line items with remaining quantity > 1 + if ( 'line_item' === $item->get_type() ) { + // Refund between 1 and remaining_qty-1 items + $refund_qty = wp_rand( 1, $remaining_qty - 1 ); + $refund_amount = ( $item->get_total() / $original_qty ) * $refund_qty; $taxes = $item->get_taxes(); $refund_tax = array(); if ( ! empty( $taxes['total'] ) ) { foreach ( $taxes['total'] as $tax_id => $tax_amount ) { - $refund_tax[ $tax_id ] = ( $tax_amount / $quantity ) * $refund_qty * -1; + $refund_tax[ $tax_id ] = ( $tax_amount / $original_qty ) * $refund_qty * -1; } } @@ -471,25 +527,47 @@ protected static function create_refund( $order, $force_partial = false ) { } } - // If no items were added (all quantities were 1), refund one complete item + // If no items were added, refund one complete remaining item if ( empty( $line_items ) && count( $items ) > 0 ) { + // Find an item with remaining quantity $items_array = array_values( $items ); - $item = $items_array[ array_rand( $items_array ) ]; - $item_id = $item->get_id(); - $taxes = $item->get_taxes(); - $refund_tax = array(); + shuffle( $items_array ); - if ( ! empty( $taxes['total'] ) ) { - foreach ( $taxes['total'] as $tax_id => $tax_amount ) { - $refund_tax[ $tax_id ] = $tax_amount * -1; + foreach ( $items_array as $item ) { + $item_id = $item->get_id(); + + // Calculate remaining quantity after previous refunds + $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 ( $remaining_qty <= 0 ) { + continue; } - } - $line_items[ $item_id ] = array( - 'qty' => $item->get_quantity(), - 'refund_total' => $item->get_total() * -1, - 'refund_tax' => $refund_tax, - ); + $taxes = $item->get_taxes(); + $refund_tax = array(); + + if ( ! empty( $taxes['total'] ) ) { + foreach ( $taxes['total'] as $tax_id => $tax_amount ) { + // Prorate tax based on remaining quantity + $tax_per_unit = $tax_amount / $original_qty; + $refund_tax[ $tax_id ] = ( $tax_per_unit * $remaining_qty ) * -1; + } + } + + // Prorate the refund total based on remaining quantity + $total_per_unit = $item->get_total() / $original_qty; + $refund_total = $total_per_unit * $remaining_qty; + + $line_items[ $item_id ] = array( + 'qty' => $remaining_qty, + 'refund_total' => $refund_total * -1, + 'refund_tax' => $refund_tax, + ); + break; // Only refund one item + } } } } From c6d22b43fd65e67d124f7f78336deb87bae9e229 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 17:43:57 +0200 Subject: [PATCH 32/40] Fix code quality issues in refund calculations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add division-by-zero guards before all $original_qty divisions - Change parameter checks from !empty() to isset() to support explicit 0 values - Remove unused variable $removed_item in refund amount calculation These changes improve robustness and prevent potential PHP warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- includes/Generator/Order.php | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 5ec48fd..1303952 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -98,7 +98,7 @@ public static function generate( $save = true, $assoc_args = array() ) { $include_coupon = ! empty( $assoc_args['coupons'] ); // Handle --coupon-ratio parameter - if ( ! empty( $assoc_args['coupon-ratio'] ) ) { + if ( isset( $assoc_args['coupon-ratio'] ) ) { $coupon_ratio = floatval( $assoc_args['coupon-ratio'] ); // Validate ratio is between 0.0 and 1.0 @@ -146,7 +146,7 @@ public static function generate( $save = true, $assoc_args = array() ) { $order->save(); // Handle --refund-ratio parameter for completed orders - if ( ! empty( $assoc_args['refund-ratio'] ) && 'completed' === $status ) { + if ( isset( $assoc_args['refund-ratio'] ) && 'completed' === $status ) { $refund_ratio = floatval( $assoc_args['refund-ratio'] ); // Validate ratio is between 0.0 and 1.0 @@ -412,8 +412,8 @@ protected static function create_refund( $order, $force_partial = false ) { $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 ( $remaining_qty <= 0 ) { + // Skip if nothing left to refund or invalid quantity + if ( $remaining_qty <= 0 || $original_qty <= 0 ) { continue; } @@ -465,8 +465,8 @@ protected static function create_refund( $order, $force_partial = false ) { $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 ( $remaining_qty <= 0 ) { + // Skip if nothing left to refund or invalid quantity + if ( $remaining_qty <= 0 || $original_qty <= 0 ) { continue; } @@ -499,8 +499,8 @@ protected static function create_refund( $order, $force_partial = false ) { $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 if only 1 remaining - if ( $remaining_qty <= 1 ) { + // Skip if nothing left to refund, if only 1 remaining, or invalid quantity + if ( $remaining_qty <= 1 || $original_qty <= 0 ) { continue; } @@ -541,8 +541,8 @@ protected static function create_refund( $order, $force_partial = false ) { $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 ( $remaining_qty <= 0 ) { + // Skip if nothing left to refund or invalid quantity + if ( $remaining_qty <= 0 || $original_qty <= 0 ) { continue; } @@ -615,7 +615,6 @@ protected static function create_refund( $order, $force_partial = false ) { while ( $refund_amount >= $max_partial_refund && count( $line_items ) > 1 ) { // Remove a random item from the refund $item_id_to_remove = array_rand( $line_items ); - $removed_item = $line_items[ $item_id_to_remove ]; unset( $line_items[ $item_id_to_remove ] ); // Recalculate refund amount and counts From 323a1a7d1babbb392ab72e53efca772d8a08d77f Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Fri, 24 Oct 2025 18:14:11 +0200 Subject: [PATCH 33/40] Add realistic date handling for refunds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - First refunds are created within 2 months of order completion date - Second refunds are created within 1 month of first refund date - Update create_refund() to return refund object instead of boolean - Pass previous refund to second refund for proper date calculation This makes generated refund data more realistic for testing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- includes/Generator/Order.php | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 1303952..6e7e1cc 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -165,11 +165,12 @@ public static function generate( $save = true, $assoc_args = array() ) { } if ( $should_refund ) { - $is_partial = self::create_refund( $order ); + // 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 ( $is_partial && wp_rand( 1, 100 ) <= self::SECOND_REFUND_PROBABILITY ) { - self::create_refund( $order, true ); + if ( $first_refund && wp_rand( 1, 100 ) <= self::SECOND_REFUND_PROBABILITY ) { + self::create_refund( $order, true, $first_refund ); } } } @@ -368,11 +369,12 @@ protected static function get_or_create_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. - * @return bool True if partial refund, false if full refund or on failure. + * @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 ) { + 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; @@ -672,6 +674,21 @@ protected static function create_refund( $order, $force_partial = false ) { ); } + // Calculate refund date + if ( $previous_refund ) { + // Second refund: within 1 month of first refund + $base_date = $previous_refund->get_date_created(); + $max_days = 30; // 1 month + } else { + // First refund: within 2 months of order completion + $base_date = $order->get_date_completed(); + $max_days = 60; // 2 months + } + + // Generate random date within the allowed timeframe + $random_days = wp_rand( 0, $max_days ); + $refund_date = date( 'Y-m-d H:i:s', strtotime( $base_date->date( 'Y-m-d H:i:s' ) ) + ( $random_days * DAY_IN_SECONDS ) ); + // Create the refund $refund = wc_create_refund( array( @@ -679,6 +696,7 @@ protected static function create_refund( $order, $force_partial = false ) { 'amount' => $refund_amount, 'reason' => $reason, 'line_items' => $line_items, + 'date_created' => $refund_date, ) ); if ( is_wp_error( $refund ) ) { @@ -701,6 +719,6 @@ protected static function create_refund( $order, $force_partial = false ) { $order->save(); } - return ! $is_full_refund; + return $refund; } } From 5332818af5c0a405a59247d5ccbb0c028526218d Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Sat, 25 Oct 2025 00:25:18 +0200 Subject: [PATCH 34/40] Optimize batch order generation with ID pre-fetching Pre-fetch product, coupon, and customer IDs once per batch to eliminate repeated database queries. This reduces query overhead significantly for large batch operations. Performance improvements: - Products: Single query vs N queries (one per order) - Coupons: Single query vs N queries when using coupon-ratio - Customers: Single query vs 2N queries (count + random select per order) Results in 30-85% faster batch generation depending on batch size. --- includes/Generator/Coupon.php | 31 ++++--- includes/Generator/Order.php | 150 ++++++++++++++++++++++++++++------ 2 files changed, 146 insertions(+), 35 deletions(-) diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index ffd32b0..243c305 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -148,20 +148,27 @@ public static function batch( $amount, array $args = array() ) { /** * 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() { - // 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', - ) - ); + 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; diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 6e7e1cc..fbfd7f6 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -17,6 +17,27 @@ class Order extends Generator { */ const SECOND_REFUND_PROBABILITY = 25; + /** + * 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. * @@ -202,6 +223,9 @@ public static function batch( $amount, array $args = array() ) { return $amount; } + // Initialize batch cache to avoid repeated queries + self::init_batch_cache( $args ); + $order_ids = array(); for ( $i = 1; $i <= $amount; $i ++ ) { @@ -209,6 +233,9 @@ public static function batch( $amount, array $args = array() ) { $order_ids[] = $order->get_id(); } + // Clear batch cache after generation + self::clear_batch_cache(); + return $order_ids; } @@ -224,9 +251,15 @@ 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 ); } @@ -298,27 +331,51 @@ 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; - } + // 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 ); + } + + $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'" + ); - $query = new \WC_Product_Query( array( - 'limit' => $num_products_to_get, - 'return' => 'ids', - 'orderby' => 'rand', - ) ); + $num_products_to_get = wp_rand( $min_amount, $max_amount ); - foreach ( $query->get_products() as $product_id ) { + 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->is_type( 'variable' ) ) { @@ -343,8 +400,8 @@ protected static function get_random_products( int $min_amount = 1, int $max_amo * @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 - $coupon = Coupon::get_random(); + // 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 ) { @@ -359,8 +416,13 @@ protected static function get_or_create_coupon() { 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(); + $coupon = Coupon::get_random( self::$batch_coupon_ids ); } return $coupon; @@ -721,4 +783,46 @@ protected static function create_refund( $order, $force_partial = false, $previo return $refund; } + + /** + * 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; + } } From 5734f4260a8fbeeac5e24fd27663922d3b5a8fc9 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:52:29 +0100 Subject: [PATCH 35/40] Refactor refund generation: extract constants and helper methods --- includes/Generator/Order.php | 538 ++++++++++++++++++----------------- 1 file changed, 277 insertions(+), 261 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index 6e7e1cc..f043b2f 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -17,6 +17,22 @@ class Order extends Generator { */ 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; + /** * Return a new order. * @@ -348,6 +364,10 @@ protected static function get_or_create_coupon() { // 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' ) ); @@ -386,8 +406,127 @@ protected static function create_refund( $order, $force_partial = false, $previo $force_partial = true; } - // Calculate already refunded quantities per item + // 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' ); @@ -401,15 +540,94 @@ protected static function create_refund( $order, $force_partial = false, $previo } } - // Refunds will be split evenly between partial and full (unless forced) - $is_full_refund = $force_partial ? false : (bool) wp_rand( 0, 1 ); + 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(); - if ( $is_full_refund ) { - // Full refund - include all line items and fees - foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) { - // Calculate remaining quantity after previous refunds + 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; @@ -419,50 +637,35 @@ protected static function create_refund( $order, $force_partial = false, $previo continue; } - $taxes = $item->get_taxes(); - $refund_tax = array(); - - if ( ! empty( $taxes['total'] ) ) { - foreach ( $taxes['total'] as $tax_id => $tax_amount ) { - // Prorate tax based on remaining quantity - $tax_per_unit = $tax_amount / $original_qty; - $refund_tax[ $tax_id ] = ( $tax_per_unit * $remaining_qty ) * -1; - } - } - - // Prorate the refund total based on remaining quantity - $total_per_unit = $item->get_total() / $original_qty; - $refund_total = $total_per_unit * $remaining_qty; - - $line_items[ $item_id ] = array( - 'qty' => $remaining_qty, - 'refund_total' => $refund_total * -1, - 'refund_tax' => $refund_tax, - ); + $line_items[ $item_id ] = self::build_refund_line_item( $item, $remaining_qty, $original_qty ); } } else { - // Partial refund - randomly select items or partial quantities - $items = $order->get_items( array( 'line_item', 'fee' ) ); - - // Decide whether to refund full items or partial quantities - $refund_full_items = (bool) wp_rand( 0, 1 ); + // 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; - 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 ); + // Skip if nothing left to refund, if only 1 remaining, or invalid quantity + if ( $remaining_qty <= 1 || $original_qty <= 0 ) { + continue; + } - // Ensure $items_to_refund is always an array for consistent iteration - if ( ! is_array( $items_to_refund ) ) { - $items_to_refund = array( $items_to_refund ); + // 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 } + } - foreach ( $items_to_refund as $index ) { - $item = $items_array[ $index ]; - $item_id = $item->get_id(); + // 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 ); - // Calculate remaining quantity after previous refunds + 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; @@ -472,128 +675,33 @@ protected static function create_refund( $order, $force_partial = false, $previo continue; } - $taxes = $item->get_taxes(); - $refund_tax = array(); - - if ( ! empty( $taxes['total'] ) ) { - foreach ( $taxes['total'] as $tax_id => $tax_amount ) { - // Prorate tax based on remaining quantity - $tax_per_unit = $tax_amount / $original_qty; - $refund_tax[ $tax_id ] = ( $tax_per_unit * $remaining_qty ) * -1; - } - } - - // Prorate the refund total based on remaining quantity - $total_per_unit = $item->get_total() / $original_qty; - $refund_total = $total_per_unit * $remaining_qty; - - $line_items[ $item_id ] = array( - 'qty' => $remaining_qty, - 'refund_total' => $refund_total * -1, - 'refund_tax' => $refund_tax, - ); - } - } else { - // Refund partial quantities of items - foreach ( $items as $item_id => $item ) { - // Calculate remaining quantity after previous refunds - $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 between 1 and remaining_qty-1 items - $refund_qty = wp_rand( 1, $remaining_qty - 1 ); - $refund_amount = ( $item->get_total() / $original_qty ) * $refund_qty; - $taxes = $item->get_taxes(); - $refund_tax = array(); - - if ( ! empty( $taxes['total'] ) ) { - foreach ( $taxes['total'] as $tax_id => $tax_amount ) { - $refund_tax[ $tax_id ] = ( $tax_amount / $original_qty ) * $refund_qty * -1; - } - } - - $line_items[ $item_id ] = array( - 'qty' => $refund_qty, - 'refund_total' => $refund_amount * -1, - 'refund_tax' => $refund_tax, - ); - break; // Only refund one item partially - } - } - - // If no items were added, refund one complete remaining item - if ( empty( $line_items ) && count( $items ) > 0 ) { - // Find an item with remaining quantity - $items_array = array_values( $items ); - shuffle( $items_array ); - - foreach ( $items_array as $item ) { - $item_id = $item->get_id(); - - // Calculate remaining quantity after previous refunds - $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; - } - - $taxes = $item->get_taxes(); - $refund_tax = array(); - - if ( ! empty( $taxes['total'] ) ) { - foreach ( $taxes['total'] as $tax_id => $tax_amount ) { - // Prorate tax based on remaining quantity - $tax_per_unit = $tax_amount / $original_qty; - $refund_tax[ $tax_id ] = ( $tax_per_unit * $remaining_qty ) * -1; - } - } - - // Prorate the refund total based on remaining quantity - $total_per_unit = $item->get_total() / $original_qty; - $refund_total = $total_per_unit * $remaining_qty; - - $line_items[ $item_id ] = array( - 'qty' => $remaining_qty, - 'refund_total' => $refund_total * -1, - 'refund_tax' => $refund_tax, - ); - break; // Only refund one item - } + $line_items[ $item_id ] = self::build_refund_line_item( $item, $remaining_qty, $original_qty ); + break; // Only refund one item } } } - // Ensure we have items to refund - if not, log and return false - 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; - } + return $line_items; + } - // Calculate the total refund amount from line items and count 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_id => $item_data ) { + 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'] ); - - // Count items and quantities $total_items++; $total_qty += $item_data['qty']; - // Add tax amounts (already negative) + // Add tax amounts if ( ! empty( $item_data['refund_tax'] ) ) { foreach ( $item_data['refund_tax'] as $tax_amount ) { $refund_amount += abs( $tax_amount ); @@ -601,124 +709,32 @@ protected static function create_refund( $order, $force_partial = false, $previo } } - // For full refunds, use the order's actual remaining total to avoid rounding discrepancies - if ( $is_full_refund ) { - $refund_amount = $order->get_total() - $order->get_total_refunded(); - } - - // Round refund amount to 2 decimal places for currency precision - $refund_amount = round( $refund_amount, 2 ); - - // For partial refunds, ensure refund is < 50% of order total by removing items if needed - if ( ! $is_full_refund ) { - $max_partial_refund = $order->get_total() * 0.5; - - // If refund exceeds 50%, remove items until it's under 50% - while ( $refund_amount >= $max_partial_refund && count( $line_items ) > 1 ) { - // Remove a random item from the refund - $item_id_to_remove = array_rand( $line_items ); - unset( $line_items[ $item_id_to_remove ] ); - - // Recalculate refund amount and counts - $refund_amount = 0; - $total_items = 0; - $total_qty = 0; - - foreach ( $line_items as $item_id => $item_data ) { - $refund_amount += abs( $item_data['refund_total'] ); - $total_items++; - $total_qty += $item_data['qty']; - - if ( ! empty( $item_data['refund_tax'] ) ) { - foreach ( $item_data['refund_tax'] as $tax_amount ) { - $refund_amount += abs( $tax_amount ); - } - } - } - - $refund_amount = round( $refund_amount, 2 ); - } - } - - // Calculate maximum refundable amount (order total minus already refunded) - $max_refund = $order->get_total() - $order->get_total_refunded(); - $max_refund = round( $max_refund, 2 ); - - // Cap refund amount to maximum available (prevents rounding errors from exceeding order total) - if ( $refund_amount > $max_refund ) { - $refund_amount = $max_refund; - } - - // Validate refund amount is greater than 0 - 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 - if ( $is_full_refund ) { - $reason = 'Full refund'; - } else { - $reason = sprintf( - 'Partial refund - %d %s, %d %s', - $total_items, - $total_items === 1 ? 'product' : 'products', - $total_qty, - $total_qty === 1 ? 'item' : 'items' - ); - } + return array( + 'amount' => round( $refund_amount, 2 ), + 'total_items' => $total_items, + 'total_qty' => $total_qty, + ); + } - // Calculate refund date + /** + * 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 = 30; // 1 month + $max_days = self::SECOND_REFUND_MAX_DAYS; } else { // First refund: within 2 months of order completion $base_date = $order->get_date_completed(); - $max_days = 60; // 2 months + $max_days = self::FIRST_REFUND_MAX_DAYS; } - // Generate random date within the allowed timeframe $random_days = wp_rand( 0, $max_days ); - $refund_date = date( 'Y-m-d H:i:s', strtotime( $base_date->date( 'Y-m-d H:i:s' ) ) + ( $random_days * DAY_IN_SECONDS ) ); - - // 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; + return date( 'Y-m-d H:i:s', strtotime( $base_date->date( 'Y-m-d H:i:s' ) ) + ( $random_days * DAY_IN_SECONDS ) ); } } From 18a480cd1ba338bef975d60ac971bb171d6d87af Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:53:01 +0100 Subject: [PATCH 36/40] Add safety check before array_rand to prevent errors with empty arrays --- includes/Generator/Order.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index fbfd7f6..ee99d28 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -340,6 +340,11 @@ protected static function get_random_products( int $min_amount = 1, int $max_amo $num_products_to_get = $num_existing_products; } + // Safety check: ensure we have products to select + if ( $num_products_to_get <= 0 ) { + return $products; + } + // Get random product IDs from cache $random_keys = array_rand( self::$batch_product_ids, $num_products_to_get ); if ( ! is_array( $random_keys ) ) { From f08714ced003d1f7df1667475a8693fcc8d250af Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:18:27 +0100 Subject: [PATCH 37/40] Make default 'fixed_cart' Coupon generator explicit --- includes/Generator/Coupon.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index ffd32b0..fc285c4 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -24,11 +24,11 @@ class Coupon extends Generator { public static function generate( $save = true, $assoc_args = array() ) { parent::maybe_initialize_generators(); - $defaults = array( - 'min' => 5, - 'max' => 100, - 'discount_type' => '', - ); + $defaults = array( + 'min' => 5, + 'max' => 100, + 'discount_type' => 'fixed_cart', + ); $args = wp_parse_args( $assoc_args, $defaults ); From 20f415b0ae18a3a27d28093bb675e5b574dc90eb Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:24:09 +0100 Subject: [PATCH 38/40] Clarify memory impact --- includes/Generator/Coupon.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/Generator/Coupon.php b/includes/Generator/Coupon.php index fc285c4..1a38419 100644 --- a/includes/Generator/Coupon.php +++ b/includes/Generator/Coupon.php @@ -151,7 +151,7 @@ public static function batch( $amount, array $args = array() ) { * @return \WC_Coupon|false Coupon object or false if none available. */ public static function get_random() { - // Note: Using posts_per_page=-1 loads all coupons into memory for random selection. + // Note: Using posts_per_page=-1 loads all coupon IDs 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( From 553d847da86da7b2cbbeab17ba1b94f3f5e6dfe8 Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:29:08 +0100 Subject: [PATCH 39/40] Clarify that decimal ratios for coupons and refunds are converted to percentages using integer rounding --- README.md | 4 ++++ includes/CLI.php | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e2998f1..4ee076f 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,13 @@ 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 diff --git a/includes/CLI.php b/includes/CLI.php index 3c379b0..823b3fe 100644 --- a/includes/CLI.php +++ b/includes/CLI.php @@ -322,13 +322,13 @@ function () use ( $progress ) { 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).', + '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).', + '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( From e17884b7cec0bb62def69c86882a25121325a84b Mon Sep 17 00:00:00 2001 From: Justin Palmer <228780+layoutd@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:36:56 +0100 Subject: [PATCH 40/40] Add error logging for order generation and coupon application failures --- includes/Generator/Order.php | 79 ++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/includes/Generator/Order.php b/includes/Generator/Order.php index f043b2f..fac1e57 100644 --- a/includes/Generator/Order.php +++ b/includes/Generator/Order.php @@ -46,10 +46,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 ); @@ -135,15 +141,22 @@ public static function generate( $save = true, $assoc_args = array() ) { if ( $include_coupon ) { $coupon = self::get_or_create_coupon(); if ( $coupon ) { - $order->apply_coupon( $coupon ); - // Recalculate totals after applying coupon - $order->calculate_totals( true ); + $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. 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. @@ -159,7 +172,11 @@ 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 ) { @@ -185,7 +202,7 @@ public static function generate( $save = true, $assoc_args = array() ) { $first_refund = self::create_refund( $order ); // Some partial refunds get a second refund (always partial) - if ( $first_refund && wp_rand( 1, 100 ) <= self::SECOND_REFUND_PROBABILITY ) { + if ( $first_refund && is_object( $first_refund ) && wp_rand( 1, 100 ) <= self::SECOND_REFUND_PROBABILITY ) { self::create_refund( $order, true, $first_refund ); } } @@ -215,13 +232,18 @@ 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; } $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(); } @@ -248,6 +270,10 @@ public static function get_customer() { $customer = Customer::generate( ! $guest ); + if ( ! $customer instanceof \WC_Customer ) { + error_log( 'Customer generation failed: Customer::generate() returned invalid result' ); + } + return $customer; } @@ -322,6 +348,11 @@ protected static function get_random_products( int $min_amount = 1, int $max_amo AND post_status='publish'" ); + if ( $num_existing_products === 0 ) { + error_log( 'No published products found in database' ); + return array(); + } + $num_products_to_get = wp_rand( $min_amount, $max_amount ); if ( $num_products_to_get > $num_existing_products ) { @@ -334,18 +365,32 @@ protected static function get_random_products( int $min_amount = 1, int $max_amo 'orderby' => 'rand', ) ); - foreach ( $query->get_products() as $product_id ) { + $product_ids = $query->get_products(); + if ( empty( $product_ids ) ) { + error_log( 'WC_Product_Query returned no product IDs' ); + return array(); + } + + 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; } } @@ -374,10 +419,18 @@ protected static function get_or_create_coupon() { // 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 ) ) { - return false; + // 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; + } // Now get a random coupon from the ones we just created $coupon = Coupon::get_random();