PluginProbe
wpForo Forum / 3.1.6
wpForo Forum v3.1.6
3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 All 138 releases
wpforo / admin / pages / license / src / Services / ActionsService.php

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

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