926 lines
25 KiB
PHP
926 lines
25 KiB
PHP
<?php
|
|
/**
|
|
* Class WC_Payments_Remediate_Canceled_Auth_Fees
|
|
*
|
|
* @package WooCommerce\Payments\Migrations
|
|
*/
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
use WCPay\Constants\Intent_Status;
|
|
|
|
/**
|
|
* Remediates incorrect transaction fees and refunds for canceled authorizations.
|
|
*
|
|
* Between April 2023 / v5.8.0 (commit 2d13751) and November 2025 (commit 3058f59ad),
|
|
* canceled authorizations incorrectly had transaction fees and refund objects created.
|
|
* This migration cleans up those incorrect records.
|
|
*/
|
|
class WC_Payments_Remediate_Canceled_Auth_Fees {
|
|
|
|
/**
|
|
* Option key for tracking remediation status.
|
|
*/
|
|
const STATUS_OPTION_KEY = 'wcpay_fee_remediation_status';
|
|
|
|
/**
|
|
* Option key for tracking last processed order ID.
|
|
*/
|
|
const LAST_ORDER_ID_OPTION_KEY = 'wcpay_fee_remediation_last_order_id';
|
|
|
|
/**
|
|
* Option key for tracking current batch size.
|
|
*/
|
|
const BATCH_SIZE_OPTION_KEY = 'wcpay_fee_remediation_batch_size';
|
|
|
|
/**
|
|
* Option key for tracking statistics.
|
|
*/
|
|
const STATS_OPTION_KEY = 'wcpay_fee_remediation_stats';
|
|
|
|
/**
|
|
* Action Scheduler hook name.
|
|
*/
|
|
const ACTION_HOOK = 'wcpay_remediate_canceled_authorization_fees';
|
|
|
|
/**
|
|
* Action Scheduler hook name for dry run.
|
|
*/
|
|
const DRY_RUN_ACTION_HOOK = 'wcpay_remediate_canceled_authorization_fees_dry_run';
|
|
|
|
/**
|
|
* Action Scheduler hook for the async affected orders check.
|
|
*/
|
|
const CHECK_AFFECTED_ORDERS_HOOK = 'wcpay_check_affected_auth_fee_orders';
|
|
|
|
/**
|
|
* Option key for tracking the affected orders check state.
|
|
*
|
|
* Possible values:
|
|
* - false (option doesn't exist): not yet checked.
|
|
* - 'scheduled': async check is scheduled or running.
|
|
* - 'has_affected_orders': affected orders were found.
|
|
* - 'no_affected_orders': no affected orders found.
|
|
*/
|
|
const CHECK_STATE_OPTION_KEY = 'wcpay_has_affected_auth_fee_orders';
|
|
|
|
/**
|
|
* Option key for tracking dry run mode.
|
|
*/
|
|
const DRY_RUN_OPTION_KEY = 'wcpay_fee_remediation_dry_run';
|
|
|
|
/**
|
|
* Starting batch size.
|
|
*/
|
|
const INITIAL_BATCH_SIZE = 20;
|
|
|
|
/**
|
|
* Minimum batch size.
|
|
*/
|
|
const MIN_BATCH_SIZE = 10;
|
|
|
|
/**
|
|
* Maximum batch size.
|
|
*/
|
|
const MAX_BATCH_SIZE = 100;
|
|
|
|
/**
|
|
* Target minimum execution time (seconds).
|
|
*/
|
|
const TARGET_MIN_TIME = 5;
|
|
|
|
/**
|
|
* Target maximum execution time (seconds).
|
|
*/
|
|
const TARGET_MAX_TIME = 20;
|
|
|
|
/**
|
|
* Bug introduction date (April 1, 2023 - commit 2d13751).
|
|
*/
|
|
const BUG_START_DATE = '2023-04-01';
|
|
|
|
/**
|
|
* Constructor.
|
|
*/
|
|
public function __construct() {
|
|
// Empty - call init() to register hooks.
|
|
}
|
|
|
|
/**
|
|
* Initialize hooks.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function init(): void {
|
|
add_action( self::ACTION_HOOK, [ $this, 'process_batch' ] );
|
|
add_action( self::DRY_RUN_ACTION_HOOK, [ $this, 'process_batch_dry_run' ] );
|
|
add_action( self::CHECK_AFFECTED_ORDERS_HOOK, [ $this, 'check_and_cache_affected_orders' ] );
|
|
}
|
|
|
|
/**
|
|
* Check if dry run mode is enabled.
|
|
*
|
|
* @return bool True if dry run mode is enabled.
|
|
*/
|
|
public function is_dry_run(): bool {
|
|
return (bool) get_option( self::DRY_RUN_OPTION_KEY, false );
|
|
}
|
|
|
|
/**
|
|
* Enable dry run mode.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function enable_dry_run(): void {
|
|
update_option( self::DRY_RUN_OPTION_KEY, true );
|
|
}
|
|
|
|
/**
|
|
* Disable dry run mode.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function disable_dry_run(): void {
|
|
delete_option( self::DRY_RUN_OPTION_KEY );
|
|
}
|
|
|
|
/**
|
|
* Check if remediation is complete.
|
|
*
|
|
* @return bool True if remediation is complete.
|
|
*/
|
|
public function is_complete(): bool {
|
|
return 'completed' === get_option( self::STATUS_OPTION_KEY, '' );
|
|
}
|
|
|
|
/**
|
|
* Mark remediation as complete.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function mark_complete(): void {
|
|
update_option( self::STATUS_OPTION_KEY, 'completed' );
|
|
}
|
|
|
|
/**
|
|
* Mark remediation as running.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function mark_running(): void {
|
|
update_option( self::STATUS_OPTION_KEY, 'running' );
|
|
}
|
|
|
|
/**
|
|
* Get current batch size.
|
|
*
|
|
* @return int Current batch size.
|
|
*/
|
|
public function get_batch_size(): int {
|
|
return (int) get_option( self::BATCH_SIZE_OPTION_KEY, self::INITIAL_BATCH_SIZE );
|
|
}
|
|
|
|
/**
|
|
* Update batch size.
|
|
*
|
|
* @param int $size New batch size.
|
|
* @return void
|
|
*/
|
|
public function update_batch_size( int $size ): void {
|
|
$size = max( self::MIN_BATCH_SIZE, min( self::MAX_BATCH_SIZE, $size ) );
|
|
update_option( self::BATCH_SIZE_OPTION_KEY, $size );
|
|
}
|
|
|
|
/**
|
|
* Get last processed order ID.
|
|
*
|
|
* @return int Last processed order ID.
|
|
*/
|
|
public function get_last_order_id(): int {
|
|
return (int) get_option( self::LAST_ORDER_ID_OPTION_KEY, 0 );
|
|
}
|
|
|
|
/**
|
|
* Update last processed order ID.
|
|
*
|
|
* @param int $order_id Order ID.
|
|
* @return void
|
|
*/
|
|
public function update_last_order_id( int $order_id ): void {
|
|
update_option( self::LAST_ORDER_ID_OPTION_KEY, $order_id );
|
|
}
|
|
|
|
/**
|
|
* Get remediation statistics.
|
|
*
|
|
* @return array Statistics array with keys: processed, remediated, errors.
|
|
*/
|
|
public function get_stats(): array {
|
|
$default = [
|
|
'processed' => 0,
|
|
'remediated' => 0,
|
|
'errors' => 0,
|
|
];
|
|
|
|
$stats = get_option( self::STATS_OPTION_KEY, [] );
|
|
return array_merge( $default, $stats );
|
|
}
|
|
|
|
/**
|
|
* Increment a statistic counter.
|
|
*
|
|
* @param string $key Stat key to increment.
|
|
* @return void
|
|
*/
|
|
public function increment_stat( string $key ): void {
|
|
$stats = $this->get_stats();
|
|
if ( isset( $stats[ $key ] ) ) {
|
|
++$stats[ $key ];
|
|
update_option( self::STATS_OPTION_KEY, $stats );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up temporary remediation options.
|
|
*
|
|
* Preserves the status and stats options so merchants can see completion
|
|
* information in the Tools page. Only removes temporary processing options.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function cleanup(): void {
|
|
// Delete only temporary processing options.
|
|
// Keep STATUS_OPTION_KEY and STATS_OPTION_KEY so merchants can see completion info.
|
|
delete_option( self::LAST_ORDER_ID_OPTION_KEY );
|
|
delete_option( self::BATCH_SIZE_OPTION_KEY );
|
|
delete_option( self::DRY_RUN_OPTION_KEY );
|
|
}
|
|
|
|
/**
|
|
* Clean up after dry run completes.
|
|
*
|
|
* Unlike the actual remediation cleanup, this removes ALL options including
|
|
* status and stats, so merchants can still run the actual remediation afterward.
|
|
* Dry run is just a preview - it shouldn't block the real action.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function cleanup_dry_run(): void {
|
|
delete_option( self::LAST_ORDER_ID_OPTION_KEY );
|
|
delete_option( self::BATCH_SIZE_OPTION_KEY );
|
|
delete_option( self::DRY_RUN_OPTION_KEY );
|
|
delete_option( self::STATUS_OPTION_KEY );
|
|
delete_option( self::STATS_OPTION_KEY );
|
|
}
|
|
|
|
/**
|
|
* Check if HPOS is enabled.
|
|
*
|
|
* This method is protected to allow mocking in tests.
|
|
*
|
|
* @return bool True if HPOS is enabled.
|
|
*/
|
|
protected function is_hpos_enabled(): bool {
|
|
return WC_Payments_Utils::is_hpos_tables_usage_enabled();
|
|
}
|
|
|
|
/**
|
|
* Get affected orders that need remediation.
|
|
*
|
|
* @param int $limit Number of orders to retrieve.
|
|
* @return WC_Order[] Array of WC_Order objects.
|
|
*/
|
|
public function get_affected_orders( int $limit ): array {
|
|
if ( $this->is_hpos_enabled() ) {
|
|
return $this->get_affected_orders_hpos( $limit );
|
|
}
|
|
|
|
return $this->get_affected_orders_cpt( $limit );
|
|
}
|
|
|
|
/**
|
|
* Get affected orders using HPOS custom tables.
|
|
*
|
|
* @param int $limit Number of orders to retrieve.
|
|
* @return WC_Order[] Array of WC_Order objects.
|
|
*/
|
|
private function get_affected_orders_hpos( int $limit ): array {
|
|
global $wpdb;
|
|
|
|
$last_order_id = $this->get_last_order_id();
|
|
$orders_table = $wpdb->prefix . 'wc_orders';
|
|
$meta_table = $wpdb->prefix . 'wc_orders_meta';
|
|
|
|
$sql = "SELECT orders.id
|
|
FROM {$orders_table} orders
|
|
INNER JOIN {$meta_table} status_meta ON orders.id = status_meta.order_id AND status_meta.meta_key = '_intention_status' AND status_meta.meta_value = %s
|
|
LEFT JOIN {$meta_table} fees_meta ON orders.id = fees_meta.order_id AND fees_meta.meta_key = '_wcpay_transaction_fee'
|
|
WHERE orders.type = 'shop_order'
|
|
AND orders.date_created_gmt >= %s
|
|
AND (
|
|
-- Refunded with or without a refund.
|
|
orders.status = 'wc-refunded'
|
|
|
|
-- Cancelled with fees.
|
|
OR (
|
|
orders.status = 'wc-cancelled'
|
|
AND fees_meta.order_id IS NOT NULL
|
|
)
|
|
)";
|
|
|
|
$params = [ Intent_Status::CANCELED, self::BUG_START_DATE ];
|
|
|
|
// Add offset based on last order ID.
|
|
if ( $last_order_id > 0 ) {
|
|
$sql .= ' AND orders.id > %d';
|
|
$params[] = $last_order_id;
|
|
}
|
|
|
|
// Add ordering and limit.
|
|
$sql .= ' ORDER BY orders.id ASC LIMIT %d';
|
|
$params[] = $limit;
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
$order_ids = $wpdb->get_col( $wpdb->prepare( $sql, $params ) );
|
|
|
|
return $this->convert_ids_to_orders( $order_ids );
|
|
}
|
|
|
|
/**
|
|
* Get affected orders using CPT (posts) storage.
|
|
*
|
|
* @param int $limit Number of orders to retrieve.
|
|
* @return WC_Order[] Array of WC_Order objects.
|
|
*/
|
|
private function get_affected_orders_cpt( int $limit ): array {
|
|
global $wpdb;
|
|
|
|
$last_order_id = $this->get_last_order_id();
|
|
|
|
// Build the SQL query to find orders with canceled intent status that have either:
|
|
// 1. Incorrect fee metadata (_wcpay_transaction_fee or _wcpay_net), OR
|
|
// 2. Refund objects (which shouldn't exist for never-captured authorizations), OR
|
|
// 3. Incorrect order status of 'wc-refunded' (should be 'wc-cancelled').
|
|
$sql = "SELECT orders.ID
|
|
FROM {$wpdb->posts} orders
|
|
INNER JOIN {$wpdb->postmeta} status_meta ON orders.ID = status_meta.post_id AND status_meta.meta_key = '_intention_status' AND status_meta.meta_value = %s
|
|
LEFT JOIN {$wpdb->postmeta} fees_meta ON orders.ID = fees_meta.post_id AND fees_meta.meta_key = '_wcpay_transaction_fee'
|
|
WHERE orders.post_type IN ('shop_order', 'shop_order_placeholder')
|
|
AND orders.post_date >= %s
|
|
AND (
|
|
-- Refunded with or without a refund.
|
|
orders.post_status = 'wc-refunded'
|
|
|
|
-- Cancelled with fees
|
|
OR (
|
|
orders.post_status = 'wc-cancelled'
|
|
AND fees_meta.post_id IS NOT NULL
|
|
)
|
|
)";
|
|
|
|
$params = [ Intent_Status::CANCELED, self::BUG_START_DATE ];
|
|
|
|
// Add offset based on last order ID.
|
|
if ( $last_order_id > 0 ) {
|
|
$sql .= ' AND orders.ID > %d';
|
|
$params[] = $last_order_id;
|
|
}
|
|
|
|
// Add ordering and limit.
|
|
$sql .= ' ORDER BY orders.ID ASC LIMIT %d';
|
|
$params[] = $limit;
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
$order_ids = $wpdb->get_col( $wpdb->prepare( $sql, $params ) );
|
|
|
|
return $this->convert_ids_to_orders( $order_ids );
|
|
}
|
|
|
|
/**
|
|
* Convert order IDs to WC_Order objects.
|
|
*
|
|
* @param array $order_ids Array of order IDs.
|
|
* @return WC_Order[] Array of WC_Order objects.
|
|
*/
|
|
private function convert_ids_to_orders( array $order_ids ): array {
|
|
$orders = [];
|
|
foreach ( $order_ids as $order_id ) {
|
|
$order = wc_get_order( $order_id );
|
|
if ( $order instanceof WC_Order ) {
|
|
$orders[] = $order;
|
|
}
|
|
}
|
|
|
|
return $orders;
|
|
}
|
|
|
|
/**
|
|
* Adjust batch size based on execution time.
|
|
*
|
|
* @param float $execution_time Execution time in seconds.
|
|
* @return void
|
|
*/
|
|
public function adjust_batch_size( float $execution_time ): void {
|
|
$current_size = $this->get_batch_size();
|
|
|
|
if ( $execution_time < self::TARGET_MIN_TIME ) {
|
|
// Too fast - double batch size.
|
|
$this->update_batch_size( $current_size * 2 );
|
|
} elseif ( $execution_time > self::TARGET_MAX_TIME ) {
|
|
// Too slow - halve batch size.
|
|
$this->update_batch_size( (int) ( $current_size / 2 ) );
|
|
}
|
|
// Otherwise, keep current size.
|
|
}
|
|
|
|
/**
|
|
* Process a batch of orders.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function process_batch(): void {
|
|
// Check if already complete.
|
|
if ( $this->is_complete() ) {
|
|
return;
|
|
}
|
|
|
|
// This can affect the order transitions by unnecessarily reaching out to Stripe.
|
|
remove_action( 'woocommerce_order_status_cancelled', [ WC_Payments::get_order_service(), 'cancel_authorizations_on_order_status_change' ] );
|
|
|
|
$start_time = microtime( true );
|
|
$batch_size = $this->get_batch_size();
|
|
$orders = $this->get_affected_orders( $batch_size );
|
|
|
|
// If no orders found, mark as complete.
|
|
if ( empty( $orders ) ) {
|
|
$this->mark_complete();
|
|
$this->log_completion();
|
|
$this->cleanup();
|
|
return;
|
|
}
|
|
|
|
// Process each order.
|
|
foreach ( $orders as $order ) {
|
|
$this->increment_stat( 'processed' );
|
|
|
|
if ( $this->remediate_order( $order ) ) {
|
|
$this->increment_stat( 'remediated' );
|
|
wc_get_logger()->info(
|
|
sprintf( 'Remediated order %d', $order->get_id() ),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
} else {
|
|
$this->increment_stat( 'errors' );
|
|
}
|
|
|
|
// Update last order ID.
|
|
$this->update_last_order_id( $order->get_id() );
|
|
}
|
|
|
|
// Adjust batch size based on execution time.
|
|
$execution_time = microtime( true ) - $start_time;
|
|
$this->adjust_batch_size( $execution_time );
|
|
|
|
// Log batch completion.
|
|
wc_get_logger()->info(
|
|
sprintf(
|
|
'Processed batch of %d orders in %.2f seconds. New batch size: %d',
|
|
count( $orders ),
|
|
$execution_time,
|
|
$this->get_batch_size()
|
|
),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
|
|
// Always schedule next batch to check for more orders.
|
|
// The batch will complete when get_affected_orders() returns empty.
|
|
// This is more reliable than checking count vs batch_size, which can
|
|
// incorrectly mark complete if the query returns fewer orders due to
|
|
// transient issues (DB performance, caching, etc.).
|
|
$this->schedule_next_batch();
|
|
}
|
|
|
|
/**
|
|
* Process a batch of orders in dry run mode.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function process_batch_dry_run(): void {
|
|
// Check if already complete (but only if not in dry run mode - dry run uses separate tracking).
|
|
if ( $this->is_complete() && ! $this->is_dry_run() ) {
|
|
return;
|
|
}
|
|
|
|
$batch_size = $this->get_batch_size();
|
|
$orders = $this->get_affected_orders( $batch_size );
|
|
|
|
// If no orders found, dry run is done.
|
|
if ( empty( $orders ) ) {
|
|
$this->log_completion_dry_run();
|
|
$this->cleanup_dry_run();
|
|
return;
|
|
}
|
|
|
|
// Process each order in dry run mode.
|
|
foreach ( $orders as $order ) {
|
|
$this->increment_stat( 'processed' );
|
|
|
|
if ( $this->remediate_order( $order, true ) ) {
|
|
$this->increment_stat( 'remediated' );
|
|
} else {
|
|
$this->increment_stat( 'errors' );
|
|
}
|
|
|
|
// Update last order ID.
|
|
$this->update_last_order_id( $order->get_id() );
|
|
}
|
|
|
|
// Log batch completion.
|
|
wc_get_logger()->info(
|
|
sprintf(
|
|
'[DRY RUN] Processed batch of %d orders.',
|
|
count( $orders )
|
|
),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
|
|
// Always schedule next batch to check for more orders.
|
|
// The batch will complete when get_affected_orders() returns empty.
|
|
$this->schedule_next_batch_dry_run();
|
|
}
|
|
|
|
/**
|
|
* Schedule the next batch.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function schedule_next_batch(): void {
|
|
if ( ! function_exists( 'as_schedule_single_action' ) ) {
|
|
wc_get_logger()->warning(
|
|
'Action Scheduler is not available. Cannot schedule next batch for fee remediation.',
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
return;
|
|
}
|
|
|
|
as_schedule_single_action(
|
|
time() + 60, // 1 minute from now.
|
|
self::ACTION_HOOK,
|
|
[],
|
|
'woocommerce-payments'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Schedule the next batch for dry run.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function schedule_next_batch_dry_run(): void {
|
|
if ( ! function_exists( 'as_schedule_single_action' ) ) {
|
|
wc_get_logger()->warning(
|
|
'Action Scheduler is not available. Cannot schedule next batch for fee remediation dry run.',
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
return;
|
|
}
|
|
|
|
as_schedule_single_action(
|
|
time() + 60, // 1 minute from now.
|
|
self::DRY_RUN_ACTION_HOOK,
|
|
[],
|
|
'woocommerce-payments'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Log completion.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function log_completion(): void {
|
|
$stats = $this->get_stats();
|
|
wc_get_logger()->info(
|
|
sprintf(
|
|
'Remediation complete. Processed: %d, Remediated: %d, Errors: %d',
|
|
$stats['processed'],
|
|
$stats['remediated'],
|
|
$stats['errors']
|
|
),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Log completion for dry run.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function log_completion_dry_run(): void {
|
|
$stats = $this->get_stats();
|
|
wc_get_logger()->info(
|
|
sprintf(
|
|
'[DRY RUN] Complete. Found %d orders that would be remediated. No changes were made. Check the WooCommerce logs for details on each order.',
|
|
$stats['remediated']
|
|
),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remediate a single order.
|
|
*
|
|
* @param WC_Order $order Order to remediate.
|
|
* @param bool $dry_run If true, only log what would be changed without modifying data.
|
|
* @return bool True on success, false on failure.
|
|
*/
|
|
public function remediate_order( WC_Order $order, bool $dry_run = false ): bool {
|
|
try {
|
|
// Capture current values for the note.
|
|
$fee = $order->get_meta( '_wcpay_transaction_fee', true );
|
|
$net = $order->get_meta( '_wcpay_net', true );
|
|
$refunds = $order->get_refunds();
|
|
$current_status = $order->get_status();
|
|
|
|
// Only delete refunds that were created by WCPay (have _wcpay_refund_id metadata).
|
|
// This avoids deleting manually-created refunds or refunds from other plugins.
|
|
$wcpay_refunds = $this->get_wcpay_refunds( $refunds );
|
|
$wcpay_refund_count = count( $wcpay_refunds );
|
|
$wcpay_refund_total = 0;
|
|
|
|
// Calculate refund IDs and totals.
|
|
$refund_ids = [];
|
|
foreach ( $wcpay_refunds as $refund ) {
|
|
$wcpay_refund_total += abs( $refund->get_amount() );
|
|
$refund_ids[] = $refund->get_id();
|
|
}
|
|
|
|
// Check if status would change.
|
|
$would_change_status = 'refunded' === $current_status;
|
|
|
|
// Build log message for dry run or note for actual remediation.
|
|
$changes = [];
|
|
|
|
if ( $would_change_status ) {
|
|
$changes[] = 'Changed order status from "Refunded" to "Cancelled"';
|
|
}
|
|
|
|
if ( $wcpay_refund_count > 0 ) {
|
|
$changes[] = sprintf(
|
|
'Deleted %d WooPayments refund object%s (IDs: %s) totaling %s',
|
|
$wcpay_refund_count,
|
|
$wcpay_refund_count > 1 ? 's' : '',
|
|
implode( ', ', $refund_ids ),
|
|
wc_price( $wcpay_refund_total, [ 'currency' => $order->get_currency() ] )
|
|
);
|
|
}
|
|
|
|
if ( ! empty( $fee ) ) {
|
|
$changes[] = sprintf(
|
|
'Removed transaction fee: %s',
|
|
wc_price( $fee, [ 'currency' => $order->get_currency() ] )
|
|
);
|
|
}
|
|
|
|
if ( ! empty( $net ) ) {
|
|
$changes[] = sprintf(
|
|
'Removed net amount: %s',
|
|
wc_price( $net, [ 'currency' => $order->get_currency() ] )
|
|
);
|
|
}
|
|
|
|
// In dry run mode, just log what would happen and return.
|
|
if ( $dry_run ) {
|
|
if ( ! empty( $changes ) ) {
|
|
wc_get_logger()->info(
|
|
sprintf(
|
|
'[DRY RUN] Order %d would be remediated: %s',
|
|
$order->get_id(),
|
|
wp_strip_all_tags( implode( '; ', $changes ) )
|
|
),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Actually perform the remediation.
|
|
$parent_order_id = $order->get_id();
|
|
foreach ( $wcpay_refunds as $refund ) {
|
|
$refund_id = $refund->get_id();
|
|
|
|
// Delete refund stats BEFORE deleting the refund (while it still exists).
|
|
// We do this proactively because the woocommerce_before_delete_order hook
|
|
// may not have its handlers registered in Action Scheduler context.
|
|
$this->delete_order_stats( $refund_id );
|
|
|
|
$refund->delete( true ); // Force delete, bypass trash.
|
|
|
|
// Fire the hook WC expects for refund deletion.
|
|
do_action( 'woocommerce_refund_deleted', $refund_id, $parent_order_id );
|
|
}
|
|
|
|
// Remove fee metadata from the order.
|
|
$order->delete_meta_data( '_wcpay_transaction_fee' );
|
|
$order->delete_meta_data( '_wcpay_net' );
|
|
$order->delete_meta_data( '_wcpay_refund_id' );
|
|
$order->delete_meta_data( '_wcpay_refund_status' );
|
|
|
|
// Fix incorrect order status: 'refunded' should be 'cancelled' for never-captured authorizations.
|
|
if ( $would_change_status ) {
|
|
$order->set_status( 'cancelled', '', false ); // Don't trigger status change emails.
|
|
}
|
|
|
|
// Build detailed note.
|
|
$note_parts = [ 'Removed incorrect data from canceled authorization:' ];
|
|
foreach ( $changes as $change ) {
|
|
$note_parts[] = '- ' . $change;
|
|
}
|
|
$note_parts[] = '';
|
|
$note_parts[] = 'These records were incorrectly created for an authorization that was never captured.';
|
|
$note_parts[] = 'No actual payment or refund occurred.';
|
|
|
|
$order->add_order_note( implode( "\n", $note_parts ) );
|
|
$order->save();
|
|
|
|
// Fallback sync in case the woocommerce_refund_deleted hook doesn't
|
|
// fully update analytics for edge cases.
|
|
$this->sync_order_stats( $order->get_id() );
|
|
|
|
return true;
|
|
|
|
} catch ( Exception $e ) {
|
|
// Log error but don't throw - let calling code handle retry.
|
|
wc_get_logger()->error(
|
|
sprintf( 'Failed to remediate order %d: %s', $order->get_id(), $e->getMessage() ),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter refunds to only include those created by WooPayments.
|
|
*
|
|
* WooPayments-created refunds have the _wcpay_refund_id metadata.
|
|
* This ensures we don't delete manually-created refunds or refunds from other plugins.
|
|
*
|
|
* @param WC_Order_Refund[] $refunds Array of refund objects.
|
|
* @return WC_Order_Refund[] Array of WooPayments-created refunds.
|
|
*/
|
|
private function get_wcpay_refunds( array $refunds ): array {
|
|
return array_filter(
|
|
$refunds,
|
|
function ( $refund ) {
|
|
// Check if this refund was created by WCPay (has the refund ID metadata).
|
|
$wcpay_refund_id = $refund->get_meta( '_wcpay_refund_id', true );
|
|
return ! empty( $wcpay_refund_id );
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sync order stats to WooCommerce Analytics.
|
|
*
|
|
* Fallback sync in case the woocommerce_refund_deleted hook doesn't
|
|
* fully update analytics for edge cases.
|
|
*
|
|
* @param int $order_id Order ID to sync.
|
|
* @return void
|
|
*/
|
|
protected function sync_order_stats( int $order_id ): void {
|
|
// Check if the OrdersStatsDataStore class exists (requires WooCommerce Admin / WooCommerce 4.0+).
|
|
if ( ! class_exists( 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore' ) ) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
\Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore::sync_order( $order_id );
|
|
} catch ( Exception $e ) {
|
|
// Log but don't fail - analytics sync is not critical.
|
|
wc_get_logger()->warning(
|
|
sprintf( 'Failed to sync order %d to analytics: %s', $order_id, $e->getMessage() ),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete order stats from WooCommerce Analytics.
|
|
*
|
|
* Uses WooCommerce's DataStore::delete_order() API to properly remove
|
|
* the order/refund stats row from the wc_order_stats table.
|
|
*
|
|
* This must be called BEFORE the refund is deleted, while it still exists,
|
|
* so the WC API can perform its internal checks.
|
|
*
|
|
* @param int $order_id Order or refund ID to delete stats for.
|
|
* @return void
|
|
*/
|
|
protected function delete_order_stats( int $order_id ): void {
|
|
// Check if the DataStore class exists (requires WooCommerce Admin / WooCommerce 4.0+).
|
|
if ( ! class_exists( 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore' ) ) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Use WooCommerce's proper API to delete the stats row.
|
|
// This handles all internal state management and fires appropriate hooks.
|
|
\Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore::delete_order( $order_id );
|
|
|
|
wc_get_logger()->info(
|
|
sprintf( 'Deleted stats row for refund %d via WC DataStore API', $order_id ),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
} catch ( Exception $e ) {
|
|
// Log but don't fail - analytics cleanup is not critical.
|
|
wc_get_logger()->warning(
|
|
sprintf( 'Failed to delete stats for order %d: %s', $order_id, $e->getMessage() ),
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule remediation to run in the background.
|
|
*
|
|
* This is the public method called from the WooCommerce Tools page.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function schedule_remediation(): void {
|
|
// Mark as running and schedule first batch.
|
|
$this->mark_running();
|
|
$this->disable_dry_run();
|
|
|
|
if ( function_exists( 'as_schedule_single_action' ) ) {
|
|
as_schedule_single_action(
|
|
time() + 10, // Start in 10 seconds.
|
|
self::ACTION_HOOK,
|
|
[],
|
|
'woocommerce-payments'
|
|
);
|
|
|
|
wc_get_logger()->info(
|
|
'Scheduled fee remediation from WooCommerce Tools.',
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule dry run to preview what would be remediated.
|
|
*
|
|
* This allows merchants to see what orders would be affected before
|
|
* committing to the actual remediation.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function schedule_dry_run(): void {
|
|
// Mark as running and enable dry run mode.
|
|
$this->mark_running();
|
|
$this->enable_dry_run();
|
|
|
|
if ( function_exists( 'as_schedule_single_action' ) ) {
|
|
as_schedule_single_action(
|
|
time() + 10, // Start in 10 seconds.
|
|
self::DRY_RUN_ACTION_HOOK,
|
|
[],
|
|
'woocommerce-payments'
|
|
);
|
|
|
|
wc_get_logger()->info(
|
|
'Scheduled fee remediation DRY RUN from WooCommerce Tools.',
|
|
[ 'source' => 'wcpay-fee-remediation' ]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if there are any orders that need remediation.
|
|
*
|
|
* @return bool True if there are affected orders.
|
|
*/
|
|
public function has_affected_orders(): bool {
|
|
$orders = $this->get_affected_orders( 1 );
|
|
return ! empty( $orders );
|
|
}
|
|
|
|
/**
|
|
* Run the affected orders query and cache the result.
|
|
*
|
|
* Called by Action Scheduler in a separate request.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function check_and_cache_affected_orders(): void {
|
|
$result = $this->has_affected_orders();
|
|
|
|
update_option(
|
|
self::CHECK_STATE_OPTION_KEY,
|
|
$result ? 'has_affected_orders' : 'no_affected_orders',
|
|
true
|
|
);
|
|
}
|
|
}
|