timestamp]. * * @var string */ const PROMOTION_DISMISSALS_OPTION = '_wcpay_pm_promotion_dismissals'; /** * The memoized raw promotions to avoid fetching multiple times during a request. * * @var array|null */ private $promotions_memo = null; /** * The memoized visible promotions (filtered and normalized) for the current request. * False means not yet computed, null means computed with no results, array means has results. * * @var array|null|false */ private $visible_promotions_memo = false; /** * WC_Payment_Gateway_WCPay instance. * * @var WC_Payment_Gateway_WCPay|null */ private $gateway; /** * WC_Payments_Account instance. * * @var WC_Payments_Account|null */ private $account; /** * Class constructor. * * @param WC_Payment_Gateway_WCPay|null $gateway Optional gateway instance. * @param WC_Payments_Account|null $account Optional account instance. */ public function __construct( ?WC_Payment_Gateway_WCPay $gateway = null, ?WC_Payments_Account $account = null ) { $this->gateway = $gateway; $this->account = $account; } /** * Initialise class hooks. * * @return void */ public function init_hooks() { // Hooks can be added here if needed in the future. } /** * Clear the promotions cache. * * @return void */ public function clear_cache(): void { delete_transient( self::PROMOTIONS_CACHE_KEY ); $this->reset_memo(); } /** * Reset the memoized promotions. * * This is useful for testing purposes. * * @return void */ public function reset_memo(): void { $this->promotions_memo = null; $this->visible_promotions_memo = false; } /** * Get promotions that should be visible to the user. * * @return array|null The promotions or null if there is no eligible promotion. */ public function get_visible_promotions(): ?array { // Promotions are only visible to users who can manage WooCommerce (aka act on the promotions). if ( ! current_user_can( 'manage_woocommerce' ) ) { return null; } // Return memoized result if available (false means not yet computed). if ( false !== $this->visible_promotions_memo ) { return $this->visible_promotions_memo; } $promotions = $this->get_promotions(); // Validate each promotion's structure. $promotions = array_filter( $promotions, function ( $promotion ) { return $this->validate_promotion( $promotion ); } ); // Filter promotions by dismissal status, PM validity, enabled status, and promo_id uniqueness. $promotions = $this->filter_promotions( $promotions ); // Normalize the promotions (apply fallbacks, derive fields). $promotions = $this->normalize_promotions( $promotions ); // Return early if there are no promotions left. if ( empty( $promotions ) ) { $this->visible_promotions_memo = null; return null; } $this->visible_promotions_memo = array_values( $promotions ); return $this->visible_promotions_memo; } /** * Fetches and caches eligible promotions from the WooPayments API. * * @return array List of eligible promotions. */ private function get_promotions(): array { // Check memoized data first. if ( null !== $this->promotions_memo ) { return $this->promotions_memo; } // Try to use the cached data. $cache = get_transient( self::PROMOTIONS_CACHE_KEY ); // If the cached data is not expired, and it's a WP_Error, // it means there was an API error previously, and we should not retry just yet. if ( is_wp_error( $cache ) ) { // Initialize the in-memory cache and return it. $this->promotions_memo = []; return $this->promotions_memo; } // Gather the store context data. $store_context = [ // All the PM promotions dismissals. 'dismissals' => $this->get_promotion_dismissals(), // Store locale, e.g. `en_US`. 'locale' => get_locale(), ]; // Fingerprint the store context through a hash of certain entries. $store_context_hash = $this->generate_context_hash( $store_context ); // Use the transient cached data if it exists, it is not expired, // and the store context hasn't changed since we last requested from the WooPayments API (based on context hash). if ( false !== $cache && ! empty( $cache['context_hash'] ) && is_string( $cache['context_hash'] ) && hash_equals( $store_context_hash, $cache['context_hash'] ) ) { // We have a store context hash, and it matches with the current context one. // We can use the cached data. $this->promotions_memo = $cache['promotions'] ?? []; return $this->promotions_memo; } // By this point, we have an expired transient or the store context has changed. // Query for promotions by calling the WooPayments API. $wcpay_request = Request\Get_PM_Promotions::create(); $wcpay_request->set_store_context_params( $store_context ); $response = $wcpay_request->handle_rest_request(); // Return early if there is an error, waiting 6 hours before the next attempt. if ( is_wp_error( $response ) ) { // Store a trimmed down, lightweight error. /** * Type hint for static analysis. * * @var WP_Error $response */ $error = new \WP_Error( $response->get_error_code(), $response->get_error_message(), wp_remote_retrieve_response_code( $response ) ); // Store the error in the transient so we know this is due to an API error. set_transient( self::PROMOTIONS_CACHE_KEY, $error, HOUR_IN_SECONDS * 6 ); // Initialize the in-memory cache and return it. $this->promotions_memo = []; return $this->promotions_memo; } $cache_for = wp_remote_retrieve_header( $response, 'cache-for' ); // Initialize the in-memory cache. $this->promotions_memo = []; if ( 200 === wp_remote_retrieve_response_code( $response ) ) { // Decode the results, falling back to an empty array. $results = json_decode( wp_remote_retrieve_body( $response ), true ) ?? []; $this->promotions_memo = $results; } // Skip transient cache if `cache-for` header equals zero. if ( '0' === $cache_for ) { // If we have a transient cache that is not expired, delete it so there are no leftovers. if ( false !== $cache ) { delete_transient( self::PROMOTIONS_CACHE_KEY ); } return $this->promotions_memo; } // Store promotions in the transient cache (together with the context hash) for the given number of seconds // or 1 day in seconds. Also attach a timestamp to the transient data so we know when we last fetched. set_transient( self::PROMOTIONS_CACHE_KEY, [ 'promotions' => $this->promotions_memo, 'context_hash' => $store_context_hash, 'timestamp' => time(), ], ! empty( $cache_for ) ? (int) $cache_for : DAY_IN_SECONDS ); return $this->promotions_memo; } /** * Activate a promotion. * * This will: * 1. Send a request to the server to apply the promotion discount. * 2. Enable the payment method for checkout. * Activating a promotion implies acceptance of terms and conditions for the promotion. * * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight'). * * @return bool True on success, false on failure. */ public function activate_promotion( string $id ): bool { // Find the promotion to get the payment method. $promotion = $this->find_promotion_by_id( $id ); if ( null === $promotion ) { return false; } $payment_method_id = $promotion['payment_method'] ?? ''; if ( empty( $payment_method_id ) ) { return false; } // Send request to server to apply the promotion discount. // The server should also handle capability requesting if it is not already requested. // This way we can keep things in sync and avoid applying discounts without having the capability requested. $wcpay_request = Request\Activate_PM_Promotion::create( $id ); $wcpay_request->assign_hook( 'wcpay_activate_pm_promotion_request' ); $response = $wcpay_request->handle_rest_request(); if ( is_wp_error( $response ) ) { $error_message = sprintf( 'Server activation request failed [%s]: %s', $response->get_error_code(), $response->get_error_message() ); return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, $error_message ); } // Mark the promotion as dismissed so it won't be shown again. // Do it before the payment method gateway enabling in case that fails. $this->mark_promotion_dismissed( $id ); // Enable the payment method for checkout. if ( ! $this->enable_payment_method_gateway( $payment_method_id, $promotion ) ) { return false; } // Clear the promotions cache to ensure fresh data on next fetch. $this->clear_cache(); // Clear the account cache. if ( null !== $this->account ) { $this->account->clear_cache(); } // Track successful activation. $this->tracks_event( Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATED, [ 'payment_method_id' => $payment_method_id, 'promo_id' => $promotion['promo_id'] ?? null, 'promo_instance_id' => $id, ] ); return true; } /** * Find a promotion by its ID. * * @param string $id The promotion ID (e.g., 'klarna-2026-promo__spotlight'). * * @return array|null The promotion data or null if not found. */ private function find_promotion_by_id( string $id ): ?array { $promotions = $this->get_visible_promotions(); if ( null === $promotions ) { return null; } foreach ( $promotions as $promotion ) { if ( isset( $promotion['id'] ) && $promotion['id'] === $id ) { return $promotion; } } return null; } /** * Find a promotion for a payment method. * * We will return the first promotion found for the payment method. * * @param string $payment_method_id The payment method ID (e.g., 'klarna'). * * @return array|null The promotion data or null if not found. */ private function find_promotion_by_payment_method( string $payment_method_id ): ?array { $promotions = $this->get_visible_promotions(); if ( null === $promotions ) { return null; } foreach ( $promotions as $promotion ) { if ( isset( $promotion['payment_method'] ) && $promotion['payment_method'] === $payment_method_id ) { return $promotion; } } return null; } /** * Enable a payment method gateway. * * @param string $payment_method_id The payment method ID (e.g., 'klarna'). * @param array $promotion The promotion data associated with the payment method. * * @return bool True on success, false on failure. */ private function enable_payment_method_gateway( string $payment_method_id, array $promotion ): bool { $gateway = WC_Payments::get_payment_gateway_by_id( $payment_method_id ); if ( ! $gateway ) { $this->log_gateway_error( $payment_method_id, 'payment gateway instance not available' ); return false; } // Attempt to enable the gateway with exception handling. try { $gateway->enable(); } catch ( \Exception $e ) { $this->log_gateway_error( $payment_method_id, $e->getMessage() ); return false; } // Verify the gateway was actually enabled. if ( 'yes' !== $gateway->get_option( 'enabled' ) ) { $this->log_gateway_error( $payment_method_id, 'gateway enable() did not persist enabled state' ); return false; } $pm_to_capability_key_map = $gateway->get_payment_method_capability_key_map(); $this->tracks_event( Track_Events::PAYMENT_METHOD_ENABLED, [ 'payment_method_id' => $payment_method_id, 'capability_id' => $pm_to_capability_key_map[ $payment_method_id ] ?? null, 'promo_id' => $promotion['promo_id'] ?? null, ] ); // Synchronize enabled payment method IDs across all gateways. $this->sync_enabled_payment_method_across_gateways( $payment_method_id ); return true; } /** * Log a gateway error. * * @param string $payment_method_id The payment method ID. * @param string $error_message The error message. */ private function log_gateway_error( string $payment_method_id, string $error_message ): void { /* translators: 1: Payment method ID, 2: Error message */ WC_Payments_Utils::log_to_wc( sprintf( 'Failed to enable payment method %1$s: %2$s', $payment_method_id, $error_message ), 'warning' ); } /** * Synchronize enabled payment method ID across all gateways. * * @param string $payment_method_id The payment method ID to sync. */ private function sync_enabled_payment_method_across_gateways( string $payment_method_id ): void { $gateway_map = WC_Payments::get_payment_gateway_map(); if ( empty( $gateway_map ) ) { return; } foreach ( $gateway_map as $payment_gateway ) { $enabled_pm_ids = $payment_gateway->get_upe_enabled_payment_method_ids(); // Skip if already present or not a valid array. if ( ! is_array( $enabled_pm_ids ) || in_array( $payment_method_id, $enabled_pm_ids, true ) ) { continue; } $enabled_pm_ids[] = $payment_method_id; $result = $payment_gateway->update_option( 'upe_enabled_payment_method_ids', $enabled_pm_ids ); if ( false === $result ) { WC_Payments_Utils::log_to_wc( sprintf( 'Failed to sync payment method %s to gateway %s', $payment_method_id, get_class( $payment_gateway ) ), 'warning' ); } } } /** * Activate any visible promotions for a payment method being enabled via settings. * * This method should be called BEFORE the payment method is enabled for checkout, * as visible promotions are filtered out for already-enabled payment methods. * * Handles its own exception catching, logging, and tracking internally. * * @param string $payment_method_id The payment method ID (e.g., 'klarna'). * @param bool $should_enable Whether to enable the payment method for checkout. * * @return bool True if a promotion was activated, false otherwise. */ public function maybe_activate_promotion_for_payment_method( string $payment_method_id, bool $should_enable = false ): bool { $promotion = $this->find_promotion_by_payment_method( $payment_method_id ); if ( null === $promotion ) { return false; } // Send request to server to apply the promotion discount. // The server should also handle capability requesting if it is not already requested. $wcpay_request = Request\Activate_PM_Promotion::create( $promotion['id'] ); $wcpay_request->assign_hook( 'wcpay_activate_pm_promotion_request' ); $response = $wcpay_request->handle_rest_request(); if ( is_wp_error( $response ) ) { $error_message = sprintf( 'Server activation request failed [%s]: %s', $response->get_error_code(), $response->get_error_message() ); return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, $error_message ); } // Enable the payment method for checkout if requested. if ( $should_enable && ! $this->enable_payment_method_gateway( $payment_method_id, $promotion ) ) { return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, 'Failed to enable payment method gateway' ); } // Clear the promotions cache to ensure fresh data on next fetch. $this->clear_cache(); // Clear the account cache. if ( null !== $this->account ) { $this->account->clear_cache(); } // Track successful activation. $this->tracks_event( Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATED, [ 'payment_method_id' => $payment_method_id, 'promo_id' => $promotion['promo_id'] ?? null, // The `unique_promo_id` is excluded intentionally as it's not a reliable without a specific promo type. ] ); return true; } /** * Handle promotion activation failure by logging and tracking. * * @param string $payment_method_id The payment method ID. * @param array $promotion The promotion data. * @param string $error_message The error message. * * @return bool Always returns false. */ private function handle_promotion_activation_failure( string $payment_method_id, array $promotion, string $error_message ): bool { /* translators: 1: Payment method ID, 2: Error message */ WC_Payments_Utils::log_to_wc( sprintf( 'Failed to activate promotion for payment method %1$s: %2$s', $payment_method_id, $error_message ) ); // Track the failure. $this->tracks_event( Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATION_FAILED, [ 'payment_method_id' => $payment_method_id, 'promo_id' => $promotion['promo_id'] ?? null, ] ); return false; } /** * Dismiss a promotion. * * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight'). * * @return bool True if dismissed, false if already dismissed or error. */ public function dismiss_promotion( string $id ): bool { // Cannot dismiss a non-existing promotion. $promotion = $this->find_promotion_by_id( $id ); if ( null === $promotion ) { return false; } if ( ! $this->mark_promotion_dismissed( $id ) ) { return false; } // Track dismissal event. $this->tracks_event( Track_Events::PAYMENT_METHOD_PROMOTION_DISMISSED, [ 'payment_method_id' => $promotion['payment_method'] ?? null, 'promo_id' => $promotion['promo_id'] ?? null, 'promo_instance_id' => $id, ] ); // Reset memo to ensure fresh data on next access. // The context hash change will also invalidate the transient cache. $this->reset_memo(); return true; } /** * Mark a promotion as dismissed in local state. * * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight'). * * @return bool True if dismissed, false if already dismissed. */ private function mark_promotion_dismissed( string $id ): bool { // Don't dismiss if already dismissed. if ( $this->is_promotion_dismissed( $id ) ) { return false; } $dismissals = $this->get_promotion_dismissals(); $dismissals[ $id ] = time(); return update_option( self::PROMOTION_DISMISSALS_OPTION, $dismissals, false ); } /** * Get all promotion dismissals. * * @return array Associative array of [id => timestamp]. */ public function get_promotion_dismissals(): array { return get_option( self::PROMOTION_DISMISSALS_OPTION, [] ); } /** * Check if a promotion has been dismissed. * * Being dismissed means having an entry in the dismissals option with a timestamp into the past. * * @param string $id The promotion unique identifier. * * @return bool True if dismissed, false otherwise. */ public function is_promotion_dismissed( string $id ): bool { $dismissals = $this->get_promotion_dismissals(); return isset( $dismissals[ $id ] ) && is_int( $dismissals[ $id ] ) && $dismissals[ $id ] > 0 && $dismissals[ $id ] <= time(); } /** * Check whether the promotion data is valid. * Validates required fields based on promotion type. * * @param mixed $promotion_data The promotion data. * * @return bool Whether the promotion data is valid. */ private function validate_promotion( $promotion_data ): bool { if ( ! is_array( $promotion_data ) || empty( $promotion_data ) ) { return false; } // Required fields for all promotions. $required_fields = [ 'id', 'promo_id', 'payment_method', 'type', 'title', 'description', 'tc_url' ]; foreach ( $required_fields as $field ) { if ( ! isset( $promotion_data[ $field ] ) || ! is_string( $promotion_data[ $field ] ) ) { return false; } } // Validate type is supported. $valid_types = [ 'spotlight', 'badge' ]; if ( ! in_array( $promotion_data['type'], $valid_types, true ) ) { return false; } return true; } /** * Generate a hash from the store context data. * * @param array $context The store context data. * * @return string The context hash. */ private function generate_context_hash( array $context ): string { // Include only certain entries in the context hash. // We need only discrete, user-interaction dependent data. // Do not include information that changes automatically (e.g., time since activation, etc.). return md5( wp_json_encode( [ 'dismissals' => $context['dismissals'] ?? [], 'locale' => $context['locale'] ?? '', ] ) ); } /** * Get list of valid payment method IDs from the gateway. * * @return array List of valid payment method IDs. */ private function get_valid_payment_method_ids(): array { if ( null === $this->gateway ) { $this->gateway = WC_Payments::get_gateway(); } if ( null === $this->gateway || ! is_callable( [ $this->gateway, 'get_upe_available_payment_methods' ] ) ) { return []; } $result = $this->gateway->get_upe_available_payment_methods(); return is_array( $result ) ? $result : []; } /** * Get list of enabled payment method IDs. * * @return array List of enabled payment method IDs. */ private function get_enabled_payment_method_ids(): array { if ( null === $this->gateway ) { $this->gateway = WC_Payments::get_gateway(); } if ( null === $this->gateway || ! is_callable( [ $this->gateway, 'get_upe_enabled_payment_method_ids' ] ) ) { return []; } $result = $this->gateway->get_upe_enabled_payment_method_ids(); return is_array( $result ) ? $result : []; } /** * Get the account fees. * * @return array Account fees indexed by payment method ID. */ private function get_account_fees(): array { if ( null === $this->account ) { $this->account = WC_Payments::get_account_service(); } if ( null === $this->account || ! is_callable( [ $this->account, 'get_fees' ] ) ) { return []; } $result = $this->account->get_fees(); return is_array( $result ) ? $result : []; } /** * Check if a payment method has an active discount. * * @param string $payment_method_id The payment method ID. * @param array|null $account_fees Optional. Pre-fetched account fees. If null, will be fetched. * * @return bool True if the payment method has an active discount. */ private function payment_method_has_active_discount( string $payment_method_id, ?array $account_fees = null ): bool { if ( null === $account_fees ) { $account_fees = $this->get_account_fees(); } if ( empty( $account_fees[ $payment_method_id ] ) ) { return false; } $pm_fees = $account_fees[ $payment_method_id ]; // Verify discount is a non-empty array. if ( ! isset( $pm_fees['discount'] ) || ! is_array( $pm_fees['discount'] ) || empty( $pm_fees['discount'] ) ) { return false; } // Get first discount entry regardless of array key structure. $first_discount = reset( $pm_fees['discount'] ); if ( is_array( $first_discount ) && array_key_exists( 'discount', $first_discount ) && ! empty( $first_discount['discount'] ) ) { return true; } return false; } /** * Filter promotions by dismissal status, payment method validity, enabled status, discount status, * and promo_id uniqueness per payment method. * * @param array $promotions Array of promotions. * * @return array Filtered promotions. */ private function filter_promotions( array $promotions ): array { // Pre-fetch all data needed for filtering to avoid N+1 queries. $enabled_pms = $this->get_enabled_payment_method_ids(); $valid_pms = $this->get_valid_payment_method_ids(); $account_fees = $this->get_account_fees(); $seen_promo_ids = []; // Track first promo_id per PM. $filtered = []; foreach ( $promotions as $promotion ) { $id = $promotion['id'] ?? ''; $pm_id = $promotion['payment_method'] ?? ''; $promo_id = $promotion['promo_id'] ?? ''; // Filters ordered by performance cost (cheapest first, all use pre-fetched data). // 1. Skip promotions for already enabled payment methods. if ( in_array( $pm_id, $enabled_pms, true ) ) { continue; } // 2. Skip invalid payment methods. if ( ! in_array( $pm_id, $valid_pms, true ) ) { continue; } // 3. Skip dismissed promotions (WP cached option). if ( $this->is_promotion_dismissed( $id ) ) { continue; } // 4. Skip promotions for payment methods that already have an active discount. if ( $this->payment_method_has_active_discount( $pm_id, $account_fees ) ) { continue; } // 5. Track first promo_id per PM - keep all surfaces for that promo_id. // Must be last as it has side effects (tracks seen promo_ids). if ( ! isset( $seen_promo_ids[ $pm_id ] ) ) { $seen_promo_ids[ $pm_id ] = $promo_id; } // Skip if this is a different promo_id for an already-seen PM. if ( $seen_promo_ids[ $pm_id ] !== $promo_id ) { continue; } $filtered[] = $promotion; } return $filtered; } /** * Normalize promotions by applying fallbacks and deriving fields. * * @param array $promotions Array of promotions. * * @return array Normalized promotions. */ private function normalize_promotions( array $promotions ): array { $normalized = []; foreach ( $promotions as $promotion ) { // These fields are validated as required before normalization. $pm_id = $promotion['payment_method']; $tc_url = $promotion['tc_url']; // Add derived payment_method_title if not provided. if ( empty( $promotion['payment_method_title'] ) ) { $promotion['payment_method_title'] = $this->get_payment_method_title( $pm_id ); } // Apply fallback for cta_label using the final payment_method_title. if ( empty( $promotion['cta_label'] ) ) { /* translators: %s is the payment method title, e.g., "Klarna" */ $promotion['cta_label'] = sprintf( __( 'Enable %s', 'woocommerce-payments' ), $promotion['payment_method_title'] ); } // Apply type-specific sanitization BEFORE tc_label fallback. // This ensures we check against the sanitized description (which might lose the link). $promotion = $this->sanitize_promotion( $promotion ); // Apply fallback for tc_label only if tc_url is not already in the sanitized description. // If tc_url is in the description, leaving tc_label empty signals frontend to not add a link. if ( empty( $promotion['tc_label'] ) ) { if ( strpos( $promotion['description'], $tc_url ) === false ) { $promotion['tc_label'] = __( 'See terms', 'woocommerce-payments' ); } else { // Explicitly set to empty string when skipping fallback. $promotion['tc_label'] = ''; } } $normalized[] = $promotion; } return $normalized; } /** * Sanitize a promotion's fields based on its type. * * @param array $promotion The promotion data. * * @return array Sanitized promotion. */ private function sanitize_promotion( array $promotion ): array { $type = $promotion['type'] ?? ''; // Sanitize identifier fields strictly with sanitize_key. $key_fields = [ 'id', 'promo_id', 'payment_method', 'type' ]; foreach ( $key_fields as $field ) { if ( isset( $promotion[ $field ] ) ) { $promotion[ $field ] = sanitize_key( $promotion[ $field ] ); } } // Sanitize text fields (no HTML allowed). $text_fields = [ 'payment_method_title', 'title', 'cta_label', 'tc_label', 'badge_text' ]; foreach ( $text_fields as $field ) { if ( isset( $promotion[ $field ] ) ) { $promotion[ $field ] = sanitize_text_field( $promotion[ $field ] ); } } // Normalize badge_type: ensure it's a valid type, defaulting to 'success'. $valid_badge_types = [ 'primary', 'success', 'light', 'warning', 'alert' ]; $promotion['badge_type'] = isset( $promotion['badge_type'] ) && in_array( $promotion['badge_type'], $valid_badge_types, true ) ? $promotion['badge_type'] : 'success'; // Sanitize URL fields. if ( isset( $promotion['tc_url'] ) ) { $promotion['tc_url'] = esc_url_raw( $promotion['tc_url'] ); } if ( isset( $promotion['image'] ) ) { $promotion['image'] = esc_url_raw( $promotion['image'] ); } // Sanitize description based on type. if ( isset( $promotion['description'] ) ) { $promotion['description'] = $this->sanitize_description( $promotion['description'], $type ); } // Sanitize footnote (same as spotlight description - allows light HTML). if ( isset( $promotion['footnote'] ) ) { $promotion['footnote'] = $this->sanitize_description( $promotion['footnote'], 'spotlight' ); } return $promotion; } /** * Sanitize description field based on promotion type. * * Spotlight type allows light HTML: paragraphs, bold, italic, links, breaks. * Badge type only allows links. * * @param string $description The description to sanitize. * @param string $type The promotion type. * * @return string Sanitized description. */ private function sanitize_description( string $description, string $type ): string { if ( 'spotlight' === $type ) { // Allow light HTML for spotlight: paragraphs, bold, italic, links, breaks. $allowed_html = [ 'p' => [], 'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], 'br' => [], ]; return wp_kses( $description, $allowed_html ); } if ( 'badge' === $type ) { // Badge type: only allow links. $allowed_html = [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ]; return wp_kses( $description, $allowed_html ); } // Default: strip all HTML. return sanitize_text_field( $description ); } /** * Get the human-readable title for a payment method. * * @param string $payment_method_id The payment method ID. * * @return string The payment method title or a fallback. */ private function get_payment_method_title( string $payment_method_id ): string { $payment_method = WC_Payments::get_payment_method_by_id( $payment_method_id ); if ( false !== $payment_method && method_exists( $payment_method, 'get_title' ) ) { return $payment_method->get_title(); } // Fallback to formatted ID (e.g., 'klarna' -> 'Klarna'). return ucfirst( str_replace( '_', ' ', $payment_method_id ) ); } /** * Send a Tracks event. * * By default Woo adds `url`, `blog_lang`, `blog_id`, `store_id`, `products_count`, and `wc_version` * properties to every event. * * @todo This is a duplicate of the one in the WC_Payments_Account, WC_REST_Payments_Settings_Controller, and WC_Payments_Onboarding_Service classes. * * @param string $name The event name. * @param array $properties Optional. The event custom properties. * * @return void */ private function tracks_event( string $name, array $properties = [] ) { if ( ! function_exists( 'wc_admin_record_tracks_event' ) ) { return; } // Add default properties to every event. $account_service = WC_Payments::get_account_service(); $tracking_info = $account_service ? $account_service->get_tracking_info() : []; $properties = array_merge( $properties, [ 'is_test_mode' => WC_Payments::mode()->is_test(), 'jetpack_connected' => true, // Any PM promotions require a Jetpack connection. 'wcpay_version' => WCPAY_VERSION_NUMBER, 'woo_country_code' => WC()->countries->get_base_country(), ], $tracking_info ?? [] ); wc_admin_record_tracks_event( $name, $properties ); Logger::info( 'Tracks event: ' . $name . ' with data: ' . wp_json_encode( WC_Payments_Utils::redact_array( $properties, [ 'woo_country_code' ] ) ) ); } }