| @@ -11,8 +11,9 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | namespace SRFM\Inc\Payments; |
| 14 | 14 | |
| 15 | +use SRFM\Inc\Database\Tables\Entries; | |
| 15 | 16 | use SRFM\Inc\Field_Validation; |
| 16 | 17 | use SRFM\Inc\Helper; |
| 17 | 18 | use SRFM\Inc\Payments\Stripe\Stripe_Helper; |
| 18 | 19 | |
| @@ -254,8 +255,13 @@ | ||
| 254 | 255 | 'name' => __( 'Norwegian Krone', 'sureforms' ), |
| 255 | 256 | 'symbol' => 'kr', |
| 256 | 257 | 'decimal_places' => 2, |
| 257 | 258 | ], |
| 259 | + 'PLN' => [ | |
| 260 | + 'name' => __( 'Polish Złoty', 'sureforms' ), | |
| 261 | + 'symbol' => 'zł', | |
| 262 | + 'decimal_places' => 2, | |
| 263 | + ], | |
| 258 | 264 | 'KRW' => [ |
| 259 | 265 | 'name' => __( 'South Korean Won', 'sureforms' ), |
| 260 | 266 | 'symbol' => '₩', |
| 261 | 267 | 'decimal_places' => 0, |
| @@ -477,8 +483,9 @@ | ||
| 477 | 483 | 'payment_unavailable' => __( 'Payment is currently unavailable. Please contact the site administrator.', 'sureforms' ), |
| 478 | 484 | 'payment_amount_not_configured' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.', 'sureforms' ), |
| 479 | 485 | 'invalid_variable_amount' => __( 'Invalid payment amount', 'sureforms' ), |
| 480 | 486 | 'amount_below_minimum' => __( 'Payment amount must be at least {symbol}{amount}.', 'sureforms' ), |
| 487 | + 'payment_required' => __( 'This form requires a payment. Please complete the payment and submit again.', 'sureforms' ), | |
| 481 | 488 | |
| 482 | 489 | // Field mapping validation. |
| 483 | 490 | 'payment_name_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer name field.', 'sureforms' ), |
| 484 | 491 | 'payment_email_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer email field.', 'sureforms' ), |
| @@ -569,9 +576,10 @@ | ||
| 569 | 576 | * @since 2.2.2 |
| 570 | 577 | * @param int|float $amount Amount in smallest currency unit (e.g., cents for USD). |
| 571 | 578 | * @param string $currency Currency code (e.g., 'usd', 'eur'). |
| 572 | 579 | * @param int $form_id WordPress post ID of the form. |
| 573 | - * @param string $block_id Block identifier for the payment block. | |
| 580 | + * @param string $block_id Block identifier for the payment block. | |
| 581 | + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution. | |
| 574 | 582 | * @return array { |
| 575 | 583 | * Validation result. |
| 576 | 584 | * |
| 577 | 585 | * @type bool $valid Whether the validation passed. |
| @@ -577,9 +585,9 @@ | ||
| 577 | 585 | * @type bool $valid Whether the validation passed. |
| 578 | 586 | * @type string $message Error message if validation failed, empty if valid. |
| 579 | 587 | * } |
| 580 | 588 | */ |
| 581 | - public static function validate_payment_amount( $amount, $currency, $form_id, $block_id ) { | |
| 589 | + public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $active_type = '' ) { | |
| 582 | 590 | // Retrieve block configuration from post meta. |
| 583 | 591 | $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id ); |
| 584 | 592 | |
| 585 | 593 | // Check if block config exists. |
| @@ -608,15 +616,33 @@ | ||
| 608 | 616 | 'message' => sprintf( __( 'Currency mismatch: expected %1$s, received %2$s.', 'sureforms' ), strtoupper( $global_currency ), strtoupper( $submitted_currency ) ), |
| 609 | 617 | ]; |
| 610 | 618 | } |
| 611 | 619 | |
| 620 | + // Reject when the requested flow (one-time vs subscription) is not allowed by | |
| 621 | + // the form's stored payment_type. "both" mode allows either flow; pure modes | |
| 622 | + // allow only their matching flow. Without this guard, an attacker could call | |
| 623 | + // the wrong intent-creation route on a pure-subscription form and pay once for | |
| 624 | + // what should be a recurring charge (or vice versa). | |
| 625 | + $payment_type = isset( $payment_config['payment_type'] ) && is_string( $payment_config['payment_type'] ) ? $payment_config['payment_type'] : 'one-time'; | |
| 626 | + if ( ! empty( $active_type ) && 'both' !== $payment_type && $active_type !== $payment_type ) { | |
| 627 | + return [ | |
| 628 | + 'valid' => false, | |
| 629 | + 'message' => __( 'Payment type does not match the form configuration.', 'sureforms' ), | |
| 630 | + ]; | |
| 631 | + } | |
| 632 | + | |
| 633 | + // BOTH MODE: when payment_type is 'both', resolve the correct per-type | |
| 634 | + // config (amount_type, fixed_amount, minimum_amount, variable_amount_field) | |
| 635 | + // based on which flow the user actually chose (one-time vs subscription). | |
| 636 | + $resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type ); | |
| 637 | + | |
| 612 | 638 | // Get amount type (fixed or minimum). |
| 613 | - $amount_type = $payment_config['amount_type'] ?? 'fixed'; | |
| 639 | + $amount_type = $resolved_config['amount_type'] ?? 'fixed'; | |
| 614 | 640 | |
| 615 | 641 | // Validate based on amount type. |
| 616 | 642 | if ( 'fixed' === $amount_type ) { |
| 617 | 643 | // Fixed amount validation - must match exactly. |
| 618 | - $configured_amount = isset( $payment_config['fixed_amount'] ) ? floatval( $payment_config['fixed_amount'] ) : 10.00; | |
| 644 | + $configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 10.00; | |
| 619 | 645 | |
| 620 | 646 | // Allow small floating point difference (0.01) due to rounding. |
| 621 | 647 | if ( abs( $amount - $configured_amount ) > 0.01 ) { |
| 622 | 648 | return [ |
| @@ -626,9 +652,9 @@ | ||
| 626 | 652 | ]; |
| 627 | 653 | } |
| 628 | 654 | } elseif ( 'variable' === $amount_type ) { |
| 629 | 655 | // Minimum amount validation - must be >= minimum. |
| 630 | - $minimum_amount = isset( $payment_config['minimum_amount'] ) ? floatval( $payment_config['minimum_amount'] ) : 0; | |
| 656 | + $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0; | |
| 631 | 657 | |
| 632 | 658 | if ( $amount < $minimum_amount ) { |
| 633 | 659 | return [ |
| 634 | 660 | 'valid' => false, |
| @@ -638,9 +664,9 @@ | ||
| 638 | 664 | } |
| 639 | 665 | |
| 640 | 666 | // Validate dynamic amount from dropdown/multi-choice field. |
| 641 | 667 | $dynamic_amount_validation = self::validate_dynamic_amount_field( |
| 642 | - $payment_config, | |
| 668 | + $resolved_config, | |
| 643 | 669 | $block_config, |
| 644 | 670 | $amount, |
| 645 | 671 | $currency |
| 646 | 672 | ); |
| @@ -693,8 +719,9 @@ | ||
| 693 | 719 | * @since 2.3.0 |
| 694 | 720 | * @param string $block_id Block identifier. |
| 695 | 721 | * @param string $payment_intent_id Payment intent ID from Stripe. |
| 696 | 722 | * @param array<string, mixed> $form_data Submitted form data. |
| 723 | + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution. | |
| 697 | 724 | * @return array { |
| 698 | 725 | * Verification result. |
| 699 | 726 | * |
| 700 | 727 | * @type bool $valid Whether verification passed. |
| @@ -700,9 +727,9 @@ | ||
| 700 | 727 | * @type bool $valid Whether verification passed. |
| 701 | 728 | * @type string $message Error message if verification failed, empty if valid. |
| 702 | 729 | * } |
| 703 | 730 | */ |
| 704 | - public static function verify_payment_intent( $block_id, $payment_intent_id, $form_data ) { | |
| 731 | + public static function verify_payment_intent( $block_id, $payment_intent_id, $form_data, $active_type = '' ) { | |
| 705 | 732 | // Get form ID from form data for verification. |
| 706 | 733 | $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0; |
| 707 | 734 | |
| 708 | 735 | // Validate required parameters. |
| @@ -723,12 +750,24 @@ | ||
| 723 | 750 | 'message' => __( 'Payment verification failed. Invalid payment intent.', 'sureforms' ), |
| 724 | 751 | ]; |
| 725 | 752 | } |
| 726 | 753 | |
| 754 | + // Reject when the submit path's active_type does not match the type that | |
| 755 | + // was validated at intent-creation time. Prevents an attacker from passing | |
| 756 | + // a one-time intent_id through the subscription submit path (or vice versa) | |
| 757 | + // to replay a small one-time charge in place of a recurring subscription. | |
| 758 | + $stored_active_type = isset( $metadata['active_type'] ) && is_string( $metadata['active_type'] ) ? $metadata['active_type'] : ''; | |
| 759 | + if ( ! empty( $active_type ) && ! empty( $stored_active_type ) && $active_type !== $stored_active_type ) { | |
| 760 | + return [ | |
| 761 | + 'valid' => false, | |
| 762 | + 'message' => __( 'Payment verification failed. Payment type mismatch.', 'sureforms' ), | |
| 763 | + ]; | |
| 764 | + } | |
| 765 | + | |
| 727 | 766 | $payment_amount = isset( $metadata['amount'] ) && ! empty( $metadata['amount'] ) && is_numeric( $metadata['amount'] ) ? floatval( $metadata['amount'] ) : 0; |
| 728 | 767 | |
| 729 | 768 | // Validate payment amount matches configuration. |
| 730 | - $amount_validation = self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount ); | |
| 769 | + $amount_validation = self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type ); | |
| 731 | 770 | |
| 732 | 771 | if ( false === $amount_validation['valid'] ) { |
| 733 | 772 | return $amount_validation; |
| 734 | 773 | } |
| @@ -740,8 +779,27 @@ | ||
| 740 | 779 | ]; |
| 741 | 780 | } |
| 742 | 781 | |
| 743 | 782 | /** |
| 783 | + * Validate an arbitrary amount against the form's server-side payment configuration. | |
| 784 | + * | |
| 785 | + * Public wrapper around the amount validator so the submission flow can re-check the amount | |
| 786 | + * Stripe actually charged (defense-in-depth) — not only the amount recorded when the intent was | |
| 787 | + * created. | |
| 788 | + * | |
| 789 | + * @param string $block_id Block identifier. | |
| 790 | + * @param int $form_id Form post ID. | |
| 791 | + * @param array<string, mixed> $form_data Submitted form data. | |
| 792 | + * @param float $amount Amount to validate (decimal, in the form currency). | |
| 793 | + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution. | |
| 794 | + * @since 2.11.1 | |
| 795 | + * @return array<string, mixed> Validation result with 'valid' (bool) and 'message' (string) keys. | |
| 796 | + */ | |
| 797 | + public static function validate_amount_against_config( $block_id, $form_id, $form_data, $amount, $active_type = '' ) { | |
| 798 | + return self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $amount, $active_type ); | |
| 799 | + } | |
| 800 | + | |
| 801 | + /** | |
| 744 | 802 | * Delete payment intent metadata from transient. |
| 745 | 803 | * |
| 746 | 804 | * Cleans up stored metadata after successful payment verification. |
| 747 | 805 | * |
| @@ -773,8 +831,244 @@ | ||
| 773 | 831 | return ! empty( $result ) && is_string( $result ) ? $result : 'left'; |
| 774 | 832 | } |
| 775 | 833 | |
| 776 | 834 | /** |
| 835 | + * Get a submitted form value by field slug. | |
| 836 | + * | |
| 837 | + * Matches the SureForms field-name convention `{block}-{block_id}-lbl-{label}-{slug}` by | |
| 838 | + * suffix, regardless of block type. Used to resolve `{form:slug}` tokens when recomputing a | |
| 839 | + * calculation server-side. Returns null when the slug is not present in the submission. | |
| 840 | + * | |
| 841 | + * @param string $slug The field slug to look up. | |
| 842 | + * @param array<mixed> $form_data Submitted form data. | |
| 843 | + * @since 2.11.1 | |
| 844 | + * @return mixed|null The submitted value, or null when not found. | |
| 845 | + */ | |
| 846 | + public static function get_submitted_value_by_slug( $slug, $form_data ) { | |
| 847 | + if ( empty( $slug ) || ! is_string( $slug ) || ! is_array( $form_data ) ) { | |
| 848 | + return null; | |
| 849 | + } | |
| 850 | + | |
| 851 | + $suffix = '-' . $slug; | |
| 852 | + foreach ( $form_data as $field_key => $field_value ) { | |
| 853 | + if ( ! is_string( $field_key ) || false === strpos( $field_key, '-lbl-' ) ) { | |
| 854 | + continue; | |
| 855 | + } | |
| 856 | + | |
| 857 | + if ( substr( $field_key, -strlen( $suffix ) ) === $suffix ) { | |
| 858 | + return $field_value; | |
| 859 | + } | |
| 860 | + } | |
| 861 | + | |
| 862 | + return null; | |
| 863 | + } | |
| 864 | + | |
| 865 | + /** | |
| 866 | + * Get the payment methods a payment block can actually offer. | |
| 867 | + * | |
| 868 | + * The block's enabled methods intersected with the methods that are registered | |
| 869 | + * and connected. Shared with Payment_Markup so the renderer and the submission | |
| 870 | + * guard can never disagree about whether a payment field is usable. | |
| 871 | + * | |
| 872 | + * @param array<mixed> $attrs Payment block attributes. | |
| 873 | + * | |
| 874 | + * @since 2.12.3 | |
| 875 | + * @return array<string, mixed> Usable payment methods, keyed by method ID. | |
| 876 | + */ | |
| 877 | + public static function get_registered_payment_methods( $attrs ) { | |
| 878 | + $methods = []; | |
| 879 | + $attrs = is_array( $attrs ) ? $attrs : []; | |
| 880 | + $enabled_methods = isset( $attrs['paymentMethods'] ) && is_array( $attrs['paymentMethods'] ) ? $attrs['paymentMethods'] : [ 'stripe' ]; | |
| 881 | + | |
| 882 | + // Filter to get method configurations - start with Stripe as default. | |
| 883 | + $available_methods = apply_filters( | |
| 884 | + 'srfm_payment_methods_registry', | |
| 885 | + [ | |
| 886 | + 'stripe' => [ | |
| 887 | + 'id' => 'stripe', | |
| 888 | + 'label' => __( 'Stripe', 'sureforms' ), | |
| 889 | + 'description' => __( 'Pay with credit or debit card', 'sureforms' ), | |
| 890 | + 'icon' => 'credit-card', | |
| 891 | + 'enabled' => Stripe_Helper::is_stripe_connected(), | |
| 892 | + 'container_class' => 'srfm-stripe-payment-element', | |
| 893 | + ], | |
| 894 | + ] | |
| 895 | + ); | |
| 896 | + | |
| 897 | + // Filter enabled methods. | |
| 898 | + foreach ( $enabled_methods as $method_id ) { | |
| 899 | + if ( is_array( $available_methods ) && isset( $available_methods[ $method_id ] ) && ! empty( $available_methods[ $method_id ]['enabled'] ) ) { | |
| 900 | + $methods[ $method_id ] = $available_methods[ $method_id ]; | |
| 901 | + } | |
| 902 | + } | |
| 903 | + | |
| 904 | + return $methods; | |
| 905 | + } | |
| 906 | + | |
| 907 | + /** | |
| 908 | + * Whether a payment block's configuration produces a usable payment field. | |
| 909 | + * | |
| 910 | + * Mirrors the conditions under which Payment_Markup::markup() returns early with | |
| 911 | + * no markup: no usable payment method, or the customer field mappings the gateway | |
| 912 | + * needs are missing. A block that renders nothing cannot be required on submit. | |
| 913 | + * | |
| 914 | + * @param array<mixed> $attrs Payment block attributes. | |
| 915 | + * | |
| 916 | + * @since 2.12.3 | |
| 917 | + * @return bool True when the block renders a payment field. | |
| 918 | + */ | |
| 919 | + public static function is_payment_field_active( $attrs ) { | |
| 920 | + if ( ! is_array( $attrs ) ) { | |
| 921 | + return false; | |
| 922 | + } | |
| 923 | + | |
| 924 | + if ( empty( self::get_registered_payment_methods( $attrs ) ) ) { | |
| 925 | + return false; | |
| 926 | + } | |
| 927 | + | |
| 928 | + // Customer field mappings, including the legacy subscriptionPlan fallbacks. | |
| 929 | + $subscription_plan = isset( $attrs['subscriptionPlan'] ) && is_array( $attrs['subscriptionPlan'] ) ? $attrs['subscriptionPlan'] : []; | |
| 930 | + $email_field = ! empty( $attrs['customerEmailField'] ) ? $attrs['customerEmailField'] : ( $subscription_plan['customer_email'] ?? '' ); | |
| 931 | + | |
| 932 | + if ( empty( $email_field ) ) { | |
| 933 | + return false; | |
| 934 | + } | |
| 935 | + | |
| 936 | + $payment_type = ! empty( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] ) ? $attrs['paymentType'] : 'one-time'; | |
| 937 | + | |
| 938 | + // A subscription path also needs the customer name mapping. | |
| 939 | + if ( in_array( $payment_type, [ 'subscription', 'both' ], true ) ) { | |
| 940 | + $name_field = ! empty( $attrs['customerNameField'] ) ? $attrs['customerNameField'] : ( $subscription_plan['customer_name'] ?? '' ); | |
| 941 | + | |
| 942 | + if ( empty( $name_field ) ) { | |
| 943 | + return false; | |
| 944 | + } | |
| 945 | + } | |
| 946 | + | |
| 947 | + return true; | |
| 948 | + } | |
| 949 | + | |
| 950 | + /** | |
| 951 | + * Get the block IDs of payment fields a submission of this form must pay for. | |
| 952 | + * | |
| 953 | + * Derived from the stored form, never from what the client submitted. Blocks that | |
| 954 | + * render nothing (see is_payment_field_active()) and blocks under conditional logic | |
| 955 | + * are excluded: conditional logic is evaluated on the client, so a hidden payment | |
| 956 | + * field legitimately submits no payment value and must not be required here. | |
| 957 | + * | |
| 958 | + * @param int $form_id Form ID. | |
| 959 | + * | |
| 960 | + * @since 2.12.3 | |
| 961 | + * @return array<string> Block IDs that require a verified payment. | |
| 962 | + */ | |
| 963 | + public static function get_required_payment_block_ids( $form_id ) { | |
| 964 | + // absint(), matching Submit_Token::verify()'s normalisation in | |
| 965 | + // Form_Submit::submit_form_permissions_check(). get_integer_value() would keep a | |
| 966 | + // negative id and bail below, so the guard would resolve a different form from | |
| 967 | + // the one the submit token authorised. | |
| 968 | + $form_id = absint( $form_id ); | |
| 969 | + $form = $form_id > 0 ? get_post( $form_id ) : null; | |
| 970 | + | |
| 971 | + if ( ! $form instanceof \WP_Post || '' === $form->post_content ) { | |
| 972 | + return []; | |
| 973 | + } | |
| 974 | + | |
| 975 | + $block_ids = self::collect_active_payment_block_ids( parse_blocks( $form->post_content ) ); | |
| 976 | + | |
| 977 | + if ( empty( $block_ids ) ) { | |
| 978 | + return []; | |
| 979 | + } | |
| 980 | + | |
| 981 | + // Drop blocks that conditional logic can hide. | |
| 982 | + foreach ( self::get_conditional_logic_block_ids( $form_id ) as $conditional_id ) { | |
| 983 | + unset( $block_ids[ $conditional_id ] ); | |
| 984 | + } | |
| 985 | + | |
| 986 | + /** | |
| 987 | + * Filters the payment block IDs that require a verified payment on submit. | |
| 988 | + * | |
| 989 | + * Lets add-ons that can evaluate their own visibility rules server-side add or | |
| 990 | + * remove blocks — e.g. re-adding a conditionally shown payment field once the | |
| 991 | + * rule is known to have matched. | |
| 992 | + * | |
| 993 | + * @since 2.12.3 | |
| 994 | + * | |
| 995 | + * @param array<string> $block_ids Block IDs requiring a verified payment. | |
| 996 | + * @param int $form_id Form ID. | |
| 997 | + */ | |
| 998 | + $block_ids = apply_filters( 'srfm_required_payment_block_ids', array_keys( $block_ids ), $form_id ); | |
| 999 | + | |
| 1000 | + return is_array( $block_ids ) ? $block_ids : []; | |
| 1001 | + } | |
| 1002 | + | |
| 1003 | + /** | |
| 1004 | + * Resolve the WordPress user associated with a payment record. | |
| 1005 | + * | |
| 1006 | + * Resolution order: | |
| 1007 | + * 1. The linked entry's `user_id` (set when a logged-in user submitted the form). | |
| 1008 | + * 2. A user matching the payment's `customer_email`. | |
| 1009 | + * 3. `0` for guest checkouts where no WordPress user can be resolved. | |
| 1010 | + * | |
| 1011 | + * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row). | |
| 1012 | + * @return int Resolved WordPress user ID, or 0 when none can be determined. | |
| 1013 | + * @since 2.12.0 | |
| 1014 | + */ | |
| 1015 | + public static function resolve_payment_user( $payment ) { | |
| 1016 | + if ( ! is_array( $payment ) ) { | |
| 1017 | + return 0; | |
| 1018 | + } | |
| 1019 | + | |
| 1020 | + // 1. Prefer the user_id stored on the linked entry. | |
| 1021 | + $entry_id = ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0; | |
| 1022 | + if ( $entry_id > 0 ) { | |
| 1023 | + $entry = Entries::get( $entry_id ); | |
| 1024 | + if ( is_array( $entry ) && ! empty( $entry['user_id'] ) && is_numeric( $entry['user_id'] ) ) { | |
| 1025 | + $user_id = intval( $entry['user_id'] ); | |
| 1026 | + if ( $user_id > 0 ) { | |
| 1027 | + return $user_id; | |
| 1028 | + } | |
| 1029 | + } | |
| 1030 | + } | |
| 1031 | + | |
| 1032 | + // 2. Fall back to a user matching the customer email. | |
| 1033 | + $customer_email = ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : ''; | |
| 1034 | + if ( ! empty( $customer_email ) ) { | |
| 1035 | + $user = get_user_by( 'email', $customer_email ); | |
| 1036 | + if ( $user instanceof \WP_User ) { | |
| 1037 | + return intval( $user->ID ); | |
| 1038 | + } | |
| 1039 | + } | |
| 1040 | + | |
| 1041 | + // 3. Guest checkout — no resolvable WordPress user. | |
| 1042 | + return 0; | |
| 1043 | + } | |
| 1044 | + | |
| 1045 | + /** | |
| 1046 | + * Build the standard context array passed alongside payment-lifecycle actions. | |
| 1047 | + * | |
| 1048 | + * Gives consumers (membership, LMS and other plugins) a consistent, resolved | |
| 1049 | + * snapshot of who paid and through which form/gateway, without each consumer | |
| 1050 | + * having to re-derive it from the raw payment row. | |
| 1051 | + * | |
| 1052 | + * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row). | |
| 1053 | + * @return array{form_id:int, entry_id:int, user_id:int, customer_email:string, type:string, gateway:string, mode:string} Resolved payment context. | |
| 1054 | + * @since 2.12.0 | |
| 1055 | + */ | |
| 1056 | + public static function build_payment_context( $payment ) { | |
| 1057 | + $payment = is_array( $payment ) ? $payment : []; | |
| 1058 | + | |
| 1059 | + return [ | |
| 1060 | + 'form_id' => ! empty( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? intval( $payment['form_id'] ) : 0, | |
| 1061 | + 'entry_id' => ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0, | |
| 1062 | + 'user_id' => self::resolve_payment_user( $payment ), | |
| 1063 | + 'customer_email' => ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '', | |
| 1064 | + 'type' => ! empty( $payment['type'] ) && is_string( $payment['type'] ) ? sanitize_text_field( $payment['type'] ) : '', | |
| 1065 | + 'gateway' => ! empty( $payment['gateway'] ) && is_string( $payment['gateway'] ) ? sanitize_text_field( $payment['gateway'] ) : '', | |
| 1066 | + 'mode' => ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? sanitize_text_field( $payment['mode'] ) : '', | |
| 1067 | + ]; | |
| 1068 | + } | |
| 1069 | + | |
| 1070 | + /** | |
| 777 | 1071 | * Validate dynamic amount field from dropdown or multi-choice. |
| 778 | 1072 | * |
| 779 | 1073 | * @param array<string, mixed> $payment_config Payment block configuration. |
| 780 | 1074 | * @param array<string, mixed> $block_config All block configurations. |
| @@ -782,8 +1076,204 @@ | ||
| 782 | 1076 | * @param string $currency Currency code. |
| 783 | 1077 | * @return array|null Validation result array or null if validation passes. |
| 784 | 1078 | * @since 2.3.0 |
| 785 | 1079 | */ |
| 1080 | + /** | |
| 1081 | + * Recursively collect the block IDs of payment blocks that render a payment field. | |
| 1082 | + * | |
| 1083 | + * Recurses into innerBlocks and expands core/block reusable/synced patterns, so a | |
| 1084 | + * payment block that renders from inside a pattern is still required. The payment | |
| 1085 | + * block sets "reusable": false, so reaching that state needs imported or | |
| 1086 | + * hand-authored post_content rather than the editor — but a payment field that | |
| 1087 | + * renders and is not required is exactly the hole this guard exists to close. | |
| 1088 | + * | |
| 1089 | + * @param array<mixed> $blocks Parsed blocks from parse_blocks(). | |
| 1090 | + * @param array<int, true> $visited_refs Reusable-block post IDs already expanded, | |
| 1091 | + * keyed by ID — guards against reference cycles. | |
| 1092 | + * | |
| 1093 | + * @since 2.12.3 | |
| 1094 | + * @return array<string,true> Active payment block IDs, keyed by block ID. | |
| 1095 | + */ | |
| 1096 | + private static function collect_active_payment_block_ids( $blocks, &$visited_refs = [] ) { | |
| 1097 | + $block_ids = []; | |
| 1098 | + | |
| 1099 | + foreach ( $blocks as $block ) { | |
| 1100 | + if ( ! is_array( $block ) ) { | |
| 1101 | + continue; | |
| 1102 | + } | |
| 1103 | + | |
| 1104 | + $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; | |
| 1105 | + | |
| 1106 | + if ( 'srfm/payment' === ( $block['blockName'] ?? '' ) | |
| 1107 | + && ! empty( $attrs['block_id'] ) | |
| 1108 | + && is_scalar( $attrs['block_id'] ) | |
| 1109 | + && self::is_payment_field_active( $attrs ) | |
| 1110 | + ) { | |
| 1111 | + $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true; | |
| 1112 | + } | |
| 1113 | + | |
| 1114 | + if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) { | |
| 1115 | + $ref = absint( $attrs['ref'] ); | |
| 1116 | + | |
| 1117 | + if ( $ref && ! isset( $visited_refs[ $ref ] ) ) { | |
| 1118 | + $visited_refs[ $ref ] = true; | |
| 1119 | + $ref_post = get_post( $ref ); | |
| 1120 | + | |
| 1121 | + if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && '' !== $ref_post->post_content ) { | |
| 1122 | + $block_ids += self::collect_active_payment_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs ); | |
| 1123 | + } | |
| 1124 | + } | |
| 1125 | + } | |
| 1126 | + | |
| 1127 | + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { | |
| 1128 | + $block_ids += self::collect_active_payment_block_ids( $block['innerBlocks'], $visited_refs ); | |
| 1129 | + } | |
| 1130 | + } | |
| 1131 | + | |
| 1132 | + return $block_ids; | |
| 1133 | + } | |
| 1134 | + | |
| 1135 | + /** | |
| 1136 | + * Get the block IDs a form has conditional logic rules for. | |
| 1137 | + * | |
| 1138 | + * Field visibility rules live in the `_srfm_conditional_logic` post meta, keyed by | |
| 1139 | + * block ID, and are evaluated on the client. A payment field under such a rule may | |
| 1140 | + * legitimately be hidden at submit time. | |
| 1141 | + * | |
| 1142 | + * @param int $form_id Form ID. | |
| 1143 | + * | |
| 1144 | + * @since 2.12.3 | |
| 1145 | + * @return array<string> Block IDs carrying conditional logic rules. | |
| 1146 | + */ | |
| 1147 | + private static function get_conditional_logic_block_ids( $form_id ) { | |
| 1148 | + // Only exempt when conditional logic can actually hide anything. The rules are | |
| 1149 | + // evaluated client-side by the add-on that registers this meta; without it | |
| 1150 | + // nothing hides, so a stale rule (e.g. written by a form importer on a site that | |
| 1151 | + // never had the add-on) must not buy a payment block an exemption. | |
| 1152 | + if ( ! registered_meta_key_exists( 'post', '_srfm_conditional_logic', SRFM_FORMS_POST_TYPE ) ) { | |
| 1153 | + return []; | |
| 1154 | + } | |
| 1155 | + | |
| 1156 | + $conditional_logic = get_post_meta( $form_id, '_srfm_conditional_logic', true ); | |
| 1157 | + | |
| 1158 | + if ( ! is_array( $conditional_logic ) ) { | |
| 1159 | + return []; | |
| 1160 | + } | |
| 1161 | + | |
| 1162 | + $block_ids = []; | |
| 1163 | + | |
| 1164 | + foreach ( $conditional_logic as $item ) { | |
| 1165 | + if ( ! is_array( $item ) ) { | |
| 1166 | + continue; | |
| 1167 | + } | |
| 1168 | + | |
| 1169 | + foreach ( $item as $block_id => $rule ) { | |
| 1170 | + if ( ! is_string( $block_id ) || '' === $block_id ) { | |
| 1171 | + continue; | |
| 1172 | + } | |
| 1173 | + | |
| 1174 | + // An actionable rule only — a rule with no conditions can never match, so | |
| 1175 | + // the field's visibility is never altered and payment stays required. | |
| 1176 | + if ( self::has_actionable_conditional_rule( $rule ) ) { | |
| 1177 | + $block_ids[] = $block_id; | |
| 1178 | + } | |
| 1179 | + } | |
| 1180 | + } | |
| 1181 | + | |
| 1182 | + return $block_ids; | |
| 1183 | + } | |
| 1184 | + | |
| 1185 | + /** | |
| 1186 | + * Whether a stored conditional-logic rule can actually change a field's visibility. | |
| 1187 | + * | |
| 1188 | + * Both `show` and `hide` actions can leave the field hidden for a given submission | |
| 1189 | + * (show = hidden until the conditions match, hide = visible until they match), so the | |
| 1190 | + * action itself is not the discriminator — the presence of at least one real condition | |
| 1191 | + * is. Empty or malformed rules are left behind by editor cleanup and must not exempt | |
| 1192 | + * a payment block. | |
| 1193 | + * | |
| 1194 | + * @param mixed $rule Stored rule for a single block. | |
| 1195 | + * | |
| 1196 | + * @since 2.12.3 | |
| 1197 | + * @return bool True when the rule carries at least one condition. | |
| 1198 | + */ | |
| 1199 | + private static function has_actionable_conditional_rule( $rule ) { | |
| 1200 | + if ( ! is_array( $rule ) || empty( $rule['action'] ) || empty( $rule['logic'] ) || ! is_array( $rule['logic'] ) ) { | |
| 1201 | + return false; | |
| 1202 | + } | |
| 1203 | + | |
| 1204 | + foreach ( $rule['logic'] as $conditions ) { | |
| 1205 | + if ( ! is_array( $conditions ) ) { | |
| 1206 | + continue; | |
| 1207 | + } | |
| 1208 | + | |
| 1209 | + foreach ( $conditions as $condition ) { | |
| 1210 | + if ( is_array( $condition ) && ! empty( $condition['field'] ) ) { | |
| 1211 | + return true; | |
| 1212 | + } | |
| 1213 | + } | |
| 1214 | + } | |
| 1215 | + | |
| 1216 | + return false; | |
| 1217 | + } | |
| 1218 | + | |
| 1219 | + /** | |
| 1220 | + * BOTH MODE: resolve the correct amount config keys from the payment block | |
| 1221 | + * config based on which flow (one-time or subscription) the user chose. | |
| 1222 | + * | |
| 1223 | + * For pure one-time / subscription blocks, the config already has the correct | |
| 1224 | + * scalar keys (amount_type, fixed_amount, minimum_amount, etc.) so this method | |
| 1225 | + * returns them unchanged. For "both" blocks, it remaps the per-type keys | |
| 1226 | + * (one_time_* or subscription_*) into the scalar positions the validation | |
| 1227 | + * functions expect. | |
| 1228 | + * | |
| 1229 | + * @param array<mixed> $payment_config Full block config from _srfm_block_config. | |
| 1230 | + * @param string $active_type 'one-time' or 'subscription' — which flow is active. | |
| 1231 | + * @return array<mixed> Config array with amount_type, fixed_amount, minimum_amount, | |
| 1232 | + * variable_amount_field, variable_amount_field_block_name resolved | |
| 1233 | + * for the active type. | |
| 1234 | + * @since 2.8.2 | |
| 1235 | + */ | |
| 1236 | + private static function resolve_payment_config_for_active_type( $payment_config, $active_type ) { | |
| 1237 | + // Only remap when the block is in "both" mode and the caller told us the active type. | |
| 1238 | + if ( 'both' !== ( $payment_config['payment_type'] ?? '' ) || empty( $active_type ) ) { | |
| 1239 | + return $payment_config; | |
| 1240 | + } | |
| 1241 | + | |
| 1242 | + $prefix = 'subscription' === $active_type ? 'subscription_' : 'one_time_'; | |
| 1243 | + | |
| 1244 | + $resolved = $payment_config; // Keep all original keys as fallback. | |
| 1245 | + | |
| 1246 | + if ( isset( $payment_config[ $prefix . 'amount_type' ] ) ) { | |
| 1247 | + $resolved['amount_type'] = $payment_config[ $prefix . 'amount_type' ]; | |
| 1248 | + } | |
| 1249 | + if ( isset( $payment_config[ $prefix . 'fixed_amount' ] ) ) { | |
| 1250 | + $resolved['fixed_amount'] = (float) $payment_config[ $prefix . 'fixed_amount' ]; | |
| 1251 | + } | |
| 1252 | + if ( isset( $payment_config[ $prefix . 'minimum_amount' ] ) ) { | |
| 1253 | + $resolved['minimum_amount'] = (float) $payment_config[ $prefix . 'minimum_amount' ]; | |
| 1254 | + } | |
| 1255 | + if ( isset( $payment_config[ $prefix . 'variable_amount_field' ] ) ) { | |
| 1256 | + $resolved['variable_amount_field'] = $payment_config[ $prefix . 'variable_amount_field' ]; | |
| 1257 | + } | |
| 1258 | + if ( isset( $payment_config[ $prefix . 'variable_amount_field_block_name' ] ) ) { | |
| 1259 | + $resolved['variable_amount_field_block_name'] = $payment_config[ $prefix . 'variable_amount_field_block_name' ]; | |
| 1260 | + } | |
| 1261 | + | |
| 1262 | + return $resolved; | |
| 1263 | + } | |
| 1264 | + | |
| 1265 | + /** | |
| 1266 | + * Validate that a submitted dynamic amount matches one of the options configured | |
| 1267 | + * on a linked dropdown/multi-choice block (when single-selection is enabled). | |
| 1268 | + * | |
| 1269 | + * @since 2.8.2 | |
| 1270 | + * @param array<string, mixed> $payment_config Resolved payment block config (active for current mode). | |
| 1271 | + * @param array<string, mixed> $block_config All form block configs keyed by block_id. | |
| 1272 | + * @param float $submitted_amount_decimal Submitted amount as a decimal (not smallest unit). | |
| 1273 | + * @param string $currency ISO currency code. | |
| 1274 | + * @return array<string, mixed>|null Validation result array with 'valid' + 'message', or null when no validation is required. | |
| 1275 | + */ | |
| 786 | 1276 | private static function validate_dynamic_amount_field( $payment_config, $block_config, $submitted_amount_decimal, $currency ) { |
| 787 | 1277 | // Check if variable amount field is from dropdown or multi-choice block. |
| 788 | 1278 | $dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? ''; |
| 789 | 1279 | |
| @@ -872,8 +1362,9 @@ | ||
| 872 | 1362 | * @param string $block_id Block identifier. |
| 873 | 1363 | * @param int $form_id Form post ID. |
| 874 | 1364 | * @param array<string, mixed> $form_data Submitted form data. |
| 875 | 1365 | * @param int|float $payment_amount Payment amount from Stripe (in smallest currency unit). |
| 1366 | + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution. | |
| 876 | 1367 | * @return array { |
| 877 | 1368 | * Validation result. |
| 878 | 1369 | * |
| 879 | 1370 | * @type bool $valid Whether validation passed. |
| @@ -879,9 +1370,9 @@ | ||
| 879 | 1370 | * @type bool $valid Whether validation passed. |
| 880 | 1371 | * @type string $message Error message if validation failed, empty if valid. |
| 881 | 1372 | * } |
| 882 | 1373 | */ |
| 883 | - private static function validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount ) { | |
| 1374 | + private static function validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type = '' ) { | |
| 884 | 1375 | // Get block configuration. |
| 885 | 1376 | $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id ); |
| 886 | 1377 | |
| 887 | 1378 | if ( empty( $block_config ) || ! isset( $block_config[ $block_id ] ) ) { |
| @@ -891,14 +1382,15 @@ | ||
| 891 | 1382 | 'message' => __( 'Payment configuration not found.', 'sureforms' ), |
| 892 | 1383 | ]; |
| 893 | 1384 | } |
| 894 | 1385 | |
| 895 | - $payment_config = $block_config[ $block_id ]; | |
| 896 | - $amount_type = $payment_config['amount_type'] ?? 'fixed'; | |
| 1386 | + $payment_config = $block_config[ $block_id ]; | |
| 1387 | + $resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type ); | |
| 1388 | + $amount_type = $resolved_config['amount_type'] ?? 'fixed'; | |
| 897 | 1389 | |
| 898 | 1390 | // For fixed amounts, validate against configured amount. |
| 899 | 1391 | if ( 'fixed' === $amount_type ) { |
| 900 | - $configured_amount = isset( $payment_config['fixed_amount'] ) ? floatval( $payment_config['fixed_amount'] ) : 0; | |
| 1392 | + $configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 0; | |
| 901 | 1393 | |
| 902 | 1394 | // Allow small floating point difference (0.01) due to rounding. |
| 903 | 1395 | if ( abs( $payment_amount - $configured_amount ) > 0.01 ) { |
| 904 | 1396 | return [ |
| @@ -916,19 +1408,66 @@ | ||
| 916 | 1408 | |
| 917 | 1409 | // For variable amounts, validate based on source field. |
| 918 | 1410 | if ( 'variable' === $amount_type ) { |
| 919 | 1411 | // Check if variable amount comes from dropdown/multi-choice. |
| 920 | - $dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? ''; | |
| 921 | - $variable_amount_field_slug = $payment_config['variable_amount_field'] ?? ''; | |
| 1412 | + $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? ''; | |
| 1413 | + $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? ''; | |
| 922 | 1414 | |
| 923 | - // Skipping if it is old form configuration. | |
| 1415 | + // "Variant B": legacy/stale config may not have recorded the amount-source field | |
| 1416 | + // (empty source reference). Without it we cannot derive a server-side expected | |
| 1417 | + // amount. First try to recover it by refreshing the block config from the form's | |
| 1418 | + // current content — forms saved with current code record the source — which | |
| 1419 | + // self-heals legacy forms whose source field still exists. | |
| 924 | 1420 | if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) { |
| 1421 | + $refreshed_config = self::refresh_block_config( $form_id ); | |
| 1422 | + if ( is_array( $refreshed_config ) && isset( $refreshed_config[ $block_id ] ) && is_array( $refreshed_config[ $block_id ] ) ) { | |
| 1423 | + $block_config = $refreshed_config; | |
| 1424 | + $resolved_config = self::resolve_payment_config_for_active_type( $block_config[ $block_id ], $active_type ); | |
| 1425 | + $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? ''; | |
| 1426 | + $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? ''; | |
| 1427 | + } | |
| 1428 | + } | |
| 1429 | + | |
| 1430 | + // The source still cannot be identified (e.g. the amount-source field was deleted from | |
| 1431 | + // the form while the payment amount type is still "variable", so there is no field-level | |
| 1432 | + // config left to derive an expected amount from). | |
| 1433 | + // | |
| 1434 | + // Security: this branch previously returned a valid result unconditionally for such | |
| 1435 | + // forms, which allowed an unauthenticated attacker to pay any amount (down to 1 cent | |
| 1436 | + // when the form had no minimum-amount floor). | |
| 1437 | + // | |
| 1438 | + // If the admin configured a positive minimum amount we enforce it as the authoritative | |
| 1439 | + // lower bound — the only server-side guarantee available for such a form — instead of | |
| 1440 | + // rejecting outright. This keeps legacy forms (whose source field still has a floor) | |
| 1441 | + // working without requiring a re-save. With no positive floor there is nothing safe to | |
| 1442 | + // validate against, so we MUST fail safe and reject to avoid reopening the bypass. | |
| 1443 | + if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) { | |
| 1444 | + $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0; | |
| 1445 | + | |
| 1446 | + if ( $minimum_amount > 0 ) { | |
| 1447 | + if ( $payment_amount < $minimum_amount ) { | |
| 1448 | + return [ | |
| 1449 | + 'valid' => false, | |
| 1450 | + /* translators: %1$s: minimum amount, %2$s: payment amount */ | |
| 1451 | + 'message' => sprintf( __( 'Payment amount below minimum. Minimum: %1$s, received %2$s.', 'sureforms' ), $minimum_amount, $payment_amount ), | |
| 1452 | + ]; | |
| 1453 | + } | |
| 1454 | + | |
| 1455 | + return [ | |
| 1456 | + 'valid' => true, | |
| 1457 | + 'message' => '', | |
| 1458 | + ]; | |
| 1459 | + } | |
| 1460 | + | |
| 925 | 1461 | return [ |
| 926 | - 'valid' => true, | |
| 927 | - 'message' => '', | |
| 1462 | + 'valid' => false, | |
| 1463 | + 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ), | |
| 928 | 1464 | ]; |
| 929 | 1465 | } |
| 930 | 1466 | |
| 1467 | + // The amount source is identified: validate the charged amount against the | |
| 1468 | + // server-derived expected amount for that source. The configured minimum-amount | |
| 1469 | + // floor below is always enforced as an additional lower bound. | |
| 931 | 1470 | $submitted_field_value = self::get_form_submitted_value_by_slug_and_block_name( $variable_amount_field_slug, $dynamic_amount_field_block_name, $form_data ); |
| 932 | 1471 | |
| 933 | 1472 | if ( empty( $submitted_field_value ) ) { |
| 934 | 1473 | return [ |
| @@ -947,11 +1486,23 @@ | ||
| 947 | 1486 | 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ), |
| 948 | 1487 | ]; |
| 949 | 1488 | } |
| 950 | 1489 | |
| 951 | - // To get the expected amount we need to check by the value of the submitted field. we will have the values now we need to check the expected amount in the block config. because block config dropdown/multi-choice has the expected amount in the options. | |
| 1490 | + // The expected amount is read from the server-side option config keyed by the | |
| 1491 | + // submitted selection — the attacker chooses the option, never its price. | |
| 952 | 1492 | $get_expected_amount = self::get_amount_by_the_config_options( $submitted_field_value, $variable_amount_block_config ); |
| 953 | 1493 | |
| 1494 | + // Fail safe when the submitted selection doesn't map to a configured | |
| 1495 | + // option value: get_amount_by_the_config_options() returns null, and | |
| 1496 | + // abs( $payment_amount - null ) would coerce null to 0 — reject explicitly | |
| 1497 | + // so the comparison can never be silently weakened by that coercion. | |
| 1498 | + if ( ! is_numeric( $get_expected_amount ) ) { | |
| 1499 | + return [ | |
| 1500 | + 'valid' => false, | |
| 1501 | + 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ), | |
| 1502 | + ]; | |
| 1503 | + } | |
| 1504 | + | |
| 954 | 1505 | // Validate payment amount matches expected amount. |
| 955 | 1506 | if ( abs( $payment_amount - $get_expected_amount ) > 0.01 ) { |
| 956 | 1507 | return [ |
| 957 | 1508 | 'valid' => false, |
| @@ -958,48 +1509,101 @@ | ||
| 958 | 1509 | /* translators: %1$s: expected amount, %2$s: payment amount */ |
| 959 | 1510 | 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $get_expected_amount, $payment_amount ), |
| 960 | 1511 | ]; |
| 961 | 1512 | } |
| 962 | - } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) { | |
| 963 | - // Get the block config for the number field to retrieve the format type. | |
| 964 | - $number_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug ); | |
| 1513 | + } else { | |
| 1514 | + // Number and hidden fields. Their value may be server-determined — a | |
| 1515 | + // configured default value, or a calculation computed from other fields. | |
| 1516 | + // In those cases the expected amount MUST be derived server-side and the | |
| 1517 | + // value submitted with the request must never be trusted as the price. | |
| 1518 | + $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug ); | |
| 965 | 1519 | |
| 966 | - if ( empty( $number_block_config ) ) { | |
| 1520 | + if ( empty( $variable_amount_block_config ) ) { | |
| 967 | 1521 | return [ |
| 968 | 1522 | 'valid' => false, |
| 969 | - 'message' => __( 'Number field configuration not found.', 'sureforms' ), | |
| 1523 | + 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ), | |
| 970 | 1524 | ]; |
| 971 | 1525 | } |
| 972 | 1526 | |
| 973 | - // Get the number format type from the block config (default to 'us-style'). | |
| 974 | - $number_format_type = isset( $number_block_config['format_type'] ) && ! empty( $number_block_config['format_type'] ) ? $number_block_config['format_type'] : 'us-style'; | |
| 1527 | + $expected_amount = self::resolve_server_side_variable_amount( $variable_amount_block_config, $block_config, $form_data ); | |
| 975 | 1528 | |
| 976 | - // If submitted_field_value is not string then convert the value to string. | |
| 977 | - $submitted_field_value = Helper::get_string_value( $submitted_field_value ); | |
| 1529 | + if ( null !== $expected_amount ) { | |
| 1530 | + // Authoritative server-side amount (static default value or a | |
| 1531 | + // server-recomputed calculation). Reject any mismatch. | |
| 1532 | + if ( abs( $payment_amount - floatval( $expected_amount ) ) > 0.01 ) { | |
| 1533 | + return [ | |
| 1534 | + 'valid' => false, | |
| 1535 | + /* translators: %1$s: expected amount, %2$s: payment amount */ | |
| 1536 | + 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), floatval( $expected_amount ), $payment_amount ), | |
| 1537 | + ]; | |
| 1538 | + } | |
| 1539 | + } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) { | |
| 1540 | + // Calculation-driven number: a null server amount means the formula | |
| 1541 | + // could NOT be recomputed server-side (a referenced field was | |
| 1542 | + // non-numeric, or the formula used something the parser can't | |
| 1543 | + // evaluate). This is NOT "name your price" — we must fail safe and | |
| 1544 | + // reject, never fall back to the client-submitted amount, which | |
| 1545 | + // would reopen the unauthenticated underpayment bypass. | |
| 1546 | + if ( ! empty( $variable_amount_block_config['enableCalculation'] ) ) { | |
| 1547 | + return [ | |
| 1548 | + 'valid' => false, | |
| 1549 | + 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ), | |
| 1550 | + ]; | |
| 1551 | + } | |
| 978 | 1552 | |
| 979 | - // Normalize the submitted amount based on the format type. | |
| 980 | - $converted_payment_amount = self::normalize_amount_by_format( $submitted_field_value, $number_format_type ); | |
| 1553 | + // Plain user-entered number ("name your price"): the amount is the | |
| 1554 | + // customer's own choice, so confirm the charge matches what they entered. | |
| 1555 | + // The minimum-amount floor below guards the lower bound. | |
| 1556 | + $number_format_type = isset( $variable_amount_block_config['format_type'] ) && ! empty( $variable_amount_block_config['format_type'] ) ? $variable_amount_block_config['format_type'] : 'us-style'; | |
| 1557 | + $submitted_field_value = Helper::get_string_value( $submitted_field_value ); | |
| 1558 | + $converted_payment_amount = self::normalize_amount_by_format( $submitted_field_value, $number_format_type ); | |
| 981 | 1559 | |
| 982 | - // Validate that the normalized amount is valid. | |
| 983 | - if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) { | |
| 984 | - return [ | |
| 985 | - 'valid' => false, | |
| 986 | - 'message' => __( 'Variable amount field value is required.', 'sureforms' ), | |
| 987 | - ]; | |
| 988 | - } | |
| 1560 | + if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) { | |
| 1561 | + return [ | |
| 1562 | + 'valid' => false, | |
| 1563 | + 'message' => __( 'Variable amount field value is required.', 'sureforms' ), | |
| 1564 | + ]; | |
| 1565 | + } | |
| 989 | 1566 | |
| 990 | - // Validate payment amount matches expected amount. | |
| 991 | - if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) { | |
| 992 | - return [ | |
| 993 | - 'valid' => false, | |
| 994 | - /* translators: %1$s: expected amount, %2$s: payment amount */ | |
| 995 | - 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ), | |
| 996 | - ]; | |
| 1567 | + if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) { | |
| 1568 | + return [ | |
| 1569 | + 'valid' => false, | |
| 1570 | + /* translators: %1$s: expected amount, %2$s: payment amount */ | |
| 1571 | + 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ), | |
| 1572 | + ]; | |
| 1573 | + } | |
| 1574 | + } else { | |
| 1575 | + // Unresolved hidden / dynamic source: resolve_server_side_variable_amount() | |
| 1576 | + // returned null (e.g. a hidden field whose default is a smart tag like | |
| 1577 | + // {get_input:amount}, stored raw and therefore non-numeric), so the submitted | |
| 1578 | + // value cannot be trusted as the price and there is no server-authoritative | |
| 1579 | + // amount to compare against. The configured minimum-amount floor is then the | |
| 1580 | + // ONLY server-side guarantee, so it must be a positive authoritative value. | |
| 1581 | + // | |
| 1582 | + // This mirrors the "amount source not identified" handling above: with a | |
| 1583 | + // positive minimum we fall through to the floor check below (the documented | |
| 1584 | + // dynamic-prefill case keeps working); with no positive minimum there is | |
| 1585 | + // nothing safe to validate against, so we MUST fail safe and reject rather than | |
| 1586 | + // letting the floor default to 0 and accept any amount down to the gateway cent | |
| 1587 | + // floor — which would reopen the unauthenticated underpayment bypass. Merchants | |
| 1588 | + // doing custom JS-driven dynamic pricing must supply a server-authoritative | |
| 1589 | + // amount via the `srfm_server_side_variable_amount` filter or a | |
| 1590 | + // calculation-enabled field rather than relying on the submitted value. | |
| 1591 | + $unresolved_minimum = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0; | |
| 1592 | + | |
| 1593 | + if ( $unresolved_minimum <= 0 ) { | |
| 1594 | + return [ | |
| 1595 | + 'valid' => false, | |
| 1596 | + 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ), | |
| 1597 | + ]; | |
| 1598 | + } | |
| 997 | 1599 | } |
| 998 | 1600 | } |
| 999 | 1601 | |
| 1000 | - // For other variable amount sources (e.g., number field), validate minimum amount. | |
| 1001 | - $minimum_amount = isset( $payment_config['minimum_amount'] ) ? floatval( $payment_config['minimum_amount'] ) : 0; | |
| 1602 | + // All variable amount sources are subject to the configured minimum amount floor. | |
| 1603 | + // Use resolved_config so 'both'-mode forms read the active type's per-type minimum | |
| 1604 | + // (oneTimeMinimumAmount / subscriptionMinimumAmount) instead of the unset legacy scalar. | |
| 1605 | + $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0; | |
| 1002 | 1606 | |
| 1003 | 1607 | if ( $payment_amount < $minimum_amount ) { |
| 1004 | 1608 | return [ |
| 1005 | 1609 | 'valid' => false, |
| @@ -1016,8 +1620,97 @@ | ||
| 1016 | 1620 | ]; |
| 1017 | 1621 | } |
| 1018 | 1622 | |
| 1019 | 1623 | /** |
| 1624 | + * Force a refresh of the form's stored block configuration from its current content. | |
| 1625 | + * | |
| 1626 | + * Recovers the amount-source field reference for legacy forms whose cached | |
| 1627 | + * _srfm_block_config predates server-side source tracking (an empty | |
| 1628 | + * variable_amount_field_block_name). Re-parses the form blocks and rebuilds the config — | |
| 1629 | + * forms saved with current code record the source — then returns the refreshed config. | |
| 1630 | + * | |
| 1631 | + * @param int $form_id Form post ID. | |
| 1632 | + * @since 2.11.1 | |
| 1633 | + * @return array<mixed>|null Refreshed block configuration, or null if it cannot be rebuilt. | |
| 1634 | + */ | |
| 1635 | + private static function refresh_block_config( $form_id ) { | |
| 1636 | + if ( ! is_int( $form_id ) || $form_id <= 0 ) { | |
| 1637 | + return null; | |
| 1638 | + } | |
| 1639 | + | |
| 1640 | + $post = get_post( $form_id ); | |
| 1641 | + if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) || ! function_exists( 'parse_blocks' ) ) { | |
| 1642 | + return null; | |
| 1643 | + } | |
| 1644 | + | |
| 1645 | + $blocks = parse_blocks( $post->post_content ); | |
| 1646 | + if ( is_array( $blocks ) && ! empty( $blocks ) ) { | |
| 1647 | + Field_Validation::add_block_config( $blocks, $form_id ); | |
| 1648 | + } | |
| 1649 | + | |
| 1650 | + return Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id ); | |
| 1651 | + } | |
| 1652 | + | |
| 1653 | + /** | |
| 1654 | + * Resolve the authoritative server-side expected amount for a variable amount source. | |
| 1655 | + * | |
| 1656 | + * The expected amount is ALWAYS derived from server-side configuration — the field's | |
| 1657 | + * configured default value, or (for calculation-enabled fields) a value recomputed by | |
| 1658 | + * SureForms Pro from the submitted inputs. It is NEVER taken from the value submitted with | |
| 1659 | + * the request. Returns null when no authoritative amount can be determined server-side, in | |
| 1660 | + * which case the caller falls back to the configured minimum-amount floor. | |
| 1661 | + * | |
| 1662 | + * @param array<mixed> $source_config The amount-source field block config (block_name, slug, enableCalculation, defaultValue, calculationFormula, ...). | |
| 1663 | + * @param array<mixed> $block_config All block configurations for the form. | |
| 1664 | + * @param array<mixed> $form_data Submitted form data. | |
| 1665 | + * @since 2.11.1 | |
| 1666 | + * @return float|null Expected amount, or null if it cannot be determined server-side. | |
| 1667 | + */ | |
| 1668 | + private static function resolve_server_side_variable_amount( $source_config, $block_config, $form_data ) { | |
| 1669 | + if ( empty( $source_config ) || ! is_array( $source_config ) ) { | |
| 1670 | + return null; | |
| 1671 | + } | |
| 1672 | + | |
| 1673 | + /** | |
| 1674 | + * Compute the authoritative server-side amount for a variable payment source. | |
| 1675 | + * | |
| 1676 | + * SureForms Pro hooks this to recompute a field's calculation formula from the | |
| 1677 | + * submitted field values. Handlers MUST return a numeric value derived only from | |
| 1678 | + * server-side configuration and other submitted inputs — never the raw value of the | |
| 1679 | + * amount field submitted with the request — or null if it cannot be computed. | |
| 1680 | + * | |
| 1681 | + * @since 2.11.1 | |
| 1682 | + * @param float|null $amount The resolved amount. Default null. | |
| 1683 | + * @param array<string, mixed> $context Context: source_config, block_config, form_data. | |
| 1684 | + */ | |
| 1685 | + $expected = apply_filters( | |
| 1686 | + 'srfm_server_side_variable_amount', | |
| 1687 | + null, | |
| 1688 | + [ | |
| 1689 | + 'source_config' => $source_config, | |
| 1690 | + 'block_config' => $block_config, | |
| 1691 | + 'form_data' => $form_data, | |
| 1692 | + ] | |
| 1693 | + ); | |
| 1694 | + | |
| 1695 | + if ( is_numeric( $expected ) ) { | |
| 1696 | + return floatval( $expected ); | |
| 1697 | + } | |
| 1698 | + | |
| 1699 | + // Static hidden field: a *literal numeric* configured default value is the server-side | |
| 1700 | + // source of truth and is authoritative. A non-numeric default (e.g. a smart tag such as | |
| 1701 | + // {get_input:amount} stored raw, resolved to a runtime value only at render time) is NOT | |
| 1702 | + // treated as authoritative here — it returns null below so the caller validates against the | |
| 1703 | + // minimum-amount floor instead, preserving the documented dynamic-prefill behavior. | |
| 1704 | + $block_name = $source_config['block_name'] ?? ( $source_config['blockName'] ?? '' ); | |
| 1705 | + if ( 'srfm/hidden' === $block_name && empty( $source_config['enableCalculation'] ) && isset( $source_config['defaultValue'] ) && is_numeric( $source_config['defaultValue'] ) ) { | |
| 1706 | + return floatval( $source_config['defaultValue'] ); | |
| 1707 | + } | |
| 1708 | + | |
| 1709 | + return null; | |
| 1710 | + } | |
| 1711 | + | |
| 1712 | + /** | |
| 1020 | 1713 | * Get amount by matching submitted value with config options. |
| 1021 | 1714 | * |
| 1022 | 1715 | * @param string $submitted_field_value The submitted value (string, can be "value1 | value2" for multi-select). |
| 1023 | 1716 | * @param array<mixed> $block_config Block configuration containing options. |
| @@ -1097,9 +1790,14 @@ | ||
| 1097 | 1790 | if ( empty( $config ) || ! is_array( $config ) ) { |
| 1098 | 1791 | continue; |
| 1099 | 1792 | } |
| 1100 | 1793 | |
| 1101 | - if ( isset( $config['slug'] ) && $config['slug'] === $slug && isset( $config['block_name'] ) && $config['block_name'] === $block_name ) { | |
| 1794 | + // Core blocks store the block name under 'block_name'; Pro blocks (e.g. the hidden | |
| 1795 | + // field, registered via the srfm_block_config filter) store it under 'blockName'. | |
| 1796 | + // Accept either so Pro-sourced amount fields resolve correctly. | |
| 1797 | + $config_block_name = $config['block_name'] ?? ( $config['blockName'] ?? '' ); | |
| 1798 | + | |
| 1799 | + if ( isset( $config['slug'] ) && $config['slug'] === $slug && $config_block_name === $block_name ) { | |
| 1102 | 1800 | return $config; |
| 1103 | 1801 | } |
| 1104 | 1802 | } |
| 1105 | 1803 | return null; |
| @@ -1152,8 +1850,10 @@ | ||
| 1152 | 1850 | } elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) { |
| 1153 | 1851 | $block_name = 'srfm-input-multi-choice'; |
| 1154 | 1852 | } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) { |
| 1155 | 1853 | $block_name = 'srfm-number'; |
| 1854 | + } elseif ( 'srfm/hidden' === $dynamic_amount_field_block_name ) { | |
| 1855 | + $block_name = 'srfm-hidden'; | |
| 1156 | 1856 | } |
| 1157 | 1857 | |
| 1158 | 1858 | // Now we need to get the submitted value. |
| 1159 | 1859 | // Here is the structure of the form data name. |