Files
krolewskie-miody.pl/wp-content/plugins/woocommerce-payments/includes/class-wc-payments-pm-promotions-service.php
2026-04-28 15:13:50 +02:00

1033 lines
31 KiB
PHP

<?php
/**
* Class WC_Payments_PM_Promotions_Service
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
use WCPay\Constants\Track_Events;
use WCPay\Core\Server\Request;
use WCPay\Logger;
/**
* Class handling WooPayments payment method promotions related business logic.
*/
class WC_Payments_PM_Promotions_Service {
/**
* Transient key for caching promotions.
*
* @var string
*/
const PROMOTIONS_CACHE_KEY = 'wcpay_pm_promotions';
/**
* Option key for promotion dismissals.
* Stores array of [id => 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' ] ) ) );
}
}