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.
+
+
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+