client = $client; } /** * Last linked purchase, if any. * * @return array{item: string, license_type: string, code_last4: string, supported_until: string, redeemed_at: int, account_created: bool}|null */ public static function state(): ?array { $stored = get_option( self::OPTION ); if ( ! is_array( $stored ) || empty( $stored['code_last4'] ) ) { return null; } return array( 'item' => isset( $stored['item'] ) ? (string) $stored['item'] : '', 'license_type' => isset( $stored['license_type'] ) ? (string) $stored['license_type'] : '', 'code_last4' => (string) $stored['code_last4'], 'supported_until' => isset( $stored['supported_until'] ) ? (string) $stored['supported_until'] : '', 'redeemed_at' => isset( $stored['redeemed_at'] ) ? (int) $stored['redeemed_at'] : 0, 'account_created' => ! empty( $stored['account_created'] ), ); } /** * Activate this site with a purchase code and the buyer's e-mail (no WPXLab account needed). * WPXLab verifies the code with Envato, opens an account for the e-mail if there is none, and connects this site. * Nothing is sent before the administrator accepts what is shared (CLAUDE.md rule 7). * * @param string $code Purchase code. * @param string $email Buyer e-mail. * @param bool $consent Whether the consent box was ticked. * @return array{ok: bool, error: string, message: string, account_created: bool} */ public function activate( string $code, string $email, bool $consent ): array { if ( ! $consent ) { return self::failure( 'consent_required', __( 'Please accept what Keepwatch will send before connecting.', 'keepwatch' ) ) + array( 'account_created' => false ); } $code = strtolower( trim( $code ) ); $email = strtolower( trim( $email ) ); if ( 1 !== preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $code ) ) { return self::failure( 'purchase_code_format', self::message_for( 'purchase_code_format' ) ) + array( 'account_created' => false ); } if ( ! is_email( $email ) ) { return self::failure( 'purchase_code_email', self::message_for( 'purchase_code_email' ) ) + array( 'account_created' => false ); } global $wp_version; // The Hub calls back /wp-json/keepwatch/v1/verify while this request runs; only then do we sign challenges. set_transient( Connection::PENDING, time(), Connection::PENDING_SECONDS ); $response = $this->client->api()->request( 'POST', '/keepwatch/envato/activate', array( 'purchase_code' => $code, 'email' => $email, 'install_uuid' => $this->client->install_uuid(), 'home_url' => home_url( '/' ), 'plugin_version' => KEEPWATCH_VERSION, 'wp_version' => (string) $wp_version, 'php_version' => PHP_VERSION, ) ); delete_transient( Connection::PENDING ); if ( 201 !== $response['status'] ) { $error = isset( $response['body']['error'] ) ? (string) $response['body']['error'] : 'unexpected'; return self::failure( $error, self::message_for( $error ) ) + array( 'account_created' => false ); } $body = $response['body']; Connection::remember( $body ); $purchase = isset( $body['purchase'] ) && is_array( $body['purchase'] ) ? $body['purchase'] : array(); $created = ! empty( $body['account_created'] ); self::store( $purchase, $code, $created ); return array( 'ok' => true, 'error' => '', 'message' => '', 'account_created' => $created, ); } /** * Link a purchase code to the connected WPXLab account. * * @param string $code Purchase code (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). * @return array{ok: bool, error: string, message: string} */ public function redeem( string $code ): array { if ( ! Connection::is_connected() ) { return self::failure( 'site_not_connected', self::message_for( 'site_not_connected' ) ); } $code = strtolower( trim( $code ) ); if ( 1 !== preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $code ) ) { return self::failure( 'purchase_code_format', self::message_for( 'purchase_code_format' ) ); } $response = $this->client->api()->request( 'POST', '/envato/redeem', array( 'product' => 'keepwatch', 'purchase_code' => $code, ) ); if ( 200 !== $response['status'] && 201 !== $response['status'] ) { $error = isset( $response['body']['error'] ) ? (string) $response['body']['error'] : 'unexpected'; return self::failure( $error, self::message_for( $error ) ); } self::store( $response['body'], $code, false ); return array( 'ok' => true, 'error' => '', 'message' => '', ); } /** * Store only a summary of the purchase; the code itself never stays on the site (rule 3). * * @param array $body Purchase summary from the Hub. * @param string $code Purchase code (only the last four characters are kept). * @param bool $account_created Whether WPXLab opened a new account for the buyer. */ private static function store( array $body, string $code, bool $account_created ): void { update_option( self::OPTION, array( 'item' => isset( $body['item'] ) ? sanitize_text_field( (string) $body['item'] ) : '', 'license_type' => isset( $body['license_type'] ) ? sanitize_text_field( (string) $body['license_type'] ) : '', 'code_last4' => substr( $code, -4 ), 'supported_until' => isset( $body['supported_until'] ) ? sanitize_text_field( (string) $body['supported_until'] ) : '', 'redeemed_at' => time(), 'account_created' => $account_created, ), false ); } /** * Forget the stored purchase summary (e.g. after disconnecting). */ public static function forget(): void { delete_option( self::OPTION ); } /** * Human readable message for an API error code. * * @param string $error Error code. */ public static function message_for( string $error ): string { switch ( $error ) { case 'site_not_connected': return __( 'Connect this site to your WPXLab account first, then add the purchase code.', 'keepwatch' ); case 'purchase_code_format': return __( 'The purchase code must look like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.', 'keepwatch' ); case 'purchase_code_invalid': return __( 'This purchase code was not found on Envato. Please check it.', 'keepwatch' ); case 'purchase_code_email': return __( 'Please enter a valid e-mail address.', 'keepwatch' ); case 'purchase_code_account_exists': return __( 'A WPXLab account with this e-mail already exists. Sign in at wpxlab.com, create a connection code under Keepwatch and connect with it below; then add the purchase code.', 'keepwatch' ); case 'purchase_code_taken': return __( 'This purchase code is already linked to another WPXLab account. If it is yours, contact support.', 'keepwatch' ); case 'purchase_code_unknown_item': return __( 'This purchase code is for a different item. Please contact support.', 'keepwatch' ); case 'purchase_code_unavailable': return __( 'Envato is not responding right now. Please try again shortly.', 'keepwatch' ); case 'network': case 'site_limit_reached': case 'site_already_connected': case 'site_unreachable': case 'site_verification_failed': case 'site_url_invalid': return Connection::message_for( $error ); default: return __( 'The purchase code could not be added. Please try again.', 'keepwatch' ); } } /** * Failure result. * * @param string $error Code. * @param string $message Message. * @return array{ok: bool, error: string, message: string} */ private static function failure( string $error, string $message ): array { return array( 'ok' => false, 'error' => $error, 'message' => $message, ); } }