PluginProbe
wpForo Forum / 3.2.2
wpForo Forum v3.2.2
3.2.2 3.2.1 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 All 142 releases
wpforo / admin / pages / license / src / Services / LicenseService.php

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

832 lines 34.4 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 && self::is_legacy_license( $license ) ) {
309 $this->keep_legacy_license( $product_id, $license );
310 } elseif( $is_terminal ) {
311 $plugin_slug = $license['plugin_slug'] ?? '';
312 $this->remove( $product_id );
313
314 if( $plugin_slug ) {
315 $this->deactivate_and_delete_addon( $plugin_slug );
316
317 $expired_notices = get_option( $this->expired_notice_option, [] );
318 if( isset( $expired_notices[ $plugin_slug ] ) ) {
319 unset( $expired_notices[ $plugin_slug ] );
320 update_option( $this->expired_notice_option, $expired_notices );
321 }
322 $tampered = get_option( $this->tampered_option, [] );
323 if( isset( $tampered[ $plugin_slug ] ) ) {
324 unset( $tampered[ $plugin_slug ] );
325 update_option( $this->tampered_option, $tampered );
326 }
327 }
328 } else {
329 // Non-terminal: update local license with server data
330 $server_data = ( ! empty( $result['data'] ) && is_array( $result['data'] ) ) ? $result['data'] : [];
331 $has_license_fields = ! empty( $server_data['license_key'] ) || ! empty( $server_data['status'] ) || ! empty( $server_data['expires_at'] );
332
333 if( $has_license_fields ) {
334 $updated = wp_parse_args( $server_data, $license );
335 $updated['last_validated'] = time();
336 $this->save( $product_id, $updated );
337 } else {
338 if( $server_status ) {
339 $license['status'] = $server_status;
340 } elseif( $server_reason === 'expired' ) {
341 $license['status'] = 'expired';
342 }
343 $license['last_validated'] = time();
344 $this->save( $product_id, $license );
345 }
346
347 if( in_array( $server_status, [ 'expired', 'cancelled' ], true ) || $server_reason === 'expired' ) {
348 $this->update_expired_notice( $product_id );
349 }
350 }
351 }
352
353 /**
354 * A license migrated from the old gVectors store (legacy activation key).
355 */
356 private static function is_legacy_license( array $license ): bool {
357 return ( $license['plan_name'] ?? '' ) === 'Legacy License';
358 }
359
360 /**
361 * A legacy license the server no longer knows or reports invalid: old-store owners keep using their
362 * addon for life, so the license is kept as expired (no updates) and the addon is never deactivated or deleted.
363 */
364 private function keep_legacy_license( string $product_id, array $license ): void {
365 $license['status'] = 'expired';
366 $license['last_validated'] = time();
367 $this->save( $product_id, $license );
368 $this->update_expired_notice( $product_id );
369 }
370
371 /**
372 * If the server reports a real Paddle product_id different from the local key
373 * (e.g. a migrated legacy license stored under its plugin slug), move the local
374 * license to that key so the store UI treats it like any purchased license.
375 * Returns the key to continue with, or '' when the entry was dropped because an
376 * active license already exists under the server's product_id.
377 */
378 private function sync_product_key( string $product_id, array $result ): string {
379 $server_pid = $result['data']['product_id'] ?? '';
380 if( ! is_string( $server_pid ) || strpos( $server_pid, 'pro_' ) !== 0 || $server_pid === $product_id ) return $product_id;
381
382 $licenses = $this->get_all();
383 if( ! isset( $licenses[ $product_id ] ) ) return $product_id;
384
385 $keep_existing = isset( $licenses[ $server_pid ] ) && $this->is_active( $server_pid );
386 if( ! $keep_existing ) {
387 $licenses[ $server_pid ] = $licenses[ $product_id ];
388 $licenses[ $server_pid ]['product_id'] = $server_pid;
389 }
390 unset( $licenses[ $product_id ] );
391 update_option( $this->option_key, $licenses );
392
393 return $keep_existing ? '' : $server_pid;
394 }
395
396 /**
397 * Remove a license for a product
398 */
399 public function remove( string $product_id ): bool {
400 $licenses = $this->get_all();
401 if( isset( $licenses[ $product_id ] ) ) {
402 unset( $licenses[ $product_id ] );
403
404 return update_option( $this->option_key, $licenses );
405 }
406
407 return true;
408 }
409
410 /**
411 * Deactivate and delete an addon plugin by its slug.
412 * Used when a license is refunded to fully remove the addon.
413 */
414 private function deactivate_and_delete_addon( string $plugin_slug ): bool {
415 if( empty( $plugin_slug ) ) return false;
416
417 if( ! function_exists( 'get_plugins' ) ) {
418 require_once ABSPATH . 'wp-admin/includes/plugin.php';
419 }
420 if( ! function_exists( 'delete_plugins' ) ) {
421 require_once ABSPATH . 'wp-admin/includes/file.php';
422 }
423
424 // Find the installed plugin file
425 $plugin_file = '';
426 $all_plugins = get_plugins();
427 foreach( $all_plugins as $file => $data ) {
428 if( strpos( $file, $plugin_slug . '/' ) === 0 ) {
429 $plugin_file = $file;
430 break;
431 }
432 }
433
434 if( empty( $plugin_file ) ) return false;
435
436 // Deactivate if active
437 if( is_plugin_active( $plugin_file ) ) {
438 deactivate_plugins( $plugin_file );
439 }
440
441 // Delete the plugin files
442 $result = delete_plugins( [ $plugin_file ] );
443
444 return ! is_wp_error( $result );
445 }
446
447 /**
448 * Update the expired license notice for a specific product.
449 */
450 private function update_expired_notice( string $product_id ): void {
451 $license = $this->get( $product_id );
452 if( empty( $license ) ) return;
453
454 $plugin_slug = $license['plugin_slug'] ?? '';
455 if( empty( $plugin_slug ) ) return;
456 // Addon not physically installed — no notice needed
457 if( ! is_dir( WP_PLUGIN_DIR . '/' . $plugin_slug ) ) return;
458
459 $expired_notices = get_option( $this->expired_notice_option, [] );
460 $expired_notices[ $plugin_slug ] = [
461 'product_name' => $license['product_name'] ?? $plugin_slug,
462 'status' => $license['status'] ?? 'expired',
463 'expires_at' => $license['expires_at'] ?? '',
464 'has_update' => false,
465 ];
466 update_option( $this->expired_notice_option, $expired_notices );
467 }
468
469 /**
470 * Revalidate a single license against the proxy server.
471 *
472 * Returns an associative array with:
473 * 'valid' => bool
474 * 'reason' => string (failure reason from server, e.g. 'refunded', 'expired')
475 * 'error' => string (human-readable error message from server)
476 * 'status' => string (license status from server response)
477 * 'removed' => bool (whether the license was removed locally)
478 * 'addon_deleted' => bool (whether the addon plugin was deactivated and deleted - only on refund)
479 */
480 public function revalidate( string $product_id ): array {
481 $license = $this->get( $product_id );
482 if( empty( $license ) || empty( $license['license_key'] ) ) {
483 return [
484 'valid' => false,
485 'reason' => 'missing',
486 'error' => __( 'No license found for this product', 'gvectors' ),
487 'status' => '',
488 'removed' => false,
489 'addon_deleted' => false,
490 ];
491 }
492
493 $response = $this->apiService->validate_license( $license['license_key'] );
494
495 // ── Server/network error — keep current local state, retry later ──
496 // Only trust the response if the server actually processed our request.
497 // Connection failures, HTTP errors (401, 403, 500), and invalid responses
498 // should NOT alter the local license status.
499 $response_code = $response['code'] ?? 0;
500 $is_server_error = (
501 $response_code === 'wp_error' // Connection failure, timeout, DNS error
502 || $response_code === 'invalid_response' // Server returned non-JSON
503 || ( is_int( $response_code ) && ( $response_code >= 500 || $response_code === 401 || $response_code === 403 || $response_code === 429 ) )
504 );
505
506 if( $is_server_error ) {
507 // Don't update last_validated — will retry on next cron run
508 return [
509 'valid' => ! empty( $license['status'] ) && in_array( $license['status'], [ 'active', 'trial' ], true ),
510 'reason' => 'server_unavailable',
511 'error' => $response['error'] ?? 'Server unavailable',
512 'status' => $license['status'] ?? '',
513 'removed' => false,
514 'addon_deleted' => false,
515 ];
516 }
517
518 $product_id = $this->sync_product_key( $product_id, $response );
519 if( $product_id === '' ) {
520 return [ 'valid' => false, 'reason' => 'duplicate', 'error' => '', 'status' => '', 'removed' => true, 'addon_deleted' => false ];
521 }
522
523 // License is valid on the server
524 if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) {
525 $this->save( $product_id, $response['data'] );
526
527 return [
528 'valid' => true,
529 'reason' => '',
530 'error' => '',
531 'status' => $response['data']['status'] ?? 'active',
532 'removed' => false,
533 'addon_deleted' => false,
534 ];
535 }
536
537 // License validation failed - extract details from server response
538 $server_status = $response['data']['status'] ?? '';
539 $server_error = $response['error'] ?? '';
540 $server_reason = '';
541
542 // Extract invalid_reason from response data if available
543 if( ! empty( $response['data']['invalid_reason'] ) ) {
544 $server_reason = $response['data']['invalid_reason'];
545 }
546 // Parse reason from the error message (e.g. "License is invalid: refunded")
547 if( empty( $server_reason ) && preg_match( '/invalid:\s*(.+)$/i', $server_error, $m ) ) {
548 $server_reason = trim( $m[1] );
549 }
550 // If the error indicates expiration, set reason accordingly
551 if( empty( $server_reason ) && stripos( $server_error, 'expired' ) !== false ) {
552 $server_reason = 'expired';
553 }
554 // If the license key was not found on server
555 if( empty( $server_reason ) && stripos( $server_error, 'not found' ) !== false ) {
556 $server_reason = 'not_found';
557 $server_status = $server_status ?: 'invalid';
558 }
559
560 // Determine if this is a terminal state: license removed + addon deactivated + deleted
561 // Terminal = refunded, not found on server, or invalid status
562 $is_terminal = in_array( $server_reason, self::REFUND_REASONS, true )
563 || in_array( $server_status, self::TERMINAL_STATUSES, true )
564 || $server_reason === 'not_found';
565
566 if( $is_terminal && self::is_legacy_license( $license ) ) {
567 $this->keep_legacy_license( $product_id, $license );
568
569 return [
570 'valid' => false,
571 'reason' => $server_reason ?: $server_status,
572 'error' => $server_error,
573 'status' => 'expired',
574 'removed' => false,
575 'addon_deleted' => false,
576 ];
577 }
578
579 $removed = false;
580 $addon_deleted = false;
581
582 if( $is_terminal ) {
583 // Terminal: remove license, deactivate and delete the addon plugin
584 $plugin_slug = $license['plugin_slug'] ?? '';
585 $this->remove( $product_id );
586 $removed = true;
587
588 // Deactivate and delete the addon if installed
589 if( $plugin_slug ) {
590 $addon_deleted = $this->deactivate_and_delete_addon( $plugin_slug );
591
592 // Clean-up-related notices
593 $expired_notices = get_option( $this->expired_notice_option, [] );
594 if( isset( $expired_notices[ $plugin_slug ] ) ) {
595 unset( $expired_notices[ $plugin_slug ] );
596 update_option( $this->expired_notice_option, $expired_notices );
597 }
598 $tampered = get_option( $this->tampered_option, [] );
599 if( isset( $tampered[ $plugin_slug ] ) ) {
600 unset( $tampered[ $plugin_slug ] );
601 update_option( $this->tampered_option, $tampered );
602 }
603 }
604 } else {
605 // Non-terminal failure (expired, cancelled, past_due, paused, etc.)
606 // Overwrite local license with authoritative server data to purge stale values.
607 $server_data = ( ! empty( $response['data'] ) && is_array( $response['data'] ) ) ? $response['data'] : [];
608
609 // Only treat server data as license fields if it has actual license keys
610 // (not just a raw error envelope like {success:false, error:'...'})
611 $has_license_fields = ! empty( $server_data['license_key'] ) || ! empty( $server_data['status'] ) || ! empty( $server_data['expires_at'] );
612
613 if( $has_license_fields ) {
614 // Server returned full license data — use it as the source of truth
615 $updated = wp_parse_args( $server_data, $license );
616 $updated['last_validated'] = time();
617 $this->save( $product_id, $updated );
618 } else {
619 // Server didn't return license fields — force-update status from error context
620 if( $server_status ) {
621 $license['status'] = $server_status;
622 } elseif( $server_reason === 'expired' ) {
623 $license['status'] = 'expired';
624 }
625 $license['last_validated'] = time();
626 $this->save( $product_id, $license );
627 }
628
629 // Trigger expired notice update for this license
630 if( in_array( $server_status, [ 'expired', 'cancelled' ], true ) || $server_reason === 'expired' ) {
631 $this->update_expired_notice( $product_id );
632 }
633 }
634
635 return [
636 'valid' => false,
637 'reason' => $server_reason ?: $server_status,
638 'error' => $server_error,
639 'status' => $server_status,
640 'removed' => $removed,
641 'addon_deleted' => $addon_deleted,
642 ];
643 }
644
645 /**
646 * Revalidate all stored licenses (called by cron).
647 * Uses batch validation to check all licenses in a single API call.
648 */
649 public function maybe_revalidate_all(): void {
650 $licenses = $this->get_all();
651 if( empty( $licenses ) ) return;
652
653 // Collect license keys that need revalidation
654 $keys_to_validate = []; // license_key => product_id
655 foreach( $licenses as $product_id => $license ) {
656 if( $this->needs_revalidation( $product_id ) && ! empty( $license['license_key'] ) ) {
657 $keys_to_validate[ $license['license_key'] ] = $product_id;
658 }
659 }
660
661 if( empty( $keys_to_validate ) ) return;
662
663 $response = $this->apiService->validate_licenses_batch( array_keys( $keys_to_validate ) );
664
665 // Server/network error — don't touch local state, retry on next cron
666 $response_code = $response['code'] ?? 0;
667 $is_server_error = (
668 $response_code === 'wp_error'
669 || $response_code === 'invalid_response'
670 || ( is_int( $response_code ) && ( $response_code >= 500 || $response_code === 401 || $response_code === 403 || $response_code === 429 ) )
671 );
672 if( $is_server_error ) return;
673
674 if( empty( $response['success'] ) || empty( $response['data']['licenses'] ) ) return;
675
676 $batch_results = $response['data']['licenses'];
677
678 // Cache the raw batch results so individual Validate clicks can reuse them
679 set_transient( $this->batch_cache_key, [
680 'time' => time(),
681 'licenses' => $batch_results,
682 ], $this->config->get_license_batch_cache_ttl() );
683
684 foreach( $keys_to_validate as $license_key => $product_id ) {
685 if( ! isset( $batch_results[ $license_key ] ) ) continue;
686
687 $result = $batch_results[ $license_key ];
688 $license = $licenses[ $product_id ];
689 $product_id = $this->sync_product_key( $product_id, $result );
690 if( $product_id === '' ) continue;
691
692 if( ! empty( $result['success'] ) && ! empty( $result['data'] ) ) {
693 // License is valid — update local data
694 $this->save( $product_id, $result['data'] );
695 } else {
696 // License validation failed — process like single "revalidate"
697 $this->process_failed_revalidation( $product_id, $license, $result );
698 }
699 }
700 }
701
702 /**
703 * Check if the license needs revalidation
704 */
705 public function needs_revalidation( string $product_id ): bool {
706 $license = $this->get( $product_id );
707 if( empty( $license ) ) return false;
708 $last = isset( $license['last_validated'] ) ? (int) $license['last_validated'] : 0;
709
710 return ( time() - $last ) > $this->config->get_license_revalidation_period();
711 }
712
713 /**
714 * Unified activation: proxy detects key type (gvl_*, txn_*, legacy, or empty=by-domain).
715 * Saves whatever license(s) the proxy returns into the local gvectors_licenses option.
716 */
717 public function activate_unified( string $key, string $product_id = '' ): array {
718 $response = $this->apiService->activate_unified( $key, $product_id );
719
720 if( ! empty( $response['success'] ) ) {
721 // Transaction / by-domain response: data.activated[] array
722 if( ! empty( $response['data']['activated'] ) ) {
723 foreach( $response['data']['activated'] as $license_data ) {
724 $pid = ! empty( $license_data['product_id'] ) ? $license_data['product_id'] : '';
725 if( $pid ) {
726 $this->save( $pid, $license_data );
727 }
728 }
729 } // Single license response (gvl_* or legacy key): data.license_key present
730 elseif( ! empty( $response['data']['license_key'] ) ) {
731 $pid = ! empty( $response['data']['product_id'] ) ? $response['data']['product_id'] : $product_id;
732 if( $pid ) {
733 $this->save( $pid, $response['data'] );
734 }
735 }
736 }
737
738 return $response;
739 }
740
741 /**
742 * Activate a license key
743 */
744 public function activate( string $license_key, string $product_id = '' ): array {
745 $response = $this->apiService->activate_license( $license_key, $product_id );
746
747 if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) {
748 $pid = ! empty( $response['data']['product_id'] ) ? $response['data']['product_id'] : $product_id;
749 if( $pid ) {
750 $this->save( $pid, $response['data'] );
751 }
752 }
753
754 return $response;
755 }
756
757 /**
758 * Activate licenses by transaction ID
759 */
760 public function activate_by_transaction( string $transaction_id, string $product_id = '' ): array {
761 $response = $this->apiService->activate_license_by_transaction( $transaction_id, $product_id );
762
763 if( ! empty( $response['success'] ) && ! empty( $response['data']['activated'] ) ) {
764 foreach( $response['data']['activated'] as $license_data ) {
765 $pid = ! empty( $license_data['product_id'] ) ? $license_data['product_id'] : '';
766 if( $pid ) {
767 $this->save( $pid, $license_data );
768 }
769 }
770 }
771
772 return $response;
773 }
774
775 /**
776 * Activate all licenses registered for this site's domain
777 */
778 public function activate_by_domain(): array {
779 $response = $this->apiService->activate_licenses_by_domain();
780
781 if( ! empty( $response['success'] ) && ! empty( $response['data']['activated'] ) ) {
782 foreach( $response['data']['activated'] as $license_data ) {
783 $pid = ! empty( $license_data['product_id'] ) ? $license_data['product_id'] : '';
784 if( $pid ) {
785 $this->save( $pid, $license_data );
786 }
787 }
788 }
789
790 return $response;
791 }
792
793 /**
794 * Deactivate a license key
795 */
796 public function deactivate( string $product_id ): array {
797 $license = $this->get( $product_id );
798 if( empty( $license ) || empty( $license['license_key'] ) ) {
799 return [ 'success' => false, 'error' => 'No license found for this product' ];
800 }
801
802 $response = $this->apiService->deactivate_license( $license['license_key'] );
803
804 if( ! empty( $response['success'] ) ) {
805 $this->remove( $product_id );
806 }
807
808 return $response;
809 }
810
811 /**
812 * Get a license status label
813 */
814 public function get_status_label( string $product_id ): string {
815 $license = $this->get( $product_id );
816 if( empty( $license ) ) return __( 'Not Licensed', 'gvectors' );
817
818 $labels = [
819 'active' => __( 'Active', 'gvectors' ),
820 'trial' => __( 'Trial', 'gvectors' ),
821 'expired' => __( 'Expired', 'gvectors' ),
822 'cancelled' => __( 'Cancelled', 'gvectors' ),
823 'past_due' => __( 'Past Due', 'gvectors' ),
824 'paused' => __( 'Paused', 'gvectors' ),
825 ];
826
827 $status = $license['status'] ?? '';
828
829 return $labels[ $status ] ?? __( 'Unknown', 'gvectors' );
830 }
831 }
832