PK     !m.]      PayPal/Constants.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Gateways\PayPal;

/**
 * PayPal Gateway Constants.
 *
 * Provides constants for PayPal payment statuses, intents, and other PayPal-related values.
 *
 * @since 10.5.0
 */
class Constants {
	/**
	 * PayPal proxy request timeout.
	 */
	const WPCOM_PROXY_REQUEST_TIMEOUT = 60;

	/**
	 * PayPal payment statuses.
	 */
	const STATUS_COMPLETED             = 'COMPLETED';
	const STATUS_APPROVED              = 'APPROVED';
	const STATUS_CAPTURED              = 'CAPTURED';
	const STATUS_AUTHORIZED            = 'AUTHORIZED';
	const STATUS_PAYER_ACTION_REQUIRED = 'PAYER_ACTION_REQUIRED';
	const VOIDED                       = 'VOIDED';

	/**
	 * PayPal payment intents.
	 */
	const INTENT_CAPTURE   = 'CAPTURE';
	const INTENT_AUTHORIZE = 'AUTHORIZE';

	/**
	 * PayPal payment actions.
	 */
	const PAYMENT_ACTION_CAPTURE   = 'capture';
	const PAYMENT_ACTION_AUTHORIZE = 'authorize';

	/**
	 * PayPal shipping preferences.
	 */
	const SHIPPING_NO_SHIPPING          = 'NO_SHIPPING';
	const SHIPPING_GET_FROM_FILE        = 'GET_FROM_FILE';
	const SHIPPING_SET_PROVIDED_ADDRESS = 'SET_PROVIDED_ADDRESS';

	/**
	 * PayPal user actions.
	 */
	const USER_ACTION_PAY_NOW = 'PAY_NOW';

	/**
	 * Maximum lengths for PayPal fields.
	 */
	const PAYPAL_ORDER_ITEM_NAME_MAX_LENGTH = 127;
	const PAYPAL_INVOICE_ID_MAX_LENGTH      = 127;
	const PAYPAL_ADDRESS_LINE_MAX_LENGTH    = 300;
	const PAYPAL_COUNTRY_CODE_LENGTH        = 2;
	const PAYPAL_STATE_MAX_LENGTH           = 300;
	const PAYPAL_CITY_MAX_LENGTH            = 120;
	const PAYPAL_POSTAL_CODE_MAX_LENGTH     = 60;
	const PAYPAL_LOCALE_MAX_LENGTH          = 10;

	/**
	 * Supported payment sources.
	 */
	const PAYMENT_SOURCE_PAYPAL     = 'paypal';
	const PAYMENT_SOURCE_VENMO      = 'venmo';
	const PAYMENT_SOURCE_PAYLATER   = 'paylater';
	const SUPPORTED_PAYMENT_SOURCES = array( self::PAYMENT_SOURCE_PAYPAL, self::PAYMENT_SOURCE_VENMO, self::PAYMENT_SOURCE_PAYLATER );

	/**
	 * Fields to redact from logs.
	 *
	 * @var array
	 */
	const FIELDS_TO_REDACT = array(
		'given_name',
		'surname',
		'full_name',
		'address_line_1',
		'address_line_2',
		'admin_area_1',
		'admin_area_2',
		'postal_code',
		'phone',
		'phone_number',
		'national_number',
	);

	/**
	 * List of currencies supported by PayPal (Orders API V2).
	 *
	 * @var array<string>
	 */
	const SUPPORTED_CURRENCIES = array(
		'AUD', // Australian Dollar.
		'BRL', // Brazilian Real.
		'CAD', // Canadian Dollar.
		'CNY', // Chinese Renminbi.
		'CZK', // Czech Koruna.
		'DKK', // Danish Krone.
		'EUR', // Euro.
		'HKD', // Hong Kong Dollar.
		'HUF', // Hungarian Forint.
		'ILS', // Israeli New Shekel.
		'JPY', // Japanese Yen.
		'MYR', // Malaysian Ringgit.
		'MXN', // Mexican Peso.
		'TWD', // New Taiwan Dollar.
		'NZD', // New Zealand Dollar.
		'NOK', // Norwegian Krone.
		'PHP', // Philippine Peso.
		'PLN', // Polish Złoty.
		'GBP', // Pound Sterling.
		'SGD', // Singapore Dollar.
		'SEK', // Swedish Krona.
		'CHF', // Swiss Franc.
		'THB', // Thai Baht.
		'USD', // United States Dollar.
		'RUB', // Russian Ruble.
	);

	/**
	 * PayPal authorization already captured issue code.
	 *
	 * @var string
	 */
	const PAYPAL_ISSUE_AUTHORIZATION_ALREADY_CAPTURED = 'AUTHORIZATION_ALREADY_CAPTURED';

	/**
	 * PayPal account locked or closed issue code.
	 *
	 * @var string
	 */
	const PAYPAL_ISSUE_PAYEE_ACCOUNT_LOCKED_OR_CLOSED = 'PAYEE_ACCOUNT_LOCKED_OR_CLOSED';

	/**
	 * PayPal account restricted issue code.
	 *
	 * @var string
	 */
	const PAYPAL_ISSUE_PAYEE_ACCOUNT_RESTRICTED = 'PAYEE_ACCOUNT_RESTRICTED';

	/**
	 * Meta key for storing PayPal payment status in order meta.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	public const PAYPAL_ORDER_META_STATUS = '_paypal_status';

	/**
	 * Meta key for storing PayPal capture ID in order meta.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	public const PAYPAL_ORDER_META_CAPTURE_ID = '_paypal_capture_id';

	/**
	 * Meta key for storing PayPal authorization ID in order meta.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	public const PAYPAL_ORDER_META_AUTHORIZATION_ID = '_paypal_authorization_id';

	/**
	 * Meta key for storing PayPal authorization checked flag in order meta.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	public const PAYPAL_ORDER_META_AUTHORIZATION_CHECKED = '_paypal_authorization_checked';

	/**
	 * Meta key for storing PayPal order ID in order meta.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	public const PAYPAL_ORDER_META_ORDER_ID = '_paypal_order_id';

	/**
	 * Meta key for storing PayPal addresses updated flag in order meta.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	public const PAYPAL_ORDER_META_ADDRESSES_UPDATED = '_paypal_addresses_updated';

	/**
	 * Meta key for storing PayPal payment source in order meta.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	public const PAYPAL_ORDER_META_PAYMENT_SOURCE = '_paypal_payment_source';

	/**
	 * Meta key for storing PayPal shipping callback token in order meta.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	public const PAYPAL_ORDER_META_SHIPPING_CALLBACK_TOKEN = '_paypal_shipping_callback_token';
}
PK     !m.]l      PayPal/AddressRequirements.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Gateways\PayPal;

/**
 * Class AddressRequirements
 *
 * This helper class checks country-specific address requirements.
 * This was built based on {@see https://developer.paypal.com/api/rest/reference/orders/v2/country-address-requirements/}
 */
class AddressRequirements {
	/**
	 * The single instance of the class.
	 *
	 * @var AddressRequirements
	 */
	protected static $instance = null;

	/**
	 * Countries that require a city in the address.
	 *
	 * @var array
	 */
	private const COUNTRIES_REQUIRING_CITY = array(
		'AD',
		'AE',
		'AF',
		'AG',
		'AI',
		'AL',
		'AM',
		'AN',
		'AO',
		'AQ',
		'AR',
		'AS',
		'AT',
		'AU',
		'AW',
		'AZ',
		'BA',
		'BB',
		'BD',
		'BE',
		'BF',
		'BG',
		'BH',
		'BI',
		'BJ',
		'BM',
		'BN',
		'BO',
		'BR',
		'BS',
		'BT',
		'BV',
		'BW',
		'BY',
		'BZ',
		'CA',
		'CC',
		'CD',
		'CF',
		'CG',
		'CH',
		'CI',
		'CK',
		'CL',
		'CM',
		'CN',
		'CO',
		'CR',
		'CS',
		'CU',
		'CV',
		'CX',
		'CY',
		'CZ',
		'DE',
		'DJ',
		'DK',
		'DM',
		'DO',
		'DZ',
		'EC',
		'EE',
		'EG',
		'EH',
		'ER',
		'ES',
		'ET',
		'FI',
		'FJ',
		'FK',
		'FM',
		'FO',
		'FR',
		'GA',
		'GB',
		'GD',
		'GE',
		'GF',
		'GG',
		'GH',
		'GI',
		'GL',
		'GM',
		'GN',
		'GP',
		'GQ',
		'GR',
		'GS',
		'GT',
		'GU',
		'GW',
		'GY',
		'HK',
		'HM',
		'HN',
		'HR',
		'HT',
		'HU',
		'ID',
		'IE',
		'IL',
		'IM',
		'IN',
		'IO',
		'IQ',
		'IR',
		'IS',
		'IT',
		'JE',
		'JM',
		'JO',
		'JP',
		'KE',
		'KG',
		'KH',
		'KI',
		'KM',
		'KN',
		'KR',
		'KW',
		'KY',
		'KZ',
		'LA',
		'LB',
		'LC',
		'LI',
		'LK',
		'LR',
		'LS',
		'LT',
		'LU',
		'LV',
		'LY',
		'MA',
		'MC',
		'MD',
		'ME',
		'MG',
		'MH',
		'MK',
		'ML',
		'MM',
		'MN',
		'MO',
		'MP',
		'MQ',
		'MR',
		'MS',
		'MT',
		'MU',
		'MV',
		'MW',
		'MX',
		'MY',
		'MZ',
		'NA',
		'NC',
		'NE',
		'NF',
		'NG',
		'NI',
		'NL',
		'NO',
		'NP',
		'NR',
		'NU',
		'NZ',
		'OM',
		'PA',
		'PE',
		'PF',
		'PG',
		'PH',
		'PK',
		'PL',
		'PM',
		'PN',
		'PR',
		'PS',
		'PT',
		'PW',
		'PY',
		'QA',
		'RE',
		'RO',
		'RS',
		'RU',
		'RW',
		'SA',
		'SB',
		'SC',
		'SD',
		'SE',
		'SG',
		'SH',
		'SI',
		'SJ',
		'SK',
		'SL',
		'SM',
		'SN',
		'SO',
		'SR',
		'ST',
		'SV',
		'SY',
		'SZ',
		'TC',
		'TD',
		'TF',
		'TG',
		'TH',
		'TJ',
		'TL',
		'TM',
		'TN',
		'TO',
		'TP',
		'TR',
		'TT',
		'TV',
		'TW',
		'TZ',
		'UA',
		'UG',
		'UM',
		'US',
		'UY',
		'UZ',
		'VA',
		'VC',
		'VE',
		'VG',
		'VI',
		'VN',
		'VU',
		'WF',
		'WS',
		'YE',
		'YT',
		'YU',
		'ZA',
		'ZM',
		'ZW',
		'ZR',
		'C2',
	);

	/**
	 * Countries that require a postal code in the address.
	 *
	 * @var array
	 */
	private const COUNTRIES_REQUIRING_POSTAL_CODE = array(
		'AR',
		'AT',
		'AU',
		'BR',
		'BT',
		'CA',
		'C2',
		'CC',
		'CH',
		'CN',
		'DE',
		'DK',
		'EH',
		'ES',
		'FK',
		'FM',
		'FO',
		'FR',
		'GB',
		'GL',
		'GM',
		'IT',
		'JP',
		'KG',
		'KI',
		'KM',
		'MR',
		'MX',
		'NE',
		'NL',
		'NO',
		'NR',
		'NU',
		'NF',
		'PL',
		'PM',
		'PN',
		'SE',
		'SG',
		'SH',
		'SJ',
		'SM',
		'SR',
		'TF',
		'TH',
		'TK',
		'TV',
		'UM',
		'US',
		'VA',
		'WF',
		'YT',
	);

	/**
	 * Get class instance.
	 *
	 * @return AddressRequirements Instance.
	 */
	final public static function instance() {
		if ( null === static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Check if a country requires a city in the address.
	 *
	 * @param string $country_code The ISO 3166-1 alpha-2 country code.
	 * @return bool
	 */
	public function country_requires_city( string $country_code ) {
		return in_array( strtoupper( $country_code ), self::COUNTRIES_REQUIRING_CITY, true );
	}

	/**
	 * Check if a country requires a postal code in the address.
	 *
	 * @param string $country_code The ISO 3166-1 alpha-2 country code.
	 * @return bool
	 */
	public function country_requires_postal_code( string $country_code ) {
		return in_array( strtoupper( $country_code ), self::COUNTRIES_REQUIRING_POSTAL_CODE, true );
	}
}
PK     !m.]hb $  $    PayPal/Buttons.phpnu         <?php
/**
 * Class Automattic\WooCommerce\Gateways\PayPal\Buttons file.
 *
 * @package WooCommerce\Gateways
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Gateways\PayPal;

use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Gateways\PayPal\Request as PayPalRequest;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Handles PayPal Buttons.
 *
 * @since 10.5.0
 */
class Buttons {

	/**
	 * The option for the client-id.
	 *
	 * @var string
	 */
	private const CLIENT_ID_OPTION = 'woocommerce_paypal_client_id';


	/**
	 * The gateway instance.
	 *
	 * @var \WC_Gateway_Paypal
	 */
	private \WC_Gateway_Paypal $gateway;

	/**
	 * Whether the gateway should use Orders v2 API.
	 *
	 * @var bool
	 */
	private bool $enabled;

	/**
	 * The request instance.
	 *
	 * @var PayPalRequest
	 */
	private PayPalRequest $request;

	/**
	 * Constructor.
	 *
	 * @param \WC_Gateway_Paypal $gateway The gateway instance.
	 */
	public function __construct( \WC_Gateway_Paypal $gateway ) {
		$this->gateway = $gateway;
		$this->request = new PayPalRequest( $this->gateway );

		$this->enabled = $this->gateway->should_use_orders_v2() && 'yes' === $this->gateway->get_option( 'paypal_buttons', 'yes' );
	}

	/**
	 * Get the options for the PayPal buttons.
	 *
	 * @since 10.5.0
	 *
	 * @return array
	 */
	public function get_options(): array {
		$common_options = $this->get_common_options();
		$options        = array(
			'partner-attribution-id' => 'Woo_Cart_CoreUpgrade',
			'page-type'              => $this->get_page_type(),
		);

		return array_merge( $common_options, $options );
	}

	/**
	 * Get the common attributes for the PayPal JS SDK script and modules.
	 *
	 * @since 10.5.0
	 *
	 * @return array
	 */
	public function get_common_options(): array {
		$intent = $this->gateway->get_option( 'paymentaction' ) === 'authorization' ? 'authorize' : 'capture';

		return array(
			'client-id'       => $this->get_client_id(),
			'components'      => 'buttons,funding-eligibility,messages',
			'disable-funding' => 'card,applepay',
			'enable-funding'  => 'venmo,paylater',
			'currency'        => get_woocommerce_currency(),
			'intent'          => $intent,
			'merchant-id'     => $this->gateway->email,
		);
	}

	/**
	 * Get the client-id for the PayPal buttons.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null The PayPal client-id, or null if the request fails.
	 */
	public function get_client_id(): ?string {
		if ( ! $this->gateway->should_use_orders_v2() ) {
			return null;
		}

		$option_key = self::CLIENT_ID_OPTION . ( $this->gateway->testmode ? '_sandbox' : '_live' );
		$client_id  = get_option( $option_key, null );

		if ( empty( $client_id ) ) {
			$client_id = $this->request->fetch_paypal_client_id();
			if ( empty( $client_id ) ) {
				return null;
			}
			update_option( $option_key, $client_id );
		}

		return $client_id;
	}

	/**
	 * Get the page type for the PayPal buttons.
	 *
	 * @since 10.5.0
	 *
	 * @return string
	 */
	public function get_page_type(): string {
		$page_type = 'checkout';
		if ( is_cart() || has_block( 'woocommerce/cart' ) ) {
			$page_type = 'cart';
		} elseif ( is_product() ) {
			$page_type = 'product-details';
		}

		return $page_type;
	}

	/**
	 * Whether PayPal Buttons is enabled.
	 *
	 * @since 10.5.0
	 *
	 * @return bool
	 */
	public function is_enabled(): bool {
		return $this->enabled;
	}

	/**
	 * Get the current page URL, to be used for App Switch.
	 * Limited to checkout, cart, and product pages for security.
	 *
	 * @since 10.5.0
	 *
	 * @return string
	 */
	public function get_current_page_for_app_switch(): string {
		// If checkout, cart or product page, return the current page URL.
		if ( wc_get_container()->get( LegacyProxy::class )->call_function( 'is_checkout' ) || is_cart() || is_product() ) {
			return get_permalink( get_the_ID() );
		}

		return '';
	}
}
PK     !m.]&  &    PayPal/Helper.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Gateways\PayPal;

use WC_Order;
use Automattic\WooCommerce\Gateways\PayPal\Constants as PayPalConstants;

defined( 'ABSPATH' ) || exit;

/**
 * PayPal Helper Class
 *
 * Helper methods for PayPal gateway operations including order validation,
 * data redaction, and address updates.
 *
 * @since 10.5.0
 */
class Helper {
	/**
	 * Check if the PayPal gateway is enabled.
	 *
	 * @return bool
	 */
	public static function is_paypal_gateway_available(): bool {
		$settings    = get_option( 'woocommerce_paypal_settings', array() );
		$enabled     = isset( $settings['enabled'] ) && 'yes' === $settings['enabled'];
		$should_load = isset( $settings['_should_load'] ) && 'yes' === $settings['_should_load'];
		return $enabled && $should_load;
	}

	/**
	 * Check if the merchant is eligible for migration from WPS to PPCP.
	 *
	 * @return bool
	 */
	public static function is_orders_v2_migration_eligible(): bool {
		$settings = get_option( 'woocommerce_paypal_settings', array() );

		// If API keys are set, the merchant is not eligible for migration
		// as they may be using features that cannot be seamlessly migrated.
		$is_test_mode  = isset( $settings['testmode'] ) && 'yes' === $settings['testmode'];
		$api_username  = $is_test_mode ? ( $settings['sandbox_api_username'] ?? null ) : ( $settings['api_username'] ?? null );
		$api_password  = $is_test_mode ? ( $settings['sandbox_api_password'] ?? null ) : ( $settings['api_password'] ?? null );
		$api_signature = $is_test_mode ? ( $settings['sandbox_api_signature'] ?? null ) : ( $settings['api_signature'] ?? null );

		return empty( $api_username ) && empty( $api_password ) && empty( $api_signature );
	}

	/**
	 * Get the WC order from the PayPal custom ID.
	 *
	 * @param string $custom_id The custom ID string from the PayPal order.
	 * @return WC_Order|null
	 */
	public static function get_wc_order_from_paypal_custom_id( string $custom_id ): ?WC_Order {
		if ( '' === $custom_id ) {
			return null;
		}

		$data = json_decode( $custom_id, true );
		if ( ! is_array( $data ) ) {
			return null;
		}

		$order_id = $data['order_id'] ?? null;
		if ( ! $order_id ) {
			return null;
		}

		$order = wc_get_order( $order_id );
		if ( ! $order instanceof \WC_Order ) {
			return null;
		}

		// Validate the order key.
		$order_key = $data['order_key'] ?? null;
		if ( $order_key !== $order->get_order_key() ) {
			return null;
		}

		return $order;
	}

	/**
	 * Remove PII (Personally Identifiable Information) from data for logging.
	 *
	 * This function recursively traverses the data array and redacts sensitive information
	 * while preserving the structure for debugging purposes.
	 *
	 * @param mixed $data The data to remove PII from (array, string, or other types).
	 * @return mixed The data with PII redacted.
	 */
	public static function redact_data( $data ) {
		if ( ! is_array( $data ) ) {
			return $data;
		}

		$redacted_data = array();

		foreach ( $data as $key => $value ) {
			// Skip redacting the payee information as it belongs to the store merchant.
			if ( 'payee' === $key ) {
				$redacted_data[ $key ] = $value;
				continue;
			}
			// Mask the email address.
			if ( 'email_address' === $key || 'email' === $key ) {
				$redacted_data[ $key ] = self::mask_email( (string) $value );
				continue;
			}

			if ( is_array( $value ) ) {
				$redacted_data[ $key ] = self::redact_data( $value );
			} elseif ( in_array( $key, Constants::FIELDS_TO_REDACT, true ) ) {
				$redacted_data[ $key ] = '[redacted]';
			} else {
				// Keep non-PII data as is.
				$redacted_data[ $key ] = $value;
			}
		}

		return $redacted_data;
	}

	/**
	 * Mask email address before @ keeping the full domain.
	 *
	 * @param string $email The email address to mask.
	 * @return string The masked email address or original input if invalid.
	 */
	public static function mask_email( string $email ): string {
		if ( empty( $email ) ) {
			return $email;
		}

		$parts = explode( '@', $email, 2 );
		if ( count( $parts ) !== 2 || empty( $parts[0] ) || empty( $parts[1] ) ) {
			return $email;
		}
		list( $local, $domain ) = $parts;

		if ( strlen( $local ) <= 3 ) {
			$masked_local = str_repeat( '*', strlen( $local ) );
		} else {
			$masked_local = substr( $local, 0, 2 )
						. str_repeat( '*', max( 1, strlen( $local ) - 3 ) )
						. substr( $local, -1 );
		}

		return $masked_local . '@' . $domain;
	}

	/**
	 * Update the addresses in the order.
	 *
	 * @param WC_Order|null $order The order object.
	 * @param array         $paypal_order_details The PayPal order details.
	 * @return void
	 */
	public static function update_addresses_in_order( ?WC_Order $order, array $paypal_order_details ): void {
		if ( empty( $order ) || empty( $paypal_order_details ) ) {
			return;
		}

		// Bail early if '_paypal_addresses_updated' is 'yes', meaning the addresses update already have been successful.
		if ( 'yes' === $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_ADDRESSES_UPDATED, true ) ) {
			return;
		}

		// Update the shipping information.
		$full_name = $paypal_order_details['purchase_units'][0]['shipping']['name']['full_name'] ?? '';
		if ( ! empty( $full_name ) ) {
			$name_parts             = explode( ' ', $full_name, 2 );
			$approximate_first_name = $name_parts[0] ?? '';
			$approximate_last_name  = isset( $name_parts[1] ) ? $name_parts[1] : '';
			$order->set_shipping_first_name( $approximate_first_name );
			$order->set_shipping_last_name( $approximate_last_name );
		}

		$shipping_address = $paypal_order_details['purchase_units'][0]['shipping']['address'] ?? array();
		if ( ! empty( $shipping_address ) ) {
			$order->set_shipping_country( $shipping_address['country_code'] ?? '' );
			$order->set_shipping_postcode( $shipping_address['postal_code'] ?? '' );
			$order->set_shipping_state( $shipping_address['admin_area_1'] ?? '' );
			$order->set_shipping_city( $shipping_address['admin_area_2'] ?? '' );
			$order->set_shipping_address_1( $shipping_address['address_line_1'] ?? '' );
			$order->set_shipping_address_2( $shipping_address['address_line_2'] ?? '' );
		}

		// Update the billing information.
		$full_name = $paypal_order_details['payer']['name'] ?? array();
		$email     = $paypal_order_details['payer']['email_address'] ?? '';
		if ( ! empty( $full_name ) ) {
			$order->set_billing_first_name( $full_name['given_name'] ?? '' );
			$order->set_billing_last_name( $full_name['surname'] ?? '' );
			$order->set_billing_email( $email );
		}

		$billing_address = $paypal_order_details['payer']['address'] ?? array();
		if ( ! empty( $billing_address ) ) {
			$order->set_billing_country( $billing_address['country_code'] ?? '' );
			$order->set_billing_postcode( $billing_address['postal_code'] ?? '' );
			$order->set_billing_state( $billing_address['admin_area_1'] ?? '' );
			$order->set_billing_city( $billing_address['admin_area_2'] ?? '' );
			$order->set_billing_address_1( $billing_address['address_line_1'] ?? '' );
			$order->set_billing_address_2( $billing_address['address_line_2'] ?? '' );
		}

		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_ADDRESSES_UPDATED, 'yes' );
		$order->save();
	}
}
PK     !m.]5&r,  ,  !  PayPal/TransactAccountManager.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Gateways\PayPal;

use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;
use Automattic\WooCommerce\Gateways\PayPal\Constants as PayPalConstants;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * PayPal TransactAccountManager Class
 *
 * Handles Transact account management.
 *
 * @since 10.5.0
 */
final class TransactAccountManager {
	/**
	 * The API version for the proxy endpoint.
	 *
	 * @var int
	 *
	 * @since 10.5.0
	 */
	private const WPCOM_PROXY_ENDPOINT_API_VERSION = 2;

	/**
	 * Transact provider type, for provider onboarding.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	private const TRANSACT_PROVIDER_TYPE = 'paypal_standard';

	/**
	 * Cache key for the merchant account in live mode.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	private const TRANSACT_MERCHANT_ACCOUNT_CACHE_KEY_LIVE = 'woocommerce_paypal_transact_merchant_account_live';

	/**
	 * Cache key for the merchant account in test mode.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	private const TRANSACT_MERCHANT_ACCOUNT_CACHE_KEY_TEST = 'woocommerce_paypal_transact_merchant_account_test';

	/**
	 * Cache key for the provider account in live mode.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	private const TRANSACT_PROVIDER_ACCOUNT_CACHE_KEY_LIVE = 'woocommerce_paypal_transact_provider_account_live';

	/**
	 * Cache key for the provider account in test mode.
	 *
	 * @var string
	 *
	 * @since 10.5.0
	 */
	private const TRANSACT_PROVIDER_ACCOUNT_CACHE_KEY_TEST = 'woocommerce_paypal_transact_provider_account_test';

	/**
	 * The expiry time for the Transact account cache.
	 *
	 * @var int
	 *
	 * @since 10.5.0
	 */
	private const TRANSACT_ACCOUNT_CACHE_EXPIRY = 60 * 60 * 24; // 24 hours.

	/**
	 * Paypal gateway object.
	 *
	 * @var \WC_Gateway_Paypal
	 */
	private \WC_Gateway_Paypal $gateway;

	/**
	 * Constructor.
	 *
	 * @param \WC_Gateway_Paypal $gateway Paypal gateway object.
	 */
	public function __construct( \WC_Gateway_Paypal $gateway ) {
		$this->gateway = $gateway;
	}

	/**
	 * Onboard the merchant with the Transact platform.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 */
	public function do_onboarding(): void {
		// Check that we have a PayPal email. This is required for processing payments --
		// used as the payee email. Only begin onboarding if this minimum requirement is met.
		if ( empty( $this->gateway->email ) ) {
			return;
		}

		// Register with Jetpack if not already connected.
		$jetpack_connection_manager = $this->gateway->get_jetpack_connection_manager();
		if ( ! $jetpack_connection_manager ) {
			\WC_Gateway_Paypal::log( 'Jetpack connection manager not found.', 'error' );
			return;
		}

		if ( ! $jetpack_connection_manager->is_connected() ) {
			$result = $jetpack_connection_manager->try_registration();
			if ( is_wp_error( $result ) ) {
				\WC_Gateway_Paypal::log( 'Jetpack registration failed: ' . $result->get_error_message(), 'error' );
				return;
			}
		}

		// Fetch (cached) or create the Transact merchant and provider accounts.
		$merchant_account_data = $this->get_transact_account_data( 'merchant' );
		if ( empty( $merchant_account_data ) ) {
			$merchant_account = $this->create_merchant_account();
			if ( empty( $merchant_account ) ) {
				\WC_Gateway_Paypal::log( 'Transact merchant onboarding failed.', 'error' );
				return;
			}

			// Cache the merchant account data.
			$this->update_transact_account_cache(
				$this->get_cache_key( 'merchant' ),
				$merchant_account
			);
		}

		$provider_account_data = $this->get_transact_account_data( 'provider' );
		if ( empty( $provider_account_data ) ) {
			$provider_account = $this->create_provider_account();
			if ( ! $provider_account ) {
				\WC_Gateway_Paypal::log( 'Transact provider onboarding failed.', 'error' );
				return;
			}

			// Cache the provider account data.
			$this->update_transact_account_cache(
				$this->get_cache_key( 'provider' ),
				$provider_account
			);
		}

		// Set an extra flag to indicate that we've completed onboarding,
		// so we can do inexpensive early returns for checkers like
		// WC_Gateway_Paypal::should_use_orders_v2().
		$this->gateway->set_transact_onboarding_complete();
	}

	/**
	 * Get the Transact account (merchant or provider) data. Performs a fetch if the account
	 * is not in cache or expired.
	 *
	 * @since 10.5.0
	 *
	 * @param string $account_type The type of account to get (merchant or provider).
	 * @return mixed Returns null if the transact account cannot be retrieved.
	 */
	public function get_transact_account_data( string $account_type ) {
		$cache_key = $this->get_cache_key( $account_type );

		// Get transact account from cache. If not found, fetch/create it.
		$transact_account = $this->get_transact_account_from_cache( $cache_key );
		if ( empty( $transact_account ) ) {
			$transact_account = 'merchant' === $account_type ? $this->fetch_merchant_account() : $this->fetch_provider_account();

			// Fetch failed.
			if ( empty( $transact_account ) ) {
				return null;
			}

			// Update cache.
			$this->update_transact_account_cache( $cache_key, $transact_account );
		}

		return $transact_account;
	}

	/**
	 * Get the cache key for the transact account.
	 *
	 * @since 10.5.0
	 *
	 * @param string $account_type The type of account to get (merchant or provider).
	 * @return string|null The cache key, or null if the account type is invalid.
	 */
	private function get_cache_key( string $account_type ): ?string {
		if ( 'merchant' === $account_type ) {
			return $this->gateway->testmode ? self::TRANSACT_MERCHANT_ACCOUNT_CACHE_KEY_TEST : self::TRANSACT_MERCHANT_ACCOUNT_CACHE_KEY_LIVE;
		}

		if ( 'provider' === $account_type ) {
			return $this->gateway->testmode ? self::TRANSACT_PROVIDER_ACCOUNT_CACHE_KEY_TEST : self::TRANSACT_PROVIDER_ACCOUNT_CACHE_KEY_LIVE;
		}

		return null;
	}

	/**
	 * Fetch the merchant account from the Transact platform.
	 *
	 * @since 10.5.0
	 *
	 * @return array|null The API response body, or null if the request fails.
	 */
	private function fetch_merchant_account(): ?array {
		$site_id = \Jetpack_Options::get_option( 'id' );
		if ( ! $site_id ) {
			return null;
		}

		$request_body = array(
			'test_mode' => $this->gateway->testmode,
		);

		$response = $this->send_transact_api_request(
			'GET',
			sprintf( '/sites/%d/transact/account', $site_id ),
			$request_body
		);

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return null;
		}

		$response_data = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( empty( $response_data['public_id'] ) ) {
			return null;
		}

		return array( 'public_id' => $response_data['public_id'] );
	}

	/**
	 * Fetch the provider account from the Transact platform.
	 *
	 * @since 10.5.0
	 *
	 * @return bool True if the provider account exists, false otherwise.
	 */
	private function fetch_provider_account(): bool {
		$site_id = \Jetpack_Options::get_option( 'id' );
		if ( ! $site_id ) {
			return false;
		}

		$request_body = array(
			'test_mode'     => $this->gateway->testmode,
			'provider_type' => self::TRANSACT_PROVIDER_TYPE,
		);

		$response = $this->send_transact_api_request(
			'GET',
			sprintf( '/sites/%d/transact/account/%s', $site_id, self::TRANSACT_PROVIDER_TYPE ),
			$request_body
		);

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return false;
		}

		// Provider account response only returns an empty onboarding link,
		// which we do not need.
		return true;
	}

	/**
	 * Create the merchant account with the Transact platform.
	 *
	 * @since 10.5.0
	 *
	 * @return array|null The API response body, or null if the request fails.
	 */
	private function create_merchant_account(): ?array {
		$site_id = \Jetpack_Options::get_option( 'id' );
		if ( ! $site_id ) {
			return null;
		}

		$request_body = array( 'test_mode' => $this->gateway->testmode );

		$response = $this->send_transact_api_request(
			'POST',
			sprintf( '/sites/%d/transact/account', $site_id ),
			$request_body
		);

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return null;
		}

		$response_data = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( empty( $response_data['public_id'] ) ) {
			\WC_Gateway_Paypal::log( 'Transact merchant account creation failed. Response body: ' . wc_print_r( $response_data, true ) );
			return null;
		}

		return array( 'public_id' => $response_data['public_id'] );
	}

	/**
	 * Create the provider account with the Transact platform.
	 *
	 * @since 10.5.0
	 *
	 * @return bool True if the provider account creation was successful, false otherwise.
	 */
	private function create_provider_account(): bool {
		$site_id = \Jetpack_Options::get_option( 'id' );
		if ( ! $site_id ) {
			return false;
		}

		$request_body = array(
			'test_mode'     => $this->gateway->testmode,
			'provider_type' => self::TRANSACT_PROVIDER_TYPE,
		);
		$response     = $this->send_transact_api_request(
			'POST',
			sprintf( '/sites/%d/transact/account/%s/onboard', $site_id, self::TRANSACT_PROVIDER_TYPE ),
			$request_body
		);

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return false;
		}

		// Provider account response only returns an empty onboarding link,
		// which we do not need.
		return true;
	}

	/**
	 * Update the transact account (merchant or provider) cache.
	 *
	 * @since 10.5.0
	 *
	 * @param string|null $cache_key The cache key to update.
	 * @param array|bool  $account_data The transact account data.
	 *
	 * @return void
	 */
	private function update_transact_account_cache( string $cache_key, $account_data ): void {
		$expires = time() + self::TRANSACT_ACCOUNT_CACHE_EXPIRY;
		update_option(
			$cache_key,
			array(
				'account' => $account_data,
				'expiry'  => $expires,
			)
		);
	}

	/**
	 * Get the transact account (merchant or provider) from the database cache.
	 *
	 * @since 10.5.0
	 *
	 * @param string $cache_key The cache key to get the account.
	 * @return mixed The transact account data, or null if the cache is
	 *                    empty or expired.
	 */
	private function get_transact_account_from_cache( string $cache_key ) {
		$transact_account = get_option( $cache_key, null );

		if ( empty( $transact_account ) || ( isset( $transact_account['expiry'] ) && $transact_account['expiry'] < time() ) ) {
			return null;
		}

		return $transact_account['account'] ?? null;
	}

	/**
	 * Send a request to the Transact platform.
	 *
	 * @since 10.5.0
	 *
	 * @param string $method The HTTP method to use.
	 * @param string $endpoint The endpoint to request.
	 * @param array  $request_body The request body.
	 *
	 * @return array|\WP_Error The API response body, or null if the request fails.
	 */
	private function send_transact_api_request( string $method, string $endpoint, array $request_body ) {
		if ( 'GET' === $method ) {
			$endpoint .= '?' . http_build_query( $request_body );
		}

		$response = Jetpack_Connection_Client::wpcom_json_api_request_as_blog(
			$endpoint,
			(string) self::WPCOM_PROXY_ENDPOINT_API_VERSION,
			array(
				'headers' => array( 'Content-Type' => 'application/json' ),
				'method'  => $method,
				'timeout' => PayPalConstants::WPCOM_PROXY_REQUEST_TIMEOUT,
			),
			'GET' === $method ? null : wp_json_encode( $request_body ),
			'wpcom'
		);

		return $response;
	}
}
PK     !m.]      PayPal/Request.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Gateways\PayPal;

use Exception;
use WC_Order;
use Automattic\WooCommerce\Gateways\PayPal\Constants as PayPalConstants;
use Automattic\WooCommerce\Gateways\PayPal\AddressRequirements as PayPalAddressRequirements;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;

defined( 'ABSPATH' ) || exit;

/**
 * PayPal Request Class
 *
 * Handles PayPal API requests for creating orders, authorizing/capturing payments,
 * and fetching PayPal order details using the Orders v2 API.
 *
 * @since 10.5.0
 */
class Request {

	/**
	 * The PayPal gateway instance.
	 *
	 * @var \WC_Gateway_Paypal
	 */
	private \WC_Gateway_Paypal $gateway;

	/**
	 * The API version for the proxy endpoint.
	 *
	 * @var string
	 */
	private const WPCOM_PROXY_ENDPOINT_API_VERSION = '2';

	/**
	 * The base for the proxy REST endpoint.
	 *
	 * @var string
	 */
	private const WPCOM_PROXY_REST_BASE = 'transact/paypal_standard/proxy';

	/**
	 * Proxy REST endpoints.
	 *
	 * @var string
	 */
	private const WPCOM_PROXY_ORDER_ENDPOINT                = 'order';
	private const WPCOM_PROXY_PAYMENT_CAPTURE_ENDPOINT      = 'payment/capture';
	private const WPCOM_PROXY_PAYMENT_AUTHORIZE_ENDPOINT    = 'payment/authorize';
	private const WPCOM_PROXY_PAYMENT_CAPTURE_AUTH_ENDPOINT = 'payment/capture_auth';
	private const WPCOM_PROXY_CLIENT_ID_ENDPOINT            = 'client_id';

	/**
	 * Constructor.
	 *
	 * @param \WC_Gateway_Paypal $gateway Paypal gateway object.
	 */
	public function __construct( \WC_Gateway_Paypal $gateway ) {
		$this->gateway = $gateway;
	}

	/**
	 * Create a PayPal order using the Orders v2 API.
	 *
	 * This method creates a PayPal order and returns the order details including
	 * the approval URL where customers will be redirected to complete payment.
	 *
	 * @param WC_Order $order Order object.
	 * @param string   $payment_source The payment source.
	 * @param array    $js_sdk_params Extra parameters for a PayPal JS SDK (Buttons) request.
	 * @return array|null
	 * @throws Exception If the PayPal order creation fails.
	 */
	public function create_paypal_order(
		WC_Order $order,
		string $payment_source = PayPalConstants::PAYMENT_SOURCE_PAYPAL,
		array $js_sdk_params = array()
	): ?array {
		$paypal_debug_id = null;

		// While PayPal JS SDK can return 'paylater' as the payment source in the createOrder callback,
		// Orders v2 API does not accept it. We will use 'paypal' instead.
		// Accepted payment_source values for Orders v2:
		// https://developer.paypal.com/docs/api/orders/v2/#orders_create!ct=application/json&path=payment_source&t=request.
		if ( PayPalConstants::PAYMENT_SOURCE_PAYLATER === $payment_source ) {
			$payment_source = PayPalConstants::PAYMENT_SOURCE_PAYPAL;
		}

		try {
			$request_body = array(
				'test_mode' => $this->gateway->testmode,
				'order'     => $this->get_paypal_create_order_request_params( $order, $payment_source, $js_sdk_params ),
			);
			$response     = $this->send_wpcom_proxy_request( 'POST', self::WPCOM_PROXY_ORDER_ENDPOINT, $request_body );

			if ( is_wp_error( $response ) ) {
				throw new Exception( 'PayPal order creation failed. Response error: ' . $response->get_error_message() );
			}

			if ( ! is_array( $response ) ) {
				throw new Exception( 'PayPal order creation failed. Invalid response type.' );
			}

			$http_code     = wp_remote_retrieve_response_code( $response );
			$body          = wp_remote_retrieve_body( $response );
			$response_data = json_decode( $body, true );

			$response_array = is_array( $response_data ) ? $response_data : array();

			/**
			 * Fires after receiving a response from PayPal order creation.
			 *
			 * This hook allows extensions to react to PayPal API responses, such as
			 * displaying admin notices or logging response data.
			 *
			 * Note: This hook fires on EVERY order creation attempt (success or failure),
			 * and can be called multiple times for the same order if retried. Extensions
			 * hooking this should be idempotent and check order state/meta before taking
			 * action to avoid duplicate processing.
			 *
			 * @since 10.4.0
			 *
			 * @param int|string $http_code     The HTTP status code from the PayPal API response.
			 * @param array      $response_data The decoded response data from the PayPal API
			 * @param WC_Order   $order         The WooCommerce order object.
			 */
			do_action( 'woocommerce_paypal_standard_order_created_response', $http_code, $response_array, $order );

			if ( ! in_array( $http_code, array( 200, 201 ), true ) ) {
				$paypal_debug_id = isset( $response_data['debug_id'] ) ? $response_data['debug_id'] : null;
				throw new Exception( 'PayPal order creation failed. Response status: ' . $http_code . '. Response body: ' . $body );
			}

			$redirect_url = null;
			if ( empty( $js_sdk_params['is_js_sdk_flow'] ) ) {
				// We only need an approve link for the classic, redirect flow.
				$redirect_url = $this->get_approve_link( $http_code, $response_data );
				if ( empty( $redirect_url ) ) {
					throw new Exception( 'PayPal order creation failed. Missing approval link.' );
				}
			}

			// Save the PayPal order ID to the order.
			$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_ORDER_ID, $response_data['id'] );

			// Save the PayPal order status to the order.
			$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, $response_data['status'] );

			// Remember the payment source: payment_source is not patchable.
			// If the payment source is changed, we need to create a new PayPal order.
			$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_PAYMENT_SOURCE, $payment_source );
			$order->save();

			return array(
				'id'           => $response_data['id'],
				'redirect_url' => $redirect_url,
			);
		} catch ( Exception $e ) {
			\WC_Gateway_Paypal::log( $e->getMessage() );
			if ( $paypal_debug_id ) {
				$order->add_order_note(
					sprintf(
						/* translators: %1$s: PayPal debug ID */
						__( 'PayPal order creation failed. PayPal debug ID: %1$s', 'woocommerce' ),
						$paypal_debug_id
					)
				);
			}
			return null;
		}
	}

	/**
	 * Get PayPal order details.
	 *
	 * @param string $paypal_order_id The ID of the PayPal order.
	 * @return array
	 * @throws Exception If the PayPal order details request fails.
	 * @throws Exception If the PayPal order details are not found.
	 */
	public function get_paypal_order_details( string $paypal_order_id ): array {
		$request_body = array(
			'test_mode' => $this->gateway->testmode,
		);
		$response     = $this->send_wpcom_proxy_request( 'GET', self::WPCOM_PROXY_ORDER_ENDPOINT . '/' . $paypal_order_id, $request_body );
		if ( is_wp_error( $response ) ) {
			throw new Exception( 'PayPal order details request failed: ' . esc_html( $response->get_error_message() ) );
		}

		$http_code     = wp_remote_retrieve_response_code( $response );
		$body          = wp_remote_retrieve_body( $response );
		$response_data = json_decode( $body, true );

		if ( 200 !== $http_code ) {
			$debug_id = isset( $response_data['debug_id'] ) ? $response_data['debug_id'] : null;
			$message  = 'PayPal order details request failed. HTTP ' . (int) $http_code . ( $debug_id ? '. Debug ID: ' . $debug_id : '' );
			throw new Exception( esc_html( $message ) );
		}

		return $response_data;
	}

	/**
	 * Authorize or capture a PayPal payment using the Orders v2 API.
	 *
	 * This method authorizes or captures a PayPal payment and updates the order status.
	 *
	 * @param WC_Order|null $order Order object.
	 * @param string|null   $action_url The URL to authorize or capture the payment.
	 * @param string        $action The action to perform. Either 'authorize' or 'capture'.
	 * @return void
	 * @throws Exception If the PayPal payment authorization or capture fails.
	 */
	public function authorize_or_capture_payment( ?WC_Order $order, ?string $action_url, string $action = PayPalConstants::PAYMENT_ACTION_CAPTURE ): void {
		if ( ! $order ) {
			\WC_Gateway_Paypal::log( 'Order not found to authorize or capture payment.' );
			return;
		}

		$paypal_debug_id = null;
		$paypal_order_id = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_ORDER_ID );
		if ( ! $paypal_order_id ) {
			\WC_Gateway_Paypal::log( 'PayPal order ID not found. Cannot ' . $action . ' payment.' );
			return;
		}

		if ( ! $action_url || ! filter_var( $action_url, FILTER_VALIDATE_URL ) ) {
			\WC_Gateway_Paypal::log( 'Invalid or missing action URL. Cannot ' . $action . ' payment.' );
			return;
		}

		// Skip if the payment is already captured.
		if ( PayPalConstants::STATUS_COMPLETED === $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_STATUS, true ) ) {
			\WC_Gateway_Paypal::log( 'PayPal payment is already captured. Skipping capture. Order ID: ' . $order->get_id() );
			return;
		}

		try {
			if ( PayPalConstants::PAYMENT_ACTION_CAPTURE === $action ) {
				$endpoint     = self::WPCOM_PROXY_PAYMENT_CAPTURE_ENDPOINT;
				$request_body = array(
					'capture_url'     => $action_url,
					'paypal_order_id' => $paypal_order_id,
					'test_mode'       => $this->gateway->testmode,
				);
			} else {
				$endpoint     = self::WPCOM_PROXY_PAYMENT_AUTHORIZE_ENDPOINT;
				$request_body = array(
					'authorize_url'   => $action_url,
					'paypal_order_id' => $paypal_order_id,
					'test_mode'       => $this->gateway->testmode,
				);
			}

			$response = $this->send_wpcom_proxy_request( 'POST', $endpoint, $request_body );

			if ( is_wp_error( $response ) ) {
				throw new Exception( 'PayPal ' . $action . ' payment request failed. Response error: ' . $response->get_error_message() );
			}

			$http_code     = wp_remote_retrieve_response_code( $response );
			$body          = wp_remote_retrieve_body( $response );
			$response_data = json_decode( $body, true );

			if ( 200 !== $http_code && 201 !== $http_code ) {
				$paypal_debug_id = isset( $response_data['debug_id'] ) ? $response_data['debug_id'] : null;
				throw new Exception( 'PayPal ' . $action . ' payment failed. Response status: ' . $http_code . '. Response body: ' . $body );
			}
		} catch ( Exception $e ) {
			\WC_Gateway_Paypal::log( $e->getMessage() );
			$note_message = sprintf(
				/* translators: %1$s: Action, %2$s: PayPal order ID */
				__( 'PayPal %1$s payment failed. PayPal Order ID: %2$s', 'woocommerce' ),
				$action,
				$paypal_order_id
			);

			// Add debug ID to the note if available.
			if ( $paypal_debug_id ) {
				$note_message .= sprintf(
					/* translators: %s: PayPal debug ID */
					__( '. PayPal debug ID: %s', 'woocommerce' ),
					$paypal_debug_id
				);
			}

			$order->add_order_note( $note_message );
			$order->update_status( OrderStatus::FAILED );
			$order->save();
		}
	}

	/**
	 * Capture a PayPal payment that has been authorized.
	 *
	 * @param WC_Order|null $order Order object.
	 * @return void
	 * @throws Exception If the PayPal payment capture fails.
	 */
	public function capture_authorized_payment( ?WC_Order $order ): void {
		if ( ! $order ) {
			\WC_Gateway_Paypal::log( 'Order not found to capture authorized payment.' );
			return;
		}

		$paypal_order_id = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_ORDER_ID, true );
		// Skip if the PayPal Order ID is not found. This means the order was not created via the Orders v2 API.
		if ( ! $paypal_order_id ) {
			\WC_Gateway_Paypal::log( 'PayPal Order ID not found to capture authorized payment. Order ID: ' . $order->get_id() );
			return;
		}

		$capture_id = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_CAPTURE_ID, true );
		// Skip if the payment is already captured.
		if ( $capture_id ) {
			\WC_Gateway_Paypal::log( 'PayPal payment is already captured. PayPal capture ID: ' . $capture_id . '. Order ID: ' . $order->get_id() );
			return;
		}

		$paypal_status = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_STATUS, true );

		// Skip if the payment is already captured.
		if ( PayPalConstants::STATUS_CAPTURED === $paypal_status || PayPalConstants::STATUS_COMPLETED === $paypal_status ) {
			\WC_Gateway_Paypal::log( 'PayPal payment is already captured. Skipping capture. Order ID: ' . $order->get_id() );
			return;
		}

		// Skip if the payment requires payer action.
		if ( PayPalConstants::STATUS_PAYER_ACTION_REQUIRED === $paypal_status ) {
			\WC_Gateway_Paypal::log( 'PayPal payment requires payer action. Skipping capture. Order ID: ' . $order->get_id() );
			return;
		}

		// Skip if the payment is voided.
		if ( PayPalConstants::VOIDED === $paypal_status ) {
			\WC_Gateway_Paypal::log( 'PayPal payment voided. Skipping capture. Order ID: ' . $order->get_id() );
			return;
		}

		$authorization_id = $this->get_authorization_id_for_capture( $order );
		if ( ! $authorization_id ) {
			\WC_Gateway_Paypal::log( 'Authorization ID not found to capture authorized payment. Order ID: ' . $order->get_id() );
			return;
		}

		$paypal_debug_id = null;
		$http_code       = null;

		try {
			$request_body = array(
				'test_mode'        => $this->gateway->testmode,
				'authorization_id' => $authorization_id,
				'paypal_order_id'  => $paypal_order_id,
			);
			$response     = $this->send_wpcom_proxy_request( 'POST', self::WPCOM_PROXY_PAYMENT_CAPTURE_AUTH_ENDPOINT, $request_body );

			if ( is_wp_error( $response ) ) {
				throw new Exception( 'PayPal capture payment request failed. Response error: ' . $response->get_error_message() );
			}

			$http_code             = wp_remote_retrieve_response_code( $response );
			$body                  = wp_remote_retrieve_body( $response );
			$response_data         = json_decode( $body, true );
			$issue                 = isset( $response_data['details'][0]['issue'] ) ? $response_data['details'][0]['issue'] : '';
			$auth_already_captured = 422 === $http_code && PayPalConstants::PAYPAL_ISSUE_AUTHORIZATION_ALREADY_CAPTURED === $issue;

			if ( 200 !== $http_code && 201 !== $http_code && ! $auth_already_captured ) {
				$paypal_debug_id = isset( $response_data['debug_id'] ) ? $response_data['debug_id'] : null;
				throw new Exception( 'PayPal capture payment failed. Response status: ' . $http_code . '. Response body: ' . $body );
			}

			// Set custom status for successful capture response, or if the authorization was already captured.
			$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, PayPalConstants::STATUS_CAPTURED );
			$order->save();
		} catch ( Exception $e ) {
			\WC_Gateway_Paypal::log( $e->getMessage() );

			$note_message = sprintf(
				__( 'PayPal capture authorized payment failed', 'woocommerce' ),
			);

			// Scenario 1: Capture auth API call returned 404 (authorization object does not exist).
			// If the authorization ID is not found (404 response), set the '_paypal_authorization_checked' flag.
			// This flag indicates that we've made an API call to capture PayPal payment and no authorization object was found with this authorization ID.
			// This prevents repeated API calls for orders that have no authorization data.
			if ( 404 === $http_code ) {
				$paypal_dashboard_url = $this->gateway->testmode
					? 'https://www.sandbox.paypal.com/unifiedtransactions'
					: 'https://www.paypal.com/unifiedtransactions';

				$note_message .= sprintf(
					/* translators: %1$s: Authorization ID, %2$s: open link tag, %3$s: close link tag */
					__( '. Authorization ID: %1$s not found. Please log into your %2$sPayPal account%3$s to capture the payment', 'woocommerce' ),
					esc_html( $authorization_id ),
					'<a href="' . esc_url( $paypal_dashboard_url ) . '" target="_blank">',
					'</a>'
				);
				$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_AUTHORIZATION_CHECKED, 'yes' );
			}

			if ( $paypal_debug_id ) {
				$note_message .= sprintf(
					/* translators: %s: PayPal debug ID */
					__( '. PayPal debug ID: %s', 'woocommerce' ),
					$paypal_debug_id
				);
			}

			$order->add_order_note( $note_message );
			$order->save();
		}
	}

	/**
	 * Get the authorization ID for the PayPal payment.
	 *
	 * @param WC_Order $order Order object.
	 * @return string|null
	 */
	private function get_authorization_id_for_capture( WC_Order $order ): ?string {
		$paypal_order_id  = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_ORDER_ID, true );
		$authorization_id = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_AUTHORIZATION_ID, true );
		$capture_id       = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_CAPTURE_ID, true );

		// If the PayPal order ID is not found or the capture ID is already set, return null.
		if ( ! $paypal_order_id || ! empty( $capture_id ) ) {
			return null;
		}

		// If '_paypal_authorization_checked' is set to 'yes', it means we've already made an API call to PayPal
		// and confirmed that no authorization object exists. This flag is set in two scenarios:
		// 1. Capture auth API call returned 404 (authorization object does not exist with the authorization ID).
		// 2. Order details API call returned empty authorization array (authorization object does not exist for this PayPal order).
		// Return null to avoid repeated API calls for orders that have no authorization data.
		if ( 'yes' === $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_AUTHORIZATION_CHECKED, true ) ) {
			return null;
		}

		// If the authorization ID is not found, try to retrieve it from the PayPal order details.
		if ( empty( $authorization_id ) ) {
			\WC_Gateway_Paypal::log( 'Authorization ID not found, trying to retrieve from PayPal order details as a fallback for backwards compatibility. Order ID: ' . $order->get_id() );

			try {
				$order_data         = $this->get_paypal_order_details( $paypal_order_id );
				$authorization_data = $this->get_latest_transaction_data(
					$order_data['purchase_units'][0]['payments']['authorizations'] ?? array()
				);

				$capture_data = $this->get_latest_transaction_data(
					$order_data['purchase_units'][0]['payments']['captures'] ?? array()
				);

				// If the payment is already captured, store the capture ID and status, and return null as there is no authorization ID that needs to be captured.
				if ( $capture_data && isset( $capture_data['id'] ) ) {
					$capture_id = $capture_data['id'];
					$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_CAPTURE_ID, $capture_id );
					$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, $capture_data['status'] ?? PayPalConstants::STATUS_CAPTURED );
					$order->save();
					\WC_Gateway_Paypal::log( 'Storing capture ID from Paypal. Order ID: ' . $order->get_id() . '; capture ID: ' . $capture_id );
					return null;
				}

				if ( $authorization_data && isset( $authorization_data['id'], $authorization_data['status'] ) ) {
					// If the payment is already captured, return null as there is no authorization ID that needs to be captured.
					if ( PayPalConstants::STATUS_CAPTURED === $authorization_data['status'] ) {
						$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, PayPalConstants::STATUS_CAPTURED );
						$order->save();
						return null;
					}
					$authorization_id = $authorization_data['id'];
					$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_AUTHORIZATION_ID, $authorization_id );
					$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, PayPalConstants::STATUS_AUTHORIZED );
					\WC_Gateway_Paypal::log( 'Storing authorization ID from Paypal. Order ID: ' . $order->get_id() . '; authorization ID: ' . $authorization_id );
					$order->save();
				} else {
					// Scenario 2: Order details API call returned empty authorization array (authorization object does not exist).
					// Store '_paypal_authorization_checked' flag to prevent repeated API calls.
					// This flag indicates that we've made an API call to get PayPal order details and confirmed no authorization object exists.
					\WC_Gateway_Paypal::log( 'Authorization ID not found in PayPal order details. Order ID: ' . $order->get_id() );
					$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_AUTHORIZATION_CHECKED, 'yes' );
					$order->save();
					return null;
				}
			} catch ( Exception $e ) {
				\WC_Gateway_Paypal::log( 'Error retrieving authorization ID from PayPal order details. Order ID: ' . $order->get_id() . '. Error: ' . $e->getMessage() );
				return null;
			}
		}

		return $authorization_id;
	}

	/**
	 * Get the latest item from the authorizations or captures array based on update_time.
	 *
	 * @param array $items Array of authorizations or captures.
	 * @return array|null The latest authorization or capture or null if array is empty or no valid update_time found.
	 */
	private function get_latest_transaction_data( array $items ): ?array {
		if ( empty( $items ) ) {
			return null;
		}

		$latest_item = null;
		$latest_time = null;

		foreach ( $items as $item ) {
			if ( empty( $item['update_time'] ) ) {
				continue;
			}

			if ( null === $latest_time || $item['update_time'] > $latest_time ) {
				$latest_time = $item['update_time'];
				$latest_item = $item;
			}
		}

		return $latest_item;
	}

	/**
	 * Get the approve link from the response data.
	 *
	 * @param int|string $http_code The HTTP code of the response.
	 * @param array      $response_data The response data.
	 * @return string|null
	 */
	private function get_approve_link( $http_code, array $response_data ): ?string {
		// See https://developer.paypal.com/docs/api/orders/v2/#orders_create.
		if ( isset( $response_data['status'] ) && PayPalConstants::STATUS_PAYER_ACTION_REQUIRED === $response_data['status'] ) {
			$rel = 'payer-action';
		} else {
			$rel = 'approve';
		}

		foreach ( $response_data['links'] as $link ) {
			if ( $rel === $link['rel'] && 'GET' === $link['method'] && filter_var( $link['href'], FILTER_VALIDATE_URL ) ) {
				return esc_url_raw( $link['href'] );
			}
		}

		return null;
	}

	/**
	 * Build the request parameters for the PayPal create-order request.
	 *
	 * @param WC_Order $order Order object.
	 * @param string   $payment_source The payment source.
	 * @param array    $js_sdk_params Extra parameters for a PayPal JS SDK (Buttons) request.
	 * @return array
	 *
	 * @throws Exception If the order items cannot be built.
	 */
	private function get_paypal_create_order_request_params( WC_Order $order, string $payment_source, array $js_sdk_params ): array {
		$payee_email         = sanitize_email( (string) $this->gateway->get_option( 'email' ) );
		$shipping_preference = $this->get_paypal_shipping_preference( $order );

		/**
		 * Filter the supported currencies for PayPal.
		 *
		 * @since 2.0.0
		 *
		 * @param array $supported_currencies Array of supported currency codes.
		 * @return array
		 */
		$supported_currencies = apply_filters(
			'woocommerce_paypal_supported_currencies',
			PayPalConstants::SUPPORTED_CURRENCIES
		);
		if ( ! in_array( strtoupper( $order->get_currency() ), $supported_currencies, true ) ) {
			throw new Exception( 'Currency is not supported by PayPal. Order ID: ' . esc_html( (string) $order->get_id() ) );
		}

		$purchase_unit_amount = $this->get_paypal_order_purchase_unit_amount( $order );
		if ( $purchase_unit_amount['value'] <= 0 ) {
			// If we cannot build purchase unit amount (e.g. negative or zero order total),
			// we should not proceed with the create-order request.
			throw new Exception( 'Cannot build PayPal order purchase unit amount. Order total is not valid. Order ID: ' . esc_html( (string) $order->get_id() ) . ', Total: ' . esc_html( (string) $purchase_unit_amount['value'] ) );
		}

		$order_items = $this->get_paypal_order_items( $order );

		$src_locale = get_locale();
		// If the locale is longer than PayPal's string limit (10).
		if ( strlen( $src_locale ) > PayPalConstants::PAYPAL_LOCALE_MAX_LENGTH ) {
			// Keep only the main language and region parts.
			$locale_parts = explode( '_', $src_locale );
			if ( count( $locale_parts ) > 2 ) {
				$src_locale = $locale_parts[0] . '_' . $locale_parts[1];
			}
		}

		$params = array(
			'intent'         => $this->get_paypal_order_intent(),
			'payment_source' => array(
				$payment_source => array(
					'experience_context' => array(
						'user_action'           => PayPalConstants::USER_ACTION_PAY_NOW,
						'shipping_preference'   => $shipping_preference,
						// Customer redirected here on approval.
						'return_url'            => $this->normalize_url_for_paypal( add_query_arg( 'utm_nooverride', '1', $this->gateway->get_return_url( $order ) ) ),
						// Customer redirected here on cancellation.
						'cancel_url'            => $this->normalize_url_for_paypal( $order->get_cancel_order_url_raw() ),
						// Convert WordPress locale format (e.g., 'en_US') to PayPal's expected format (e.g., 'en-US').
						'locale'                => str_replace( '_', '-', $src_locale ),
						'app_switch_preference' => array(
							'launch_paypal_app' => true,
						),
					),
				),
			),
			'purchase_units' => array(
				array(
					'custom_id'  => $this->get_paypal_order_custom_id( $order ),
					'amount'     => $purchase_unit_amount,
					'invoice_id' => $this->limit_length( $this->gateway->get_option( 'invoice_prefix' ) . $order->get_order_number(), PayPalConstants::PAYPAL_INVOICE_ID_MAX_LENGTH ),
					'items'      => $order_items,
					'payee'      => array(
						'email_address' => $payee_email,
					),
				),
			),
		);

		if ( ! in_array(
			$shipping_preference,
			array(
				PayPalConstants::SHIPPING_NO_SHIPPING,
				PayPalConstants::SHIPPING_SET_PROVIDED_ADDRESS,
			),
			true
		) ) {
			$shipping_callback_token = $this->generate_shipping_callback_token( $order );
			$callback_url            = add_query_arg(
				'token',
				$shipping_callback_token,
				rest_url( 'wc/v3/paypal-standard/update-shipping' )
			);

			$params['payment_source'][ $payment_source ]['experience_context']['order_update_callback_config'] = array(
				'callback_events' => array( 'SHIPPING_ADDRESS', 'SHIPPING_OPTIONS' ),
				'callback_url'    => $this->normalize_url_for_paypal( $callback_url ),
			);
		}

		// If the request is from PayPal JS SDK (Buttons), we need a cancel URL that is compatible with App Switch.
		if ( ! empty( $js_sdk_params['is_js_sdk_flow'] ) && ! empty( $js_sdk_params['app_switch_request_origin'] ) ) {
			// App Switch may open a new tab, so we cannot rely on client-side data.
			// We need to pass the order ID manually.
			// See https://developer.paypal.com/docs/checkout/standard/customize/app-switch/#resume-flow.

			$request_origin = $js_sdk_params['app_switch_request_origin'];

			// Check if $request_origin is a valid URL, and matches the current site.
			$origin_parts       = wp_parse_url( $request_origin );
			$site_parts         = wp_parse_url( get_site_url() );
			$is_valid_url       = filter_var( $request_origin, FILTER_VALIDATE_URL );
			$is_expected_scheme = isset( $origin_parts['scheme'], $site_parts['scheme'] ) && strcasecmp( $origin_parts['scheme'], $site_parts['scheme'] ) === 0;
			$is_expected_host   = isset( $origin_parts['host'], $site_parts['host'] ) && strcasecmp( $origin_parts['host'], $site_parts['host'] ) === 0;
			if ( $is_valid_url && $is_expected_scheme && $is_expected_host ) {
				$cancel_url = add_query_arg(
					array(
						'order_id' => $order->get_id(),
					),
					$request_origin
				);
				$params['payment_source'][ $payment_source ]['experience_context']['cancel_url'] = $this->normalize_url_for_paypal( $cancel_url );
			}
		}

		$shipping = $this->get_paypal_order_shipping( $order );
		if ( $shipping ) {
			$params['purchase_units'][0]['shipping'] = $shipping;
		}

		return $params;
	}

	/**
	 * Get the amount data  for the PayPal order purchase unit field.
	 *
	 * @param WC_Order|null $order Order object.
	 * @return array
	 */
	public function get_paypal_order_purchase_unit_amount( ?WC_Order $order ): array {
		if ( ! $order ) {
			return array();
		}

		$currency = $order->get_currency();

		return array(
			'currency_code' => $currency,
			'value'         => wc_format_decimal( $order->get_total(), wc_get_price_decimals() ),
			'breakdown'     => array(
				'item_total' => array(
					'currency_code' => $currency,
					'value'         => wc_format_decimal( $this->get_paypal_order_items_subtotal( $order ), wc_get_price_decimals() ),
				),
				'shipping'   => array(
					'currency_code' => $currency,
					'value'         => wc_format_decimal( $order->get_shipping_total(), wc_get_price_decimals() ),
				),
				'tax_total'  => array(
					'currency_code' => $currency,
					'value'         => wc_format_decimal( $order->get_total_tax(), wc_get_price_decimals() ),
				),
				'discount'   => array(
					'currency_code' => $currency,
					'value'         => wc_format_decimal( $order->get_discount_total(), wc_get_price_decimals() ),
				),
			),
		);
	}

	/**
	 * Build the custom ID for the PayPal order. The custom ID will be used by the proxy for webhook forwarding,
	 * and by later steps to identify the order.
	 *
	 * @param WC_Order $order Order object.
	 * @return string
	 * @throws Exception If the custom ID is too long.
	 */
	private function get_paypal_order_custom_id( WC_Order $order ): string {
		$custom_id = wp_json_encode(
			array(
				'order_id'  => $order->get_id(),
				'order_key' => $order->get_order_key(),
				// Endpoint for the proxy to forward webhooks to.
				'site_url'  => home_url(),
				'site_id'   => class_exists( '\Jetpack_Options' ) ? \Jetpack_Options::get_option( 'id' ) : null,
				'v'         => defined( 'WC_VERSION' ) ? WC_VERSION : WC()->version,
			)
		);

		if ( false === $custom_id ) {
			throw new Exception( 'Failed to encode custom ID.' );
		}

		if ( strlen( $custom_id ) > 255 ) {
			throw new Exception( 'PayPal order custom ID is too long. Max length is 255 chars.' );
		}

		return $custom_id ? $custom_id : '';
	}

	/**
	 * Get the order items for the PayPal create-order request.
	 * Returns an empty array if any of the items (amount, quantity) are invalid.
	 *
	 * @param WC_Order $order Order object.
	 * @return array
	 */
	private function get_paypal_order_items( WC_Order $order ): array {
		$items = array();

		foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item ) {
			$item_amount = $this->get_paypal_order_item_amount( $order, $item );
			if ( $item_amount < 0 ) {
				// PayPal does not accept negative item amounts in the items breakdown, so we return an empty list.
				return array();
			}

			$quantity = $item->get_quantity();
			// PayPal does not accept zero or fractional quantities.
			if ( ! is_numeric( $quantity ) || $quantity <= 0 || floor( $quantity ) != $quantity ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual
				return array();
			}

			$items[] = array(
				'name'        => $this->limit_length( $item->get_name(), PayPalConstants::PAYPAL_ORDER_ITEM_NAME_MAX_LENGTH ),
				'quantity'    => $item->get_quantity(),
				'unit_amount' => array(
					'currency_code' => $order->get_currency(),
					// Use the subtotal before discounts.
					'value'         => wc_format_decimal( $item_amount, wc_get_price_decimals() ),
				),
			);
		}

		return $items;
	}

	/**
	 * Get the subtotal for all items, before discounts.
	 *
	 * @param WC_Order $order Order object.
	 * @return float
	 */
	private function get_paypal_order_items_subtotal( WC_Order $order ): float {
		$total = 0;
		foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item ) {
			$total += wc_add_number_precision( $this->get_paypal_order_item_amount( $order, $item ) * $item->get_quantity(), false );
		}

		return wc_remove_number_precision( $total );
	}

	/**
	 * Get the amount for a specific order item.
	 *
	 * @param WC_Order       $order Order object.
	 * @param \WC_Order_Item $item Order item.
	 * @return float
	 */
	private function get_paypal_order_item_amount( WC_Order $order, \WC_Order_Item $item ): float {
		if ( 'fee' === $item->get_type() && $item instanceof \WC_Order_Item_Fee ) {
			return (float) $item->get_amount();
		}
		return (float) $order->get_item_subtotal( $item, $include_tax = false, $rounding_enabled = false );
	}

	/**
	 * Get the value for the intent field in the create-order request.
	 *
	 * @return string
	 */
	private function get_paypal_order_intent(): string {
		$payment_action = $this->gateway->get_option( 'paymentaction' );
		if ( 'authorization' === $payment_action ) {
			return PayPalConstants::INTENT_AUTHORIZE;
		}

		return PayPalConstants::INTENT_CAPTURE;
	}

	/**
	 * Get the shipping preference for the PayPal create-order request.
	 *
	 * @param WC_Order $order Order object.
	 * @return string
	 */
	private function get_paypal_shipping_preference( WC_Order $order ): string {
		if ( ! $order->needs_shipping() ) {
			return PayPalConstants::SHIPPING_NO_SHIPPING;
		}

		$address_override = $this->gateway->get_option( 'address_override' ) === 'yes';
		return $address_override ? PayPalConstants::SHIPPING_SET_PROVIDED_ADDRESS : PayPalConstants::SHIPPING_GET_FROM_FILE;
	}

	/**
	 * Get the shipping information for the PayPal create-order request.
	 *
	 * @param WC_Order $order Order object.
	 * @return array|null Returns null if the shipping is not required,
	 *  or the address is not set, or is incomplete.
	 */
	private function get_paypal_order_shipping( WC_Order $order ): ?array {
		if ( ! $order->needs_shipping() ) {
			return null;
		}

		$address_type = 'yes' === $this->gateway->get_option( 'send_shipping' ) ? 'shipping' : 'billing';

		$full_name      = trim( $order->{"get_formatted_{$address_type}_full_name"}() );
		$address_line_1 = trim( $order->{"get_{$address_type}_address_1"}() );
		$address_line_2 = trim( $order->{"get_{$address_type}_address_2"}() );
		$state          = trim( $order->{"get_{$address_type}_state"}() );
		$city           = trim( $order->{"get_{$address_type}_city"}() );
		$postcode       = trim( $order->{"get_{$address_type}_postcode"}() );
		$country        = trim( $order->{"get_{$address_type}_country"}() );

		// If we do not have the complete address,
		// e.g. PayPal Buttons on product pages, we should not set the 'shipping' param
		// for the create-order request, otherwise it will fail.
		// Shipping information will be updated by the shipping callback handlers.

		// Country is a required field.
		if ( empty( $country ) ) {
			return null;
		}

		// Make sure the country code is in the correct format.
		$raw_country = $country;
		$country     = $this->normalize_paypal_order_shipping_country_code( $raw_country );
		if ( ! $country ) {
			\WC_Gateway_Paypal::log( sprintf( 'Could not identify a correct country code. Raw value: %s', $raw_country ), 'error' );
			return null;
		}

		// Validate required fields based on country-specific address requirements.
		// phpcs:ignore Generic.Commenting.Todo.TaskFound
		// TODO: The container call can be removed once we migrate this class to the `src` folder.
		$address_requirements = wc_get_container()->get( PayPalAddressRequirements::class )::instance();
		if ( empty( $city ) && $address_requirements->country_requires_city( $country ) ) {
			\WC_Gateway_Paypal::log( sprintf( 'City is required for country: %s', $country ), 'error' );
			return null;
		}

		if ( empty( $postcode ) && $address_requirements->country_requires_postal_code( $country ) ) {
			\WC_Gateway_Paypal::log( sprintf( 'Postal code is required for country: %s', $country ), 'error' );
			return null;
		}

		return array(
			'name'    => array(
				'full_name' => $full_name,
			),
			'address' => array(
				'address_line_1' => $this->limit_length( $address_line_1, PayPalConstants::PAYPAL_ADDRESS_LINE_MAX_LENGTH ),
				'address_line_2' => $this->limit_length( $address_line_2, PayPalConstants::PAYPAL_ADDRESS_LINE_MAX_LENGTH ),
				'admin_area_1'   => $this->limit_length( $state, PayPalConstants::PAYPAL_STATE_MAX_LENGTH ),
				'admin_area_2'   => $this->limit_length( $city, PayPalConstants::PAYPAL_CITY_MAX_LENGTH ),
				'postal_code'    => $this->limit_length( $postcode, PayPalConstants::PAYPAL_POSTAL_CODE_MAX_LENGTH ),
				'country_code'   => strtoupper( $country ),
			),
		);
	}

	/**
	 * Generate and store a shipping callback token for the order.
	 * The token is stored in the database cache and can be validated later.
	 *
	 * @param WC_Order $order The order object.
	 * @return string The generated token.
	 */
	private function generate_shipping_callback_token( WC_Order $order ): string {
		$token = bin2hex( random_bytes( 32 ) );

		// Store the token in order meta for validation.
		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_SHIPPING_CALLBACK_TOKEN, $token );
		$order->save();

		return $token;
	}

	/**
	 * Normalize PayPal order shipping country code.
	 *
	 * @param string $country_code Country code to normalize.
	 * @return string|null
	 */
	private function normalize_paypal_order_shipping_country_code( string $country_code ): ?string {
		// Normalize to uppercase.
		$code = strtoupper( trim( (string) $country_code ) );

		// Check if it's a valid alpha-2 code.
		if ( strlen( $code ) === PayPalConstants::PAYPAL_COUNTRY_CODE_LENGTH ) {
			if ( WC()->countries->country_exists( $code ) ) {
				return $code;
			}

			\WC_Gateway_Paypal::log( sprintf( 'Invalid country code: %s', $code ) );
			return null;
		}

		// Log when we get an unexpected country code length.
		\WC_Gateway_Paypal::log( sprintf( 'Unexpected country code length (%d) for country: %s', strlen( $code ), $code ) );

		// Truncate to the expected maximum length (3).
		$max_country_code_length = PayPalConstants::PAYPAL_COUNTRY_CODE_LENGTH + 1;
		if ( strlen( $code ) > $max_country_code_length ) {
			$code = substr( $code, 0, $max_country_code_length );
		}

		// Check if it's a valid alpha-3 code.
		$alpha2 = WC()->countries->get_country_from_alpha_3_code( $code );
		if ( null === $alpha2 ) {
			\WC_Gateway_Paypal::log( sprintf( 'Invalid alpha-3 country code: %s', $code ) );
		}

		return $alpha2;
	}

	/**
	 * Normalize a URL for PayPal. PayPal requires absolute URLs with protocol.
	 *
	 * @param string $url The URL to check.
	 * @return string Normalized URL.
	 */
	private function normalize_url_for_paypal( string $url ): string {
		// Replace encoded ampersand with actual ampersand.
		// In some cases, the URL may contain encoded ampersand but PayPal expects the actual ampersand.
		// PayPal request fails if the URL contains encoded ampersand.
		$url = str_replace( '&#038;', '&', $url );

		// If the URL is already the home URL, return it.
		if ( strpos( $url, home_url() ) === 0 ) {
			return esc_url_raw( $url );
		}

		// Return the URL if it is already absolute (contains ://).
		if ( strpos( $url, '://' ) !== false ) {
			return esc_url_raw( $url );
		}

		$home_url = untrailingslashit( home_url() );

		// If the URL is relative (starts with /), prepend the home URL.
		if ( strpos( $url, '/' ) === 0 ) {
			return esc_url_raw( $home_url . $url );
		}

		// Prepend home URL with a slash.
		return esc_url_raw( $home_url . '/' . $url );
	}

	/**
	 * Fetch the PayPal client-id from the Transact platform.
	 *
	 * @return string|null The PayPal client-id, or null if the request fails.
	 * @throws Exception If the request fails.
	 */
	public function fetch_paypal_client_id(): ?string {
		try {
			$request_body = array(
				'test_mode' => $this->gateway->testmode,
			);

			$response = $this->send_wpcom_proxy_request( 'GET', self::WPCOM_PROXY_CLIENT_ID_ENDPOINT, $request_body );

			if ( is_wp_error( $response ) ) {
				throw new Exception( 'Failed to fetch the client ID. Response error: ' . $response->get_error_message() );
			}

			$http_code     = wp_remote_retrieve_response_code( $response );
			$body          = wp_remote_retrieve_body( $response );
			$response_data = json_decode( $body, true );

			if ( 200 !== $http_code ) {
				throw new Exception( 'Failed to fetch the client ID. Response status: ' . $http_code . '. Response body: ' . $body );
			}

			return $response_data['client_id'] ?? null;
		} catch ( Exception $e ) {
			\WC_Gateway_Paypal::log( $e->getMessage() );
			return null;
		}
	}

	/**
	 * Send a request to the API proxy.
	 *
	 * @param string $method The HTTP method to use.
	 * @param string $endpoint The endpoint to request.
	 * @param array  $request_body The request body.
	 *
	 * @return array|\WP_Error The API response body, or WP_Error if the request fails.
	 * @throws Exception If the site ID is not found.
	 */
	private function send_wpcom_proxy_request( string $method, string $endpoint, array $request_body ) {
		$site_id = \Jetpack_Options::get_option( 'id' );
		if ( ! $site_id ) {
			\WC_Gateway_Paypal::log( sprintf( 'Site ID not found. Cannot send request to %s.', $endpoint ) );
			throw new Exception( 'Site ID not found. Cannot send proxy request.' );
		}

		if ( 'GET' === $method ) {
			$endpoint .= '?' . http_build_query( $request_body );
		}

		$response = Jetpack_Connection_Client::wpcom_json_api_request_as_blog(
			sprintf( '/sites/%d/%s/%s', $site_id, self::WPCOM_PROXY_REST_BASE, $endpoint ),
			self::WPCOM_PROXY_ENDPOINT_API_VERSION,
			array(
				'headers' => array(
					'Content-Type' => 'application/json',
					'User-Agent'   => 'TransactGateway/woocommerce/' . WC()->version,
				),
				'method'  => $method,
				'timeout' => PayPalConstants::WPCOM_PROXY_REQUEST_TIMEOUT,
			),
			'GET' === $method ? null : wp_json_encode( $request_body ),
			'wpcom'
		);

		return $response;
	}

	/**
	 * Limit length of an arg.
	 *
	 * @param  string  $text Text to limit.
	 * @param  integer $limit Limit size in characters.
	 * @return string
	 */
	private function limit_length( string $text, int $limit = 127 ): string {
		$str_limit = $limit - 3;
		if ( function_exists( 'mb_strimwidth' ) ) {
			if ( mb_strlen( $text ) > $limit ) {
				$text = mb_strimwidth( $text, 0, $str_limit ) . '...';
			}
		} elseif ( strlen( $text ) > $limit ) {
			$text = substr( $text, 0, $str_limit ) . '...';
		}
		return $text;
	}
}
PK     !m.]}K%%  %    PayPal/WebhookHandler.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Gateways\PayPal;

use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Gateways\PayPal\Constants as PayPalConstants;
use Automattic\WooCommerce\Gateways\PayPal\Helper as PayPalHelper;
use Automattic\WooCommerce\Gateways\PayPal\Request as PayPalRequest;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class WebhookHandler file.
 *
 * Handles webhook events.
 *
 * @since 10.5.0
 */
class WebhookHandler {

	/**
	 * Process the webhook event.
	 *
	 * @since 10.5.0
	 *
	 * @param \WP_REST_Request $request The request object.
	 * @return void
	 */
	public function process_webhook( \WP_REST_Request $request ): void {
		$data = $request->get_json_params();
		if ( ! is_array( $data ) || empty( $data['event_type'] ) || empty( $data['resource'] ) ) {
			\WC_Gateway_Paypal::log( 'Invalid PayPal webhook payload: ' . wc_print_r( $data, true ) );
			return;
		}

		\WC_Gateway_Paypal::log( 'Webhook received: ' . wc_print_r( PayPalHelper::redact_data( $data ), true ) );

		switch ( $data['event_type'] ) {
			case 'CHECKOUT.ORDER.APPROVED':
				$this->process_checkout_order_approved( $data );
				break;
			case 'PAYMENT.CAPTURE.PENDING':
				$this->process_payment_capture_pending( $data );
				break;
			case 'PAYMENT.CAPTURE.COMPLETED':
				$this->process_payment_capture_completed( $data );
				break;
			case 'PAYMENT.AUTHORIZATION.CREATED':
				$this->process_payment_authorization_created( $data );
				break;
			default:
				\WC_Gateway_Paypal::log( 'Unhandled PayPal webhook event: ' . wc_print_r( PayPalHelper::redact_data( $data ), true ) );
				break;
		}
	}

	/**
	 * Process the CHECKOUT.ORDER.APPROVED webhook event.
	 *
	 * @since 10.5.0
	 *
	 * @param array $event The webhook event data.
	 * @return void
	 */
	private function process_checkout_order_approved( array $event ): void {
		$custom_id = $event['resource']['purchase_units'][0]['custom_id'] ?? '';
		$order     = PayPalHelper::get_wc_order_from_paypal_custom_id( $custom_id );
		if ( ! $order ) {
			\WC_Gateway_Paypal::log( 'Invalid order. Custom ID: ' . wc_print_r( $custom_id, true ) );
			return;
		}

		// Skip if the payment is already processed.
		$paypal_status = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_STATUS, true );
		if ( in_array( $paypal_status, array( PayPalConstants::STATUS_COMPLETED, PayPalConstants::STATUS_APPROVED ), true ) ) {
			return;
		}

		$status          = $event['resource']['status'] ?? null;
		$paypal_order_id = $event['resource']['id'] ?? null;
		if ( PayPalConstants::STATUS_APPROVED === $status ) {
			\WC_Gateway_Paypal::log( 'PayPal payment approved. Order ID: ' . $order->get_id() );
			$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, $status );
			// Clear the shipping callback token by setting it to an empty string.
			// This is done to prevent the token from being used again for the same order.
			// We are not deleting the meta key as we use the existence of the meta key to determine if the token was ever generated for this order.
			$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_SHIPPING_CALLBACK_TOKEN, '' );
			$order->save();
			$order->add_order_note(
				sprintf(
					/* translators: %1$s: PayPal order ID */
					__( 'PayPal payment approved. PayPal Order ID: %1$s', 'woocommerce' ),
					$paypal_order_id
				)
			);
			$order->save();

			// Update the addresses in the order with the addresses from the PayPal order details.
			PayPalHelper::update_addresses_in_order( $order, $event['resource'] );

			// Authorize or capture the payment after approval.
			$paypal_intent = $event['resource']['intent'] ?? null;
			$links         = $event['resource']['links'] ?? null;
			$action        = PayPalConstants::INTENT_CAPTURE === $paypal_intent ? PayPalConstants::PAYMENT_ACTION_CAPTURE : PayPalConstants::PAYMENT_ACTION_AUTHORIZE;
			$this->authorize_or_capture_payment( $order, $links, $action );
		} else {
			// This is unexpected for a CHECKOUT.ORDER.APPROVED event.
			\WC_Gateway_Paypal::log( 'PayPal payment approval failed. Order ID: ' . $order->get_id() . ' Status: ' . $status );
			$order->add_order_note(
				sprintf(
					/* translators: %1$s: PayPal order ID, %2$s: Status */
					__( 'PayPal payment approval failed. PayPal Order ID: %1$s. Status: %2$s', 'woocommerce' ),
					$paypal_order_id,
					$status
				)
			);
		}
	}

	/**
	 * Process the PAYMENT.CAPTURE.COMPLETED webhook event.
	 *
	 * @since 10.5.0
	 *
	 * @param array $event The webhook event data.
	 * @return void
	 */
	private function process_payment_capture_completed( array $event ): void {
		$custom_id = $event['resource']['custom_id'] ?? '';
		$order     = PayPalHelper::get_wc_order_from_paypal_custom_id( $custom_id );
		if ( ! $order ) {
			\WC_Gateway_Paypal::log( 'Invalid order. Custom ID: ' . wc_print_r( $custom_id, true ) );
			return;
		}

		// Skip if the payment is already processed.
		if ( PayPalConstants::STATUS_COMPLETED === $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_STATUS, true ) ) {
			return;
		}

		$transaction_id = $event['resource']['id'] ?? null;
		$status         = $event['resource']['status'] ?? null;
		$order->set_transaction_id( $transaction_id );
		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_CAPTURE_ID, $transaction_id );
		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, $status );
		$order->payment_complete();
		$order->add_order_note(
			sprintf(
				/* translators: %1$s: Transaction ID */
				__( 'PayPal payment captured. Transaction ID: %1$s.', 'woocommerce' ),
				$transaction_id
			)
		);
		$order->save();
	}

	/**
	 * Process the PAYMENT.CAPTURE.PENDING webhook event.
	 *
	 * @since 10.5.0
	 *
	 * @param array $event The webhook event data.
	 * @return void
	 */
	private function process_payment_capture_pending( array $event ): void {
		$custom_id = $event['resource']['custom_id'] ?? '';
		$order     = PayPalHelper::get_wc_order_from_paypal_custom_id( $custom_id );
		if ( ! $order ) {
			\WC_Gateway_Paypal::log( 'Invalid order. Custom ID: ' . wc_print_r( $custom_id, true ) );
			return;
		}

		// Skip if the payment is already processed.
		if ( PayPalConstants::STATUS_COMPLETED === $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_STATUS, true ) ) {
			return;
		}

		$transaction_id = $event['resource']['id'] ?? null;
		$status         = $event['resource']['status'] ?? null;
		$reason         = $event['resource']['status_details']['reason'] ?? 'Unknown';
		$order->set_transaction_id( $transaction_id );
		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_CAPTURE_ID, $transaction_id );
		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, $status );
		/* translators: %s: reason */
		$order->update_status( OrderStatus::ON_HOLD, sprintf( __( 'Payment pending (reason: %s).', 'woocommerce' ), $reason ) );
		$order->save();
	}

	/**
	 * Process the PAYMENT.AUTHORIZATION.CREATED webhook event.
	 *
	 * @since 10.5.0
	 *
	 * @param array $event The webhook event data.
	 * @return void
	 */
	private function process_payment_authorization_created( array $event ): void {
		$custom_id = $event['resource']['custom_id'] ?? '';
		$order     = PayPalHelper::get_wc_order_from_paypal_custom_id( $custom_id );
		if ( ! $order ) {
			\WC_Gateway_Paypal::log( 'Invalid order. Custom ID: ' . wc_print_r( $custom_id, true ) );
			return;
		}

		// Skip if the payment is already processed.
		if ( PayPalConstants::STATUS_COMPLETED === $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_STATUS, true ) ) {
			return;
		}

		$transaction_id = $event['resource']['id'] ?? null;
		$order->set_transaction_id( $transaction_id );
		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_AUTHORIZATION_ID, $transaction_id );
		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, PayPalConstants::STATUS_AUTHORIZED );
		$order->add_order_note(
			sprintf(
				/* translators: %1$s: Transaction ID */
				__( 'PayPal payment authorized. Transaction ID: %1$s. Change payment status to processing or complete to capture funds.', 'woocommerce' ),
				$transaction_id
			)
		);
		$order->update_status( OrderStatus::ON_HOLD );
		$order->save();
	}

	/**
	 * Capture the payment.
	 *
	 * @since 10.5.0
	 *
	 * @param \WC_Order $order The order object.
	 * @param array     $links The links from the webhook event.
	 * @param string    $action The action to perform (capture or authorize).
	 * @return void
	 */
	private function authorize_or_capture_payment( \WC_Order $order, array $links, string $action ): void {
		$action_url = $this->get_action_url( $links, $action );

		$payment_gateways = WC()->payment_gateways()->payment_gateways();
		if ( ! isset( $payment_gateways['paypal'] ) ) {
			\WC_Gateway_Paypal::log( 'PayPal gateway is not available.' );
			return;
		}
		$gateway        = $payment_gateways['paypal'];
		$paypal_request = new PayPalRequest( $gateway );
		$paypal_request->authorize_or_capture_payment( $order, $action_url, $action );
	}

	/**
	 * Get the action URL from the links.
	 *
	 * @since 10.5.0
	 *
	 * @param array  $links The links from the webhook event.
	 * @param string $action The action to perform (capture or authorize).
	 * @return string|null
	 */
	private function get_action_url( array $links, string $action ): ?string {
		$action_url = null;
		foreach ( $links as $link ) {
			if ( $action === $link['rel'] && 'POST' === $link['method'] && filter_var( $link['href'], FILTER_VALIDATE_URL ) ) {
				$action_url = esc_url_raw( $link['href'] );
				break;
			}
		}
		return $action_url;
	}
}
PK     !m.]Q$G(  G(    PayPal/Notices.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Gateways\PayPal;

use WC_Order;
use Automattic\WooCommerce\Gateways\PayPal\Helper as PayPalHelper;

defined( 'ABSPATH' ) || exit;

/**
 * PayPal Notices Class
 *
 * Handles admin notices for PayPal gateway including migration notices,
 * account restriction warnings, and currency support notifications.
 *
 * @since 10.5.0
 */
class Notices {
	/**
	 * The name of the notice for PayPal migration.
	 *
	 * @since 10.5.0
	 * @var string
	 */
	private const PAYPAL_MIGRATION_NOTICE = 'paypal_migration_completed';

	/**
	 * The name of the notice for PayPal account restriction.
	 *
	 * @since 10.5.0
	 * @var string
	 */
	private const PAYPAL_ACCOUNT_RESTRICTED_NOTICE = 'paypal_account_restricted';

	/**
	 * The name of the notice for PayPal unsupported currency.
	 *
	 * @since 10.5.0
	 * @var string
	 */
	private const PAYPAL_UNSUPPORTED_CURRENCY_NOTICE = 'paypal_unsupported_currency';

	/**
	 * PayPal account restriction issue codes from PayPal API.
	 *
	 * @since 10.5.0
	 * @var array
	 */
	protected const PAYPAL_ACCOUNT_RESTRICTION_ISSUES = array(
		Constants::PAYPAL_ISSUE_PAYEE_ACCOUNT_LOCKED_OR_CLOSED,
		Constants::PAYPAL_ISSUE_PAYEE_ACCOUNT_RESTRICTED,
	);

	/**
	 * The PayPal gateway instance.
	 *
	 * @var \WC_Gateway_Paypal
	 */
	private $gateway;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->gateway = \WC_Gateway_Paypal::get_instance();
		if ( ! $this->gateway ) {
			return;
		}

		// Only register admin notice hooks in the admin area.
		if ( is_admin() ) {
			add_action( 'admin_notices', array( $this, 'add_paypal_notices' ) );

			// Use admin_head to inject notice on payments settings page.
			// This bypasses the suppress_admin_notices() function which removes all admin_notices hooks on the payments page.
			// This is a workaround to avoid the notice being suppressed by the suppress_admin_notices() function.
			add_action( 'admin_head', array( $this, 'add_paypal_notices_on_payments_settings_page' ) );
		}
	}

	/**
	 * Add PayPal Standard notices.
	 *
	 * @since 10.5.0
	 * @return void
	 */
	public function add_paypal_notices(): void {
		// Show only to users who can manage the site.
		if ( ! current_user_can( 'manage_woocommerce' ) && ! current_user_can( 'manage_options' ) ) {
			return;
		}

		// Skip if the gateway is not available or the merchant has not been onboarded.
		if ( ! PayPalHelper::is_paypal_gateway_available() || ! $this->gateway->should_use_orders_v2() ) {
			return;
		}

		$this->add_paypal_migration_notice();
		$this->add_paypal_account_restricted_notice();
		$this->add_paypal_unsupported_currency_notice();
	}

	/**
	 * Add PayPal notices on the payments settings page.
	 *
	 * @since 10.5.0
	 * @return void
	 */
	public function add_paypal_notices_on_payments_settings_page(): void {
		global $current_tab, $current_section;

		$screen    = get_current_screen();
		$screen_id = $screen ? $screen->id : '';

		$is_payments_settings_page = 'woocommerce_page_wc-settings' === $screen_id && 'checkout' === $current_tab && empty( $current_section );

		// Only add the notice from this callback on the payments settings page.
		if ( ! $is_payments_settings_page ) {
			return;
		}

		$this->add_paypal_notices();
	}

	/**
	 * Add notice warning about the migration to PayPal Payments.
	 *
	 * @since 10.5.0
	 * @return void
	 */
	public function add_paypal_migration_notice(): void {
		// Skip if the notice has been dismissed.
		if ( $this->is_notice_dismissed( self::PAYPAL_MIGRATION_NOTICE ) ) {
			return;
		}

		$doc_url     = 'https://woocommerce.com/document/woocommerce-paypal-payments/paypal-payments-upgrade-guide/';
		$dismiss_url = $this->get_dismiss_url( self::PAYPAL_MIGRATION_NOTICE );
		$message     = sprintf(
			/* translators: 1: opening <a> tag, 2: closing </a> tag */
			esc_html__( 'WooCommerce has upgraded your PayPal integration from PayPal Standard to PayPal Payments (PPCP), for a more reliable and modern checkout experience. If you do not prefer the upgraded integration in WooCommerce, we recommend switching to %1$sPayPal Payments%2$s extension.', 'woocommerce' ),
			'<a href="' . esc_url( $doc_url ) . '" target="_blank" rel="noopener noreferrer">',
			'</a>',
		);

		$notice_html = '<div class="notice notice-warning is-dismissible">'
			. '<a class="woocommerce-message-close notice-dismiss" style="text-decoration: none;" href="' . esc_url( $dismiss_url ) . '" aria-label="' . esc_attr__( 'Dismiss this notice', 'woocommerce' ) . '"></a>'
			. '<p>' . $message . '</p>'
			. '</div>';

		echo wp_kses_post( $notice_html );
	}

	/**
	 * Add notice warning about PayPal account restriction.
	 *
	 * @since 10.5.0
	 * @return void
	 */
	private function add_paypal_account_restricted_notice(): void {
		// Skip if there's no account restriction flag.
		if ( ! $this->has_account_restriction_flag() ) {
			return;
		}

		// Skip if the notice has been dismissed.
		if ( $this->is_notice_dismissed( self::PAYPAL_ACCOUNT_RESTRICTED_NOTICE ) ) {
			return;
		}

		$support_url = 'https://www.paypal.com/smarthelp/contact-us';
		$dismiss_url = $this->get_dismiss_url( self::PAYPAL_ACCOUNT_RESTRICTED_NOTICE );
		$message     = sprintf(
			/* translators: 1: opening <a> tag, 2: closing </a> tag */
			esc_html__( 'Your PayPal account has been restricted by PayPal. This may prevent customers from completing payments. Please %1$scontact PayPal support%2$s to resolve this issue and restore full functionality to your account.', 'woocommerce' ),
			'<a href="' . esc_url( $support_url ) . '" target="_blank" rel="noopener noreferrer">',
			'</a>',
		);

		$notice_html = '<div class="notice notice-error is-dismissible">'
			. '<a class="woocommerce-message-close notice-dismiss" style="text-decoration: none;" href="' . esc_url( $dismiss_url ) . '" aria-label="' . esc_attr__( 'Dismiss this notice', 'woocommerce' ) . '"></a>'
			. '<p><strong>' . esc_html__( 'PayPal Account Restricted', 'woocommerce' ) . '</strong></p>'
			. '<p>' . $message . '</p>'
			. '</div>';

		echo wp_kses_post( $notice_html );
	}

	/**
	 * Add notice warning when PayPal does not support the store's currency.
	 *
	 * @since 10.5.0
	 * @return void
	 */
	private function add_paypal_unsupported_currency_notice(): void {
		$currency = get_woocommerce_currency();

		// Skip if the currency is supported by PayPal.
		if ( $this->gateway->is_valid_for_use() ) {
			return;
		}

		// Skip if the notice has been dismissed.
		if ( $this->is_notice_dismissed( self::PAYPAL_UNSUPPORTED_CURRENCY_NOTICE ) ) {
			return;
		}

		$dismiss_url = $this->get_dismiss_url( self::PAYPAL_UNSUPPORTED_CURRENCY_NOTICE );
		$message     = sprintf(
			/* translators: %s: Currency code */
			esc_html__( 'PayPal Standard does not support your store currency (%s).', 'woocommerce' ),
			$currency
		);

		$notice_html = '<div class="notice notice-error is-dismissible">'
			. '<a class="woocommerce-message-close notice-dismiss" style="text-decoration: none;" href="' . esc_url( $dismiss_url ) . '" aria-label="' . esc_attr__( 'Dismiss this notice', 'woocommerce' ) . '"></a>'
			. '<p>' . $message . '</p>'
			. '</div>';

		echo wp_kses_post( $notice_html );
	}

	/**
	 * Get the dismiss URL for a notice.
	 *
	 * @since 10.5.0
	 * @param string $notice_name The name of the notice.
	 * @return string
	 */
	private function get_dismiss_url( string $notice_name ): string {
		return wp_nonce_url(
			add_query_arg( 'wc-hide-notice', $notice_name ),
			'woocommerce_hide_notices_nonce',
			'_wc_notice_nonce'
		);
	}

	/**
	 * Check if the notice has been dismissed.
	 *
	 * User meta keys for notice dismissals:
	 * - dismissed_paypal_migration_completed_notice
	 * - dismissed_paypal_account_restricted_notice
	 * - dismissed_paypal_unsupported_currency_notice
	 *
	 * The meta keys are set by WC_Admin_Notices when the notice is dismissed by the user.
	 *
	 * @since 10.5.0
	 * @param string $notice_name The name of the notice.
	 * @return bool
	 */
	private function is_notice_dismissed( string $notice_name ): bool {
		return (bool) get_user_meta( get_current_user_id(), 'dismissed_' . $notice_name . '_notice', true );
	}

	/**
	 * Check if there's a flag indicating PayPal account restriction.
	 *
	 * @since 10.5.0
	 * @return bool
	 */
	private function has_account_restriction_flag(): bool {
		return 'yes' === get_option( 'woocommerce_paypal_account_restricted_status', 'no' );
	}

	/**
	 * Set the flag indicating PayPal account restriction.
	 *
	 * @since 10.5.0
	 * @return void
	 */
	public static function set_account_restriction_flag(): void {
		if ( 'no' === get_option( 'woocommerce_paypal_account_restricted_status', 'no' ) ) {
			update_option( 'woocommerce_paypal_account_restricted_status', 'yes', false );
		}
	}

	/**
	 * Clear the flag indicating PayPal account restriction.
	 *
	 * @since 10.5.0
	 * @return void
	 */
	public static function clear_account_restriction_flag(): void {
		if ( 'yes' === get_option( 'woocommerce_paypal_account_restricted_status', 'no' ) ) {
			update_option( 'woocommerce_paypal_account_restricted_status', 'no' );
		}
	}

	/**
	 * Handle PayPal order response to manage account restriction notices.
	 *
	 * @since 10.5.0
	 * @param int|string $http_code     The HTTP status code from the PayPal API response.
	 * @param array      $response_data The decoded response data from the PayPal API.
	 * @param WC_Order   $order         The WooCommerce order object.
	 * @return void
	 */
	public static function manage_account_restriction_flag_for_notice( $http_code, array $response_data, WC_Order $order ): void {
		// Clear the restriction flag on successful responses.
		if ( in_array( (int) $http_code, array( 200, 201 ), true ) ) {
			self::clear_account_restriction_flag();
			return;
		}

		if ( empty( $response_data ) ) {
			return;
		}

		// Set the restriction flag for account-related errors.
		if ( 422 === (int) $http_code ) {
			$issue = isset( $response_data['details'][0]['issue'] ) ? $response_data['details'][0]['issue'] : '';

			if ( in_array( $issue, self::PAYPAL_ACCOUNT_RESTRICTION_ISSUES, true ) ) {
				\WC_Gateway_Paypal::log( 'PayPal account restriction flag set due to issues when handling the order: ' . $order->get_id() );
				self::set_account_restriction_flag();
			}
		}
	}
}
PK       !m.]                    PayPal/Constants.phpnu         PK       !m.]l                X  PayPal/AddressRequirements.phpnu         PK       !m.]hb $  $              K$  PayPal/Buttons.phpnu         PK       !m.]&  &              3  PayPal/Helper.phpnu         PK       !m.]5&r,  ,  !            P  PayPal/TransactAccountManager.phpnu         PK       !m.]                <}  PayPal/Request.phpnu         PK       !m.]}K%%  %              f! PayPal/WebhookHandler.phpnu         PK       !m.]Q$G(  G(              WG PayPal/Notices.phpnu         PK        o