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 / ActionsService.php

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

1,034 lines 49.3 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 // Exit if accessed directly
6
7 use gVectors\License\Config;
8
9 if( ! defined( 'ABSPATH' ) ) exit;
10
11 /**
12 * AJAX action handlers for the Paddle module.
13 * All admin AJAX endpoints for products, checkout, license, addon management.
14 */
15 class ActionsService {
16 public $addonsService;
17 private $config;
18 private $pending_transactions_option_name;
19
20 public function __construct( Config $config, AddonsService $addonsService ) {
21 $this->addonsService = $addonsService;
22 $this->config = $config;
23 $this->pending_transactions_option_name = $this->config->get_core_plugin_slug() . '_gvectors_pending_transactions';
24 $this->init_hooks();
25 }
26
27 private function init_hooks() {
28 $p = 'wp_ajax_' . $this->config->get_core_plugin_slug() . '_gvectors_';
29
30 // Product listing
31 add_action( $p . 'get_products', [ $this, 'ajax_get_products' ] );
32 add_action( $p . 'get_prices', [ $this, 'ajax_get_prices' ] );
33
34 // Checkout
35 add_action( $p . 'check_overlap', [ $this, 'ajax_check_overlap' ] );
36 add_action( $p . 'create_checkout', [ $this, 'ajax_create_checkout' ] );
37 add_action( $p . 'verify_transaction', [ $this, 'ajax_verify_transaction' ] );
38
39 // License
40 add_action( $p . 'activate_license', [ $this, 'ajax_activate_license' ] );
41 add_action( $p . 'activate_by_transaction', [ $this, 'ajax_activate_by_transaction' ] );
42 add_action( $p . 'unified_activate', [ $this, 'ajax_unified_activate' ] );
43 add_action( $p . 'activate_by_domain', [ $this, 'ajax_activate_by_domain' ] );
44 add_action( $p . 'deactivate_license', [ $this, 'ajax_deactivate_license' ] );
45 add_action( $p . 'validate_license', [ $this, 'ajax_validate_license' ] );
46 add_action( $p . 'get_licenses', [ $this, 'ajax_get_licenses' ] );
47
48 // Addons
49 add_action( $p . 'install_addon', [ $this, 'ajax_install_addon' ] );
50 add_action( $p . 'activate_addon', [ $this, 'ajax_activate_addon' ] );
51 add_action( $p . 'deactivate_addon', [ $this, 'ajax_deactivate_addon' ] );
52 add_action( $p . 'install_activate_addon', [ $this, 'ajax_install_activate_addon' ] );
53 add_action( $p . 'download_addon', [ $this, 'ajax_download_addon' ] );
54 add_action( $p . 'install_verified_copy', [ $this, 'ajax_install_verified_copy' ] );
55
56 // Subscription
57 add_action( $p . 'cancel_subscription', [ $this, 'ajax_cancel_subscription' ] );
58 add_action( $p . 'resume_subscription', [ $this, 'ajax_resume_subscription' ] );
59 add_action( $p . 'update_payment', [ $this, 'ajax_update_payment' ] );
60 add_action( $p . 'get_portal_url', [ $this, 'ajax_get_portal_url' ] );
61
62 // Trial
63 add_action( $p . 'start_trial', [ $this, 'ajax_start_trial' ] );
64
65 // Account
66 add_action( $p . 'get_account', [ $this, 'ajax_get_account' ] );
67
68 // Pending transactions
69 add_action( $p . 'save_pending_transaction', [ $this, 'ajax_save_pending_transaction' ] );
70 add_action( $p . 'get_pending_transactions', [ $this, 'ajax_get_pending_transactions' ] );
71 add_action( $p . 'clear_pending_transaction', [ $this, 'ajax_clear_pending_transaction' ] );
72
73 // Timeline
74 add_action( $p . 'get_timeline', [ $this, 'ajax_get_timeline' ] );
75
76 // Cache
77 add_action( $p . 'clear_cache', [ $this, 'ajax_clear_cache' ] );
78
79 // Abandoned checkout recovery: scheduled per-phase checks (shared,
80 // unprefixed hook — a static guard in the handler prevents double
81 // processing when several gVectors plugins carry this module)
82 add_action( 'gvectors_abandoned_checkout_check', [ $this, 'handle_abandoned_check' ], 10, 3 );
83 }
84
85 public function ajax_get_products(): void {
86 if( ! $this->verify_admin() ) return;
87
88 $result = $this->addonsService->licenseService->apiService->get_products();
89 $products = $result['products'] ?? [];
90 $checkout_mode = $result['checkout_mode'] ?? 'both';
91
92 if( ! empty( $products ) ) {
93 // Build a slug-to-product map for resolving bundle addon names
94 $slug_to_name = [];
95 foreach( $products as $p ) {
96 if( $p['is_saas'] ) continue;
97 if( ! empty( $p['plugin_slug'] ) ) {
98 $slug_to_name[ $p['plugin_slug'] ] = $p['name'];
99 }
100 }
101
102 // Enrich with a local license /addon status
103 foreach( $products as $k => &$product ) {
104 if( $product['is_saas'] ) {
105 unset( $products[ $k ] );
106 continue;
107 }
108 $pid = $product['id'];
109 $is_bundle = ! empty( $product['is_bundle'] );
110
111 // Marketing links from the Paddle product custom attributes (empty = no button on the card)
112 $product['product_url'] = $this->product_link( $product, 'product_url' );
113 $product['demo_url'] = $this->product_link( $product, 'demo_url' );
114
115 // For bundle products, check if all included addons are licensed
116 if( $is_bundle && ! empty( $product['bundle_slugs'] ) ) {
117 $bundle_all_licensed = true;
118 $bundle_addon_names = [];
119 foreach( $product['bundle_slugs'] as $bslug ) {
120 $bundle_addon_names[] = $slug_to_name[ $bslug ] ?? $bslug;
121 // Find the individual product for this slug to check its license
122 $slug_licensed = false;
123 foreach( $products as $sp ) {
124 if( ! empty( $sp['plugin_slug'] ) && $sp['plugin_slug'] === $bslug ) {
125 if( $this->addonsService->licenseService->is_active( $sp['id'] ) ) {
126 $slug_licensed = true;
127 }
128 break;
129 }
130 }
131 if( ! $slug_licensed ) {
132 $bundle_all_licensed = false;
133 }
134 }
135 $product['bundle_all_licensed'] = $bundle_all_licensed;
136 $product['bundle_addon_names'] = $bundle_addon_names;
137 }
138
139 $product['license_status'] = $this->addonsService->licenseService->get_status_label( $pid );
140 $product['is_licensed'] = $this->addonsService->licenseService->is_active( $pid );
141 $product['is_trial'] = $this->addonsService->licenseService->is_trial( $pid );
142 $slug = $product['plugin_slug'] ?? '';
143 $product['addon_status'] = ( $slug && ! $is_bundle ) ? $this->addonsService->get_status( $slug ) : 'not_installed';
144
145 // Attach active license info for display and manage modal
146 $license = $this->addonsService->licenseService->get( $pid );
147 $product['active_plan_name'] = ! empty( $license['plan_name'] ) ? $license['plan_name'] : '';
148 $product['active_price_formatted'] = '';
149 $product['active_price_interval'] = '';
150
151 // Pass full license details for the manage modal
152 $product['license_key'] = ! empty( $license['license_key'] ) ? $license['license_key'] : '';
153 $product['plan_name'] = ! empty( $license['plan_name'] ) ? $license['plan_name'] : '';
154 $product['subscription_id'] = ! empty( $license['subscription_id'] ) ? $license['subscription_id'] : '';
155 $product['customer_id'] = ! empty( $license['customer_id'] ) ? $license['customer_id'] : '';
156 $product['expires_at'] = ! empty( $license['expires_at'] ) ? $license['expires_at'] : '';
157 $product['activated_at'] = ! empty( $license['activated_at'] ) ? $license['activated_at'] : '';
158 $product['max_sites'] = ! empty( $license['max_sites'] ) ? (int) $license['max_sites'] : 0;
159 $product['activated_sites'] = ! empty( $license['activated_sites'] ) ? $license['activated_sites'] : [];
160 $product['product_name'] = ! empty( $license['product_name'] ) ? $license['product_name'] : '';
161 $product['last_validated'] = ! empty( $license['last_validated'] ) ? (int) $license['last_validated'] : 0;
162
163 if( ( $product['is_licensed'] || $product['is_trial'] ) && ! empty( $product['prices'] ) ) {
164 // Try to find the matching price by plan_name or use the first price
165 $matched_price = null;
166 if( ! empty( $license['plan_name'] ) ) {
167 foreach( $product['prices'] as $pr ) {
168 if( isset( $pr['name'] ) && $pr['name'] === $license['plan_name'] ) {
169 $matched_price = $pr;
170 break;
171 }
172 }
173 }
174 if( ! $matched_price ) {
175 $matched_price = $product['prices'][0];
176 }
177 $product['active_price_formatted'] = $matched_price['formatted_price'] ?? '';
178 $product['active_price_interval'] = $matched_price['interval_label'] ?? '';
179 }
180 }
181 unset( $product );
182
183 // Probe the filesystem only when a licensed addon still needs installing (the probe writes a temp file)
184 $needs_install = false;
185 foreach( $products as $product ) {
186 if( ( $product['is_licensed'] || $product['is_trial'] ) && empty( $product['is_bundle'] ) && $product['addon_status'] === 'not_installed' ) {
187 $needs_install = true;
188 break;
189 }
190 }
191 $can_install = ! $needs_install || $this->addonsService->can_install_addons();
192 foreach( $products as &$product ) {
193 $product['can_install'] = $can_install;
194 // Download ZIP is offered only when WordPress can't install a licensed addon (admin.js also sets it after an install fails for that reason)
195 $product['offer_download'] = ! $can_install && ( $product['is_licensed'] || $product['is_trial'] ) && empty( $product['is_bundle'] ) && $product['addon_status'] === 'not_installed';
196 // "Reinstall Addon" for every installed licensed addon; emphasized when the installed copy isn't verified
197 $product['offer_verified_copy'] = ( $product['is_licensed'] || $product['is_trial'] ) && empty( $product['is_bundle'] ) && $product['addon_status'] !== 'not_installed';
198 $product['needs_verified_copy'] = $product['offer_verified_copy'] && $this->addonsService->needs_verified_copy( $product['plugin_slug'] ?? '' );
199 }
200 unset( $product );
201
202 $products = array_values( $products );
203 wp_send_json_success( [ 'products' => $products, 'checkout_mode' => $checkout_mode ] );
204 } else {
205 wp_send_json_error( [ 'message' => __( 'Failed to load products', 'gvectors' ) ] );
206 }
207 }
208
209 /**
210 * A link from the product's Paddle custom data (http/https only) — '' when missing, empty or not a web URL.
211 */
212 private function product_link( array $product, string $key ): string {
213 $url = $product['custom_data'][ $key ] ?? '';
214 if( ! is_string( $url ) || ( $url = trim( $url ) ) === '' ) return '';
215
216 return esc_url_raw( $url, [ 'http', 'https' ] );
217 }
218
219 private function verify_admin(): bool {
220 if( ! current_user_can( 'administrator' ) ) {
221 wp_send_json_error( [ 'message' => __( 'Permission denied', 'gvectors' ) ] );
222
223 return false;
224 }
225 if( ! check_ajax_referer( $this->config->get_core_plugin_slug() . '_gvectors_nonce', 'nonce', false ) ) {
226 wp_send_json_error( [ 'message' => __( 'Security check failed', 'gvectors' ) ] );
227
228 return false;
229 }
230
231 return true;
232 }
233
234 // ==========================================
235 // Products
236 // ==========================================
237
238 public function ajax_get_prices(): void {
239 if( ! $this->verify_admin() ) return;
240
241 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
242 if( empty( $product_id ) ) {
243 wp_send_json_error( [ 'message' => __( 'Product ID required', 'gvectors' ) ] );
244
245 return;
246 }
247
248 $prices = $this->addonsService->licenseService->apiService->get_prices( $product_id );
249 if( ! empty( $prices ) ) {
250 wp_send_json_success( $prices );
251 } else {
252 wp_send_json_error( [ 'message' => __( 'Failed to load prices', 'gvectors' ) ] );
253 }
254 }
255
256 public function ajax_check_overlap(): void {
257 if( ! $this->verify_admin() ) return;
258
259 $price_id = isset( $_POST['price_id'] ) ? sanitize_text_field( $_POST['price_id'] ) : '';
260 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
261
262 if( empty( $price_id ) || empty( $product_id ) ) {
263 wp_send_json_success( [ 'overlap_type' => 'none', 'message' => '', 'overlapping_licenses' => [] ] );
264
265 return;
266 }
267
268 $response = $this->addonsService->licenseService->apiService->check_overlap( $price_id, $product_id );
269 if( ! empty( $response['success'] ) ) {
270 wp_send_json_success( $response['data'] );
271 } else {
272 $error = $response['error'] ?? __( 'Failed to check overlap', 'gvectors' );
273 wp_send_json_error( [ 'message' => $error ] );
274 }
275 }
276
277 // ==========================================
278 // Checkout
279 // ==========================================
280
281 public function ajax_create_checkout(): void {
282 if( ! $this->verify_admin() ) return;
283
284 $price_id = isset( $_POST['price_id'] ) ? sanitize_text_field( $_POST['price_id'] ) : '';
285 if( empty( $price_id ) ) {
286 wp_send_json_error( [ 'message' => __( 'Price ID required', 'gvectors' ) ] );
287
288 return;
289 }
290
291 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
292
293 $custom_data = [];
294 $checkout_options = null;
295 if( ! empty( $_POST['checkout_options'] ) && is_array( $_POST['checkout_options'] ) ) {
296 $checkout_options = $this->sanitize_checkout_options( $_POST['checkout_options'] );
297 }
298
299 $response = $this->addonsService->licenseService->apiService->create_checkout( $price_id, $product_id, $custom_data, $checkout_options );
300 if( ! empty( $response['success'] ) ) {
301 $this->schedule_abandoned_checks( is_array( $response['data'] ?? null ) ? $response['data'] : [] );
302 wp_send_json_success( $response['data'] );
303 } else {
304 $error = $response['error'] ?? __( 'Failed to create checkout', 'gvectors' );
305 wp_send_json_error( [ 'message' => $error ] );
306 }
307 }
308
309 /**
310 * Abandoned checkout recovery: schedule one check per phase the proxy
311 * announced in the checkout response (`abandoned_phases`, minute offsets
312 * from ABANDONED_CHECKOUT_PHASES — empty when the feature is disabled,
313 * so nothing is ever scheduled).
314 *
315 * The purchaser's user id rides along in the event args: it is known HERE,
316 * at creation time, and must not depend on the pending-transactions entry
317 * (saved by a later AJAX call the buyer may never reach).
318 */
319 private function schedule_abandoned_checks( array $data ): void {
320 if( empty( $data['transaction_id'] ) || empty( $data['abandoned_phases'] ) || ! is_array( $data['abandoned_phases'] ) ) return;
321
322 $transaction_id = sanitize_text_field( (string) $data['transaction_id'] );
323 $phases = [];
324 foreach( $data['abandoned_phases'] as $minutes ) {
325 $minutes = (int) $minutes;
326 if( $minutes < 1 || $minutes > 20160 ) continue; // 1 min .. 14 days
327 $phases[] = $minutes;
328 $args = [ $transaction_id, $minutes, get_current_user_id() ];
329 // Reused pending transactions (15-min checkout reuse) would schedule
330 // identical events — args-exact dedup keeps one check per phase.
331 if( ! wp_next_scheduled( 'gvectors_abandoned_checkout_check', $args ) ) {
332 wp_schedule_single_event( time() + $minutes * MINUTE_IN_SECONDS, 'gvectors_abandoned_checkout_check', $args );
333 }
334 }
335
336 // Persist the phases in the pending entry: WP-Cron single events can be
337 // lost (parallel requests racing on the cron option, or consumed by a
338 // failed run) — heal_abandoned_checks() re-creates missing ones from
339 // this record on every dashboard load.
340 if( ! empty( $phases ) ) {
341 $pending = get_option( $this->pending_transactions_option_name, [] );
342 if( ! is_array( $pending ) ) $pending = [];
343 $entry = isset( $pending[ $transaction_id ] ) && is_array( $pending[ $transaction_id ] ) ? $pending[ $transaction_id ] : [];
344 $pending[ $transaction_id ] = [
345 'time' => (int) ( $entry['time'] ?? time() ),
346 'user_id' => (int) ( $entry['user_id'] ?? get_current_user_id() ),
347 'phases' => $phases,
348 ];
349 update_option( $this->pending_transactions_option_name, $pending );
350 }
351 }
352
353 /**
354 * Self-healing for abandoned-checkout checks (mirrors the news module's
355 * self-healing cron scheduler): pending entries remember their phases, so
356 * any check event that went missing — WP cron-array write races between
357 * parallel dashboard requests, or an event consumed by a run that errored
358 * before emailing — is re-scheduled here. Runs on every dashboard load via
359 * ajax_get_pending_transactions; overdue phases are scheduled to fire in
360 * ~2 minutes (the news module's per-phase dedup makes re-runs harmless).
361 */
362 private function heal_abandoned_checks( array $pending ): void {
363 foreach( $pending as $transaction_id => $entry ) {
364 if( ! is_array( $entry ) || empty( $entry['phases'] ) || ! is_array( $entry['phases'] ) ) continue;
365 $created = (int) ( $entry['time'] ?? 0 );
366 $user_id = (int) ( $entry['user_id'] ?? 0 );
367 if( $created <= 0 ) continue;
368
369 foreach( $entry['phases'] as $minutes ) {
370 $minutes = (int) $minutes;
371 if( $minutes < 1 || $minutes > 20160 ) continue;
372 $args = [ (string) $transaction_id, $minutes, $user_id ];
373 if( wp_next_scheduled( 'gvectors_abandoned_checkout_check', $args ) ) continue;
374
375 $due = $created + $minutes * MINUTE_IN_SECONDS;
376 if( $due <= time() ) {
377 // The event is gone AND its time has passed: it may have been
378 // lost before running, or run without sending. Re-fire soon —
379 // proxy status + email dedup decide whether anything happens.
380 $due = time() + 2 * MINUTE_IN_SECONDS;
381 }
382 wp_schedule_single_event( $due, 'gvectors_abandoned_checkout_check', $args );
383 }
384 }
385 }
386
387 /**
388 * Scheduled abandoned-checkout check: ask the proxy whether the transaction
389 * is still unpaid. The proxy is authoritative (it also attaches the phase
390 * discount and builds the email); if still pending, hand the email to the
391 * news module via the shared action — it emails the recorded purchaser.
392 */
393 public function handle_abandoned_check( $transaction_id, $minutes, $purchaser_id = 0 ): void {
394 $transaction_id = sanitize_text_field( (string) $transaction_id );
395 $minutes = (int) $minutes;
396 if( $transaction_id === '' || $minutes <= 0 ) return;
397
398 // Every gVectors plugin's license module listens on this shared hook —
399 // process each (transaction, phase) once per request.
400 static $handled = [];
401 $key = $transaction_id . ':' . $minutes;
402 if( isset( $handled[ $key ] ) ) return;
403 $handled[ $key ] = true;
404
405 $response = $this->addonsService->licenseService->apiService->check_abandoned( $transaction_id, $minutes );
406 if( empty( $response['success'] ) ) return;
407
408 $data = is_array( $response['data'] ?? null ) ? $response['data'] : [];
409 if( ( $data['status'] ?? '' ) !== 'pending' || empty( $data['email']['body_html'] ) ) return;
410
411 do_action( 'gvectors_abandoned_checkout_email', $transaction_id, $data['email'], (int) $purchaser_id, $minutes, $this->config->get_core_plugin_slug() );
412 }
413
414 /**
415 * Sanitize the checkout_options structure from JS.
416 */
417 private function sanitize_checkout_options( array $raw ): array {
418 $options = [
419 'site_domain' => sanitize_text_field( $raw['site_domain'] ?? '' ),
420 'decided_at' => sanitize_text_field( $raw['decided_at'] ?? '' ),
421 'overlap_type' => sanitize_text_field( $raw['overlap_type'] ?? '' ),
422 'subscriptions' => [],
423 ];
424
425 if( ! empty( $raw['subscriptions'] ) && is_array( $raw['subscriptions'] ) ) {
426 foreach( $raw['subscriptions'] as $sub_id => $sub ) {
427 if( ! is_array( $sub ) ) continue;
428 $clean_sub_id = sanitize_text_field( $sub_id );
429 $options['subscriptions'][ $clean_sub_id ] = [
430 'action' => sanitize_text_field( $sub['action'] ?? '' ),
431 'product_id' => sanitize_text_field( $sub['product_id'] ?? '' ),
432 'product_name' => sanitize_text_field( $sub['product_name'] ?? '' ),
433 'price_id' => sanitize_text_field( $sub['price_id'] ?? '' ),
434 'license_keys' => ! empty( $sub['license_keys'] ) && is_array( $sub['license_keys'] )
435 ? array_map( 'sanitize_text_field', $sub['license_keys'] )
436 : [],
437 'deactivate_keys' => ! empty( $sub['deactivate_keys'] ) && is_array( $sub['deactivate_keys'] )
438 ? array_map( 'sanitize_text_field', $sub['deactivate_keys'] )
439 : [],
440 ];
441 }
442 }
443
444 return $options;
445 }
446
447 public function ajax_verify_transaction(): void {
448 if( ! $this->verify_admin() ) return;
449
450 $transaction_id = isset( $_POST['transaction_id'] ) ? sanitize_text_field( $_POST['transaction_id'] ) : '';
451 if( empty( $transaction_id ) ) {
452 wp_send_json_error( [ 'message' => __( 'Transaction ID required', 'gvectors' ) ] );
453
454 return;
455 }
456
457 $response = $this->addonsService->licenseService->apiService->verify_transaction( $transaction_id );
458 if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) {
459 $data = $response['data'];
460
461 // If completed, save the license(s) locally
462 if( $data['status'] === 'completed' ) {
463 $saved_licenses = [];
464 // Bundle: multiple licenses
465 if( ! empty( $data['is_bundle'] ) && ! empty( $data['licenses'] ) ) {
466 foreach( $data['licenses'] as $license ) {
467 $pid = ! empty( $license['product_id'] ) ? $license['product_id'] : '';
468 if( $pid ) {
469 $this->addonsService->licenseService->save( $pid, $license );
470 $saved_licenses[] = $license;
471 }
472 }
473 } elseif( ! empty( $data['license'] ) ) {
474 // Single license
475 $license = $data['license'];
476 $pid = ! empty( $license['product_id'] ) ? $license['product_id'] : '';
477 if( $pid ) {
478 $this->addonsService->licenseService->save( $pid, $license );
479 $saved_licenses[] = $license;
480 }
481 }
482
483 /**
484 * Fires after a completed checkout transaction activated license(s)
485 * on this site (dashboard polling of pending transactions).
486 * The gVectors News module listens to send the purchase/keys
487 * confirmation email to the purchasing administrator.
488 *
489 * @param string $transaction_id Paddle transaction id
490 * @param array $saved_licenses License data arrays (license_key, product_name, expires_at, ...)
491 * @param string $core_slug Host plugin slug (wpforo, wpdiscuz, ...)
492 * @param int $purchaser_id User id of the admin who opened the checkout
493 */
494 if( ! empty( $saved_licenses ) ) {
495 // The verification polling may be executed by ANY admin whose
496 // dashboard is open — resolve the REAL purchaser from the
497 // pending-transactions record (captured at checkout time).
498 // Fallback: the current user (covers the same-session flow).
499 $pending = get_option( $this->pending_transactions_option_name, [] );
500 $pending_entry = is_array( $pending ) ? ( $pending[ $transaction_id ] ?? null ) : null;
501 $purchaser_id = is_array( $pending_entry ) && ! empty( $pending_entry['user_id'] )
502 ? (int) $pending_entry['user_id']
503 : get_current_user_id();
504
505 do_action( 'gvectors_transaction_licenses_activated', $transaction_id, $saved_licenses, $this->config->get_core_plugin_slug(), $purchaser_id );
506 }
507 }
508
509 wp_send_json_success( $data );
510 } else {
511 $error = $response['error'] ?? __( 'Failed to verify transaction', 'gvectors' );
512 wp_send_json_error( [ 'message' => $error ] );
513 }
514 }
515
516 // ==========================================
517 // License
518 // ==========================================
519
520 public function ajax_activate_license(): void {
521 if( ! $this->verify_admin() ) return;
522
523 $license_key = isset( $_POST['license_key'] ) ? sanitize_text_field( $_POST['license_key'] ) : '';
524 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
525
526 if( empty( $license_key ) ) {
527 wp_send_json_error( [ 'message' => __( 'License key required', 'gvectors' ) ] );
528
529 return;
530 }
531
532 $response = $this->addonsService->licenseService->activate( $license_key, $product_id );
533 if( ! empty( $response['success'] ) ) {
534 // Revalidate all licenses to clear expired notices for newly purchased/renewed addons
535 $this->addonsService->check_all_license_validity();
536 $this->addonsService->reverify_licensed_addons();
537 wp_send_json_success( [
538 'license' => $response['data'],
539 'message' => self::activated_message( $response['data'] ),
540 ] );
541 } else {
542 $error = $response['error'] ?? __( 'Failed to activate license', 'gvectors' );
543 wp_send_json_error( [ 'message' => $error ] );
544 }
545 }
546
547 public function ajax_activate_by_transaction(): void {
548 if( ! $this->verify_admin() ) return;
549
550 $transaction_id = isset( $_POST['transaction_id'] ) ? sanitize_text_field( $_POST['transaction_id'] ) : '';
551 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
552
553 if( empty( $transaction_id ) ) {
554 wp_send_json_error( [ 'message' => __( 'Transaction ID required', 'gvectors' ) ] );
555
556 return;
557 }
558
559 $response = $this->addonsService->licenseService->activate_by_transaction( $transaction_id, $product_id );
560 if( ! empty( $response['success'] ) ) {
561 $this->addonsService->check_all_license_validity();
562 $this->addonsService->reverify_licensed_addons();
563 wp_send_json_success( [
564 'activated' => $response['data']['activated'] ?? [],
565 'errors' => $response['data']['errors'] ?? [],
566 'message' => $response['data']['message'] ?? __( 'Licenses activated successfully', 'gvectors' ),
567 ] );
568 } else {
569 $error = $response['error'] ?? __( 'Failed to activate licenses by transaction', 'gvectors' );
570 wp_send_json_error( [ 'message' => $error ] );
571 }
572 }
573
574 public function ajax_unified_activate(): void {
575 if( ! $this->verify_admin() ) return;
576
577 // Empty key = activate-by-domain (proxy auto-detects all key types)
578 $key = isset( $_POST['key'] ) ? sanitize_text_field( $_POST['key'] ) : '';
579 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
580
581 $response = $this->addonsService->licenseService->activate_unified( $key, $product_id );
582
583 if( ! empty( $response['success'] ) ) {
584 $this->addonsService->check_all_license_validity();
585 $this->addonsService->reverify_licensed_addons();
586 // Multi-license response (txn_* or by-domain)
587 if( ! empty( $response['data']['activated'] ) ) {
588 wp_send_json_success( [
589 'activated' => $response['data']['activated'],
590 'errors' => $response['data']['errors'] ?? [],
591 'message' => $response['data']['message'] ?? __( 'Licenses activated successfully', 'gvectors' ),
592 ] );
593 } else {
594 // Single license response (gvl_* or legacy key)
595 wp_send_json_success( [
596 'license' => $response['data'],
597 'message' => self::activated_message( $response['data'] ),
598 ] );
599 }
600
601 return;
602 }
603
604 wp_send_json_error( [ 'message' => $response['error'] ?? __( 'Could not activate. Please check your key.', 'gvectors' ) ] );
605 }
606
607 /**
608 * Success message for a single activated license. An expired old-store key is still linked to the site
609 * (the addon keeps working), but it grants no updates until renewed.
610 */
611 private static function activated_message( $license ): string {
612 if( is_array( $license ) && ( $license['status'] ?? '' ) === 'expired' ) {
613 return __( 'Your license was linked to this site. It has expired: the addon keeps working, renew it to get updates.', 'gvectors' );
614 }
615
616 return __( 'License activated successfully', 'gvectors' );
617 }
618
619 public function ajax_activate_by_domain(): void {
620 if( ! $this->verify_admin() ) return;
621
622 $response = $this->addonsService->licenseService->activate_by_domain();
623 if( ! empty( $response['success'] ) ) {
624 $this->addonsService->check_all_license_validity();
625 $this->addonsService->reverify_licensed_addons();
626 wp_send_json_success( [
627 'activated' => $response['data']['activated'] ?? [],
628 'errors' => $response['data']['errors'] ?? [],
629 'message' => $response['data']['message'] ?? __( 'Licenses activated successfully', 'gvectors' ),
630 ] );
631 } else {
632 wp_send_json_error( [ 'message' => $response['error'] ?? __( 'No licenses found for this domain', 'gvectors' ) ] );
633 }
634 }
635
636 public function ajax_deactivate_license(): void {
637 if( ! $this->verify_admin() ) return;
638
639 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
640 if( empty( $product_id ) ) {
641 wp_send_json_error( [ 'message' => __( 'Product ID required', 'gvectors' ) ] );
642
643 return;
644 }
645
646 $response = $this->addonsService->licenseService->deactivate( $product_id );
647 if( ! empty( $response['success'] ) ) {
648 wp_send_json_success( [ 'message' => __( 'License deactivated', 'gvectors' ) ] );
649 } else {
650 $error = $response['error'] ?? __( 'Failed to deactivate license', 'gvectors' );
651 wp_send_json_error( [ 'message' => $error ] );
652 }
653 }
654
655 public function ajax_validate_license(): void {
656 if( ! $this->verify_admin() ) return;
657
658 // Batch-validates ALL licenses in one API call (or uses cache).
659 // product_id is optional — when omitted, returns a summary for all.
660 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
661 $result = $this->addonsService->licenseService->validate_with_cache( $product_id );
662
663 $all_licenses = $result['all_licenses'] ?? $this->addonsService->licenseService->get_all();
664
665 // ── Server unavailable — local state preserved ──
666 if( $result['reason'] === 'server_unavailable' ) {
667 wp_send_json_success( [
668 'valid' => false,
669 'reason' => 'server_unavailable',
670 'message' => __( 'Could not reach the license server. Your current license status is preserved. Will retry automatically.', 'gvectors' ),
671 'all_licenses' => $all_licenses,
672 ] );
673
674 return;
675 }
676
677 // Build response message
678 if( $result['valid'] ) {
679 $message = __( 'All licenses validated successfully', 'gvectors' );
680 } elseif( $result['reason'] === 'some_invalid' ) {
681 $message = __( 'Validation complete. Some licenses have issues — check the status column below.', 'gvectors' );
682 } else {
683 $message = __( 'All licenses validated', 'gvectors' );
684 }
685
686 wp_send_json_success( [
687 'valid' => $result['valid'],
688 'reason' => $result['reason'],
689 'message' => $message,
690 'all_licenses' => $all_licenses,
691 'cached' => ! empty( $result['cached'] ),
692 ] );
693 }
694
695 public function ajax_get_licenses(): void {
696 if( ! $this->verify_admin() ) return;
697 wp_send_json_success( $this->addonsService->licenseService->get_all() );
698 }
699
700 // ==========================================
701 // Addons
702 // ==========================================
703
704 public function ajax_install_addon(): void {
705 if( ! $this->verify_admin() ) return;
706
707 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
708 if( empty( $product_id ) ) {
709 wp_send_json_error( [ 'message' => __( 'Product ID required', 'gvectors' ) ] );
710
711 return;
712 }
713
714 $result = $this->addonsService->install( $product_id );
715 if( ! empty( $result['success'] ) ) {
716 wp_send_json_success( $result );
717 } else {
718 wp_send_json_error( [
719 'message' => $result['error'] ?? __( 'Installation failed', 'gvectors' ),
720 'manual_install' => ! empty( $result['manual_install'] ),
721 ] );
722 }
723 }
724
725 public function ajax_activate_addon(): void {
726 if( ! $this->verify_admin() ) return;
727
728 $plugin_file = isset( $_POST['plugin_file'] ) ? sanitize_text_field( $_POST['plugin_file'] ) : '';
729 if( empty( $plugin_file ) ) {
730 wp_send_json_error( [ 'message' => __( 'Plugin file required', 'gvectors' ) ] );
731
732 return;
733 }
734
735 $result = $this->addonsService->activate( $plugin_file );
736 if( ! empty( $result['success'] ) ) {
737 wp_send_json_success( $result );
738 } else {
739 wp_send_json_error( [ 'message' => $result['error'] ?? __( 'Activation failed', 'gvectors' ) ] );
740 }
741 }
742
743 public function ajax_deactivate_addon(): void {
744 if( ! $this->verify_admin() ) return;
745
746 $plugin_file = isset( $_POST['plugin_file'] ) ? sanitize_text_field( $_POST['plugin_file'] ) : '';
747 if( empty( $plugin_file ) ) {
748 wp_send_json_error( [ 'message' => __( 'Plugin file required', 'gvectors' ) ] );
749
750 return;
751 }
752
753 $result = $this->addonsService->deactivate_addon( $plugin_file );
754 if( ! empty( $result['success'] ) ) {
755 wp_send_json_success( $result );
756 } else {
757 wp_send_json_error( [ 'message' => $result['error'] ?? __( 'Deactivation failed', 'gvectors' ) ] );
758 }
759 }
760
761 public function ajax_install_activate_addon(): void {
762 if( ! $this->verify_admin() ) return;
763
764 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
765 if( empty( $product_id ) ) {
766 wp_send_json_error( [ 'message' => __( 'Product ID required', 'gvectors' ) ] );
767
768 return;
769 }
770
771 $result = $this->addonsService->install_and_activate( $product_id );
772 if( ! empty( $result['success'] ) ) {
773 wp_send_json_success( $result );
774 } else {
775 wp_send_json_error( [
776 'message' => $result['error'] ?? __( 'Install & activate failed', 'gvectors' ),
777 'manual_install' => ! empty( $result['manual_install'] ),
778 ] );
779 }
780 }
781
782 /**
783 * "Reinstall Addon": replace an installed licensed addon with a fresh copy signed for this site
784 */
785 public function ajax_install_verified_copy(): void {
786 if( ! $this->verify_admin() ) return;
787
788 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
789 if( empty( $product_id ) ) {
790 wp_send_json_error( [ 'message' => __( 'Product ID required', 'gvectors' ) ] );
791
792 return;
793 }
794
795 $result = $this->addonsService->install_verified_copy( $product_id );
796 if( ! empty( $result['success'] ) ) {
797 wp_send_json_success( $result );
798 } else {
799 wp_send_json_error( [
800 'message' => $result['error'] ?? __( 'Could not reinstall the addon', 'gvectors' ),
801 'manual_install' => ! empty( $result['manual_install'] ),
802 ] );
803 }
804 }
805
806 /**
807 * Signed addon ZIP URL for a manual (FTP) install — the browser downloads it straight from the proxy
808 */
809 public function ajax_download_addon(): void {
810 if( ! $this->verify_admin() ) return;
811
812 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
813 if( empty( $product_id ) ) {
814 wp_send_json_error( [ 'message' => __( 'Product ID required', 'gvectors' ) ] );
815
816 return;
817 }
818
819 $result = $this->addonsService->get_download_link( $product_id );
820 if( ! empty( $result['success'] ) ) {
821 wp_send_json_success( $result );
822 } else {
823 wp_send_json_error( [ 'message' => $result['error'] ?? __( 'Failed to get download URL', 'gvectors' ) ] );
824 }
825 }
826
827 // ==========================================
828 // Subscription
829 // ==========================================
830
831 public function ajax_cancel_subscription(): void {
832 if( ! $this->verify_admin() ) return;
833
834 $subscription_id = isset( $_POST['subscription_id'] ) ? sanitize_text_field( $_POST['subscription_id'] ) : '';
835 if( empty( $subscription_id ) ) {
836 wp_send_json_error( [ 'message' => __( 'Subscription ID required', 'gvectors' ) ] );
837
838 return;
839 }
840
841 $response = $this->addonsService->licenseService->apiService->cancel_subscription( $subscription_id );
842 if( ! empty( $response['success'] ) ) {
843 wp_send_json_success( [ 'message' => __( 'Subscription will be cancelled at the end of the billing period', 'gvectors' ) ] );
844 } else {
845 $error = $response['error'] ?? __( 'Failed to cancel subscription', 'gvectors' );
846 wp_send_json_error( [ 'message' => $error ] );
847 }
848 }
849
850 public function ajax_resume_subscription(): void {
851 if( ! $this->verify_admin() ) return;
852
853 $subscription_id = isset( $_POST['subscription_id'] ) ? sanitize_text_field( $_POST['subscription_id'] ) : '';
854 if( empty( $subscription_id ) ) {
855 wp_send_json_error( [ 'message' => __( 'Subscription ID required', 'gvectors' ) ] );
856
857 return;
858 }
859
860 $response = $this->addonsService->licenseService->apiService->resume_subscription( $subscription_id );
861 if( ! empty( $response['success'] ) ) {
862 wp_send_json_success( [ 'message' => __( 'Subscription resumed', 'gvectors' ) ] );
863 } else {
864 $error = $response['error'] ?? __( 'Failed to resume subscription', 'gvectors' );
865 wp_send_json_error( [ 'message' => $error ] );
866 }
867 }
868
869 public function ajax_update_payment(): void {
870 if( ! $this->verify_admin() ) return;
871
872 $subscription_id = isset( $_POST['subscription_id'] ) ? sanitize_text_field( $_POST['subscription_id'] ) : '';
873 if( empty( $subscription_id ) ) {
874 wp_send_json_error( [ 'message' => __( 'Subscription ID required', 'gvectors' ) ] );
875
876 return;
877 }
878
879 $response = $this->addonsService->licenseService->apiService->update_subscription_payment( $subscription_id );
880 if( ! empty( $response['success'] ) ) {
881 wp_send_json_success( $response['data'] );
882 } else {
883 $error = $response['error'] ?? __( 'Failed to get update URL', 'gvectors' );
884 wp_send_json_error( [ 'message' => $error ] );
885 }
886 }
887
888 public function ajax_get_portal_url(): void {
889 if( ! $this->verify_admin() ) return;
890
891 $subscription_id = isset( $_POST['subscription_id'] ) ? sanitize_text_field( $_POST['subscription_id'] ) : '';
892 if( empty( $subscription_id ) ) {
893 wp_send_json_error( [ 'message' => __( 'Subscription ID required', 'gvectors' ) ] );
894
895 return;
896 }
897
898 $response = $this->addonsService->licenseService->apiService->get_subscription_portal_url( $subscription_id );
899 if( ! empty( $response['success'] ) && ! empty( $response['data']['portal_url'] ) ) {
900 wp_send_json_success( [ 'portal_url' => $response['data']['portal_url'] ] );
901 } else {
902 $error = $response['error'] ?? __( 'Failed to get portal URL', 'gvectors' );
903 wp_send_json_error( [ 'message' => $error ] );
904 }
905 }
906
907 // ==========================================
908 // Trial
909 // ==========================================
910
911 public function ajax_start_trial(): void {
912 if( ! $this->verify_admin() ) return;
913
914 $product_id = isset( $_POST['product_id'] ) ? sanitize_text_field( $_POST['product_id'] ) : '';
915 if( empty( $product_id ) ) {
916 wp_send_json_error( [ 'message' => __( 'Product ID required', 'gvectors' ) ] );
917
918 return;
919 }
920
921 $response = $this->addonsService->licenseService->apiService->start_trial( $product_id );
922 if( ! empty( $response['success'] ) ) {
923 // Store trial license locally
924 if( ! empty( $response['data'] ) ) {
925 $this->addonsService->licenseService->save( $product_id, $response['data'] );
926 }
927 wp_send_json_success( [
928 'message' => __( 'Trial started successfully', 'gvectors' ),
929 'data' => $response['data'],
930 ] );
931 } else {
932 $error = $response['error'] ?? __( 'Failed to start trial', 'gvectors' );
933 wp_send_json_error( [ 'message' => $error ] );
934 }
935 }
936
937 // ==========================================
938 // Account
939 // ==========================================
940
941 public function ajax_get_account(): void {
942 if( ! $this->verify_admin() ) return;
943
944 $response = $this->addonsService->licenseService->apiService->get_account();
945 if( ! empty( $response['success'] ) ) {
946 wp_send_json_success( $response['data'] );
947 } else {
948 $error = $response['error'] ?? __( 'Failed to load account', 'gvectors' );
949 wp_send_json_error( [ 'message' => $error ] );
950 }
951 }
952
953 // ==========================================
954 // Cache
955 // ==========================================
956
957 public function ajax_get_pending_transactions(): void {
958 if( ! $this->verify_admin() ) return;
959
960 $pending = get_option( $this->pending_transactions_option_name, [] );
961 if( ! is_array( $pending ) ) $pending = [];
962
963 // Remove entries older than 24 hours (entries are ['time'=>..,'user_id'=>..],
964 // legacy entries may be a plain timestamp)
965 $cutoff = time() - 86400;
966 $pending = array_filter( $pending, function( $entry ) use ( $cutoff ) {
967 $ts = is_array( $entry ) ? (int) ( $entry['time'] ?? 0 ) : (int) $entry;
968 return $ts > $cutoff;
969 } );
970 update_option( $this->pending_transactions_option_name, $pending );
971
972 // Re-create any abandoned-checkout check events that got lost
973 $this->heal_abandoned_checks( $pending );
974
975 wp_send_json_success( array_keys( $pending ) );
976 }
977
978 public function ajax_clear_pending_transaction(): void {
979 $this->ajax_save_pending_transaction( true );
980 }
981
982 public function ajax_save_pending_transaction( bool $shouldClear = false ): void {
983 if( ! $this->verify_admin() ) return;
984
985 $transaction_id = isset( $_POST['transaction_id'] ) ? sanitize_text_field( $_POST['transaction_id'] ) : '';
986 if( empty( $transaction_id ) ) {
987 wp_send_json_error( [ 'message' => __( 'Transaction ID required', 'gvectors' ) ] );
988
989 return;
990 }
991
992 $pending = get_option( $this->pending_transactions_option_name, [] );
993 if( ! is_array( $pending ) ) $pending = [];
994 if( $shouldClear ) {
995 unset( $pending[ $transaction_id ] );
996 } else {
997 // Record WHO opened the checkout: the pending list is a site-wide
998 // option and ANY admin's dashboard may later run the verification
999 // polling, so the purchaser must be captured here, at purchase time.
1000 // Preserve fields set by schedule_abandoned_checks (phases) — this
1001 // AJAX may land after the checkout response already stored them.
1002 $entry = isset( $pending[ $transaction_id ] ) && is_array( $pending[ $transaction_id ] ) ? $pending[ $transaction_id ] : [];
1003 $pending[ $transaction_id ] = array_merge( $entry, [ 'time' => time(), 'user_id' => get_current_user_id() ] );
1004 }
1005 update_option( $this->pending_transactions_option_name, $pending );
1006 wp_send_json_success();
1007 }
1008
1009 public function ajax_get_timeline(): void {
1010 if( ! $this->verify_admin() ) return;
1011
1012 $license_key = isset( $_POST['license_key'] ) ? sanitize_text_field( $_POST['license_key'] ) : '';
1013 if( empty( $license_key ) ) {
1014 wp_send_json_error( [ 'message' => __( 'License key required', 'gvectors' ) ] );
1015
1016 return;
1017 }
1018
1019 $response = $this->addonsService->licenseService->apiService->get_license_timeline( $license_key );
1020 if( ! empty( $response['success'] ) ) {
1021 wp_send_json_success( $response['data'] );
1022 } else {
1023 $error = $response['error'] ?? __( 'Failed to load timeline', 'gvectors' );
1024 wp_send_json_error( [ 'message' => $error ] );
1025 }
1026 }
1027
1028 public function ajax_clear_cache(): void {
1029 if( ! $this->verify_admin() ) return;
1030 $this->addonsService->licenseService->apiService->clear_cache();
1031 wp_send_json_success( [ 'message' => __( 'Cache cleared', 'gvectors' ) ] );
1032 }
1033 }
1034