WooPayments.php
385 lines
| 1 | <?php |
| 2 | declare( strict_types=1 ); |
| 3 | |
| 4 | namespace Automattic\WooCommerce\Internal\Admin\Suggestions\Incentives; |
| 5 | |
| 6 | defined( 'ABSPATH' ) || exit; |
| 7 | |
| 8 | use Automattic\WooCommerce\Admin\WCAdminHelper; |
| 9 | use Automattic\WooCommerce\Enums\OrderInternalStatus; |
| 10 | use WC_Abstract_Order; |
| 11 | |
| 12 | /** |
| 13 | * WooPayments incentives provider class. |
| 14 | * |
| 15 | * @internal |
| 16 | */ |
| 17 | class WooPayments extends Incentive { |
| 18 | /** |
| 19 | * The transient name for incentives cache. |
| 20 | * |
| 21 | * @var string |
| 22 | */ |
| 23 | protected string $cache_transient_name; |
| 24 | |
| 25 | /** |
| 26 | * The transient name used to store the value for if store has orders. |
| 27 | * |
| 28 | * @var string |
| 29 | */ |
| 30 | protected string $store_has_orders_transient_name; |
| 31 | |
| 32 | /** |
| 33 | * The option name used to store the value for if store had WooPayments in use. |
| 34 | * |
| 35 | * @var string |
| 36 | */ |
| 37 | protected string $store_had_woopayments_option_name; |
| 38 | |
| 39 | /** |
| 40 | * The memoized incentives to avoid fetching multiple times during a request. |
| 41 | * |
| 42 | * @var array|null |
| 43 | */ |
| 44 | private ?array $incentives_memo = null; |
| 45 | |
| 46 | /** |
| 47 | * Constructor. |
| 48 | * |
| 49 | * @param string $suggestion_id The suggestion ID. |
| 50 | */ |
| 51 | public function __construct( string $suggestion_id ) { |
| 52 | parent::__construct( $suggestion_id ); |
| 53 | |
| 54 | $this->cache_transient_name = self::PREFIX . $suggestion_id . '_cache'; |
| 55 | $this->store_has_orders_transient_name = self::PREFIX . $suggestion_id . '_store_has_orders'; |
| 56 | $this->store_had_woopayments_option_name = self::PREFIX . $suggestion_id . '_store_had_woopayments'; |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * Check if an incentive should be visible. |
| 61 | * |
| 62 | * @param string $id The incentive ID to check for visibility. |
| 63 | * @param string $country_code The business location country code to get incentives for. |
| 64 | * @param bool $skip_extension_active_check Whether to skip the check for the extension plugin being active. |
| 65 | * |
| 66 | * @return boolean Whether the incentive should be visible. |
| 67 | */ |
| 68 | public function is_visible( string $id, string $country_code, bool $skip_extension_active_check = false ): bool { |
| 69 | // Always skip the extension active check since we will check below. |
| 70 | if ( false === parent::is_visible( $id, $country_code, true ) ) { |
| 71 | return false; |
| 72 | } |
| 73 | |
| 74 | // Instead of just extension active, we check if WooPayments is active and has an account. |
| 75 | if ( ! $skip_extension_active_check && $this->is_extension_active() && $this->has_wcpay_account_data() ) { |
| 76 | return false; |
| 77 | } |
| 78 | |
| 79 | return true; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Clear the incentives cache. |
| 84 | */ |
| 85 | public function clear_cache() { |
| 86 | delete_transient( $this->cache_transient_name ); |
| 87 | $this->reset_memo(); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Reset the memoized incentives. |
| 92 | * |
| 93 | * This is useful for testing purposes. |
| 94 | */ |
| 95 | public function reset_memo() { |
| 96 | $this->incentives_memo = null; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Check if the extension plugin is active. |
| 101 | * |
| 102 | * @return boolean Whether the extension plugin is active. |
| 103 | */ |
| 104 | protected function is_extension_active(): bool { |
| 105 | return class_exists( '\WC_Payments' ); |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Fetches and caches eligible incentives from the WooPayments API. |
| 110 | * |
| 111 | * @param string $country_code The business location country code to get incentives for. |
| 112 | * |
| 113 | * @return array List of eligible incentives. |
| 114 | */ |
| 115 | protected function get_incentives( string $country_code ): array { |
| 116 | if ( isset( $this->incentives_memo ) ) { |
| 117 | return $this->incentives_memo; |
| 118 | } |
| 119 | |
| 120 | // Get the cached data. |
| 121 | $cache = get_transient( $this->cache_transient_name ); |
| 122 | |
| 123 | // If the cached data is not expired, and it's a WP_Error, |
| 124 | // it means there was an API error previously, and we should not retry just yet. |
| 125 | if ( is_wp_error( $cache ) ) { |
| 126 | // Initialize the in-memory cache and return it. |
| 127 | $this->incentives_memo = array(); |
| 128 | |
| 129 | return $this->incentives_memo; |
| 130 | } |
| 131 | |
| 132 | // Gather the store context data. |
| 133 | $store_context = array( |
| 134 | 'country' => $country_code, |
| 135 | // Store locale, e.g. `en_US`. |
| 136 | 'locale' => get_locale(), |
| 137 | // WooCommerce store active for duration in seconds. |
| 138 | 'active_for' => WCAdminHelper::get_wcadmin_active_for_in_seconds(), |
| 139 | 'has_orders' => $this->has_orders(), |
| 140 | 'has_payments' => $this->has_enabled_payment_gateways(), |
| 141 | 'has_wcpay' => $this->has_wcpay(), |
| 142 | ); |
| 143 | |
| 144 | // Fingerprint the store context through a hash of certain entries. |
| 145 | $store_context_hash = $this->generate_context_hash( $store_context ); |
| 146 | |
| 147 | // Use the transient cached incentive if it exists, it is not expired, |
| 148 | // and the store context hasn't changed since we last requested from the WooPayments API (based on context hash). |
| 149 | if ( false !== $cache |
| 150 | && ! empty( $cache['context_hash'] ) && is_string( $cache['context_hash'] ) |
| 151 | && hash_equals( $store_context_hash, $cache['context_hash'] ) ) { |
| 152 | |
| 153 | // We have a store context hash, and it matches with the current context one. |
| 154 | // We can use the cached incentive data. |
| 155 | // Store the incentives in the in-memory cache and return them. |
| 156 | $this->incentives_memo = $cache['incentives'] ?? array(); |
| 157 | |
| 158 | return $this->incentives_memo; |
| 159 | } |
| 160 | |
| 161 | // By this point, we have an expired transient or the store context has changed. |
| 162 | // Query for incentives by calling the WooPayments API. |
| 163 | $url = add_query_arg( |
| 164 | $store_context, |
| 165 | 'https://public-api.wordpress.com/wpcom/v2/wcpay/incentives', |
| 166 | ); |
| 167 | |
| 168 | $response = wp_remote_get( |
| 169 | $url, |
| 170 | array( |
| 171 | 'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ), |
| 172 | ) |
| 173 | ); |
| 174 | |
| 175 | // Return early if there is an error, waiting 6 hours before the next attempt. |
| 176 | if ( is_wp_error( $response ) ) { |
| 177 | // Store a trimmed down, lightweight error. |
| 178 | $error = new \WP_Error( |
| 179 | $response->get_error_code(), |
| 180 | $response->get_error_message(), |
| 181 | wp_remote_retrieve_response_code( $response ) |
| 182 | ); |
| 183 | // Store the error in the transient so we know this is due to an API error. |
| 184 | set_transient( $this->cache_transient_name, $error, HOUR_IN_SECONDS * 6 ); |
| 185 | // Initialize the in-memory cache and return it. |
| 186 | $this->incentives_memo = array(); |
| 187 | |
| 188 | return $this->incentives_memo; |
| 189 | } |
| 190 | |
| 191 | $cache_for = wp_remote_retrieve_header( $response, 'cache-for' ); |
| 192 | // Initialize the in-memory cache. |
| 193 | $this->incentives_memo = array(); |
| 194 | |
| 195 | if ( 200 === wp_remote_retrieve_response_code( $response ) ) { |
| 196 | // Decode the results, falling back to an empty array. |
| 197 | $results = json_decode( wp_remote_retrieve_body( $response ), true ) ?? array(); |
| 198 | |
| 199 | // Store incentives in the in-memory cache. |
| 200 | $this->incentives_memo = $results; |
| 201 | } |
| 202 | |
| 203 | // Skip transient cache if `cache-for` header equals zero. |
| 204 | if ( '0' === $cache_for ) { |
| 205 | // If we have a transient cache that is not expired, delete it so there are no leftovers. |
| 206 | if ( false !== $cache ) { |
| 207 | delete_transient( $this->cache_transient_name ); |
| 208 | } |
| 209 | |
| 210 | return $this->incentives_memo; |
| 211 | } |
| 212 | |
| 213 | // Store incentive in transient cache (together with the context hash) for the given number of seconds |
| 214 | // or 1 day in seconds. Also attach a timestamp to the transient data so we know when we last fetched. |
| 215 | set_transient( |
| 216 | $this->cache_transient_name, |
| 217 | array( |
| 218 | 'incentives' => $this->incentives_memo, |
| 219 | 'context_hash' => $store_context_hash, |
| 220 | 'timestamp' => time(), |
| 221 | ), |
| 222 | ! empty( $cache_for ) ? (int) $cache_for : DAY_IN_SECONDS |
| 223 | ); |
| 224 | |
| 225 | return $this->incentives_memo; |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Check if the WooPayments payment gateway is active and set up or was at some point, |
| 230 | * or there are orders processed with it, at some moment. |
| 231 | * |
| 232 | * @return boolean Whether the store has WooPayments. |
| 233 | */ |
| 234 | private function has_wcpay(): bool { |
| 235 | // First, get the stored value, if it exists. |
| 236 | // This way we avoid costly DB queries and API calls. |
| 237 | // Basically, we only want to know if WooPayments was in use in the past. |
| 238 | // Since the past can't be changed, neither can this value. |
| 239 | $had_wcpay = get_option( $this->store_had_woopayments_option_name ); |
| 240 | if ( false !== $had_wcpay ) { |
| 241 | return filter_var( $had_wcpay, FILTER_VALIDATE_BOOLEAN ); |
| 242 | } |
| 243 | |
| 244 | // We need to determine the value. |
| 245 | // Start with the assumption that the store didn't have WooPayments in use. |
| 246 | $had_wcpay = false; |
| 247 | |
| 248 | // We consider the store to have WooPayments if there is meaningful account data in the WooPayments account cache. |
| 249 | // This implies that WooPayments was active at some point and that it was connected. |
| 250 | // If WooPayments is active right now, we will not get to this point since the plugin is active check is done first. |
| 251 | if ( $this->has_wcpay_account_data() ) { |
| 252 | $had_wcpay = true; |
| 253 | } |
| 254 | |
| 255 | // If there is at least one order processed with WooPayments, we consider the store to have WooPayments. |
| 256 | if ( false === $had_wcpay && ! empty( |
| 257 | wc_get_orders( |
| 258 | array( |
| 259 | 'payment_method' => 'woocommerce_payments', |
| 260 | 'return' => 'ids', |
| 261 | 'limit' => 1, |
| 262 | 'orderby' => 'none', |
| 263 | ) |
| 264 | ) |
| 265 | ) ) { |
| 266 | $had_wcpay = true; |
| 267 | } |
| 268 | |
| 269 | // Store the value for future use. |
| 270 | update_option( $this->store_had_woopayments_option_name, $had_wcpay ? 'yes' : 'no' ); |
| 271 | |
| 272 | return $had_wcpay; |
| 273 | } |
| 274 | |
| 275 | /** |
| 276 | * Check if there is meaningful data in the WooPayments account cache. |
| 277 | * |
| 278 | * @return boolean |
| 279 | */ |
| 280 | private function has_wcpay_account_data(): bool { |
| 281 | $account_data = get_option( 'wcpay_account_data', array() ); |
| 282 | if ( ! empty( $account_data['data']['account_id'] ) ) { |
| 283 | return true; |
| 284 | } |
| 285 | |
| 286 | return false; |
| 287 | } |
| 288 | |
| 289 | /** |
| 290 | * Check if the store has any paid orders. |
| 291 | * |
| 292 | * Currently, we look at the past 90 days and only consider orders |
| 293 | * with status `wc-completed`, `wc-processing`, or `wc-refunded`. |
| 294 | * |
| 295 | * @return boolean Whether the store has any paid orders. |
| 296 | */ |
| 297 | private function has_orders(): bool { |
| 298 | // First, get the stored value, if it exists. |
| 299 | // This way we avoid costly DB queries and API calls. |
| 300 | $has_orders = get_transient( $this->store_has_orders_transient_name ); |
| 301 | if ( false !== $has_orders ) { |
| 302 | return filter_var( $has_orders, FILTER_VALIDATE_BOOLEAN ); |
| 303 | } |
| 304 | |
| 305 | // We need to determine the value. |
| 306 | // Start with the assumption that the store doesn't have orders in the timeframe we look at. |
| 307 | $has_orders = false; |
| 308 | // By default, we will check for new orders every 6 hours. |
| 309 | $expiration = 6 * HOUR_IN_SECONDS; |
| 310 | |
| 311 | // Get the latest completed, processing, or refunded order. |
| 312 | $latest_order = wc_get_orders( |
| 313 | array( |
| 314 | 'status' => array( OrderInternalStatus::COMPLETED, OrderInternalStatus::PROCESSING, OrderInternalStatus::REFUNDED ), |
| 315 | 'limit' => 1, |
| 316 | 'orderby' => 'date', |
| 317 | 'order' => 'DESC', |
| 318 | ) |
| 319 | ); |
| 320 | if ( ! empty( $latest_order ) ) { |
| 321 | $latest_order = reset( $latest_order ); |
| 322 | // If the latest order is within the timeframe we look at, we consider the store to have orders. |
| 323 | // Otherwise, it clearly doesn't have orders. |
| 324 | if ( $latest_order instanceof WC_Abstract_Order |
| 325 | && strtotime( (string) $latest_order->get_date_created() ) >= strtotime( '-90 days' ) ) { |
| 326 | |
| 327 | $has_orders = true; |
| 328 | |
| 329 | // For ultimate efficiency, we will check again after 90 days from the latest order |
| 330 | // because in all that time we will consider the store to have orders regardless of new orders. |
| 331 | $expiration = strtotime( (string) $latest_order->get_date_created() ) + 90 * DAY_IN_SECONDS - time(); |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // Store the value for future use. |
| 336 | set_transient( $this->store_has_orders_transient_name, $has_orders ? 'yes' : 'no', $expiration ); |
| 337 | |
| 338 | return $has_orders; |
| 339 | } |
| 340 | |
| 341 | /** |
| 342 | * Check if the store has at least one enabled payment gateway. |
| 343 | * |
| 344 | * @return boolean Whether the store has any enabled payment gateways. |
| 345 | */ |
| 346 | private function has_enabled_payment_gateways(): bool { |
| 347 | $payment_gateways = WC()->payment_gateways()->payment_gateways; |
| 348 | if ( empty( $payment_gateways ) || ! is_array( $payment_gateways ) ) { |
| 349 | return false; |
| 350 | } |
| 351 | |
| 352 | foreach ( $payment_gateways as $payment_gateway ) { |
| 353 | if ( filter_var( $payment_gateway->enabled, FILTER_VALIDATE_BOOLEAN ) ) { |
| 354 | return true; |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | return false; |
| 359 | } |
| 360 | |
| 361 | /** |
| 362 | * Generate a hash from the store context data. |
| 363 | * |
| 364 | * @param array $context The store context data. |
| 365 | * |
| 366 | * @return string The context hash. |
| 367 | */ |
| 368 | private function generate_context_hash( array $context ): string { |
| 369 | // Include only certain entries in the context hash. |
| 370 | // We need only discrete, user-interaction dependent data. |
| 371 | // Entries like `active_for` have no place in the hash generation since they change automatically. |
| 372 | return md5( |
| 373 | wp_json_encode( |
| 374 | array( |
| 375 | 'country' => $context['country'] ?? '', |
| 376 | 'locale' => $context['locale'] ?? '', |
| 377 | 'has_orders' => $context['has_orders'] ?? false, |
| 378 | 'has_payments' => $context['has_payments'] ?? false, |
| 379 | 'has_wcpay' => $context['has_wcpay'] ?? false, |
| 380 | ) |
| 381 | ) |
| 382 | ); |
| 383 | } |
| 384 | } |
| 385 |