diff --git a/changelog/cancel-auth-fee-remediation b/changelog/cancel-auth-fee-remediation new file mode 100644 index 00000000000..4b8439caa34 --- /dev/null +++ b/changelog/cancel-auth-fee-remediation @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add remediation tool to fix incorrect analytics data from canceled authorizations diff --git a/includes/class-wc-payments-status.php b/includes/class-wc-payments-status.php index b9b7d5222f0..119105897c7 100644 --- a/includes/class-wc-payments-status.php +++ b/includes/class-wc-payments-status.php @@ -66,7 +66,7 @@ public function debug_tools( $tools ) { return array_merge( $tools, [ - 'clear_wcpay_account_cache' => [ + 'clear_wcpay_account_cache' => [ 'name' => sprintf( /* translators: %s: WooPayments */ __( 'Clear %s account cache', 'woocommerce-payments' ), @@ -80,7 +80,7 @@ public function debug_tools( $tools ) { ), 'callback' => [ $this->account, 'refresh_account_data' ], ], - 'delete_wcpay_test_orders' => [ + 'delete_wcpay_test_orders' => [ 'name' => sprintf( /* translators: %s: WooPayments */ __( 'Delete %s test orders', 'woocommerce-payments' ), @@ -94,6 +94,14 @@ public function debug_tools( $tools ) { ), 'callback' => [ $this, 'delete_test_orders' ], ], + 'remediate_canceled_auth_fees' => [ + 'name' => __( 'Fix canceled authorization analytics', 'woocommerce-payments' ), + 'button' => $this->get_remediation_button_text(), + 'desc' => $this->get_remediation_description(), + 'confirm' => __( 'This will update order metadata and delete incorrect refund records for affected orders. Make sure you have a recent backup before proceeding. Continue?', 'woocommerce-payments' ), + 'callback' => [ $this, 'schedule_canceled_auth_remediation' ], + 'disabled' => $this->is_remediation_running_or_complete(), + ], ] ); } @@ -155,6 +163,151 @@ public function delete_test_orders() { } } + /** + * Schedules the canceled authorization fee remediation. + * + * This tool fixes incorrect refund records and fee data from orders where + * payment authorization was canceled but never captured. + * + * @return string Success or error message. + */ + public function schedule_canceled_auth_remediation() { + // Add explicit capability check. + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return __( 'You do not have permission to run this tool.', 'woocommerce-payments' ); + } + + try { + include_once WCPAY_ABSPATH . 'includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; + $remediation = new WC_Payments_Remediate_Canceled_Auth_Fees(); + + // Check if already complete. + if ( $remediation->is_complete() ) { + return __( 'Remediation has already been completed.', 'woocommerce-payments' ); + } + + // Check if already running. + if ( function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( WC_Payments_Remediate_Canceled_Auth_Fees::ACTION_HOOK ) ) { + return __( 'Remediation is already in progress. Check the Action Scheduler for status.', 'woocommerce-payments' ); + } + + // Schedule the remediation. + $remediation->schedule_remediation(); + + return __( 'Remediation has been scheduled and will run in the background. You can monitor progress in the Action Scheduler.', 'woocommerce-payments' ); + + } catch ( Exception $e ) { + return sprintf( + /* translators: %s: error message */ + __( 'Error scheduling remediation: %s', 'woocommerce-payments' ), + $e->getMessage() + ); + } + } + + /** + * Get the button text for the remediation tool based on current status. + * + * @return string Button text. + */ + private function get_remediation_button_text(): string { + $status = get_option( 'wcpay_fee_remediation_status', '' ); + + if ( 'completed' === $status ) { + return __( 'Completed', 'woocommerce-payments' ); + } + + if ( 'running' === $status || $this->is_remediation_action_scheduled() ) { + return __( 'Running...', 'woocommerce-payments' ); + } + + return __( 'Run', 'woocommerce-payments' ); + } + + /** + * Get the description for the remediation tool including current status. + * + * @return string Tool description with status. + */ + private function get_remediation_description(): string { + $base_desc = __( 'This tool removes incorrect refund records and fee data from orders where payment authorization was canceled (not captured). This fixes negative values appearing in WooCommerce Analytics for stores using manual capture.', 'woocommerce-payments' ); + + $status = get_option( 'wcpay_fee_remediation_status', '' ); + + if ( 'completed' === $status ) { + $stats = get_option( 'wcpay_fee_remediation_stats', [] ); + $processed = isset( $stats['processed'] ) ? (int) $stats['processed'] : 0; + $remediated = isset( $stats['remediated'] ) ? (int) $stats['remediated'] : 0; + + if ( $processed > 0 ) { + return sprintf( + /* translators: 1: base description, 2: number of orders processed, 3: number of orders remediated */ + __( '%1$s Status: Completed. Processed %2$d orders, remediated %3$d.', 'woocommerce-payments' ), + $base_desc, + $processed, + $remediated + ); + } + + return sprintf( + /* translators: %s: base description */ + __( '%s Status: Completed. No affected orders found.', 'woocommerce-payments' ), + $base_desc + ); + } + + if ( 'running' === $status || $this->is_remediation_action_scheduled() ) { + $stats = get_option( 'wcpay_fee_remediation_stats', [] ); + $processed = isset( $stats['processed'] ) ? (int) $stats['processed'] : 0; + + if ( $processed > 0 ) { + return sprintf( + /* translators: 1: base description, 2: number of orders processed so far */ + __( '%1$s Status: Running... Processed %2$d orders so far. Check the Action Scheduler for details.', 'woocommerce-payments' ), + $base_desc, + $processed + ); + } + + return sprintf( + /* translators: %s: base description */ + __( '%s Status: Running... Check the Action Scheduler for details.', 'woocommerce-payments' ), + $base_desc + ); + } + + return $base_desc; + } + + /** + * Check if the remediation is currently running or already complete. + * + * @return bool True if running or complete. + */ + private function is_remediation_running_or_complete(): bool { + $status = get_option( 'wcpay_fee_remediation_status', '' ); + + if ( 'completed' === $status || 'running' === $status ) { + return true; + } + + return $this->is_remediation_action_scheduled(); + } + + /** + * Check if the remediation action is scheduled in Action Scheduler. + * + * @return bool True if action is scheduled. + */ + private function is_remediation_action_scheduled(): bool { + if ( ! function_exists( 'as_has_scheduled_action' ) ) { + return false; + } + + include_once WCPAY_ABSPATH . 'includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; + return as_has_scheduled_action( WC_Payments_Remediate_Canceled_Auth_Fees::ACTION_HOOK ); + } + /** * Renders WCPay information on the status page. */ diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index aefbf32c0a1..9cea28d96f0 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -319,6 +319,13 @@ class WC_Payments { */ private static $payment_method_service; + /** + * Instance of WC_Payments_Remediate_Canceled_Auth_Fees, created in init function + * + * @var WC_Payments_Remediate_Canceled_Auth_Fees + */ + private static $fee_remediation; + /** * Entry point to the initialization logic. */ @@ -492,6 +499,7 @@ public static function init() { include_once __DIR__ . '/class-wc-payments-order-service.php'; include_once __DIR__ . '/class-wc-payments-order-success-page.php'; include_once __DIR__ . '/class-wc-payments-file-service.php'; + include_once __DIR__ . '/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; include_once __DIR__ . '/class-wc-payments-webhook-processing-service.php'; include_once __DIR__ . '/class-wc-payments-webhook-reliability-service.php'; include_once __DIR__ . '/fraud-prevention/class-fraud-prevention-service.php'; @@ -552,6 +560,7 @@ public static function init() { self::$incentives_service = new WC_Payments_Incentives_Service( self::$database_cache ); self::$duplicate_payment_prevention_service = new Duplicate_Payment_Prevention_Service(); self::$duplicates_detection_service = new Duplicates_Detection_Service(); + self::$fee_remediation = new WC_Payments_Remediate_Canceled_Auth_Fees(); ( new WooPay_Scheduler( self::$api_client ) )->init(); @@ -564,6 +573,7 @@ public static function init() { self::$compatibility_service->init_hooks(); self::$customer_service->init_hooks(); self::$token_service->init_hooks(); + self::$fee_remediation->init(); /** * FLAG: PAYMENT_METHODS_LIST @@ -1537,6 +1547,9 @@ public static function add_woo_admin_notes() { require_once WCPAY_ABSPATH . 'includes/notes/class-wc-payments-notes-stripe-billing-deprecation.php'; WC_Payments_Notes_Stripe_Billing_Deprecation::possibly_add_note(); + + require_once WCPAY_ABSPATH . 'includes/notes/class-wc-payments-notes-canceled-auth-remediation.php'; + WC_Payments_Notes_Canceled_Auth_Remediation::possibly_add_note(); } if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '7.5', '<' ) && get_woocommerce_currency() === 'NOK' ) { @@ -1603,6 +1616,9 @@ public static function remove_woo_admin_notes() { require_once WCPAY_ABSPATH . 'includes/notes/class-wc-payments-notes-stripe-billing-deprecation.php'; WC_Payments_Notes_Stripe_Billing_Deprecation::possibly_delete_note(); + + require_once WCPAY_ABSPATH . 'includes/notes/class-wc-payments-notes-canceled-auth-remediation.php'; + WC_Payments_Notes_Canceled_Auth_Remediation::possibly_delete_note(); } } diff --git a/includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php b/includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php new file mode 100644 index 00000000000..7f4ff043fc6 --- /dev/null +++ b/includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php @@ -0,0 +1,682 @@ + 0, + 'remediated' => 0, + 'skipped' => 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 ); + } + + /** + * 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'; + + // 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 DISTINCT o.id + FROM {$orders_table} o + INNER JOIN {$meta_table} pm_status ON o.id = pm_status.order_id + LEFT JOIN {$meta_table} pm_fee ON o.id = pm_fee.order_id + AND pm_fee.meta_key IN ('_wcpay_transaction_fee', '_wcpay_net') + LEFT JOIN {$orders_table} refunds ON o.id = refunds.parent_order_id + AND refunds.type = 'shop_order_refund' + WHERE o.type = 'shop_order' + AND o.date_created_gmt >= %s + AND pm_status.meta_key = '_intention_status' + AND pm_status.meta_value = %s + AND (pm_fee.order_id IS NOT NULL OR refunds.id IS NOT NULL OR o.status = 'wc-refunded') + "; + + $params = [ self::BUG_START_DATE, Intent_Status::CANCELED ]; + + // Add offset based on last order ID. + if ( $last_order_id > 0 ) { + $sql .= ' AND o.id > %d'; + $params[] = $last_order_id; + } + + // Add ordering and limit. + $sql .= ' ORDER BY o.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 DISTINCT p.ID + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id + LEFT JOIN {$wpdb->postmeta} pm_fee ON p.ID = pm_fee.post_id + AND pm_fee.meta_key IN ('_wcpay_transaction_fee', '_wcpay_net') + LEFT JOIN {$wpdb->posts} refunds ON p.ID = refunds.post_parent + AND refunds.post_type = 'shop_order_refund' + WHERE p.post_type IN ('shop_order', 'shop_order_placeholder') + AND p.post_date >= %s + AND pm_status.meta_key = '_intention_status' + AND pm_status.meta_value = %s + AND (pm_fee.post_id IS NOT NULL OR refunds.ID IS NOT NULL OR p.post_status = 'wc-refunded') + "; + + $params = [ self::BUG_START_DATE, Intent_Status::CANCELED ]; + + // Add offset based on last order ID. + if ( $last_order_id > 0 ) { + $sql .= ' AND p.ID > %d'; + $params[] = $last_order_id; + } + + // Add ordering and limit. + $sql .= ' ORDER BY p.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 ) { + $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; + } + + $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' ] + ); + + // Schedule next batch if we got a full batch (indicates more to process). + if ( count( $orders ) === $batch_size ) { + $this->schedule_next_batch(); + } else { + // Last partial batch - mark complete. + $this->mark_complete(); + $this->log_completion(); + $this->cleanup(); + } + } + + /** + * 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' + ); + } + + /** + * Log completion. + * + * @return void + */ + private function log_completion(): void { + $stats = $this->get_stats(); + wc_get_logger()->info( + sprintf( + 'Remediation complete. Processed: %d, Remediated: %d, Skipped: %d, Errors: %d', + $stats['processed'], + $stats['remediated'], + $stats['skipped'], + $stats['errors'] + ), + [ 'source' => 'wcpay-fee-remediation' ] + ); + } + + /** + * Remediate a single order. + * + * @param WC_Order $order Order to remediate. + * @return bool True on success, false on failure. + */ + public function remediate_order( WC_Order $order ): 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 total WCPay refund amount and delete them. + $deleted_refund_ids = []; + foreach ( $wcpay_refunds as $refund ) { + $wcpay_refund_total += abs( $refund->get_amount() ); + $deleted_refund_ids[] = $refund->get_id(); + $refund->delete( true ); // Force delete, bypass trash. + } + + // Delete orphaned refund stats from wp_wc_order_stats. + // WooCommerce doesn't automatically clean these up when refunds are deleted. + $this->delete_refund_stats( $deleted_refund_ids ); + + // 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. + $status_changed = false; + if ( 'refunded' === $current_status ) { + $order->set_status( 'cancelled', '', false ); // Don't trigger status change emails. + $status_changed = true; + } + + // Build detailed note. + $note_parts = [ 'Removed incorrect data from canceled authorization:' ]; + + if ( $status_changed ) { + $note_parts[] = '- Changed order status from "Refunded" to "Cancelled"'; + } + + if ( $wcpay_refund_count > 0 ) { + $note_parts[] = sprintf( + '- Deleted %d WooPayments refund object%s (IDs: %s) totaling %s', + $wcpay_refund_count, + $wcpay_refund_count > 1 ? 's' : '', + implode( ', ', $deleted_refund_ids ), + wc_price( $wcpay_refund_total, [ 'currency' => $order->get_currency() ] ) + ); + } + + if ( ! empty( $fee ) ) { + $note_parts[] = sprintf( + '- Removed transaction fee: %s', + wc_price( $fee, [ 'currency' => $order->get_currency() ] ) + ); + } + + if ( ! empty( $net ) ) { + $note_parts[] = sprintf( + '- Removed net amount: %s', + wc_price( $net, [ 'currency' => $order->get_currency() ] ) + ); + } + + $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(); + + // Trigger analytics sync to update wc_order_stats table. This is necessary because + // WooCommerce doesn't automatically sync when refunds are deleted (see issue #1073). + $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. + * + * WooCommerce doesn't automatically update the wc_order_stats table when refunds are deleted. + * This method ensures the order stats are updated after remediation. + * + * @see https://github.com/woocommerce/woocommerce-admin/issues/1073 + * + * @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 refund stats from wp_wc_order_stats table. + * + * When refund objects are deleted with $refund->delete(), WooCommerce doesn't + * automatically clean up the corresponding entries in wp_wc_order_stats. + * This causes orphaned negative values in analytics reports. + * + * @param array $refund_ids Array of refund order IDs to delete stats for. + * @return void + */ + protected function delete_refund_stats( array $refund_ids ): void { + if ( empty( $refund_ids ) ) { + return; + } + + global $wpdb; + + $placeholders = implode( ', ', array_fill( 0, count( $refund_ids ), '%d' ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}wc_order_stats WHERE order_id IN ({$placeholders})", $refund_ids ) ); + } + + /** + * 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(); + + 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' ] + ); + } + } + + /** + * 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 ); + } +} diff --git a/includes/notes/class-wc-payments-notes-canceled-auth-remediation.php b/includes/notes/class-wc-payments-notes-canceled-auth-remediation.php new file mode 100644 index 00000000000..86ff8047008 --- /dev/null +++ b/includes/notes/class-wc-payments-notes-canceled-auth-remediation.php @@ -0,0 +1,112 @@ +set_title( __( 'WooPayments: Fix incorrect order data', 'woocommerce-payments' ) ); + $note->set_content( + __( + 'Some orders with canceled payment authorizations have incorrect data that may cause negative values in your WooCommerce Analytics. This affects stores using manual capture (authorize and capture separately). Run the fix tool to correct this.', + 'woocommerce-payments' + ) + ); + $note->set_content_data( (object) [] ); + $note->set_type( Note::E_WC_ADMIN_NOTE_WARNING ); + $note->set_name( self::NOTE_NAME ); + $note->set_source( 'woocommerce-payments' ); + $note->add_action( + 'run-remediation-tool', + __( 'Go to Tools page', 'woocommerce-payments' ), + admin_url( self::NOTE_TOOLS_URL ), + 'actioned', + false + ); + + return $note; + } + + /** + * Check if there are orders that need remediation. + * + * @return bool + */ + private static function has_affected_orders() { + include_once WCPAY_ABSPATH . 'includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; + $remediation = new WC_Payments_Remediate_Canceled_Auth_Fees(); + return $remediation->has_affected_orders(); + } + + /** + * Check if remediation is currently running. + * + * @return bool + */ + private static function is_remediation_running() { + if ( ! function_exists( 'as_has_scheduled_action' ) ) { + return false; + } + + include_once WCPAY_ABSPATH . 'includes/migrations/class-wc-payments-remediate-canceled-auth-fees.php'; + return as_has_scheduled_action( WC_Payments_Remediate_Canceled_Auth_Fees::ACTION_HOOK ); + } +} diff --git a/tests/unit/migrations/test-class-wc-payments-remediate-canceled-auth-fees.php b/tests/unit/migrations/test-class-wc-payments-remediate-canceled-auth-fees.php new file mode 100644 index 00000000000..99ae69b7189 --- /dev/null +++ b/tests/unit/migrations/test-class-wc-payments-remediate-canceled-auth-fees.php @@ -0,0 +1,715 @@ +remediation = new WC_Payments_Remediate_Canceled_Auth_Fees(); + + // Clean up options before each test. + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::STATUS_OPTION_KEY ); + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::LAST_ORDER_ID_OPTION_KEY ); + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::BATCH_SIZE_OPTION_KEY ); + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::STATS_OPTION_KEY ); + } + + /** + * Tear down test. + */ + public function tear_down() { + // Clean up options after each test. + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::STATUS_OPTION_KEY ); + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::LAST_ORDER_ID_OPTION_KEY ); + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::BATCH_SIZE_OPTION_KEY ); + delete_option( WC_Payments_Remediate_Canceled_Auth_Fees::STATS_OPTION_KEY ); + + // Clean up any scheduled actions. + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( WC_Payments_Remediate_Canceled_Auth_Fees::ACTION_HOOK ); + } + + parent::tear_down(); + } + + public function test_class_exists() { + $this->assertInstanceOf( WC_Payments_Remediate_Canceled_Auth_Fees::class, $this->remediation ); + } + + public function test_is_complete_returns_false_when_not_started() { + $this->assertFalse( $this->remediation->is_complete() ); + } + + public function test_is_complete_returns_true_when_marked_complete() { + update_option( WC_Payments_Remediate_Canceled_Auth_Fees::STATUS_OPTION_KEY, 'completed' ); + $this->assertTrue( $this->remediation->is_complete() ); + } + + public function test_get_batch_size_returns_initial_size_when_not_set() { + $this->assertEquals( 20, $this->remediation->get_batch_size() ); + } + + public function test_update_batch_size_stores_value() { + $this->remediation->update_batch_size( 50 ); + $this->assertEquals( 50, $this->remediation->get_batch_size() ); + } + + public function test_get_last_order_id_returns_zero_when_not_set() { + $this->assertEquals( 0, $this->remediation->get_last_order_id() ); + } + + public function test_update_last_order_id_stores_value() { + $this->remediation->update_last_order_id( 123 ); + $this->assertEquals( 123, $this->remediation->get_last_order_id() ); + } + + public function test_get_stats_returns_empty_array_when_not_set() { + $expected = [ + 'processed' => 0, + 'remediated' => 0, + 'skipped' => 0, + 'errors' => 0, + ]; + $this->assertEquals( $expected, $this->remediation->get_stats() ); + } + + public function test_increment_stat_updates_counter() { + $this->remediation->increment_stat( 'processed' ); + $this->remediation->increment_stat( 'processed' ); + $stats = $this->remediation->get_stats(); + $this->assertEquals( 2, $stats['processed'] ); + } + + public function test_get_affected_orders_returns_canceled_orders_with_fees() { + // Create order with canceled intent and fees. + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + + $orders = $this->remediation->get_affected_orders( 10 ); + + $this->assertCount( 1, $orders ); + $this->assertEquals( $order->get_id(), $orders[0]->get_id() ); + } + + public function test_get_affected_orders_excludes_orders_before_bug_date() { + // Create order before bug introduction. + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-03-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + + $orders = $this->remediation->get_affected_orders( 10 ); + + $this->assertCount( 0, $orders ); + } + + public function test_get_affected_orders_excludes_orders_without_canceled_status() { + // Create order with succeeded intent. + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::SUCCEEDED ); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + + $orders = $this->remediation->get_affected_orders( 10 ); + + $this->assertCount( 0, $orders ); + } + + public function test_get_affected_orders_excludes_orders_without_fees_or_refunds() { + // Create order with canceled intent but no fees and no refunds. + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->save(); + + $orders = $this->remediation->get_affected_orders( 10 ); + + $this->assertCount( 0, $orders ); + } + + public function test_get_affected_orders_finds_orders_with_refunds_but_no_fees() { + // Create order with canceled intent and refund, but no fee metadata. + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->save(); + + // Create a refund for this order. + wc_create_refund( + [ + 'order_id' => $order->get_id(), + 'amount' => 10.00, + 'reason' => 'Test refund', + ] + ); + + $orders = $this->remediation->get_affected_orders( 10 ); + + $this->assertCount( 1, $orders ); + $this->assertEquals( $order->get_id(), $orders[0]->get_id() ); + } + + public function test_get_affected_orders_respects_batch_size() { + // Create 5 affected orders. + for ( $i = 0; $i < 5; $i++ ) { + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + } + + $orders = $this->remediation->get_affected_orders( 3 ); + + $this->assertCount( 3, $orders ); + } + + public function test_get_affected_orders_uses_offset_from_last_order_id() { + // Create 3 affected orders. + $order1 = WC_Helper_Order::create_order(); + $order1->set_date_created( '2023-05-01' ); + $order1->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order1->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order1->save(); + + $order2 = WC_Helper_Order::create_order(); + $order2->set_date_created( '2023-05-02' ); + $order2->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order2->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order2->save(); + + $order3 = WC_Helper_Order::create_order(); + $order3->set_date_created( '2023-05-03' ); + $order3->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order3->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order3->save(); + + // Set last order ID to skip first order. + $this->remediation->update_last_order_id( $order1->get_id() ); + + $orders = $this->remediation->get_affected_orders( 10 ); + + $this->assertCount( 2, $orders ); + $this->assertEquals( $order2->get_id(), $orders[0]->get_id() ); + } + + public function test_remediate_order_removes_fee_metadata() { + $order = WC_Helper_Order::create_order(); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->update_meta_data( '_wcpay_net', '48.50' ); + $order->save(); + + $this->remediation->remediate_order( $order ); + + $order = wc_get_order( $order->get_id() ); // Refresh. + $this->assertEquals( '', $order->get_meta( '_wcpay_transaction_fee', true ) ); + $this->assertEquals( '', $order->get_meta( '_wcpay_net', true ) ); + } + + public function test_remediate_order_deletes_wcpay_refund_objects() { + $order = WC_Helper_Order::create_order(); + $order->save(); + + // Create a WCPay refund (has _wcpay_refund_id metadata). + $refund = wc_create_refund( + [ + 'order_id' => $order->get_id(), + 'amount' => 10.00, + 'reason' => 'Test refund', + ] + ); + $refund->update_meta_data( '_wcpay_refund_id', 're_test123' ); + $refund->save(); + + $this->assertCount( 1, $order->get_refunds() ); + + $this->remediation->remediate_order( $order ); + + $order = wc_get_order( $order->get_id() ); // Refresh. + $this->assertCount( 0, $order->get_refunds() ); + } + + public function test_remediate_order_preserves_non_wcpay_refund_objects() { + $order = WC_Helper_Order::create_order(); + $order->save(); + + // Create a non-WCPay refund (no _wcpay_refund_id metadata). + wc_create_refund( + [ + 'order_id' => $order->get_id(), + 'amount' => 10.00, + 'reason' => 'Manual refund', + ] + ); + + $this->assertCount( 1, $order->get_refunds() ); + + $this->remediation->remediate_order( $order ); + + $order = wc_get_order( $order->get_id() ); // Refresh. + // Non-WCPay refunds should be preserved. + $this->assertCount( 1, $order->get_refunds() ); + } + + public function test_remediate_order_deletes_only_wcpay_refunds_among_mixed() { + $order = WC_Helper_Order::create_order(); + $order->save(); + + // Create a WCPay refund. + $wcpay_refund = wc_create_refund( + [ + 'order_id' => $order->get_id(), + 'amount' => 10.00, + 'reason' => 'WCPay refund', + ] + ); + $wcpay_refund->update_meta_data( '_wcpay_refund_id', 're_test123' ); + $wcpay_refund->save(); + + // Create a manual refund. + wc_create_refund( + [ + 'order_id' => $order->get_id(), + 'amount' => 5.00, + 'reason' => 'Manual refund', + ] + ); + + $this->assertCount( 2, $order->get_refunds() ); + + $this->remediation->remediate_order( $order ); + + $order = wc_get_order( $order->get_id() ); // Refresh. + // Only the manual refund should remain. + $refunds = $order->get_refunds(); + $this->assertCount( 1, $refunds ); + $this->assertEquals( 'Manual refund', $refunds[0]->get_reason() ); + } + + public function test_remediate_order_adds_detailed_note() { + $order = WC_Helper_Order::create_order(); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->update_meta_data( '_wcpay_net', '48.50' ); + $order->save(); + + // Create a WCPay refund (has _wcpay_refund_id metadata). + $refund = wc_create_refund( + [ + 'order_id' => $order->get_id(), + 'amount' => 10.00, + 'reason' => 'Test refund', + ] + ); + $refund->update_meta_data( '_wcpay_refund_id', 're_test123' ); + $refund->save(); + + $initial_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) ); + + $this->remediation->remediate_order( $order ); + + $notes = wc_get_order_notes( [ 'order_id' => $order->get_id() ] ); + $new_notes = array_slice( $notes, 0, count( $notes ) - $initial_notes_count ); + + $this->assertCount( 1, $new_notes ); + $this->assertStringContainsString( 'Removed incorrect data from canceled authorization', $new_notes[0]->content ); + $this->assertStringContainsString( 'WooPayments refund object', $new_notes[0]->content ); + $this->assertStringContainsString( 'transaction fee', $new_notes[0]->content ); + } + + public function test_remediate_order_returns_true_on_success() { + $order = WC_Helper_Order::create_order(); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + + $result = $this->remediation->remediate_order( $order ); + + $this->assertTrue( $result ); + } + + public function test_remediate_order_handles_missing_fee_gracefully() { + $order = WC_Helper_Order::create_order(); + $order->save(); + + $result = $this->remediation->remediate_order( $order ); + + $this->assertTrue( $result ); + } + + public function test_remediate_order_changes_refunded_status_to_cancelled() { + $order = WC_Helper_Order::create_order(); + $order->set_status( 'refunded' ); + $order->save(); + + $this->assertEquals( 'refunded', $order->get_status() ); + + $this->remediation->remediate_order( $order ); + + $order = wc_get_order( $order->get_id() ); // Refresh. + $this->assertEquals( 'cancelled', $order->get_status() ); + } + + public function test_remediate_order_does_not_change_non_refunded_status() { + $order = WC_Helper_Order::create_order(); + $order->set_status( 'on-hold' ); + $order->save(); + + $this->assertEquals( 'on-hold', $order->get_status() ); + + $this->remediation->remediate_order( $order ); + + $order = wc_get_order( $order->get_id() ); // Refresh. + $this->assertEquals( 'on-hold', $order->get_status() ); + } + + public function test_remediate_order_adds_status_change_to_note() { + $order = WC_Helper_Order::create_order(); + $order->set_status( 'refunded' ); + $order->save(); + + $initial_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) ); + + $this->remediation->remediate_order( $order ); + + $notes = wc_get_order_notes( [ 'order_id' => $order->get_id() ] ); + $new_notes = array_slice( $notes, 0, count( $notes ) - $initial_notes_count ); + + // Check that our remediation note contains the status change info. + // Note: WooCommerce may add additional notes when status changes. + $found_remediation_note = false; + foreach ( $new_notes as $note ) { + if ( strpos( $note->content, 'Changed order status from "Refunded" to "Cancelled"' ) !== false ) { + $found_remediation_note = true; + break; + } + } + $this->assertTrue( $found_remediation_note, 'Remediation note with status change should be present' ); + } + + public function test_get_affected_orders_finds_orders_with_refunded_status() { + // Create order with canceled intent and refunded status (no fees, no refunds). + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->set_status( 'refunded' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->save(); + + $orders = $this->remediation->get_affected_orders( 10 ); + + $this->assertCount( 1, $orders ); + $this->assertEquals( $order->get_id(), $orders[0]->get_id() ); + } + + public function test_adjust_batch_size_doubles_on_fast_execution() { + $this->remediation->update_batch_size( 20 ); + $this->remediation->adjust_batch_size( 3 ); // 3 seconds < 5 seconds. + + $this->assertEquals( 40, $this->remediation->get_batch_size() ); + } + + public function test_adjust_batch_size_halves_on_slow_execution() { + $this->remediation->update_batch_size( 40 ); + $this->remediation->adjust_batch_size( 25 ); // 25 seconds > 20 seconds. + + $this->assertEquals( 20, $this->remediation->get_batch_size() ); + } + + public function test_adjust_batch_size_unchanged_on_good_execution() { + $this->remediation->update_batch_size( 30 ); + $this->remediation->adjust_batch_size( 10 ); // 10 seconds is between 5 and 20. + + $this->assertEquals( 30, $this->remediation->get_batch_size() ); + } + + public function test_adjust_batch_size_respects_minimum() { + $this->remediation->update_batch_size( 10 ); + $this->remediation->adjust_batch_size( 25 ); // Try to halve to 5. + + $this->assertEquals( 10, $this->remediation->get_batch_size() ); // Should stay at minimum. + } + + public function test_adjust_batch_size_respects_maximum() { + $this->remediation->update_batch_size( 100 ); + $this->remediation->adjust_batch_size( 3 ); // Try to double to 200. + + $this->assertEquals( 100, $this->remediation->get_batch_size() ); // Should stay at maximum. + } + + public function test_process_batch_remediates_affected_orders() { + // Create 3 affected orders. + for ( $i = 0; $i < 3; $i++ ) { + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + } + + $this->remediation->process_batch(); + + // Stats should be preserved after completion for display in Tools page. + $stats = $this->remediation->get_stats(); + $this->assertEquals( 3, $stats['processed'] ); + $this->assertEquals( 3, $stats['remediated'] ); + } + + public function test_process_batch_updates_last_order_id() { + $order1 = WC_Helper_Order::create_order(); + $order1->set_date_created( '2023-05-01' ); + $order1->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order1->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order1->save(); + + $order2 = WC_Helper_Order::create_order(); + $order2->set_date_created( '2023-05-02' ); + $order2->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order2->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order2->save(); + + $this->remediation->process_batch(); + + // After completion with partial batch, cleanup() is called and last_order_id is deleted. + $this->assertEquals( 0, $this->remediation->get_last_order_id() ); + } + + public function test_process_batch_marks_complete_when_no_orders() { + $this->remediation->process_batch(); + + // Status should be preserved as 'completed' for display in Tools page. + $this->assertTrue( $this->remediation->is_complete() ); + } + + public function test_process_batch_increments_error_count_on_failure() { + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + + // Create a mock to force remediate_order to fail. + $mock_remediation = $this->getMockBuilder( WC_Payments_Remediate_Canceled_Auth_Fees::class ) + ->onlyMethods( [ 'remediate_order' ] ) + ->getMock(); + + $mock_remediation->method( 'remediate_order' )->willReturn( false ); + + $mock_remediation->process_batch(); + + // Stats should be preserved after completion for display in Tools page. + $stats = $mock_remediation->get_stats(); + $this->assertEquals( 1, $stats['errors'] ); + } + + public function test_schedule_remediation_schedules_action() { + $this->remediation->schedule_remediation(); + + // Should have scheduled the action. + $this->assertTrue( as_has_scheduled_action( WC_Payments_Remediate_Canceled_Auth_Fees::ACTION_HOOK ) ); + + // Should have marked as running. + $this->assertEquals( 'running', get_option( WC_Payments_Remediate_Canceled_Auth_Fees::STATUS_OPTION_KEY ) ); + } + + public function test_has_affected_orders_returns_true_when_orders_exist() { + // Create an affected order. + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + + $this->assertTrue( $this->remediation->has_affected_orders() ); + } + + public function test_has_affected_orders_returns_false_when_no_orders() { + $this->assertFalse( $this->remediation->has_affected_orders() ); + } + + public function test_init_hooks_into_action_scheduler() { + $remediation = new WC_Payments_Remediate_Canceled_Auth_Fees(); + $remediation->init(); + + $this->assertEquals( + 10, + has_action( WC_Payments_Remediate_Canceled_Auth_Fees::ACTION_HOOK, [ $remediation, 'process_batch' ] ) + ); + } + + public function test_get_affected_orders_uses_hpos_when_enabled() { + // Create a mock that forces HPOS mode. + $mock_remediation = $this->getMockBuilder( WC_Payments_Remediate_Canceled_Auth_Fees::class ) + ->onlyMethods( [ 'is_hpos_enabled' ] ) + ->getMock(); + + $mock_remediation->method( 'is_hpos_enabled' )->willReturn( true ); + + // HPOS tables don't exist in the test environment, so this should return empty. + // This test verifies the HPOS code path is taken without errors. + $orders = $mock_remediation->get_affected_orders( 10 ); + + $this->assertIsArray( $orders ); + } + + public function test_get_affected_orders_uses_cpt_when_hpos_disabled() { + // Create a mock that forces CPT mode. + $mock_remediation = $this->getMockBuilder( WC_Payments_Remediate_Canceled_Auth_Fees::class ) + ->onlyMethods( [ 'is_hpos_enabled' ] ) + ->getMock(); + + $mock_remediation->method( 'is_hpos_enabled' )->willReturn( false ); + + // Create order with canceled intent and fees. + $order = WC_Helper_Order::create_order(); + $order->set_date_created( '2023-05-01' ); + $order->update_meta_data( '_intention_status', Intent_Status::CANCELED ); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + + $orders = $mock_remediation->get_affected_orders( 10 ); + + $this->assertCount( 1, $orders ); + $this->assertEquals( $order->get_id(), $orders[0]->get_id() ); + } + + public function test_is_hpos_enabled_returns_boolean() { + // Use reflection to test protected method. + $reflection = new ReflectionMethod( WC_Payments_Remediate_Canceled_Auth_Fees::class, 'is_hpos_enabled' ); + $reflection->setAccessible( true ); + + $result = $reflection->invoke( $this->remediation ); + + $this->assertIsBool( $result ); + } + + public function test_sync_order_stats_does_not_throw_when_class_unavailable() { + // Use reflection to test protected method. + $reflection = new ReflectionMethod( WC_Payments_Remediate_Canceled_Auth_Fees::class, 'sync_order_stats' ); + $reflection->setAccessible( true ); + + // This should not throw, even if OrdersStatsDataStore is unavailable. + $reflection->invoke( $this->remediation, 123 ); + + // If we get here without exception, the test passes. + $this->assertTrue( true ); + } + + public function test_remediate_order_calls_sync_order_stats() { + // Create a mock that tracks if sync_order_stats is called. + $mock_remediation = $this->getMockBuilder( WC_Payments_Remediate_Canceled_Auth_Fees::class ) + ->onlyMethods( [ 'sync_order_stats' ] ) + ->getMock(); + + $order = WC_Helper_Order::create_order(); + $order->update_meta_data( '_wcpay_transaction_fee', '1.50' ); + $order->save(); + + $mock_remediation->expects( $this->once() ) + ->method( 'sync_order_stats' ) + ->with( $order->get_id() ); + + $mock_remediation->remediate_order( $order ); + } + + public function test_remediate_order_calls_delete_refund_stats_with_refund_ids() { + // Create a mock that tracks if delete_refund_stats is called with correct IDs. + $mock_remediation = $this->getMockBuilder( WC_Payments_Remediate_Canceled_Auth_Fees::class ) + ->onlyMethods( [ 'delete_refund_stats', 'sync_order_stats' ] ) + ->getMock(); + + $order = WC_Helper_Order::create_order(); + $order->save(); + + // Create a WCPay refund. + $refund = wc_create_refund( + [ + 'order_id' => $order->get_id(), + 'amount' => 10.00, + 'reason' => 'Test refund', + ] + ); + $refund->update_meta_data( '_wcpay_refund_id', 're_test123' ); + $refund->save(); + + $refund_id = $refund->get_id(); + + $mock_remediation->expects( $this->once() ) + ->method( 'delete_refund_stats' ) + ->with( [ $refund_id ] ); + + $mock_remediation->remediate_order( $order ); + } + + public function test_delete_refund_stats_removes_entries_from_order_stats() { + global $wpdb; + + // Insert fake refund stats entries. + $wpdb->insert( + $wpdb->prefix . 'wc_order_stats', + [ + 'order_id' => 99991, + 'parent_id' => 99990, + 'net_total' => -50, + 'total_sales' => -50, + 'status' => 'wc-refunded', + ] + ); + $wpdb->insert( + $wpdb->prefix . 'wc_order_stats', + [ + 'order_id' => 99992, + 'parent_id' => 99990, + 'net_total' => -75, + 'total_sales' => -75, + 'status' => 'wc-refunded', + ] + ); + + // Verify entries exist. + $count_before = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats WHERE order_id IN (99991, 99992)" ); + $this->assertEquals( 2, $count_before ); + + // Use reflection to call protected method. + $reflection = new ReflectionMethod( WC_Payments_Remediate_Canceled_Auth_Fees::class, 'delete_refund_stats' ); + $reflection->setAccessible( true ); + $reflection->invoke( $this->remediation, [ 99991, 99992 ] ); + + // Verify entries are deleted. + $count_after = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats WHERE order_id IN (99991, 99992)" ); + $this->assertEquals( 0, $count_after ); + } + + public function test_delete_refund_stats_does_nothing_with_empty_array() { + // Use reflection to call protected method. + $reflection = new ReflectionMethod( WC_Payments_Remediate_Canceled_Auth_Fees::class, 'delete_refund_stats' ); + $reflection->setAccessible( true ); + + // This should not throw or cause any issues. + $reflection->invoke( $this->remediation, [] ); + + // If we get here without exception, the test passes. + $this->assertTrue( true ); + } +} diff --git a/tests/unit/notes/test-class-wc-payments-notes-canceled-auth-remediation.php b/tests/unit/notes/test-class-wc-payments-notes-canceled-auth-remediation.php new file mode 100644 index 00000000000..e36557a5149 --- /dev/null +++ b/tests/unit/notes/test-class-wc-payments-notes-canceled-auth-remediation.php @@ -0,0 +1,61 @@ +assertInstanceOf( 'Automattic\WooCommerce\Admin\Notes\Note', $note ); + $this->assertEquals( 'WooPayments: Fix incorrect order data', $note->get_title() ); + $this->assertStringContainsString( 'canceled payment authorizations', $note->get_content() ); + $this->assertStringContainsString( 'negative values', $note->get_content() ); + $this->assertEquals( 'warning', $note->get_type() ); + $this->assertEquals( 'wc-payments-notes-canceled-auth-remediation', $note->get_name() ); + $this->assertEquals( 'woocommerce-payments', $note->get_source() ); + + $actions = $note->get_actions(); + $this->assertCount( 1, $actions ); + $this->assertEquals( 'Go to Tools page', $actions[0]->label ); + $this->assertStringContainsString( 'wc-status&tab=tools', $actions[0]->query ); + } + + /** + * Tests that note cannot be added when remediation is complete. + */ + public function test_can_be_added_returns_false_when_complete() { + update_option( 'wcpay_fee_remediation_status', 'completed' ); + + $result = WC_Payments_Notes_Canceled_Auth_Remediation::can_be_added(); + + $this->assertFalse( $result ); + } +}