PluginProbe
wpForo Forum / 3.2.0
wpForo Forum v3.2.0
3.2.0 3.1.7 3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 All 140 releases
wpforo / admin / pages / license / src / Services / LicenseService.php

LicenseService.php in wpForo Forum 3.2.0, at admin/pages/license/src/Services/LicenseService.php

799 lines 33.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace gVectors\License\Services;
4
5 use gVectors\License\Config;
6 use gVectors\License\LicenseModule;
7
8 // Exit if accessed directly
9 if( ! defined( 'ABSPATH' ) ) exit;
10
11 /**
12 * Manages license storage, validation, and revalidation.
13 * Licenses are stored locally, but the proxy server is the source of truth.
14 */
15 class LicenseService {
16 /**
17 * Terminal statuses/reasons: license is removed AND the addon is deactivated + deleted.
18 */
19 const TERMINAL_STATUSES = [ 'invalid' ];
20 const REFUND_REASONS = [ 'refunded' ];
21 public $apiService;
22 public $tampered_option;
23 public $expired_notice_option;
24 private $config;
25 private $batch_cache_key;
26 private $option_key;
27
28 public function __construct( Config $config, ApiService $apiService ) {
29 $this->config = $config;
30 $this->apiService = $apiService;
31 $this->batch_cache_key = $this->config->get_core_plugin_slug() . '_gvectors_batch_validation';
32 $this->option_key = $this->config->get_core_plugin_slug() . '_gvectors_licenses';
33 $this->tampered_option = $this->config->get_core_plugin_slug() . '_gvectors_tampered_addons';
34 $this->expired_notice_option = $this->config->get_core_plugin_slug() . '_gvectors_expired_license_notices';
35 $this->init_hooks();
36 }
37
38 private function init_hooks() {
39 add_action( 'gvectors_daily_cron', [ $this, 'maybe_revalidate_all' ] );
40 if( ! wp_next_scheduled( 'gvectors_revalidate' ) ) {
41 wp_schedule_event( time(), 'daily', 'gvectors_revalidate' );
42 }
43 add_action( 'gvectors_revalidate', [ $this, 'maybe_revalidate_all' ] );
44 }
45
46 /**
47 * Check if a product has an active license
48 */
49 public function is_active( string $product_id ): bool {
50 $license = $this->get( $product_id );
51 if( empty( $license ) || empty( $license['status'] ) ) return false;
52 if( ! in_array( $license['status'], [ 'active', 'trial' ], true ) ) return false;
53 $expires_ts = ! empty( $license['expires_at'] ) ? strtotime( $license['expires_at'] ) : false;
54 if( $expires_ts !== false && $expires_ts < time() ) return false;
55
56 return true;
57 }
58
59 /**
60 * Get a license for a specific product
61 */
62 public function get( string $product_id ): array {
63 $licenses = $this->get_all();
64
65 return $licenses[ $product_id ] ?? [];
66 }
67
68 /**
69 * Get all stored licenses
70 */
71 public function get_all(): array {
72 return get_option( $this->option_key, [] );
73 }
74
75 /**
76 * Check if a product is on trial
77 */
78 public function is_trial( string $product_id ): bool {
79 $license = $this->get( $product_id );
80
81 return ! empty( $license ) && $license['status'] === 'trial';
82 }
83
84 /**
85 * Validate all licenses via a single batch call (or use cache), then return
86 * the result for the requested product along with ALL updated licenses.
87 *
88 * Called by the AJAX validate button. One click validates everything.
89 *
90 * Returns the same array shape as revalidate(), plus:
91 * 'all_licenses' => array (full refreshed licenses keyed by product_id)
92 * 'cached' => bool
93 */
94 public function validate_with_cache( string $product_id = '' ): array {
95 // If a recent batch cache exists, all local data is already up to date
96 $cached = get_transient( $this->batch_cache_key );
97 $is_cached = ! empty( $cached ) && ! empty( $cached['time'] );
98
99 if( ! $is_cached ) {
100 // No cache — fetch fresh batch and process ALL licenses
101 $status = $this->batch_validate_and_cache();
102
103 if( $status === 'server_unavailable' ) {
104 return [
105 'valid' => false,
106 'reason' => 'server_unavailable',
107 'error' => __( 'Could not reach the license server. Your current license status is preserved. Will retry automatically.', 'gvectors' ),
108 'status' => '',
109 'removed' => false,
110 'addon_deleted' => false,
111 'all_licenses' => $this->get_all(),
112 ];
113 }
114 }
115
116 $all_licenses = $this->get_all();
117
118 // If a specific product was requested, return its individual result
119 if( ! empty( $product_id ) ) {
120 $license = $this->get( $product_id );
121 $valid = ! empty( $license ) && ! empty( $license['status'] ) && in_array( $license['status'], [ 'active', 'trial' ], true );
122
123 if( $valid && ! empty( $license['expires_at'] ) ) {
124 $expires_ts = strtotime( $license['expires_at'] );
125 if( $expires_ts !== false && $expires_ts < time() ) {
126 $valid = false;
127 }
128 }
129
130 $reason = '';
131 if( ! $valid && ! empty( $license['status'] ) ) {
132 $reason = $license['status'];
133 }
134 if( empty( $license ) ) {
135 $reason = 'missing';
136 }
137
138 return [
139 'valid' => $valid,
140 'reason' => $reason,
141 'error' => '',
142 'status' => $license['status'] ?? '',
143 'removed' => empty( $license ),
144 'addon_deleted' => false,
145 'all_licenses' => $all_licenses,
146 'cached' => $is_cached,
147 ];
148 }
149
150 // No specific product — return summary for all
151 // Consider valid if all licenses are active/trial
152 $all_valid = true;
153 foreach( $all_licenses as $lic ) {
154 if( empty( $lic['status'] ) || ! in_array( $lic['status'], [ 'active', 'trial' ], true ) ) {
155 $all_valid = false;
156 break;
157 }
158 if( ! empty( $lic['expires_at'] ) ) {
159 $expires_ts = strtotime( $lic['expires_at'] );
160 if( $expires_ts !== false && $expires_ts < time() ) {
161 $all_valid = false;
162 break;
163 }
164 }
165 }
166
167 return [
168 'valid' => $all_valid,
169 'reason' => $all_valid ? '' : 'some_invalid',
170 'error' => '',
171 'status' => '',
172 'removed' => false,
173 'addon_deleted' => false,
174 'all_licenses' => $all_licenses,
175 'cached' => $is_cached,
176 ];
177 }
178
179 /**
180 * Statuses that indicate a license is permanently invalid and should be removed locally.
181 */
182
183 /**
184 * Perform a batch validation for all stored licenses, process each result
185 * (update local DB/options), and cache the raw results.
186 *
187 * Returns 'server_unavailable' on server error, 'ok' on success, 'empty' if no licenses.
188 */
189 public function batch_validate_and_cache(): string {
190 $licenses = $this->get_all();
191 if( empty( $licenses ) ) return 'empty';
192
193 $key_to_product = []; // license_key => product_id
194 foreach( $licenses as $product_id => $license ) {
195 if( ! empty( $license['license_key'] ) ) {
196 $key_to_product[ $license['license_key'] ] = $product_id;
197 }
198 }
199 if( empty( $key_to_product ) ) return 'empty';
200
201 $response = $this->apiService->validate_licenses_batch( array_keys( $key_to_product ) );
202
203 $response_code = $response['code'] ?? 0;
204 $is_server_error = (
205 $response_code === 'wp_error'
206 || $response_code === 'invalid_response'
207 || ( is_int( $response_code ) && ( $response_code >= 500 || $response_code === 401 || $response_code === 403 || $response_code === 429 ) )
208 );
209 if( $is_server_error ) return 'server_unavailable';
210
211 if( empty( $response['success'] ) || empty( $response['data']['licenses'] ) ) return 'empty';
212
213 $batch_results = $response['data']['licenses'];
214
215 // Cache the raw batch results
216 set_transient( $this->batch_cache_key, [
217 'time' => time(),
218 'licenses' => $batch_results,
219 ], $this->config->get_license_batch_cache_ttl() );
220
221 // Process every license result — update local storage and notices
222 foreach( $key_to_product as $license_key => $product_id ) {
223 if( ! isset( $batch_results[ $license_key ] ) ) continue;
224
225 $result = $batch_results[ $license_key ];
226 $license = $licenses[ $product_id ];
227 $product_id = $this->sync_product_key( $product_id, $result );
228 if( $product_id === '' ) continue;
229
230 if( ! empty( $result['success'] ) && ! empty( $result['data'] ) ) {
231 $this->save( $product_id, $result['data'] );
232
233 // Clear any expired notice for this license
234 $plugin_slug = $result['data']['plugin_slug'] ?? '';
235 if( $plugin_slug ) {
236 $expired_notices = get_option( $this->expired_notice_option, [] );
237 if( isset( $expired_notices[ $plugin_slug ] ) ) {
238 unset( $expired_notices[ $plugin_slug ] );
239 update_option( $this->expired_notice_option, $expired_notices );
240 }
241 }
242 } else {
243 $this->process_failed_revalidation( $product_id, $license, $result );
244 }
245 }
246
247 return 'ok';
248 }
249
250 /**
251 * Store/update a license locally
252 */
253 public function save( string $product_id, array $license_data ): bool {
254 $licenses = $this->get_all();
255
256 // Guard: Never overwrite a locally-stored active lifetime license with a subscription license.
257 // Only protect lifetime licenses that are still valid (active/trial status).
258 $existing = $licenses[ $product_id ] ?? [];
259 $existing_status = $existing['status'] ?? '';
260 if( ! empty( $existing ) && empty( $existing['expires_at'] ) && ! empty( $license_data['expires_at'] )
261 && in_array( $existing_status, [ 'active', 'trial', '' ] ) ) {
262 return true;
263 }
264
265 $license_data['last_validated'] = time();
266 $licenses[ $product_id ] = wp_parse_args( $license_data, [
267 'license_key' => '',
268 'product_id' => $product_id,
269 'subscription_id' => '',
270 'status' => '',
271 'expires_at' => '',
272 'activated_at' => '',
273 'site_domain' => LicenseModule::get_site_domain(),
274 'customer_id' => '',
275 'plan_name' => '',
276 'last_validated' => time(),
277 ] );
278
279 return update_option( $this->option_key, $licenses );
280 }
281
282 /**
283 * Process a failed batch revalidation result for a single license.
284 */
285 private function process_failed_revalidation( string $product_id, array $license, array $result ): void {
286 $server_status = $result['data']['status'] ?? '';
287 $server_error = $result['error'] ?? '';
288 $server_reason = '';
289
290 if( ! empty( $result['data']['invalid_reason'] ) ) {
291 $server_reason = $result['data']['invalid_reason'];
292 }
293 if( empty( $server_reason ) && preg_match( '/invalid:\\s*(.+)$/i', $server_error, $m ) ) {
294 $server_reason = trim( $m[1] );
295 }
296 if( empty( $server_reason ) && stripos( $server_error, 'expired' ) !== false ) {
297 $server_reason = 'expired';
298 }
299 if( empty( $server_reason ) && stripos( $server_error, 'not found' ) !== false ) {
300 $server_reason = 'not_found';
301 $server_status = $server_status ?: 'invalid';
302 }
303
304 $is_terminal = in_array( $server_reason, self::REFUND_REASONS, true )
305 || in_array( $server_status, self::TERMINAL_STATUSES, true )
306 || $server_reason === 'not_found';
307
308 if( $is_terminal ) {
309 $plugin_slug = $license['plugin_slug'] ?? '';
310 $this->remove( $product_id );
311
312 if( $plugin_slug ) {
313 $this->deactivate_and_delete_addon( $plugin_slug );
314
315 $expired_notices = get_option( $this->expired_notice_option, [] );
316 if( isset( $expired_notices[ $plugin_slug ] ) ) {
317 unset( $expired_notices[ $plugin_slug ] );
318 update_option( $this->expired_notice_option, $expired_notices );
319 }
320 $tampered = get_option( $this->tampered_option, [] );
321 if( isset( $tampered[ $plugin_slug ] ) ) {
322 unset( $tampered[ $plugin_slug ] );
323 update_option( $this->tampered_option, $tampered );
324 }
325 }
326 } else {
327 // Non-terminal: update local license with server data
328 $server_data = ( ! empty( $result['data'] ) && is_array( $result['data'] ) ) ? $result['data'] : [];
329 $has_license_fields = ! empty( $server_data['license_key'] ) || ! empty( $server_data['status'] ) || ! empty( $server_data['expires_at'] );
330
331 if( $has_license_fields ) {
332 $updated = wp_parse_args( $server_data, $license );
333 $updated['last_validated'] = time();
334 $this->save( $product_id, $updated );
335 } else {
336 if( $server_status ) {
337 $license['status'] = $server_status;
338 } elseif( $server_reason === 'expired' ) {
339 $license['status'] = 'expired';
340 }
341 $license['last_validated'] = time();
342 $this->save( $product_id, $license );
343 }
344
345 if( in_array( $server_status, [ 'expired', 'cancelled' ], true ) || $server_reason === 'expired' ) {
346 $this->update_expired_notice( $product_id );
347 }
348 }
349 }
350
351 /**
352 * If the server reports a real Paddle product_id different from the local key
353 * (e.g. a migrated legacy license stored under its plugin slug), move the local
354 * license to that key so the store UI treats it like any purchased license.
355 * Returns the key to continue with, or '' when the entry was dropped because an
356 * active license already exists under the server's product_id.
357 */
358 private function sync_product_key( string $product_id, array $result ): string {
359 $server_pid = $result['data']['product_id'] ?? '';
360 if( ! is_string( $server_pid ) || strpos( $server_pid, 'pro_' ) !== 0 || $server_pid === $product_id ) return $product_id;
361
362 $licenses = $this->get_all();
363 if( ! isset( $licenses[ $product_id ] ) ) return $product_id;
364
365 $keep_existing = isset( $licenses[ $server_pid ] ) && $this->is_active( $server_pid );
366 if( ! $keep_existing ) {
367 $licenses[ $server_pid ] = $licenses[ $product_id ];
368 $licenses[ $server_pid ]['product_id'] = $server_pid;
369 }
370 unset( $licenses[ $product_id ] );
371 update_option( $this->option_key, $licenses );
372
373 return $keep_existing ? '' : $server_pid;
374 }
375
376 /**
377 * Remove a license for a product
378 */
379 public function remove( string $product_id ): bool {
380 $licenses = $this->get_all();
381 if( isset( $licenses[ $product_id ] ) ) {
382 unset( $licenses[ $product_id ] );
383
384 return update_option( $this->option_key, $licenses );
385 }
386
387 return true;
388 }
389
390 /**
391 * Deactivate and delete an addon plugin by its slug.
392 * Used when a license is refunded to fully remove the addon.
393 */
394 private function deactivate_and_delete_addon( string $plugin_slug ): bool {
395 if( empty( $plugin_slug ) ) return false;
396
397 if( ! function_exists( 'get_plugins' ) ) {
398 require_once ABSPATH . 'wp-admin/includes/plugin.php';
399 }
400 if( ! function_exists( 'delete_plugins' ) ) {
401 require_once ABSPATH . 'wp-admin/includes/file.php';
402 }
403
404 // Find the installed plugin file
405 $plugin_file = '';
406 $all_plugins = get_plugins();
407 foreach( $all_plugins as $file => $data ) {
408 if( strpos( $file, $plugin_slug . '/' ) === 0 ) {
409 $plugin_file = $file;
410 break;
411 }
412 }
413
414 if( empty( $plugin_file ) ) return false;
415
416 // Deactivate if active
417 if( is_plugin_active( $plugin_file ) ) {
418 deactivate_plugins( $plugin_file );
419 }
420
421 // Delete the plugin files
422 $result = delete_plugins( [ $plugin_file ] );
423
424 return ! is_wp_error( $result );
425 }
426
427 /**
428 * Update the expired license notice for a specific product.
429 */
430 private function update_expired_notice( string $product_id ): void {
431 $license = $this->get( $product_id );
432 if( empty( $license ) ) return;
433
434 $plugin_slug = $license['plugin_slug'] ?? '';
435 if( empty( $plugin_slug ) ) return;
436 // Addon not physically installed — no notice needed
437 if( ! is_dir( WP_PLUGIN_DIR . '/' . $plugin_slug ) ) return;
438
439 $expired_notices = get_option( $this->expired_notice_option, [] );
440 $expired_notices[ $plugin_slug ] = [
441 'product_name' => $license['product_name'] ?? $plugin_slug,
442 'status' => $license['status'] ?? 'expired',
443 'expires_at' => $license['expires_at'] ?? '',
444 'has_update' => false,
445 ];
446 update_option( $this->expired_notice_option, $expired_notices );
447 }
448
449 /**
450 * Revalidate a single license against the proxy server.
451 *
452 * Returns an associative array with:
453 * 'valid' => bool
454 * 'reason' => string (failure reason from server, e.g. 'refunded', 'expired')
455 * 'error' => string (human-readable error message from server)
456 * 'status' => string (license status from server response)
457 * 'removed' => bool (whether the license was removed locally)
458 * 'addon_deleted' => bool (whether the addon plugin was deactivated and deleted - only on refund)
459 */
460 public function revalidate( string $product_id ): array {
461 $license = $this->get( $product_id );
462 if( empty( $license ) || empty( $license['license_key'] ) ) {
463 return [
464 'valid' => false,
465 'reason' => 'missing',
466 'error' => __( 'No license found for this product', 'gvectors' ),
467 'status' => '',
468 'removed' => false,
469 'addon_deleted' => false,
470 ];
471 }
472
473 $response = $this->apiService->validate_license( $license['license_key'] );
474
475 // ── Server/network error — keep current local state, retry later ──
476 // Only trust the response if the server actually processed our request.
477 // Connection failures, HTTP errors (401, 403, 500), and invalid responses
478 // should NOT alter the local license status.
479 $response_code = $response['code'] ?? 0;
480 $is_server_error = (
481 $response_code === 'wp_error' // Connection failure, timeout, DNS error
482 || $response_code === 'invalid_response' // Server returned non-JSON
483 || ( is_int( $response_code ) && ( $response_code >= 500 || $response_code === 401 || $response_code === 403 || $response_code === 429 ) )
484 );
485
486 if( $is_server_error ) {
487 // Don't update last_validated — will retry on next cron run
488 return [
489 'valid' => ! empty( $license['status'] ) && in_array( $license['status'], [ 'active', 'trial' ], true ),
490 'reason' => 'server_unavailable',
491 'error' => $response['error'] ?? 'Server unavailable',
492 'status' => $license['status'] ?? '',
493 'removed' => false,
494 'addon_deleted' => false,
495 ];
496 }
497
498 $product_id = $this->sync_product_key( $product_id, $response );
499 if( $product_id === '' ) {
500 return [ 'valid' => false, 'reason' => 'duplicate', 'error' => '', 'status' => '', 'removed' => true, 'addon_deleted' => false ];
501 }
502
503 // License is valid on the server
504 if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) {
505 $this->save( $product_id, $response['data'] );
506
507 return [
508 'valid' => true,
509 'reason' => '',
510 'error' => '',
511 'status' => $response['data']['status'] ?? 'active',
512 'removed' => false,
513 'addon_deleted' => false,
514 ];
515 }
516
517 // License validation failed - extract details from server response
518 $server_status = $response['data']['status'] ?? '';
519 $server_error = $response['error'] ?? '';
520 $server_reason = '';
521
522 // Extract invalid_reason from response data if available
523 if( ! empty( $response['data']['invalid_reason'] ) ) {
524 $server_reason = $response['data']['invalid_reason'];
525 }
526 // Parse reason from the error message (e.g. "License is invalid: refunded")
527 if( empty( $server_reason ) && preg_match( '/invalid:\s*(.+)$/i', $server_error, $m ) ) {
528 $server_reason = trim( $m[1] );
529 }
530 // If the error indicates expiration, set reason accordingly
531 if( empty( $server_reason ) && stripos( $server_error, 'expired' ) !== false ) {
532 $server_reason = 'expired';
533 }
534 // If the license key was not found on server
535 if( empty( $server_reason ) && stripos( $server_error, 'not found' ) !== false ) {
536 $server_reason = 'not_found';
537 $server_status = $server_status ?: 'invalid';
538 }
539
540 // Determine if this is a terminal state: license removed + addon deactivated + deleted
541 // Terminal = refunded, not found on server, or invalid status
542 $is_terminal = in_array( $server_reason, self::REFUND_REASONS, true )
543 || in_array( $server_status, self::TERMINAL_STATUSES, true )
544 || $server_reason === 'not_found';
545
546 $removed = false;
547 $addon_deleted = false;
548
549 if( $is_terminal ) {
550 // Terminal: remove license, deactivate and delete the addon plugin
551 $plugin_slug = $license['plugin_slug'] ?? '';
552 $this->remove( $product_id );
553 $removed = true;
554
555 // Deactivate and delete the addon if installed
556 if( $plugin_slug ) {
557 $addon_deleted = $this->deactivate_and_delete_addon( $plugin_slug );
558
559 // Clean-up-related notices
560 $expired_notices = get_option( $this->expired_notice_option, [] );
561 if( isset( $expired_notices[ $plugin_slug ] ) ) {
562 unset( $expired_notices[ $plugin_slug ] );
563 update_option( $this->expired_notice_option, $expired_notices );
564 }
565 $tampered = get_option( $this->tampered_option, [] );
566 if( isset( $tampered[ $plugin_slug ] ) ) {
567 unset( $tampered[ $plugin_slug ] );
568 update_option( $this->tampered_option, $tampered );
569 }
570 }
571 } else {
572 // Non-terminal failure (expired, cancelled, past_due, paused, etc.)
573 // Overwrite local license with authoritative server data to purge stale values.
574 $server_data = ( ! empty( $response['data'] ) && is_array( $response['data'] ) ) ? $response['data'] : [];
575
576 // Only treat server data as license fields if it has actual license keys
577 // (not just a raw error envelope like {success:false, error:'...'})
578 $has_license_fields = ! empty( $server_data['license_key'] ) || ! empty( $server_data['status'] ) || ! empty( $server_data['expires_at'] );
579
580 if( $has_license_fields ) {
581 // Server returned full license data — use it as the source of truth
582 $updated = wp_parse_args( $server_data, $license );
583 $updated['last_validated'] = time();
584 $this->save( $product_id, $updated );
585 } else {
586 // Server didn't return license fields — force-update status from error context
587 if( $server_status ) {
588 $license['status'] = $server_status;
589 } elseif( $server_reason === 'expired' ) {
590 $license['status'] = 'expired';
591 }
592 $license['last_validated'] = time();
593 $this->save( $product_id, $license );
594 }
595
596 // Trigger expired notice update for this license
597 if( in_array( $server_status, [ 'expired', 'cancelled' ], true ) || $server_reason === 'expired' ) {
598 $this->update_expired_notice( $product_id );
599 }
600 }
601
602 return [
603 'valid' => false,
604 'reason' => $server_reason ?: $server_status,
605 'error' => $server_error,
606 'status' => $server_status,
607 'removed' => $removed,
608 'addon_deleted' => $addon_deleted,
609 ];
610 }
611
612 /**
613 * Revalidate all stored licenses (called by cron).
614 * Uses batch validation to check all licenses in a single API call.
615 */
616 public function maybe_revalidate_all(): void {
617 $licenses = $this->get_all();
618 if( empty( $licenses ) ) return;
619
620 // Collect license keys that need revalidation
621 $keys_to_validate = []; // license_key => product_id
622 foreach( $licenses as $product_id => $license ) {
623 if( $this->needs_revalidation( $product_id ) && ! empty( $license['license_key'] ) ) {
624 $keys_to_validate[ $license['license_key'] ] = $product_id;
625 }
626 }
627
628 if( empty( $keys_to_validate ) ) return;
629
630 $response = $this->apiService->validate_licenses_batch( array_keys( $keys_to_validate ) );
631
632 // Server/network error — don't touch local state, retry on next cron
633 $response_code = $response['code'] ?? 0;
634 $is_server_error = (
635 $response_code === 'wp_error'
636 || $response_code === 'invalid_response'
637 || ( is_int( $response_code ) && ( $response_code >= 500 || $response_code === 401 || $response_code === 403 || $response_code === 429 ) )
638 );
639 if( $is_server_error ) return;
640
641 if( empty( $response['success'] ) || empty( $response['data']['licenses'] ) ) return;
642
643 $batch_results = $response['data']['licenses'];
644
645 // Cache the raw batch results so individual Validate clicks can reuse them
646 set_transient( $this->batch_cache_key, [
647 'time' => time(),
648 'licenses' => $batch_results,
649 ], $this->config->get_license_batch_cache_ttl() );
650
651 foreach( $keys_to_validate as $license_key => $product_id ) {
652 if( ! isset( $batch_results[ $license_key ] ) ) continue;
653
654 $result = $batch_results[ $license_key ];
655 $license = $licenses[ $product_id ];
656 $product_id = $this->sync_product_key( $product_id, $result );
657 if( $product_id === '' ) continue;
658
659 if( ! empty( $result['success'] ) && ! empty( $result['data'] ) ) {
660 // License is valid — update local data
661 $this->save( $product_id, $result['data'] );
662 } else {
663 // License validation failed — process like single "revalidate"
664 $this->process_failed_revalidation( $product_id, $license, $result );
665 }
666 }
667 }
668
669 /**
670 * Check if the license needs revalidation
671 */
672 public function needs_revalidation( string $product_id ): bool {
673 $license = $this->get( $product_id );
674 if( empty( $license ) ) return false;
675 $last = isset( $license['last_validated'] ) ? (int) $license['last_validated'] : 0;
676
677 return ( time() - $last ) > $this->config->get_license_revalidation_period();
678 }
679
680 /**
681 * Unified activation: proxy detects key type (gvl_*, txn_*, legacy, or empty=by-domain).
682 * Saves whatever license(s) the proxy returns into the local gvectors_licenses option.
683 */
684 public function activate_unified( string $key, string $product_id = '' ): array {
685 $response = $this->apiService->activate_unified( $key, $product_id );
686
687 if( ! empty( $response['success'] ) ) {
688 // Transaction / by-domain response: data.activated[] array
689 if( ! empty( $response['data']['activated'] ) ) {
690 foreach( $response['data']['activated'] as $license_data ) {
691 $pid = ! empty( $license_data['product_id'] ) ? $license_data['product_id'] : '';
692 if( $pid ) {
693 $this->save( $pid, $license_data );
694 }
695 }
696 } // Single license response (gvl_* or legacy key): data.license_key present
697 elseif( ! empty( $response['data']['license_key'] ) ) {
698 $pid = ! empty( $response['data']['product_id'] ) ? $response['data']['product_id'] : $product_id;
699 if( $pid ) {
700 $this->save( $pid, $response['data'] );
701 }
702 }
703 }
704
705 return $response;
706 }
707
708 /**
709 * Activate a license key
710 */
711 public function activate( string $license_key, string $product_id = '' ): array {
712 $response = $this->apiService->activate_license( $license_key, $product_id );
713
714 if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) {
715 $pid = ! empty( $response['data']['product_id'] ) ? $response['data']['product_id'] : $product_id;
716 if( $pid ) {
717 $this->save( $pid, $response['data'] );
718 }
719 }
720
721 return $response;
722 }
723
724 /**
725 * Activate licenses by transaction ID
726 */
727 public function activate_by_transaction( string $transaction_id, string $product_id = '' ): array {
728 $response = $this->apiService->activate_license_by_transaction( $transaction_id, $product_id );
729
730 if( ! empty( $response['success'] ) && ! empty( $response['data']['activated'] ) ) {
731 foreach( $response['data']['activated'] as $license_data ) {
732 $pid = ! empty( $license_data['product_id'] ) ? $license_data['product_id'] : '';
733 if( $pid ) {
734 $this->save( $pid, $license_data );
735 }
736 }
737 }
738
739 return $response;
740 }
741
742 /**
743 * Activate all licenses registered for this site's domain
744 */
745 public function activate_by_domain(): array {
746 $response = $this->apiService->activate_licenses_by_domain();
747
748 if( ! empty( $response['success'] ) && ! empty( $response['data']['activated'] ) ) {
749 foreach( $response['data']['activated'] as $license_data ) {
750 $pid = ! empty( $license_data['product_id'] ) ? $license_data['product_id'] : '';
751 if( $pid ) {
752 $this->save( $pid, $license_data );
753 }
754 }
755 }
756
757 return $response;
758 }
759
760 /**
761 * Deactivate a license key
762 */
763 public function deactivate( string $product_id ): array {
764 $license = $this->get( $product_id );
765 if( empty( $license ) || empty( $license['license_key'] ) ) {
766 return [ 'success' => false, 'error' => 'No license found for this product' ];
767 }
768
769 $response = $this->apiService->deactivate_license( $license['license_key'] );
770
771 if( ! empty( $response['success'] ) ) {
772 $this->remove( $product_id );
773 }
774
775 return $response;
776 }
777
778 /**
779 * Get a license status label
780 */
781 public function get_status_label( string $product_id ): string {
782 $license = $this->get( $product_id );
783 if( empty( $license ) ) return __( 'Not Licensed', 'gvectors' );
784
785 $labels = [
786 'active' => __( 'Active', 'gvectors' ),
787 'trial' => __( 'Trial', 'gvectors' ),
788 'expired' => __( 'Expired', 'gvectors' ),
789 'cancelled' => __( 'Cancelled', 'gvectors' ),
790 'past_due' => __( 'Past Due', 'gvectors' ),
791 'paused' => __( 'Paused', 'gvectors' ),
792 ];
793
794 $status = $license['status'] ?? '';
795
796 return $labels[ $status ] ?? __( 'Unknown', 'gvectors' );
797 }
798 }
799