Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
161c802
Add --coupon-ratio parameter to order generator
layoutd Oct 22, 2025
df0fda0
Add --refund-ratio parameter to order generator
layoutd Oct 22, 2025
4d3eb9d
Improve refund logic to properly handle line items and fees
layoutd Oct 22, 2025
4dd4a96
Fix refund tax calculation to properly handle tax rate IDs
layoutd Oct 22, 2025
84a81bc
Calculate explicit refund amount from line items
layoutd Oct 22, 2025
368e5b7
Fix refund ratio logic and add multiple refund support
layoutd Oct 22, 2025
479a1a8
Update partial refund reason to show products and items
layoutd Oct 22, 2025
4f98a67
Force partial refunds for orders with existing refunds
layoutd Oct 24, 2025
a3e1536
Recalculate order totals after applying coupon
layoutd Oct 24, 2025
74ae8e1
Update includes/Generator/Order.php
layoutd Oct 24, 2025
e2494ec
Doc update
layoutd Oct 24, 2025
ff277e2
Fix array_rand edge case for orders with exactly 2 items
layoutd Oct 24, 2025
f80b01e
Refactor coupon creation to use Coupon::generate()
layoutd Oct 24, 2025
ee9ff32
Move coupon retrieval logic to Coupon::get_random()
layoutd Oct 24, 2025
07daacb
Use WordPress get_posts() API instead of raw SQL queries
layoutd Oct 24, 2025
57daab8
Add discount_type parameter to Coupon generator
layoutd Oct 24, 2025
24b2493
Refactor Order generator to use Coupon::batch()
layoutd Oct 24, 2025
f8d718c
Add discount_type parameter to CLI coupon command
layoutd Oct 24, 2025
080ee99
Update README with new coupon and order parameters
layoutd Oct 24, 2025
b9c85f9
Clarify that refunds are split evenly between partial and full
layoutd Oct 24, 2025
86a499c
Fix backwards compatibility: only set discount_type when explicitly p…
layoutd Oct 24, 2025
2c9940e
Add input validation for coupon-ratio and refund-ratio parameters
layoutd Oct 24, 2025
5b53925
Add performance comment for get_posts() in Coupon::get_random()
layoutd Oct 24, 2025
0816747
Clarify that --coupons flag is equivalent to --coupon-ratio=1.0
layoutd Oct 24, 2025
0f5dd77
Fix ratio probability calculation using integer-based random generation
layoutd Oct 24, 2025
f69d126
Add check to prevent refunds with empty line items
layoutd Oct 24, 2025
73cdb1b
Improve refund error handling and logging
layoutd Oct 24, 2025
ae9b6a6
Add validation to prevent refunds with invalid amounts
layoutd Oct 24, 2025
ac5f820
Fix refund amount validation to prevent exceeding order total
layoutd Oct 24, 2025
0b3faa5
Fix refund amount calculations to prevent rounding errors and ensure …
layoutd Oct 24, 2025
9894e7e
Fix partial refunds to track already-refunded quantities and prevent …
layoutd Oct 24, 2025
c6d22b4
Fix code quality issues in refund calculations
layoutd Oct 24, 2025
323a1a7
Add realistic date handling for refunds
layoutd Oct 24, 2025
5734f42
Refactor refund generation: extract constants and helper methods
layoutd Oct 27, 2025
f08714c
Make default 'fixed_cart' Coupon generator explicit
layoutd Oct 28, 2025
20f415b
Clarify memory impact
layoutd Oct 28, 2025
553d847
Clarify that decimal ratios for coupons and refunds are converted to …
layoutd Oct 28, 2025
e17884b
Add error logging for order generation and coupon application failures
layoutd Oct 28, 2025
810e614
Fix indentation
layoutd Oct 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions includes/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,18 @@ 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' => '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',
Expand Down
297 changes: 295 additions & 2 deletions includes/Generator/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,27 @@ 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 );
// Recalculate totals after applying coupon
$order->calculate_totals( true );
}
}

// Orders created before 2024-01-09 represents orders created before the attribution feature was added.
Expand All @@ -114,6 +131,30 @@ 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'] );
$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 (always partial)
if ( $is_partial && wp_rand( 1, 100 ) <= 25 ) {
self::create_refund( $order, true );
}
}
}
}

/**
Expand Down Expand Up @@ -275,4 +316,256 @@ 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;
}

/**
* 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, $force_partial = false ) {
if ( ! $order instanceof \WC_Order ) {
return false;
}

// 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();

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' => $refund_tax,
);
}
} 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 );

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();
$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' => $refund_tax,
);
}
} 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;
$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' => $refund_tax,
);
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();
$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' => $refund_tax,
);
}
}
}

// 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 ) {
$refund_amount += abs( $tax_amount );
}
}
}

// 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'
);
}

// Create the refund
$refund = wc_create_refund(
array(
'order_id' => $order->get_id(),
'amount' => $refund_amount,
'reason' => $reason,
'line_items' => $line_items,
)
);

if ( is_wp_error( $refund ) ) {
return false;
}

// Update order status to refunded if it's a full refund
if ( $is_full_refund ) {
$order->set_status( 'refunded' );
$order->save();
}

return ! $is_full_refund;
}
}