trunk f1b8f3efb93c cached
45 files
237.8 KB
68.7k tokens
244 symbols
1 requests
Download .txt
Showing preview only (252K chars total). Download the full file or copy to clipboard to get everything.
Repository: woocommerce/wc-smooth-generator
Branch: trunk
Commit: f1b8f3efb93c
Files: 45
Total size: 237.8 KB

Directory structure:
gitextract_4kf8m5xx/

├── .editorconfig
├── .github/
│   ├── CONTRIBUTING.md
│   ├── ISSUE_TEMPLATE/
│   │   ├── 1-bug-report.yml
│   │   ├── 2-enhancement.yml
│   │   └── config.yml
│   ├── ISSUE_TEMPLATE.md
│   ├── PULL_REQUEST_TEMPLATE.md
│   └── workflows/
│       └── php-unit-tests.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .nvmrc
├── README.md
├── TESTING.md
├── bin/
│   ├── install-wp-tests.sh
│   └── lint-branch.sh
├── changelog.txt
├── composer.json
├── includes/
│   ├── Admin/
│   │   ├── AsyncJob.php
│   │   ├── BatchProcessor.php
│   │   └── Settings.php
│   ├── CLI.php
│   ├── Generator/
│   │   ├── Coupon.php
│   │   ├── Customer.php
│   │   ├── CustomerInfo.php
│   │   ├── Generator.php
│   │   ├── Order.php
│   │   ├── OrderAttribution.php
│   │   ├── Product.php
│   │   └── Term.php
│   ├── Plugin.php
│   ├── Router.php
│   └── Util/
│       └── RandomRuntimeCache.php
├── package.json
├── phpcs.xml.dist
├── phpunit.xml.dist
├── tests/
│   ├── README.md
│   ├── Unit/
│   │   ├── Generator/
│   │   │   ├── CouponTest.php
│   │   │   ├── CustomerTest.php
│   │   │   ├── GeneratorTest.php
│   │   │   ├── OrderTest.php
│   │   │   └── ProductTest.php
│   │   ├── PluginTest.php
│   │   └── Util/
│   │       └── RandomRuntimeCacheTest.php
│   └── bootstrap.php
└── wc-smooth-generator.php

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org

# WordPress Coding Standards
# https://make.wordpress.org/core/handbook/coding-standards/

root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 4
tab_width = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true

[*.txt]
trim_trailing_whitespace = false

[*.{md,json,yml}]
trim_trailing_whitespace = false
indent_style = space
indent_size = 2

================================================
FILE: .github/CONTRIBUTING.md
================================================
# Contributing ✨

Your help will be greatly appreciated :)

WooCommerce is licensed under the GPLv3+, and all contributions to the project will be released under the same license. You maintain copyright over any contribution you make, and by submitting a pull request, you are agreeing to release that contribution under the GPLv3+ license.

## Coding Guidelines and Development 🛠

- Ensure you stick to the [WordPress Coding Standards](https://make.wordpress.org/core/handbook/best-practices/coding-standards/php/)
- Whenever possible please fix pre-existing code standards errors in the files that you change. It is ok to skip that for larger files or complex fixes.
- Ensure you use LF line endings in your code editor. Use [EditorConfig](http://editorconfig.org/) if your editor supports it so that indentation, line endings and other settings are auto configured.
- When committing, reference your issue number (#1234) and include a note about the fix.
- Push the changes to your fork and submit a pull request on the trunk branch of the repository.
- Make sure to write good and detailed commit messages (see [this post](https://chris.beams.io/posts/git-commit/) for more on this) and follow all the applicable sections of the pull request template.
- Please avoid modifying the changelog directly or updating the .pot files. These will be updated by the team.


================================================
FILE: .github/ISSUE_TEMPLATE/1-bug-report.yml
================================================
name: 🐞 Bug Report
description: Report a bug if something isn't working as expected in WooCommerce Smooth Generator.
body:
  - type: markdown
    attributes:
      value: |
        ### Thanks for contributing!

        Please provide us with the information requested in this bug report. 
        Without these details, we won't be able to fully evaluate this issue. 
        Bug reports lacking detail, or for any other reason than to report a bug, may be closed without action.

        Make sure to look through the [existing `bug` issues](https://github.com/woocommerce/wc-smooth-generator/issues?q=is%3Aopen+is%3Aissue+label%3Abug) to see whether your bug has already been submitted.
        Feel free to contribute to any existing issues.
  - type: checkboxes
    id: prerequisites
    attributes:
      label: Prerequisites
      description: Please confirm these before submitting the issue.
      options:
        - label: I have carried out troubleshooting steps and I believe I have found a bug.
        - label: I have searched for similar bugs in both open and closed issues and cannot find a duplicate.
    validations:
      required: true
  - type: textarea
    id: summary
    attributes:
      label: Describe the bug
      description: |
        A clear and concise description of what the bug is and what actually happens. Please be as descriptive as possible.
        Please also include any error logs or output.
        If applicable you can attach screenshot(s) or recording(s) directly by dragging & dropping.
    validations:
      required: true
  - type: textarea
    id: environment
    attributes:
      label: WordPress Environment
      description: |
        Please share the [WooCommerce System Status Report](https://woocommerce.com/document/understanding-the-woocommerce-system-status-report/) of your site to help us evaluate the issue. 
      placeholder: |
        The System Status Report is found in your WordPress admin under **WooCommerce > Status**. 
        Please select “Get system report”, then “Copy for support”, and then paste it here.
  - type: checkboxes
    id: isolating
    attributes:
      label: Isolating the problem
      description: |
        Please try testing your site for theme and plugins conflict. 
        To do that deactivate all plugins except for WooCommerce and WooCommerce Smooth Generator and switch to a default WordPress theme or [Storefront](https://en-gb.wordpress.org/themes/storefront/). Then test again. 
        If the issue is resolved with the default theme and all plugins deactivated, it means that one of your plugins or a theme is causing the issue. 
        You will then need to enable it one by one and test every time you do that in order to figure out which plugin is causing the issue.
      options:
        - label: I have deactivated other plugins and confirmed this bug occurs when only WooCommerce and WooCommerce Smooth Generator plugins are active.
        - label: This bug happens with a default WordPress theme active, or [Storefront](https://woocommerce.com/storefront/).
        - label: I can reproduce this bug consistently using the steps above.


================================================
FILE: .github/ISSUE_TEMPLATE/2-enhancement.yml
================================================
name: ✨ Enhancement Request
description: If you have an idea to improve WooCommerce Smooth Generator or need something for development please let us know or submit a Pull Request!
title: "[Enhancement]: "
body:
  - type: markdown
    attributes:
      value: |
        ### Thanks for contributing!

        Please provide us with the information requested in this form. 

        Make sure to look through [existing `enhancement` issues](https://github.com/woocommerce/wc-smooth-generator/issues?q=is%3Aissue+label%3Aenhancement+is%3Aopen) to see whether your idea is already being discussed.
        Feel free to contribute to any existing issues.
  - type: textarea
    id: summary
    attributes:
        label: Describe the solution you'd like
        description: A clear and concise description of what you want to happen.
    validations:
      required: true
  - type: textarea
    id: alternative
    attributes:
      label: Describe alternatives you've considered
      description: A clear and concise description of any alternative solutions or features you've considered.
  - type: textarea
    id: context
    attributes:
      label: Additional context
      description: Add any other context or screenshots about the feature request here.


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: true
contact_links:
  - name: 🔒 Security issue
    url: https://hackerone.com/automattic/
    about: For security reasons, please report all security issues via HackerOne. Also, if the issue is valid, a bug bounty will be paid out to you. Please disclose responsibly and not via GitHub (which allows for exploiting issues in the wild before the patch is released).


================================================
FILE: .github/ISSUE_TEMPLATE.md
================================================
<!-- This form is for other issue types specific to WooCommerce Smooth Generator. This is not a support portal. -->

**Prerequisites (mark completed items with an [x]):**
- [ ] I have checked that my issue type is not listed here https://github.com/woocommerce/wc-smooth-generator/issues/new/choose
- [ ] My issue is not a security issue, bug report, or enhancement (Please use the link above if it is).

**Issue Description:**


================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
### All Submissions:

* [ ] Have you followed the [Contributing guidelines](https://github.com/woocommerce/wc-smooth-generator/blob/trunk/.github/CONTRIBUTING.md)?
* [ ] Does your code follow the [WordPress' coding standards](https://make.wordpress.org/core/handbook/best-practices/coding-standards/)?
* [ ] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/woocommerce/wc-smooth-generator/pulls) for the same update/change?

<!-- Mark completed items with an [x] -->

<!-- You can erase any parts of this template not applicable to your Pull Request. -->

### Changes proposed in this Pull Request:

<!-- Describe the changes made to this Pull Request and the reason for such changes. -->

Closes # .

### How to test the changes in this Pull Request:

1.
2.
3.

### Other information:

* [ ] Have you added an explanation of what your changes do and why you'd like us to include them?
* [ ] Have you written new tests for your changes, as applicable?
* [ ] Have you successfully run tests with your changes locally?

<!-- Mark completed items with an [x] -->

### Changelog entry

> Enter a summary of all changes on this Pull Request. This will appear in the changelog if accepted.

### FOR PR REVIEWER ONLY:

* [ ] I have reviewed that everything is sanitized/escaped appropriately for any SQL or XSS injection possibilities. I made sure Linting is not ignored or disabled.


================================================
FILE: .github/workflows/php-unit-tests.yml
================================================
name: PHP Unit Tests

on:
    push:
        branches:
            - trunk
        paths:
            - '**.php'
            - composer.json
            - composer.lock
            - phpunit.xml.dist
            - tests/**
            - .github/workflows/php-unit-tests.yml
    pull_request:
        paths:
            - '**.php'
            - composer.json
            - composer.lock
            - phpunit.xml.dist
            - tests/**
            - .github/workflows/php-unit-tests.yml
    workflow_dispatch:

concurrency:
    group: ${{ github.workflow }}-${{ github.ref }}
    cancel-in-progress: true

jobs:
    UnitTests:
        name: PHP unit tests - PHP ${{ matrix.php }}, WP ${{ matrix.wp-version }}
        runs-on: ubuntu-latest
        env:
            WP_CORE_DIR: '/tmp/wordpress/src'
            WP_TESTS_DIR: '/tmp/wordpress/tests/phpunit'
        strategy:
            matrix:
                php: ['7.4', '8.2', '8.5']
                wp-version: ['latest']

        services:
            mysql:
                image: mysql:8.0
                env:
                    MYSQL_ROOT_PASSWORD: root
                    MYSQL_DATABASE: wordpress_test
                ports:
                    - 3306:3306
                options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

        steps:
            - name: Checkout repository
              uses: actions/checkout@v4

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: ${{ matrix.php }}
                  extensions: mbstring, intl, mysql
                  coverage: none
                  tools: composer

            - name: Install Composer dependencies
              run: composer install --prefer-dist --no-progress

            - name: Install SVN (used for installing WP versions)
              run: sudo apt-get update && sudo apt-get install -y subversion

            - name: Install WP tests
              run: echo "y" | bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 ${{ matrix.wp-version }}

            - name: Install WooCommerce
              run: |
                  mkdir -p /tmp/wordpress/src/wp-content/plugins
                  cd /tmp/wordpress/src/wp-content/plugins
                  wget https://downloads.wordpress.org/plugin/woocommerce.latest-stable.zip
                  unzip -q woocommerce.latest-stable.zip
                  rm woocommerce.latest-stable.zip

            - name: Run PHP unit tests
              run: composer test-unit

    PHPCS:
        name: PHPCS
        runs-on: ubuntu-latest
        if: github.event_name == 'pull_request'
        steps:
            - name: Checkout repository
              uses: actions/checkout@v4
              with:
                  fetch-depth: 0

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: '8.2'
                  tools: composer

            - name: Install Composer dependencies
              run: composer install --prefer-dist --no-progress

            - name: Get Changed Files
              id: changed-files
              uses: tj-actions/changed-files@v41
              with:
                  files: '**/*.php'

            - name: Run PHPCS on changed files
              if: steps.changed-files.outputs.any_changed == 'true'
              run: vendor/bin/phpcs-changed -s --git --git-base ${{ github.event.pull_request.base.sha }} ${{ steps.changed-files.outputs.all_changed_files }}


================================================
FILE: .gitignore
================================================
vendor/
node_modules/
wc-smooth-generator.zip
wc-smooth-generator/

# PHPStorm
.idea

# PHPCS
.phpcs.xml
phpcs.xml

# PHPUnit
.phpunit.result.cache

# PHP xdebug
.vscode/launch.json

================================================
FILE: .husky/pre-commit
================================================
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

composer run lint-staged


================================================
FILE: .nvmrc
================================================
v22


================================================
FILE: README.md
================================================
# WooCommerce Smooth Generator

Generate realistic WooCommerce products, orders, customers, coupons, and taxonomy terms for development, testing, and demos.

WP-CLI is the primary interface. A limited WP Admin UI is also available at Dashboard > Tools > WooCommerce Smooth Generator.

## Installation

**From GitHub releases (recommended):**

1. Download the latest zip from [GitHub Releases](https://github.com/woocommerce/wc-smooth-generator/releases/).
2. Install via WP Admin > Plugins > Add New > Upload Plugin.

**From source:**

```bash
git clone https://github.com/woocommerce/wc-smooth-generator.git
cd wc-smooth-generator
composer install --no-dev
```

## Requirements

- PHP 7.4+
- WordPress (tested up to 6.9)
- WooCommerce 5.0+

## WP-CLI commands

All commands use the `wp wc generate` prefix. Run `wp help wc generate` for a summary, or `wp help wc generate <command>` for detailed usage.

### Products

```bash
# Generate 10 products (default, mix of simple and variable)
wp wc generate products

# Generate 25 simple products
wp wc generate products 25 --type=simple

# Generate variable products using only existing categories and tags
wp wc generate products 10 --type=variable --use-existing-terms
```

| Option | Description |
|---|---|
| `<amount>` | Number of products to generate. Default: `10` |
| `--type=<type>` | Product type: `simple` or `variable`. Default: random mix |
| `--use-existing-terms` | Only use existing categories and tags instead of generating new ones |

### Orders

```bash
# Generate 10 orders for today's date
wp wc generate orders

# Generate orders with random dates in a range
wp wc generate orders 50 --date-start=2024-01-01 --date-end=2024-12-31

# Generate completed orders with a specific status
wp wc generate orders 20 --status=completed

# Apply coupons to half the orders
wp wc generate orders 100 --coupon-ratio=0.5

# Refund 30% of completed orders
wp wc generate orders 50 --status=completed --refund-ratio=0.3
```

| Option | Description |
|---|---|
| `<amount>` | Number of orders to generate. Default: `10` |
| `--date-start=<date>` | Earliest order date (YYYY-MM-DD). Dates are randomized between this and today or `--date-end` |
| `--date-end=<date>` | Latest order date (YYYY-MM-DD). Requires `--date-start` |
| `--status=<status>` | Order status: `completed`, `processing`, `on-hold`, or `failed`. Default: random mix |
| `--coupons` | Apply a coupon to every order. Equivalent to `--coupon-ratio=1.0` |
| `--coupon-ratio=<ratio>` | Fraction of orders that get coupons (0.0-1.0). Creates 6 coupons if none exist (3 fixed cart, 3 percentage) |
| `--refund-ratio=<ratio>` | Fraction of completed orders to refund (0.0-1.0). In batch mode: 50% full, 25% partial, 25% multi-partial. Single-order mode uses probabilistic distribution |
| `--skip-order-attribution` | Skip generating order attribution metadata |

**Batch distribution:** When generating multiple orders, coupon and refund counts are deterministic (selection without replacement). For odd totals, `round()` distributes coupons and remainders go to multi-partial refunds. Single-order generation uses probabilistic distribution.

**Order attribution:** Random attribution metadata (device type, UTM parameters, referrer, session data) is added by default. Orders dated before 2024-01-09 skip attribution, since the feature didn't exist in WooCommerce yet.

### Customers

```bash
# Generate 10 customers (70% people, 30% companies)
wp wc generate customers

# Generate Spanish company customers
wp wc generate customers 20 --country=ES --type=company
```

| Option | Description |
|---|---|
| `<amount>` | Number of customers to generate. Default: `10` |
| `--country=<code>` | ISO 3166-1 alpha-2 country code (e.g., `US`, `ES`, `CN`). Localizes names and addresses. Default: random from store selling locations |
| `--type=<type>` | Customer type: `person` or `company`. Default: 70/30 mix |

### Coupons

```bash
# Generate 10 coupons with default discount range (5-100)
wp wc generate coupons

# Generate percentage coupons between 5% and 25%
wp wc generate coupons 20 --discount_type=percent --min=5 --max=25
```

| Option | Description |
|---|---|
| `<amount>` | Number of coupons to generate. Default: `10` |
| `--min=<amount>` | Minimum discount amount. Default: `5` |
| `--max=<amount>` | Maximum discount amount. Default: `100` |
| `--discount_type=<type>` | Discount type: `fixed_cart` or `percent`. Default: `fixed_cart` |

### Terms

```bash
# Generate 10 product tags
wp wc generate terms product_tag 10

# Generate hierarchical product categories
wp wc generate terms product_cat 50 --max-depth=3

# Generate child categories under an existing category
wp wc generate terms product_cat 10 --parent=123
```

| Option | Description |
|---|---|
| `<taxonomy>` | Required. Taxonomy to generate terms for: `product_cat` or `product_tag` |
| `<amount>` | Number of terms to generate. Default: `10` |
| `--max-depth=<levels>` | Maximum hierarchy depth (1-5). Only applies to `product_cat`. Default: `1` (flat) |
| `--parent=<term_id>` | Create all terms as children of this existing term ID. Only applies to `product_cat` |

## Programmatic usage

All generators live in the `WC\SmoothGenerator\Generator` namespace and expose `generate()` and `batch()` static methods.

### Single objects

```php
use WC\SmoothGenerator\Generator;

// Generate and save a product (returns WC_Product or WP_Error).
$product = Generator\Product::generate( true, [ 'type' => 'simple' ] );

// Generate and save an order (returns WC_Order or false).
$order = Generator\Order::generate( true, [ 'status' => 'completed' ] );

// Generate and save a customer (returns WC_Customer or WP_Error).
$customer = Generator\Customer::generate( true, [ 'country' => 'US', 'type' => 'person' ] );

// Generate and save a coupon (returns WC_Coupon or WP_Error).
$coupon = Generator\Coupon::generate( true, [ 'min' => 5, 'max' => 25, 'discount_type' => 'percent' ] );

// Generate and save a term (returns WP_Term or WP_Error).
$term = Generator\Term::generate( true, 'product_cat', 0 );
```

### Batch generation

```php
use WC\SmoothGenerator\Generator;

// Generate 50 products (returns array of product IDs or WP_Error).
// Max batch size: 100.
$product_ids = Generator\Product::batch( 50, [ 'type' => 'variable', 'use-existing-terms' => true ] );

// Generate 100 orders with date range and coupons.
$order_ids = Generator\Order::batch( 100, [
    'date-start'   => '2024-01-01',
    'date-end'     => '2024-06-30',
    'status'       => 'completed',
    'coupon-ratio' => '0.3',
    'refund-ratio' => '0.2',
] );

// Generate 25 customers.
$customer_ids = Generator\Customer::batch( 25, [ 'country' => 'ES' ] );

// Generate 10 coupons.
$coupon_ids = Generator\Coupon::batch( 10, [ 'min' => 1, 'max' => 50 ] );

// Generate 20 hierarchical product categories.
$term_ids = Generator\Term::batch( 20, 'product_cat', [ 'max-depth' => 3 ] );
```

### Action hooks

Each generator fires an action after creating an object:

- `smoothgenerator_product_generated` -- after a product is saved
- `smoothgenerator_order_generated` -- after an order is saved
- `smoothgenerator_customer_generated` -- after a customer is saved
- `smoothgenerator_coupon_generated` -- after a coupon is saved
- `smoothgenerator_term_generated` -- after a term is saved

## Available generators

### Product generator

Creates simple or variable products with:

- Name, SKU, global unique ID, featured status
- Price, sale price, sale date scheduling
- Tax status and class, stock management
- Product image and gallery images (auto-generated)
- Categories, tags, and brands (if the `product_brand` taxonomy exists)
- Upsells and cross-sells from existing products
- Attributes and variations (for variable products)
- Virtual/downloadable flags, dimensions, weight
- Cost of Goods Sold (if WooCommerce COGS is enabled)
- Reviews allowed toggle, purchase notes, menu order

### Order generator

Creates orders with realistic data:

- Billing and shipping addresses from the customer
- Line items from existing products
- Random status distribution (or a specific status)
- Date randomization within a given range
- Coupon application with configurable ratio
- Refunds: full, partial, and multi-partial with realistic timing
- Order attribution: device type, UTM parameters, referrer, session data
- Extra fees (~20% chance per order)
- Paid and completed dates based on status

### Customer generator

Creates customer accounts with localized data:

- Person (first/last name) or company profiles
- Localized names, emails, and phone numbers based on country
- Billing address with street, city, state, postcode
- Shipping address (50% chance; half copy billing, half are unique)
- Username and password

### Coupon generator

Creates discount coupons:

- Auto-generated coupon codes
- Configurable discount range (min/max)
- Fixed cart or percentage discount type

### Term generator

Creates taxonomy terms for products:

- Product categories (`product_cat`) with optional hierarchy (up to 5 levels deep)
- Product tags (`product_tag`)
- Auto-generated descriptions
- Child terms under a specified parent

## Contributing

Found a bug or want a feature? [Open an issue](https://github.com/woocommerce/wc-smooth-generator/issues) or submit a pull request.

### Development setup

Requires Node.js v16 and Composer v2+.

```bash
npm run setup
```

This installs dependencies and sets up a pre-commit hook that lints PHP changes using the WooCommerce Core phpcs ruleset.

## License

[GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html)


================================================
FILE: TESTING.md
================================================
# Testing Guide for Exact Ratio Distribution

This document provides comprehensive test cases for verifying the exact ratio distribution feature for coupons and refunds in batch mode.

## Prerequisites

- WordPress installation with WooCommerce
- WC Smooth Generator plugin installed
- WP-CLI access
- Some products already generated (run `wp wc generate products 50` if needed)

## Test Cases

### 1. Basic Coupon Ratio Tests

#### Test 1.1: Exact 50% coupon ratio
```bash
wp wc generate orders 100 --coupon-ratio=0.5
```
**Expected Result:** Exactly 50 orders with coupons

**Verification:**
```bash
# Count orders with coupons via database
wp db query "SELECT COUNT(DISTINCT order_id) FROM wp_woocommerce_order_items WHERE order_item_type = 'coupon'"
```

#### Test 1.2: Edge case - 0.0 ratio (no coupons)
```bash
wp wc generate orders 50 --coupon-ratio=0.0
```
**Expected Result:** 0 orders with coupons

#### Test 1.3: Edge case - 1.0 ratio (all coupons)
```bash
wp wc generate orders 50 --coupon-ratio=1.0
```
**Expected Result:** Exactly 50 orders with coupons

#### Test 1.4: Odd number rounding
```bash
wp wc generate orders 11 --coupon-ratio=0.5
```
**Expected Result:** Exactly 6 orders with coupons (5.5 rounds to 6)

### 2. Basic Refund Ratio Tests

#### Test 2.1: Exact 40% refund ratio with distribution
```bash
wp wc generate orders 100 --status=completed --refund-ratio=0.4
```
**Expected Result:**
- Total: 40 refunds
- Distribution: ~20 full, ~10 single partial, ~10 multi-partial

**Verification:**
```bash
# Count total refunds
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_type = 'shop_order_refund'"

# Count full refunds (orders with status 'refunded')
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_type = 'shop_order' AND post_status = 'wc-refunded'"
```

#### Test 2.2: Edge case - 0.0 ratio (no refunds)
```bash
wp wc generate orders 50 --status=completed --refund-ratio=0.0
```
**Expected Result:** 0 refunds

#### Test 2.3: Edge case - 1.0 ratio (all refunds)
```bash
wp wc generate orders 50 --status=completed --refund-ratio=1.0
```
**Expected Result:** Exactly 50 refunds (distributed 50/25/25)

#### Test 2.4: Odd number rounding for refunds
```bash
wp wc generate orders 11 --status=completed --refund-ratio=0.4
```
**Expected Result:**
- Total: 4 refunds (rounded)
- Distribution: ~2 full, ~1 partial, ~1 multi (remainder)

### 3. Parameter Precedence Tests

#### Test 3.1: Legacy --coupons flag
```bash
wp wc generate orders 20 --coupons
```
**Expected Result:** All 20 orders have coupons (legacy behavior preserved)

#### Test 3.2: Coupon ratio without legacy flag
```bash
wp wc generate orders 100 --coupon-ratio=0.3
```
**Expected Result:** Exactly 30 orders with coupons

#### Test 3.3: Both flags (ratio should be ignored when legacy flag present)
```bash
wp wc generate orders 100 --coupons --coupon-ratio=0.3
```
**Expected Result:** All 100 orders have coupons (--coupons takes precedence)

### 4. Single Order Generation (Probabilistic Fallback)

#### Test 4.1: Single order with coupon ratio should use probabilistic
```bash
# Run multiple times to verify probabilistic behavior
wp wc generate orders 1 --coupon-ratio=0.5
wp wc generate orders 1 --coupon-ratio=0.5
wp wc generate orders 1 --coupon-ratio=0.5
```
**Expected Result:** Approximately 50% of single orders will have coupons (varies each run)

### 5. Refund Distribution Verification

#### Test 5.1: Verify 50/25/25 refund split
```bash
# Generate orders and check distribution
wp wc generate orders 200 --status=completed --refund-ratio=0.5
```
**Expected Result:**
- Total refunds: 100
- Full refunds (~50): Orders with status "refunded"
- Single partial (~25): Orders with 1 refund, status still "completed"
- Multi-partial (~25): Orders with 2 refunds, status still "completed"

**Manual Verification:**
1. Check a sample of fully refunded orders in WP Admin
2. Check a sample of partially refunded orders
3. Count number of refund entries per order

### 6. Combined Parameters

#### Test 6.1: Date range + coupon ratio + refund ratio
```bash
wp wc generate orders 100 --date-start=2024-01-01 --date-end=2024-12-31 --status=completed --coupon-ratio=0.4 --refund-ratio=0.3
```
**Expected Result:**
- Orders spread across date range
- Exactly 40 orders with coupons
- Exactly 30 orders with refunds (distributed 50/25/25)

### 7. Failed Orders Edge Case

#### Test 7.1: Verify failed orders don't affect count
```bash
# If products are missing or invalid, some orders may fail
wp wc generate orders 100 --coupon-ratio=0.5
```
**Expected Result:**
- Count only successful orders
- If only 95 orders succeed, there should be exactly 47-48 with coupons (based on 95, not 100)

## Verification Queries

### Count Orders with Coupons
```bash
wp db query "SELECT COUNT(DISTINCT order_id) FROM wp_woocommerce_order_items WHERE order_item_type = 'coupon'"
```

### Count All Refunds
```bash
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_type = 'shop_order_refund'"
```

### Count Full Refunds (Orders with 'refunded' status)
```bash
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_type = 'shop_order' AND post_status = 'wc-refunded'"
```

### Count Partial Refunds
```bash
wp db query "
SELECT COUNT(*) as partial_refund_orders
FROM (
    SELECT p.ID, COUNT(r.ID) as refund_count
    FROM wp_posts p
    LEFT JOIN wp_posts r ON r.post_parent = p.ID AND r.post_type = 'shop_order_refund'
    WHERE p.post_type = 'shop_order' AND p.post_status = 'wc-completed'
    GROUP BY p.ID
    HAVING refund_count > 0
) as refunded_completed
"
```

### Count Multi-Partial Refunds (2 refunds on same order)
```bash
wp db query "
SELECT COUNT(*) as multi_partial_orders
FROM (
    SELECT p.ID, COUNT(r.ID) as refund_count
    FROM wp_posts p
    LEFT JOIN wp_posts r ON r.post_parent = p.ID AND r.post_type = 'shop_order_refund'
    WHERE p.post_type = 'shop_order'
    GROUP BY p.ID
    HAVING refund_count = 2
) as multi_refunded
"
```

## Notes

- All exact ratio tests assume successful order generation
- Exact ratio distribution uses O(1) memory via dynamic counters (selection without replacement algorithm)
- Works for any batch size without memory constraints
- Ratios are rounded using PHP's `round()` function for odd numbers


================================================
FILE: bin/install-wp-tests.sh
================================================
#!/usr/bin/env bash

if [ $# -lt 3 ]; then
	echo "usage: $0 <db-name> <db-user> <db-pass> [db-host] [wp-version] [skip-database-creation]"
	exit 1
fi

DB_NAME=$1
DB_USER=$2
DB_PASS=$3
DB_HOST=${4-localhost}
WP_VERSION=${5-latest}
SKIP_DB_CREATE=${6-false}

TMPDIR=${TMPDIR-/tmp}
TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//")
WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib}
WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/}

download() {
    if [ `which curl` ]; then
        curl -s "$1" > "$2";
    elif [ `which wget` ]; then
        wget -nv -O "$2" "$1"
    fi
}

if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then
	WP_BRANCH=${WP_VERSION%\-*}
	WP_TESTS_TAG="branches/$WP_BRANCH"

elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then
	WP_TESTS_TAG="branches/$WP_VERSION"
elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
	if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
		# version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
		WP_TESTS_TAG="tags/${WP_VERSION%??}"
	else
		WP_TESTS_TAG="tags/$WP_VERSION"
	fi
elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
	WP_TESTS_TAG="trunk"
else
	# http serves a single offer, whereas https serves multiple. we only want one
	download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json
	grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json
	LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//')
	if [[ -z "$LATEST_VERSION" ]]; then
		echo "Latest WordPress version could not be found"
		exit 1
	fi
	WP_TESTS_TAG="tags/$LATEST_VERSION"
fi
set -ex

install_wp() {

	if [ -d $WP_CORE_DIR ]; then
		return;
	fi

	mkdir -p $WP_CORE_DIR

	if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
		mkdir -p $TMPDIR/wordpress-trunk
		rm -rf $TMPDIR/wordpress-trunk/*
		svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress
		mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR
	else
		if [ $WP_VERSION == 'latest' ]; then
			local ARCHIVE_NAME='latest'
		elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then
			# https serves multiple offers, whereas http serves single.
			download https://wordpress.org/wordpress-$WP_VERSION.tar.gz  $TMPDIR/wordpress.tar.gz
			ARCHIVE_NAME="wordpress-$WP_VERSION"
		fi

		if [ ! -z "$ARCHIVE_NAME" ]; then
			download https://wordpress.org/${ARCHIVE_NAME}.tar.gz  $TMPDIR/wordpress.tar.gz
			tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR
		fi
	fi

	download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php
}

install_test_suite() {
	# portable in-place argument for both GNU sed and Mac OSX sed
	if [[ $(uname -s) == 'Darwin' ]]; then
		local ioption='-i.bak'
	else
		local ioption='-i'
	fi

	# set up testing suite if it doesn't yet exist
	if [ ! -d $WP_TESTS_DIR ]; then
		# set up testing suite
		mkdir -p $WP_TESTS_DIR
		rm -rf $WP_TESTS_DIR/{includes,data}
		svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes
		svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data
	fi

	if [ ! -f wp-tests-config.php ]; then
		download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php
		# remove all forward slashes in the end
		WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::")
		sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
		sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php
		sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php
		sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php
		sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php
	fi

}

recreate_db() {
	shopt -s nocasematch
	if [[ $1 =~ ^(y|yes)$ ]]
	then
		mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA
		create_db
		echo "Recreated the database ($DB_NAME)."
	else
		echo "Leaving the existing database ($DB_NAME) in place."
	fi
	shopt -u nocasematch
}

create_db() {
	mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA
}

install_db() {

	if [ ${SKIP_DB_CREATE} = "true" ]; then
		return 0
	fi

	# parse DB_HOST for port or socket references
	local PARTS=(${DB_HOST//\:/ })
	local DB_HOSTNAME=${PARTS[0]};
	local DB_SOCK_OR_PORT=${PARTS[1]};
	local EXTRA=""

	if ! [ -z $DB_HOSTNAME ] ; then
		if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then
			EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp"
		elif ! [ -z $DB_SOCK_OR_PORT ] ; then
			EXTRA=" --socket=$DB_SOCK_OR_PORT"
		elif ! [ -z $DB_HOSTNAME ] ; then
			EXTRA=" --host=$DB_HOSTNAME --protocol=tcp"
		fi
	fi

	# create database
	if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ]
	then
		echo "Reinstalling will delete the existing test database ($DB_NAME)"
		read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB
		recreate_db $DELETE_EXISTING_DB
	else
		create_db
	fi
}

install_wp
install_test_suite
install_db


================================================
FILE: bin/lint-branch.sh
================================================
#!/bin/bash

# Lint branch
#
# Runs phpcs-changed, comparing the current branch to its "base" or "parent" branch.
# The base branch defaults to trunk, but another branch name can be specified as an
# optional positional argument.
#
# Example:
# ./lint-branch.sh base-branch

baseBranch=${1:-"trunk"}

changedFiles=$(git diff $(git merge-base HEAD $baseBranch) --relative --name-only -- '*.php')

# Only complete this if changed files are detected.
[[ -z $changedFiles ]] || composer exec phpcs-changed -- -s --git --git-base $baseBranch $changedFiles


================================================
FILE: changelog.txt
================================================
*** Changelog ***

2026-02-19 - version 1.3.0
* Add - Brand support for generated products (product_brand taxonomy).
* Add - Comprehensive unit test suite with PHPUnit tests.
* Add - GitHub Actions workflow for automated PHP unit testing across multiple PHP versions.
* Add - Exact ratio distribution for deterministic test data generation with --coupon-ratio and --refund-ratio flags.
* Add - Cost of goods sold (COGS) support for generated products.
* Add - Global unique ID (_wc_gla_gtin) support for product generation.
* Add - Additional referral sources to order attribution data.
* Add - Plugin settings link on plugins page.
* Add - --use-existing-terms flag for product generator to use existing taxonomy terms.
* Tweak - Sale dates now generate only future dates when is_on_sale is true.
* Tweak - Tax status generation is now more dynamic across different product types.
* Tweak - Order attribution source now uses woocommerce.com instead of woo.com.
* Tweak - Notify users when no orders are generated due to lack of published products.
* Tweak - Optimize order date generation performance.
* Tweak - Update jdenticon to version 2.0.0.
* Tweak - Update Composer dependencies for PHP 8.5 support.
* Tweak - Update phpcs-changed to version 2.11.8 for PHP 8.4 compatibility.
* Dev - Add woocommerce as required plugin in plugin header.
* Dev - Deprecate generate_term_ids method in favor of more selective cache clearing.
* Fix - Refund date constraints to prevent future dates.
* Fix - GitHub Actions MySQL setup to use service containers for better reliability.
* Fix - Allow attribution on orders with no product items.

2025-03-25 - version 1.2.2
* Add - Add date range arguments to admin UI for generating data.
* Avoid fatal errors that sporadically occurred while generating taxonomy terms.
* De-couple taxonomy term generation from product generation.
* Ensure transactional emails are disabled before generating test content.

2024-12-05 - version 1.2.1
* Add - Support for campaign order attribution data.
* Fix - Remove unknown from get_random_device_type() output.
* Fix fatal when generating a large amount of orders, which increases the chances of hitting the empty locale issue.
* Fixes progress bar feedback when generating customers via WP-CLI.
* Set paid and completed dates based on order status.
* Tweak - Upgrade fakerphp to latest version to address PHP 8.4 compatibility.

= 1.2.0 - 2024-07-12 =
* Add - --country and --type arguments for the `generate customers` command.
* Add - customer generator attempts to localize data based on the specified country.
* Add - orders will now include order attribution meta data.
* Add - a progress bar in the Web UI.
* Add - all generators now use a `batch` function under the hood when generating multiple items.
* Change - customer generator defaults to only using countries that the store is configured to sell to.
* Change - customer generator attempts to keep data consistent between name, username, and email address.
* Change - coupon generator now generates more unique coupon codes.
* Change - background process for the Web UI now generates items in batches instead of one at a time.
* Change - menu item under WP Admin > Tools is now just "Smooth Generator" for better space efficiency.
* Dev - update build tools, remove Grunt.
* Fix - coupon generator will always generate the specified number of coupons.

= 1.1.0 - 2023-03-14 =
* Add - some generated orders will now include fees.
* Add - the possibility for billing, shipping, and location addresses to be different in orders.
* Add - declare compatibility with WooCommerce's High Performance Order Storage feature.
* Add - all CLI commands now show elapsed time upon completion.
* Add - introduce --type argument to the `generate products` command.
* Add - more music video possibilities on the Smooth Generator admin screen.
* Add - new generator for terms in the product categories and product tags taxonomies.
* Dev - update PHP version requirement to 7.4.
* Fix - ensure emails are disabled during generation.
* Fix - add missing documentation about the coupons CLI command to the readme.

= 1.0.5 - 2022-06-30 =
* Fix - Lower version requirement from PHP 8.0.2 to PHP 7.1.

= 1.0.4 - 2021-12-15 =
* Add - coupon generator and a new option for orders to allow for coupon generation.
* Add - use product name to generate more realistic product term names.
* Fix - include jdenticon package in generated zip.

= 1.0.3 - 2021-08-12 =
* Add - --status argument to `generate orders` command
* Add - UI support for generating products and orders
* Dev - update Composer support for V2
* Fix - reduce product generation time by reducing the maximum number of attribute terms on variable products
* Fix - disable all email notifications on customer and order generation


= 1.0.2 - 2020-11-19 =
* Change log starts.


================================================
FILE: composer.json
================================================
{
  "name": "woocommerce/wc-smooth-generator",
  "description": "A smooth product, order, customer, and coupon generator for WooCommerce.",
  "homepage": "https://woocommerce.com/",
  "type": "wordpress-plugin",
  "license": "GPL-3.0-or-later",
  "prefer-stable": true,
  "minimum-stability": "dev",
  "require": {
    "php": ">=7.4",
    "psr/container": "1.0.0",
    "composer/installers": "~1.2",
    "fakerphp/faker": "^1.24.0",
    "jdenticon/jdenticon": "^2.0.0",
    "mbezhanov/faker-provider-collection": "^2.0.1",
    "symfony/deprecation-contracts": "^2.2"
  },
  "require-dev": {
    "woocommerce/woocommerce-sniffs": "*",
    "sirbrillig/phpcs-changed": "^2.11.8",
    "phpunit/phpunit": "^9.5 || ^10.0 || ^11.0",
    "yoast/phpunit-polyfills": "^1.0 || ^2.0"
  },
  "autoload": {
    "psr-4": {"WC\\SmoothGenerator\\": "includes/"}
  },
  "autoload-dev": {
    "psr-4": {"WC\\SmoothGenerator\\Tests\\": "tests/Unit"}
  },
  "scripts": {
    "test-unit": "./vendor/bin/phpunit",
    "phpcs": [
      "vendor/bin/phpcs"
    ],
    "phpcbf": [
      "vendor/bin/phpcbf"
    ],
    "lint": [
      "chg=$(git diff --relative --name-only -- '*.php'); [[ -z $chg ]] || phpcs-changed -s --git --git-unstaged $chg"
    ],
    "lint-staged": [
      "chg=$(git diff HEAD --relative --name-only -- '*.php'); [[ -z $chg ]] || phpcs-changed -s --git $chg"
    ],
    "lint-branch": [
      "sh ./bin/lint-branch.sh"
    ]
  },
  "extra": {
    "scripts-description": {
      "phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer",
      "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier"
    }
  },
  "archive": {
    "exclude": [
      "/.github",
      "/.husky",
      "/bin",
      "/node_modules",
      "/tests",
      "composer.*",
      "package*.json",
      "phpcs*",
      "phpunit*",
      "TESTING.md",
      ".*",
      "!vendor/autoload.php",
      "!vendor/composer",
      "!vendor/fakerphp",
      "!vendor/jdenticon",
      "!vendor/mbezhanov",
      "!vendor/symfony"
    ]
  },
  "config": {
    "allow-plugins": {
      "composer/installers": true,
      "dealerdirect/phpcodesniffer-composer-installer": true
    }
  }
}


================================================
FILE: includes/Admin/AsyncJob.php
================================================
<?php

namespace WC\SmoothGenerator\Admin;

/**
 * Class AsyncJob.
 *
 * A Record Object to hold the current state of an async job.
 */
class AsyncJob {
	/**
	 * The slug of the generator.
	 *
	 * @var string
	 */
	public string $generator_slug = '';

	/**
	 * The total number of objects to generate.
	 *
	 * @var int
	 */
	public int $amount = 0;

	/**
	 * Additional args for generating the objects.
	 *
	 * @var array
	 */
	public array $args = array();

	/**
	 * The number of objects already generated.
	 *
	 * @var int
	 */
	public int $processed = 0;

	/**
	 * The number of objects that still need to be generated.
	 *
	 * @var int
	 */
	public int $pending = 0;

	/**
	 * AsyncJob class.
	 *
	 * @param array $data
	 */
	public function __construct( array $data = array() ) {
		$defaults = array(
			'generator_slug' => $this->generator_slug,
			'amount'         => $this->amount,
			'args'           => $this->args,
			'processed'      => $this->processed,
			'pending'        => $this->pending,
		);
		$data     = wp_parse_args( $data, $defaults );

		list(
			'generator_slug' => $this->generator_slug,
			'amount'         => $this->amount,
			'args'           => $this->args,
			'processed'      => $this->processed,
			'pending'        => $this->pending
		) = $data;
	}
}


================================================
FILE: includes/Admin/BatchProcessor.php
================================================
<?php

namespace WC\SmoothGenerator\Admin;

use Automattic\WooCommerce\Internal\BatchProcessing\{ BatchProcessorInterface, BatchProcessingController };
use WC\SmoothGenerator\Router;

/**
 * Class BatchProcessor.
 *
 * A class for asynchronously generating batches of objects using WooCommerce's internal batch processing tool.
 * (This might break if changes are made to the tool.)
 */
class BatchProcessor implements BatchProcessorInterface {
	/**
	 * The key used to store the state of the current job in the options table.
	 */
	const OPTION_KEY = 'smoothgenerator_async_job';

	/**
	 * Get the state of the current job.
	 *
	 * @return ?AsyncJob Null if there is no current job.
	 */
	public static function get_current_job() {
		$current_job = get_option( self::OPTION_KEY, null );

		if ( ! $current_job instanceof AsyncJob && wc_get_container()->get( BatchProcessingController::class )->is_enqueued( self::class ) ) {
			wc_get_container()->get( BatchProcessingController::class )->remove_processor( self::class );
		} elseif ( $current_job instanceof AsyncJob && ! wc_get_container()->get( BatchProcessingController::class )->is_enqueued( self::class ) ) {
			self::delete_current_job();
			$current_job = null;
		}

		return $current_job;
	}

	/**
	 * Create a new AsyncJob object.
	 *
	 * @param string $generator_slug The slug identifier of the generator to use.
	 * @param int    $amount         The number of objects to generate.
	 * @param array  $args           Additional args for object generation.
	 *
	 * @return AsyncJob|\WP_Error
	 */
	public static function create_new_job( string $generator_slug, int $amount, array $args = array() ) {
		if ( self::get_current_job() instanceof AsyncJob ) {
			return new \WP_Error(
				'smoothgenerator_async_job_already_exists',
				'Can\'t create a new Smooth Generator job because one is already in progress.'
			);
		}

		$job = new AsyncJob( array(
			'generator_slug' => $generator_slug,
			'amount'         => $amount,
			'args'           => $args,
			'pending'        => $amount,
		) );

		update_option( self::OPTION_KEY, $job, false );

		wc_get_container()->get( BatchProcessingController::class )->enqueue_processor( self::class );

		return $job;
	}

	/**
	 * Update the state of the current job.
	 *
	 * @param int $processed The amount to change the state values by.
	 *
	 * @return AsyncJob|\WP_Error
	 */
	public static function update_current_job( int $processed ) {
		$current_job = self::get_current_job();

		if ( ! $current_job instanceof AsyncJob ) {
			return new \WP_Error(
				'smoothgenerator_async_job_does_not_exist',
				'There is no Smooth Generator job to update.'
			);
		}

		$current_job->processed += $processed;
		$current_job->pending    = max( $current_job->pending - $processed, 0 );

		update_option( self::OPTION_KEY, $current_job, false );

		return $current_job;
	}

	/**
	 * Delete the AsyncJob object.
	 *
	 * @return bool
	 */
	public static function delete_current_job() {
		wc_get_container()->get( BatchProcessingController::class )->remove_processor( self::class );
		delete_option( self::OPTION_KEY );
	}

	/**
	 * Get a user-friendly name for this processor.
	 *
	 * @return string Name of the processor.
	 */
	public function get_name(): string {
		return 'Smooth Generator';
	}

	/**
	 * Get a user-friendly description for this processor.
	 *
	 * @return string Description of what this processor does.
	 */
	public function get_description(): string {
		return 'Generates various types of WooCommerce data objects with randomized data for use in testing.';
	}

	/**
	 * Get the total number of pending items that require processing.
	 * Once an item is successfully processed by 'process_batch' it shouldn't be included in this count.
	 *
	 * Note that the once the processor is enqueued the batch processor controller will keep
	 * invoking `get_next_batch_to_process` and `process_batch` repeatedly until this method returns zero.
	 *
	 * @return int Number of items pending processing.
	 */
	public function get_total_pending_count(): int {
		$current_job = self::get_current_job();

		if ( ! $current_job instanceof AsyncJob ) {
			return 0;
		}

		return $current_job->pending;
	}

	/**
	 * Returns the next batch of items that need to be processed.
	 *
	 * A batch item can be anything needed to identify the actual processing to be done,
	 * but whenever possible items should be numbers (e.g. database record ids)
	 * or at least strings, to ease troubleshooting and logging in case of problems.
	 *
	 * The size of the batch returned can be less than $size if there aren't that
	 * many items pending processing (and it can be zero if there isn't anything to process),
	 * but the size should always be consistent with what 'get_total_pending_count' returns
	 * (i.e. the size of the returned batch shouldn't be larger than the pending items count).
	 *
	 * @param int $size Maximum size of the batch to be returned.
	 *
	 * @return array Batch of items to process, containing $size or less items.
	 */
	public function get_next_batch_to_process( int $size ): array {
		$current_job = self::get_current_job();
		$max_batch   = self::get_default_batch_size();

		if ( ! $current_job instanceof AsyncJob ) {
			$current_job = new AsyncJob();
		}

		$amount = min( $size, $current_job->pending, $max_batch );

		// The batch processing controller counts items in the array to determine if there are still pending items.
		if ( $amount < 1 ) {
			return array();
		}

		return array(
			'generator_slug' => $current_job->generator_slug,
			'amount'         => $amount,
			'args'           => $current_job->args,
		);
	}

	/**
	 * Process data for the supplied batch.
	 *
	 * This method should be prepared to receive items that don't actually need processing
	 * (because they have been processed before) and ignore them, but if at least
	 * one of the batch items that actually need processing can't be processed, an exception should be thrown.
	 *
	 * Once an item has been processed it shouldn't be counted in 'get_total_pending_count'
	 * nor included in 'get_next_batch_to_process' anymore (unless something happens that causes it
	 * to actually require further processing).
	 *
	 * @throw \Exception Something went wrong while processing the batch.
	 *
	 * @param array $batch Batch to process, as returned by 'get_next_batch_to_process'.
	 */
	public function process_batch( array $batch ): void {
		list( 'generator_slug' => $slug, 'amount' => $amount, 'args' => $args ) = $batch;

		$result = Router::generate_batch( $slug, $amount, $args );

		if ( is_wp_error( $result ) ) {
			throw new \Exception( $result->get_error_message() );
		}

		self::update_current_job( count( $result ) );
	}

	/**
	 * Default (preferred) batch size to pass to 'get_next_batch_to_process'.
	 * The controller will pass this size unless it's externally configured
	 * to use a different size.
	 *
	 * @return int Default batch size.
	 */
	public function get_default_batch_size(): int {
		$current_job = self::get_current_job() ?: new AsyncJob();
		$generator   = Router::get_generator_class( $current_job->generator_slug );

		if ( is_wp_error( $generator ) ) {
			return 0;
		}

		return $generator::MAX_BATCH_SIZE;
	}
}


================================================
FILE: includes/Admin/Settings.php
================================================
<?php
/**
 * Plugin admin settings
 *
 * @package SmoothGenerator\Admin\Classes
 */

namespace WC\SmoothGenerator\Admin;

/**
 *  Initializes and manages the settings screen.
 */
class Settings {

	const DEFAULT_NUM_PRODUCTS           = 10;
	const DEFAULT_NUM_ORDERS             = 10;

	/**
	 *  Set up hooks.
	 */
	public static function init() {
		add_action( 'admin_menu', array( __CLASS__, 'register_admin_menu' ) );
		add_filter( 'heartbeat_received', array( __CLASS__, 'receive_heartbeat' ), 10, 3 );
	}

	/**
	 * Register the admin menu and screen.
	 */
	public static function register_admin_menu() {
		$hook = add_management_page(
			'WooCommerce Smooth Generator',
			'Smooth Generator',
			'install_plugins',
			'smoothgenerator',
			array( __CLASS__, 'render_admin_page' )
		);

		add_action( "load-$hook", array( __CLASS__, 'process_page_submit' ) );
	}

	/**
	 * Render the admin page.
	 */
	public static function render_admin_page() {
		$current_job = self::get_current_job();

		$generate_button_atts = $current_job instanceof AsyncJob ? array( 'disabled' => true ) : array();
		$cancel_button_atts   = ! $current_job instanceof AsyncJob ? array( 'disabled' => true ) : array();

		?>
		<h1>WooCommerce Smooth Generator</h1>
		<p class="description">
			Generate randomized WooCommerce data for testing.
		</p>

		<?php echo self::while_you_wait(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>

		<?php if ( $current_job instanceof AsyncJob ) : ?>
			<div id="smoothgenerator-progress">
				<label for="smoothgenerator-progress-bar" style="display: block;">
					<?php
					printf(
						'Generating %s %s&hellip;',
						number_format_i18n( $current_job->amount ),
						esc_html( $current_job->generator_slug )
					);
					?>
				</label>
				<progress
					id="smoothgenerator-progress-bar"
					max="<?php echo esc_attr( $current_job->amount ); ?>"
					value="<?php echo $current_job->processed ? esc_attr( $current_job->processed ) : ''; ?>"
					style="width: 560px;"
				>
					<?php
					printf(
						'%d out of %d',
						esc_html( $current_job->processed ),
						esc_html( $current_job->amount ),
					);
					?>
				</progress>
			</div>
		<?php elseif ( filter_input( INPUT_POST, 'cancel_job' ) ) : ?>
			<div class="notice notice-error inline-notice is-dismissible" style="margin-left: 0;">
				<p>Current job canceled.</p>
			</div>
		<?php endif; ?>

		<form method="post">
			<?php wp_nonce_field( 'generate', 'smoothgenerator_nonce' ); ?>
			<h2>Generate products</h2>
			<p>
				<label for="generate_products_input" class="screen-reader-text">Number of products to generate</label>
				<input
					id="generate_products_input"
					type="number"
					name="num_products_to_generate"
					value="<?php echo esc_attr( self::DEFAULT_NUM_PRODUCTS ); ?>"
					min="1"
					<?php disabled( $current_job instanceof AsyncJob ); ?>
				/>
				<?php
				submit_button(
					'Generate',
					'primary',
					'generate_products',
					false,
					$generate_button_atts
				);
				?>
			</p>

			<h2>Generate orders</h2>
			<p>
				<label for="generate_orders_input" class="screen-reader-text">Number of orders to generate</label>
				<input
					id="generate_orders_input"
					type="number"
					name="num_orders_to_generate"
					value="<?php echo esc_attr( self::DEFAULT_NUM_ORDERS ); ?>"
					min="1"
					<?php disabled( $current_job instanceof AsyncJob ); ?>
				/>
				<?php
				submit_button(
					'Generate',
					'primary',
					'generate_orders',
					false,
					$generate_button_atts
				);
				?>
			</p>

			<h2>Advanced Options</h2>
			<p>
				<label>
					<input
						type="checkbox"
						id="use_date_range"
						name="use_date_range"
						<?php disabled( $current_job instanceof AsyncJob ); ?>
					/>
					Specify date range for generation
				</label>
			</p>
			<div id="date_range_inputs" style="display: none;">
				<p>
					<label for="generate_start_date_input">Start date</label>
					<input
						id="generate_start_date_input"
						type="date"
						name="start_date"
						value="<?php echo esc_attr( date( 'Y-m-d' ) ); ?>"
						<?php disabled( $current_job instanceof AsyncJob ); ?>
					/>
					<label for="generate_end_date_input">End date</label>
					<input
						id="generate_end_date_input"
						type="date"
						name="end_date"
						value="<?php echo esc_attr( date( 'Y-m-d' ) ); ?>"
						<?php disabled( $current_job instanceof AsyncJob ); ?>
					/>
				</p>
			</div>

			<?php
			submit_button(
				'Cancel current job',
				'secondary',
				'cancel_job',
				true,
				$cancel_button_atts
			);
			?>
		</form>
		<?php

		self::heartbeat_script();
		self::date_range_toggle_script();
	}

	/**
	 * Script to toggle date range inputs visibility.
	 *
	 * @return void
	 */
	protected static function date_range_toggle_script() {
		?>
		<script>
			( function( $ ) {
				$( '#use_date_range' ).on( 'change', function() {
					$( '#date_range_inputs' ).toggle( this.checked );
				} );
			} )( jQuery );
		</script>
		<?php
	}

	/**
	 * Script to interact with heartbeat and run the progress bar.
	 *
	 * @return void
	 */
	protected static function heartbeat_script() {
		?>
		<script>
			( function( $ ) {
				const $document = $( document );
				const $progress = $( '#smoothgenerator-progress-bar' );
				const $controls = $( '[id^="generate_"], #use_date_range, #date_range_inputs input' );
				const $cancel   = $( '#cancel_job' );

				$document.on( 'ready', function () {
					wp.heartbeat.disableSuspend();
					wp.heartbeat.interval( 'fast' );
					wp.heartbeat.connectNow();
				} );

				$document.on( 'heartbeat-send', function ( event, data ) {
					data.smoothgenerator = 'check_async_job_progress';
				} );

				$document.on( 'heartbeat-tick', function ( event, data ) {
					// Heartbeat and other admin-ajax calls don't trigger wp-cron, so we have to do it manually.
					$.ajax( {
						url: data.smoothgenerator_ping_cron,
						method: 'get',
						timeout: 5000,
						dataType: 'html'
					} );

					if ( 'object' === typeof data.smoothgenerator_async_job_progress ) {
						const value = parseInt( data.smoothgenerator_async_job_progress.processed );
						if ( value > 0 ) {
							$progress.prop( 'value', value );
						}
					} else if ( 'complete' === data.smoothgenerator_async_job_progress && $progress.is( ':visible' ) ) {
						$progress.prop( 'value', $progress.prop( 'max' ) );
						$progress.parent().append( '✅' );
						$progress.siblings( 'label' ).first().append( ' Done!' );
						$controls.add( $cancel ).prop( 'disabled', function ( i, val ) {
							return ! val;
						} );
						$document.off( 'heartbeat-send' );
						$document.off( 'heartbeat-tick' );
					}
				} );
			} )( jQuery );
		</script>
	<?php
	}

	/**
	 * Callback to send data for updating the progress bar.
	 *
	 * @param array  $response  The data that will be sent back to heartbeat.
	 * @param array  $data      The incoming data from heartbeat.
	 * @param string $screen_id The ID of the current WP Admin screen.
	 *
	 * @return array
	 */
	public static function receive_heartbeat( array $response, array $data, $screen_id ) {
		if ( 'tools_page_smoothgenerator' !== $screen_id || empty( $data['smoothgenerator'] ) ) {
			return $response;
		}

		$current_job = self::get_current_job();

		if ( $current_job instanceof AsyncJob ) {
			$response['smoothgenerator_async_job_progress'] = $current_job;
			$response['smoothgenerator_ping_cron']          = site_url( 'wp-cron.php' );
		} else {
			$response['smoothgenerator_async_job_progress'] = 'complete';
		}

		return $response;
	}

	/**
	 * Process the generation.
	 */
	public static function process_page_submit() {
		$args = array();
		
		if ( ! empty( $_POST['use_date_range'] ) ) {
			$args['date-start'] = sanitize_text_field( $_POST['start_date'] );
			$args['date-end'] = sanitize_text_field( $_POST['end_date'] );
		}

		if ( ! empty( $_POST['generate_products'] ) && ! empty( $_POST['num_products_to_generate'] ) ) {
			check_admin_referer( 'generate', 'smoothgenerator_nonce' );
			$num_to_generate = absint( $_POST['num_products_to_generate'] );
			BatchProcessor::create_new_job( 'products', $num_to_generate, $args );
		} else if ( ! empty( $_POST['generate_orders'] ) && ! empty( $_POST['num_orders_to_generate'] ) ) {
			check_admin_referer( 'generate', 'smoothgenerator_nonce' );
			$num_to_generate = absint( $_POST['num_orders_to_generate'] );
			BatchProcessor::create_new_job( 'orders', $num_to_generate, $args );
		} else if ( ! empty( $_POST['cancel_job'] ) ) {
			check_admin_referer( 'generate', 'smoothgenerator_nonce' );
			BatchProcessor::delete_current_job();
		}
	}

	/**
	 * Get the state of the current background job.
	 *
	 * @return AsyncJob|null
	 */
	protected static function get_current_job() {
		return BatchProcessor::get_current_job();
	}

	/**
	 * Render some entertainment while waiting for the generator to finish.
	 *
	 * @return string
	 */
	protected static function while_you_wait() {
		$current_job = self::get_current_job();
		$content     = '';

		if ( filter_input( INPUT_POST, 'smoothgenerator_nonce' ) || $current_job instanceof AsyncJob ) {
			if ( filter_input( INPUT_POST, 'cancel_job' ) ) {
				$embed = 'NF9Y3GVuPfY';
			} else {
				$videos    = array(
					'4TYv2PhG89A',
					'6Whgn_iE5uc',
					'h_D3VFfhvs4',
					'QcjAXI4jANw',
				);
				$next_wait = filter_input( INPUT_COOKIE, 'smoothgenerator_next_wait' );
				if ( ! isset( $videos[ $next_wait ] ) ) {
					$next_wait = 0;
				}
				$embed = $videos[ $next_wait ];
				$next_wait ++;
				setcookie(
					'smoothgenerator_next_wait',
					$next_wait,
					array(
						'expires'  => time() + WEEK_IN_SECONDS,
						'path'     => ADMIN_COOKIE_PATH,
						'domain'   => COOKIE_DOMAIN,
						'secure'   => is_ssl(),
						'samesite' => 'strict',
					)
				);
			}

			$content = <<<"EMBED"
<h2>While you wait...</h2>
<div class="wp-block-embed__wrapper" style="margin: 2em 0;"><iframe width="560" height="315" src="https://www.youtube.com/embed/$embed?autoplay=1&fs=0&iv_load_policy=3&showinfo=0&rel=0&cc_load_policy=0&start=0&end=0" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen>></iframe></div>
EMBED;
		}

		return $content;
	}
}


================================================
FILE: includes/CLI.php
================================================
<?php
/**
 * WP-CLI functionality.
 *
 * @package SmoothGenerator\Classes
 */

namespace WC\SmoothGenerator;

use WP_CLI, WP_CLI_Command;

/**
 * WP-CLI Integration class
 */
class CLI extends WP_CLI_Command {
	/**
	 * Generate products.
	 *
	 * @param array $args Arguments specified.
	 * @param array $assoc_args Associative arguments specified.
	 */
	public static function products( $args, $assoc_args ) {
		list( $amount ) = $args;
		$amount = absint( $amount );

		$time_start = microtime( true );

		WP_CLI::line( 'Initializing...' );

		// Pre-generate images. Min 20, max 100.
		Generator\Product::seed_images( min( $amount + 19, 100 ) );

		$progress = \WP_CLI\Utils\make_progress_bar( 'Generating products', $amount );

		add_action(
			'smoothgenerator_product_generated',
			function () use ( $progress ) {
				$progress->tick();
			}
		);

		$remaining_amount = $amount;
		$generated        = 0;

		while ( $remaining_amount > 0 ) {
			$batch = min( $remaining_amount, Generator\Product::MAX_BATCH_SIZE );

			$result = Generator\Product::batch( $batch, $assoc_args );

			if ( is_wp_error( $result ) ) {
				WP_CLI::error( $result );
			}

			$generated        += count( $result );
			$remaining_amount -= $batch;
		}

		$progress->finish();

		$time_end       = microtime( true );
		$execution_time = round( ( $time_end - $time_start ), 2 );
		$display_time   = $execution_time < 60 ? $execution_time . ' seconds' : human_time_diff( $time_start, $time_end );

		WP_CLI::success( $generated . ' products generated in ' . $display_time );
	}

	/**
	 * Generate orders.
	 *
	 * @param array $args Arguments specified.
	 * @param array $assoc_args Associative arguments specified.
	 */
	public static function orders( $args, $assoc_args ) {
		list( $amount ) = $args;
		$amount = absint( $amount );

		$time_start = microtime( true );

		if ( ! empty( $assoc_args['status'] ) ) {
			$status = $assoc_args['status'];
			if ( ! wc_is_order_status( 'wc-' . $status ) ) {
				WP_CLI::error( "The argument \"$status\" is not a valid order status." );
				return;
			}
		}

		$progress = \WP_CLI\Utils\make_progress_bar( 'Generating orders', $amount );

		add_action(
			'smoothgenerator_order_generated',
			function () use ( $progress ) {
				$progress->tick();
			}
		);

		$remaining_amount = $amount;
		$generated        = 0;

		while ( $remaining_amount > 0 ) {
			$batch = min( $remaining_amount, Generator\Order::MAX_BATCH_SIZE );

			$result = Generator\Order::batch( $batch, $assoc_args );

			if ( is_wp_error( $result ) ) {
				WP_CLI::error( $result );
			}

			$generated        += count( $result );
			$remaining_amount -= $batch;
		}

		$progress->finish();

		$time_end       = microtime( true );
		$execution_time = round( ( $time_end - $time_start ), 2 );
		$display_time   = $execution_time < 60 ? $execution_time . ' seconds' : human_time_diff( $time_start, $time_end );

		if ( $generated === 0 && $amount > 0 ) {
			WP_CLI::error( 'No orders were generated. Make sure there are published products in your store.' );
		}

		WP_CLI::success( $generated . ' orders generated in ' . $display_time );
	}

	/**
	 * Generate customers.
	 *
	 * @param array $args Arguments specified.
	 * @param array $assoc_args Associative arguments specified.
	 */
	public static function customers( $args, $assoc_args ) {
		list( $amount ) = $args;
		$amount = absint( $amount );

		$time_start = microtime( true );

		$progress = \WP_CLI\Utils\make_progress_bar( 'Generating customers', $amount );

		add_action(
			'smoothgenerator_customer_generated',
			function () use ( $progress ) {
				$progress->tick();
			}
		);

		$remaining_amount = $amount;
		$generated        = 0;

		while ( $remaining_amount > 0 ) {
			$batch = min( $remaining_amount, Generator\Customer::MAX_BATCH_SIZE );

			$result = Generator\Customer::batch( $batch, $assoc_args );

			if ( is_wp_error( $result ) ) {
				WP_CLI::error( $result );
			}

			$generated        += count( $result );
			$remaining_amount -= $batch;
		}

		$progress->finish();

		$time_end       = microtime( true );
		$execution_time = round( ( $time_end - $time_start ), 2 );
		$display_time   = $execution_time < 60 ? $execution_time . ' seconds' : human_time_diff( $time_start, $time_end );

		WP_CLI::success( $generated . ' customers generated in ' . $display_time );
	}

	/**
	 * Generate coupons.
	 *
	 * @param array $args Arguments specified.
	 * @param array $assoc_args Associative arguments specified.
	 */
	public static function coupons( $args, $assoc_args ) {
		list( $amount ) = $args;
		$amount = absint( $amount );

		$time_start = microtime( true );

		$progress = \WP_CLI\Utils\make_progress_bar( 'Generating coupons', $amount );

		add_action(
			'smoothgenerator_coupon_generated',
			function () use ( $progress ) {
				$progress->tick();
			}
		);

		$remaining_amount = $amount;
		$generated        = 0;

		while ( $remaining_amount > 0 ) {
			$batch = min( $remaining_amount, Generator\Coupon::MAX_BATCH_SIZE );

			$result = Generator\Coupon::batch( $batch, $assoc_args );

			if ( is_wp_error( $result ) ) {
				WP_CLI::error( $result );
			}

			$generated        += count( $result );
			$remaining_amount -= $batch;
		}

		$progress->finish();

		$time_end       = microtime( true );
		$execution_time = round( ( $time_end - $time_start ), 2 );
		$display_time   = $execution_time < 60 ? $execution_time . ' seconds' : human_time_diff( $time_start, $time_end );

		WP_CLI::success( $generated . ' coupons generated in ' . $display_time );
	}

	/**
	 * Generate terms for the Product Category taxonomy.
	 *
	 * @param array $args Arguments specified.
	 * @param array $assoc_args Associative arguments specified.
	 */
	public static function terms( $args, $assoc_args ) {
		list( $taxonomy, $amount ) = $args;
		$amount = absint( $amount );

		$time_start = microtime( true );

		$progress = \WP_CLI\Utils\make_progress_bar( 'Generating terms', $amount );

		add_action(
			'smoothgenerator_term_generated',
			function () use ( $progress ) {
				$progress->tick();
			}
		);

		$remaining_amount = $amount;
		$generated        = 0;

		while ( $remaining_amount > 0 ) {
			$batch = min( $remaining_amount, Generator\Term::MAX_BATCH_SIZE );

			$result = Generator\Term::batch( $amount, $taxonomy, $assoc_args );

			if ( is_wp_error( $result ) ) {
				WP_CLI::error( $result );
			}

			$generated        += count( $result );
			$remaining_amount -= $batch;
		}

		$progress->finish();

		$time_end       = microtime( true );
		$execution_time = round( ( $time_end - $time_start ), 2 );
		$display_time   = $execution_time < 60 ? $execution_time . ' seconds' : human_time_diff( $time_start, $time_end );

		WP_CLI::success( $generated . ' terms generated in ' . $display_time );
	}
}

WP_CLI::add_command( 'wc generate products', array( 'WC\SmoothGenerator\CLI', 'products' ), array(
	'shortdesc' => 'Generate products.',
	'synopsis'  => array(
		array(
			'name'        => 'amount',
			'type'        => 'positional',
			'description' => 'The number of products to generate.',
			'optional'    => true,
			'default'     => 10,
		),
		array(
			'name'        => 'type',
			'type'        => 'assoc',
			'description' => 'Specify one type of product to generate. Otherwise defaults to a mix.',
			'optional'    => true,
			'options'     => array( 'simple', 'variable' ),
		),
		array(
			'name'        => 'use-existing-terms',
			'type'        => 'flag',
			'description' => 'Only apply existing categories and tags to products, rather than generating new ones.',
			'optional'    => true,
		),
	),
	'longdesc'  => "## EXAMPLES\n\nwc generate products 10\n\nwc generate products 20 --type=variable --use-existing-terms",
) );

WP_CLI::add_command( 'wc generate orders', array( 'WC\SmoothGenerator\CLI', 'orders' ), array(
	'shortdesc' => 'Generate orders.',
	'synopsis'  => array(
		array(
			'name'        => 'amount',
			'type'        => 'positional',
			'description' => 'The number of orders to generate.',
			'optional'    => true,
			'default'     => 10,
		),
		array(
			'name'        => 'date-start',
			'type'        => 'assoc',
			'description' => 'Randomize the order date using this as the lower limit. Format as YYYY-MM-DD.',
			'optional'    => true,
		),
		array(
			'name'        => 'date-end',
			'type'        => 'assoc',
			'description' => 'Randomize the order date using this as the upper limit. Only works in conjunction with date-start. Format as YYYY-MM-DD.',
			'optional'    => true,
		),
		array(
			'name'        => 'status',
			'type'        => 'assoc',
			'description' => 'Specify one status for all the generated orders. Otherwise defaults to a mix.',
			'optional'    => true,
			'options'     => array( 'completed', 'processing', 'on-hold', 'failed' ),
		),
		array(
			'name'        => 'coupons',
			'type'        => 'flag',
			'description' => 'Create and apply a coupon to each generated order. Equivalent to --coupon-ratio=1.0.',
			'optional'    => true,
		),
		array(
			'name'        => 'coupon-ratio',
			'type'        => 'assoc',
			'description' => 'Decimal ratio (0.0-1.0) of orders that should have coupons applied. If no coupons exist, 6 will be created (3 fixed value, 3 percentage). Note: Decimal values are converted to percentages using integer rounding (e.g., 0.505 becomes 50%).',
			'optional'    => true,
		),
		array(
			'name'        => 'refund-ratio',
			'type'        => 'assoc',
			'description' => 'Decimal ratio (0.0-1.0) of completed orders that should be refunded (wholly or partially). Note: Decimal values are converted to percentages using integer rounding (e.g., 0.505 becomes 50%).',
			'optional'    => true,
		),
		array(
			'name'        => 'skip-order-attribution',
			'type'        => 'flag',
			'description' => 'Skip adding order attribution meta to the generated orders.',
			'optional'    => true,
		)
	),
	'longdesc'  => "## EXAMPLES\n\nwc generate orders 10\n\nwc generate orders 50 --date-start=2020-01-01 --date-end=2022-12-31 --status=completed --coupons",
) );

WP_CLI::add_command( 'wc generate customers', array( 'WC\SmoothGenerator\CLI', 'customers' ), array(
	'shortdesc' => 'Generate customers.',
	'synopsis'  => array(
		array(
			'name'        => 'amount',
			'type'        => 'positional',
			'description' => 'The number of customers to generate.',
			'optional'    => true,
			'default'     => 10,
		),
		array(
			'name'        => 'country',
			'type'        => 'assoc',
			'description' => 'The ISO 3166-1 alpha-2 country code to use for localizing the customer data. If none is specified, any country in the "Selling location(s)" setting may be used.',
			'optional'    => true,
			'default'     => '',
		),
		array(
			'name'        => 'type',
			'type'        => 'assoc',
			'description' => 'The type of customer to generate data for. If none is specified, it will be a 70% person, 30% company mix.',
			'optional'    => true,
			'options'     => array( 'company', 'person' ),
		),
	),
	'longdesc'  => "## EXAMPLES\n\nwc generate customers 10\n\nwc generate customers --country=ES --type=company",
) );

WP_CLI::add_command( 'wc generate coupons', array( 'WC\SmoothGenerator\CLI', 'coupons' ), array(
	'shortdesc' => 'Generate coupons.',
	'synopsis'  => array(
		array(
			'name'        => 'amount',
			'type'        => 'positional',
			'description' => 'The number of coupons to generate.',
			'optional'    => true,
			'default'     => 10,
		),
		array(
			'name'        => 'min',
			'type'        => 'assoc',
			'description' => 'Specify the minimum discount of each coupon, as an integer.',
			'optional'    => true,
			'default'     => 5,
		),
		array(
			'name'        => 'max',
			'type'        => 'assoc',
			'description' => 'Specify the maximum discount of each coupon, as an integer.',
			'optional'    => true,
			'default'     => 100,
		),
		array(
			'name'        => 'discount_type',
			'type'        => 'assoc',
			'description' => 'The type of discount for the coupon. If not specified, defaults to WooCommerce default (fixed_cart).',
			'optional'    => true,
			'options'     => array( 'fixed_cart', 'percent' ),
		),
	),
	'longdesc'  => "## EXAMPLES\n\nwc generate coupons 10\n\nwc generate coupons 50 --min=1 --max=50\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(
	'shortdesc' => 'Generate product categories.',
	'synopsis'  => array(
		array(
			'name'        => 'taxonomy',
			'type'        => 'positional',
			'description' => 'The taxonomy to generate the terms for.',
			'options'     => array( 'product_cat', 'product_tag' ),
		),
		array(
			'name'        => 'amount',
			'type'        => 'positional',
			'description' => 'The number of terms to generate.',
			'optional'    => true,
			'default'     => 10,
		),
		array(
			'name'        => 'max-depth',
			'type'        => 'assoc',
			'description' => 'The maximum number of hierarchy levels for the terms. A value of 1 means all categories will be top-level. Max value 5. Only applies to taxonomies that are hierarchical.',
			'optional'    => true,
			'options'     => array( 1, 2, 3, 4, 5 ),
			'default'     => 1,
		),
		array(
			'name'        => 'parent',
			'type'        => 'assoc',
			'description' => 'Specify an existing term ID as the parent for the new terms. Only applies to taxonomies that are hierarchical.',
			'optional'    => true,
			'default'     => 0,
		),
	),
	'longdesc' => "## EXAMPLES\n\nwc generate terms product_tag 10\n\nwc generate terms product_cat 50 --max-depth=3",
) );


================================================
FILE: includes/Generator/Coupon.php
================================================
<?php
/**
 * Customer data generation.
 *
 * @package SmoothGenerator\Classes
 */

namespace WC\SmoothGenerator\Generator;

use WC_Data_Store;

/**
 * Customer data generator.
 */
class Coupon extends Generator {
	/**
	 * Create a new coupon.
	 *
	 * @param bool  $save       Whether to save the new coupon to the database.
	 * @param array $assoc_args Arguments passed via the CLI for additional customization.
	 *
	 * @return \WC_Coupon|\WP_Error Coupon object with data populated.
	 */
	public static function generate( $save = true, $assoc_args = array() ) {
		parent::maybe_initialize_generators();

		$defaults = array(
			'min'           => 5,
			'max'           => 100,
			'discount_type' => 'fixed_cart',
		);

		$args = wp_parse_args( $assoc_args, $defaults );

		list( 'min' => $min, 'max' => $max ) = filter_var_array(
			$args,
			array(
				'min' => array(
					'filter'  => FILTER_VALIDATE_INT,
					'options' => array(
						'min_range' => 1,
					),
				),
				'max' => array(
					'filter'  => FILTER_VALIDATE_INT,
					'options' => array(
						'min_range' => 1,
					),
				),
			)
		);

		if ( false === $min ) {
			return new \WP_Error(
				'smoothgenerator_coupon_invalid_min_max',
				'The minimum coupon amount must be a valid positive integer.'
			);
		}

		if ( false === $max ) {
			return new \WP_Error(
				'smoothgenerator_coupon_invalid_min_max',
				'The maximum coupon amount must be a valid positive integer.'
			);
		}

		if ( $min > $max ) {
			return new \WP_Error(
				'smoothgenerator_coupon_invalid_min_max',
				'The maximum coupon amount must be an integer that is greater than or equal to the minimum amount.'
			);
		}

		// Validate discount_type if provided
		$discount_type = ! empty( $args['discount_type'] ) ? $args['discount_type'] : '';
		if ( ! empty( $discount_type ) && ! in_array( $discount_type, array( 'fixed_cart', 'percent' ), true ) ) {
			return new \WP_Error(
				'smoothgenerator_coupon_invalid_discount_type',
				'The discount_type must be either "fixed_cart" or "percent".'
			);
		}

		$code        = substr( self::$faker->promotionCode( 1 ), 0, -1 ); // Omit the random digit.
		$amount      = self::$faker->numberBetween( $min, $max );
		$coupon_code = sprintf(
			'%s%d',
			$code,
			$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( $props );

		if ( $save ) {
			$data_store = WC_Data_Store::load( 'coupon' );
			$data_store->create( $coupon );
		}

		/**
		 * Action: Coupon generator returned a new coupon.
		 *
		 * @since 1.2.0
		 *
		 * @param \WC_Coupon $coupon
		 */
		do_action( 'smoothgenerator_coupon_generated', $coupon );

		return $coupon;
	}

	/**
	 * Create multiple coupons.
	 *
	 * @param int   $amount The number of coupons to create.
	 * @param array $args   Additional args for coupon creation.
	 *
	 * @return int[]|\WP_Error
	 */
	public static function batch( $amount, array $args = array() ) {
		$amount = self::validate_batch_amount( $amount );
		if ( is_wp_error( $amount ) ) {
			return $amount;
		}

		$coupon_ids = array();

		for ( $i = 1; $i <= $amount; $i ++ ) {
			$coupon = self::generate( true, $args );
			if ( is_wp_error( $coupon ) ) {
				return $coupon;
			}
			$coupon_ids[] = $coupon->get_id();
		}

		return $coupon_ids;
	}

	/**
	 * Get a random existing coupon.
	 *
	 * @return \WC_Coupon|false Coupon object or false if none available.
	 */
	public static function get_random() {
		// 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(
			array(
				'post_type'      => 'shop_coupon',
				'post_status'    => 'publish',
				'posts_per_page' => -1,
				'fields'         => 'ids',
			)
		);

		if ( empty( $coupon_ids ) ) {
			return false;
		}

		$random_coupon_id = $coupon_ids[ array_rand( $coupon_ids ) ];

		return new \WC_Coupon( $random_coupon_id );
	}
}



================================================
FILE: includes/Generator/Customer.php
================================================
<?php
/**
 * Customer data generation.
 *
 * @package SmoothGenerator\Classes
 */

namespace WC\SmoothGenerator\Generator;

/**
 * Customer data generator.
 */
class Customer extends Generator {
	/**
	 * Return a new customer.
	 *
	 * @param bool  $save       Save the object before returning or not.
	 * @param array $assoc_args Arguments passed via the CLI for additional customization.
	 *
	 * @return \WC_Customer|\WP_Error Customer object with data populated.
	 */
	public static function generate( $save = true, array $assoc_args = array() ) {
		parent::maybe_initialize_generators();

		$args = filter_var_array(
			$assoc_args,
			array(
				'country' => array(
					'filter'  => FILTER_VALIDATE_REGEXP,
					'options' => array(
						'regexp'  => '/^[A-Za-z]{2}$/',
						'default' => '',
					),
				),
				'type'    => array(
					'filter'  => FILTER_VALIDATE_REGEXP,
					'options' => array(
						'regexp' => '/^(company|person)$/',
					),
				),
			)
		);

		list( 'country' => $country, 'type' => $type ) = $args;

		$country = CustomerInfo::get_valid_country_code( $country );
		if ( is_wp_error( $country ) ) {
			return $country;
		}

		if ( ! $type ) {
			$type = self::$faker->randomDigit() < 7 ? 'person' : 'company'; // 70% person, 30% company.
		}

		$keys_for_address = array( 'email' );

		$customer_data = array(
			'role' => 'customer',
		);
		switch ( $type ) {
			case 'person':
			default:
				$customer_data       = array_merge( $customer_data, CustomerInfo::generate_person( $country ) );
				$other_customer_data = CustomerInfo::generate_person( $country );
				$keys_for_address[]  = 'first_name';
				$keys_for_address[]  = 'last_name';
				break;

			case 'company':
				$customer_data       = array_merge( $customer_data, CustomerInfo::generate_company( $country ) );
				$other_customer_data = CustomerInfo::generate_company( $country );
				$keys_for_address[]  = 'company';
				break;
		}

		$customer_data['billing'] = array_merge(
			CustomerInfo::generate_address( $country ),
			array_intersect_key( $customer_data, array_fill_keys( $keys_for_address, '' ) )
		);

		$has_shipping = self::$faker->randomDigit() < 5;
		if ( $has_shipping ) {
			$same_shipping = self::$faker->randomDigit() < 5;
			if ( $same_shipping ) {
				$customer_data['shipping'] = $customer_data['billing'];
			} else {
				$customer_data['shipping'] = array_merge(
					CustomerInfo::generate_address( $country ),
					array_intersect_key( $other_customer_data, array_fill_keys( $keys_for_address, '' ) )
				);
			}
		}

		unset( $customer_data['company'], $customer_data['shipping']['email'] );

		foreach ( array( 'billing', 'shipping' ) as $address_type ) {
			if ( isset( $customer_data[ $address_type ] ) ) {
				$address_data = array_combine(
					array_map(
						fn( $line ) => $address_type . '_' . $line,
						array_keys( $customer_data[ $address_type ] )
					),
					array_values( $customer_data[ $address_type ] )
				);

				$customer_data = array_merge( $customer_data, $address_data );
				unset( $customer_data[ $address_type ] );
			}
		}

		$customer = new \WC_Customer();
		$customer->set_props( $customer_data );

		if ( $save ) {
			$customer->save();
		}

		/**
		 * Action: Customer generator returned a new customer.
		 *
		 * @since 1.2.0
		 *
		 * @param \WC_Customer $customer
		 */
		do_action( 'smoothgenerator_customer_generated', $customer );

		return $customer;
	}

	/**
	 * Create multiple customers.
	 *
	 * @param int   $amount The number of customers to create.
	 * @param array $args   Additional args for customer creation.
	 *
	 * @return int[]|\WP_Error
	 */
	public static function batch( $amount, array $args = array() ) {
		$amount = self::validate_batch_amount( $amount );
		if ( is_wp_error( $amount ) ) {
			return $amount;
		}

		$customer_ids = array();

		for ( $i = 1; $i <= $amount; $i++ ) {
			$customer       = self::generate( true, $args );
			if ( is_wp_error( $customer ) ) {
				return $customer;
			}
			$customer_ids[] = $customer->get_id();
		}

		return $customer_ids;
	}
}


================================================
FILE: includes/Generator/CustomerInfo.php
================================================
<?php

namespace WC\SmoothGenerator\Generator;

/**
 * Class CustomerInfo.
 *
 * Helper class for generating locale-specific coherent customer test data.
 */
class CustomerInfo {
	/**
	 * Get a country code for a country that the store is set to sell to, or validate a given country code.
	 *
	 * @param string|null $country_code ISO 3166-1 alpha-2 country code. E.g. US, ES, CN, RU etc.
	 *
	 * @return string|\WP_Error
	 */
	public static function get_valid_country_code( ?string $country_code = '' ) {
		$country_code = !empty( $country_code ) ? strtoupper( $country_code ) : '';

		if ( $country_code && ! WC()->countries->country_exists( $country_code ) ) {
			$country_code = new \WP_Error(
				'smoothgenerator_customer_invalid_country',
				sprintf(
					'No data for a country with country code "%s"',
					esc_html( $country_code )
				)
			);
		} elseif ( ! $country_code ) {
			$valid_countries = WC()->countries->get_allowed_countries();
			$country_code    = array_rand( $valid_countries );
		}

		return $country_code;
	}

	/**
	 * Retrieve locale data for a given country.
	 *
	 * @param string string $country_code ISO 3166-1 alpha-2 country code. E.g. US, ES, CN, RU etc.
	 *
	 * @return array
	 */
	protected static function get_country_locale_info( string $country_code = 'en_US' ) {
		$all_locale_info = include WC()->plugin_path() . '/i18n/locale-info.php';

		if ( ! isset( $all_locale_info[ $country_code ] ) ) {
			return array();
		}

		return $all_locale_info[ $country_code ];
	}


	/**
	 * Get a localized Faker library instance.
	 *
	 * @param string $country_code ISO 3166-1 alpha-2 country code. E.g. US, ES, CN, RU etc.
	 *
	 * @return \Faker\Generator
	 */
	protected static function get_faker( $country_code = 'en_US' ) {
		$locale_info    = self::get_country_locale_info( $country_code );
		$default_locale = ! empty( $locale_info['default_locale'] ) ? $locale_info['default_locale'] : 'en_US';

		$faker = \Faker\Factory::create( $default_locale );

		return $faker;
	}

	/**
	 * Retrieve the localized instance of a particular provider from within the Faker.
	 *
	 * @param \Faker\Generator $faker         The current instance of the Faker.
	 * @param string           $provider_name The name of the provider to retrieve. E.g. 'Person'.
	 *
	 * @return \Faker\Provider\Base|null
	 */
	protected static function get_provider_instance( \Faker\Generator $faker, string $provider_name ) {
		$instance = null;
		foreach ( $faker->getProviders() as $provider ) {
			if ( str_ends_with( get_class( $provider ), $provider_name ) ) {
				$instance = $provider;
				break;
			}
		}

		return $instance;
	}

	/**
	 * Generate data for a person, localized for a particular country.
	 *
	 * Includes first name, last name, username, email address, and password.
	 *
	 * @param string $country_code ISO 3166-1 alpha-2 country code. E.g. US, ES, CN, RU etc.
	 *
	 * @return string[]|\WP_Error
	 * @throws \ReflectionException
	 */
	public static function generate_person( string $country_code = '' ) {
		$country_code = self::get_valid_country_code( $country_code );
		if ( is_wp_error( $country_code ) ) {
			return $country_code;
		}

		$faker = self::get_faker( $country_code );

		$first_name = $faker->firstName( $faker->randomElement( array( 'male', 'female' ) ) );
		$last_name  = $faker->lastName();

		if ( $faker->randomDigit() < 3 ) {
			// 30% chance for no capitalization.
			$first_name = strtolower( $first_name );
			$last_name  = strtolower( $last_name );
		}

		$person = array(
			'first_name' => $first_name,
			'last_name'  => $last_name,
			'password'   => 'password',
		);

		// Make sure email and username use the same first and last name that were previously generated.
		$person_provider    = self::get_provider_instance( $faker, 'Person' );
		$reflected_provider = new \ReflectionClass( $person_provider );
		$orig_fn_male       = $reflected_provider->getStaticPropertyValue( 'firstNameMale', array() );
		$orig_fn_female     = $reflected_provider->getStaticPropertyValue( 'firstNameFemale', array() );
		$orig_ln            = $reflected_provider->getStaticPropertyValue( 'lastName', array() );

		$reflected_provider->setStaticPropertyValue( 'firstNameMale', array( $first_name ) );
		$reflected_provider->setStaticPropertyValue( 'firstNameFemale', array( $first_name ) );
		$reflected_provider->setStaticPropertyValue( 'lastName', array( $last_name ) );

		$person['display_name'] = $faker->name();

		// Switch Faker to default locale if transliteration fails or there's another issue.
		try {
			$faker->safeEmail();
			$faker->userName();
		} catch ( \Exception $e ) {
			$faker = self::get_faker();
		}

		do {
			$person['email'] = $faker->safeEmail();
		} while ( email_exists( $person['email'] ) );

		do {
			$person['username'] = $faker->userName();
		} while ( username_exists( $person['username'] ) );

		$reflected_provider->setStaticPropertyValue( 'firstNameMale', $orig_fn_male );
		$reflected_provider->setStaticPropertyValue( 'firstNameFemale', $orig_fn_female );
		$reflected_provider->setStaticPropertyValue( 'lastName', $orig_ln );

		return $person;
	}

	/**
	 * Generate data for a company, localized for a particular country.
	 *
	 * Includes company name, username, email address, and password.
	 *
	 * @param string $country_code ISO 3166-1 alpha-2 country code. E.g. US, ES, CN, RU etc.
	 *
	 * @return string[]|\WP_Error
	 */
	public static function generate_company( string $country_code = '' ) {
		$country_code = self::get_valid_country_code( $country_code );
		if ( is_wp_error( $country_code ) ) {
			return $country_code;
		}

		$faker = self::get_faker( $country_code );

		$last_names = array();
		for ( $i = 0; $i < 3; $i++ ) {
			try {
				$last_names[] = $faker->unique()->lastName();
			} catch ( \OverflowException $e ) {
				$last_names[] = $faker->unique( true )->lastName();
			}
		}

		if ( $faker->randomDigit() < 3 ) {
			// 30% chance for no capitalization.
			$last_names = array_map( 'strtolower', $last_names );
		}

		// Make sure all the company-related strings draw from the same set of last names that were previously generated.
		$person_provider    = self::get_provider_instance( $faker, 'Person' );
		$reflected_provider = new \ReflectionClass( $person_provider );
		$orig_ln            = $reflected_provider->getStaticPropertyValue( 'lastName', array() );

		$reflected_provider->setStaticPropertyValue( 'lastName', $last_names );

		$company = array(
			'company'  => $faker->company(),
			'password' => 'password',
		);

		$company['display_name'] = $company['company'];

		$reflected_provider->setStaticPropertyValue( 'lastName', array( $faker->randomElement( $last_names ) ) );

		// Make sure a unique email and username are used.
		do {
			try {
				$company['email'] = $faker->companyEmail();
			} catch ( \Exception $e ) {
				$default_faker    = self::get_faker();
				$company['email'] = $default_faker->email();
			}
		} while ( email_exists( $company['email'] ) );

		do {
			try {
				$company['username'] = $faker->domainWord() . $faker->optional()->randomNumber( 2 );
			} catch ( \Exception $e ) {
				$default_faker       = self::get_faker();
				$company['username'] = $default_faker->userName();
			}
		} while ( username_exists( $company['username'] ) || strlen( $company['username'] ) < 3 );

		$reflected_provider->setStaticPropertyValue( 'lastName', $orig_ln );

		return $company;
	}

	/**
	 * Generate address data, localized for a particular country.
	 *
	 * @param string $country_code ISO 3166-1 alpha-2 country code. E.g. US, ES, CN, RU etc.
	 *
	 * @return string[]|\WP_Error
	 */
	public static function generate_address( string $country_code = '' ) {
		$country_code = self::get_valid_country_code( $country_code );
		if ( is_wp_error( $country_code ) ) {
			return $country_code;
		}

		$faker = self::get_faker( $country_code );

		$address = array(
			'address1' => '',
			'city'     => '',
			'state'    => '',
			'postcode' => '',
			'country'  => '',
			'phone'    => '',
		);

		$exceptions = WC()->countries->get_country_locale();
		foreach ( array_keys( $address ) as $line ) {
			if ( isset( $exceptions[ $country_code ][ $line ]['hidden'] ) && true === $exceptions[ $country_code ][ $line ]['hidden'] ) {
				continue;
			}

			if ( isset( $exceptions[ $country_code ][ $line ]['required'] ) && false === $exceptions[ $country_code ][ $line ]['required'] ) {
				// 50% chance to skip if it's not required.
				if ( $faker->randomDigit() < 5 ) {
					continue;
				}
			}

			switch ( $line ) {
				case 'address1':
					$address[ $line ] = $faker->streetAddress();
					break;
				case 'city':
					$address[ $line ] = $faker->city();
					break;
				case 'state':
					$states           = WC()->countries->get_states( $country_code );
					if ( is_array( $states ) ) {
						$address[ $line ] = $faker->randomElement( array_keys( $states ) );
					}
					break;
				case 'postcode':
					$address[ $line ] = $faker->postcode();
					break;
				case 'country':
					$address[ $line ] = $country_code;
					break;
				case 'phone':
					$address[ $line ] = $faker->phoneNumber();
					break;
			}
		}

		return $address;
	}
}


================================================
FILE: includes/Generator/Generator.php
================================================
<?php
/**
 * Abstract Generator class
 *
 * @package SmoothGenerator\Abstracts
 */

namespace WC\SmoothGenerator\Generator;

/**
 * Data generator base class.
 */
abstract class Generator {
	/**
	 * Maximum number of objects that can be generated in one batch.
	 */
	const MAX_BATCH_SIZE = 100;

	/**
	 * Dimension, in pixels, of generated images.
	 */
	const IMAGE_SIZE = 700;

	/**
	 * Are we ready to generate objects?
	 *
	 * @var bool
	 */
	protected static $ready = false;

	/**
	 * Holds the Faker factory object.
	 *
	 * @var \Faker\Generator Factory object.
	 */
	protected static $faker;

	/**
	 * Caches term IDs.
	 *
	 * @deprecated
	 *
	 * @var array Array of IDs.
	 */
	protected static $term_ids;

	/**
	 * Holds array of generated images to assign to products.
	 *
	 * @var array Array of image attachment IDs.
	 */
	protected static $images = array();

	/**
	 * Return a new object of this object type.
	 *
	 * @param bool $save Save the object before returning or not.
	 * @return array
	 */
	abstract public static function generate( $save = true );

	/**
	 * Create multiple objects.
	 *
	 * @param int   $amount Number of objects to create.
	 * @param array $args   Additional args for object creation.
	 *
	 * @return int[]|\WP_Error An array of IDs of created objects on success.
	 */
	// TODO normalize the signature of this method in all generator classes so we can add this to the contract.
	//abstract public static function batch( $amount, array $args = array() );

	/**
	 * Get ready to generate objects.
	 *
	 * This can be run from any generator, but it applies to all generators.
	 *
	 * @return void
	 */
	protected static function maybe_initialize_generators() {
		if ( true !== self::$ready ) {
			self::init_faker();
			self::disable_emails();

			// Set this to avoid notices as when you run via WP-CLI SERVER vars are not set, order emails uses this variable.
			if ( ! isset( $_SERVER['SERVER_NAME'] ) ) {
				$_SERVER['SERVER_NAME'] = 'localhost';
			}
		}

		self::$ready = true;
	}

	/**
	 * Create and store an instance of the Faker library.
	 */
	protected static function init_faker() {
		if ( ! self::$faker ) {
			self::$faker = \Faker\Factory::create( 'en_US' );
			self::$faker->addProvider( new \Bezhanov\Faker\Provider\Commerce( self::$faker ) );
		}
	}

	/**
	 * Disable sending WooCommerce emails when generating objects.
	 *
	 * This needs to run as late in the request as possible so that the callbacks we want to remove
	 * have actually been added.
	 *
	 * @return void
	 */
	public static function disable_emails() {
		$email_actions = array(
			// Customer emails.
			'woocommerce_new_customer_note',
			'woocommerce_created_customer',
			// Order emails.
			'woocommerce_order_status_pending_to_processing',
			'woocommerce_order_status_pending_to_completed',
			'woocommerce_order_status_processing_to_cancelled',
			'woocommerce_order_status_pending_to_failed',
			'woocommerce_order_status_pending_to_on-hold',
			'woocommerce_order_status_failed_to_processing',
			'woocommerce_order_status_failed_to_completed',
			'woocommerce_order_status_failed_to_on-hold',
			'woocommerce_order_status_cancelled_to_processing',
			'woocommerce_order_status_cancelled_to_completed',
			'woocommerce_order_status_cancelled_to_on-hold',
			'woocommerce_order_status_on-hold_to_processing',
			'woocommerce_order_status_on-hold_to_cancelled',
			'woocommerce_order_status_on-hold_to_failed',
			'woocommerce_order_status_completed',
			'woocommerce_order_status_failed',
			'woocommerce_order_fully_refunded',
			'woocommerce_order_partially_refunded',
			// Product emails.
			'woocommerce_low_stock',
			'woocommerce_no_stock',
			'woocommerce_product_on_backorder',
		);

		foreach ( $email_actions as $action ) {
			remove_action( $action, array( 'WC_Emails', 'send_transactional_email' ) );
		}

		if ( ! has_action( 'woocommerce_allow_send_queued_transactional_email', '__return_false' ) ) {
			add_action( 'woocommerce_allow_send_queued_transactional_email', '__return_false' );
		}
	}

	/**
	 * Validate the value of the amount input for a batch command.
	 *
	 * @param int $amount The number of items to create in a batch.
	 *
	 * @return mixed|\WP_Error
	 */
	protected static function validate_batch_amount( $amount ) {
		$amount = filter_var(
			$amount,
			FILTER_VALIDATE_INT,
			array(
				'options' => array(
					'min_range' => 1,
					'max_range' => static::MAX_BATCH_SIZE,
				),
			)
		);

		if ( false === $amount ) {
			return new \WP_Error(
				'smoothgenerator_batch_invalid_amount',
				sprintf(
					'Amount must be a number between 1 and %d.',
					static::MAX_BATCH_SIZE
				)
			);
		}

		return $amount;
	}

	/**
	 * Get random term ids.
	 *
	 * @deprecated Use Product::get_term_ids instead.
	 *
	 * @param int    $limit Number of term IDs to get.
	 * @param string $taxonomy Taxonomy name.
	 * @param string $name Product name to extract terms from.
	 *
	 * @return array
	 */
	protected static function generate_term_ids( $limit, $taxonomy, $name = '' ) {
		_deprecated_function( __METHOD__, '1.2.2', 'Product::get_term_ids' );

		self::init_faker();

		$term_ids = array();

		if ( ! $limit ) {
			return $term_ids;
		}

		$words       = str_word_count( $name, 1 );
		$extra_terms = str_word_count( self::$faker->department( $limit ), 1 );
		$words       = array_merge( $words, $extra_terms );

		if ( 'product_cat' === $taxonomy ) {
			$terms = array_slice( $words, 1 );
		} else {
			$terms = array_merge( self::$faker->words( $limit ), array( strtolower( $words[0] ) ) );
		}

		foreach ( $terms as $term ) {
			if ( isset( self::$term_ids[ $taxonomy ], self::$term_ids[ $taxonomy ][ $term ] ) ) {
				$term_id    = self::$term_ids[ $taxonomy ][ $term ];
				$term_ids[] = $term_id;

				continue;
			}

			$term_id = 0;
			$args    = array(
				'taxonomy' => $taxonomy,
				'name'     => $term,
			);

			$existing = get_terms( $args );

			if ( $existing && count( $existing ) && ! is_wp_error( $existing ) ) {
				$term_id = $existing[0]->term_id;
			} else {
				$term_ob = wp_insert_term( $term, $taxonomy, $args );

				if ( $term_ob && ! is_wp_error( $term_ob ) ) {
					$term_id = $term_ob['term_id'];
				}
			}

			if ( $term_id ) {
				$term_ids[]                           = $term_id;
				self::$term_ids[ $taxonomy ][ $term ] = $term_id;
			}
		}

		return $term_ids;
	}

	/**
	 * Create/retrieve a set of random images to assign to products.
	 *
	 * @param integer $amount Number of images required.
	 */
	public static function seed_images( $amount = 10 ) {
		self::$images = get_posts(
			array(
				'post_type'      => 'attachment',
				'fields'         => 'ids',
				'parent'         => 0,
				'posts_per_page' => $amount,
				'exclude'        => get_option( 'woocommerce_placeholder_image', 0 ),
			)
		);

		$found_count = count( self::$images );

		for ( $i = 1; $i <= ( $amount - $found_count ); $i++ ) {
			self::$images[] = self::generate_image();
		}
	}

	/**
	 * Get an image at random from our seeded data.
	 *
	 * @return int
	 */
	protected static function get_image() {
		if ( ! self::$images ) {
			self::seed_images();
		}
		return self::$images[ array_rand( self::$images ) ];
	}

	/**
	 * Generate and upload a random image, or choose an existing attachment.
	 *
	 * @param string $seed Seed for image generation.
	 * @return int The attachment id of the image (0 on failure).
	 */
	protected static function generate_image( $seed = '' ) {
		self::init_faker();

		$attachment_id = 0;

		if ( ! $seed ) {
			$seed = self::$faker->word();
		}

		$seed = sanitize_key( $seed );
		$icon = new \Jdenticon\Identicon();
		$icon->setValue( $seed );
		$icon->setSize( self::IMAGE_SIZE );

		$image = imagecreatefromstring( @$icon->getImageData() ); // phpcs:ignore
		ob_start();
		imagepng( $image );
		$file = ob_get_clean();
		// Unset image to free memory early. imagedestroy() has no effect since PHP 8.0 and is deprecated in PHP 8.5.
		unset( $image );
		$upload = wp_upload_bits( 'img-' . $seed . '.png', null, $file );

		if ( empty( $upload['error'] ) ) {
			$attachment_id = (int) wp_insert_attachment(
				array(
					'post_title'     => 'img-' . $seed . '.png',
					'post_mime_type' => $upload['type'],
					'post_status'    => 'publish',
					'post_content'   => '',
				),
				$upload['file']
			);
		}

		if ( $attachment_id ) {
			if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
				include_once ABSPATH . 'wp-admin/includes/image.php';
			}
			wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $upload['file'] ) );
		}

		return $attachment_id;
	}

	/**
	 * Get a random value from an array based on weight.
	 * Taken from https://stackoverflow.com/questions/445235/generating-random-results-by-weight-in-php
	 *
	 * @param array $weighted_values Array of value => weight options.
	 * @return mixed
	 */
	protected static function random_weighted_element( array $weighted_values ) {
		$rand = wp_rand( 1, (int) array_sum( $weighted_values ) );

		foreach ( $weighted_values as $key => $value ) {
			$rand -= $value;
			if ( $rand <= 0 ) {
				return $key;
			}
		}
	}
}


================================================
FILE: includes/Generator/Order.php
================================================
<?php
/**
 * Order data generation.
 *
 * @package SmoothGenerator\Classes
 */

namespace WC\SmoothGenerator\Generator;

/**
 * Order data generator.
 */
class Order extends Generator {

	/**
	 * Probability (percentage) that a partial refund will receive a second refund.
	 */
	const SECOND_REFUND_PROBABILITY = 25;

	/**
	 * Maximum ratio of order total that can be refunded in a partial refund.
	 * Ensures partial refunds don't exceed 50% of order total.
	 */
	const MAX_PARTIAL_REFUND_RATIO = 0.5;

	/**
	 * Maximum days after order completion for first refund (2 months).
	 */
	const FIRST_REFUND_MAX_DAYS = 60;

	/**
	 * Maximum days after first refund for second refund (1 month).
	 */
	const SECOND_REFUND_MAX_DAYS = 30;

	/**
	 * Refund type constants for memory-efficient batch operations.
	 */
	const REFUND_TYPE_NONE = 0;
	const REFUND_TYPE_FULL = 1;
	const REFUND_TYPE_PARTIAL = 2;
	const REFUND_TYPE_MULTI = 3;

	/**
	 * Refund distribution ratios for batch generation with exact ratios.
	 * When generating refunds in batch mode:
	 * - 50% will be full refunds
	 * - 25% will be single partial refunds
	 * - 25% will be multi-partial refunds (two partial refunds)
	 */
	const REFUND_DISTRIBUTION_FULL_RATIO = 0.5;
	const REFUND_DISTRIBUTION_PARTIAL_RATIO = 0.25;

	/**
	 * Return a new order.
	 *
	 * @param bool        $save Save the object before returning or not.
	 * @param array       $assoc_args Arguments passed via the CLI for additional customization.
	 * @param string|null $date Optional date string (Y-m-d) to use for order creation. If not provided, will be generated.
	 * @param bool|null   $include_coupon Optional flag to include coupon. If null, will be determined based on coupon-ratio.
	 * @param int|null    $refund_type Optional refund type constant. If null, will be determined based on refund-ratio.
	 * @return \WC_Order|false Order object with data populated or false when failed.
	 */
	public static function generate( $save = true, $assoc_args = array(), $date = null, $include_coupon = null, $refund_type = null ) {
		parent::maybe_initialize_generators();

		$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 );
		}

		$order->set_customer_id( $customer->get_id() );
		$order->set_created_via( 'smooth-generator' );
		$order->set_currency( get_woocommerce_currency() );
		$order->set_billing_first_name( $customer->get_billing_first_name() );
		$order->set_billing_last_name( $customer->get_billing_last_name() );
		$order->set_billing_address_1( $customer->get_billing_address_1() );
		$order->set_billing_address_2( $customer->get_billing_address_2() );
		$order->set_billing_email( $customer->get_billing_email() );
		$order->set_billing_phone( $customer->get_billing_phone() );
		$order->set_billing_city( $customer->get_billing_city() );
		$order->set_billing_postcode( $customer->get_billing_postcode() );
		$order->set_billing_state( $customer->get_billing_state() );
		$order->set_billing_country( $customer->get_billing_country() );
		$order->set_billing_company( $customer->get_billing_company() );
		$order->set_shipping_first_name( $customer->get_shipping_first_name() );
		$order->set_shipping_last_name( $customer->get_shipping_last_name() );
		$order->set_shipping_address_1( $customer->get_shipping_address_1() );
		$order->set_shipping_address_2( $customer->get_shipping_address_2() );
		$order->set_shipping_city( $customer->get_shipping_city() );
		$order->set_shipping_postcode( $customer->get_shipping_postcode() );
		$order->set_shipping_state( $customer->get_shipping_state() );
		$order->set_shipping_country( $customer->get_shipping_country() );
		$order->set_shipping_company( $customer->get_shipping_company() );

		// 20% chance
		if ( rand( 0, 100 ) <= 20 ) {
			$country_code = $order->get_shipping_country();

			$calculate_tax_for = array(
				'country' => $country_code,
				'state' => '',
				'postcode' => '',
				'city' => '',
			);

			$fee = new \WC_Order_Item_Fee();
			$randomAmount = self::$faker->randomFloat( 2, 0.05, 100 );

			$fee->set_name( 'Extra Fee' );
			$fee->set_amount( $randomAmount );
			$fee->set_tax_class( '' );
			$fee->set_tax_status( 'taxable' );
			$fee->set_total( $randomAmount );
			$fee->calculate_taxes( $calculate_tax_for );
			$order->add_item( $fee );
		}
		$status = self::get_status( $assoc_args );
		$order->set_status( $status );
		$order->calculate_totals( true );

		// Use provided date or generate one
		if ( null === $date ) {
			$date = self::get_date_created( $assoc_args );
		}
		$date .= ' ' . wp_rand( 0, 23 ) . ':00:00';

		$order->set_date_created( $date );

		// Coupon parameter precedence:
		// 1. Batch mode flag (from generate_coupon_flags) - takes highest priority
		// 2. Legacy --coupons flag - used if batch flag not provided
		// 3. Probabilistic --coupon-ratio - used if neither batch nor legacy flags are set

		// Handle legacy --coupons flag (only if not provided from batch mode)
		if ( null === $include_coupon ) {
			$include_coupon = ! empty( $assoc_args['coupons'] );
		}

		// Handle --coupon-ratio parameter
		if ( isset( $assoc_args['coupon-ratio'] ) && null === $include_coupon ) {
			// Use probabilistic approach for single order generation or when flag not provided
			$coupon_ratio = floatval( $assoc_args['coupon-ratio'] );

			// Validate ratio is between 0.0 and 1.0
			if ( $coupon_ratio < 0.0 || $coupon_ratio > 1.0 ) {
				$coupon_ratio = max( 0.0, min( 1.0, $coupon_ratio ) );
			}

			// Apply coupon based on ratio
			if ( $coupon_ratio >= 1.0 ) {
				$include_coupon = true;
			} elseif ( $coupon_ratio > 0 && wp_rand( 1, 100 ) <= ( $coupon_ratio * 100 ) ) {
				$include_coupon = true;
			} else {
				$include_coupon = false;
			}
		}

		if ( $include_coupon ) {
			$coupon = self::get_or_create_coupon();
			if ( $coupon ) {
				$apply_result = $order->apply_coupon( $coupon );
				if ( is_wp_error( $apply_result ) ) {
					error_log( 'Coupon application failed: ' . $apply_result->get_error_message() . ' (Coupon: ' . $coupon->get_code() . ')' );
				} else {
					// Recalculate totals after applying coupon
					$order->calculate_totals( true );
				}
			}
		}

		// Orders created before 2024-01-09 represents orders created before the attribution feature was added.
		if ( ! ( strtotime( $date ) < strtotime( '2024-01-09' ) ) ) {
			$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.
		if ( 'completed' === $status || 'processing' === $status ) {
			// Add random 0 to 36 hours to creation date.
			$date_paid = date( 'Y-m-d H:i:s', strtotime( $date ) + ( wp_rand( 0, 36 ) * HOUR_IN_SECONDS ) );
			$order->set_date_paid( $date_paid );
			if ( 'completed' === $status ) {
				// Add random 0 to 36 hours to paid date.
				$date_completed = date( 'Y-m-d H:i:s', strtotime( $date_paid ) + ( wp_rand( 0, 36 ) * HOUR_IN_SECONDS ) );
				$order->set_date_completed( $date_completed );
			}
		}

		if ( $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 ) {
				// Use provided refund type or determine probabilistically
				if ( null === $refund_type ) {
					$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 ) );
					}

					$refund_type = self::REFUND_TYPE_NONE;
					if ( $refund_ratio >= 1.0 ) {
						// Always refund if ratio is 1.0 or higher
						$refund_type = self::REFUND_TYPE_FULL;
					} elseif ( $refund_ratio > 0 && wp_rand( 1, 100 ) <= ( $refund_ratio * 100 ) ) {
						// Use random chance for ratios between 0 and 1
						// Split evenly between full and partial
						$refund_type = wp_rand( 0, 1 ) ? self::REFUND_TYPE_FULL : self::REFUND_TYPE_PARTIAL;

						// 25% chance for multi-partial
						if ( self::REFUND_TYPE_PARTIAL === $refund_type && wp_rand( 1, 100 ) <= self::SECOND_REFUND_PROBABILITY ) {
							$refund_type = self::REFUND_TYPE_MULTI;
						}
					}
				}

				// Process refund based on type
				if ( self::REFUND_TYPE_FULL === $refund_type ) {
					self::create_refund( $order, false, null, true ); // Explicitly full
				} elseif ( self::REFUND_TYPE_PARTIAL === $refund_type ) {
					self::create_refund( $order, true, null, false ); // Explicitly partial
				} elseif ( self::REFUND_TYPE_MULTI === $refund_type ) {
					$first_refund = self::create_refund( $order, true, null, false ); // Explicitly partial
					if ( $first_refund && is_object( $first_refund ) ) {
						self::create_refund( $order, true, $first_refund, false ); // Explicitly partial
					}
				}
			}
		}

		/**
		 * Action: Order generator returned a new order.
		 *
		 * @since 1.2.0
		 *
		 * @param \WC_Order $order
		 */
		do_action( 'smoothgenerator_order_generated', $order );

		return $order;
	}

	/**
	 * Create multiple orders.
	 *
	 * @param int    $amount   The number of orders to create.
	 * @param array  $args     Additional args for order creation.
	 *
	 * @return int[]|\WP_Error
	 */
	public static function batch( $amount, array $args = array() ) {
		$amount = self::validate_batch_amount( $amount );
		if ( is_wp_error( $amount ) ) {
			error_log( 'Batch generation failed: ' . $amount->get_error_message() );
			return $amount;
		}

		// Initialize dynamic counters for exact ratio distribution (O(1) memory)
		// Using "selection without replacement" algorithm for exact counts
		$coupons_remaining = 0;
		if ( isset( $args['coupon-ratio'] ) ) {
			$coupon_ratio = floatval( $args['coupon-ratio'] );
			$coupon_ratio = max( 0.0, min( 1.0, $coupon_ratio ) );
			$coupons_remaining = (int) round( $amount * $coupon_ratio );
		}

		// Initialize refund type counters for weighted selection without replacement
		$full_remaining = 0;
		$partial_remaining = 0;
		$multi_remaining = 0;
		if ( isset( $args['refund-ratio'] ) && 'completed' === ( $args['status'] ?? '' ) ) {
			$refund_ratio = floatval( $args['refund-ratio'] );
			$refund_ratio = max( 0.0, min( 1.0, $refund_ratio ) );

			$total_refunds = (int) round( $amount * $refund_ratio );

			// Split using floor to avoid over-allocation, remainder goes to multi
			$full_remaining = (int) floor( $total_refunds * self::REFUND_DISTRIBUTION_FULL_RATIO );
			$partial_remaining = (int) floor( $total_refunds * self::REFUND_DISTRIBUTION_PARTIAL_RATIO );
			$multi_remaining = $total_refunds - $full_remaining - $partial_remaining;
		}

		// Pre-generate dates if date-start is provided
		// This ensures chronological order: lower order IDs = earlier dates
		$dates = null;
		if ( ! empty( $args['date-start'] ) ) {
			$dates = self::generate_batch_dates( $amount, $args );
		}

		$order_ids = array();
		$orders_remaining = $amount;

		for ( $i = 1; $i <= $amount; $i ++ ) {
			// Use pre-generated date if available, otherwise pass null to generate one
			$date = ( null !== $dates && ! empty( $dates ) ) ? array_shift( $dates ) : null;

			// Use selection without replacement for exact coupon distribution
			$include_coupon = null;
			if ( isset( $args['coupon-ratio'] ) ) {
				// Probability = remaining_coupons / remaining_orders
				// Guarantees exact count while maintaining random distribution
				$include_coupon = ( wp_rand( 1, $orders_remaining ) <= $coupons_remaining );
				if ( $include_coupon ) {
					$coupons_remaining--;
				}
			}

			// Use weighted selection without replacement for exact refund distribution
			$refund_type = null;
			if ( isset( $args['refund-ratio'] ) && 'completed' === ( $args['status'] ?? '' ) ) {
				$total_refund_remaining = $full_remaining + $partial_remaining + $multi_remaining;

				if ( $total_refund_remaining > 0 && wp_rand( 1, $orders_remaining ) <= $total_refund_remaining ) {
					// This order gets a refund, decide which type using weighted selection
					// Store thresholds before decrementing
					$full_threshold = $full_remaining;
					$partial_threshold = $full_remaining + $partial_remaining;
					$rand = wp_rand( 1, $total_refund_remaining );

					if ( $rand <= $full_threshold ) {
						$refund_type = self::REFUND_TYPE_FULL;
						$full_remaining--;
					} elseif ( $rand <= $partial_threshold ) {
						$refund_type = self::REFUND_TYPE_PARTIAL;
						$partial_remaining--;
					} else {
						$refund_type = self::REFUND_TYPE_MULTI;
						$multi_remaining--;
					}
				} else {
					$refund_type = self::REFUND_TYPE_NONE;
				}
			}

			$orders_remaining--;

			$order = self::generate( true, $args, $date, $include_coupon, $refund_type );
			if ( ! $order instanceof \WC_Order ) {
				error_log( "Batch generation failed: Order {$i} of {$amount} could not be generated" );
				// Restore counters since order generation failed
				$orders_remaining++;
				if ( $include_coupon && isset( $args['coupon-ratio'] ) ) {
					$coupons_remaining++;
				}
				if ( isset( $args['refund-ratio'] ) && 'completed' === ( $args['status'] ?? '' ) && null !== $refund_type ) {
					if ( self::REFUND_TYPE_FULL === $refund_type ) {
						$full_remaining++;
					} elseif ( self::REFUND_TYPE_PARTIAL === $refund_type ) {
						$partial_remaining++;
					} elseif ( self::REFUND_TYPE_MULTI === $refund_type ) {
						$multi_remaining++;
					}
				}
				continue;
			}
			$order_ids[] = $order->get_id();
		}

		return $order_ids;
	}

	/**
	 * Return a new customer.
	 *
	 * @return \WC_Customer Customer object with data populated.
	 */
	public static function get_customer() {
		global $wpdb;

		$guest    = (bool) wp_rand( 0, 1 );
		$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
			return new \WC_Customer( $user_id );
		}

		$customer = Customer::generate( ! $guest );

		if ( ! $customer instanceof \WC_Customer ) {
			error_log( 'Customer generation failed: Customer::generate() returned invalid result' );
		}

		return $customer;
	}

	/**
	 * Returns a date to use as the order date. If no date arguments have been passed, this will
	 * return the current date. If a `date-start` argument is provided, a random date will be chosen
	 * between `date-start` and the current date. You can pass an `end-date` and a random date between start
	 * and end will be chosen.
	 *
	 * @param array $assoc_args CLI arguments.
	 * @return string Date string (Y-m-d)
	 */
	protected static function get_date_created( $assoc_args ) {
		$current = date( 'Y-m-d', time() );
		if ( ! empty( $assoc_args['date-start'] ) && empty( $assoc_args['date-end'] ) ) {
			$start = $assoc_args['date-start'];
			$end   = $current;
		} elseif ( ! empty( $assoc_args['date-start'] ) && ! empty( $assoc_args['date-end'] ) ) {
			$start = $assoc_args['date-start'];
			$end   = $assoc_args['date-end'];
		} else {
			return $current;
		}

		// Use timestamp-based random selection for single order generation
		$start_timestamp = strtotime( $start );
		$end_timestamp   = strtotime( $end );
		$days_between    = (int) ( ( $end_timestamp - $start_timestamp ) / DAY_IN_SECONDS );

		// If start and end are the same day, return that date (time will be randomized in generate())
		if ( 0 === $days_between ) {
			return date( 'Y-m-d', $start_timestamp );
		}

		// Generate random offset in days and add to start timestamp
		$random_days = wp_rand( 0, $days_between );
		return date( 'Y-m-d', $start_timestamp + ( $random_days * DAY_IN_SECONDS ) );
	}

	/**
	 * Returns a status to use as the order's status. If no status argument has been passed, this will
	 * return a random status.
	 *
	 * @param array $assoc_args CLI arguments.
	 * @return string An order status.
	 */
	private static function get_status( $assoc_args ) {
		if ( ! empty( $assoc_args['status'] ) ) {
			return $assoc_args['status'];
		} else {
			return self::random_weighted_element( array(
				'completed'  => 70,
				'processing' => 15,
				'on-hold'    => 5,
				'failed'     => 10,
			) );
		}
	}

	/**
	 *  Get random products selected from existing products.
	 *
	 * @param int $min_amount Minimum amount of products to get.
	 * @param int $max_amount Maximum amount of products to get.
	 * @return array Random list of products.
	 */
	protected static function get_random_products( int $min_amount = 1, int $max_amount = 4 ) {
		global $wpdb;

		$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'"
		);

		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 ) {
			$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();
		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 );
				$variation = new \WC_Product_Variation( $available_variations[ $index ]['variation_id'] );
				if ( $variation && $variation->exists() ) {
					$products[] = $variation;
				}
			} else {
				$products[] = $product;
			}
		}

		return $products;
	}

	/**
	 * Get a random existing coupon or create coupons if none exist.
	 * If no coupons exist, creates 6 coupons: 3 fixed value and 3 percentage.
	 *
	 * @return \WC_Coupon|false Coupon object or false if none available.
	 */
	protected static function get_or_create_coupon() {
		// Try to get a random existing coupon
		$coupon = Coupon::get_random();

		// If no coupons exist, create 6 (3 fixed, 3 percentage)
		if ( false === $coupon ) {
			if ( class_exists( 'WP_CLI' ) ) {
				\WP_CLI::log( 'No coupons found. Creating 6 coupons (3 fixed cart $5-$50, 3 percentage 5%-25%)...' );
			}

			// Create 3 fixed cart coupons ($5-$50)
			$fixed_result = Coupon::batch( 3, array( 'min' => 5, 'max' => 50, 'discount_type' => 'fixed_cart' ) );

			// Create 3 percentage coupons (5%-25%)
			$percent_result = Coupon::batch( 3, array( 'min' => 5, 'max' => 25, 'discount_type' => 'percent' ) );

		// If coupon creation failed, return false
		if ( is_wp_error( $fixed_result ) || is_wp_error( $percent_result ) ) {
			$error_message = 'Coupon creation failed: ';
			if ( is_wp_error( $fixed_result ) ) {
				$error_message .= 'Fixed coupons error: ' . $fixed_result->get_error_message() . ' ';
			}
			if ( is_wp_error( $percent_result ) ) {
				$error_message .= 'Percentage coupons error: ' . $percent_result->get_error_message();
			}
			error_log( $error_message );
			return false;
		}

			// Now get a random coupon from the ones we just created
			$coupon = Coupon::get_random();
		}

		return $coupon;
	}

	/**
	 * Create a refund for an order (either full or partial).
	 *
	 * @param \WC_Order      $order The order to refund.
	 * @param bool           $force_partial Force partial refund only (legacy parameter).
	 * @param \WC_Order_Refund|null $previous_refund Previous refund to base date on (for second refunds).
	 * @param bool|null      $force_full Explicitly force full refund (overrides random logic).
	 * @return \WC_Order_Refund|false Refund object on success, false on failure.
	 */
	protected static function create_refund( $order, $force_partial = false, $previous_refund = null, $force_full = null ) {
		if ( ! $order instanceof \WC_Order ) {
			error_log( "Error: Order is not an instance of \WC_Order: " . print_r( $order, true ) );
			return false;
		}

		// Check if order already has refunds
		$existing_refunds = $order->get_refunds();
		if ( ! empty( $existing_refunds ) ) {
			$force_partial = true;
			$force_full = false; // Can't do full refund if already has refunds
		}

		// Calculate already refunded quantities
		$refunded_qty_by_item = self::calculate_refunded_quantities( $existing_refunds );

		// Determine refund type (full or partial)
		if ( null !== $force_full ) {
			// Explicit full/partial specified (batch mode with exact ratios)
			$is_full_refund = $force_full;
		} else {
			// Legacy random logic (single order generation or old code)
			$is_full_refund = $force_partial ? false : wp_rand( 0, 1 );
		}

		// Build refund line items
		$line_items = $is_full_refund
			? self::build_full_refund_items( $order, $refunded_qty_by_item )
			: self::build_partial_refund_items( $order, $refunded_qty_by_item );

		// Ensure we have items to refund
		if ( empty( $line_items ) ) {
			error_log( sprintf(
				'Refund skipped for order %d: No line items to refund. Order has %d items.',
				$order->get_id(),
				count( $order->get_items( array( 'line_item', 'fee' ) ) )
			) );
			return false;
		}

		// Calculate refund totals
		$totals = self::calculate_refund_totals( $line_items );
		$refund_amount = $totals['amount'];
		$total_items = $totals['total_items'];
		$total_qty = $totals['total_qty'];

		// For full refunds, use order's actual remaining total to avoid rounding discrepancies
		if ( $is_full_refund ) {
			$refund_amount = round( $order->get_total() - $order->get_total_refunded(), 2 );
		}

		// For partial refunds, ensure refund is < 50% of order total
		if ( ! $is_full_refund ) {
			$max_partial_refund = $order->get_total() * self::MAX_PARTIAL_REFUND_RATIO;

			// Remove items until refund is under threshold
			while ( $refund_amount >= $max_partial_refund && count( $line_items ) > 1 ) {
				unset( $line_items[ array_rand( $line_items ) ] );
				$totals = self::calculate_refund_totals( $line_items );
				$refund_amount = $totals['amount'];
				$total_items = $totals['total_items'];
				$total_qty = $totals['total_qty'];
			}
		}

		// Cap refund amount to maximum available
		$max_refund = round( $order->get_total() - $order->get_total_refunded(), 2 );
		if ( $refund_amount > $max_refund ) {
			$refund_amount = $max_refund;
		}

		// Validate refund amount
		if ( $refund_amount <= 0 ) {
			error_log( sprintf(
				'Refund skipped for order %d: Invalid refund amount (%s). Order total: %s, Already refunded: %s',
				$order->get_id(),
				$refund_amount,
				$order->get_total(),
				$order->get_total_refunded()
			) );
			return false;
		}

		// Create refund reason
		$reason = $is_full_refund
			? 'Full refund'
			: sprintf(
				'Partial refund - %d %s, %d %s',
				$total_items,
				$total_items === 1 ? 'product' : 'products',
				$total_qty,
				$total_qty === 1 ? 'item' : 'items'
			);

		// Calculate refund date
		$refund_date = self::calculate_refund_date( $order, $previous_refund );

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

		if ( is_wp_error( $refund ) ) {
			error_log( sprintf(
				"Refund creation failed for order %d:\nError: %s\nCalculated Amount: %s\nOrder Total: %s\nOrder Refunded Total: %s\nReason: %s\nLine Items: %s",
				$order->get_id(),
				$refund->get_error_message(),
				$refund_amount,
				$order->get_total(),
				$order->get_total_refunded(),
				$reason,
				print_r( $line_items, true )
			) );
			return false;
		}

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

		return $refund;
	}

	/**
	 * Calculate already refunded quantities per item from existing refunds.
	 *
	 * @param array $existing_refunds Array of existing refund objects.
	 * @return array Associative array of item_id => refunded_quantity.
	 */
	protected static function calculate_refunded_quantities( $existing_refunds ) {
		$refunded_qty_by_item = array();

		foreach ( $existing_refunds as $existing_refund ) {
			foreach ( $existing_refund->get_items( array( 'line_item', 'fee' ) ) as $refund_item ) {
				$item_id = $refund_item->get_meta( '_refunded_item_id' );
				if ( ! $item_id ) {
					continue;
				}
				if ( ! isset( $refunded_qty_by_item[ $item_id ] ) ) {
					$refunded_qty_by_item[ $item_id ] = 0;
				}
				$refunded_qty_by_item[ $item_id ] += abs( $refund_item->get_quantity() );
			}
		}

		return $refunded_qty_by_item;
	}

	/**
	 * Build a refund line item with proper tax and total calculations.
	 *
	 * @param \WC_Order_Item $item Order item to refund.
	 * @param int            $refund_qty Quantity to refund.
	 * @param int            $original_qty Original quantity of the item.
	 * @return array Refund line item data.
	 */
	protected static function build_refund_line_item( $item, $refund_qty, $original_qty ) {
		$taxes      = $item->get_taxes();
		$refund_tax = array();

		// Prorate tax based on refund quantity
		if ( ! empty( $taxes['total'] ) && $original_qty > 0 ) {
			foreach ( $taxes['total'] as $tax_id => $tax_amount ) {
				$tax_per_unit = $tax_amount / $original_qty;
				$refund_tax[ $tax_id ] = ( $tax_per_unit * $refund_qty ) * -1;
			}
		}

		// Prorate the refund total based on refund quantity
		$total_per_unit = $original_qty > 0 ? $item->get_total() / $original_qty : 0;
		$refund_total = $total_per_unit * $refund_qty;

		return array(
			'qty'          => $refund_qty,
			'refund_total' => $refund_total * -1,
			'refund_tax'   => $refund_tax,
		);
	}

	/**
	 * Build line items for a full refund.
	 *
	 * @param \WC_Order $order Order to refund.
	 * @param array     $refunded_qty_by_item Already refunded quantities.
	 * @return array Refund line items.
	 */
	protected static function build_full_refund_items( $order, $refunded_qty_by_item ) {
		$line_items = array();

		foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) {
			$original_qty = $item->get_quantity();
			$refunded_qty = isset( $refunded_qty_by_item[ $item_id ] ) ? $refunded_qty_by_item[ $item_id ] : 0;
			$remaining_qty = $original_qty - $refunded_qty;

			// Skip if nothing left to refund or invalid quantity
			if ( $remaining_qty <= 0 || $original_qty <= 0 ) {
				continue;
			}

			$line_items[ $item_id ] = self::build_refund_line_item( $item, $remaining_qty, $original_qty );
		}

		return $line_items;
	}

	/**
	 * Build line items for a partial refund.
	 *
	 * @param \WC_Order $order Order to refund.
	 * @param array     $refunded_qty_by_item Already refunded quantities.
	 * @return array Refund line items.
	 */
	protected static function build_partial_refund_items( $order, $refunded_qty_by_item ) {
		$items = $order->get_items( array( 'line_item', 'fee' ) );
		$line_items = array();

		// Decide whether to refund full items or partial quantities
		$refund_full_items = (bool) wp_rand( 0, 1 );

		if ( $refund_full_items && count( $items ) > 2 ) {
			// Refund a random subset of items completely (requires at least 3 items)
			$items_array  = array_values( $items );
			$num_to_refund = wp_rand( 1, count( $items_array ) - 1 );
			$items_to_refund = array_rand( $items_array, $num_to_refund );

			// Ensure $items_to_refund is always an array for consistent iteration
			if ( ! is_array( $items_to_refund ) ) {
				$items_to_refund = array( $items_to_refund );
			}

			foreach ( $items_to_refund as $index ) {
				$item = $items_array[ $index ];
				$item_id = $item->get_id();
				$original_qty = $item->get_quantity();
				$refunded_qty = isset( $refunded_qty_by_item[ $item_id ] ) ? $refunded_qty_by_item[ $item_id ] : 0;
				$remaining_qty = $original_qty - $refunded_qty;

				// Skip if nothing left to refund or invalid quantity
				if ( $remaining_qty <= 0 || $original_qty <= 0 ) {
					continue;
				}

				$line_items[ $item_id ] = self::build_refund_line_item( $item, $remaining_qty, $original_qty );
			}
		} else {
			// Refund partial quantities of items
			foreach ( $items as $item_id => $item ) {
				$original_qty = $item->get_quantity();
				$refunded_qty = isset( $refunded_qty_by_item[ $item_id ] ) ? $refunded_qty_by_item[ $item_id ] : 0;
				$remaining_qty = $original_qty - $refunded_qty;

				// Skip if nothing left to refund, if only 1 remaining, or invalid quantity
				if ( $remaining_qty <= 1 || $original_qty <= 0 ) {
					continue;
				}

				// Only refund line items with remaining quantity > 1
				if ( 'line_item' === $item->get_type() ) {
					$refund_qty = wp_rand( 1, $remaining_qty - 1 );
					$line_items[ $item_id ] = self::build_refund_line_item( $item, $refund_qty, $original_qty );
					break; // Only refund one item partially
				}
			}

			// If no items were added, refund one complete remaining item
			if ( empty( $line_items ) && count( $items ) > 0 ) {
				$items_array = array_values( $items );
				shuffle( $items_array );

				foreach ( $items_array as $item ) {
					$item_id = $item->get_id();
					$original_qty = $item->get_quantity();
					$refunded_qty = isset( $refunded_qty_by_item[ $item_id ] ) ? $refunded_qty_by_item[ $item_id ] : 0;
					$remaining_qty = $original_qty - $refunded_qty;

					// Skip if nothing left to refund or invalid quantity
					if ( $remaining_qty <= 0 || $original_qty <= 0 ) {
						continue;
					}

					$line_items[ $item_id ] = self::build_refund_line_item( $item, $remaining_qty, $original_qty );
					break; // Only refund one item
				}
			}
		}

		return $line_items;
	}

	/**
	 * Calculate total refund amount and item counts from line items.
	 *
	 * @param array $line_items Refund line items.
	 * @return array Array containing 'amount', 'total_items', and 'total_qty'.
	 */
	protected static function calculate_refund_totals( $line_items ) {
		$refund_amount = 0;
		$total_items   = 0;
		$total_qty     = 0;

		foreach ( $line_items as $item_data ) {
			// Add item total: refund amounts are stored as negative, convert to positive for total calculation
			$refund_amount += abs( $item_data['refund_total'] );
			$total_items++;
			$total_qty += $item_data['qty'];

			// Add tax amounts
			if ( ! empty( $item_data['refund_tax'] ) ) {
				foreach ( $item_data['refund_tax'] as $tax_amount ) {
					$refund_amount += abs( $tax_amount );
				}
			}
		}

		return array(
			'amount'      => round( $refund_amount, 2 ),
			'total_items' => $total_items,
			'total_qty'   => $total_qty,
		);
	}

	/**
	 * Calculate a realistic refund date based on order completion or previous refund.
	 * Ensures refund dates never exceed current time and second refunds always occur after first.
	 *
	 * @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 ) {
		$now = time();

		if ( $previous_refund ) {
			// Second refund: must be after first refund but before current time
			$base_timestamp = strtotime( $previous_refund->get_date_created()->date( 'Y-m-d H:i:s' ) );
			$max_timestamp = min( $base_timestamp + ( self::SECOND_REFUND_MAX_DAYS * DAY_IN_SECONDS ), $now );

			// Ensure second refund is always after first refund
			if ( $max_timestamp <= $base_timestamp ) {
				// If there's no time window, use base timestamp + 1 hour
				// If base is in the future, second refund will also be in the future (but after first)
				$refund_timestamp = $base_timestamp + HOUR_IN_SECONDS;
			} else {
				$refund_timestamp = wp_rand( $base_timestamp + 1, $max_timestamp );
			}
		} else {
			// First refund: within 2 months of order completion, but never in the future
			$completion_timestamp = strtotime( $order->get_date_completed()->date( 'Y-m-d H:i:s' ) );
			$max_timestamp = min( $completion_timestamp + ( self::FIRST_REFUND_MAX_DAYS * DAY_IN_SECONDS ), $now );

			// Ensure we have a valid time window
			if ( $max_timestamp < $completion_timestamp ) {
				// Order completed in the future somehow, use current time
				$refund_timestamp = $now;
			} elseif ( $max_timestamp == $completion_timestamp ) {
				// No time window, use completion timestamp
				$refund_timestamp = $completion_timestamp;
			} else {
				$refund_timestamp = wp_rand( $completion_timestamp, $max_timestamp );
			}
		}

		return date( 'Y-m-d H:i:s', $refund_timestamp );
	}

	/**
	 * Generate an array of sorted dates for batch order creation.
	 * Ensures chronological order when creating multiple orders.
	 *
	 * @param int   $count Number of dates to generate.
	 * @param array $args  Arguments containing date-start and optional date-end.
	 * @return array Sorted array of date strings (Y-m-d).
	 */
	protected static function generate_batch_dates( $count, $args ) {
		$current = date( 'Y-m-d', time() );

		if ( ! empty( $args['date-start'] ) && empty( $args['date-end'] ) ) {
			$start = $args['date-start'];
			$end   = $current;
		} elseif ( ! empty( $args['date-start'] ) && ! empty( $args['date-end'] ) ) {
			$start = $args['date-start'];
			$end   = $args['date-end'];
		} else {
			// No date range specified, return array of current dates
			return array_fill( 0, $count, $current );
		}

		$start_timestamp = strtotime( $start );
		$end_timestamp   = strtotime( $end );
		$days_between    = (int) ( ( $end_timestamp - $start_timestamp ) / DAY_IN_SECONDS );

		// If start and end dates are the same, return array of that date
		if ( 0 === $days_between ) {
			return array_fill( 0, $count, date( 'Y-m-d', $start_timestamp ) );
		}

		$dates = array();
		for ( $i = 0; $i < $count; $i++ ) {
			$random_days = wp_rand( 0, $days_between );
			$dates[] = date( 'Y-m-d', $start_timestamp + ( $random_days * DAY_IN_SECONDS ) );
		}

		// Sort chronologically so lower order IDs get earlier dates
		sort( $dates );

		return $dates;
	}

}


================================================
FILE: includes/Generator/OrderAttribution.php
================================================
<?php
/**
 * Order Attribution data helper.
 *
 * @package SmoothGenerator\Classes
 */

namespace WC\SmoothGenerator\Generator;

/**
 * Order Attribution data helper class.
 */
class OrderAttribution {

	/**
	 * Campaign distribution percentages
	 */
	const CAMPAIGN_PROBABILITY = 15; // 15% of orders will have campaign data

	/**
	 * Generate order attribution data.
	 *
	 * @param \WC_Order $order Order.
	 * @param array     $assoc_args Arguments passed via the CLI for additional customization.
	 */
	public static function add_order_attribution_meta( $order, $assoc_args = array() ) {

		if ( isset( $assoc_args['skip-order-attribution'] ) ) {
			return;
		}

		$order_products = $order->get_items();

		$device_type = self::get_random_device_type();
		$source      = 'woocommerce.com';
		$source_type = self::get_source_type();
		$origin      = self::get_origin( $source_type, $source );
		$product_url = empty( $order_products ) ? '' : get_permalink( $order_products[ array_rand( $order_products ) ]->get_id() );
		$utm_content = array( '/', 'campaign_a', 'campaign_b' );
		$utm_content = $utm_content[ array_rand( $utm_content ) ];

		$meta = array();

		// For these source types, we only need to set the source type.
		if ( in_array( $source_type, array( 'admin', 'mobile_app', 'unknown' ), true ) ) {
			$meta = array(
				'_wc_order_attribution_source_type' => $source_type,
			);
		} else {
			$meta = array(
				'_wc_order_attribution_origin'             => $origin,
				'_wc_order_attribution_device_type'        => $device_type,
				'_wc_order_attribution_user_agent'         => self::get_random_user_agent_for_device( $device_type ),
				'_wc_order_attribution_session_count'      => wp_rand( 1, 10 ),
				'_wc_order_attribution_session_pages'      => wp_rand( 1, 10 ),
				'_wc_order_attribution_session_start_time' => self::get_random_session_start_time( $order ),
				'_wc_order_attribution_session_entry'      => $product_url,
				'_wc_order_attribution_utm_content'        => $utm_content,
				'_wc_order_attribution_utm_source'         => self::get_source( $source_type ),
				'_wc_order_attribution_referrer'           => self::get_referrer( $source_type ),
				'_wc_order_attribution_source_type'        => $source_type,
			);

			// Add campaign data only for a percentage of orders.
			if ( wp_rand( 1, 100 ) <= self::CAMPAIGN_PROBABILITY ) {
				$campaign_data = self::get_campaign_data();
				$meta          = array_merge( $meta, $campaign_data );
			}
		}

		// If the source type is not typein ( Direct ), set a random utm medium.
		if ( ! in_array( $source_type, array( 'typein', 'admin', 'mobile_app', 'unknown' ), true ) ) {
			$meta['_wc_order_attribution_utm_medium'] = self::get_random_utm_medium();
		}

		foreach ( $meta as $key => $value ) {
			$order->add_meta_data( $key, $value );
		}
	}

	/**
	 * Get a random referrer based on the source type.
	 *
	 * @param string $source_type The source type.
	 * @return string The referrer.
	 */
	public static function get_referrer( string $source_type ) {
		// Set up the label based on the source type.
		switch ( $source_type ) {
			case 'utm':
				$utm = array(
					'https://woocommerce.com/',
					'https://twitter.com',
				);
				return $utm[ array_rand( $utm ) ];
			case 'organic':
				$organic = array(
					'https://google.com',
					'https://bing.com',
				);
				return $organic[ array_rand( $organic ) ];
			case 'referral':
				$refferal = array(
					'https://woocommerce.com/',
					'https://facebook.com',
					'https://twitter.com',
					'https://chatgpt.com',
					'https://claude.ai',
				);
				return $refferal[ array_rand( $refferal ) ];
			case 'typein':
				return '';
			case 'admin':
				return '';
			case 'mobile_app':
				return '';
			default:
				return '';
		}
	}

	/**
	 * Get a random utm medium.
	 *
	 * @return string The utm medium.
	 */
	public static function get_random_utm_medium() {
		$utm_mediums = array(
			'referral',
			'cpc',
			'email',
			'social',
			'organic',
			'unknown',
		);

		return $utm_mediums[ array_rand( $utm_mediums ) ];
	}

	/**
	 * Get the origin.
	 *
	 * @param string $source_type The source type.
	 * @param string $source The source.
	 *
	 * @return string The origin.
	 */
	public static function get_origin( string $source_type, string $source ) {
		// Set up the label based on the source type.
		switch ( $source_type ) {
			case 'utm':
				return 'Source: ' . $source;
			case 'organic':
				return 'Organic: ' . $source;
			case 'referral':
				return 'Referral: ' . $source;
			case 'typein':
				return 'Direct';
			case 'admin':
				return 'Web admin';
			case 'mobile_app':
				return 'Mobile app';
			default:
				return 'Unknown';
		}
	}

	/**
	 * Get random source type.
	 *
	 * @return string The source type.
	 */
	public static function get_source_type() {
		$source_types = array(
			'typein',
			'organic',
			'referral',
			'utm',
			'admin',
			'mobile_app',
			'unknown',
		);

		return $source_types[ array_rand( $source_types ) ];
	}

	/**
	 * Get random source based on the source type.
	 *
	 * @param string $source_type The source type.
	 * @return string The source.
	 */
	public static function get_source( $source_type ) {
		switch ( $source_type ) {
			case 'typein':
				return '(direct)';
			case 'organic':
				$organic = array(
					'google',
					'bing',
					'yahoo',
				);
				return $organic[ array_rand( $organic ) ];
			case 'referral':
				$refferal = array(
					'woocommerce.com',
					'facebook.com',
					'twitter.com',
					'chatgpt.com',
					'claude.ai',
				);
				return $refferal[ array_rand( $refferal ) ];
			case 'social':
				$social = array(
					'facebook.com',
					'twitter.com',
					'instagram.com',
					'pinterest.com',
				);
				return $social[ array_rand( $social ) ];
			case 'utm':
				$utm = array(
					'mailchimp',
					'google',
					'newsletter',
				);
				return $utm[ array_rand( $utm ) ];
			default:
				return 'Unknown';
		}
	}

	/**
	 * Get random device type based on the following distribution:
	 * Mobile:  50%
	 * Desktop: 35%
	 * Tablet:  15%
	 */
	public static function get_random_device_type() {
		$randomNumber = wp_rand( 1, 100 ); // Generate a random number between 1 and 100.

		if ( $randomNumber <= 50 ) {
			return 'Mobile';
		} elseif ( $randomNumber <= 85 ) {
			return 'Desktop';
		} else {
			return 'Tablet';
		}
	}

	/**
	 * Get a random user agent based on the device type.
	 *
	 * @param string $device_type The device type.
	 * @return string The user agent.
	 */
	public static function get_random_user_agent_for_device( $device_type ) {
		switch ( $device_type ) {
			case 'Mobile':
				return self::get_random_mobile_user_agent();
			case 'Tablet':
				return self::get_random_tablet_user_agent();
			case 'Desktop':
				return self::get_random_desktop_user_agent();
			default:
				return '';
		}
	}

	/**
	 * Get a random mobile user agent.
	 *
	 * @return string The user agent.
	 */
	public static function get_random_mobile_user_agent() {
		$user_agents = array(
			'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1',
			'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/114.0.5735.99 Mobile/15E148 Safari/604.1',
			'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36',
			'Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/21.0 Chrome/110.0.5481.154 Mobile Safari/537.36',
		);

		return $user_agents[ array_rand( $user_agents ) ];
	}

	/**
	 * Get a random tablet user agent.
	 *
	 * @return string The user agent.
	 */
	public static function get_random_tablet_user_agent() {
		$user_agents = array(
			'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/114.0.5735.124 Mobile/15E148 Safari/604.1',
			'Mozilla/5.0 (Linux; Android 12; SM-X906C Build/QP1A.190711.020; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/80.0.3987.119 Mobile Safari/537.36',
		);

		return $user_agents[ array_rand( $user_agents ) ];
	}

	/**
	 * Get a random desktop user agent.
	 *
	 * @return string The user agent.
	 */
	public static function get_random_desktop_user_agent() {
		$user_agents = array(
			'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246',
			'Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36',
			'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9',
		);

		return $user_agents[ array_rand( $user_agents ) ];
	}

	/**
	 * Get a random session start time based on the order creation time.
	 *
	 * @param \WC_Order $order The order.
	 * @return string The session start time.
	 */
	public static function get_random_session_start_time( $order ) {

		// Clone the order creation date so we don't modify the original.
		$order_created_date = clone $order->get_date_created();

		// Random DateTimeInterval between 10 minutes and 6 hours.
		$random_interval = new \DateInterval( 'PT' . (string) wp_rand( 10, 360 ) . 'M' );

		// Subtract the random interval from the order creation date.
		$order_created_date->sub( $random_interval );

		return $order_created_date->format( 'Y-m-d H:i:s' );
	}

	/**
	 * Get campaign attribution data.
	 *
	 * @return array Campaign attribution data.
	 */
	private static function get_campaign_data() {
		$campaign_type = self::get_campaign_type();

		switch ( $campaign_type ) {
			case 'seasonal':
				return self::get_seasonal_campaign_data();
			case 'promotional':
				return self::get_promotional_campaign_data();
			case 'product':
				return self::get_product_campaign_data();
			default:
				return self::get_general_campaign_data();
		}
	}

	/**
	 * Get the campaign type based on weighted probabilities.
	 *
	 * @return string Campaign type.
	 */
	private static function get_campaign_type() {
		$random = wp_rand( 1, 100 );

		if ( $random <= 40 ) {
			return 'seasonal'; // 40% seasonal campaigns
		} elseif ( $random <= 70 ) {
			return 'promotional'; // 30% promotional campaigns
		} elseif ( $random <= 90 ) {
			return 'product'; // 20% product campaigns
		} else {
			return 'general'; // 10% general campaigns
		}
	}

	/**
	 * Get seasonal campaign data.
	 *
	 * @return array Campaign data.
	 */
	private static function get_seasonal_campaign_data() {
		$campaigns = array(
			'summer_sale'  => array(
				'content' => 'summer_deals',
				'term'    => 'seasonal_discount',
			),
			'black_friday' => array(
				'content' => 'bf_deals',
				'term'    => 'black_friday_sale',
			),
			'holiday_special'   => array(
				'content' => 'holiday_deals',
				'term'    => 'christmas_sale',
			),
		);

		$campaign_name = array_rand( $campaigns );
		$campaign      = $campaigns[ $campaign_name ];

		return array(
			'_wc_order_attribution_utm_campaign' => $campaign_name,
			'_wc_order_attribution_utm_content'  => $campaign['content'],
			'_wc_order_attribution_utm_term'     => $campaign['term'],
		);
	}

	/**
	 * Get promotional campaign data.
	 *
	 * @return array Campaign data.
	 */
	private static function get_promotional_campaign_data() {
		$campaigns = array(
			'flash_sale'       => array(
				'content' => '24hr_sale',
				'term'    => 'limited_time',
			),
			'membership_promo' => array(
				'content' => 'member_exclusive',
				'term'    => 'join_now',
			),
		);

		$campaign_name = array_rand( $campaigns );
		$campaign      = $campaigns[ $campaign_name ];

		return array(
			'_wc_order_attribution_utm_campaign' => $campaign_name,
			'_wc_order_attribution_utm_content'  => $campaign['content'],
			'_wc_order_attribution_utm_term'     => $campaign['term'],
		);
	}

	/**
	 * Get product campaign data.
	 *
	 * @return array Campaign data.
	 */
	private static function get_product_campaign_data() {
		$campaigns = array(
			'new_product_launch' => array(
				'content' => 'product_launch',
				'term'    => 'new_arrival',
			),
			'spring_collection'  => array(
				'content' => 'spring',
				'term'    => 'new_collection',
			),
		);

		$campaign_name = array_rand( $campaigns );
		$campaign      = $campaigns[ $campaign_name ];

		return array(
			'_wc_order_attribution_utm_campaign' => $campaign_name,
			'_wc_order_attribution_utm_content'  => $campaign['content'],
			'_wc_order_attribution_utm_term'     => $campaign['term'],
		);
	}

	/**
	 * Get general campaign data.
	 *
	 * @return array Campaign data.
	 */
	private static function get_general_campaign_data() {
		$campaigns = array(
			'newsletter_special' => array(
				'content' => 'newsletter_special',
				'term'    => 'newsletter_special',
			),
			'social_campaign'    => array(
				'content' => 'social_campaign',
				'term'    => 'social_campaign',
			),
			'influencer_collab'  => array(
				'content' => 'influencer_collab',
				'term'    => 'influencer_collab',
			),
		);

		$campaign_name = array_rand( $campaigns );
		$campaign      = $campaigns[ $campaign_name ];

		return array(
			'_wc_order_attribution_utm_campaign' => $campaign_name,
			'_wc_order_attribution_utm_content'  => $campaign['content'],
			'_wc_order_attribution_utm_term'     => $campaign['term'],
		);
	}

}


================================================
FILE: includes/Generator/Product.php
================================================
<?php
/**
 * Abstract product generator class
 *
 * @package SmoothGenerator\Abstracts
 */

namespace WC\SmoothGenerator\Generator;

use WC\SmoothGenerator\Util\RandomRuntimeCache;

/**
 * Product data generator.
 */
class Product extends Generator {
	/**
	 * Holds array of product IDs for generating relationships.
	 *
	 * @var array Array of IDs.
	 */
	protected static $product_ids = array();

	/**
	 * Holds array of global attributes new products may reuse.
	 *
	 * @var array Array of attributes.
	 */
	protected static $global_attributes = array(
		'Color'        => array(
			'Green',
			'Blue',
			'Red',
			'Yellow',
			'Indigo',
			'Violet',
			'Black',
			'White',
			'Orange',
			'Pink',
			'Purple',
		),
		'Size'         => array(
			'Small',
			'Medium',
			'Large',
			'XL',
			'XXL',
			'XXXL',
		),
		'Numeric Size' => array(
			'6',
			'7',
			'8',
			'9',
			'10',
			'11',
			'12',
			'13',
			'14',
			'15',
			'16',
			'17',
			'18',
			'19',
			'20',
		),
	);

	/**
	 * Return a new product.
	 *
	 * @param bool  $save Save the object before returning or not.
	 * @param array $assoc_args Arguments passed via the CLI for additional customization.
	 * @return \WC_Product|\WP_Error The product object consisting of random data, or WP_Error on failure.
	 */
	public static function generate( $save = true, $assoc_args = array() ) {
		parent::maybe_initialize_generators();

		$type = self::get_product_type( $assoc_args );
		switch ( $type ) {
			case 'simple':
			default:
				$product = self::generate_simple_product();
				break;
			case 'variable':
				$product = self::generate_variable_product();
				break;
		}

		// Check if product generation failed.
		if ( is_wp_error( $product ) ) {
			return $product;
		}

		if ( $product ) {
			$product->save();

			// Assign brand terms using wp_set_object_terms, but only if the taxonomy exists.
			if ( taxonomy_exists( 'product_brand' ) ) {
				$brand_ids = self::get_term_ids( 'product_brand', self::$faker->numberBetween( 1, 3 ) );
				if ( ! empty( $brand_ids ) ) {
					$brand_result = wp_set_object_terms( $product->get_id(), $brand_ids, 'product_brand' );
					if ( is_wp_error( $brand_result ) ) {
						return $brand_result;
					}
				}
			}
		}

		// Limit size of stored relationship IDs.
		if ( 100 < count( self::$product_ids ) ) {
			shuffle( self::$product_ids );
			self::$product_ids = array_slice( self::$product_ids, 0, 50 );
		}

		self::$product_ids[] = $product->get_id();

		/**
		 * Action: Product generator returned a new product.
		 *
		 * @since 1.2.0
		 *
		 * @param \WC_Product $product
		 */
		do_action( 'smoothgenerator_product_generated', $product );

		return $product;
	}

	/**
	 * Create multiple products.
	 *
	 * @param int   $amount   The number of products to create.
	 * @param array $args     Additional args for product creation.
	 *
	 * @return int[]|\WP_Error
	 */
	public static function batch( $amount, array $args = array() ) {
		$amount = self::validate_batch_amount( $amount );
		if ( is_wp_error( $amount ) ) {
			return $amount;
		}

		$use_existing_terms = ! empty( $args['use-existing-terms'] );
		if ( ! $use_existing_terms ) {
			self::maybe_generate_terms( $amount );
		}

		$product_ids = array();

		for ( $i = 1; $i <= $amount; $i++ ) {
			$product = self::generate( true, $args );

			// Skip products that failed to generate.
			if ( is_wp_error( $product ) ) {
				continue;
			}

			$product_ids[] = $product->get_id();
		}

		// In case multiple batches are being run in one request, refresh the cache data.
		RandomRuntimeCache::clear( 'product_cat' );
		RandomRuntimeCache::clear( 'product_tag' );
		RandomRuntimeCache::clear( 'product_brand' );

		return $product_ids;
	}

	/**
	 * Create a new global attribute.
	 *
	 * @param string $raw_name Attribute name (label).
	 * @return int Attribute ID.
	 */
	protected static function create_global_attribute( $raw_name ) {
		$slug = wc_sanitize_taxonomy_name( $raw_name );

		$attribute_id = wc_create_attribute( array(
			'name'         => $raw_name,
			'slug'         => $slug,
			'type'         => 'select',
			'order_by'     => 'menu_order',
			'has_archives' => false,
		) );

		$taxonomy_name = wc_attribute_taxonomy_name( $slug );
		register_taxonomy(
			$taxonomy_name,
			apply_filters( 'woocommerce_taxonomy_objects_' . $taxonomy_name, array( 'product' ) ),
			apply_filters( 'woocommerce_taxonomy_args_' . $taxonomy_name, array(
				'labels'       => array(
					'name' => $raw_name,
				),
				'hierarchical' => true,
				'show_ui'      => false,
				'query_var'    => true,
				'rewrite'      => false,
			) )
		);

		self::$global_attributes[ $raw_name ] = isset( self::$global_attributes[ $raw_name ] ) ? self::$global_attributes[ $raw_name ] : array();

		delete_transient( 'wc_attribute_taxonomies' );

		return $attribute_id;
	}

	/**
	 * Generate attributes for a product.
	 *
	 * @param integer $qty Number of attributes to generate.
	 * @param integer $maximum_terms Maximum number of terms per attribute to generate.
	 * @return array|\WP_Error Array of attributes or WP_Error on failure.
	 */
	protected static function generate_attributes( $qty = 1, $maximum_terms = 10 ) {
		$used_names = array();
		$attributes = array();

		for ( $i = 0; $i < $qty; $i++ ) {
			$attribute = new \WC_Product_Attribute();
			$attribute->set_id( 0 );
			$attribute->set_position( $i );
			$attribute->set_visible( true );
			$attribute->set_variation( true );

			if ( self::$faker->boolean() ) {
				$raw_name = array_rand( self::$global_attributes );

				if ( in_array( $raw_name, $used_names, true ) ) {
					$raw_name = ucfirst( substr( self::$faker->word(), 0, 28 ) );
				}

				$attribute_labels = wp_list_pluck( wc_get_attribute_taxonomies(), 'attribute_label', 'attribute_name' );
				$attribute_name   = array_search( $raw_name, $attribute_labels, true );

				if ( ! $attribute_name ) {
					$attribute_name = wc_sanitize_taxonomy_name( $raw_name );
				}

				$attribute_id = wc_attribute_taxonomy_id_by_name( $attribute_name );

				if ( ! $attribute_id ) {
					$attribute_id = self::create_global_attribute( $raw_name );

					// Check if attribute creation failed.
					if ( is_wp_error( $attribute_id ) ) {
						return $attribute_id;
					}
				}

				$slug          = wc_sanitize_taxonomy_name( $raw_name );
				$taxonomy_name = wc_attribute_taxonomy_name( $slug );

				$attribute->set_name( $taxonomy_name );
				$attribute->set_id( $attribute_id );

				$used_names[] = $raw_name;

				$num_values      = self::$faker->numberBetween( 1, $maximum_terms );
				$values          = array();
				$existing_values = isset( self::$global_attributes[ $raw_name ] ) ? self::$global_attributes[ $raw_name ] : array();

				for ( $j = 0; $j < $num_values; $j++ ) {
					$value = '';

					if ( self::$faker->boolean( 80 ) && ! empty( $existing_values ) ) {
						shuffle( $existing_values );
						$value = array_pop( $existing_values );
					}

					if ( empty( $value ) || in_array( $value, $values, true ) ) {
						$value = ucfirst( self::$faker->words( self::$faker->numberBetween( 1, 2 ), true ) );
					}

					self::$global_attributes[ $raw_name ][] = $value;

					$values[] = $value;
				}
				$attribute->set_options( $values );
			} else {
				$attribute->set_name( ucfirst( self::$faker->words( self::$faker->numberBetween( 1, 3 ), true ) ) );
				$attribute->set_options( array_filter( self::$faker->words( self::$faker->numberBetween( 2, 4 ), false ) ), 'ucfirst' );
			}
			$attributes[] = $attribute;
		}

		return $attributes;
	}

	/**
	 * Returns a product type to generate. If no type is specified, or an invalid type is specified,
	 * a weighted random type is returned.
	 *
	 * @param array $assoc_args CLI arguments.
	 * @return string A product type.
	 */
	protected static function get_product_type( array $assoc_args ) {
		$type  = $assoc_args['type'] ?? null;
		$types = array(
			'simple',
			'variable',
		);

		if ( ! is_null( $type ) && in_array( $type, $types, true ) ) {
			return $type;
		} else {
			return self::random_weighted_element( array(
				'simple'   => 80,
				'variable' => 20,
			) );
		}
	}

	/**
	 * Generate a variable product and return it.
	 *
	 * @return \WC_Product_Variable|\WP_Error Product object or WP_Error on failure.
	 */
	protected static function generate_variable_product() {
		$name              = ucwords( self::$faker->productName );
		$will_manage_stock = self::$faker->boolean();
		$product           = new \WC_Product_Variable();

		$gallery    = self::maybe_get_gallery_image_ids();
		$attributes = self::generate_attributes( self::$faker->numberBetween( 1, 3 ), 5 );

		// Check if attribute generation failed.
		if ( is_wp_error( $attributes ) ) {
			return $attributes;
		}

		$product->set_props( array(
			'name'              => $name,
			'featured'          => self::$faker->boolean( 10 ),
			'sku'               => sanitize_title( $name ) . '-' . self::$faker->ean8,
			'global_unique_id'  => self::$faker->randomElement( array( self::$faker->ean13, self::$faker->isbn10 ) ),
			'attributes'        => $attributes,
			'tax_status'        => self::$faker->randomElement( array( 'taxable', 'shipping', 'none' ) ),
			'tax_class'         => '',
			'manage_stock'      => $will_manage_stock,
			'stock_quantity'    => $will_manage_stock ? self::$faker->numberBetween( -100, 100 ) : null,
			'stock_status'      => 'instock',
			'backorders'        => self::$faker->randomElement( array( 'yes', 'no', 'notify' ) ),
			'sold_individually' => self::$faker->boolean( 20 ),
			'upsell_ids'        => self::get_existing_product_ids(),
			'cross_sell_ids'    => self::get_existing_product_ids(),
			'image_id'          => self::get_image(),
			'category_ids'      => self::get_term_ids( 'product_cat', self::$faker->numberBetween( 0, 3 ) ),
			'tag_ids'           => self::get_term_ids( 'product_tag', self::$faker->numberBetween( 0, 5 ) ),
			'gallery_image_ids' => $gallery,
			'reviews_allowed'   => self::$faker->boolean(),
			'purchase_note'     => self::$faker->boolean() ? self::$faker->text() : '',
			'menu_order'        => self::$faker->numberBetween( 0, 10000 ),
		) );
		// Need to save to get an ID for variations.
		$product->save();

		// Create variations, one for each attribute value combination.
		$variation_attributes = wc_list_pluck( array_filter( $product->get_attributes(), 'wc_attributes_array_filter_variation' ), 'get_slugs' );
		$possible_attributes  = array_reverse( wc_array_cartesian( $variation_attributes ) );
		foreach ( $possible_attributes as $possible_attribute ) {
			$price             = self::$faker->randomFloat( 2, 1, 1000 );
			$is_on_sale        = self::$faker->boolean( 35 );
			$has_sale_schedule = $is_on_sale && self::$faker->boolean( 40 ); // ~40% of on-sale variations have a schedule.
			$sale_price        = $is_on_sale ? self::$faker->randomFloat( 2, 0, $price ) : '';
			$date_on_sale_from = $has_sale_schedule ? self::$faker->dateTimeBetween( '-3 days', '+3 days' )->format( DATE_ATOM ) : '';
			$date_on_sale_to   = $has_sale_schedule ? self::$faker->dateTimeBetween( '+4 days', '+4 months' )->format( DATE_ATOM ) : '';
			$is_virtual        = self::$faker->boolean( 20 );
			$variation         = new \WC_Product_Variation();
			$variation->set_props( array(
				'parent_id'         => $product->get_id(),
				'attributes'        => $possible_attribute,
				'regular_price'     => $price,
				'sale_price'        => $sale_price,
				'date_on_sale_from' => $date_on_sale_from,
				'date_on_sale_to'   => $date_on_sale_to,
				'tax_status'        => self::$faker->randomElement( array( 'taxable', 'shipping', 'none' ) ),
				'tax_class'         => '',
				'manage_stock'      => $will_manage_stock,
				'stock_quantity'    => $will_manage_stock ? self::$faker->numberBetween( -20, 100 ) : null,
				'stock_status'      => 'instock',
				'weight'            => $is_virtual ? '' : self::$faker->numberBetween( 1, 200 ),
				'length'            => $is_virtual ? '' : self::$faker->numberBetween( 1, 200 ),
				'width'             => $is_virtual ? '' : self::$faker->numberBetween( 1, 200 ),
				'height'            => $is_virtual ? '' : self::$faker->numberBetween( 1, 200 ),
				'virtual'           => $is_virtual,
				'downloadable'      => false,
				'image_id'          => self::get_image(),
			) );

			// Set COGS if the feature is enabled.
			if ( wc_get_container()->get( 'Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController' )->feature_is_enabled() ) {
				$variation->set_props( array( 'cogs_value' => round( $price * ( 1 - self::$faker->numberBetween( 15, 60 ) / 100 ), 2 ) ) );
			}

			$variation->save();
		}
		$data_store = $product->get_data_store();
		$data_store->sort_all_product_variations( $product->get_id() );

		return $product;
	}

	/**
	 * Generate a simple product and return it.
	 *
	 * @return \WC_Product
	 */
	protected static function generate_simple_product() {
		$name              = ucwords( self::$faker->productName );
		$will_manage_stock = self::$faker->boolean();
		$is_virtual        = self::$faker->boolean();
		$price             = self::$faker->randomFloat( 2, 1, 1000 );
		$is_on_sale        = self::$faker->boolean( 35 );
		$has_sale_schedule = $is_on_sale && self::$faker->boolean( 40 ); // ~40% scheduled, rest indefinite.
		$sale_price        = $is_on_sale ? self::$faker->randomFloat( 2, 0, $price ) : '';
		$date_on_sale_from = $has_sale_schedule ? self::$faker->dateTimeBetween( '-3 days', '+3 days' )->format( DATE_ATOM ) : '';
		$date_on_sale_to   = $has_sale_schedule ? self::$faker->dateTimeBetween( '+4 days', '+4 months' )->format( DATE_ATOM ) : '';
		$product           = new \WC_Product();

		$image_id = self::get_image();
		$gallery  = self::maybe_get_gallery_image_ids();

		$product->set_props( array(
			'name'               => $name,
			'featured'           => self::$faker->boolean(),
			'catalog_visibility' => 'visible',
			'description'        => self::$faker->paragraphs( self::$faker->numberBetween( 1, 5 ), true ),
			'short_description'  => self::$faker->text(),
			'sku'                => sanitize_title( $name ) . '-' . self::$faker->ean8,
			'global_unique_id'   => self::$faker->randomElement( array( self::$faker->ean13, self::$faker->isbn10 ) ),
			'regular_price'      => $price,
			'sale_price'         => $sale_price,
			'date_on_sale_from'  => $date_on_sale_from,
			'date_on_sale_to'    => $date_on_sale_to,
			'total_sales'        => self::$faker->numberBetween( 0, 10000 ),
			'tax_status'         => self::$faker->randomElement( array( 'taxable', 'shipping', 'none' ) ),
			'tax_class'          => '',
			'manage_stock'       => $will_manage_stock,
			'stock_quantity'     => $will_manage_stock ? self::$faker->numberBetween( -100, 100 ) : null,
			'stock_status'       => 'instock',
			'backorders'         => self::$faker->randomElement( array( 'yes', 'no', 'notify' ) ),
			'sold_individually'  => self::$faker->boolean( 20 ),
			'weight'             => $is_virtual ? '' : self::$faker->numberBetween( 1, 200 ),
			'length'             => $is_virtual ? '' : self::$faker->numberBetween( 1, 200 ),
			'width'              => $is_virtual ? '' : self::$faker->numberBetween( 1, 200 ),
			'height'             => $is_virtual ? '' : self::$faker->numberBetween( 1, 200 ),
			'upsell_ids'         => self::get_existing_product_ids(),
			'cross_sell_ids'     => self::get_existing_product_ids(),
			'parent_id'          => 0,
			'reviews_allowed'    => self::$faker->boolean(),
			'purchase_note'      => self::$faker->boolean() ? self::$faker->text() : '',
			'menu_order'         => self::$faker->numberBetween( 0, 10000 ),
			'virtual'            => $is_virtual,
			'downloadable'       => false,
			'category_ids'       => self::get_term_ids( 'product_cat', self::$faker->numberBetween( 0, 3 ) ),
			'tag_ids'            => self::get_term_ids( 'product_tag', self::$faker->numberBetween( 0, 5 ) ),
			'shipping_class_id'  => 0,
			'image_id'           => $image_id,
			'gallery_image_ids'  => $gallery,
		) );

		// Set COGS if the feature is enabled.
		if ( wc_get_container()->get( 'Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController' )->feature_is_enabled() ) {
			$product->set_props( array( 'cogs_value' => round( $price * ( 1 - self::$faker->numberBetween( 15, 60 ) / 100 ), 2 ) ) );
		}

		return $product;
	}

	/**
	 * Maybe generate a number of terms for use with products, if there aren't enough existing terms.
	 *
	 * Number of terms is determined by the number of products that will be generated.
	 *
	 * @param int $product_amount The number of products that will be generated.
	 *
	 * @return void
	 */
	protected static function maybe_generate_terms( int $product_amount ): void {
		if ( $product_amount < 10 ) {
			$cats      = 5;
			$cat_depth = 1;
			$tags      = 10;
			$brands    = 5;
		} elseif ( $product_amount < 50 ) {
			$cats      = 10;
			$cat_depth = 2;
			$tags      = 20;
			$brands    = 10;
		} else {
			$cats      = 20;
			$cat_depth = 3;
			$tags      = 40;
			$brands    = 10;
		}

		$existing_cats = count( self::get_term_ids( 'product_cat', $cats ) );
		if ( $existing_cats < $cats ) {
			Term::batch( $cats - $existing_cats, 'product_cat', array( 'max-depth' => $cat_depth ) );
			RandomRuntimeCache::clear( 'product_cat' );
		}

		$existing_tags = count( self::get_term_ids( 'product_tag', $tags ) );
		if ( $existing_tags < $tags ) {
			Term::batch( $tags - $existing_tags, 'product_tag' );
			RandomRuntimeCache::clear( 'product_tag' );
		}

		$existing_brands = count( self::get_term_ids( 'product_brand', $brands ) );
		if ( $existing_brands < $brands ) {
			Term::batch( $brands - $existing_brands, 'product_brand' );
			RandomRuntimeCache::clear( 'product_brand' );
		}
	}

	/**
	 * Get a number of random term IDs for a specific taxonomy.
	 *
	 * @param string $taxonomy The taxonomy to get terms for.
	 * @param int    $limit    The number of term IDs to get. Maximum value of 50.
	 *
	 * @return array
	 */
	protected static function get_term_ids( string $taxonomy, int $limit ): array {
		if ( $limit <= 0 ) {
			return array();
		}

		if ( ! RandomRuntimeCache::exists( $taxonomy ) ) {
			$args = array(
				'taxonomy'   => $taxonomy,
				'number'     => 50,
				'orderby'    => 'count',
				'order'      => 'ASC',
				'hide_empty' => false,
				'fields'     => 'ids',
			);

			if ( 'product_cat' === $taxonomy ) {
				$uncategorized = get_term_by( 'slug', 'uncategorized', 'product_cat' );
				if ( $uncategorized ) {
					$args['exclude'] = $uncategorized->term_id;
				}
			}

			$term_ids = get_terms( $args );

			RandomRuntimeCache::set( $taxonomy, $term_ids );
		}

		RandomRuntimeCache::shuffle( $taxonomy );

		return RandomRuntimeCache::get( $taxonomy, $limit );
	}

	/**
	 * Generate an image gallery.
	 *
	 * @return array
	 */
	protected static function maybe_get_gallery_image_ids() {
		$gallery = array();

		$create_gallery = self::$faker->boolean( 10 );

		if ( ! $create_gallery ) {
			return;
		}

		$image_count = wp_rand( 0, 3 );

		for ( $i = 0; $i < $image_count; $i++ ) {
			$gallery[] = self::get_image();
		}

		return $gallery;
	}

	/**
	 * Get some random existing product IDs.
	 *
	 * @param int $limit Number of term IDs to get.
	 * @return array
	 */
	protected static function get_existing_product_ids( $limit = 5 ) {
		if ( ! self::$product_ids ) {
			self::$product_ids = wc_get_products(
				array(
					'limit'   => $limit,
					'return'  => 'ids',
					'status'  => 'publish',
					'orderby' => 'rand',
				)
			);
		}

		$random_limit = wp_rand( 0, $limit );

		if ( ! $random_limit ) {
			return array();
		}

		shuffle( self::$product_ids );

		return array_slice( self::$product_ids, 0, min( count( self::$product_ids ), $random_limit ) );
	}
}


================================================
FILE: includes/Generator/Term.php
================================================
<?php
/**
 * Generate taxonomy terms.
 *
 * @package SmoothGenerator\Classes
 */

namespace WC\SmoothGenerator\Generator;

/**
 * Customer data generator.
 */
class Term extends Generator {
	/**
	 * Create a new taxonomy term.
	 *
	 * @param bool   $save     Whether to save the new term to the database.
	 * @param string $taxonomy The taxonomy slug.
	 * @param int    $parent   ID of parent term.
	 *
	 * @return \WP_Error|\WP_Term
	 */
	public static function generate( $save = true, string $taxonomy = 'product_cat', int $parent = 0 ) {
		$taxonomy_obj = get_taxonomy( $taxonomy );
		if ( ! $taxonomy_obj ) {
			return new \WP_Error(
				'smoothgenerator_invalid_taxonomy',
				'The specified taxonomy is invalid.'
			);
		}

		if ( 0 !== $parent && true !== $taxonomy_obj->hierarchical ) {
			return new \WP_Error(
				'smoothgenerator_invalid_term_hierarchy',
				'The specified taxonomy does not support parent terms.'
			);
		}

		parent::maybe_initialize_generators();

		if ( 'product_brand' === $taxonomy ) {
			$term_name = self::$faker->company();
		} elseif ( $taxonomy_obj->hierarchical ) {
			$term_name = ucwords( self::$faker->department( 3 ) );
		} else {
			$term_name = self::random_weighted_element( array(
				self::$faker->lastName()       => 45,
				self::$faker->colorName()      => 35,
				self::$faker->words( 3, true ) => 20,
			) );
			$term_name = strtolower( $term_name );
		}

		$description_size = wp_rand( 20, 260 );

		$term_args = array(
			'description' => self::$faker->realTextBetween( $description_size, $description_size + 40, 4 ),
		);
		if ( 0 !== $parent ) {
			$term_args['parent'] = $parent;
		}

		$result = wp_insert_term( $term_name, $taxonomy, $term_args );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		$term = get_term( $result['term_id'] );

		/**
		 * Action: Term generator returned a new term.
		 *
		 * @since 1.1.0
		 *
		 * @param \WP_Term $term
		 */
		do_action( 'smoothgenerator_term_generated', $term );

		return $term;
	}

	/**
	 * Create multiple terms for a taxonomy.
	 *
	 * @param int    $amount   The number of terms to create.
	 * @param string $taxonomy The taxonomy to assign the terms to.
	 * @param array  $args     Additional args for term creation.
	 *
	 * @return int[]|\WP_Error
	 */
	public static function batch( $amount, $taxonomy, array $args = array() ) {
		$amount = self::validate_batch_amount( $amount );
		if ( is_wp_error( $amount ) ) {
			return $amount;
		}

		$taxonomy_obj = get_taxonomy( $taxonomy );
		if ( ! $taxonomy_obj ) {
			return new \WP_Error(
				'smoothgenerator_term_batch_invalid_taxonomy',
				'The specified taxonomy is invalid.'
			);
		}

		if ( true === $taxonomy_obj->hierarchical ) {
			return self::batch_hierarchical( $amount, $taxonomy, $args );
		}

		$term_ids = array();

		for ( $i = 1; $i <= $amount; $i++ ) {
			$term = self::generate( true, $taxonomy );
			if ( is_wp_error( $term ) ) {
				if ( 'term_exists' === $term->get_error_code() ) {
					--$i; // Try again.
					continue;
				}

				return $term;
			}
			$term_ids[] = $term->term_id;
		}

		return $term_ids;
	}

	/**
	 * Create multiple terms for a hierarchical taxonomy.
	 *
	 * @param int    $amount   The number of terms to create.
	 * @param string $taxonomy The taxonomy to assign the terms to.
	 * @param array  $args     Additional args for term creation.
	 *   @type int $max_depth The maximum level of hierarchy.
	 *   @type int $parent    ID of a term to be the parent of the generated terms.
	 *
	 * @return int[]|\WP_Error
	 */
	protected static function batch_hierarchical( int $amount, string $taxonomy, array $args = array() ) {
		$defaults = array(
			'max-depth' => 1,
			'parent'    => 0,
		);

		list( 'max-depth' => $max_depth, 'parent' => $parent ) = filter_var_array(
			wp_parse_args( $args, $defaults ),
			array(
				'max-depth' => array(
					'filter'  => FILTER_VALIDATE_INT,
					'options' => array(
						'min_range' => 1,
						'max_range' => 5,
					),
				),
				'parent'    => FILTER_VALIDATE_INT,
			)
		);

		if ( false === $max_depth ) {
			return new \WP_Error(
				'smoothgenerator_term_batch_invalid_max_depth',
				'Max depth must be a number between 1 and 5.'
			);
		}
		if ( false === $parent ) {
			return new \WP_Error(
				'smoothgenerator_term_batch_invalid_parent',
				'Parent must be the ID number of an existing term.'
			);
		}

		$term_ids = array();

		self::init_faker();

		if ( $parent || 1 === $max_depth ) {
			// All terms will be in the same hierarchy level.
			for ( $i = 1; $i <= $amount; $i++ ) {
				$term = self::generate( true, $taxonomy, $parent );
				if ( is_wp_error( $term ) ) {
					if ( 'term_exists' === $term->get_error_code() ) {
						--$i; // Try again.
						continue;
					}

					return $term;
				}
				$term_ids[] = $term->term_id;
			}
		} else {
			$remaining = $amount;
			$term_max  = 1;
			if ( $amount > 2 ) {
				$term_max = floor( log( $amount ) );
			}
			$levels = array_fill( 1, $max_depth, array() );

			for ( $i = 1; $i <= $max_depth; $i++ ) {
				if ( 1 === $i ) {
					// Always use the full term max for the top level of the hierarchy.
					for ( $j = 1; $j <= $term_max && $remaining > 0; $j++ ) {
						$term = self::generate( true, $taxonomy );
						if ( is_wp_error( $term ) ) {
							if ( 'term_exists' === $term->get_error_code() ) {
								--$j; // Try again.
								continue;
							}

							return $term;
						}
						$term_ids[]     = $term->term_id;
						$levels[ $i ][] = $term->term_id;
						--$remaining;
					}
				} else {
					// Subsequent hierarchy levels.
					foreach ( $levels[ $i - 1 ] as $term_id ) {
						$tcount = wp_rand( 0, $term_max );

						for ( $j = 1; $j <= $tcount && $remaining > 0; $j++ ) {
							$term = self::generate( true, $taxonomy, $term_id );
							if ( is_wp_error( $term ) ) {
								if ( 'term_exists' === $term->get_error_code() ) {
									--$j; // Try again.
									continue;
								}

								return $term;
							}
							$term_ids[]     = $term->term_id;
							$levels[ $i ][] = $term->term_id;
							--$remaining;
						}
					}
				}
				if ( $i === $max_depth && $remaining > 0 ) {
					// If we haven't generated enough yet, start back at the top level of the hierarchy.
					$i = 0;
				}
			}
		}

		return $term_ids;
	}
}


================================================
FILE: includes/Plugin.php
================================================
<?php
/**
 * Main plugin class.
 *
 * @package SmoothGenerator\Classes
 */

namespace WC\SmoothGenerator;

/**
 * Main plugin class.
 */
class Plugin {

	/**
	 * Constructor.
	 *
	 * @param string $file Main plugin __FILE__ reference.
	 */
	public function __construct( $file ) {
		if ( is_admin() ) {
			Admin\Settings::init();
		}

		if ( class_exists( 'WP_CLI' ) ) {
			$cli = new CLI();
		}
	}
}


================================================
FILE: includes/Router.php
================================================
<?php

namespace WC\SmoothGenerator;

/**
 * Methods to retrieve and use a particular generator class based on its slug.
 */
class Router {
	/**
	 * @const array Associative array of available generator classes.
	 */
	const GENERATORS = array(
		'coupons'   => Generator\Coupon::class,
		'customers' => Generator\Customer::class,
		'orders'    => Generator\Order::class,
		'products'  => Generator\Product::class,
		'terms'     => Generator\Term::class,
	);

	/**
	 * Get the classname of a generator via slug.
	 *
	 * @param string $generator_slug The slug of the generator to retrieve.
	 *
	 * @return string|\WP_Error
	 */
	public static function get_generator_class( string $generator_slug ) {
		if ( ! isset( self::GENERATORS[ $generator_slug ] ) ) {
			return new \WP_Error(
				'smoothgenerator_invalid_generator',
				sprintf(
					'A generator class for "%s" can\'t be found.',
					$generator_slug
				)
			);
		}

		return self::GENERATORS[ $generator_slug ];
	}

	/**
	 * Generate a batch of objects using the specified generator.
	 *
	 * @param string $generator_slug The slug identifier of the generator to use.
	 * @param int    $amount         The number of objects to generate.
	 * @param array  $args           Additional args for object generation.
	 *
	 * @return int[]|\WP_Error
	 */
	public static function generate_batch( string $generator_slug, int $amount, array $args = array() ) {
		$generator = self::get_generator_class( $generator_slug );

		if ( is_wp_error( $generator ) ) {
			return $generator;
		}

		return $generator::batch( $amount, $args );
	}
}


================================================
FILE: includes/Util/RandomRuntimeCache.php
================================================
<?php
/**
 * A runtime object cache for storing and randomly retrieving reusable data.
 *
 * @package SmoothGenerator\Util
 */

namespace WC\SmoothGenerator\Util;

/**
 * Class RandomRuntimeCache.
 */
class RandomRuntimeCache {
	/**
	 * Associative array for storing groups of cache items.
	 *
	 * @var array
	 */
	private static $cache = array();

	/**
	 * Check if a specific cache group exists.
	 *
	 * @param string $group The specified cache group.
	 *
	 * @return bool
	 */
	public static function exists( string $group ): bool {
		return array_key_exists( $group, self::$cache );
	}

	/**
	 * Get a number of items from a specific cache group.
	 *
	 * The retrieved items will be from the top of the group's array.
	 *
	 * @param string $group The specified cache group.
	 * @param int    $limit Optional. Get up to this many items. Using 0 will return all the items in the group.
	 *                      Default 0.
	 *
	 * @return array
	 */
	public static function get( string $group, int $limit = 0 ): array {
		$all_items = self::get_group( $group );

		if ( $limit <= 0 || count( $all_items ) <= $limit ) {
			return $all_items;
		}

		$items = array_slice( $all_items, 0, $limit );

		return $items;
	}

	/**
	 * Remove a number of items from a specific cache group and return them.
	 *
	 * The items will be extracted from the top of the group's array.
	 *
	 * @param string $group The specified cache group.
	 * @param int    $limit Optional. Extract up to this many items. Using 0 will return all the items in the group and
	 *                      delete it from the cache. Default 0.
	 *
	 * @return array
	 */
	public static function extract( string $group, int $limit = 0 ): array {
		$all_items = self::get_group( $group );

		if ( $limit <= 0 || count( $all_items ) <= $limit ) {
			self::clear( $group );

			return $all_items;
		}

		$items           = array_slice( $all_items, 0, $limit );
		$remaining_items = array_slice( $all_items, $limit );

		self::set( $group, $remaining_items );

		return $items;
	}

	/**
	 * Add items to a specific cache group.
	 *
	 * @param string $group The specified cache group.
	 * @param array  $items The items to add to the group.
	 *
	 * @return void
	 */
	public static function add( string $group, array $items ): void {
		$existing_items = self::get_group( $group );

		self::set( $group, array_merge( $existing_items, $items ) );
	}

	/**
	 * Set a cache group to contain a specific set of items.
	 *
	 * @param string $group The specified cache group.
	 * @param array  $items The items that will be in the group.
	 *
	 * @return void
	 */
	public static function set( string $group, array $items ): void {
		self::$cache[ $group ] = $items;
	}

	/**
	 * Count the number of items in a specific cache group.
	 *
	 * @param string $group The specified cache group.
	 *
	 * @return int
	 */
	public static function count( string $group ): int {
		$group = self::get_group( $group );

		return count( $group );
	}

	/**
	 * Shuffle the order of the items in a specific cache group.
	 *
	 * @param string $group The specified cache group.
	 *
	 * @return void
	 */
	public static function shuffle( string $group ): void {
		// Ensure group exists.
		self::get_group( $group );

		shuffle( self::$cache[ $group ] );
	}

	/**
	 * Delete a group from the cache.
	 *
	 * @param string $group The specified cache group.
	 *
	 * @return void
	 */
	public static function clear( string $group ): void {
		unset( self::$cache[ $group ] );
	}

	/**
	 * Clear the entire cache.
	 *
	 * @return void
	 */
	public static function reset(): void {
		self::$cache = array();
	}

	/**
	 * Get the items in a cache group, ensuring that the group exists in the cache.
	 *
	 * @param string $group The specified cache group.
	 *
	 * @return array
	 */
	private static function get_group( string $group ): array {
		if ( ! self::exists( $group ) ) {
			self::set( $group, array() );
		}

		return self::$cache[ $group ];
	}
}


================================================
FILE: package.json
================================================
{
  "name": "wc-smooth-generator",
  "title": "WooCommerce Smooth Generator",
  "version": "1.3.0",
  "homepage": "https://github.com/woocommerce/wc-smooth-generator",
  "repository": {
    "type": "git",
    "url": "https://github.com/woocommerce/wc-smooth-generator.git"
  },
  "license": "GPL-3.0+",
  "scripts": {
    "setup": "npm install && composer install && husky install",
    "build": "composer install --no-dev && npm install --omit=dev && composer archive --file=$npm_package_name --format=zip && rm -rf build && mkdir -p build/$npm_package_name && unzip -q $npm_package_name.zip -d build/$npm_package_name && rm $npm_package_name.zip && cd build && zip -qr ../$npm_package_name.zip $npm_package_name && cd .. && rm -rf build",
    "phpcs": "composer run phpcs",
    "lint": "composer run lint",
    "lint:staged": "composer run lint-staged",
    "lint:branch": "composer run lint-branch"
  },
  "devDependencies": {
    "husky": "^8.0.0"
  },
  "engines": {
    "node": ">=22",
    "npm": ">=6.4.1"
  }
}


================================================
FILE: phpcs.xml.dist
================================================
<?xml version="1.0"?>
<ruleset name="WordPress Coding Standards">
	<!-- See https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-ruleset.xml -->
	<!-- See https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards/blob/develop/WordPress-Core/ruleset.xml -->

	<description>WooCommerce dev PHP_CodeSniffer ruleset.</description>

	<file>.</file>

	<!-- Exclude paths -->
	<exclude-pattern>*/vendor/*</exclude-pattern>
	<exclude-pattern>*/node_modules/*</exclude-pattern>

	<!-- Show progress, show the error codes for each message (source). -->
	<arg value="ps"/>

	<!-- Strip the filepaths down to the relevant bit. -->
	<arg name="basepath" value="./"/>

	<!-- Check up to 8 files simultaneously. -->
	<arg name="parallel" value="8"/>

	<!-- Configs -->
	<config name="minimum_supported_wp_version" value="5.0"/>
	<config name="testVersion" value="7.1-"/>

	<!-- Rules -->
	<rule ref="WooCommerce-Core">
		<exclude name="WordPress.Files.FileName"/>
		<exclude name="WordPress.NamingConventions.ValidVariableName"/>
		<exclude name="WordPress.DateTime.RestrictedFunctions.date_date"/>

		<exclude name="PEAR.Functions.FunctionCallSignature.CloseBracketLine"/>
		<exclude name="PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket"/>
		<exclude name="PEAR.Functions.FunctionCallSignature.MultipleArguments"/>
	</rule>
	<rule ref="WordPress.WP.I18n">
		<properties>
			<property name="text_domain" type="array" value="wc-smooth-generator"/>
		</properties>
	</rule>
</ruleset>


================================================
FILE: phpunit.xml.dist
================================================
<?xml version="1.0" encoding="utf-8"?>
<phpunit
		bootstrap="tests/bootstrap.php"
		backupGlobals="false"
		colors="true"
		convertErrorsToExceptions="true"
		convertNoticesToExceptions="true"
		convertWarningsToExceptions="true"
		defaultTestSuite="unit"
>
	<testsuites>
		<testsuite name="unit">
			<directory suffix="Test.php">./tests/Unit</directory>
		</testsuite>
	</testsuites>
	<coverage>
		<include>
			<directory suffix=".php">./includes</directory>
		</include>
		<exclude>
			<directory suffix=".php">./includes/CLI.php</directory>
		</exclude>
	</coverage>
</phpunit>


================================================
FILE: tests/README.md
================================================
# WC Smooth Generator - Test Suite

Comprehensive unit test suite for the WooCommerce Smooth Generator plugin, targeting 90% code coverage.

## Setup

### Prerequisites

1. Install PHP 7.4 or higher
2. Install Composer
3. Install WordPress test library
4. Install WooCommerce

### Installing WordPress Test Library

Run the WordPress test installation script:

```bash
bash bin/install-wp-tests.sh wordpress_test root '' localhost latest
```

Replace the arguments as needed:
- `wordpress_test` - Database name for tests
- `root` - Database username
- Empty string - Database password (or your password)
- `localhost` - Database host
- `latest` - WordPress version to install (or specific version like `6.4`)

### Environment Variables

Set these environment variables before running tests:

```bash
export WP_TESTS_DIR=/path/to/wordpress-tests-lib
export WP_CORE_DIR=/path/to/wordpress
export WC_DIR=/path/to/woocommerce
```

Or create them temporarily:

```bash
WP_TESTS_DIR=/tmp/wordpress-tests-lib \
WP_CORE_DIR=/tmp/wordpress \
WC_DIR=/path/to/woocommerce \
vendor/bin/phpunit
```

## Running Tests

### Run All Tests

```bash
composer test-unit
```

Or directly:

```bash
vendor/bin/phpunit
```

### Run Specific Test File

```bash
vendor/bin/phpunit tests/Unit/Generator/ProductTest.php
```

### Run Specific Test

```bash
vendor/bin/phpunit --filter test_generate_simple_product
```

### Run with Coverage Report

```bash
vendor/bin/phpunit --coverage-html coverage
```

Then open `coverage/index.html` in your browser.

### Run Tests by Group

Tests can be organized with `@group` annotations:

```bash
vendor/bin/phpunit --group generator
vendor/bin/phpunit --group slow --exclude-group slow
```

## Test Structure

```
tests/
├── bootstrap.php              # Test environment setup
├── README.md                  # This file
└── Unit/
    ├── Generator/             # Generator class tests
    │   ├── GeneratorTest.php  # Base generator tests
    │   ├── ProductTest.php    # Product generation tests
    │   ├── OrderTest.php      # Order generation tests
    │   ├── CustomerTest.php   # Customer generation tests
    │   └── CouponTest.php     # Coupon generation tests
    ├── Util/                  # Utility class tests
    │   └── RandomRuntimeCacheTest.php
    └── PluginTest.php         # Main plugin tests
```

## Test Coverage Goals

- **Overall**: ~90% code coverage
- **Generator Classes**: 85-95% coverage (core functionality)
- **Utility Classes**: 95%+ coverage (simpler logic)
- **Admin Classes**: 70-80% coverage (UI-heavy)

## Writing Tests

### Test File Template

```php
<?php
namespace WC\SmoothGenerator\Tests\Generator;

use WC\SmoothGenerator\Generator\YourClass;
use WP_UnitTestCase;

class YourClassTest extends WP_UnitTestCase {

    public function setUp(): void {
        parent::setUp();
        // Setup code
    }

    public function tearDown(): void {
        // Cleanup code
        parent::tearDown();
    }

    public function test_your_functionality() {
        // Arrange
        $expected = 'value';

        // Act
        $result = YourClass::method();

        // Assert
        $this->assertEquals( $expected, $result );
    }
}
```

### Best Practices

1. **Use descriptive test names**: `test_generate_simple_product_with_sale_price()`
2. **Follow AAA pattern**: Arrange, Act, Assert
3. **One assertion per test** (when practical)
4. **Clean up after tests**: Use `tearDown()` to reset state
5. **Use data providers** for parameterized tests
6. **Mock external dependencies** when appropriate

### Available Assertions

PHPUnit provides many assertions. Common ones:

- `$this->assertEquals( $expected, $actual )`
- `$this->assertTrue( $condition )`
- `$this->assertInstanceOf( ClassName::class, $object )`
- `$this->assertNotEmpty( $value )`
- `$this->assertWPError( $result )`
- `$this->assertGreaterThan( $threshold, $value )`

See: https://docs.phpunit.de/en/9.6/assertions.html

## Continuous Integration

Tests should be run on:
- Every pull request
- Before merging to main branch
- Nightly builds

## Troubleshooting

### "Class not found" errors

Regenerate the autoloader:

```bash
composer dump-autoload
```

### Database errors

Ensure your test database is created and accessible:

```bash
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS wordpress_test;"
```

### "Call to undefined function" for WC functions

Ensure WooCommerce path is set correctly:

```bash
export WC_DIR=/path/to/woocommerce
```

### Permission errors on temp directories

```bash
chmod -R 777 /tmp/wordpress-tests-lib
```

## Resources

- [PHPUnit Documentation](https://docs.phpunit.de/)
- [WordPress Plugin Handbook - Unit Tests](https://developer.wordpress.org/plugins/testing/automated-testing/)
- [WooCommerce Testing Guide](https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment)


================================================
FILE: tests/Unit/Generator/CouponTest.php
================================================
<?php
/**
 * Tests for Coupon Generator.
 *
 * @package WC\SmoothGenerator\Tests\Generator
 */

namespace WC\SmoothGenerator\Tests\Generator;

use WC\SmoothGenerator\Generator\Coupon;
use WP_UnitTestCase;

/**
 * Coupon Generator test case.
 */
class CouponTest extends WP_UnitTestCase {

	/**
	 * Test generating a coupon.
	 */
	public function test_generate_coupon() {
		$coupon = Coupon::generate( true );

		$this->assertInstanceOf( \WC_Coupon::class, $coupon );
		$this->assertTrue( $coupon->get_id() > 0 );
		$this->assertNotEmpty( $coupon->get_code() );
	}

	/**
	 * Test coupon has amount.
	 */
	public function test_coupon_has_amount() {
		$coupon = Coupon::generate( true );

		$amount = $coupon->get_amount();
		$this->assertGreaterThan( 0, $amount );
	}

	/**
	 * Test coupon with custom min and max.
	 */
	public function test_coupon_custom_min_max() {
		$coupon = Coupon::generate(
			true,
			array(
				'min' => 10,
				'max' => 20,
			)
		);

		$amount = $coupon->get_amount();
		$this->assertGreaterThanOrEqual( 10, $amount );
		$this->assertLessThanOrEqual( 20, $amount );
	}

	/**
	 * Test coupon with fixed_cart discount type.
	 */
	public function test_coupon_fixed_cart_type() {
		$coupon = Coupon::generate(
			true,
			array(
				'discount_type' => 'fixed_cart',
			)
		);

		$this->assertEquals( 'fixed_cart', $coupon->get_discount_type() );
	}

	/**
	 * Test coupon with percent discount type.
	 */
	public function test_coupon_percent_type() {
		$coupon = Coupon::generate(
			true,
			array(
				'discount_type' => 'percent',
			)
		);

		$this->assertEquals( 'percent', $coupon->get_discount_type() );
	}

	/**
	 * Test batch coupon generation.
	 */
	public function test_batch_generation() {
		$amount     = 5;
		$coupon_ids = Coupon::batch( $amount );

		$this->assertIsArray( $coupon_ids );
		$this->assertCount( $amount, $coupon_ids );

		foreach ( $coupon_ids as $coupon_id ) {
			$coupon = new \WC_Coupon( $coupon_id );
			$this->assertTrue( $coupon->get_id() > 0 );
		}
	}

	/**
	 * Test batch validation.
	 */
	public function test_batch_validation() {
		$result = Coupon::batch( 0 );

		$this->assertWPError( $result );
	}

	/**
	 * Test invalid min value returns error.
	 */
	public function test_invalid_min_value() {
		$coupon = Coupon::generate(
			true,
			array(
				'min' => -5,
			)
		);

		$this->assertWPError( $coupon );
	}

	/**
	 * Test invalid max value returns error.
	 */
	public function test_invalid_max_value() {
		$coupon = Coupon::generate(
			true,
			array(
				'max' => 0,
			)
		);

		$this->assertWPError( $coupon );
	}

	/**
	 * Test min greater than max returns error.
	 */
	public function test_min_greater_than_max() {
		$coupon = Coupon::generate(
			true,
			array(
				'min' => 50,
				'max' => 10,
			)
		);

		$this->assertWPError( $coupon );
	}

	/**
	 * Test invalid discount type returns error.
	 */
	public function test_invalid_discount_type() {
		$coupon = Coupon::generate(
			true,
			array(
				'discount_type' => 'invalid_type',
			)
		);

		$this->assertWPError( $coupon );
	}

	/**
	 * Test get_random returns false when no coupons exist.
	 */
	public function test_get_random_no_coupons() {
		$coupon = Coupon::get_random();

		$this->assertFalse( $coupon );
	}

	/**
	 * Test get_random returns coupon when coupons exist.
	 */
	public function test_get_random_with_coupons() {
		// Create some coupons.
		Coupon::batch( 3 );

		$coupon = Coupon::get_random();

		$this->assertInstanceOf( \WC_Coupon::class, $coupon );
		$this->assertTrue( $coupon->get_id() > 0 );
	}

	/**
	 * Test coupon action hook is fired.
	 */
	public function test_coupon_generated_action_hook() {
		$hook_fired = false;
		$generated_coupon = null;

		add_action(
			'smoothgenerator_coupon_generated',
			function ( $coupon ) use ( &$hook_fired, &$generated_coupon ) {
				$hook_fired = true;
				$generated_coupon = $coupon;
			}
		);

		$coupon = Coupon::generate( true );

		$this->assertTrue( $hook_fired, 'smoothgenerator_coupon_generated action should fire' );
		$this->assertInstanceOf( \WC_Coupon::class, $generated_coupon );
	}

	/**
	 * Test coupon code format.
	 */
	public function test_coupon_code_format() {
		$coupon = Coupon::generate( true );

		$code = $coupon->get_code();
		$this->assertNotEmpty( $code );
		// Code should end with numbers (the amount).
		$this->assertMatchesRegularExpression( '/\d+$/', $code );
	}
}


================================================
FILE: tests/Unit/Generator/CustomerTest.php
================================================
<?php
/**
 * Tests for Customer Generator.
 *
 * @package WC\SmoothGenerator\Tests\Generator
 */

namespace WC\SmoothGenerator\Tests\Generator;

use WC\SmoothGenerator\Generator\Customer;
use WP_UnitTestCase;

/**
 * Customer Generator test case.
 */
class CustomerTest extends WP_UnitTestCase {

	/**
	 * Test generating a customer.
	 */
	public function test_generate_customer() {
		$customer = Customer::generate( true );

		$this->assertInstanceOf( \WC_Customer::class, $customer );
		$this->assertTrue( $customer->get_id() > 0 );
	}

	/**
	 * Test customer has billing information.
	 */
	public function test_customer_has_billing_info() {
		$customer = Customer::generate( true, array( 'type' => 'person', 'country' => 'US' ) );

		$this->assertNotEmpty( $customer->get_billing_first_name(), 'Billing first name should not be empty' );
		$this->assertNotEmpty( $customer->get_billing_last_name(), 'Billing last name should not be empty' );
		$this->assertNotEmpty( $customer->get_billing_email(), 'Billing email should not be empty' );
		$this->assertNotEmpty( $customer->get_billing_city(), 'Billing city should not be empty' );
		$this->assertNotEmpty( $customer->get_billing_country(), 'Billing country should not be empty' );

		// Address line 1 may be empty in some locales, so just check it's a string.
		$this->assertIsString( $customer->get_billing_address_1() );
	}

	/**
	 * Test customer email is valid.
	 */
	public function test_customer_email_valid() {
		$customer = Customer::generate( true );

		$email = $customer->get_billing_email();
		$this->assertNotFalse( filter_var( $email, FILTER_VALIDATE_EMAIL ) );
	}

	/**
	 * Test customer with specific country.
	 */
	public fu
Download .txt
gitextract_4kf8m5xx/

├── .editorconfig
├── .github/
│   ├── CONTRIBUTING.md
│   ├── ISSUE_TEMPLATE/
│   │   ├── 1-bug-report.yml
│   │   ├── 2-enhancement.yml
│   │   └── config.yml
│   ├── ISSUE_TEMPLATE.md
│   ├── PULL_REQUEST_TEMPLATE.md
│   └── workflows/
│       └── php-unit-tests.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .nvmrc
├── README.md
├── TESTING.md
├── bin/
│   ├── install-wp-tests.sh
│   └── lint-branch.sh
├── changelog.txt
├── composer.json
├── includes/
│   ├── Admin/
│   │   ├── AsyncJob.php
│   │   ├── BatchProcessor.php
│   │   └── Settings.php
│   ├── CLI.php
│   ├── Generator/
│   │   ├── Coupon.php
│   │   ├── Customer.php
│   │   ├── CustomerInfo.php
│   │   ├── Generator.php
│   │   ├── Order.php
│   │   ├── OrderAttribution.php
│   │   ├── Product.php
│   │   └── Term.php
│   ├── Plugin.php
│   ├── Router.php
│   └── Util/
│       └── RandomRuntimeCache.php
├── package.json
├── phpcs.xml.dist
├── phpunit.xml.dist
├── tests/
│   ├── README.md
│   ├── Unit/
│   │   ├── Generator/
│   │   │   ├── CouponTest.php
│   │   │   ├── CustomerTest.php
│   │   │   ├── GeneratorTest.php
│   │   │   ├── OrderTest.php
│   │   │   └── ProductTest.php
│   │   ├── PluginTest.php
│   │   └── Util/
│   │       └── RandomRuntimeCacheTest.php
│   └── bootstrap.php
└── wc-smooth-generator.php
Download .txt
SYMBOL INDEX (244 symbols across 24 files)

FILE: includes/Admin/AsyncJob.php
  class AsyncJob (line 10) | class AsyncJob {
    method __construct (line 51) | public function __construct( array $data = array() ) {

FILE: includes/Admin/BatchProcessor.php
  class BatchProcessor (line 14) | class BatchProcessor implements BatchProcessorInterface {
    method get_current_job (line 25) | public static function get_current_job() {
    method create_new_job (line 47) | public static function create_new_job( string $generator_slug, int $am...
    method update_current_job (line 76) | public static function update_current_job( int $processed ) {
    method delete_current_job (line 99) | public static function delete_current_job() {
    method get_name (line 109) | public function get_name(): string {
    method get_description (line 118) | public function get_description(): string {
    method get_total_pending_count (line 131) | public function get_total_pending_count(): int {
    method get_next_batch_to_process (line 157) | public function get_next_batch_to_process( int $size ): array {
    method process_batch (line 194) | public function process_batch( array $batch ): void {
    method get_default_batch_size (line 213) | public function get_default_batch_size(): int {

FILE: includes/Admin/Settings.php
  class Settings (line 13) | class Settings {
    method init (line 21) | public static function init() {
    method register_admin_menu (line 29) | public static function register_admin_menu() {
    method render_admin_page (line 44) | public static function render_admin_page() {
    method date_range_toggle_script (line 190) | protected static function date_range_toggle_script() {
    method heartbeat_script (line 207) | protected static function heartbeat_script() {
    method receive_heartbeat (line 265) | public static function receive_heartbeat( array $response, array $data...
    method process_page_submit (line 285) | public static function process_page_submit() {
    method get_current_job (line 312) | protected static function get_current_job() {
    method while_you_wait (line 321) | protected static function while_you_wait() {

FILE: includes/CLI.php
  class CLI (line 15) | class CLI extends WP_CLI_Command {
    method products (line 22) | public static function products( $args, $assoc_args ) {
    method orders (line 73) | public static function orders( $args, $assoc_args ) {
    method customers (line 131) | public static function customers( $args, $assoc_args ) {
    method coupons (line 177) | public static function coupons( $args, $assoc_args ) {
    method terms (line 223) | public static function terms( $args, $assoc_args ) {

FILE: includes/Generator/Coupon.php
  class Coupon (line 15) | class Coupon extends Generator {
    method generate (line 24) | public static function generate( $save = true, $assoc_args = array() ) {
    method batch (line 129) | public static function batch( $amount, array $args = array() ) {
    method get_random (line 153) | public static function get_random() {

FILE: includes/Generator/Customer.php
  class Customer (line 13) | class Customer extends Generator {
    method generate (line 22) | public static function generate( $save = true, array $assoc_args = arr...
    method batch (line 138) | public static function batch( $amount, array $args = array() ) {

FILE: includes/Generator/CustomerInfo.php
  class CustomerInfo (line 10) | class CustomerInfo {
    method get_valid_country_code (line 18) | public static function get_valid_country_code( ?string $country_code =...
    method get_country_locale_info (line 44) | protected static function get_country_locale_info( string $country_cod...
    method get_faker (line 62) | protected static function get_faker( $country_code = 'en_US' ) {
    method get_provider_instance (line 79) | protected static function get_provider_instance( \Faker\Generator $fak...
    method generate_person (line 101) | public static function generate_person( string $country_code = '' ) {
    method generate_company (line 169) | public static function generate_company( string $country_code = '' ) {
    method generate_address (line 238) | public static function generate_address( string $country_code = '' ) {

FILE: includes/Generator/Generator.php
  class Generator (line 13) | abstract class Generator {
    method generate (line 60) | abstract public static function generate( $save = true );
    method maybe_initialize_generators (line 80) | protected static function maybe_initialize_generators() {
    method init_faker (line 97) | protected static function init_faker() {
    method disable_emails (line 112) | public static function disable_emails() {
    method validate_batch_amount (line 158) | protected static function validate_batch_amount( $amount ) {
    method generate_term_ids (line 194) | protected static function generate_term_ids( $limit, $taxonomy, $name ...
    method seed_images (line 255) | public static function seed_images( $amount = 10 ) {
    method get_image (line 278) | protected static function get_image() {
    method generate_image (line 291) | protected static function generate_image( $seed = '' ) {
    method random_weighted_element (line 342) | protected static function random_weighted_element( array $weighted_val...

FILE: includes/Generator/Order.php
  class Order (line 13) | class Order extends Generator {
    method generate (line 64) | public static function generate( $save = true, $assoc_args = array(), ...
    method batch (line 274) | public static function batch( $amount, array $args = array() ) {
    method get_customer (line 390) | public static function get_customer() {
    method get_date_created (line 421) | protected static function get_date_created( $assoc_args ) {
    method get_status (line 455) | private static function get_status( $assoc_args ) {
    method get_random_products (line 475) | protected static function get_random_products( int $min_amount = 1, in...
    method get_or_create_coupon (line 543) | protected static function get_or_create_coupon() {
    method create_refund (line 588) | protected static function create_refund( $order, $force_partial = fals...
    method calculate_refunded_quantities (line 725) | protected static function calculate_refunded_quantities( $existing_ref...
    method build_refund_line_item (line 752) | protected static function build_refund_line_item( $item, $refund_qty, ...
    method build_full_refund_items (line 782) | protected static function build_full_refund_items( $order, $refunded_q...
    method build_partial_refund_items (line 808) | protected static function build_partial_refund_items( $order, $refunde...
    method calculate_refund_totals (line 891) | protected static function calculate_refund_totals( $line_items ) {
    method calculate_refund_date (line 925) | protected static function calculate_refund_date( $order, $previous_ref...
    method generate_batch_dates (line 969) | protected static function generate_batch_dates( $count, $args ) {

FILE: includes/Generator/OrderAttribution.php
  class OrderAttribution (line 13) | class OrderAttribution {
    method add_order_attribution_meta (line 26) | public static function add_order_attribution_meta( $order, $assoc_args...
    method get_referrer (line 87) | public static function get_referrer( string $source_type ) {
    method get_random_utm_medium (line 127) | public static function get_random_utm_medium() {
    method get_origin (line 148) | public static function get_origin( string $source_type, string $source...
    method get_source_type (line 173) | public static function get_source_type() {
    method get_source (line 193) | public static function get_source( $source_type ) {
    method get_random_device_type (line 239) | public static function get_random_device_type() {
    method get_random_user_agent_for_device (line 257) | public static function get_random_user_agent_for_device( $device_type ) {
    method get_random_mobile_user_agent (line 275) | public static function get_random_mobile_user_agent() {
    method get_random_tablet_user_agent (line 291) | public static function get_random_tablet_user_agent() {
    method get_random_desktop_user_agent (line 305) | public static function get_random_desktop_user_agent() {
    method get_random_session_start_time (line 321) | public static function get_random_session_start_time( $order ) {
    method get_campaign_data (line 340) | private static function get_campaign_data() {
    method get_campaign_type (line 360) | private static function get_campaign_type() {
    method get_seasonal_campaign_data (line 379) | private static function get_seasonal_campaign_data() {
    method get_promotional_campaign_data (line 410) | private static function get_promotional_campaign_data() {
    method get_product_campaign_data (line 437) | private static function get_product_campaign_data() {
    method get_general_campaign_data (line 464) | private static function get_general_campaign_data() {

FILE: includes/Generator/Product.php
  class Product (line 15) | class Product extends Generator {
    method generate (line 76) | public static function generate( $save = true, $assoc_args = array() ) {
    method batch (line 138) | public static function batch( $amount, array $args = array() ) {
    method create_global_attribute (line 176) | protected static function create_global_attribute( $raw_name ) {
    method generate_attributes (line 216) | protected static function generate_attributes( $qty = 1, $maximum_term...
    method get_product_type (line 298) | protected static function get_product_type( array $assoc_args ) {
    method generate_variable_product (line 320) | protected static function generate_variable_product() {
    method generate_simple_product (line 410) | protected static function generate_simple_product() {
    method maybe_generate_terms (line 481) | protected static function maybe_generate_terms( int $product_amount ):...
    method get_term_ids (line 526) | protected static function get_term_ids( string $taxonomy, int $limit )...
    method maybe_get_gallery_image_ids (line 563) | protected static function maybe_get_gallery_image_ids() {
    method get_existing_product_ids (line 587) | protected static function get_existing_product_ids( $limit = 5 ) {

FILE: includes/Generator/Term.php
  class Term (line 13) | class Term extends Generator {
    method generate (line 23) | public static function generate( $save = true, string $taxonomy = 'pro...
    method batch (line 92) | public static function batch( $amount, $taxonomy, array $args = array(...
    method batch_hierarchical (line 139) | protected static function batch_hierarchical( int $amount, string $tax...

FILE: includes/Plugin.php
  class Plugin (line 13) | class Plugin {
    method __construct (line 20) | public function __construct( $file ) {

FILE: includes/Router.php
  class Router (line 8) | class Router {
    method get_generator_class (line 27) | public static function get_generator_class( string $generator_slug ) {
    method generate_batch (line 50) | public static function generate_batch( string $generator_slug, int $am...

FILE: includes/Util/RandomRuntimeCache.php
  class RandomRuntimeCache (line 13) | class RandomRuntimeCache {
    method exists (line 28) | public static function exists( string $group ): bool {
    method get (line 43) | public static function get( string $group, int $limit = 0 ): array {
    method extract (line 66) | public static function extract( string $group, int $limit = 0 ): array {
    method add (line 91) | public static function add( string $group, array $items ): void {
    method set (line 105) | public static function set( string $group, array $items ): void {
    method count (line 116) | public static function count( string $group ): int {
    method shuffle (line 129) | public static function shuffle( string $group ): void {
    method clear (line 143) | public static function clear( string $group ): void {
    method reset (line 152) | public static function reset(): void {
    method get_group (line 163) | private static function get_group( string $group ): array {

FILE: tests/Unit/Generator/CouponTest.php
  class CouponTest (line 16) | class CouponTest extends WP_UnitTestCase {
    method test_generate_coupon (line 21) | public function test_generate_coupon() {
    method test_coupon_has_amount (line 32) | public function test_coupon_has_amount() {
    method test_coupon_custom_min_max (line 42) | public function test_coupon_custom_min_max() {
    method test_coupon_fixed_cart_type (line 59) | public function test_coupon_fixed_cart_type() {
    method test_coupon_percent_type (line 73) | public function test_coupon_percent_type() {
    method test_batch_generation (line 87) | public function test_batch_generation() {
    method test_batch_validation (line 103) | public function test_batch_validation() {
    method test_invalid_min_value (line 112) | public function test_invalid_min_value() {
    method test_invalid_max_value (line 126) | public function test_invalid_max_value() {
    method test_min_greater_than_max (line 140) | public function test_min_greater_than_max() {
    method test_invalid_discount_type (line 155) | public function test_invalid_discount_type() {
    method test_get_random_no_coupons (line 169) | public function test_get_random_no_coupons() {
    method test_get_random_with_coupons (line 178) | public function test_get_random_with_coupons() {
    method test_coupon_generated_action_hook (line 191) | public function test_coupon_generated_action_hook() {
    method test_coupon_code_format (line 212) | public function test_coupon_code_format() {

FILE: tests/Unit/Generator/CustomerTest.php
  class CustomerTest (line 16) | class CustomerTest extends WP_UnitTestCase {
    method test_generate_customer (line 21) | public function test_generate_customer() {
    method test_customer_has_billing_info (line 31) | public function test_customer_has_billing_info() {
    method test_customer_email_valid (line 47) | public function test_customer_email_valid() {
    method test_customer_with_specific_country (line 57) | public function test_customer_with_specific_country() {
    method test_customer_type_person (line 66) | public function test_customer_type_person() {
    method test_customer_type_company (line 76) | public function test_customer_type_company() {
    method test_batch_generation (line 85) | public function test_batch_generation() {
    method test_batch_validation (line 101) | public function test_batch_validation() {
    method test_customer_generated_action_hook (line 110) | public function test_customer_generated_action_hook() {
    method test_customer_with_invalid_country (line 131) | public function test_customer_with_invalid_country() {
    method test_customer_has_phone (line 140) | public function test_customer_has_phone() {
    method test_customer_role (line 150) | public function test_customer_role() {

FILE: tests/Unit/Generator/GeneratorTest.php
  class GeneratorTest (line 16) | class GeneratorTest extends WP_UnitTestCase {
    method test_max_batch_size_constant (line 21) | public function test_max_batch_size_constant() {
    method test_image_size_constant (line 28) | public function test_image_size_constant() {
    method test_batch_validation_max_size (line 35) | public function test_batch_validation_max_size() {
    method test_emails_disabled (line 44) | public function test_emails_disabled() {
    method test_queued_emails_blocked (line 56) | public function test_queued_emails_blocked() {

FILE: tests/Unit/Generator/OrderTest.php
  class OrderTest (line 18) | class OrderTest extends WP_UnitTestCase {
    method setUp (line 23) | public function setUp(): void {
    method test_generate_order (line 33) | public function test_generate_order() {
    method test_order_has_products (line 44) | public function test_order_has_products() {
    method test_order_completed_status (line 59) | public function test_order_completed_status() {
    method test_order_processing_status (line 70) | public function test_order_processing_status() {
    method test_order_failed_status (line 80) | public function test_order_failed_status() {
    method test_order_on_hold_status (line 89) | public function test_order_on_hold_status() {
    method test_order_has_customer_info (line 98) | public function test_order_has_customer_info() {
    method test_order_has_shipping_info (line 122) | public function test_order_has_shipping_info() {
    method test_order_total_calculated (line 138) | public function test_order_total_calculated() {
    method test_batch_generation (line 148) | public function test_batch_generation() {
    method test_batch_validation (line 164) | public function test_batch_validation() {
    method test_order_with_date (line 174) | public function test_order_with_date() {
    method test_order_with_date_range (line 185) | public function test_order_with_date_range() {
    method test_order_with_coupon_ratio (line 205) | public function test_order_with_coupon_ratio() {
    method test_order_generated_action_hook (line 227) | public function test_order_generated_action_hook() {
    method test_completed_order_dates_sequential (line 248) | public function test_completed_order_dates_sequential() {
    method test_order_with_refund_ratio (line 262) | public function test_order_with_refund_ratio() {
    method test_full_refund_status (line 278) | public function test_full_refund_status() {
    method test_partial_refund_status (line 297) | public function test_partial_refund_status() {
    method test_batch_exact_coupon_distribution (line 329) | public function test_batch_exact_coupon_distribution() {
    method test_batch_exact_refund_distribution (line 363) | public function test_batch_exact_refund_distribution() {
    method test_refund_has_line_items (line 391) | public function test_refund_has_line_items() {
    method test_refund_amount_valid (line 411) | public function test_refund_amount_valid() {
    method test_batch_orders_chronological_dates (line 431) | public function test_batch_orders_chronological_dates() {
    method test_order_with_fees (line 461) | public function test_order_with_fees() {
    method test_order_has_currency (line 482) | public function test_order_has_currency() {
    method test_refund_dates_after_completion (line 492) | public function test_refund_dates_after_completion() {

FILE: tests/Unit/Generator/ProductTest.php
  class ProductTest (line 16) | class ProductTest extends WP_UnitTestCase {
    method test_generate_simple_product (line 21) | public function test_generate_simple_product() {
    method test_generate_variable_product (line 35) | public function test_generate_variable_product() {
    method test_variable_product_has_attributes (line 67) | public function test_variable_product_has_attributes() {
    method test_variations_have_prices (line 99) | public function test_variations_have_prices() {
    method test_batch_generation (line 130) | public function test_batch_generation() {
    method test_batch_validation_invalid_amount (line 147) | public function test_batch_validation_invalid_amount() {
    method test_batch_validation_amount_too_large (line 157) | public function test_batch_validation_amount_too_large() {
    method test_products_have_categories (line 167) | public function test_products_have_categories() {
    method test_products_have_tags (line 178) | public function test_products_have_tags() {
    method test_products_have_images (line 189) | public function test_products_have_images() {
    method test_product_sale_price (line 205) | public function test_product_sale_price() {
    method test_product_stock_management (line 223) | public function test_product_stock_management() {
    method test_product_upsells (line 238) | public function test_product_upsells() {
    method test_product_cross_sells (line 251) | public function test_product_cross_sells() {
    method test_product_tax_status (line 264) | public function test_product_tax_status() {
    method test_simple_product_dimensions (line 274) | public function test_simple_product_dimensions() {
    method test_virtual_product_no_dimensions (line 295) | public function test_virtual_product_no_dimensions() {
    method test_product_reviews_allowed (line 315) | public function test_product_reviews_allowed() {
    method test_product_backorders (line 324) | public function test_product_backorders() {
    method test_product_generated_action_hook (line 334) | public function test_product_generated_action_hook() {
    method test_product_global_unique_id (line 356) | public function test_product_global_unique_id() {
    method test_batch_with_existing_terms (line 366) | public function test_batch_with_existing_terms() {
    method test_variation_sale_prices (line 383) | public function test_variation_sale_prices() {
    method test_featured_products (line 422) | public function test_featured_products() {
    method test_products_have_brands_when_taxonomy_exists (line 438) | public function test_products_have_brands_when_taxonomy_exists() {
    method test_product_generation_without_brand_taxonomy (line 476) | public function test_product_generation_without_brand_taxonomy() {

FILE: tests/Unit/PluginTest.php
  class PluginTest (line 16) | class PluginTest extends WP_UnitTestCase {
    method test_plugin_instantiation (line 21) | public function test_plugin_instantiation() {

FILE: tests/Unit/Util/RandomRuntimeCacheTest.php
  class RandomRuntimeCacheTest (line 16) | class RandomRuntimeCacheTest extends WP_UnitTestCase {
    method setUp (line 21) | public function setUp(): void {
    method tearDown (line 29) | public function tearDown(): void {
    method test_exists_returns_false_for_non_existent_group (line 37) | public function test_exists_returns_false_for_non_existent_group() {
    method test_exists_returns_true_after_set (line 44) | public function test_exists_returns_true_after_set() {
    method test_set_and_get_items (line 52) | public function test_set_and_get_items() {
    method test_get_with_limit (line 64) | public function test_get_with_limit() {
    method test_get_with_limit_larger_than_available (line 77) | public function test_get_with_limit_larger_than_available() {
    method test_get_with_zero_limit_returns_all (line 90) | public function test_get_with_zero_limit_returns_all() {
    method test_extract_removes_items_from_cache (line 102) | public function test_extract_removes_items_from_cache() {
    method test_extract_all_deletes_group (line 117) | public function test_extract_all_deletes_group() {
    method test_extract_with_limit_larger_than_available (line 130) | public function test_extract_with_limit_larger_than_available() {
    method test_add_items_to_existing_group (line 143) | public function test_add_items_to_existing_group() {
    method test_add_items_to_non_existent_group (line 155) | public function test_add_items_to_non_existent_group() {
    method test_count_items (line 165) | public function test_count_items() {
    method test_count_non_existent_group (line 176) | public function test_count_non_existent_group() {
    method test_shuffle_randomizes_order (line 185) | public function test_shuffle_randomizes_order() {
    method test_shuffle_non_existent_group (line 210) | public function test_shuffle_non_existent_group() {
    method test_clear_removes_group (line 220) | public function test_clear_removes_group() {
    method test_clear_non_existent_group (line 230) | public function test_clear_non_existent_group() {
    method test_reset_clears_all_groups (line 239) | public function test_reset_clears_all_groups() {
    method test_complex_operations_sequence (line 254) | public function test_complex_operations_sequence() {

FILE: tests/bootstrap.php
  function install_woocommerce (line 89) | function install_woocommerce() {
  function load_plugins (line 121) | function load_plugins() {
  function validate_file_exists (line 136) | function validate_file_exists( string $file_name ) {
  function path_join (line 151) | function path_join( string $base, string $path ) {

FILE: wc-smooth-generator.php
  function wc_smooth_generator (line 32) | function wc_smooth_generator() {
  function load_wc_smooth_generator (line 45) | function load_wc_smooth_generator() {
  function wc_smooth_generator_plugin_action_links (line 69) | function wc_smooth_generator_plugin_action_links( $links ) {
Condensed preview — 45 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (268K chars).
[
  {
    "path": ".editorconfig",
    "chars": 476,
    "preview": "# This file is for unifying the coding style for different editors and IDEs\n# editorconfig.org\n\n# WordPress Coding Stand"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "chars": 1367,
    "preview": "# Contributing ✨\n\nYour help will be greatly appreciated :)\n\nWooCommerce is licensed under the GPLv3+, and all contributi"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1-bug-report.yml",
    "chars": 3158,
    "preview": "name: 🐞 Bug Report\ndescription: Report a bug if something isn't working as expected in WooCommerce Smooth Generator.\nbod"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/2-enhancement.yml",
    "chars": 1257,
    "preview": "name: ✨ Enhancement Request\ndescription: If you have an idea to improve WooCommerce Smooth Generator or need something f"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 387,
    "preview": "blank_issues_enabled: true\ncontact_links:\n  - name: 🔒 Security issue\n    url: https://hackerone.com/automattic/\n    abou"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "chars": 428,
    "preview": "<!-- This form is for other issue types specific to WooCommerce Smooth Generator. This is not a support portal. -->\n\n**P"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "chars": 1414,
    "preview": "### All Submissions:\n\n* [ ] Have you followed the [Contributing guidelines](https://github.com/woocommerce/wc-smooth-gen"
  },
  {
    "path": ".github/workflows/php-unit-tests.yml",
    "chars": 3574,
    "preview": "name: PHP Unit Tests\n\non:\n    push:\n        branches:\n            - trunk\n        paths:\n            - '**.php'\n        "
  },
  {
    "path": ".gitignore",
    "chars": 181,
    "preview": "vendor/\nnode_modules/\nwc-smooth-generator.zip\nwc-smooth-generator/\n\n# PHPStorm\n.idea\n\n# PHPCS\n.phpcs.xml\nphpcs.xml\n\n# PH"
  },
  {
    "path": ".husky/pre-commit",
    "chars": 78,
    "preview": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\ncomposer run lint-staged\n"
  },
  {
    "path": ".nvmrc",
    "chars": 4,
    "preview": "v22\n"
  },
  {
    "path": "README.md",
    "chars": 9621,
    "preview": "# WooCommerce Smooth Generator\n\nGenerate realistic WooCommerce products, orders, customers, coupons, and taxonomy terms "
  },
  {
    "path": "TESTING.md",
    "chars": 6255,
    "preview": "# Testing Guide for Exact Ratio Distribution\n\nThis document provides comprehensive test cases for verifying the exact ra"
  },
  {
    "path": "bin/install-wp-tests.sh",
    "chars": 5308,
    "preview": "#!/usr/bin/env bash\n\nif [ $# -lt 3 ]; then\n\techo \"usage: $0 <db-name> <db-user> <db-pass> [db-host] [wp-version] [skip-d"
  },
  {
    "path": "bin/lint-branch.sh",
    "chars": 551,
    "preview": "#!/bin/bash\n\n# Lint branch\n#\n# Runs phpcs-changed, comparing the current branch to its \"base\" or \"parent\" branch.\n# The "
  },
  {
    "path": "changelog.txt",
    "chars": 4851,
    "preview": "*** Changelog ***\n\n2026-02-19 - version 1.3.0\n* Add - Brand support for generated products (product_brand taxonomy).\n* A"
  },
  {
    "path": "composer.json",
    "chars": 2211,
    "preview": "{\n  \"name\": \"woocommerce/wc-smooth-generator\",\n  \"description\": \"A smooth product, order, customer, and coupon generator"
  },
  {
    "path": "includes/Admin/AsyncJob.php",
    "chars": 1287,
    "preview": "<?php\n\nnamespace WC\\SmoothGenerator\\Admin;\n\n/**\n * Class AsyncJob.\n *\n * A Record Object to hold the current state of an"
  },
  {
    "path": "includes/Admin/BatchProcessor.php",
    "chars": 7233,
    "preview": "<?php\n\nnamespace WC\\SmoothGenerator\\Admin;\n\nuse Automattic\\WooCommerce\\Internal\\BatchProcessing\\{ BatchProcessorInterfac"
  },
  {
    "path": "includes/Admin/Settings.php",
    "chars": 10315,
    "preview": "<?php\n/**\n * Plugin admin settings\n *\n * @package SmoothGenerator\\Admin\\Classes\n */\n\nnamespace WC\\SmoothGenerator\\Admin;"
  },
  {
    "path": "includes/CLI.php",
    "chars": 13595,
    "preview": "<?php\n/**\n * WP-CLI functionality.\n *\n * @package SmoothGenerator\\Classes\n */\n\nnamespace WC\\SmoothGenerator;\n\nuse WP_CLI"
  },
  {
    "path": "includes/Generator/Coupon.php",
    "chars": 4288,
    "preview": "<?php\n/**\n * Customer data generation.\n *\n * @package SmoothGenerator\\Classes\n */\n\nnamespace WC\\SmoothGenerator\\Generato"
  },
  {
    "path": "includes/Generator/Customer.php",
    "chars": 4058,
    "preview": "<?php\n/**\n * Customer data generation.\n *\n * @package SmoothGenerator\\Classes\n */\n\nnamespace WC\\SmoothGenerator\\Generato"
  },
  {
    "path": "includes/Generator/CustomerInfo.php",
    "chars": 9179,
    "preview": "<?php\n\nnamespace WC\\SmoothGenerator\\Generator;\n\n/**\n * Class CustomerInfo.\n *\n * Helper class for generating locale-spec"
  },
  {
    "path": "includes/Generator/Generator.php",
    "chars": 9161,
    "preview": "<?php\n/**\n * Abstract Generator class\n *\n * @package SmoothGenerator\\Abstracts\n */\n\nnamespace WC\\SmoothGenerator\\Generat"
  },
  {
    "path": "includes/Generator/Order.php",
    "chars": 35224,
    "preview": "<?php\n/**\n * Order data generation.\n *\n * @package SmoothGenerator\\Classes\n */\n\nnamespace WC\\SmoothGenerator\\Generator;\n"
  },
  {
    "path": "includes/Generator/OrderAttribution.php",
    "chars": 13417,
    "preview": "<?php\n/**\n * Order Attribution data helper.\n *\n * @package SmoothGenerator\\Classes\n */\n\nnamespace WC\\SmoothGenerator\\Gen"
  },
  {
    "path": "includes/Generator/Product.php",
    "chars": 19803,
    "preview": "<?php\n/**\n * Abstract product generator class\n *\n * @package SmoothGenerator\\Abstracts\n */\n\nnamespace WC\\SmoothGenerator"
  },
  {
    "path": "includes/Generator/Term.php",
    "chars": 6303,
    "preview": "<?php\n/**\n * Generate taxonomy terms.\n *\n * @package SmoothGenerator\\Classes\n */\n\nnamespace WC\\SmoothGenerator\\Generator"
  },
  {
    "path": "includes/Plugin.php",
    "chars": 400,
    "preview": "<?php\n/**\n * Main plugin class.\n *\n * @package SmoothGenerator\\Classes\n */\n\nnamespace WC\\SmoothGenerator;\n\n/**\n * Main p"
  },
  {
    "path": "includes/Router.php",
    "chars": 1585,
    "preview": "<?php\n\nnamespace WC\\SmoothGenerator;\n\n/**\n * Methods to retrieve and use a particular generator class based on its slug."
  },
  {
    "path": "includes/Util/RandomRuntimeCache.php",
    "chars": 3971,
    "preview": "<?php\n/**\n * A runtime object cache for storing and randomly retrieving reusable data.\n *\n * @package SmoothGenerator\\Ut"
  },
  {
    "path": "package.json",
    "chars": 1019,
    "preview": "{\n  \"name\": \"wc-smooth-generator\",\n  \"title\": \"WooCommerce Smooth Generator\",\n  \"version\": \"1.3.0\",\n  \"homepage\": \"https"
  },
  {
    "path": "phpcs.xml.dist",
    "chars": 1504,
    "preview": "<?xml version=\"1.0\"?>\n<ruleset name=\"WordPress Coding Standards\">\n\t<!-- See https://github.com/squizlabs/PHP_CodeSniffer"
  },
  {
    "path": "phpunit.xml.dist",
    "chars": 581,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<phpunit\n\t\tbootstrap=\"tests/bootstrap.php\"\n\t\tbackupGlobals=\"false\"\n\t\tcolors=\"true"
  },
  {
    "path": "tests/README.md",
    "chars": 4878,
    "preview": "# WC Smooth Generator - Test Suite\n\nComprehensive unit test suite for the WooCommerce Smooth Generator plugin, targeting"
  },
  {
    "path": "tests/Unit/Generator/CouponTest.php",
    "chars": 4401,
    "preview": "<?php\n/**\n * Tests for Coupon Generator.\n *\n * @package WC\\SmoothGenerator\\Tests\\Generator\n */\n\nnamespace WC\\SmoothGener"
  },
  {
    "path": "tests/Unit/Generator/CustomerTest.php",
    "chars": 4319,
    "preview": "<?php\n/**\n * Tests for Customer Generator.\n *\n * @package WC\\SmoothGenerator\\Tests\\Generator\n */\n\nnamespace WC\\SmoothGen"
  },
  {
    "path": "tests/Unit/Generator/GeneratorTest.php",
    "chars": 1975,
    "preview": "<?php\n/**\n * Tests for Generator base class.\n *\n * @package WC\\SmoothGenerator\\Tests\\Generator\n */\n\nnamespace WC\\SmoothG"
  },
  {
    "path": "tests/Unit/Generator/OrderTest.php",
    "chars": 14128,
    "preview": "<?php\n/**\n * Tests for Order Generator.\n *\n * @package WC\\SmoothGenerator\\Tests\\Generator\n */\n\nnamespace WC\\SmoothGenera"
  },
  {
    "path": "tests/Unit/Generator/ProductTest.php",
    "chars": 15663,
    "preview": "<?php\n/**\n * Tests for Product Generator.\n *\n * @package WC\\SmoothGenerator\\Tests\\Generator\n */\n\nnamespace WC\\SmoothGene"
  },
  {
    "path": "tests/Unit/PluginTest.php",
    "chars": 439,
    "preview": "<?php\n/**\n * Tests for Plugin main class.\n *\n * @package WC\\SmoothGenerator\\Tests\n */\n\nnamespace WC\\SmoothGenerator\\Test"
  },
  {
    "path": "tests/Unit/Util/RandomRuntimeCacheTest.php",
    "chars": 7612,
    "preview": "<?php\n/**\n * Tests for RandomRuntimeCache utility class.\n *\n * @package WC\\SmoothGenerator\\Tests\\Util\n */\n\nnamespace WC\\"
  },
  {
    "path": "tests/bootstrap.php",
    "chars": 3994,
    "preview": "<?php\n/**\n * Bootstrap file for PHPUnit tests.\n *\n * @package WC\\SmoothGenerator\\Tests\n */\n\ndeclare( strict_types=1 );\n\n"
  },
  {
    "path": "wc-smooth-generator.php",
    "chars": 2024,
    "preview": "<?php\n/**\n * Plugin Name: WooCommerce Smooth Generator\n * Plugin URI: https://woocommerce.com\n * Description: A smooth p"
  }
]

About this extraction

This page contains the full source code of the woocommerce/wc-smooth-generator GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 45 files (237.8 KB), approximately 68.7k tokens, and a symbol index with 244 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!