$tier, 'limits' => self::limits_from_api( $response['limits'] ), 'expires_at' => isset( $response['expires_at'] ) ? (string) $response['expires_at'] : '', ); } /** * Plugin-shaped limits from the server's limits object (null means unlimited). * * Keys: drive_time (the API builds drive-time zones), drive_time_locations (how many locations may have them), * drive_time_zones (how many in total), max_drive_minutes, demand_map (the API stores and shows unserved demand). * * @param array $api Limits from the API. * @return array{drive_time: bool, drive_time_locations: int, drive_time_zones: int, max_drive_minutes: int, demand_map: bool} */ public static function limits_from_api( array $api ) { $int = static function ( $value, $fallback ) { if ( null === $value ) { return PHP_INT_MAX; } return is_numeric( $value ) ? max( 0, (int) $value ) : $fallback; }; $drive = ! empty( $api['drive_time'] ); return array( 'drive_time' => $drive, 'drive_time_locations' => $int( array_key_exists( 'drive_time_locations', $api ) ? $api['drive_time_locations'] : ( $drive ? 1 : 0 ), $drive ? 1 : 0 ), 'drive_time_zones' => $int( array_key_exists( 'drive_time_zones', $api ) ? $api['drive_time_zones'] : 0, 0 ), 'max_drive_minutes' => isset( $api['max_minutes'] ) && is_numeric( $api['max_minutes'] ) ? max( 0, (int) $api['max_minutes'] ) : 15, 'demand_map' => ! empty( $api['demand_map'] ), ); } /** * Decide the tier from what is cached and stored. Pure function: no WordPress calls, so it is unit tested. * * @param string $key Licence key from the settings. * @param array|null $cached The transient (a fresh, verified answer), or null. * @param array|null $stored The last stored answer, or null. * @param int $now Unix time. * @return array{tier: string, limits: array, source: string} source is none|cache|grace|refused|stale. */ public static function resolve( $key, $cached, $stored, $now ) { $free = array( 'tier' => self::FREE, 'limits' => self::limits( self::FREE ), ); if ( '' === trim( (string) $key ) ) { return $free + array( 'source' => 'none' ); } $hash = self::key_hash( $key ); foreach ( array( 'cache' => $cached, 'grace' => $stored, ) as $source => $record ) { if ( ! is_array( $record ) || ! isset( $record['key_hash'], $record['state'] ) || $record['key_hash'] !== $hash ) { continue; } if ( 'refused' === $record['state'] ) { return $free + array( 'source' => 'refused' ); } if ( 'ok' !== $record['state'] || empty( $record['tier'] ) || ! isset( $record['limits'], $record['verified_at'] ) ) { continue; } if ( 'grace' === $source && ( $now - (int) $record['verified_at'] ) > self::GRACE ) { return $free + array( 'source' => 'stale' ); } return array( 'tier' => $record['tier'], 'limits' => self::normalise_limits( $record['limits'], (string) $record['tier'] ), 'source' => $source, ); } return $free + array( 'source' => 'none' ); } /** * Make sure a stored limits array has every key the plugin reads. A record written by an older plugin version (with * the retired "locations" and "demand_log" keys) is completed from the tier table until the next refresh. * * @param mixed $limits Stored limits. * @param string $tier Tier of the record. * @return array */ private static function normalise_limits( $limits, $tier ) { $defaults = self::limits( $tier ); return array_merge( $defaults, array_intersect_key( is_array( $limits ) ? $limits : array(), $defaults ) ); } /** * The current status for this site. * * @return array{tier: string, limits: array, source: string} */ public static function current() { if ( null !== self::$memo ) { return self::$memo; } $key = Settings::get()['license_key']; if ( '' === $key ) { self::$memo = self::resolve( '', null, null, time() ); return self::$memo; } $cached = get_transient( self::TRANSIENT ); $stored = get_option( self::OPTION, null ); $result = self::resolve( $key, is_array( $cached ) ? $cached : null, is_array( $stored ) ? $stored : null, time() ); if ( false === $cached && ! wp_next_scheduled( self::CRON_NOW ) ) { // Nothing fresh: look again in the background, never while a shopper waits. wp_schedule_single_event( time() + 10, self::CRON_NOW ); } self::$memo = $result; return $result; } /** * Forget the per-request memo (after a refresh or in tests). */ public static function forget() { self::$memo = null; } /** * Current site tier. * * @return string */ public static function tier() { return self::current()['tier']; } /** * Limits of the current plan. * * @return array */ public static function current_limits() { return self::current()['limits']; } /** * Fallback limits for a tier constant (the free tier, and the labels the settings page shows). Paid sites use the * limits the server sent. These only say what the API may be asked to do. * * @param string $tier Tier. * @return array{drive_time: bool, drive_time_locations: int, drive_time_zones: int, max_drive_minutes: int, demand_map: bool} */ public static function limits( $tier ) { switch ( $tier ) { case self::BUSINESS: case self::AGENCY: return array( 'drive_time' => true, 'drive_time_locations' => 5, 'drive_time_zones' => PHP_INT_MAX, 'max_drive_minutes' => 15, 'demand_map' => true, ); case self::PRO: return array( 'drive_time' => true, 'drive_time_locations' => 1, 'drive_time_zones' => 3, 'max_drive_minutes' => 15, 'demand_map' => false, ); } return array( 'drive_time' => false, 'drive_time_locations' => 0, 'drive_time_zones' => 0, 'max_drive_minutes' => 15, 'demand_map' => false, ); } /** * Ask the server about the saved key and store the answer. Live network call: cron, the settings page and saving only. * * @param bool $force Ignore the retry back-off (saving the settings). * @return array{ok: bool, message: string} */ public static function refresh( $force = false ) { $key = Settings::get()['license_key']; self::forget(); if ( '' === $key ) { self::clear(); return array( 'ok' => true, 'message' => '', ); } if ( ! $force && false !== get_transient( self::RETRY ) ) { return array( 'ok' => false, 'message' => '', ); } $answer = Api_Client::license_status(); if ( ! is_wp_error( $answer ) && empty( $answer['site_active'] ) ) { $answer = Api_Client::license_activate( $key ); } return self::store_answer( $key, $answer ); } /** * Settings were saved with a possibly different key: free the old activation, activate the new key now. * * @param string $old_key Key before saving ('' for none). * @param string $new_key Key after saving. * @return array{ok: bool, message: string} */ public static function apply_key( $old_key, $new_key ) { $old_key = strtoupper( trim( (string) $old_key ) ); $new_key = strtoupper( trim( (string) $new_key ) ); self::forget(); if ( '' !== $old_key && $old_key !== $new_key ) { Api_Client::license_deactivate( $old_key ); // Best effort: frees the site slot for the next key. } if ( '' === $new_key ) { self::clear(); return array( 'ok' => true, 'message' => '', ); } return self::store_answer( $new_key, Api_Client::license_activate( $new_key ) ); } /** * Write a server answer (or an error) into the stored state and the transient. * * @param string $key Key the answer is for. * @param array|\WP_Error $answer Decoded response or error. * @return array{ok: bool, message: string} */ private static function store_answer( $key, $answer ) { $hash = self::key_hash( $key ); $now = time(); self::forget(); if ( ! is_wp_error( $answer ) ) { $status = self::status_from_response( $answer ); if ( null !== $status ) { $record = array( 'key_hash' => $hash, 'state' => 'ok', 'tier' => $status['tier'], 'limits' => $status['limits'], 'expires_at' => $status['expires_at'], 'verified_at' => $now, ); update_option( self::OPTION, $record, false ); set_transient( self::TRANSIENT, $record, self::TTL ); delete_transient( self::RETRY ); return array( 'ok' => true, 'message' => '', ); } $answer = new \WP_Error( 'reachzone_bad_response', 'Unexpected licence response.' ); } $refusals = array( 'reachzone_bad_key', 'reachzone_payment_required', 'reachzone_forbidden', 'reachzone_site_limit' ); if ( in_array( $answer->get_error_code(), $refusals, true ) ) { $record = array( 'key_hash' => $hash, 'state' => 'refused', 'reason' => $answer->get_error_code(), 'checked_at' => $now, ); update_option( self::OPTION, $record, false ); set_transient( self::TRANSIENT, $record, self::REFUSED_TTL ); delete_transient( self::RETRY ); } else { // The API is down or answered nonsense: keep the last verified answer (7 days of grace), try again later. set_transient( self::RETRY, 1, self::RETRY_TTL ); } return array( 'ok' => false, 'message' => self::message_for( $answer->get_error_code() ), ); } /** * Forget everything stored about a licence. */ private static function clear() { delete_option( self::OPTION ); delete_transient( self::TRANSIENT ); delete_transient( self::RETRY ); self::forget(); } /** * Shop-owner-facing text for a licence error code. * * @param string $code WP_Error code from the API client. * @return string */ public static function message_for( $code ) { switch ( $code ) { case 'reachzone_bad_key': return __( 'The licence key was not recognised.', 'reachzone' ); case 'reachzone_payment_required': return __( 'This licence has expired. Renew the plan to use the ReachZone API services again.', 'reachzone' ); case 'reachzone_forbidden': return __( 'This licence was cancelled or suspended.', 'reachzone' ); case 'reachzone_site_limit': return __( 'This licence is already active on as many sites as the plan allows. Remove the key from another site first.', 'reachzone' ); } return __( 'The ReachZone licence server could not be reached. Your last verified plan stays active for up to 7 days.', 'reachzone' ); } }