PluginProbe ʕ •ᴥ•ʔ
JetFormBuilder — Dynamic Blocks Form Builder / trunk
JetFormBuilder — Dynamic Blocks Form Builder vtrunk
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 / trusted-price-resolver.php
jetformbuilder / modules / gateways Last commit date
actions-abstract 2 years ago assets 1 day ago db-models 2 years ago export 2 years ago legacy 2 years ago meta-boxes 2 years ago pages 2 years ago paypal 1 day ago query-views 2 years ago rest-api 8 months ago scenarios-abstract 2 years ago tab-handlers 2 years ago table-views 1 day 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 1 day ago legacy-base-gateway.php 1 day ago migrate-legacy-data.php 2 years ago module.php 1 day ago scenario-item.php 2 years ago secure-price-notice.php 1 day ago trusted-price-resolver-expression-parser.php 1 day ago trusted-price-resolver.php 1 day ago
trusted-price-resolver.php
1011 lines
1 <?php
2
3
4 namespace JFB_Modules\Gateways;
5
6 use Jet_Form_Builder\Blocks\Block_Helper;
7 use Jet_Form_Builder\Blocks\Types\Hidden_Field;
8 use Jet_Form_Builder\Exceptions\Gateway_Exception;
9 use Jet_Form_Builder\Exceptions\Repository_Exception;
10 use JFB_Modules\Option_Field\Interfaces\Support_Option_Query_It;
11
12 // If this file is called directly, abort.
13 if ( ! defined( 'WPINC' ) ) {
14 die;
15 }
16
17 class Trusted_Price_Resolver {
18
19 /**
20 * Caps for calculated-field formulas, to bound server-side parsing cost.
21 */
22 const MAX_FORMULA_LENGTH = 20000;
23 const MAX_MACRO_TOKENS = 200;
24
25 /**
26 * @var int
27 */
28 private $form_id;
29
30 /**
31 * @var array
32 */
33 private $blocks = array();
34
35 /**
36 * @var array<string,float>
37 */
38 private $resolved = array();
39
40 /**
41 * @var array<string,bool>
42 */
43 private $resolving = array();
44
45 public function __construct( int $form_id ) {
46 $this->form_id = $form_id;
47 $this->blocks = Block_Helper::get_blocks_by_post( $form_id, true, true );
48 }
49
50 /**
51 * @throws Gateway_Exception
52 */
53 public function resolve( string $field_name ): float {
54 if ( isset( $this->resolved[ $field_name ] ) ) {
55 return $this->resolved[ $field_name ];
56 }
57
58 if ( isset( $this->resolving[ $field_name ] ) ) {
59 throw new Gateway_Exception( 'Circular price field dependency detected.' );
60 }
61
62 $block = Block_Helper::find_block_by_name( $field_name, $this->blocks );
63
64 if ( empty( $block ) ) {
65 throw new Gateway_Exception( 'Invalid price field.' );
66 }
67
68 $this->resolving[ $field_name ] = true;
69
70 try {
71 $price = $this->resolve_block( $field_name, $block );
72 } finally {
73 unset( $this->resolving[ $field_name ] );
74 }
75
76 $this->resolved[ $field_name ] = $price;
77
78 return $price;
79 }
80
81 /**
82 * @throws Gateway_Exception
83 */
84 private function resolve_block( string $field_name, array $block ): float {
85 $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' );
86
87 switch ( $type ) {
88 case 'calculated-field':
89 return $this->resolve_calculated_field( $block );
90
91 case 'checkbox-field':
92 case 'radio-field':
93 case 'select-field':
94 return $this->resolve_option_field( $field_name, $block );
95
96 case 'hidden-field':
97 return $this->resolve_hidden_field( $field_name, $block );
98
99 default:
100 // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
101 $price = apply_filters(
102 'jet-form-builder/gateways/trusted-price/resolve-block',
103 null,
104 $field_name,
105 $type,
106 $block,
107 $this
108 );
109
110 if ( null !== $price ) {
111 return (float) $price;
112 }
113
114 throw new Gateway_Exception(
115 sprintf(
116 'The "%1$s" price field type is not supported when Secure payment amount is enabled. Use a Calculated Field, Hidden Field with "Render in HTML" disabled, or static Select/Radio/Checkbox options.',
117 esc_html( $type )
118 )
119 );
120 }
121 }
122
123 /**
124 * @throws Gateway_Exception
125 */
126 private function resolve_hidden_field( string $field_name, array $block ): float {
127 return $this->normalize_numeric(
128 $this->resolve_hidden_field_value( $field_name, $block ),
129 $field_name
130 );
131 }
132
133 /**
134 * @return mixed
135 * @throws Gateway_Exception
136 */
137 private function resolve_hidden_field_value( string $field_name, array $block ) {
138 $render = $block['attrs']['render'] ?? true;
139
140 if ( $render ) {
141 throw new Gateway_Exception(
142 'Visible hidden fields cannot be used when Secure payment amount is enabled. Disable "Render in HTML" in the Hidden Field settings or choose a Calculated, Select, Radio, or Checkbox field.'
143 );
144 }
145
146 $field_value_source = (string) ( $block['attrs']['field_value'] ?? 'post_id' );
147 $unsafe_sources = array( 'query_var', 'referer_url' );
148 $unsigned_context_sources = array(
149 'term_url',
150 'current_term_id',
151 'current_object_id',
152 'post_id',
153 'post_title',
154 'post_url',
155 'post_type',
156 'post_meta',
157 'author_id',
158 'author_email',
159 'author_name',
160 );
161
162 if ( in_array( $field_value_source, $unsafe_sources, true ) ) {
163 throw new Gateway_Exception(
164 sprintf(
165 'The "%1$s" hidden field uses a client-controlled source ("%2$s") and cannot be used when Secure payment amount is enabled. Use Manual Input, a user source, Current Date, or an integration-provided trusted resolver instead.',
166 esc_html( $field_name ),
167 esc_html( $field_value_source )
168 )
169 );
170 }
171
172 if ( in_array( $field_value_source, $unsigned_context_sources, true ) ) {
173 throw new Gateway_Exception(
174 sprintf(
175 'The "%1$s" hidden field uses an unsigned context source ("%2$s") and cannot be used when Secure payment amount is enabled.',
176 esc_html( $field_name ),
177 esc_html( $field_value_source )
178 )
179 );
180 }
181
182 $trusted_native_sources = array(
183 'manual_input',
184 'user_id',
185 'user_email',
186 'user_name',
187 'user_meta',
188 'current_date',
189 'random_string',
190 );
191
192 $attributes = $block['attrs'] ?? array();
193 $this->assert_hidden_field_default_is_static( $field_name, $attributes );
194
195 if ( in_array( $field_value_source, $trusted_native_sources, true ) ) {
196 /** @var Hidden_Field $hidden_type */
197 $hidden_type = clone jet_form_builder()->blocks->get_field_by_name( 'hidden-field' );
198 $secure_attributes = $attributes;
199 $secure_attributes['default'] = '';
200
201 $hidden_type->set_rendering( false );
202 $hidden_type->set_block_data( $secure_attributes );
203
204 // Resolve the server source first so runtime presets cannot replace its fallback.
205 $hidden_type->block_attrs['default'] = null;
206 $value = $hidden_type->get_hidden_field_value();
207 } else {
208 $value = $this->resolve_custom_hidden_field_value(
209 $field_value_source,
210 $field_name,
211 $block
212 );
213 }
214
215 if ( ! in_array( $value, array( '', null, false ), true ) ) {
216 return $value;
217 }
218
219 $this->assert_hidden_field_has_no_runtime_preset( $field_name, $attributes );
220
221 // Secure defaults come from saved form data, without runtime macro parsing.
222 return $attributes['default'] ?? '';
223 }
224
225 /**
226 * Custom Hidden sources must explicitly provide a server-trusted resolver.
227 * The legacy value-cb hook is intentionally not executed in secure pricing.
228 *
229 * @return mixed
230 * @throws Gateway_Exception
231 */
232 private function resolve_custom_hidden_field_value( string $source, string $field_name, array $block ) {
233 $unresolved = new \stdClass();
234
235 $value = apply_filters(
236 'jet-form-builder/gateways/trusted-price/resolve-hidden-source',
237 $unresolved,
238 $source,
239 $field_name,
240 $block,
241 $this
242 );
243
244 if ( $unresolved === $value ) {
245 throw new Gateway_Exception(
246 sprintf(
247 'The "%1$s" hidden price field uses custom source "%2$s" without a trusted secure resolver.',
248 esc_html( $field_name ),
249 esc_html( $source )
250 )
251 );
252 }
253
254 return $value;
255 }
256
257 /**
258 * Runtime presets may read query/request context and cannot be trusted as a
259 * payment amount fallback.
260 *
261 * @throws Gateway_Exception
262 */
263 private function assert_hidden_field_has_no_runtime_preset( string $field_name, array $attributes ): void {
264 $default = $attributes['default'] ?? '';
265
266 // A non-empty literal default takes precedence over the general preset.
267 if ( '' !== $default ) {
268 return;
269 }
270
271 $preset = jet_form_builder()->post_type->get_preset( $this->form_id );
272
273 if ( empty( $preset['enabled'] ) || empty( $preset['fields_map'][ $field_name ] ) ) {
274 return;
275 }
276
277 $this->throw_unsupported_hidden_preset( $field_name );
278 }
279
280 private function assert_hidden_field_default_is_static( string $field_name, array $attributes ): void {
281 $default = $attributes['default'] ?? '';
282 $dynamic_preset = is_string( $default ) ? json_decode( $default, true ) : null;
283
284 if ( is_array( $dynamic_preset ) && ! empty( $dynamic_preset['jet_preset'] ) ) {
285 $this->throw_unsupported_hidden_preset( $field_name );
286 }
287 }
288
289 /**
290 * @throws Gateway_Exception
291 */
292 private function throw_unsupported_hidden_preset( string $field_name ): void {
293 throw new Gateway_Exception(
294 sprintf(
295 'The "%1$s" hidden price field uses a runtime preset, which cannot be trusted when Secure payment amount is enabled.',
296 esc_html( $field_name )
297 )
298 );
299 }
300
301 /**
302 * @throws Gateway_Exception
303 */
304 private function resolve_option_field( string $field_name, array $block ): float {
305 $from = $block['attrs']['field_options_from'] ?? 'manual_input';
306
307 if ( 'meta_field' === $from ) {
308 throw new Gateway_Exception(
309 sprintf(
310 'The "%1$s" price field uses Meta Field options that depend on unsigned post context and cannot be used when Secure payment amount is enabled.',
311 esc_html( $field_name )
312 )
313 );
314 }
315
316 $options = $this->get_option_field_options( $field_name, $block );
317
318 if ( empty( $options ) ) {
319 throw new Gateway_Exception(
320 sprintf(
321 'The "%1$s" price field has no options available.',
322 esc_html( $field_name )
323 )
324 );
325 }
326
327 $this->assert_unique_option_values( $field_name, $options );
328
329 $submitted = jet_fb_context()->get_value( $field_name );
330
331 if ( '' === $submitted || array() === $submitted ) {
332 return 0.0;
333 }
334
335 // Post and term values are record identifiers unless a calculate source is set.
336 $requires_calculate = 'manual_input' !== $from;
337 $selected = $this->validate_option_selection( $field_name, $block, $submitted );
338 $amount = 0.0;
339
340 foreach ( $selected as $selected_value ) {
341 $matched = $this->find_option_by_value( $options, $selected_value );
342
343 if ( null === $matched ) {
344 throw new Gateway_Exception(
345 sprintf(
346 'Invalid submitted option for secure price field "%1$s".',
347 esc_html( $field_name )
348 )
349 );
350 }
351
352 $amount += $this->resolve_option_amount( $matched, $field_name, $requires_calculate );
353 }
354
355 return $amount;
356 }
357
358 /**
359 * Reject option selections that cannot be produced by the configured field.
360 *
361 * @param mixed $submitted
362 *
363 * @throws Gateway_Exception
364 */
365 private function validate_option_selection( string $field_name, array $block, $submitted ): array {
366 $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' );
367 $is_multiple = 'checkbox-field' === $type
368 || ( 'select-field' === $type && ! empty( $block['attrs']['multiple'] ) );
369
370 if ( ! $is_multiple && is_array( $submitted ) ) {
371 throw new Gateway_Exception(
372 sprintf(
373 'The secure price field "%1$s" does not allow multiple selections.',
374 esc_html( $field_name )
375 )
376 );
377 }
378
379 $selected = is_array( $submitted ) ? array_values( $submitted ) : array( $submitted );
380 $unique = array();
381
382 foreach ( $selected as $selected_value ) {
383 if ( ! is_scalar( $selected_value ) ) {
384 throw new Gateway_Exception(
385 sprintf(
386 'Invalid submitted option for secure price field "%1$s".',
387 esc_html( $field_name )
388 )
389 );
390 }
391
392 $key = (string) $selected_value;
393
394 if ( isset( $unique[ $key ] ) ) {
395 throw new Gateway_Exception(
396 sprintf(
397 'Duplicate submitted option for secure price field "%1$s".',
398 esc_html( $field_name )
399 )
400 );
401 }
402
403 $unique[ $key ] = true;
404 }
405
406 return $selected;
407 }
408
409 /**
410 * A submitted option value does not identify which UI option was selected
411 * when the configured values are duplicated.
412 *
413 * @throws Gateway_Exception
414 */
415 private function assert_unique_option_values( string $field_name, array $options ): void {
416 $values = array();
417
418 foreach ( $options as $option ) {
419 if ( ! is_array( $option ) || ! array_key_exists( 'value', $option ) ) {
420 continue;
421 }
422
423 $value = $option['value'];
424
425 if ( ! is_scalar( $value ) ) {
426 throw new Gateway_Exception(
427 sprintf(
428 'The secure price field "%1$s" contains an invalid option value.',
429 esc_html( $field_name )
430 )
431 );
432 }
433
434 $key = (string) $value;
435
436 if ( isset( $values[ $key ] ) ) {
437 throw new Gateway_Exception(
438 sprintf(
439 'The secure price field "%1$s" contains duplicate option values. Each option must have a unique value.',
440 esc_html( $field_name )
441 )
442 );
443 }
444
445 $values[ $key ] = true;
446 }
447 }
448
449 /**
450 * Get options for a select/radio/checkbox field.
451 *
452 * For static options (manual_input), reads from block attributes.
453 * For reproducible dynamic options (posts, terms, meta_field),
454 * fetches them server-side via the option-query module.
455 *
456 * @throws Gateway_Exception
457 */
458 private function get_option_field_options( string $field_name, array $block ): array {
459 $from = $block['attrs']['field_options_from'] ?? 'manual_input';
460
461 if ( 'manual_input' === $from ) {
462 $options = $block['attrs']['field_options'] ?? array();
463
464 return is_array( $options ) ? $options : array();
465 }
466
467 if ( 'generate' === $from ) {
468 throw new Gateway_Exception(
469 sprintf(
470 'The "%1$s" price field uses generated options, which cannot be safely reproduced when Secure payment amount is enabled.',
471 esc_html( $field_name )
472 )
473 );
474 }
475
476 return $this->fetch_dynamic_options( $field_name, $from, $block );
477 }
478
479 /**
480 * Fetch dynamic options server-side using the option-query module.
481 *
482 * Replicates the query configuration from Option_Field\Module::on_set_in_block()
483 * to resolve options with their calculate values from trusted server sources
484 * (post meta, term meta, etc.).
485 *
486 * @throws Gateway_Exception
487 */
488 private function fetch_dynamic_options( string $field_name, string $from, array $block ): array {
489 try {
490 /** @var \JFB_Modules\Option_Query\Module $query_module */
491 $query_module = jet_form_builder()->module( 'option-query' );
492 $query = $query_module->get_query( $from );
493 } catch ( Repository_Exception $exception ) {
494 throw new Gateway_Exception(
495 sprintf(
496 'The "%1$s" price field uses an unsupported options source "%2$s".',
497 esc_html( $field_name ),
498 esc_html( $from )
499 )
500 );
501 }
502
503 $block_type = jet_form_builder()->blocks->get_field_by_name( $block['blockName'] ?? '' );
504
505 if ( ! $block_type instanceof Support_Option_Query_It ) {
506 throw new Gateway_Exception(
507 sprintf(
508 'The "%1$s" price field cannot initialize its options source securely.',
509 esc_html( $field_name )
510 )
511 );
512 }
513
514 try {
515 $block_type->set_block_data( $block['attrs'] ?? array() );
516 } catch ( Repository_Exception $exception ) {
517 throw new Gateway_Exception(
518 sprintf(
519 'The "%1$s" price field cannot initialize its options securely.',
520 esc_html( $field_name )
521 )
522 );
523 }
524
525 $block_type->set_query( $query );
526
527 // Match frontend query setup, including WPML/Polylang compatibility.
528 do_action( 'jet-form-builder/option-query/set-in-block', $block_type );
529
530 return iterator_to_array( $block_type->get_query()->fetch() );
531 }
532
533 private function find_option_by_value( array $options, $selected_value ): ?array {
534 foreach ( $options as $option ) {
535 $option_value = is_array( $option ) ? ( $option['value'] ?? null ) : null;
536
537 if ( null !== $option_value && (string) $option_value === (string) $selected_value ) {
538 return $option;
539 }
540 }
541
542 return null;
543 }
544
545 /**
546 * @throws Gateway_Exception
547 */
548 private function resolve_option_amount( array $option, string $field_name, bool $requires_calculate = false ): float {
549 $calculate = $option['calculate'] ?? null;
550 $has_calculate = null !== $calculate && '' !== $calculate;
551
552 if ( $requires_calculate && ! $has_calculate ) {
553 throw new Gateway_Exception(
554 sprintf(
555 'The "%1$s" price field uses dynamic options without a calculate value, so a trusted amount cannot be determined.',
556 esc_html( $field_name )
557 )
558 );
559 }
560
561 $amount = $has_calculate ? $calculate : ( $option['value'] ?? '' );
562
563 return $this->normalize_numeric( $amount, $field_name );
564 }
565
566 /**
567 * @throws Gateway_Exception
568 */
569 private function resolve_calculated_field( array $block ): float {
570 $value_type = $block['attrs']['value_type'] ?? 'number';
571
572 if ( 'number' !== $value_type ) {
573 throw new Gateway_Exception(
574 'Only Calculated Fields with Value type set to Number can be used when Secure payment amount is enabled.'
575 );
576 }
577
578 $formula = $block['attrs']['calc_formula'] ?? '';
579
580 if ( ! is_string( $formula ) || '' === trim( $formula ) ) {
581 throw new Gateway_Exception( 'Calculated price field has empty formula.' );
582 }
583
584 if ( strlen( $formula ) > self::MAX_FORMULA_LENGTH ) {
585 throw new Gateway_Exception( 'Calculated price formula is too long to resolve securely.' );
586 }
587
588 if ( substr_count( $formula, '%' ) / 2 > self::MAX_MACRO_TOKENS ) {
589 throw new Gateway_Exception( 'Calculated price formula has too many macros to resolve securely.' );
590 }
591
592 $formula = html_entity_decode( $formula, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
593
594 $expression = preg_replace_callback(
595 '/%((?:[a-zA-Z0-9_-]+::)?[a-zA-Z0-9_-]+(?:\|[a-zA-Z][a-zA-Z0-9]*(?:\([^%|()]*\))?)*)%/',
596 function ( $matches ) {
597 return (string) $this->resolve_macro_token( $matches[1] );
598 },
599 $formula
600 );
601
602 $result = $this->evaluate_expression( $expression );
603 $precision = $this->get_calculated_precision( $block );
604
605 return $this->round_to_frontend_precision( $result, $precision );
606 }
607
608 /**
609 * Resolve a macro and apply only filters that can be reproduced from
610 * server-trusted numeric data.
611 *
612 * @throws Gateway_Exception
613 */
614 private function resolve_macro_token( string $token ): float {
615 $parts = explode( '|', $token );
616 $source = array_shift( $parts );
617
618 if ( preg_match( '/^([a-zA-Z0-9_-]+)::([a-zA-Z0-9_-]+)$/', $source, $matches ) ) {
619 $value = $this->resolve_macro_value( $matches[1], $matches[2] );
620 } elseif ( preg_match( '/^[a-zA-Z0-9_-]+$/', $source ) ) {
621 $value = $this->resolve_field_macro_value( $source );
622 } else {
623 throw new Gateway_Exception( 'Calculated price field contains an invalid macro.' );
624 }
625
626 foreach ( $parts as $filter ) {
627 $value = $this->apply_macro_filter( $value, $filter, $source );
628 }
629
630 return $this->normalize_numeric( $value, $source );
631 }
632
633 private function resolve_field_macro_value( string $field_name ) {
634 $block = Block_Helper::find_block_by_name( $field_name, $this->blocks );
635 $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' );
636
637 if ( 'hidden-field' === $type ) {
638 return $this->resolve_hidden_field_value( $field_name, $block );
639 }
640
641 $value = $this->resolve( $field_name );
642
643 if ( 'calculated-field' === $type ) {
644 return $this->format_frontend_precision(
645 $value,
646 $this->get_calculated_precision( $block )
647 );
648 }
649
650 return $value;
651 }
652
653 private function get_calculated_precision( array $block ): int {
654 $precision = isset( $block['attrs']['precision'] )
655 ? (int) $block['attrs']['precision']
656 : 2;
657
658 return max( 0, min( 100, $precision ) );
659 }
660
661 /**
662 * @throws Gateway_Exception
663 */
664 private function apply_macro_filter( $value, string $filter, string $source ) {
665 if ( ! preg_match( '/^([a-zA-Z][a-zA-Z0-9]*)(?:\(([^()]*)\))?$/', trim( $filter ), $matches ) ) {
666 throw new Gateway_Exception( 'Calculated price field contains an invalid macro filter.' );
667 }
668
669 $name = $matches[1];
670 $arguments = array();
671
672 if ( isset( $matches[2] ) && '' !== trim( $matches[2] ) ) {
673 foreach ( explode( ',', $matches[2] ) as $argument ) {
674 $arguments[] = $this->normalize_numeric( $argument, $name );
675 }
676 }
677
678 switch ( $name ) {
679 case 'ifEmpty':
680 if ( 1 !== count( $arguments ) ) {
681 break;
682 }
683
684 return $this->is_frontend_empty( $value ) ? $arguments[0] : $value;
685
686 case 'length':
687 if ( 0 !== count( $arguments ) ) {
688 break;
689 }
690
691 if ( is_array( $value ) ) {
692 return count( $value );
693 }
694
695 return is_string( $value ) ? $this->frontend_string_length( $value ) : 0;
696
697 case 'toMinuteInMs':
698 return $this->normalize_numeric( $value, $source ) * 60 * 1000;
699
700 case 'toHourInMs':
701 return $this->normalize_numeric( $value, $source ) * 60 * 60 * 1000;
702
703 case 'toDayInMs':
704 return $this->normalize_numeric( $value, $source ) * 24 * 60 * 60 * 1000;
705
706 case 'toWeekInMs':
707 return $this->normalize_numeric( $value, $source ) * 7 * 24 * 60 * 60 * 1000;
708
709 case 'toMonthInMs':
710 return $this->normalize_numeric( $value, $source ) * 30 * 24 * 60 * 60 * 1000;
711
712 case 'toYearInMs':
713 return $this->normalize_numeric( $value, $source ) * 365 * 24 * 60 * 60 * 1000;
714 }
715
716 // Allow integrations to provide an equivalent trusted server filter.
717 $filtered = apply_filters(
718 'jet-form-builder/gateways/trusted-price/apply-macro-filter',
719 null,
720 $value,
721 $name,
722 $arguments,
723 $source,
724 $this
725 );
726
727 if ( null !== $filtered ) {
728 return $filtered;
729 }
730
731 throw new Gateway_Exception(
732 sprintf(
733 'Unsupported calculated-field macro filter "%1$s" in secure gateway pricing.',
734 esc_html( $name )
735 )
736 );
737 }
738
739 /**
740 * Match the frontend isEmpty() semantics used by ifEmpty.
741 *
742 * @param mixed $value
743 */
744 private function is_frontend_empty( $value ): bool {
745 if ( is_bool( $value ) ) {
746 return ! $value;
747 }
748
749 if ( null === $value ) {
750 return true;
751 }
752
753 if ( is_array( $value ) ) {
754 return 0 === count( $value );
755 }
756
757 if ( is_int( $value ) || is_float( $value ) ) {
758 return 0.0 === (float) $value || is_nan( (float) $value );
759 }
760
761 return '' === (string) $value;
762 }
763
764 /**
765 * JavaScript String.length counts UTF-16 code units, not UTF-8 bytes.
766 */
767 private function frontend_string_length( string $value ): int {
768 if ( ! preg_match_all( '/./us', $value, $characters ) ) {
769 return strlen( $value );
770 }
771
772 $length = 0;
773
774 foreach ( $characters[0] as $character ) {
775 $length += 4 === strlen( $character ) ? 2 : 1;
776 }
777
778 return $length;
779 }
780
781 /**
782 * Match Number.prototype.toFixed() without requiring BCMath or GMP.
783 */
784 private function round_to_frontend_precision( float $value, int $precision ): float {
785 if ( ! is_finite( $value ) || abs( $value ) >= 1e21 ) {
786 return $value;
787 }
788
789 return (float) $this->format_frontend_precision( $value, $precision );
790 }
791
792 private function format_frontend_precision( float $value, int $precision ): string {
793 if ( ! is_finite( $value ) || abs( $value ) >= 1e21 ) {
794 return (string) $value;
795 }
796
797 if ( 0.0 === $value ) {
798 return $this->format_fixed_integer( '0', $precision );
799 }
800
801 $is_negative = $value < 0;
802 $hex = $this->double_to_big_endian_hex( abs( $value ) );
803 $exponent = ( ( hexdec( $hex[0] ) & 0x7 ) << 8 ) | hexdec( substr( $hex, 1, 2 ) );
804 $mantissa = $this->decimal_from_hex(
805 ( 0 === $exponent ? '' : '1' ) . substr( $hex, 3 )
806 );
807
808 if ( 0 === $exponent ) {
809 $binary_exponent = -1074;
810 } else {
811 $binary_exponent = $exponent - 1075;
812 }
813
814 $scaled = $mantissa;
815
816 for ( $index = 0; $index < $precision; ++$index ) {
817 $scaled = $this->decimal_multiply( $scaled, 5 );
818 }
819
820 $binary_shift = $binary_exponent + $precision;
821
822 if ( 0 <= $binary_shift ) {
823 for ( $index = 0; $index < $binary_shift; ++$index ) {
824 $scaled = $this->decimal_multiply( $scaled, 2 );
825 }
826 } else {
827 $round_up = 0;
828
829 for ( $index = 0; $index < abs( $binary_shift ); ++$index ) {
830 list( $scaled, $round_up ) = $this->decimal_divide_by_two( $scaled );
831 }
832
833 if ( $round_up ) {
834 $scaled = $this->decimal_increment( $scaled );
835 }
836 }
837
838 $rounded = $this->format_fixed_integer( $scaled, $precision );
839
840 return $is_negative ? '-' . $rounded : $rounded;
841 }
842
843 private function double_to_big_endian_hex( float $value ): string {
844 $parts = unpack( 'H*value', pack( 'd', $value ) );
845 $hex = $parts['value'];
846
847 if ( pack( 'L', 1 ) === pack( 'V', 1 ) ) {
848 $hex = implode( '', array_reverse( str_split( $hex, 2 ) ) );
849 }
850
851 return $hex;
852 }
853
854 private function decimal_from_hex( string $value ): string {
855 $result = '0';
856 $value_length = strlen( $value );
857
858 for ( $index = 0; $index < $value_length; ++$index ) {
859 $result = $this->decimal_multiply( $result, 16 );
860 $result = $this->decimal_add_small( $result, hexdec( $value[ $index ] ) );
861 }
862
863 return $result;
864 }
865
866 private function decimal_multiply( string $value, int $multiplier ): string {
867 $result = '';
868 $carry = 0;
869
870 for ( $index = strlen( $value ) - 1; 0 <= $index; --$index ) {
871 $product = ( (int) $value[ $index ] * $multiplier ) + $carry;
872 $result = (string) ( $product % 10 ) . $result;
873 $carry = intdiv( $product, 10 );
874 }
875
876 while ( $carry ) {
877 $result = (string) ( $carry % 10 ) . $result;
878 $carry = intdiv( $carry, 10 );
879 }
880
881 return ltrim( $result, '0' ) ?: '0';
882 }
883
884 private function decimal_add_small( string $value, int $addend ): string {
885 for ( $index = strlen( $value ) - 1; 0 <= $index && $addend; --$index ) {
886 $sum = (int) $value[ $index ] + $addend;
887 $value[ $index ] = (string) ( $sum % 10 );
888 $addend = intdiv( $sum, 10 );
889 }
890
891 return $addend ? (string) $addend . $value : $value;
892 }
893
894 private function decimal_divide_by_two( string $value ): array {
895 $result = '';
896 $remainder = 0;
897 $value_length = strlen( $value );
898
899 for ( $index = 0; $index < $value_length; ++$index ) {
900 $current = ( $remainder * 10 ) + (int) $value[ $index ];
901 $quotient = intdiv( $current, 2 );
902 $remainder = $current % 2;
903
904 if ( '' !== $result || $quotient ) {
905 $result .= (string) $quotient;
906 }
907 }
908
909 return array( $result ?: '0', $remainder );
910 }
911
912 private function decimal_increment( string $value ): string {
913 return $this->decimal_add_small( $value, 1 );
914 }
915
916 private function format_fixed_integer( string $value, int $precision ): string {
917 if ( 0 === $precision ) {
918 return $value;
919 }
920
921 $value = str_pad( $value, $precision + 1, '0', STR_PAD_LEFT );
922 $split = strlen( $value ) - $precision;
923
924 return substr( $value, 0, $split ) . '.' . substr( $value, $split );
925 }
926
927 /**
928 * @return mixed
929 * @throws Gateway_Exception
930 */
931 private function resolve_macro_value( string $macro_type, string $macro_value ) {
932 switch ( strtolower( $macro_type ) ) {
933 case 'field':
934 return $this->resolve_field_macro_value( $macro_value );
935
936 case 'meta':
937 throw new Gateway_Exception(
938 sprintf(
939 'The calculated-field macro "%%META::%1$s%%" depends on unsigned post context and cannot be used when Secure payment amount is enabled.',
940 esc_html( $macro_value )
941 )
942 );
943
944 default:
945 // Allow integrations to recalculate their macros from trusted server data.
946 $value = apply_filters(
947 'jet-form-builder/gateways/trusted-price/resolve-macro',
948 null,
949 $macro_type,
950 $macro_value,
951 $this
952 );
953
954 if ( null !== $value ) {
955 return $value;
956 }
957
958 throw new Gateway_Exception(
959 sprintf(
960 'Unsupported calculated-field macro "%1$s" in secure gateway pricing.',
961 esc_html( $macro_type )
962 )
963 );
964 }
965 }
966
967 /**
968 * @throws Gateway_Exception
969 */
970 private function evaluate_expression( string $expression ): float {
971 $parser = new Trusted_Price_Resolver_Expression_Parser( $expression );
972
973 return $parser->parse();
974 }
975
976 /**
977 * @throws Gateway_Exception
978 */
979 public function normalize_numeric( $value, string $field_name ): float {
980 if ( '' === $value || null === $value ) {
981 return 0.0;
982 }
983
984 if ( is_array( $value ) ) {
985 throw new Gateway_Exception(
986 sprintf(
987 'The "%1$s" price source produced a non-scalar value.',
988 esc_html( $field_name )
989 )
990 );
991 }
992
993 $value = trim( (string) $value );
994
995 if ( '' === $value ) {
996 return 0.0;
997 }
998
999 if ( ! is_numeric( $value ) ) {
1000 throw new Gateway_Exception(
1001 sprintf(
1002 'The "%1$s" price source produced a non-numeric value.',
1003 esc_html( $field_name )
1004 )
1005 );
1006 }
1007
1008 return (float) $value;
1009 }
1010 }
1011