PluginProbe
wpForo Forum / 3.2.1
wpForo Forum v3.2.1
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 1.4.10 All 141 releases
wpforo / admin / pages / license / src / Services / ActionsService.php

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

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