> */ private $request_context_by_order = []; public static function instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } private function __construct() { if ( ! CheckoutTraceConfig::is_enabled() ) { return; } add_action( 'mollie-payments-for-woocommerce_create_payment', [ $this, 'capture_create_payment' ], 10, 2 ); add_filter( 'mollie-payments-for-woocommerce_return_url', [ $this, 'capture_return_url' ], 10, 2 ); add_filter( 'mollie-payments-for-woocommerce_webhook_url', [ $this, 'capture_webhook_url' ], 10, 2 ); add_filter( 'rest_request_after_callbacks', [ $this, 'capture_checkout_failure_details' ], 20, 3 ); } /** * @param array $data * @param mixed $order */ public function capture_create_payment( $data, $order ): void { $order_id = $this->resolve_order_id( $order ); if ( $order_id <= 0 ) { return; } $gateway_id = $this->resolve_gateway_id( $order ); if ( ! $this->is_mollie_gateway( $gateway_id ) ) { return; } $entry = $this->get_request_context( $order_id ); $entry['order_id'] = $order_id; $entry['gateway_id'] = $gateway_id; $entry['payment_request_type'] = $this->resolve_request_type( $data ); $entry['request_method'] = sanitize_key( (string) ( $data['method'] ?? '' ) ); $entry['request_locale'] = sanitize_text_field( (string) ( $data['locale'] ?? '' ) ); $entry['payment_keys'] = $this->sanitize_keys( is_array( $data['payment'] ?? null ) ? array_keys( $data['payment'] ) : [] ); $entry['has_card_token'] = ! empty( $data['payment']['cardToken'] ); $entry['line_count'] = is_array( $data['lines'] ?? null ) ? count( $data['lines'] ) : 0; if ( isset( $data['redirectUrl'] ) ) { $entry['redirect_url'] = (string) $data['redirectUrl']; } if ( isset( $data['webhookUrl'] ) ) { $entry['webhook_url'] = (string) $data['webhookUrl']; } $this->request_context_by_order[ $order_id ] = $entry; } /** * @param mixed $order */ public function capture_return_url( string $return_url, $order ): string { $order_id = $this->resolve_order_id( $order ); if ( $order_id > 0 ) { $entry = $this->get_request_context( $order_id ); $entry['redirect_url'] = $return_url; $entry['gateway_id'] = $this->resolve_gateway_id( $order ); $this->request_context_by_order[ $order_id ] = $entry; } return $return_url; } /** * @param mixed $order */ public function capture_webhook_url( string $webhook_url, $order ): string { $order_id = $this->resolve_order_id( $order ); if ( $order_id > 0 ) { $entry = $this->get_request_context( $order_id ); $entry['webhook_url'] = $webhook_url; $entry['gateway_id'] = $this->resolve_gateway_id( $order ); $this->request_context_by_order[ $order_id ] = $entry; } return $webhook_url; } /** * @param mixed $response * @param array $handler * @return mixed */ public function capture_checkout_failure_details( $response, array $handler, WP_REST_Request $request ) { if ( ! $this->is_store_checkout_request( $request ) ) { return $response; } $trace_id = $this->get_trace_id( $request ); if ( '' === $trace_id ) { return $response; } $status_code = 200; if ( is_wp_error( $response ) ) { $status_code = 400; } elseif ( $response instanceof WP_REST_Response ) { $status_code = (int) $response->get_status(); } $gateway_id = sanitize_key( (string) $request->get_param( 'payment_method' ) ); if ( $status_code < 400 || ! $this->is_mollie_gateway( $gateway_id ) ) { return $response; } $failure_summary = $this->build_failure_summary( $gateway_id ); $message = (string) ( $failure_summary['message'] ?? '' ); CheckoutTraceService::record( [ 'traceId' => $trace_id, 'event' => 'mollie_failure_probe', 'step' => 'review', 'status' => 'error', 'origin' => 'backend', 'gatewayId' => $gateway_id, 'message' => $message, 'context' => [ 'order_id' => (int) ( $failure_summary['order_id'] ?? 0 ), 'payment_request_type' => sanitize_key( (string) ( $failure_summary['payment_request_type'] ?? '' ) ), 'request_method' => sanitize_key( (string) ( $failure_summary['request_method'] ?? '' ) ), 'request_locale' => sanitize_text_field( (string) ( $failure_summary['request_locale'] ?? '' ) ), 'has_card_token' => ! empty( $failure_summary['has_card_token'] ), 'redirect_url_host' => sanitize_text_field( (string) ( $failure_summary['redirect_url_host'] ?? '' ) ), 'redirect_url_scheme' => sanitize_key( (string) ( $failure_summary['redirect_url_scheme'] ?? '' ) ), 'webhook_url_host' => sanitize_text_field( (string) ( $failure_summary['webhook_url_host'] ?? '' ) ), 'webhook_url_scheme' => sanitize_key( (string) ( $failure_summary['webhook_url_scheme'] ?? '' ) ), 'webhook_is_local' => ! empty( $failure_summary['is_local'] ), 'webhook_uses_https' => ! empty( $failure_summary['uses_https'] ), 'likely_failure_reason' => sanitize_key( (string) ( $failure_summary['reason'] ?? '' ) ), 'api_error_field' => sanitize_key( (string) ( $failure_summary['field'] ?? '' ) ), 'api_error_status' => (int) ( $failure_summary['status'] ?? 0 ), 'mollie_log_excerpt' => is_array( $failure_summary['excerpt'] ?? null ) ? $failure_summary['excerpt'] : [], ], ] ); return $response; } /** * @return array */ public function get_failure_summary( string $gateway_id ): array { return $this->build_failure_summary( sanitize_key( $gateway_id ) ); } /** * @return array */ private function resolve_failed_order_context( string $gateway_id ): array { $matching = []; foreach ( $this->request_context_by_order as $order_id => $context ) { if ( sanitize_key( (string) ( $context['gateway_id'] ?? '' ) ) !== $gateway_id ) { continue; } $matching[ $order_id ] = $context; } if ( [] === $matching ) { return []; } end( $matching ); $latest = current( $matching ); return is_array( $latest ) ? $latest : []; } /** * @return array */ private function probe_mollie_log( int $order_id ): array { $log_file = $this->resolve_mollie_log_file(); if ( '' === $log_file || ! is_readable( $log_file ) ) { return []; } $tail_lines = $this->read_log_tail_lines( $log_file, 120 ); $excerpt = []; foreach ( array_reverse( $tail_lines ) as $line ) { $normalized = html_entity_decode( wp_strip_all_tags( (string) $line ), ENT_QUOTES, 'UTF-8' ); $normalized = trim( preg_replace( '/\s+/', ' ', $normalized ) ); if ( '' === $normalized ) { continue; } if ( $order_id > 0 && false === strpos( $normalized, (string) $order_id ) && false === stripos( $normalized, 'webhook url' ) && false === stripos( $normalized, 'Error executing API call' ) ) { continue; } $excerpt[] = sanitize_text_field( $normalized ); if ( count( $excerpt ) >= 4 ) { break; } } $excerpt = array_reverse( $excerpt ); $joined = implode( ' | ', $excerpt ); return [ 'status' => $this->parse_api_status( $joined ), 'field' => $this->parse_api_field( $joined ), 'reason' => $this->parse_failure_reason( $joined ), 'excerpt' => $excerpt, ]; } private function resolve_mollie_log_file(): string { if ( is_callable( [ '\Automattic\WooCommerce\Utilities\LoggingUtil', 'get_log_directory' ] ) ) { // Respect Woo's configured directory without creating it during diagnostics. $log_dir = \Automattic\WooCommerce\Utilities\LoggingUtil::get_log_directory( false ); } else { // WooCommerce versions before the logging utility expose the resolved path here. $log_dir = defined( 'WC_LOG_DIR' ) ? (string) WC_LOG_DIR : ''; } if ( '' === $log_dir || ! is_dir( $log_dir ) ) { return ''; } $matches = glob( trailingslashit( $log_dir ) . 'mollie-payments-for-woocommerce-*.log' ); $matches = is_array( $matches ) ? array_values( array_filter( $matches, 'is_file' ) ) : []; if ( [] === $matches ) { return ''; } usort( $matches, static function ( string $left, string $right ): int { return filemtime( $right ) <=> filemtime( $left ); } ); return (string) $matches[0]; } /** * @return array */ private function read_log_tail_lines( string $log_file, int $max_lines ): array { $size = filesize( $log_file ); if ( false === $size || $size <= 0 ) { return []; } $offset = max( 0, $size - 65536 ); // Read a bounded tail, even when the provider log has grown very large. $buffer = file_get_contents( $log_file, false, null, $offset, 65536 ); if ( false === $buffer ) { return []; } $lines = preg_split( "/\r\n|\n|\r/", $buffer ); if ( ! is_array( $lines ) ) { return []; } return array_values( array_slice( $lines, -1 * absint( $max_lines ) ) ); } /** * @param mixed $order */ private function resolve_order_id( $order ): int { if ( is_object( $order ) && method_exists( $order, 'get_id' ) ) { return (int) $order->get_id(); } return 0; } /** * @param mixed $order */ private function resolve_gateway_id( $order ): string { if ( is_object( $order ) && method_exists( $order, 'get_payment_method' ) ) { return sanitize_key( (string) $order->get_payment_method() ); } return ''; } private function is_mollie_gateway( string $gateway_id ): bool { return 0 === strpos( $gateway_id, 'mollie_wc_gateway_' ); } /** * @param array $data */ private function resolve_request_type( array $data ): string { if ( isset( $data['orderNumber'] ) || isset( $data['lines'] ) ) { return 'order'; } return 'payment'; } /** * @param array $keys * @return array */ private function sanitize_keys( array $keys ): array { $sanitized = []; foreach ( array_slice( $keys, 0, 8 ) as $key ) { $key = sanitize_key( (string) $key ); if ( '' !== $key ) { $sanitized[] = $key; } } return array_values( array_unique( $sanitized ) ); } /** * @return array */ private function summarize_url( string $url ): array { if ( '' === $url ) { return []; } $host = (string) wp_parse_url( $url, PHP_URL_HOST ); $scheme = (string) wp_parse_url( $url, PHP_URL_SCHEME ); return [ 'host' => $host, 'scheme' => $scheme, 'is_local' => $this->is_local_host_value( $host ), ]; } private function is_local_host_value( string $host ): bool { $host = strtolower( $host ); if ( '' === $host ) { return false; } return in_array( $host, [ 'localhost', '127.0.0.1', '::1' ], true ) || '.local' === substr( $host, -6 ); } private function parse_api_status( string $message ): int { if ( preg_match( '/API call \\((\\d+):/i', $message, $matches ) ) { return (int) $matches[1]; } return 0; } private function parse_api_field( string $message ): string { if ( preg_match( '/Field:\\s*([A-Za-z0-9_]+)/i', $message, $matches ) ) { return (string) $matches[1]; } return ''; } private function parse_failure_reason( string $message ): string { $message = strtolower( $message ); if ( false !== strpos( $message, 'webhook url is invalid because it is unreachable' ) ) { return 'webhook_url_unreachable'; } if ( false !== strpos( $message, 'webhook url is invalid' ) ) { return 'webhook_url_invalid'; } if ( false !== strpos( $message, 'invalid phone number' ) ) { return 'invalid_phone_number'; } if ( false !== strpos( $message, 'suspected fraud' ) ) { return 'fraud_rejection'; } return ''; } /** * @return array */ private function get_request_context( int $order_id ): array { $existing = $this->request_context_by_order[ $order_id ] ?? []; return is_array( $existing ) ? $existing : []; } /** * @return array */ private function build_failure_summary( string $gateway_id ): array { if ( ! $this->is_mollie_gateway( $gateway_id ) ) { return []; } $order_context = $this->resolve_failed_order_context( $gateway_id ); $order_id = (int) ( $order_context['order_id'] ?? 0 ); $log_probe = $this->probe_mollie_log( $order_id ); $redirect_summary = $this->summarize_url( (string) ( $order_context['redirect_url'] ?? '' ) ); $webhook_summary = $this->summarize_url( (string) ( $order_context['webhook_url'] ?? '' ) ); return [ 'gateway_id' => $gateway_id, 'order_id' => $order_id, 'payment_request_type' => sanitize_key( (string) ( $order_context['payment_request_type'] ?? '' ) ), 'request_method' => sanitize_key( (string) ( $order_context['request_method'] ?? '' ) ), 'request_locale' => sanitize_text_field( (string) ( $order_context['request_locale'] ?? '' ) ), 'has_card_token' => ! empty( $order_context['has_card_token'] ), 'redirect_url_host' => sanitize_text_field( (string) ( $redirect_summary['host'] ?? '' ) ), 'redirect_url_scheme' => sanitize_key( (string) ( $redirect_summary['scheme'] ?? '' ) ), 'webhook_url_host' => sanitize_text_field( (string) ( $webhook_summary['host'] ?? '' ) ), 'webhook_url_scheme' => sanitize_key( (string) ( $webhook_summary['scheme'] ?? '' ) ), 'is_local' => ! empty( $webhook_summary['is_local'] ), 'uses_https' => 'https' === ( $webhook_summary['scheme'] ?? '' ), 'reason' => sanitize_key( (string) ( $log_probe['reason'] ?? '' ) ), 'field' => sanitize_key( (string) ( $log_probe['field'] ?? '' ) ), 'status' => (int) ( $log_probe['status'] ?? 0 ), 'excerpt' => is_array( $log_probe['excerpt'] ?? null ) ? $log_probe['excerpt'] : [], 'message' => $this->build_failure_message( $log_probe ), ]; } private function build_failure_message( array $log_probe ): string { $reason = sanitize_key( (string) ( $log_probe['reason'] ?? '' ) ); $field = sanitize_key( (string) ( $log_probe['field'] ?? '' ) ); $status = (int) ( $log_probe['status'] ?? 0 ); $excerpt = is_array( $log_probe['excerpt'] ?? null ) ? $log_probe['excerpt'] : []; if ( '' !== $reason ) { return trim( sprintf( '%s%s%s', $reason, $field ? ' (' . $field . ')' : '', $status > 0 ? ' [' . $status . ']' : '' ) ); } if ( ! empty( $excerpt ) ) { return (string) $excerpt[ count( $excerpt ) - 1 ]; } return 'mollie_failure_probe'; } private function is_store_checkout_request( WP_REST_Request $request ): bool { if ( 'POST' !== strtoupper( $request->get_method() ) ) { return false; } return in_array( $request->get_route(), [ '/wc/store/v1/checkout', '/wc/store/v1/checkout-order' ], true ); } private function get_trace_id( ?WP_REST_Request $request = null ): string { $trace_id = ''; if ( $request instanceof WP_REST_Request ) { $trace_id = (string) $request->get_header( 'X-IPS-Checkout-Trace' ); } if ( '' === $trace_id ) { $trace_id = isset( $_SERVER['HTTP_X_IPS_CHECKOUT_TRACE'] ) ? sanitize_key( wp_unslash( $_SERVER['HTTP_X_IPS_CHECKOUT_TRACE'] ) ) : ''; } return sanitize_key( $trace_id ); } }