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