diff --git a/modules/ppcp-compat/services.php b/modules/ppcp-compat/services.php index 30c5599ef6..9aa16302d2 100644 --- a/modules/ppcp-compat/services.php +++ b/modules/ppcp-compat/services.php @@ -18,6 +18,9 @@ use WooCommerce\PayPalCommerce\Compat\Settings\StylingSettingsMapHelper; use WooCommerce\PayPalCommerce\Compat\Settings\SubscriptionSettingsMapHelper; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; +use WooCommerce\PayPalCommerce\Compat\WooCommerceBlueprint\PayPalSettingsExporter; +use WooCommerce\PayPalCommerce\Compat\WooCommerceBlueprint\PayPalSettingsImporter; +use WooCommerce\PayPalCommerce\Compat\WooCommerceBlueprint\PayPalBlueprintBootstrap; return array( @@ -215,4 +218,19 @@ 'compat.settings.payment_methods_map_helper' => static function (): PaymentMethodSettingsMapHelper { return new PaymentMethodSettingsMapHelper(); }, + 'compat.blueprint.is_available' => function (): bool { + return interface_exists( 'Automattic\WooCommerce\Blueprint\Exporters\StepExporter' ); + }, + 'compat.blueprint.paypal_settings_exporter' => static function( ContainerInterface $container ) : PayPalSettingsExporter { + return new PayPalSettingsExporter(); + }, + 'compat.blueprint.paypal_settings_importer' => static function( ContainerInterface $container ) : PayPalSettingsImporter { + return new PayPalSettingsImporter(); + }, + 'compat.blueprint.bootstrap' => static function( ContainerInterface $container ) : PayPalBlueprintBootstrap { + return new PayPalBlueprintBootstrap( + $container->get( 'compat.blueprint.paypal_settings_exporter' ), + $container->get( 'compat.blueprint.paypal_settings_importer' ) + ); + }, ); diff --git a/modules/ppcp-compat/src/CompatModule.php b/modules/ppcp-compat/src/CompatModule.php index ee9fbe2306..dae75fba9b 100644 --- a/modules/ppcp-compat/src/CompatModule.php +++ b/modules/ppcp-compat/src/CompatModule.php @@ -72,6 +72,8 @@ function () use ( $c ) { } ); + $this->add_blueprint_export_test_page( $c ); + $this->migrate_pay_later_settings( $c ); $this->migrate_smart_button_settings( $c ); $this->migrate_three_d_secure_setting(); @@ -89,6 +91,8 @@ function () use ( $c ) { $this->initialize_wc_bookings_compat_layer( $c ); } + $this->initialize_blueprint_compat_layer( $c ); + add_action( 'woocommerce_paypal_payments_gateway_migrate', static fn() => delete_transient( 'ppcp_has_ppec_subscriptions' ) ); $this->legacy_ui_card_payment_mapping( $c ); @@ -553,6 +557,22 @@ static function ( WC_Order $wc_order, CartData $cart_data ) use ( $container ): ); } + /** + * Sets up the WooCommerce Blueprint compatibility layer. + * + * @param ContainerInterface $container The Container. + * @return void + */ + private function initialize_blueprint_compat_layer( ContainerInterface $container ): void { + $is_blueprint_available = $container->get( 'compat.blueprint.is_available' ); + if ( ! $is_blueprint_available ) { + return; + } + + $blueprint_bootstrap = $container->get( 'compat.blueprint.bootstrap' ); + $blueprint_bootstrap->init(); + } + /** * Responsible to keep the credit card payment configuration backwards * compatible with the legacy UI. @@ -582,4 +602,77 @@ static function ( bool $is_acdc ) use ( $container ): bool { } ); } + + /** + * Add temporary admin page for manual blueprint export testing. + * + * @param ContainerInterface $c The Container. + */ + private function add_blueprint_export_test_page( ContainerInterface $c ): void { + add_action('admin_menu', function() use ($c) { + add_submenu_page( + 'woocommerce', + 'PayPal Blueprint Export', + 'PayPal Blueprint Export', + 'manage_woocommerce', + 'ppcp-blueprint-export', + function() use ($c) { + // Handle export BEFORE any output + if ( isset($_POST['export_blueprint']) && check_admin_referer('ppcp_export_blueprint') ) { + + // Get all registered exporters + $all_exporters = apply_filters('wooblueprint_exporters', array()); + + $steps = array(); + + // Add PayPal Settings step + $paypal_exporter = $c->get('compat.blueprint.paypal_settings_exporter'); + $steps[] = $paypal_exporter->export()->prepare_json_array(); + + // Find and add wcPaymentGateways step + foreach ($all_exporters as $exporter) { + if ($exporter->get_step_name() === 'wcPaymentGateways') { + $steps[] = $exporter->export()->prepare_json_array(); + break; + } + } + + // Create proper blueprint structure + $blueprint = array( + 'landingPage' => '/wp-admin/admin.php?page=wc-settings&tab=checkout', + 'steps' => $steps + ); + + if ( ob_get_level() ) { + ob_end_clean(); + } + + header('Content-Type: application/json'); + header('Content-Disposition: attachment; filename="paypal-blueprint-' . date('Y-m-d-His') . '.json"'); + echo wp_json_encode( $blueprint, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); + exit; + } + + // Only show UI if not exporting. + ?> +
+

PayPal Settings Blueprint Export

+

Export your PayPal Payments settings and WooCommerce payment gateways as a Blueprint file.

+
+ +

This will export:

+ +

+ +

+
+
+ exporter = $exporter; + $this->importer = $importer; + } + + /** + * Initialize the PayPal Blueprint functionality. + * + * @return void + */ + public function init(): void { + $this->register_hooks(); + } + + /** + * Register WordPress hooks. + * + * @return void + */ + private function register_hooks(): void { + add_filter( 'wooblueprint_exporters', array( $this, 'register_exporters' ) ); + add_filter( 'wooblueprint_importers', array( $this, 'register_importers' ) ); + } + + /** + * Register PayPal exporters. + * + * @param array $exporters Existing exporters. + * @return array + */ + public function register_exporters( array $exporters ): array { + $exporters[] = $this->exporter; + return $exporters; + } + + /** + * Register PayPal importers. + * + * @param array $importers Existing importers. + * @return array + */ + public function register_importers( array $importers ): array { + $importers[] = $this->importer; + return $importers; + } +} diff --git a/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsExporter.php b/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsExporter.php new file mode 100644 index 0000000000..3c9f6f513b --- /dev/null +++ b/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsExporter.php @@ -0,0 +1,115 @@ + + */ + private const PAYPAL_OPTIONS = array( + // Core PPCP data settings. + 'woocommerce-ppcp-data-common', + 'woocommerce-ppcp-data-onboarding', + 'woocommerce-ppcp-data-payment', + 'woocommerce-ppcp-data-settings', + 'woocommerce-ppcp-data-styling', + // Main PPCP configuration. + 'woocommerce-ppcp-settings', + 'woocommerce-ppcp-version', + 'woocommerce-ppcp-is-new-merchant', + // Admin and migration flags. + 'woocommerce_ppcp-admin-notices', + 'woocommerce_ppcp-is_pay_later_settings_migrated', + 'woocommerce_ppcp-is_smart_button_settings_migrated', + 'woocommerce_ppcp-settings-should-use-old-ui', + // Individual payment method settings (non-gateway). + 'woocommerce_venmo_settings', + 'woocommerce_pay-later_settings', + ); + + /** + * Export PayPal settings. + * + * @return Step + */ + public function export(): Step { + $paypal_options = array(); + + foreach ( self::PAYPAL_OPTIONS as $option_name ) { + $value = get_option( $option_name, self::OPTION_NOT_FOUND ); + if ( self::OPTION_NOT_FOUND !== $value ) { + $paypal_options[ $option_name ] = $value; + } + } + + return new SetSiteOptions( $paypal_options ); + } + + /** + * Get step name. + * + * @return string + */ + public function get_step_name(): string { + return SetSiteOptions::get_step_name(); + } + + /** + * Get alias for this exporter. + * + * @return string + */ + public function get_alias(): string { + return 'paypalSettings'; + } + + /** + * Return label used in the frontend. + * + * @return string + */ + public function get_label(): string { + return __( 'PayPal Settings', 'woocommerce-paypal-payments' ); + } + + /** + * Return the description used in the frontend. + * + * @return string + */ + public function get_description(): string { + return __( 'Exports PayPal Payments settings and configuration options.', 'woocommerce-paypal-payments' ); + } + + /** + * Check if user has capability to export PayPal settings. + * + * @return bool + */ + public function check_step_capabilities(): bool { + return current_user_can( 'manage_woocommerce' ) && current_user_can( 'manage_options' ); + } +} diff --git a/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsImporter.php b/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsImporter.php new file mode 100644 index 0000000000..cc3035d4f5 --- /dev/null +++ b/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsImporter.php @@ -0,0 +1,210 @@ + + */ + private const PAYPAL_PATTERNS = array( + 'ppcp', + 'paypal', + 'venmo', + 'pay-later', + ); + + /** + * Process PayPal settings import. + * + * @param object $schema Schema object. + * @return StepProcessorResult + */ + public function process( $schema ): StepProcessorResult { + $result = StepProcessorResult::success( SetSiteOptions::get_step_name() ); + + if ( ! isset( $schema->options ) || ! is_object( $schema->options ) ) { + $result->add_error( 'Invalid PayPal options data' ); + return $result; + } + + // Validate that the object can be meaningfully converted to array. + if ( ! $this->is_valid_options_object( $schema->options ) ) { + $result->add_error( 'PayPal options data is not in the expected format' ); + return $result; + } + + $options = (array) $schema->options; + $imported_count = 0; + + foreach ( $options as $option_name => $option_value ) { + // Validate option name first (before using it in any operations). + if ( ! $this->is_valid_option_name( $option_name ) ) { + $result->add_error( 'Invalid option name provided' ); + continue; + } + + // Validate option value early. + if ( ! $this->is_valid_option_value( $option_value ) ) { + $sanitized_name = sanitize_text_field( (string) $option_name ); + $result->add_warn( "Skipped option with invalid value: {$sanitized_name}" ); + continue; + } + + // Check if this is a PayPal-related option. + if ( ! $this->is_paypal_option( $option_name ) ) { + $sanitized_name = sanitize_text_field( $option_name ); + $result->add_warn( "Skipped non-PayPal option: {$sanitized_name}" ); + continue; + } + + // Attempt to update the option with proper error handling. + if ( $this->update_option_safely( $option_name, $option_value ) ) { + $imported_count++; + } else { + $sanitized_name = sanitize_text_field( $option_name ); + $result->add_error( "Failed to update option: {$sanitized_name}" ); + } + } + + $result->add_info( "Successfully imported {$imported_count} PayPal options" ); + return $result; + } + + /** + * Get step class. + * + * @return string + */ + public function get_step_class(): string { + return SetSiteOptions::class; + } + + /** + * Check capabilities. + * + * @param object $schema Schema object. + * @return bool + */ + public function check_step_capabilities( $schema ): bool { + return current_user_can( 'manage_woocommerce' ) && current_user_can( 'manage_options' ); + } + + /** + * Validate that the options object can be meaningfully converted to array. + * + * @param object $options The options object. + * @return bool + */ + private function is_valid_options_object( object $options ): bool { + // Check if it's a stdClass or similar object that can be cast to array. + return $options instanceof \stdClass || method_exists( $options, '__toString' ) || is_iterable( $options ); + } + + /** + * Validate option name. + * + * @param mixed $option_name The option name to validate. + * @return bool + */ + private function is_valid_option_name( $option_name ): bool { + return is_string( $option_name ) && ! empty( trim( $option_name ) ) && strlen( $option_name ) <= 191; + } + + /** + * Validate option value for WordPress options. + * + * @param mixed $option_value The option value to validate. + * @return bool + */ + private function is_valid_option_value( $option_value ): bool { + // WordPress options should be scalar, array, or object (but not resources or closures). + if ( is_resource( $option_value ) || is_callable( $option_value ) ) { + return false; + } + + if ( null === $option_value ) { + return false; + } + + return true; + } + + /** + * Check if option is PayPal related using efficient pattern matching. + * + * @param string $option_name Option name. + * @return bool + */ + private function is_paypal_option( string $option_name ): bool { + $lowercase_name = strtolower( $option_name ); + + foreach ( self::PAYPAL_PATTERNS as $pattern ) { + if ( str_contains( $lowercase_name, $pattern ) ) { + return true; + } + } + + return false; + } + + /** + * Safely update an option with proper comparison for existing values. + * + * @param string $option_name Option name. + * @param mixed $option_value Option value. + * @return bool + */ + private function update_option_safely( string $option_name, $option_value ): bool { + // Get the current value with a sentinel to distinguish between false and non-existent. + $current_value = get_option( $option_name, self::OPTION_NOT_FOUND ); + + // If the values are already equal, consider it a success. + if ( self::OPTION_NOT_FOUND !== $current_value && $this->values_are_equal( $current_value, $option_value ) ) { + return true; + } + if(is_object($option_value)) { + $option_value = get_object_vars($option_value); + } + + return update_option( $option_name, $option_value ); + } + + /** + * Compare two values for equality, handling complex data types properly. + * + * @param mixed $value1 First value. + * @param mixed $value2 Second value. + * @return bool + */ + private function values_are_equal( $value1, $value2 ): bool { + // For arrays and objects, serialize for comparison to handle deep equality. + if ( ( is_array( $value1 ) || is_object( $value1 ) ) && ( is_array( $value2 ) || is_object( $value2 ) ) ) { + return serialize( $value1 ) === serialize( $value2 ); + } + + // For scalar values, use strict comparison. + return $value1 === $value2; + } +} diff --git a/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsStep.php b/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsStep.php new file mode 100644 index 0000000000..261d965046 --- /dev/null +++ b/modules/ppcp-compat/src/WooCommerceBlueprint/PayPalSettingsStep.php @@ -0,0 +1,78 @@ + + */ + private array $paypal_options; + + /** + * Constructor. + * + * @param array $paypal_options PayPal options data. + */ + public function __construct( array $paypal_options ) { + $this->paypal_options = $paypal_options; + } + + /** + * Get step name. + * + * @return string + */ + public static function get_step_name(): string { + return 'setPayPalOptions'; + } + + /** + * Get schema. + * + * @param int $version Schema version. + * @return array + */ + public static function get_schema( int $version = 1 ): array { + return array( + 'type' => 'object', + 'properties' => array( + 'step' => array( + 'type' => 'string', + 'enum' => array( static::get_step_name() ), + ), + 'options' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + ), + 'required' => array( 'step', 'options' ), + ); + } + + /** + * Prepare JSON array. + * + * @return array + */ + public function prepare_json_array(): array { + return array( + 'step' => static::get_step_name(), + 'options' => $this->paypal_options, + ); + } +} diff --git a/psalm.xml.dist b/psalm.xml.dist index 470d5ceb9b..63a717ec50 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -97,7 +97,12 @@ - + + + + + + @@ -126,6 +131,18 @@ + + + + + + + + + + + +