PluginProbe ʕ •ᴥ•ʔ
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.4.2
JetFormBuilder — Dynamic Blocks Form Builder v3.6.4.2
3.6.5 3.6.4.2 3.6.4.1 3.6.4 3.6.3.1 3.6.3 3.6.2.2 3.6.2.1 3.6.2 3.6.1.1 3.6.1 3.6.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.4.1 1.4.2 1.4.3 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.1.0 2.1.1 2.1.10 2.1.11 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 3.0.0 3.0.0.1 3.0.0.2 3.0.0.3 3.0.1 3.0.1.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.0.1 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 3.1.8 3.1.9 3.2.0 3.2.1 3.2.2 3.2.3 3.3.0 3.3.1 3.3.2 3.3.3 3.3.3.1 3.3.4 3.3.4.1 3.3.4.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.4.5.1 3.4.5.2 3.4.6 3.4.7 3.4.7.1 3.5.0 3.5.1 3.5.1.1 3.5.1.2 3.5.2 3.5.2.1 3.5.3 3.5.4 3.5.5 3.5.6 3.5.6.1 3.5.6.2 3.5.6.3 3.6.0
jetformbuilder / modules / gateways / secure-price-notice.php
jetformbuilder / modules / gateways Last commit date
actions-abstract 2 years ago assets 1 week ago db-models 2 years ago export 2 years ago legacy 2 years ago meta-boxes 2 years ago pages 2 years ago paypal 2 weeks ago query-views 2 years ago rest-api 9 months ago scenarios-abstract 2 years ago tab-handlers 2 years ago table-views 2 weeks ago base-gateway-action.php 1 year ago base-gateway.php 3 months ago base-scenario-gateway.php 2 years ago gateways-editor-data.php 2 weeks ago legacy-base-gateway.php 2 weeks ago migrate-legacy-data.php 2 years ago module.php 2 weeks ago scenario-item.php 2 years ago secure-price-notice.php 1 week ago trusted-price-resolver-expression-parser.php 1 week ago trusted-price-resolver.php 2 weeks ago
secure-price-notice.php
732 lines
1 <?php
2
3 namespace JFB_Modules\Gateways;
4
5 use Jet_Form_Builder\Blocks\Block_Helper;
6 use JFB_Modules\Post_Type\Meta\Gateways_Meta;
7 use JFB_Modules\Post_Type\Module as Post_Type_Module;
8
9 if ( ! defined( 'WPINC' ) ) {
10 die;
11 }
12
13 class Secure_Price_Notice {
14
15 const SCAN_VERSION_OPTION = 'jet_fb_secure_price_scan_version';
16 const NOTICE_OPTION = 'jet_fb_secure_price_notice';
17 const DISMISS_META_KEY = 'jet_fb_secure_price_notice_dismissed';
18 const NOTICE_QUERY_ARG = 'jet_fb_dismiss_secure_price_notice';
19 const SCAN_SCHEMA_VERSION = '2';
20 const SCAN_BATCH_SIZE = 30;
21
22 const CALCULATED_MACRO_PATTERN = '/%'
23 . '((?:[a-zA-Z0-9_-]+::)?'
24 . '[a-zA-Z0-9_-]+'
25 . '(?:\|[a-zA-Z][a-zA-Z0-9]*(?:\([^%|()]*\))?)*)'
26 . '%/';
27
28 public function init_hooks() {
29 add_action( 'save_post_' . Post_Type_Module::SLUG, array( $this, 'invalidate_scan_cache' ) );
30
31 if ( ! is_admin() ) {
32 return;
33 }
34
35 add_action( 'admin_init', array( $this, 'maybe_scan_affected_forms' ) );
36 add_action( 'admin_init', array( $this, 'maybe_dismiss_notice' ) );
37 add_action( 'admin_notices', array( $this, 'render_affected_forms_notice' ) );
38 }
39
40 public function remove_hooks() {
41 remove_action( 'save_post_' . Post_Type_Module::SLUG, array( $this, 'invalidate_scan_cache' ) );
42 remove_action( 'admin_init', array( $this, 'maybe_scan_affected_forms' ) );
43 remove_action( 'admin_init', array( $this, 'maybe_dismiss_notice' ) );
44 remove_action( 'admin_notices', array( $this, 'render_affected_forms_notice' ) );
45 }
46
47 public function invalidate_scan_cache() {
48 delete_option( self::SCAN_VERSION_OPTION );
49 }
50
51 public function maybe_scan_affected_forms() {
52 if ( ! current_user_can( 'manage_options' ) ) {
53 return;
54 }
55
56 if (
57 wp_doing_ajax() ||
58 wp_doing_cron() ||
59 ( defined( 'REST_REQUEST' ) && REST_REQUEST )
60 ) {
61 return;
62 }
63
64 $version = $this->get_scan_version();
65 $notice = get_option( self::NOTICE_OPTION, array() );
66
67 if ( get_option( self::SCAN_VERSION_OPTION, '' ) === $version ) {
68 return;
69 }
70
71 $tracked_ids = array_values(
72 array_unique(
73 array_filter(
74 array_map( 'intval', $notice['tracked_ids'] ?? array() )
75 )
76 )
77 );
78
79 if (
80 array_key_exists( 'tracked_ids', $notice )
81 && $this->notice_uses_current_scan_schema( $notice )
82 ) {
83 $forms = empty( $tracked_ids )
84 ? array()
85 : $this->scan_specific_forms( $tracked_ids );
86
87 $this->save_scan( $version, $tracked_ids, $forms );
88
89 return;
90 }
91
92 $forms = $this->scan_affected_forms();
93 $tracked_ids = array_values( array_unique( array_map( 'intval', wp_list_pluck( $forms, 'id' ) ) ) );
94
95 $this->save_scan( $version, $tracked_ids, $forms );
96 }
97
98 public function maybe_dismiss_notice() {
99 if ( ! current_user_can( 'manage_options' ) ) {
100 return;
101 }
102
103 if ( empty( $_GET[ self::NOTICE_QUERY_ARG ] ) ) {
104 return;
105 }
106
107 check_admin_referer( self::NOTICE_QUERY_ARG );
108
109 update_user_meta(
110 get_current_user_id(),
111 self::DISMISS_META_KEY,
112 $this->get_dismiss_version()
113 );
114
115 wp_safe_redirect(
116 remove_query_arg(
117 array(
118 self::NOTICE_QUERY_ARG,
119 '_wpnonce',
120 )
121 )
122 );
123 exit;
124 }
125
126 public function render_affected_forms_notice() {
127 if ( ! current_user_can( 'manage_options' ) ) {
128 return;
129 }
130
131 $notice = get_option( self::NOTICE_OPTION, array() );
132
133 if ( empty( $notice['forms'] ) || empty( $notice['version'] ) ) {
134 return;
135 }
136
137 if ( $this->get_scan_version() !== $notice['version'] || $this->is_notice_dismissed( $notice ) ) {
138 return;
139 }
140
141 $forms = array_slice( $notice['forms'], 0, 5 );
142 $more_forms = count( $notice['forms'] ) - count( $forms );
143 $dismiss_url = wp_nonce_url(
144 add_query_arg( self::NOTICE_QUERY_ARG, '1' ),
145 self::NOTICE_QUERY_ARG
146 );
147
148 ?>
149 <div class="notice notice-warning">
150 <p>
151 <strong><?php esc_html_e( 'JetFormBuilder: enable Secure payment amount for existing payment forms.', 'jet-form-builder' ); ?></strong>
152 </p>
153 <p>
154 <?php esc_html_e( 'Secure payment amount was enabled automatically only for existing forms with static price sources that can be safely verified. The forms below still trust the submitted amount, which visitors may be able to modify.', 'jet-form-builder' ); ?>
155 </p>
156 <p>
157 <?php esc_html_e( 'Open each form, enable Secure payment amount in Gateways Settings, verify that its price source is supported, and test the payment flow. Unsupported client-controlled price sources will be rejected after protection is enabled.', 'jet-form-builder' ); ?>
158 </p>
159 <ul style="list-style: disc; margin-left: 1.5em;">
160 <?php foreach ( $forms as $form ) : ?>
161 <li>
162 <a href="<?php echo esc_url( $form['edit_link'] ); ?>">
163 <?php echo esc_html( $form['title'] ); ?>
164 </a>
165 <?php echo esc_html( ' (#' . $form['id'] . ')' ); ?>:
166 <?php
167 echo esc_html(
168 sprintf(
169 /* translators: %s: comma-separated payment gateway IDs */
170 __( 'payment gateway(s): %s', 'jet-form-builder' ),
171 implode( ', ', $form['gateways'] )
172 )
173 );
174 ?>
175 </li>
176 <?php endforeach; ?>
177 </ul>
178 <?php if ( $more_forms > 0 ) : ?>
179 <p>
180 <?php
181 echo esc_html(
182 sprintf(
183 /* translators: %d: number of hidden forms */
184 __( 'Plus %d more form(s) that should be reviewed.', 'jet-form-builder' ),
185 $more_forms
186 )
187 );
188 ?>
189 </p>
190 <?php endif; ?>
191 <p>
192 <a href="<?php echo esc_url( admin_url( 'edit.php?post_type=' . Post_Type_Module::SLUG ) ); ?>" class="button button-primary">
193 <?php esc_html_e( 'Review Forms', 'jet-form-builder' ); ?>
194 </a>
195 <a href="<?php echo esc_url( $dismiss_url ); ?>" class="button button-secondary">
196 <?php esc_html_e( 'Dismiss', 'jet-form-builder' ); ?>
197 </a>
198 </p>
199 </div>
200 <?php
201 }
202
203 private function save_scan( string $version, array $tracked_ids, array $forms ) {
204 update_option( self::SCAN_VERSION_OPTION, $version, false );
205 update_option(
206 self::NOTICE_OPTION,
207 array(
208 'version' => $version,
209 'notice_token' => wp_generate_uuid4(),
210 'tracked_ids' => $tracked_ids,
211 'forms' => $forms,
212 ),
213 false
214 );
215 }
216
217 private function scan_affected_forms(): array {
218 $affected_forms = array();
219 $offset = 0;
220
221 while ( true ) {
222 $form_ids = get_posts(
223 array(
224 'post_type' => Post_Type_Module::SLUG,
225 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ),
226 'posts_per_page' => self::SCAN_BATCH_SIZE,
227 'offset' => $offset,
228 'fields' => 'ids',
229 'orderby' => 'ID',
230 'order' => 'ASC',
231 'no_found_rows' => true,
232 'update_post_meta_cache' => false,
233 'update_post_term_cache' => false,
234 )
235 );
236
237 if ( empty( $form_ids ) ) {
238 break;
239 }
240
241 foreach ( $form_ids as $form_id ) {
242 $form = $this->get_affected_form( (int) $form_id );
243
244 if ( $form ) {
245 $affected_forms[] = $form;
246 }
247 }
248
249 if ( count( $form_ids ) < self::SCAN_BATCH_SIZE ) {
250 break;
251 }
252
253 $offset += self::SCAN_BATCH_SIZE;
254 }
255
256 return $affected_forms;
257 }
258
259 private function scan_specific_forms( array $form_ids ): array {
260 $affected_forms = array();
261
262 foreach ( $form_ids as $form_id ) {
263 $form_id = (int) $form_id;
264
265 if ( $form_id <= 0 || Post_Type_Module::SLUG !== get_post_type( $form_id ) ) {
266 continue;
267 }
268
269 $form = $this->get_affected_form( $form_id );
270
271 if ( $form ) {
272 $affected_forms[] = $form;
273 }
274 }
275
276 return $affected_forms;
277 }
278
279 private function get_affected_form( int $form_id ): array {
280 $settings = jet_form_builder()->post_type->get_gateways( $form_id );
281 $gateways = $this->get_active_gateways( $settings );
282
283 if ( empty( $gateways ) || $this->is_price_protection_enabled( $settings ) ) {
284 return array();
285 }
286
287 if (
288 ! $this->has_explicit_price_protection_setting( $settings )
289 && $this->has_safe_static_price_source( $form_id, $settings )
290 && $this->enable_price_protection( $form_id, $settings )
291 ) {
292 return array();
293 }
294
295 return array(
296 'id' => $form_id,
297 'title' => get_the_title( $form_id ) ?: sprintf(
298 /* translators: %d: form ID */
299 __( 'Form #%d', 'jet-form-builder' ),
300 $form_id
301 ),
302 'edit_link' => get_edit_post_link( $form_id, 'raw' ) ?: admin_url( 'post.php?post=' . absint( $form_id ) . '&action=edit' ),
303 'gateways' => $gateways,
304 );
305 }
306
307 private function has_explicit_price_protection_setting( array $settings ): bool {
308 if ( array_key_exists( 'protect_price_field', $settings ) ) {
309 return true;
310 }
311
312 foreach ( $settings as $value ) {
313 if (
314 is_array( $value )
315 && $this->has_explicit_price_protection_setting( $value )
316 ) {
317 return true;
318 }
319 }
320
321 return false;
322 }
323
324 private function has_safe_static_price_source( int $form_id, array $settings ): bool {
325 $field_name = is_string( $settings['price_field'] ?? null )
326 ? sanitize_key( $settings['price_field'] )
327 : '';
328
329 if ( ! $field_name ) {
330 return false;
331 }
332
333 $blocks = Block_Helper::get_blocks_by_post( $form_id, true, true );
334 $matches = $this->find_price_field_blocks( $field_name, $blocks );
335
336 if ( 1 !== count( $matches ) ) {
337 return false;
338 }
339
340 $block = $matches[0];
341 $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' );
342
343 if ( 'calculated-field' === $type ) {
344 return $this->is_safe_static_calculated_field( $field_name, $block, $blocks );
345 }
346
347 if ( 'hidden-field' === $type ) {
348 return $this->is_safe_static_hidden_field( $block );
349 }
350
351 if ( in_array( $type, array( 'select-field', 'radio-field', 'checkbox-field' ), true ) ) {
352 return $this->is_safe_static_option_field( $block );
353 }
354
355 return false;
356 }
357
358 private function find_price_field_blocks( string $field_name, array $blocks ): array {
359 $matches = array();
360
361 foreach ( $blocks as $block ) {
362 if ( ! is_array( $block ) ) {
363 continue;
364 }
365
366 $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
367
368 if ( ( $attributes['name'] ?? '' ) === $field_name ) {
369 $matches[] = $block;
370 }
371
372 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
373 $matches = array_merge(
374 $matches,
375 $this->find_price_field_blocks( $field_name, $block['innerBlocks'] )
376 );
377 }
378 }
379
380 return $matches;
381 }
382
383 private function is_safe_static_calculated_field(
384 string $field_name,
385 array $block,
386 array $blocks,
387 array $resolving_fields = array()
388 ): bool {
389 $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
390 $formula = $attributes['calc_formula'] ?? '';
391 $precision = $attributes['precision'] ?? 2;
392
393 if (
394 isset( $resolving_fields[ $field_name ] )
395 || 'number' !== ( $attributes['value_type'] ?? 'number' )
396 || ! is_string( $formula )
397 || '' === trim( $formula )
398 || strlen( $formula ) > Trusted_Price_Resolver::MAX_FORMULA_LENGTH
399 || substr_count( $formula, '%' ) / 2 > Trusted_Price_Resolver::MAX_MACRO_TOKENS
400 || ! is_numeric( $precision )
401 || (float) (int) $precision !== (float) $precision
402 || (int) $precision < 0
403 || (int) $precision > 100
404 ) {
405 return false;
406 }
407
408 $formula = html_entity_decode( $formula, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
409
410 if (
411 false !== strpos( $formula, '^' )
412 || $this->is_field_inside_dynamic_container( $field_name, $blocks )
413 ) {
414 return false;
415 }
416
417 $resolving_fields[ $field_name ] = true;
418
419 return $this->is_safe_calculated_formula( $formula, $blocks, $resolving_fields );
420 }
421
422 private function is_safe_calculated_formula(
423 string $formula,
424 array $blocks,
425 array $resolving_fields
426 ): bool {
427 $replacements = array();
428 $dependencies = array();
429 $match = preg_match_all(
430 self::CALCULATED_MACRO_PATTERN,
431 $formula,
432 $macros,
433 PREG_SET_ORDER
434 );
435
436 if ( false === $match ) {
437 return false;
438 }
439
440 foreach ( $macros as $macro ) {
441 $token = $macro[1];
442
443 if ( false !== strpos( $token, '|' ) ) {
444 return false;
445 }
446
447 $parts = explode( '::', $token, 2 );
448
449 if ( 2 === count( $parts ) ) {
450 if ( 'field' !== strtolower( $parts[0] ) ) {
451 return false;
452 }
453
454 $dependency = $parts[1];
455 } else {
456 $dependency = $parts[0];
457 }
458
459 if (
460 ! isset( $dependencies[ $dependency ] )
461 && ! $this->is_safe_calculated_dependency(
462 $dependency,
463 $blocks,
464 $resolving_fields
465 )
466 ) {
467 return false;
468 }
469
470 $dependencies[ $dependency ] = true;
471 $replacements[ $macro[0] ] = '1';
472 }
473
474 try {
475 ( new Trusted_Price_Resolver_Expression_Parser(
476 strtr( $formula, $replacements )
477 ) )->validate();
478 } catch ( \Throwable $exception ) {
479 return false;
480 }
481
482 return true;
483 }
484
485 private function is_safe_calculated_dependency(
486 string $field_name,
487 array $blocks,
488 array $resolving_fields
489 ): bool {
490 $matches = $this->find_price_field_blocks( $field_name, $blocks );
491
492 if (
493 1 !== count( $matches )
494 || $this->is_field_inside_dynamic_container( $field_name, $blocks )
495 ) {
496 return false;
497 }
498
499 $block = $matches[0];
500 $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' );
501
502 if ( 'calculated-field' === $type ) {
503 return $this->is_safe_static_calculated_field(
504 $field_name,
505 $block,
506 $blocks,
507 $resolving_fields
508 );
509 }
510
511 if ( 'hidden-field' === $type ) {
512 return $this->is_safe_static_hidden_field( $block );
513 }
514
515 if ( in_array( $type, array( 'select-field', 'radio-field', 'checkbox-field' ), true ) ) {
516 return $this->has_safe_static_option_configuration( $block, false );
517 }
518
519 return false;
520 }
521
522 private function is_field_inside_dynamic_container(
523 string $field_name,
524 array $blocks,
525 bool $inside_dynamic_container = false
526 ): bool {
527 foreach ( $blocks as $block ) {
528 if ( ! is_array( $block ) ) {
529 continue;
530 }
531
532 $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' );
533 $inside_current = $inside_dynamic_container || in_array(
534 $type,
535 array( 'repeater-field', 'conditional-block' ),
536 true
537 );
538 $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
539
540 if ( ( $attributes['name'] ?? '' ) === $field_name ) {
541 return $inside_current;
542 }
543
544 if (
545 ! empty( $block['innerBlocks'] )
546 && is_array( $block['innerBlocks'] )
547 && $this->is_field_inside_dynamic_container(
548 $field_name,
549 $block['innerBlocks'],
550 $inside_current
551 )
552 ) {
553 return true;
554 }
555 }
556
557 return false;
558 }
559
560 private function is_safe_static_hidden_field( array $block ): bool {
561 $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
562 $default = $attributes['default'] ?? '';
563
564 return false === ( $attributes['render'] ?? true )
565 && 'manual_input' === ( $attributes['field_value'] ?? '' )
566 && '' === $default
567 && $this->is_positive_number( $attributes['hidden_value'] ?? null );
568 }
569
570 private function is_safe_static_option_field( array $block ): bool {
571 return $this->has_safe_static_option_configuration( $block, true );
572 }
573
574 private function has_safe_static_option_configuration(
575 array $block,
576 bool $require_positive_amount
577 ): bool {
578 $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
579
580 if (
581 $this->has_custom_option( $block )
582 || 'manual_input' !== ( $attributes['field_options_from'] ?? 'manual_input' )
583 ) {
584 return false;
585 }
586
587 $options = $attributes['field_options'] ?? array();
588
589 if ( empty( $options ) || ! is_array( $options ) ) {
590 return false;
591 }
592
593 $values = array();
594
595 foreach ( $options as $option ) {
596 if ( ! is_array( $option ) || ! isset( $option['value'] ) || ! is_scalar( $option['value'] ) ) {
597 return false;
598 }
599
600 $value = (string) $option['value'];
601
602 if ( '' === $value || isset( $values[ $value ] ) ) {
603 return false;
604 }
605
606 $calculate = $option['calculate'] ?? null;
607 $amount = null !== $calculate && '' !== $calculate ? $calculate : $value;
608
609 if (
610 ! $this->is_finite_number( $amount )
611 || ( $require_positive_amount && (float) $amount <= 0 )
612 ) {
613 return false;
614 }
615
616 $values[ $value ] = true;
617 }
618
619 return true;
620 }
621
622 private function has_custom_option( array $block ): bool {
623 $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' );
624
625 if ( ! in_array( $type, array( 'radio-field', 'checkbox-field' ), true ) ) {
626 return false;
627 }
628
629 $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
630 $custom_option = $attributes['custom_option'] ?? false;
631
632 if ( is_array( $custom_option ) ) {
633 $custom_option = $custom_option['allow'] ?? false;
634 }
635
636 return filter_var( $custom_option, FILTER_VALIDATE_BOOLEAN );
637 }
638
639 private function is_positive_number( $value ): bool {
640 return $this->is_finite_number( $value ) && (float) $value > 0;
641 }
642
643 private function is_finite_number( $value ): bool {
644 return is_scalar( $value )
645 && is_numeric( $value )
646 && is_finite( (float) $value );
647 }
648
649 private function enable_price_protection( int $form_id, array $settings ): bool {
650 $settings['protect_price_field'] = true;
651
652 return false !== update_post_meta(
653 $form_id,
654 Gateways_Meta::META_KEY,
655 wp_json_encode( $settings )
656 );
657 }
658
659 private function get_active_gateways( array $settings ): array {
660 $gateways = array();
661
662 if ( 'manual' === ( $settings['mode'] ?? 'single' ) ) {
663 foreach ( $settings as $gateway_id => $gateway_settings ) {
664 if (
665 ! is_string( $gateway_id ) ||
666 ! is_array( $gateway_settings ) ||
667 ! filter_var( $gateway_settings['show_on_front'] ?? false, FILTER_VALIDATE_BOOLEAN )
668 ) {
669 continue;
670 }
671
672 $gateways[] = sanitize_key( $gateway_id );
673 }
674 } else {
675 $gateway_id = is_string( $settings['gateway'] ?? null )
676 ? sanitize_key( $settings['gateway'] )
677 : '';
678
679 if ( $gateway_id && ! in_array( $gateway_id, array( 'none', 'manual' ), true ) ) {
680 $gateways[] = $gateway_id;
681 }
682 }
683
684 return array_values( array_unique( array_filter( $gateways ) ) );
685 }
686
687 private function is_price_protection_enabled( array $settings ): bool {
688 return filter_var( $settings['protect_price_field'] ?? false, FILTER_VALIDATE_BOOLEAN );
689 }
690
691 private function get_scan_version(): string {
692 return jet_form_builder()->get_version() . ':' . self::SCAN_SCHEMA_VERSION;
693 }
694
695 private function notice_uses_current_scan_schema( array $notice ): bool {
696 $version = (string) ( $notice['version'] ?? '' );
697 $suffix = ':' . self::SCAN_SCHEMA_VERSION;
698
699 return strlen( $version ) > strlen( $suffix )
700 && substr( $version, -strlen( $suffix ) ) === $suffix;
701 }
702
703 private function get_dismiss_version(): string {
704 return 'schema:' . self::SCAN_SCHEMA_VERSION;
705 }
706
707 private function is_notice_dismissed( array $notice ): bool {
708 $dismissed = (string) get_user_meta( get_current_user_id(), self::DISMISS_META_KEY, true );
709
710 if ( '' === $dismissed ) {
711 return false;
712 }
713
714 if (
715 $dismissed === $this->get_dismiss_version() ||
716 $dismissed === $this->get_notice_dismiss_token( $notice )
717 ) {
718 return true;
719 }
720
721 // Older builds stored "<plugin-version>:<schema>" in user meta.
722 $legacy_suffix = ':' . self::SCAN_SCHEMA_VERSION;
723
724 return strlen( $dismissed ) > strlen( $legacy_suffix )
725 && substr( $dismissed, -strlen( $legacy_suffix ) ) === $legacy_suffix;
726 }
727
728 private function get_notice_dismiss_token( array $notice ): string {
729 return (string) ( $notice['notice_token'] ?? $notice['version'] ?? '' );
730 }
731 }
732