'PayPal', 'user_label' => __( 'Payment', 'formidable' ), 'class' => 'PayPalLite', 'recurring' => true, 'include' => array( 'billing_first_name', 'billing_last_name', 'credit_card', 'billing_address', ), ); return $gateways; } /** * Handle the request to initialize with PayPal Api */ public static function handle_oauth() { FrmAppHelper::permission_check( 'frm_change_settings' ); if ( ! check_admin_referer( 'frm_ajax', 'nonce' ) ) { wp_send_json_error(); } $redirect_url = FrmPayPalLiteConnectHelper::get_oauth_redirect_url(); if ( false === $redirect_url ) { wp_send_json_error( 'Unable to connect to PayPal successfully' ); } $response_data = array( 'redirect_url' => $redirect_url, ); wp_send_json_success( $response_data ); } public static function handle_disconnect() { FrmAppHelper::permission_check( 'frm_change_settings' ); if ( ! check_admin_referer( 'frm_ajax', 'nonce' ) ) { wp_send_json_error(); } FrmPayPalLiteConnectHelper::handle_disconnect(); wp_send_json_success(); } /** * Get the current amount for a PayPal action via AJAX. * Used to update the Pay Later messaging when price fields change. * * @since 6.31 * * @return void */ public static function get_amount() { check_ajax_referer( 'frm_paypal_ajax', 'nonce' ); $form_id = FrmAppHelper::get_post_param( 'form_id', 0, 'absint' ); if ( ! $form_id ) { wp_send_json_error( __( 'Invalid form ID', 'formidable' ) ); } $actions = FrmPayPalLiteActionsController::get_actions_before_submit( $form_id ); if ( ! $actions ) { wp_send_json_error( __( 'No PayPal actions found for this form', 'formidable' ) ); } $action = reset( $actions ); $amount = self::get_amount_value_for_verification( $action ); wp_send_json_success( array( 'amount' => $amount ) ); } /** * Extract pricing data from posted form values. * * @since 6.31 * * @param int $form_id The form ID. * * @return array Array of products with prices and quantities. */ // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh private static function get_pricing_data_from_posted_values( $form_id ) { $products = array(); $fields = FrmField::get_all_for_form( $form_id ); if ( ! $fields ) { return $products; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $posted_data = $_POST['item_meta'] ?? array(); foreach ( $fields as $field ) { if ( ! in_array( $field->type, array( 'product', 'quantity', 'total' ), true ) ) { continue; } $field_id = $field->id; $value = $posted_data[ $field_id ] ?? ''; if ( ! $value ) { continue; } if ( 'product' === $field->type ) { $product_field = FrmFieldFactory::get_field_object( $field ); if ( method_exists( $product_field, 'get_posted_price' ) ) { $price = $product_field->get_posted_price( $value ); if ( $price ) { $products[] = array( 'name' => $field->name, 'price' => is_array( $price ) ? array_sum( $price ) : $price, 'quantity' => 1, 'type' => 'product', 'field_id' => $field_id, ); } } } elseif ( 'quantity' === $field->type ) { $quantity = is_numeric( $value ) ? (int) $value : 1; // Quantity fields are linked to product fields via product_field setting $product_field_ids = FrmField::get_option( $field, 'product_field' ); if ( $product_field_ids ) { // This quantity will be associated with its product field // We'll handle the association in the product processing $products[] = array( 'name' => $field->name, 'price' => 0, // Quantity fields don't have price 'quantity' => $quantity, 'type' => 'quantity', 'product_field_ids' => (array) $product_field_ids, ); } }//end if }//end foreach // Associate quantity fields with their products $final_products = array(); $product_quantities = array(); foreach ( $products as $item ) { if ( 'quantity' !== $item['type'] ) { continue; } foreach ( $item['product_field_ids'] as $product_field_id ) { $product_quantities[ $product_field_id ] = $item['quantity']; } } foreach ( $products as $item ) { if ( 'product' !== $item['type'] ) { continue; } $quantity = $product_quantities[ $item['field_id'] ] ?? 1; $final_products[] = array( 'name' => $item['name'], 'price' => $item['price'], 'quantity' => $quantity, ); } return $final_products; } /** * Create a PayPal order via AJAX. */ public static function create_order() { check_ajax_referer( 'frm_paypal_ajax', 'nonce' ); // Check if PayPal is connected before attempting to create an order. $connection_check = self::check_paypal_connection(); if ( is_wp_error( $connection_check ) ) { wp_send_json_error( $connection_check->get_error_message() ); } $form_id = FrmAppHelper::get_post_param( 'form_id', 0, 'absint' ); if ( ! $form_id ) { wp_send_json_error( __( 'Invalid form ID', 'formidable' ) ); } $payment_source = FrmAppHelper::get_post_param( 'payment_source', '', 'sanitize_text_field' ); if ( ! $payment_source ) { wp_send_json_error( __( 'No payment source provided', 'formidable' ) ); } if ( ! in_array( $payment_source, self::get_valid_payment_sources(), true ) ) { wp_send_json_error( __( 'Invalid payment source', 'formidable' ) ); } $actions = FrmPayPalLiteActionsController::get_actions_before_submit( $form_id ); if ( ! $actions ) { wp_send_json_error( __( 'No PayPal actions found for this form', 'formidable' ) ); } $action = reset( $actions ); $amount = self::get_amount_value_for_verification( $action ); $payer = self::get_payer_data_from_posted_values( $action ); $shipping = self::get_shipping_data_from_posted_values( $action ); $shipping_preference = self::get_shipping_preference( $action ); $pricing_data = self::get_pricing_data_from_posted_values( $form_id ); $description = self::process_shortcodes_for_action( $action->post_content['description'] ?? '', $action ); if ( 0.0 === floatval( $amount ) ) { wp_send_json_error( __( 'Order amount cannot be zero.', 'formidable' ) ); } // PayPal expects the amount in a format like 10.00, so format it. $amount = number_format( floatval( $amount ), 2, '.', '' ); $currency = strtoupper( $action->post_content['currency'] ); $order_response = FrmPayPalLiteConnectHelper::create_order( $amount, $currency, $payment_source, $payer, $shipping_preference, $pricing_data, $shipping, $description ); if ( class_exists( 'FrmLog' ) ) { $log = new FrmLog(); $log->add( array( 'title' => 'PayPal Order Response', 'content' => print_r( $order_response, true ), ) ); } if ( false === $order_response ) { $error = FrmPayPalLiteConnectHelper::get_latest_error_from_paypal_api(); wp_send_json_error( self::format_paypal_error( $error, 'Failed to create PayPal order' ) ); } if ( ! isset( $order_response->order_id ) ) { // Check if the response is a structured error with debug_id if ( isset( $order_response->message ) && isset( $order_response->debug_id ) ) { wp_send_json_error( self::format_paypal_error( $order_response->message . '{{debug_id:' . $order_response->debug_id . '}}', 'Failed to create PayPal order' ) ); } wp_send_json_error( 'Failed to create PayPal order' ); } wp_send_json_success( array( 'orderID' => $order_response->order_id ) ); } /** * @since 6.31 * * @param WP_Post $action * * @return array */ private static function get_payer_data_from_posted_values( $action ) { $email_setting = $action->post_content['email']; $first_name_setting = $action->post_content['billing_first_name']; $last_name_setting = $action->post_content['billing_last_name']; // @phpstan-ignore-next-line $address_setting = $action->post_content['billing_address'] ?? ''; $entry = self::generate_false_entry(); $first_name = $first_name_setting && isset( $entry->metas[ $first_name_setting ] ) ? $entry->metas[ $first_name_setting ] : ''; $last_name = $last_name_setting && isset( $entry->metas[ $last_name_setting ] ) ? $entry->metas[ $last_name_setting ] : ''; $address = $address_setting && isset( $entry->metas[ $address_setting ] ) ? $entry->metas[ $address_setting ] : ''; if ( is_array( $first_name ) && isset( $first_name['first'] ) ) { $first_name = $first_name['first']; } if ( is_array( $last_name ) && isset( $last_name['last'] ) ) { $last_name = $last_name['last']; } $payer = array(); if ( $first_name && $last_name ) { $payer['name'] = array( 'given_name' => $first_name, 'surname' => $last_name, ); } if ( $email_setting ) { $shortcode_atts = array( 'entry' => $entry, 'form' => $action->menu_order, 'value' => $email_setting, ); $payer['email_address'] = FrmTransLiteAppHelper::process_shortcodes( $shortcode_atts ); } self::maybe_add_address_data( $payer, $address, (int) $address_setting ); return $payer; } /** * Build shipping data from the action's shipping field settings. * * @since 6.31 * * @param WP_Post $action * * @return array */ private static function get_shipping_data_from_posted_values( $action ) { $settings = $action->post_content; $email_setting = ! empty( $settings['shipping_email'] ) ? $settings['shipping_email'] : ''; $first_name_setting = ! empty( $settings['shipping_first_name'] ) ? $settings['shipping_first_name'] : ''; $last_name_setting = ! empty( $settings['shipping_last_name'] ) ? $settings['shipping_last_name'] : ''; $address_setting = ! empty( $settings['shipping_address'] ) ? $settings['shipping_address'] : ''; if ( ! $email_setting && ! $first_name_setting && ! $last_name_setting && ! $address_setting ) { return array(); } $entry = self::generate_false_entry(); $first_name = $first_name_setting && isset( $entry->metas[ $first_name_setting ] ) ? $entry->metas[ $first_name_setting ] : ''; $last_name = $last_name_setting && isset( $entry->metas[ $last_name_setting ] ) ? $entry->metas[ $last_name_setting ] : ''; if ( is_array( $first_name ) && isset( $first_name['first'] ) ) { $first_name = $first_name['first']; } if ( is_array( $last_name ) && isset( $last_name['last'] ) ) { $last_name = $last_name['last']; } $shipping = array(); if ( $email_setting ) { $shortcode_atts = array( 'entry' => $entry, 'form' => $action->menu_order, 'value' => $email_setting, ); $email = FrmTransLiteAppHelper::process_shortcodes( $shortcode_atts ); if ( $email ) { $shipping['email_address'] = $email; } } if ( $first_name || $last_name ) { $shipping['name'] = array( 'full_name' => trim( $first_name . ' ' . $last_name ), ); } if ( ! $address_setting ) { return $shipping; } $address = $entry->metas[ $address_setting ] ?? ''; $formatted_address = self::format_address_for_paypal( $address, (int) $address_setting ); if ( $formatted_address ) { $shipping['address'] = $formatted_address; } return $shipping; } /** * Format a Formidable address field value into a PayPal address array. * * @since 6.31 * * @param mixed $address The address field value. * @param int $address_field_id The field ID. * * @return array|false The formatted address array, or false if invalid. */ private static function format_address_for_paypal( $address, $address_field_id ) { if ( ! is_array( $address ) || ! isset( $address['line1'] ) ) { return false; } $address_field = FrmField::getOne( $address_field_id ); if ( ! $address_field ) { return false; } if ( 'us' === $address_field->field_options['address_type'] ) { $country_code = 'US'; } else { $country_code = FrmAddressesController::get_country_code( $address['country'] ); } if ( ! $address['line1'] || ! $address['city'] || ! $address['state'] || ! $address['zip'] || ! $country_code ) { return false; } return array( 'address_line_1' => $address['line1'], 'address_line_2' => $address['line2'] ?? '', 'admin_area_2' => $address['city'], 'admin_area_1' => $address['state'], 'postal_code' => $address['zip'], 'country_code' => $country_code, ); } /** * @since 6.31 * * @param WP_Post $action * * @return string */ private static function get_shipping_preference( $action ) { $setting = ! empty( $action->post_content['shipping_preference'] ) ? $action->post_content['shipping_preference'] : 'use_paypal_account_data'; switch ( $setting ) { case 'use_address_field_data': return 'SET_PROVIDED_ADDRESS'; case 'no_shipping': return 'NO_SHIPPING'; case 'use_paypal_account_data': default: return 'GET_FROM_FILE'; } } /** * @since 6.31 * * @param array $payer * @param array $address * @param int $address_field_id * * @return void */ private static function maybe_add_address_data( &$payer, $address, $address_field_id ) { if ( ! is_array( $address ) || ! isset( $address['line1'] ) ) { return; } $address_field = FrmField::getOne( $address_field_id ); if ( ! $address_field ) { return; } if ( 'us' === $address_field->field_options['address_type'] ) { $country_code = 'US'; } else { $country_code = FrmAddressesController::get_country_code( $address['country'] ); } if ( ! $address['line1'] || ! $address['city'] || ! $address['state'] || ! $address['zip'] || ! $country_code ) { return; } $payer['address'] = array( 'address_line_1' => $address['line1'], 'address_line_2' => $address['line2'] ?? '', 'admin_area_2' => $address['city'], 'admin_area_1' => $address['state'], 'postal_code' => $address['zip'], 'country_code' => $country_code, ); } /** * @since 6.31 * * @return array */ private static function get_valid_payment_sources() { $sources = array( 'card', 'paypal', 'apple_pay', 'bancontact', 'blik', 'eps', 'giropay', 'ideal', 'mybank', 'p24', 'sepa', 'sofort', 'trustly', 'venmo', 'paylater', 'google_pay', ); /** * @since 6.31 * * @param array $sources */ return apply_filters( 'frm_paypal_valid_payment_sources', $sources ); } /** * Get the amount value for verification. * * @param WP_Post $action * * @return string */ private static function get_amount_value_for_verification( $action ) { $amount = $action->post_content['amount']; if ( ! str_contains( $amount, '[' ) ) { return $amount; } $form = FrmForm::getOne( $action->menu_order ); if ( ! $form ) { return $amount; } // Update amount based on field shortcodes. $entry = self::generate_false_entry(); return number_format( floatval( FrmPayPalLiteActionsController::prepare_amount( $amount, compact( 'form', 'entry', 'action' ) ) ) / 100, 2, '.', '' ); } /** * Create an entry object with posted values. * * @since 6.31 * * @return stdClass */ private static function generate_false_entry() { $entry = new stdClass(); $entry->post_id = 0; $entry->id = 0; $entry->item_key = ''; $entry->metas = array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing foreach ( $_POST as $k => $v ) { $k = sanitize_text_field( stripslashes( $k ) ); $v = wp_unslash( $v ); if ( $k !== 'item_meta' ) { FrmAppHelper::sanitize_value( 'wp_kses_post', $v ); $entry->{$k} = $v; continue; } if ( ! is_array( $v ) ) { continue; } foreach ( $v as $f => $value ) { FrmAppHelper::sanitize_value( 'wp_kses_post', $value ); $entry->metas[ absint( $f ) ] = $value; } } return $entry; } /** * Create a PayPal subscription object via AJAX. * * @return void */ public static function create_subscription() { check_ajax_referer( 'frm_paypal_ajax', 'nonce' ); // Check if PayPal is connected before attempting to create a subscription. $connection_check = self::check_paypal_connection(); if ( is_wp_error( $connection_check ) ) { wp_send_json_error( $connection_check->get_error_message() ); } $form_id = FrmAppHelper::get_post_param( 'form_id', 0, 'absint' ); if ( ! $form_id ) { wp_send_json_error( __( 'Invalid form ID', 'formidable' ) ); } $actions = FrmPayPalLiteActionsController::get_actions_before_submit( $form_id ); if ( ! $actions ) { wp_send_json_error( __( 'No PayPal actions found for this form', 'formidable' ) ); } $action = reset( $actions ); $amount = self::get_amount_value_for_verification( $action ); // PayPal expects the amount in a format like 10.00, so format it. $amount = number_format( floatval( $amount ), 2, '.', '' ); $currency = strtoupper( $action->post_content['currency'] ); // Pass $product_name, $interval and $interval_count to the helper // As well as trial period and the maximum number of payments. // Also send subscriber email and description. $product_name = self::process_shortcodes_for_action( $action->post_content['product_name'] ?? '', $action ); $interval = $action->post_content['interval'] ?? ''; $interval_count = $action->post_content['interval_count'] ?? 1; $trial_period = $action->post_content['trial_interval_count'] ?? ''; $payment_limit = $action->post_content['payment_limit'] ?? ''; $product_type = $action->post_content['product_type'] ?? 'SERVICE'; $description = self::process_shortcodes_for_action( $action->post_content['description'] ?? '', $action ); if ( ! $product_name ) { wp_send_json_error( __( 'A product name is required for subscriptions. Please update your PayPal action settings.', 'formidable' ) ); } if ( ! $interval ) { wp_send_json_error( __( 'A billing interval is required for subscriptions. Please update your PayPal action settings.', 'formidable' ) ); } if ( ! $amount || '0.00' === $amount ) { wp_send_json_error( __( 'A valid amount is required for subscriptions. Zero amounts are not allowed.', 'formidable' ) ); } $data = array( 'amount' => $amount, 'currency' => $currency, 'product_name' => $product_name, 'product_type' => $product_type, 'interval' => $interval, 'interval_count' => $interval_count, 'trial_period' => $trial_period, 'payment_limit' => $payment_limit, 'payer' => self::get_payer_data_from_posted_values( $action ), 'shipping_preference' => self::get_shipping_preference( $action ), 'description' => $description, ); $response = FrmPayPalLiteConnectHelper::create_subscription( $data ); if ( false === $response ) { $error = FrmPayPalLiteConnectHelper::get_latest_error_from_paypal_api(); wp_send_json_error( self::format_paypal_error( $error, 'Failed to create PayPal subscription' ) ); } // Check if response is a structured error with message and debug_id (array or object) if ( is_object( $response ) && isset( $response->message ) && isset( $response->debug_id ) ) { wp_send_json_error( $response ); } if ( ! isset( $response->subscription_id ) ) { wp_send_json_error( 'Failed to create PayPal subscription' ); } wp_send_json_success( array( 'subscriptionID' => $response->subscription_id ) ); } /** * Handle a PayPal error reported by the frontend JavaScript. * * Logs the debug ID and error details, then returns a display message * that includes the debug ID only when the current user has permission. * * @since 6.31 * * @return void */ public static function report_error() { check_ajax_referer( 'frm_paypal_ajax', 'nonce' ); $error_message = substr( FrmAppHelper::get_post_param( 'error_message', '', 'sanitize_text_field' ), 0, 500 ); $debug_id = substr( FrmAppHelper::get_post_param( 'debug_id', '', 'sanitize_text_field' ), 0, 50 ); $context = substr( FrmAppHelper::get_post_param( 'context', '', 'sanitize_text_field' ), 0, 100 ); if ( $debug_id ) { self::log_paypal_debug_id( $debug_id, $error_message, $context ); } $display_message = $error_message ? $error_message : __( 'Payment failed. Please try again.', 'formidable' ); if ( $debug_id && current_user_can( 'frm_change_settings' ) ) { $display_message .= "\n\nDebug ID: " . $debug_id; } wp_send_json_success( array( 'message' => $display_message ) ); } /** * Log a PayPal debug ID to the recent debug IDs option. * * Stores up to 20 entries, newest first. * * @since 6.31 * * @param string $debug_id The PayPal debug ID. * @param string $error_message The associated error message. * @param string $context The context where the error occurred (e.g. 'create_order', 'card_submit'). * * @return void */ public static function log_paypal_debug_id( $debug_id, $error_message = '', $context = '' ) { $option_name = 'frm_paypal_debug_ids'; $max_entries = 20; $entries = get_option( $option_name, array() ); if ( ! is_array( $entries ) ) { $entries = array(); } if ( $error_message && preg_match( '/^[A-Z_]+$/', $error_message ) ) { $prefixes = array( 'REFUND_FAILED_', 'REFUND_' ); $reason = str_replace( $prefixes, '', $error_message ); if ( $reason === $error_message ) { $error_message = ucwords( strtolower( str_replace( '_', ' ', $error_message ) ) ); } else { $error_message = 'Refund Failed (' . ucwords( strtolower( str_replace( '_', ' ', $reason ) ) ) . ')'; } } $entry = array( 'debug_id' => $debug_id, 'error_message' => $error_message, 'context' => $context, 'timestamp' => gmdate( 'Y-m-d H:i:s' ), ); array_unshift( $entries, $entry ); $entries = array_slice( $entries, 0, $max_entries ); update_option( $option_name, $entries, false ); } /** * Process shortcodes in an action setting value using posted form data. * * @since 6.31 * * @param string $value The value that may contain shortcodes. * @param WP_Post $action The payment action. * * @return string */ private static function process_shortcodes_for_action( $value, $action ) { if ( ! str_contains( $value, '[' ) ) { return $value; } $form = FrmForm::getOne( $action->menu_order ); if ( ! $form ) { return $value; } $entry = self::generate_false_entry(); return FrmTransLiteAppHelper::process_shortcodes( array( 'value' => $value, 'form' => $form, 'entry' => $entry, ) ); } /** * Check if PayPal is connected before attempting to create an order or subscription. * * @since 6.31 * * @return true|WP_Error True if connected, WP_Error with message if not connected. */ private static function check_paypal_connection() { $merchant_id = FrmPayPalLiteConnectHelper::get_merchant_id(); if ( ! $merchant_id ) { $message = __( 'PayPal is not connected. Please connect your PayPal account to process payments.', 'formidable' ); if ( current_user_can( 'frm_change_settings' ) ) { $message .= ' ' . __( 'You can connect PayPal in Global Settings, under the Payments section.', 'formidable' ); } return new WP_Error( 'paypal_not_connected', $message ); } return true; } /** * Parse a PayPal API error string and conditionally include the debug ID. * * The PayPal API addon embeds debug IDs using a {{debug_id:...}} delimiter. * This method strips that token and appends a human-readable debug ID line * only for users who can edit forms. * * @since 6.31 * * @param array|string|null $error The error string from the PayPal API, possibly containing a debug ID token. * @param string $fallback The fallback message when no error is available. * * @return string */ private static function format_paypal_error( $error, $fallback ) { if ( ! $error ) { return $fallback; } // If error is already a structured array with message and debug_id, return it directly if ( is_array( $error ) && isset( $error['message'] ) && isset( $error['debug_id'] ) ) { return $error; } if ( ! preg_match( '/\{\{debug_id:([^}]+)\}\}/', $error, $matches ) ) { return $error; } $clean_message = str_replace( $matches[0], '', $error ); $clean_message = trim( $clean_message ); // Always return structured error with debug_id so JavaScript can extract and send it return array( 'message' => $clean_message ? $clean_message : $fallback, 'debug_id' => $matches[1], ); } /** * @deprecated 6.32.1 */ public static function create_vault_setup_token() { _deprecated_function( __METHOD__, '6.32.1' ); wp_send_json_error( 'This API endpoint is no longer in use.' ); } }