> */ private $active_callbacks = []; public static function instance(): self { return self::$instance ??= new self(); } private function __construct() { if ( ! CheckoutTraceConfig::is_enabled() ) { return; } add_filter( 'rest_request_before_callbacks', [ $this, 'capture_callback_request' ], 10, 3 ); add_filter( 'rest_request_after_callbacks', [ $this, 'capture_callback_response' ], 10, 3 ); } /** * @param mixed $response * @param array $handler * @return mixed */ public function capture_callback_request( $response, array $handler, WP_REST_Request $request ) { if ( ! $this->is_shipping_callback_request( $request ) ) { return $response; } $request_summary = $this->summarize_request( $request ); $trace_id = $this->resolve_trace_id( $request, (string) ( $request_summary['cart_token_hash'] ?? '' ) ); $this->active_callbacks[ $trace_id ] = $request_summary; register_shutdown_function( [ $this, 'capture_shutdown_error' ], $trace_id ); CheckoutTraceService::record( [ 'traceId' => $trace_id, 'event' => 'ppcp_shipping_callback_received', 'step' => 'payment', 'status' => 'start', 'origin' => 'backend', 'gatewayId' => 'ppcp-gateway', 'providerKey' => 'ppcp', 'context' => $request_summary, ] ); return $response; } /** * @param mixed $response * @param array $handler * @return mixed */ public function capture_callback_response( $response, array $handler, WP_REST_Request $request ) { if ( ! $this->is_shipping_callback_request( $request ) ) { return $response; } $request_summary = $this->summarize_request( $request ); $trace_id = $this->resolve_trace_id( $request, (string) ( $request_summary['cart_token_hash'] ?? '' ) ); $response_summary = $this->summarize_response( $response ); $status_code = (int) ( $response_summary['status_code'] ?? 0 ); CheckoutTraceService::record( [ 'traceId' => $trace_id, 'event' => $status_code >= 400 ? 'ppcp_shipping_callback_failed' : 'ppcp_shipping_callback_completed', 'step' => 'payment', 'status' => $status_code >= 400 ? 'error' : 'success', 'origin' => 'backend', 'gatewayId' => 'ppcp-gateway', 'providerKey' => 'ppcp', 'message' => (string) ( $response_summary['response_issue'] ?? $response_summary['response_code'] ?? '' ), 'context' => array_merge( $request_summary, $response_summary ), ] ); unset( $this->active_callbacks[ $trace_id ] ); return $response; } public function capture_shutdown_error( string $trace_id ): void { if ( ! isset( $this->active_callbacks[ $trace_id ] ) ) { return; } $error = error_get_last(); if ( ! is_array( $error ) || ! $this->is_fatal_error_type( (int) ( $error['type'] ?? 0 ) ) ) { return; } CheckoutTraceService::record( [ 'traceId' => $trace_id, 'event' => 'ppcp_shipping_callback_shutdown_error', 'step' => 'payment', 'status' => 'critical', 'origin' => 'backend', 'gatewayId' => 'ppcp-gateway', 'providerKey' => 'ppcp', 'message' => 'PayPal shipping callback ended with a fatal PHP error.', 'context' => array_merge( $this->active_callbacks[ $trace_id ], [ 'fatal_error_type' => (int) ( $error['type'] ?? 0 ), 'fatal_error_file_basename' => basename( (string) ( $error['file'] ?? '' ) ), 'fatal_error_line' => (int) ( $error['line'] ?? 0 ), 'fatal_error_message_length' => strlen( (string) ( $error['message'] ?? '' ) ), ] ), ] ); } /** * @return array */ public function summarize_request( WP_REST_Request $request ): array { $params = $this->get_request_params( $request ); $body = $this->get_request_body( $request ); $cart_token = trim( (string) ( $params['cart_token'] ?? $request->get_param( 'cart_token' ) ?? '' ) ); $shipping_address = isset( $params['shipping_address'] ) && is_array( $params['shipping_address'] ) ? $params['shipping_address'] : []; $shipping_option = isset( $params['shipping_option'] ) && is_array( $params['shipping_option'] ) ? $params['shipping_option'] : []; $purchase_units = isset( $params['purchase_units'] ) && is_array( $params['purchase_units'] ) ? $params['purchase_units'] : []; $first_purchase_unit = isset( $purchase_units[0] ) && is_array( $purchase_units[0] ) ? $purchase_units[0] : []; return [ 'route' => $this->get_request_route( $request ), 'request_method' => $this->get_request_method( $request ), 'has_cart_token' => '' !== $cart_token, 'cart_token_hash' => self::hash_identifier( $cart_token ), 'cart_token_length' => strlen( $cart_token ), 'request_body_length' => strlen( $body ), 'request_key_count' => count( $params ), 'request_keys' => $this->sanitize_keys( array_keys( $params ), 12 ), 'callback_id_length' => strlen( trim( (string) ( $params['id'] ?? '' ) ) ), 'purchase_unit_count' => count( $purchase_units ), 'purchase_unit_reference_id_length' => strlen( trim( (string) ( $first_purchase_unit['reference_id'] ?? '' ) ) ), 'has_shipping_address' => [] !== $shipping_address, 'shipping_address_summary' => $this->summarize_paypal_shipping_address( $shipping_address ), 'has_shipping_option' => [] !== $shipping_option, 'shipping_option_summary' => $this->summarize_paypal_shipping_option( $shipping_option ), ]; } /** * @param mixed $response * @return array */ public function summarize_response( $response ): array { $summary = [ 'status_code' => 200, 'response_code' => '', 'response_name' => '', 'response_issue' => '', 'response_purchase_unit_count' => 0, 'response_shipping_option_count' => 0, ]; if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { $summary['status_code'] = 500; if ( is_object( $response ) && method_exists( $response, 'get_error_code' ) ) { $summary['response_code'] = sanitize_key( (string) $response->get_error_code() ); } return $summary; } if ( ! $response instanceof WP_REST_Response ) { return $summary; } $summary['status_code'] = (int) $response->get_status(); $data = $response->get_data(); if ( ! is_array( $data ) ) { return $summary; } $purchase_units = isset( $data['purchase_units'] ) && is_array( $data['purchase_units'] ) ? $data['purchase_units'] : []; $first_purchase_unit = isset( $purchase_units[0] ) && is_array( $purchase_units[0] ) ? $purchase_units[0] : []; $shipping_options = isset( $first_purchase_unit['shipping_options'] ) && is_array( $first_purchase_unit['shipping_options'] ) ? $first_purchase_unit['shipping_options'] : []; $summary['response_code'] = sanitize_key( (string) ( $data['code'] ?? '' ) ); $summary['response_name'] = sanitize_key( (string) ( $data['name'] ?? '' ) ); $summary['response_issue'] = $this->extract_response_issue( $data ); $summary['response_purchase_unit_count'] = count( $purchase_units ); $summary['response_shipping_option_count'] = count( $shipping_options ); return $summary; } /** * @param array $context * @return array */ public static function remember_trace_for_callback_url( string $callback_url, string $trace_id, array $context = [] ): array { $summary = self::summarize_callback_url( $callback_url ); $cart_token_hash = (string) ( $summary['callback_cart_token_hash'] ?? '' ); $trace_id = sanitize_key( $trace_id ); if ( '' === $trace_id || '' === $cart_token_hash || ! function_exists( 'set_transient' ) ) { $summary['stored_trace_link'] = false; return $summary; } set_transient( self::TRACE_LINK_TRANSIENT_PREFIX . $cart_token_hash, [ 'trace_id' => $trace_id, 'created_at' => gmdate( 'c' ), 'context' => [ 'payment_method' => sanitize_key( (string) ( $context['payment_method'] ?? '' ) ), 'funding_source' => sanitize_key( (string) ( $context['funding_source'] ?? '' ) ), ], ], self::TRACE_LINK_TTL ); $summary['stored_trace_link'] = true; return $summary; } /** * @return array */ public static function summarize_callback_url( string $callback_url ): array { $parts = '' !== trim( $callback_url ) ? wp_parse_url( $callback_url ) : []; $query = []; if ( is_array( $parts ) && isset( $parts['query'] ) ) { parse_str( (string) $parts['query'], $query ); } $cart_token = trim( (string) ( $query['cart_token'] ?? '' ) ); return [ 'has_callback_url' => '' !== trim( $callback_url ), 'callback_url_scheme' => sanitize_key( (string) ( is_array( $parts ) ? ( $parts['scheme'] ?? '' ) : '' ) ), 'callback_url_host' => sanitize_text_field( (string) ( is_array( $parts ) ? ( $parts['host'] ?? '' ) : '' ) ), 'callback_url_path' => sanitize_text_field( (string) ( is_array( $parts ) ? ( $parts['path'] ?? '' ) : '' ) ), 'has_callback_cart_token' => '' !== $cart_token, 'callback_cart_token_hash' => self::hash_identifier( $cart_token ), 'callback_cart_token_length' => strlen( $cart_token ), ]; } public static function hash_identifier( string $value ): string { $value = trim( $value ); return '' === $value ? '' : substr( hash( 'sha256', $value ), 0, 16 ); } private function resolve_trace_id( WP_REST_Request $request, string $cart_token_hash ): string { $trace_id = $this->get_trace_id_from_request( $request ); if ( '' !== $trace_id ) { return $trace_id; } if ( '' !== $cart_token_hash && function_exists( 'get_transient' ) ) { $stored = get_transient( self::TRACE_LINK_TRANSIENT_PREFIX . $cart_token_hash ); if ( is_array( $stored ) && '' !== sanitize_key( (string) ( $stored['trace_id'] ?? '' ) ) ) { return sanitize_key( (string) $stored['trace_id'] ); } } return 'ppcpshipping' . ( '' !== $cart_token_hash ? $cart_token_hash : 'missing' ); } private function get_trace_id_from_request( WP_REST_Request $request ): string { $headers = [ 'x-ips-checkout-trace', 'x-ips-checkout-trace-id' ]; foreach ( $headers as $header ) { $value = method_exists( $request, 'get_header' ) ? (string) $request->get_header( $header ) : ''; if ( '' !== trim( $value ) ) { return sanitize_key( $value ); } } return ''; } private function is_shipping_callback_request( WP_REST_Request $request ): bool { $route = $this->get_request_route( $request ); if ( false === strpos( $route, self::ROUTE ) ) { return false; } return 'POST' === strtoupper( $this->get_request_method( $request ) ); } private function get_request_route( WP_REST_Request $request ): string { if ( method_exists( $request, 'get_route' ) ) { return sanitize_text_field( (string) $request->get_route() ); } return sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ); } private function get_request_method( WP_REST_Request $request ): string { if ( method_exists( $request, 'get_method' ) ) { return strtoupper( sanitize_key( (string) $request->get_method() ) ); } return strtoupper( sanitize_key( (string) ( $_SERVER['REQUEST_METHOD'] ?? 'POST' ) ) ); } /** * @return array */ private function get_request_params( WP_REST_Request $request ): array { if ( method_exists( $request, 'get_params' ) ) { $params = $request->get_params(); return is_array( $params ) ? $params : []; } if ( method_exists( $request, 'get_json_params' ) ) { $params = $request->get_json_params(); return is_array( $params ) ? $params : []; } return []; } private function get_request_body( WP_REST_Request $request ): string { if ( method_exists( $request, 'get_body' ) ) { return (string) $request->get_body(); } $params = $this->get_request_params( $request ); $encoded = wp_json_encode( $params ); return false !== $encoded ? (string) $encoded : ''; } /** * @param array $address * @return array */ private function summarize_paypal_shipping_address( array $address ): array { $country_code = strtoupper( preg_replace( '/[^A-Z]/', '', (string) ( $address['country_code'] ?? '' ) ) ?: '' ); return [ 'key_count' => count( $address ), 'keys' => $this->sanitize_keys( array_keys( $address ), 12 ), 'has_country_code' => '' !== $country_code, 'country_code' => substr( $country_code, 0, 2 ), 'has_admin_area_1' => '' !== trim( (string) ( $address['admin_area_1'] ?? '' ) ), 'has_admin_area_2' => '' !== trim( (string) ( $address['admin_area_2'] ?? '' ) ), 'has_postal_code' => '' !== trim( (string) ( $address['postal_code'] ?? '' ) ), 'postal_code_length' => strlen( trim( (string) ( $address['postal_code'] ?? '' ) ) ), 'has_address_line_1' => '' !== trim( (string) ( $address['address_line_1'] ?? '' ) ), 'has_address_line_2' => '' !== trim( (string) ( $address['address_line_2'] ?? '' ) ), ]; } /** * @param array $shipping_option * @return array */ private function summarize_paypal_shipping_option( array $shipping_option ): array { $option_id = trim( (string) ( $shipping_option['id'] ?? '' ) ); return [ 'key_count' => count( $shipping_option ), 'keys' => $this->sanitize_keys( array_keys( $shipping_option ), 8 ), 'has_id' => '' !== $option_id, 'id_length' => strlen( $option_id ), 'has_selected' => isset( $shipping_option['selected'] ), ]; } /** * @param array $data */ private function extract_response_issue( array $data ): string { $details = isset( $data['details'] ) && is_array( $data['details'] ) ? $data['details'] : []; foreach ( $details as $detail ) { if ( ! is_array( $detail ) ) { continue; } $issue = sanitize_key( (string) ( $detail['issue'] ?? '' ) ); if ( '' !== $issue ) { return $issue; } } return ''; } /** * @param array $keys * @return array */ private function sanitize_keys( array $keys, int $limit ): array { $sanitized = []; foreach ( $keys as $key ) { $normalized = sanitize_key( (string) $key ); if ( '' !== $normalized ) { $sanitized[] = $normalized; } } return array_slice( array_values( array_unique( $sanitized ) ), 0, max( 1, $limit ) ); } private function is_fatal_error_type( int $type ): bool { return in_array( $type, [ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR ], true ); } }