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