payment_type to the resolved default-choice, * so we keep the original intent here for conditionals. * * @var string * @since 2.8.2 */ protected $original_payment_type; /** * Label shown next to the "one-time" radio choice in both mode. * * @var string * @since 2.8.2 */ protected $one_time_label; /** * Label shown next to the "subscription" radio choice in both mode. * * @var string * @since 2.8.2 */ protected $subscription_label; /** * Default radio selection in both mode ("one-time" or "subscription"). * * @var string * @since 2.8.2 */ protected $default_payment_choice; /** * One-time amount configuration (used only in both mode). * * @var array * @since 2.8.2 */ protected $one_time_config = []; /** * Subscription amount configuration (used only in both mode). * * @var array * @since 2.8.2 */ protected $subscription_config = []; // BOTH MODE: end. /** * Constructor for the Payment Markup class. * * @param array $attributes Block attributes. * @since 2.0.0 */ public function __construct( $attributes ) { // Get payment settings from Stripe Helper. $this->stripe_connected = Stripe_Helper::is_stripe_connected(); $this->payment_mode = Stripe_Helper::get_stripe_mode(); $this->slug = 'payment'; $this->set_properties( $attributes ); $this->set_input_label( 'Payment' ); $this->set_error_msg( $attributes, 'srfm_payment_block_required_text' ); $this->set_unique_slug(); $this->set_markup_properties(); $this->set_aria_described_by(); $this->set_field_name( $this->unique_slug ); // Set payment-specific properties. $this->amount = $attributes['amount'] ?? 10; $this->currency = $attributes['currency'] ?? 'USD'; // Use currency from settings if not specified in block. if ( empty( $this->currency ) || 'USD' === $this->currency ) { $this->currency = Stripe_Helper::get_currency(); } // Get appropriate Stripe publishable key based on mode. $this->stripe_publishable_key = Stripe_Helper::get_stripe_publishable_key(); $this->payment_type = $attributes['paymentType'] ?? 'one-time'; $this->subscription_plan = $attributes['subscriptionPlan'] ?? []; $this->amount_type = $attributes['amountType'] ?? 'fixed'; $this->fixed_amount = $attributes['fixedAmount'] ?? 10; $this->minimum_amount = $attributes['minimumAmount'] ?? 0; // Set customer field mappings. $this->customer_name_field = $attributes['customerNameField'] ?? ''; $this->customer_email_field = $attributes['customerEmailField'] ?? ''; // Set variable amount field mapping. $this->variable_amount_field = $attributes['variableAmountField'] ?? ''; // BOTH MODE: start — capture original intent and dual-mode configuration. $this->original_payment_type = $this->payment_type; $this->one_time_label = $attributes['oneTimeLabel'] ?? __( 'One-Time Payment', 'sureforms' ); $this->subscription_label = $attributes['subscriptionLabel'] ?? __( 'Subscription', 'sureforms' ); $this->default_payment_choice = $attributes['defaultPaymentChoice'] ?? 'one-time'; if ( ! in_array( $this->default_payment_choice, [ 'one-time', 'subscription' ], true ) ) { $this->default_payment_choice = 'one-time'; } $this->one_time_config = [ 'amount_type' => $attributes['oneTimeAmountType'] ?? 'fixed', 'fixed_amount' => isset( $attributes['oneTimeFixedAmount'] ) ? (float) $attributes['oneTimeFixedAmount'] : 10.0, 'minimum_amount' => isset( $attributes['oneTimeMinimumAmount'] ) ? (float) $attributes['oneTimeMinimumAmount'] : 0.0, 'variable_field' => $attributes['oneTimeVariableAmountField'] ?? '', ]; $this->subscription_config = [ 'amount_type' => $attributes['subscriptionAmountType'] ?? 'fixed', 'fixed_amount' => isset( $attributes['subscriptionFixedAmount'] ) ? (float) $attributes['subscriptionFixedAmount'] : 10.0, 'minimum_amount' => isset( $attributes['subscriptionMinimumAmount'] ) ? (float) $attributes['subscriptionMinimumAmount'] : 0.0, 'variable_field' => $attributes['subscriptionVariableAmountField'] ?? '', ]; // When rendering the "both" block, the visible amount + Stripe mode is driven by // the default choice. Rewrite the scalar properties so the existing rendering // logic below continues to work with zero changes. if ( 'both' === $this->original_payment_type ) { $active_config = 'subscription' === $this->default_payment_choice ? $this->subscription_config : $this->one_time_config; $this->amount_type = $active_config['amount_type']; $this->fixed_amount = $active_config['fixed_amount']; $this->minimum_amount = $active_config['minimum_amount']; $this->variable_amount_field = $active_config['variable_field']; // $this->payment_type now represents the *active* rendering type, not the admin-set value. $this->payment_type = $this->default_payment_choice; } // BOTH MODE: end. // Set payment methods from block attributes, default to 'stripe' for backward compatibility. $this->payment_methods = $attributes['paymentMethods'] ?? [ 'stripe' ]; // Set custom payment description (empty means JS/gateway will use its own default). $this->payment_description = $attributes['paymentDescription'] ?? ''; // BACKWARD COMPATIBILITY: Migrate customer fields from subscriptionPlan. if ( empty( $this->customer_name_field ) && ! empty( $this->subscription_plan['customer_name'] ) ) { $this->customer_name_field = $this->subscription_plan['customer_name']; } if ( empty( $this->customer_email_field ) && ! empty( $this->subscription_plan['customer_email'] ) ) { $this->customer_email_field = $this->subscription_plan['customer_email']; } } /** * Render the payment field markup. * * @return string|bool * @since 2.0.0 */ public function markup() { // Get registered payment methods. $registered_methods = $this->get_registered_payment_methods(); // Check if any payment methods are available. if ( empty( $registered_methods ) ) { return ''; } // Validate payment field requirements. $is_valid = $this->validate_payment_requirements(); if ( ! $is_valid ) { return ''; } $field_classes = $this->get_field_classes(); // Get first payment method as default. $first_method_id = array_key_first( $registered_methods ); $data_input_attributes = [ 'name' => $this->field_name, 'class' => 'srfm-payment-input', 'data-currency' => strtolower( $this->currency ), 'data-stripe-key' => $this->stripe_publishable_key, 'data-payment-mode' => $this->payment_mode, 'data-amount-type' => $this->amount_type, 'data-fixed-amount' => $this->fixed_amount, 'aria-describedby' => trim( $this->aria_described_by ), 'data-payment-type' => $this->payment_type, 'data-customer-name-field' => $this->customer_name_field, 'data-customer-email-field' => $this->customer_email_field, 'data-payment-methods' => wp_json_encode( array_keys( $registered_methods ) ), 'data-selected-method' => $first_method_id, ]; // Subscription plan attributes are emitted when subscription is the active type // OR when "both" mode allows it as a possible end-user choice. $has_subscription_path = 'subscription' === $this->payment_type || 'both' === $this->original_payment_type; if ( $has_subscription_path && ! empty( $this->subscription_plan ) ) { // Defense-in-depth: coerce to scalar before output. A malformed REST // save could store these as arrays, which would otherwise emit notices // or empty strings through esc_attr() downstream. $data_input_attributes['data-subscription-plan-name'] = (string) ( $this->subscription_plan['name'] ?? __( 'Subscription Plan', 'sureforms' ) ); $data_input_attributes['data-subscription-interval'] = (string) ( $this->subscription_plan['interval'] ?? 'month' ); $data_input_attributes['data-subscription-billing-cycles'] = $this->subscription_plan['billingCycles'] ?? 0; } if ( ! empty( $this->payment_description ) ) { $data_input_attributes['data-description'] = $this->payment_description; } if ( 'variable' === $this->amount_type ) { $data_input_attributes['data-variable-amount-field'] = $this->variable_amount_field; } // BOTH MODE: ensure data-variable-amount-field is emitted at page load if // EITHER type uses variable amount. listenAmountChanges() runs once at init // and queries [data-variable-amount-field] — without this, the listener is // never wired when the default choice is fixed but the other type is variable. if ( 'both' === $this->original_payment_type && 'variable' !== $this->amount_type ) { $other_config = 'subscription' === $this->default_payment_choice ? $this->one_time_config : $this->subscription_config; if ( 'variable' === $other_config['amount_type'] && ! empty( $other_config['variable_field'] ) ) { $data_input_attributes['data-variable-amount-field'] = $other_config['variable_field']; } } // If minimum amount is greater than 0, add it to the data input attributes. if ( $this->minimum_amount > 0 ) { $data_input_attributes['data-minimum-amount'] = $this->minimum_amount; } // BOTH MODE: start — emit per-type configuration for the JS chooser to read. if ( 'both' === $this->original_payment_type ) { $data_input_attributes['data-original-payment-type'] = 'both'; $data_input_attributes['data-default-payment-choice'] = $this->default_payment_choice; $data_input_attributes['data-one-time-amount-type'] = $this->one_time_config['amount_type']; $data_input_attributes['data-one-time-fixed-amount'] = $this->one_time_config['fixed_amount']; $data_input_attributes['data-one-time-minimum-amount'] = $this->one_time_config['minimum_amount']; if ( 'variable' === $this->one_time_config['amount_type'] ) { $data_input_attributes['data-one-time-variable-amount-field'] = $this->one_time_config['variable_field']; } $data_input_attributes['data-subscription-amount-type'] = $this->subscription_config['amount_type']; $data_input_attributes['data-subscription-fixed-amount'] = $this->subscription_config['fixed_amount']; $data_input_attributes['data-subscription-minimum-amount'] = $this->subscription_config['minimum_amount']; if ( 'variable' === $this->subscription_config['amount_type'] ) { $data_input_attributes['data-subscription-variable-amount-field'] = $this->subscription_config['variable_field']; } } // BOTH MODE: end. ob_start(); ?>
label_markup ); ?> help_markup ); ?>
original_payment_type ) { echo $this->render_payment_type_chooser(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } // BOTH MODE: end. if ( 'both' === $this->original_payment_type ) { // BOTH MODE: render two amount displays, one per choice. JS toggles visibility. $is_one_time_default = 'one-time' === $this->default_payment_choice; ?>
> render_amount_display( 'one-time', Helper::get_string_value( $this->one_time_config['amount_type'] ?? 'fixed' ), is_numeric( $this->one_time_config['fixed_amount'] ?? null ) ? (float) $this->one_time_config['fixed_amount'] : 0.0, is_numeric( $this->one_time_config['minimum_amount'] ?? null ) ? (float) $this->one_time_config['minimum_amount'] : 0.0 ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped ?>
> render_amount_display( 'subscription', Helper::get_string_value( $this->subscription_config['amount_type'] ?? 'fixed' ), is_numeric( $this->subscription_config['fixed_amount'] ?? null ) ? (float) $this->subscription_config['fixed_amount'] : 0.0, is_numeric( $this->subscription_config['minimum_amount'] ?? null ) ? (float) $this->subscription_config['minimum_amount'] : 0.0 ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped ?>
render_amount_display( $this->payment_type, $this->amount_type, $this->fixed_amount, $this->minimum_amount ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } ?> payment_mode ) { echo $this->get_test_mode_notice(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } ?> render_payment_methods_accordion( $registered_methods ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> $attr_value ) { echo esc_attr( $attr_key ) . '="' . esc_attr( $attr_value ) . '" '; } ?> />
$this->payment_methods ] ); } // BOTH MODE: start — helper renderers for the dual-mode chooser and amount displays. /** * Render the one-time / subscription radio chooser shown only in "both" mode. * * @return string Chooser markup. * @since 2.8.2 */ private function render_payment_type_chooser() { $chooser_name = 'srfm-payment-type-choice-' . $this->block_id; $is_one_time = 'one-time' === $this->default_payment_choice; $options = [ [ 'value' => 'one-time', 'label' => $this->one_time_label, 'checked' => $is_one_time, ], [ 'value' => 'subscription', 'label' => $this->subscription_label, 'checked' => ! $is_one_time, ], ]; ob_start(); ?>
block_id . '-' . $opt['value']; $panel_id = 'srfm-payment-amount-' . $this->block_id . '-' . $opt['value']; // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- render_radio_pill handles its own escaping. echo $this->render_radio_pill( [ 'wrapper_class' => 'srfm-payment-type-choice srfm-payment-type-choice--' . $opt['value'], 'radio_id' => $radio_id, 'name' => $chooser_name, 'value' => $opt['value'], 'label' => $opt['label'], 'checked' => $opt['checked'], 'radio_class' => 'srfm-payment-type-choice-radio', 'aria_controls' => $panel_id, 'data_attrs' => [ 'data-payment-type' => $opt['value'] ], ] ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } ?>
} $args Pill config. * @return string Pill markup. * @since 2.8.2 */ private function render_radio_pill( $args ) { $check_svg = Helper::fetch_svg( 'circle-checked', 'srfm-payment-icon', 'aria-hidden="true"' ); $unchecked_svg = Helper::fetch_svg( 'circle-unchecked', 'srfm-payment-icon-unchecked', 'aria-hidden="true"' ); $data_attrs_html = ''; if ( ! empty( $args['data_attrs'] ) && is_array( $args['data_attrs'] ) ) { foreach ( $args['data_attrs'] as $key => $val ) { $data_attrs_html .= ' ' . esc_attr( $key ) . '="' . esc_attr( $val ) . '"'; } } ob_start(); ?>
subscription_plan ) ) { $interval = $this->subscription_plan['interval'] ?? 'month'; $billing_cycles = $this->subscription_plan['billingCycles'] ?? 0; $interval_label = $this->get_interval_label( $interval ); if ( 'ongoing' === $billing_cycles ) { echo esc_html( sprintf( /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year) */ __( '%1$s per %2$s (until cancelled)', 'sureforms' ), $this->format_currency( $fixed_amount, $this->currency ), $interval_label ) ); } elseif ( $billing_cycles > 0 ) { echo esc_html( sprintf( /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year), 3: Number of billing cycles */ __( '%1$s per %2$s (%3$s payments)', 'sureforms' ), $this->format_currency( $fixed_amount, $this->currency ), $interval_label, $billing_cycles ) ); } else { echo esc_html( sprintf( /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year) */ __( '%1$s per %2$s', 'sureforms' ), $this->format_currency( $fixed_amount, $this->currency ), $interval_label ) ); } } else { echo esc_html( $this->format_currency( $fixed_amount, $this->currency ) ); } ?>
subscription_plan ) ) { $interval = $this->subscription_plan['interval'] ?? 'month'; $billing_cycles = $this->subscription_plan['billingCycles'] ?? 0; $interval_label = $this->get_interval_label( $interval ); if ( 'ongoing' === $billing_cycles ) { /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year) */ $message_format = sprintf( __( '{amount} per %s (until cancelled)', 'sureforms' ), $interval_label ); } elseif ( $billing_cycles > 0 ) { /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year), 3: Number of billing cycles */ $message_format = sprintf( __( '{amount} per %1$s (%2$s payments)', 'sureforms' ), $interval_label, $billing_cycles ); } else { /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year) */ $message_format = sprintf( __( '{amount} per %s', 'sureforms' ), $interval_label ); } } ?>
0 ) { ?> format_currency( $minimum_amount, $this->currency ) ) ); ?>
$methods Array of payment methods. * @return string Payment methods accordion markup. * @since 2.4.0 */ private function render_payment_methods_accordion( $methods ) { if ( empty( $methods ) || ! is_array( $methods ) ) { return ''; } $is_single_method = count( $methods ) === 1; $is_first = true; // Track the first payment method. ob_start(); ?>
aria-label="" />
customer_email_field ) ) { return false; } // Check subscription-specific requirements. // BOTH MODE: subscription is a possible end-user choice, so name field is also required. if ( 'subscription' === $this->original_payment_type || 'both' === $this->original_payment_type ) { if ( empty( $this->customer_name_field ) ) { return false; } } return true; } /** * Format currency for display. * * @param float $amount Amount to format. * @param string $currency Currency code. * @return string * @since 2.0.0 */ private function format_currency( $amount, $currency ) { $symbol = Stripe_Helper::get_currency_symbol( $currency ); $position = Payment_Helper::get_currency_sign_position(); // Format based on currency. if ( in_array( $currency, [ 'JPY', 'KRW' ], true ) ) { // No decimal places for these currencies. $formatted_amount = number_format( $amount, 0 ); } else { $formatted_amount = number_format( $amount, 2 ); } // Apply currency sign position. switch ( $position ) { case 'right': return $formatted_amount . $symbol; case 'left_space': return $symbol . ' ' . $formatted_amount; case 'right_space': return $formatted_amount . ' ' . $symbol; case 'left': default: return $symbol . $formatted_amount; } } /** * Get the human-readable label for a payment interval slug. * * @param string $interval_slug The slug (e.g., 'day', 'week', 'month', 'quarter', 'yearly'). * @return string The translated interval label, or the slug itself if not found. * @since 2.0.0 */ private function get_interval_label( $interval_slug ) { $interval_labels = [ 'day' => __( 'day', 'sureforms' ), 'week' => __( 'week', 'sureforms' ), 'month' => __( 'month', 'sureforms' ), 'quarter' => __( 'quarter', 'sureforms' ), 'yearly' => __( 'year', 'sureforms' ), ]; return $interval_labels[ $interval_slug ] ?? $interval_slug; } /** * Render test mode notice for admin users. * Only shows if user has manage_options capability. * * @return string|bool Test mode notice markup or empty string. * @since 2.0.0 */ private function get_test_mode_notice() { // Only show to users with manage_options capability. if ( ! current_user_can( 'manage_options' ) ) { return ''; } // Build dynamic link to payment settings. $settings_url = admin_url( 'admin.php?page=sureforms_form_settings&tab=payments-settings&subpage=general' ); ob_start(); ?>