File "SessionDataCollector.php"

Full Path: /home/shadsolw/public_html/wp-content/plugins/woocommerce/src/Internal/FraudProtection/SessionDataCollector.php
File size: 21.43 KB
MIME-type: text/x-php
Charset: utf-8

<?php
/**
 * SessionDataCollector class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Collects comprehensive session and order data for fraud protection analysis.
 *
 * This class provides manual data collection for fraud protection events, gathering
 * session, customer, order, address, and payment information in the exact nested format
 * required by the WPCOM fraud protection service. All data collection is designed to
 * degrade gracefully when fields are unavailable, ensuring checkout never fails due to
 * missing fraud protection data.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class SessionDataCollector {

	/**
	 * SessionClearanceManager instance.
	 *
	 * @var SessionClearanceManager
	 */
	private SessionClearanceManager $session_clearance_manager;

	/**
	 * Initialize with dependencies.
	 *
	 * @internal
	 *
	 * @param SessionClearanceManager $session_clearance_manager The session clearance manager instance.
	 */
	final public function init( SessionClearanceManager $session_clearance_manager ): void {
		$this->session_clearance_manager = $session_clearance_manager;
	}

	/**
	 * Collect comprehensive session and order data for fraud protection.
	 *
	 * This method is called manually at specific points in the checkout/payment flow
	 * to gather all relevant data for fraud analysis. It returns data in the nested
	 * format expected by the WPCOM fraud protection service.
	 *
	 * @since 10.5.0
	 *
	 * @param string|null $event_type Optional event type identifier (e.g., 'checkout_started', 'payment_attempt').
	 * @param array       $event_data Optional event-specific additional context data (may include 'order_id').
	 * @return array Nested array containing all collected fraud protection data.
	 */
	public function collect( ?string $event_type = null, array $event_data = array() ): array {
		// Ensure cart and session are loaded.
		$this->session_clearance_manager->ensure_cart_loaded();

		// Extract order ID from event_data if provided.
		// There seem to be no universal way to get order id from session data, so we may start with passing it as a parameter when calling this method.
		$order_id_from_event = $event_data['order_id'] ?? null;

		return array(
			'event_type'       => $event_type,
			'timestamp'        => gmdate( 'Y-m-d H:i:s' ),
			'wc_version'       => WC()->version,
			'session'          => $this->get_session_data(),
			'customer'         => $this->get_customer_data(),
			'order'            => $this->get_order_data( $order_id_from_event ),
			'shipping_address' => $this->get_shipping_address(),
			'billing_address'  => $this->get_billing_address(),
			'event_data'       => $event_data,
		);
	}

	/**
	 * Get current billing country from customer data.
	 *
	 * Reuses the same logic as get_billing_address() but returns only the country.
	 * Tries WC_Customer first, falls back to session data, with graceful error handling.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null Current billing country code or null if unavailable.
	 */
	public function get_current_billing_country(): ?string {
		try {
			if ( WC()->customer instanceof \WC_Customer ) {
				$country = WC()->customer->get_billing_country();
				return ! empty( $country ) ? \sanitize_text_field( $country ) : null;
			} elseif ( WC()->session instanceof \WC_Session ) {
				$customer_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_data ) && ! empty( $customer_data['country'] ) ) {
					return \sanitize_text_field( $customer_data['country'] );
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation.
		}
		return null;
	}

	/**
	 * Get current shipping country from customer data.
	 *
	 * Reuses the same logic as get_shipping_address() but returns only the country.
	 * Tries WC_Customer first, falls back to session data, with graceful error handling.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null Current shipping country code or null if unavailable.
	 */
	public function get_current_shipping_country(): ?string {
		try {
			if ( WC()->customer instanceof \WC_Customer ) {
				$country = WC()->customer->get_shipping_country();
				return ! empty( $country ) ? \sanitize_text_field( $country ) : null;
			} elseif ( WC()->session instanceof \WC_Session ) {
				$customer_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_data ) && ! empty( $customer_data['shipping_country'] ) ) {
					return \sanitize_text_field( $customer_data['shipping_country'] );
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation.
		}
		return null;
	}

	/**
	 * Get session data including session ID, IP address, email, and user agent.
	 *
	 * Collects session identification and tracking data with graceful degradation
	 * for unavailable fields. Email collection follows the fallback chain:
	 * logged-in user email → session customer data → WC_Customer billing email.
	 *
	 * @since 10.5.0
	 *
	 * @return array Session data array with 6 keys.
	 */
	private function get_session_data(): array {
		try {
			$session_id = $this->session_clearance_manager->get_session_id();
			$ip_address = $this->get_ip_address();
			$email      = $this->get_email();
			$user_agent = $this->get_user_agent();

			/**
			 * $is_user_session is flag that we have a real browser session vs API-based interaction.
			 * We start with a very basic check, but we might need a more sophisticated way to detect it in the future.
			 */
			$is_user_session = 'no-session' !== $session_id;

			return array(
				'session_id'      => $session_id,
				'ip_address'      => $ip_address,
				'email'           => $email,
				'ja3_hash'        => null,
				'user_agent'      => $user_agent,
				'is_user_session' => $is_user_session,
			);
		} catch ( \Exception $e ) {
			// Graceful degradation - return structure with null values.
			return array(
				'session_id'      => null,
				'ip_address'      => null,
				'email'           => null,
				'ja3_hash'        => null,
				'user_agent'      => null,
				'is_user_session' => false,
			);
		}
	}

	/**
	 * Get customer data including name, billing email, and order history.
	 *
	 * Collects customer identification and history data with graceful degradation.
	 * Tries WC_Customer object first, then falls back to session data if values are empty.
	 * Includes lifetime_order_count which counts all orders regardless of status.
	 *
	 * @since 10.5.0
	 *
	 * @return array Customer data array with 4 keys.
	 */
	private function get_customer_data(): array {
		$customer_data = array(
			'first_name'           => null,
			'last_name'            => null,
			'billing_email'        => null,
			'lifetime_order_count' => 0,
		);
		try {
			$lifetime_order_count = 0;

			// Try WC_Customer object first.
			if ( WC()->customer instanceof \WC_Customer ) {
				if ( WC()->customer->get_id() > 0 ) {
					// We need to reload the customer so it uses the correct data store to count the orders.
					$customer             = new \WC_Customer( WC()->customer->get_id() );
					$lifetime_order_count = $customer->get_order_count();
				}

				$customer_data = array_merge(
					$customer_data,
					array(
						'first_name'           => \sanitize_text_field( WC()->customer->get_billing_first_name() ),
						'last_name'            => \sanitize_text_field( WC()->customer->get_billing_last_name() ),
						'billing_email'        => \sanitize_email( \WC()->customer->get_billing_email() ),
						'lifetime_order_count' => $lifetime_order_count,
					)
				);

			} elseif ( WC()->session instanceof \WC_Session ) {
				// Fallback to session customer data if WC_Customer not available.
				$customer_session_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_session_data ) ) {
					$customer_data = array_merge(
						$customer_data,
						array(
							'first_name'    => \sanitize_text_field( $customer_session_data['first_name'] ?? null ),
							'last_name'     => \sanitize_text_field( $customer_session_data['last_name'] ?? null ),
							'billing_email' => \sanitize_email( $customer_session_data['email'] ?? null ),
						)
					);
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation - return as much data as possible.
		}

		return $customer_data;
	}

	/**
	 * Get order data including totals, currency, cart hash, and cart items.
	 *
	 * Collects comprehensive order information from the cart with graceful degradation.
	 * Calculates shipping_tax_rate from shipping tax and shipping total. Sets customer_id
	 * to 'guest' for non-logged-in users.
	 *
	 * @since 10.5.0
	 *
	 * @param int|null $order_id_from_event Optional order ID from event data.
	 * @return array Order data array with 11 keys including items array.
	 */
	private function get_order_data( ?int $order_id_from_event = null ): array {
		try {
			// Initialize default values.
			$order_id          = $order_id_from_event;
			$customer_id       = 'guest';
			$total             = 0;
			$items_total       = 0;
			$shipping_total    = 0;
			$tax_total         = 0;
			$shipping_tax_rate = null;
			$discount_total    = 0;
			$currency          = WC()->call_function( 'get_woocommerce_currency' );
			$cart_hash         = null;
			$items             = array();

			// Get customer ID from WooCommerce customer object if available.
			// We don't need to fallback to session data here, because customer id won't be stored there.
			if ( WC()->customer instanceof \WC_Customer ) {
				$id = WC()->customer->get_id();
				if ( $id ) {
					$customer_id = $id;
				}
			}
			// Get cart data if available.
			if ( WC()->cart instanceof \WC_Cart ) {
				$items_total    = (float) WC()->cart->get_subtotal();
				$shipping_total = (float) WC()->cart->get_shipping_total();
				$tax_total      = (float) WC()->cart->get_cart_contents_tax();
				$discount_total = (float) WC()->cart->get_discount_total();
				$cart_hash      = WC()->cart->get_cart_hash();
				$items          = $this->get_cart_items();
				$total          = (float) WC()->cart->get_total( 'edit' );

				// Calculate shipping_tax_rate.
				$shipping_tax = (float) WC()->cart->get_shipping_tax();
				if ( $shipping_total > 0 && $shipping_tax > 0 ) {
					$shipping_tax_rate = $shipping_tax / $shipping_total;
				}
			}

			return array(
				'order_id'          => $order_id,
				'customer_id'       => $customer_id,
				'total'             => $total,
				'items_total'       => $items_total,
				'shipping_total'    => $shipping_total,
				'tax_total'         => $tax_total,
				'shipping_tax_rate' => $shipping_tax_rate,
				'discount_total'    => $discount_total,
				'currency'          => $currency,
				'cart_hash'         => $cart_hash,
				'items'             => $items,
			);
		} catch ( \Exception $e ) {
			// Graceful degradation - return structure with default values.
			return array(
				'order_id'          => null,
				'customer_id'       => 'guest',
				'total'             => 0,
				'items_total'       => 0,
				'shipping_total'    => 0,
				'tax_total'         => 0,
				'shipping_tax_rate' => null,
				'discount_total'    => 0,
				'currency'          => WC()->call_function( 'get_woocommerce_currency' ),
				'cart_hash'         => null,
				'items'             => array(),
			);
		}
	}

	/**
	 * Get cart items with detailed product information.
	 *
	 * Iterates through cart items and extracts comprehensive product data including
	 * name, description, category, SKU, pricing, quantities, and WooCommerce-specific
	 * attributes. Returns array of item objects with 12 fields each.
	 *
	 * @since 10.5.0
	 *
	 * @return array Array of cart item objects with detailed product information.
	 */
	private function get_cart_items(): array {
		$items = array();

		try {
			if ( ! WC()->cart instanceof \WC_Cart ) {
				return $items;
			}

			foreach ( WC()->cart->get_cart() as $cart_item ) {
				try {
					$product = $cart_item['data'] ?? null;

					if ( ! $product instanceof \WC_Product ) {
						continue;
					}

					$quantity = $cart_item['quantity'] ?? 1;

					// Calculate per-unit amounts.
					$unit_price           = (float) $product->get_price();
					$line_tax             = $cart_item['line_tax'] ?? 0;
					$unit_tax_amount      = $quantity > 0 ? ( (float) $line_tax / $quantity ) : 0;
					$line_discount        = $cart_item['line_subtotal'] - $cart_item['line_total'];
					$unit_discount_amount = $quantity > 0 ? ( (float) $line_discount / $quantity ) : 0;
					$category             = $this->get_product_category_names( $product );

					$items[] = array(
						'name'                 => $product->get_name() ? $product->get_name() : null,
						'description'          => $product->get_description() ? $product->get_description() : null,
						'category'             => $category,
						'sku'                  => $product->get_sku() ? $product->get_sku() : null,
						'quantity'             => $quantity,
						'unit_price'           => $unit_price,
						'unit_tax_amount'      => $unit_tax_amount,
						'unit_discount_amount' => $unit_discount_amount,
						'product_type'         => $product->get_type() ? $product->get_type() : null,
						'is_virtual'           => $product->is_virtual(),
						'is_downloadable'      => $product->is_downloadable(),
						'attributes'           => $product->get_attributes() ? $product->get_attributes() : array(),
					);
				} catch ( \Exception $e ) {
					// Skip this item if there's an error, continue with next item.
					continue;
				}
			}
		} catch ( \Exception $e ) {
			// Return empty array on error.
			return array();
		}

		return $items;
	}

	/**
	 * Get billing address from customer data.
	 *
	 * Collects billing address fields from WC_Customer object with graceful degradation.
	 * Returns array with 6 address fields, sanitized with sanitize_text_field().
	 *
	 * @since 10.5.0
	 *
	 * @return array Billing address array with 6 keys.
	 */
	private function get_billing_address(): array {
		$billing_data = array(
			'first_name' => null,
			'last_name'  => null,
			'address'    => null,
			'address_1'  => null,
			'address_2'  => null,
			'city'       => null,
			'state'      => null,
			'country'    => null,
			'phone'      => null,
			'postcode'   => null,
		);

		try {
			// Try WC_Customer object first.
			if ( WC()->customer instanceof \WC_Customer ) {
				$billing_data = array_merge(
					$billing_data,
					array(
						'first_name' => \sanitize_text_field( WC()->customer->get_billing_first_name() ),
						'last_name'  => \sanitize_text_field( WC()->customer->get_billing_last_name() ),
						'address_1'  => \sanitize_text_field( WC()->customer->get_billing_address_1() ),
						'address_2'  => \sanitize_text_field( WC()->customer->get_billing_address_2() ),
						'city'       => \sanitize_text_field( WC()->customer->get_billing_city() ),
						'state'      => \sanitize_text_field( WC()->customer->get_billing_state() ),
						'country'    => \sanitize_text_field( WC()->customer->get_billing_country() ),
						'phone'      => \sanitize_text_field( WC()->customer->get_billing_phone() ),
						'postcode'   => \sanitize_text_field( WC()->customer->get_billing_postcode() ),
					)
				);
			} elseif ( WC()->session instanceof \WC_Session ) {
				// Fallback to session customer data if WC_Customer not available.
				$customer_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_data ) ) {
					$billing_data = array_merge(
						$billing_data,
						array(
							'first_name' => \sanitize_text_field( $customer_data['first_name'] ?? null ),
							'last_name'  => \sanitize_text_field( $customer_data['last_name'] ?? null ),
							'address'    => \sanitize_text_field( $customer_data['address'] ?? null ),
							'address_1'  => \sanitize_text_field( $customer_data['address_1'] ?? null ),
							'address_2'  => \sanitize_text_field( $customer_data['address_2'] ?? null ),
							'city'       => \sanitize_text_field( $customer_data['city'] ?? null ),
							'state'      => \sanitize_text_field( $customer_data['state'] ?? null ),
							'country'    => \sanitize_text_field( $customer_data['country'] ?? null ),
							'phone'      => \sanitize_text_field( $customer_data['phone'] ?? null ),
							'postcode'   => \sanitize_text_field( $customer_data['postcode'] ?? null ),
						)
					);
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation - prevents any errors from being thrown.
		}

		return $billing_data;
	}

	/**
	 * Get shipping address from customer data.
	 *
	 * Collects shipping address fields from WC_Customer object with graceful degradation.
	 * Returns array with 6 address fields, sanitized with sanitize_text_field().
	 *
	 * @since 10.5.0
	 *
	 * @return array Shipping address array with 6 keys.
	 */
	private function get_shipping_address(): array {
		$shipping_data = array(
			'first_name' => null,
			'last_name'  => null,
			'address'    => null,
			'address_1'  => null,
			'address_2'  => null,
			'city'       => null,
			'state'      => null,
			'postcode'   => null,
			'country'    => null,
		);
		try {
			if ( WC()->customer instanceof \WC_Customer ) {
				$shipping_data = array_merge(
					$shipping_data,
					array(
						'first_name' => \sanitize_text_field( WC()->customer->get_shipping_first_name() ),
						'last_name'  => \sanitize_text_field( WC()->customer->get_shipping_last_name() ),
						'address_1'  => \sanitize_text_field( WC()->customer->get_shipping_address_1() ),
						'address_2'  => \sanitize_text_field( WC()->customer->get_shipping_address_2() ),
						'city'       => \sanitize_text_field( WC()->customer->get_shipping_city() ),
						'state'      => \sanitize_text_field( WC()->customer->get_shipping_state() ),
						'postcode'   => \sanitize_text_field( WC()->customer->get_shipping_postcode() ),
						'country'    => \sanitize_text_field( WC()->customer->get_shipping_country() ),
					)
				);
			} elseif ( WC()->session instanceof \WC_Session ) {
				// Fallback to session customer data if WC_Customer not available.
				$customer_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_data ) ) {
					$shipping_data = array_merge(
						$shipping_data,
						array(
							'first_name' => \sanitize_text_field( $customer_data['shipping_first_name'] ?? null ),
							'last_name'  => \sanitize_text_field( $customer_data['shipping_last_name'] ?? null ),
							'address_1'  => \sanitize_text_field( $customer_data['shipping_address_1'] ?? null ),
							'address_2'  => \sanitize_text_field( $customer_data['shipping_address_2'] ?? null ),
							'city'       => \sanitize_text_field( $customer_data['shipping_city'] ?? null ),
							'state'      => \sanitize_text_field( $customer_data['shipping_state'] ?? null ),
							'postcode'   => \sanitize_text_field( $customer_data['shipping_postcode'] ?? null ),
							'country'    => \sanitize_text_field( $customer_data['shipping_country'] ?? null ),
						)
					);
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation - returns as much data as possible.
		}

		return $shipping_data;
	}

	/**
	 * Get client IP address using WooCommerce geolocation utility.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null IP address or null if not available.
	 */
	private function get_ip_address(): ?string {
		if ( class_exists( 'WC_Geolocation' ) ) {
			$ip = \WC_Geolocation::get_ip_address();
			return $ip ? $ip : null;
		}
		return null;
	}

	/**
	 * Get customer email with fallback chain.
	 *
	 * Tries logged-in user email first, then WC_Customer billing email,
	 * then session customer data as fallback.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null Email address or null if not available.
	 */
	private function get_email(): ?string {
		// Try logged-in user first.
		if ( \is_user_logged_in() ) {
			$user = \wp_get_current_user();
			if ( $user && $user->user_email ) {
				return \sanitize_email( $user->user_email );
			}
		}

		// Try WC_Customer object.
		if ( WC()->customer instanceof \WC_Customer ) {
			$email = WC()->customer->get_billing_email();
			if ( $email ) {
				return \sanitize_email( $email );
			}
		}

		// Fallback to session customer data if WC_Customer not available.
		if ( WC()->session instanceof \WC_Session ) {
			$customer_data = WC()->session->get( 'customer' );
			if ( is_array( $customer_data ) && ! empty( $customer_data['email'] ) ) {
				return \sanitize_email( $customer_data['email'] );
			}
		}

		return null;
	}

	/**
	 * Get user agent string from HTTP headers.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null User agent or null if not available.
	 */
	private function get_user_agent(): ?string {
		if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
		}
		return null;
	}

	/**
	 * Get product category names as comma-separated list.
	 *
	 * Uses WooCommerce helper with caching for better performance.
	 * Returns all categories for the product, not just the primary one.
	 *
	 * @since 10.5.0
	 *
	 * @param \WC_Product $product The product object.
	 * @return string|null Comma-separated category names or null if none.
	 */
	private function get_product_category_names( \WC_Product $product ): ?string {
		$terms = WC()->call_function( 'wc_get_product_terms', $product->get_id(), 'product_cat' );
		if ( empty( $terms ) || ! is_array( $terms ) ) {
			return null;
		}
		$category_names = array_map(
			function ( $term ) {
				return $term->name;
			},
			$terms
		);
		return implode( ', ', $category_names );
	}
}