",'')).replaceAll('
"," "),t.outerHTML=r)}),null===(e=document.querySelector(".preview.dropdown .frm-dropdown-toggle"))||void 0===e||e.setAttribute("data-bs-toggle","dropdown"),document.querySelectorAll("[data-toggle]").forEach(function(e){return e.setAttribute("data-bs-toggle",e.getAttribute("data-toggle"))})}),window.frm_show_div=function(e,t,r,n){t==r?jQuery(n+e).fadeIn("slow").css("visibility","visible"):jQuery(n+e).fadeOut("slow")},window.frmCheckAll=function(e,t){jQuery('input[name^="'.concat(t,'"]')).prop("checked",Boolean(e))},window.frmCheckAllLevel=function(e,t,r){jQuery(".frm_catlevel_".concat(r)).children(".frm_checkbox").children("label").children('input[name^="'.concat(t,'"]')).prop("checked",Boolean(e))},window.frmGetFieldValues=function(e,t,r,n,o,i){e&&jQuery.ajax({type:"POST",url:ajaxurl,data:"action=frm_get_field_values¤t_field=".concat(t,"&field_id=").concat(e,"&name=").concat(o,"&t=").concat(n,"&form_action=").concat(jQuery('input[name="frm_action"]').val(),"&nonce=").concat(frmGlobal.nonce),success:function(e){document.getElementById("frm_show_selected_values_".concat(t,"_").concat(r)).innerHTML=e,"function"==typeof i&&i()}})},window.frmImportCsv=function(e){var t="";"undefined"!=typeof __FRMURLVARS&&(t=__FRMURLVARS),jQuery.ajax({type:"POST",url:ajaxurl,data:"action=frm_import_csv&nonce=".concat(frmGlobal.nonce,"&frm_skip_cookie=1").concat(t),success:function(t){var r=jQuery(".frm_admin_progress_bar").attr("aria-valuemax"),n=r-t,o=n/r*100;jQuery(".frm_admin_progress_bar").css("width","".concat(o,"%")).attr("aria-valuenow",n),parseInt(t,10)>0?(jQuery(".frm_csv_remaining").html(t),frmImportCsv(e)):(jQuery(document.getElementById("frm_import_message")).html(frm_admin_js.import_complete),setTimeout(function(){location.href="?page=formidable-entries&frm_action=list&form=".concat(e,"&import-message=1")},2e3))}})}})();
\ No newline at end of file
diff --git a/js/src/admin/admin.js b/js/src/admin/admin.js
index b6d455f2e0..7da2a6d539 100644
--- a/js/src/admin/admin.js
+++ b/js/src/admin/admin.js
@@ -700,12 +700,28 @@ window.frmAdminBuildJS = function() {
function afterActionRemoved( type ) {
checkActiveAction( type );
+ maybeEnableOtherPaymentActions( type );
const hookName = 'frm_after_action_removed';
const hookArgs = { type };
wp.hooks.doAction( hookName, hookArgs );
}
+ /**
+ * @since x.x
+ *
+ * @param {string} deletedType
+ *
+ * @return {void}
+ */
+ function maybeEnableOtherPaymentActions( deletedType ) {
+ if ( 'payment' !== deletedType ) {
+ return;
+ }
+
+ [ 'stripe', 'square', 'paypal' ].forEach( action => checkActiveAction( action ) );
+ }
+
function clickWidget( event, b ) {
/*jshint validthis:true */
if ( b === undefined ) {
@@ -7587,6 +7603,8 @@ window.frmAdminBuildJS = function() {
// Check if icon should be active
checkActiveAction( type );
+ maybeDisableOtherPaymentActions( type );
+
showInputIcon( `#frm_form_action_${ actionId }` );
initiateMultiselect();
@@ -7607,6 +7625,30 @@ window.frmAdminBuildJS = function() {
}
}
+ /**
+ * @since x.x
+ *
+ * @param {string} excludedType
+ *
+ * @return {void}
+ */
+ function maybeDisableOtherPaymentActions( excludedType ) {
+ const paymentActions = [ 'stripe', 'square', 'paypal' ];
+
+ if ( ! paymentActions.includes( excludedType ) ) {
+ // Not a payment action so exit early.
+ return;
+ }
+
+ paymentActions.forEach(
+ action => {
+ if ( action !== excludedType ) {
+ checkActiveAction( action );
+ }
+ }
+ );
+ }
+
function closeOpenActions() {
document.querySelectorAll( '.frm_form_action_settings.open' ).forEach(
setting => setting.classList.remove( 'open' )
@@ -7908,7 +7950,15 @@ window.frmAdminBuildJS = function() {
return parseInt( jQuery( `.frm_${ type }_action` ).data( 'limit' ), 10 );
}
+ /**
+ * @param {string} type
+ *
+ * @return {number} The number of actions for the specified type.
+ */
function getNumberOfActionsForType( type ) {
+ if ( [ 'paypal', 'stripe', 'square' ].includes( type ) ) {
+ type = 'payment';
+ }
return jQuery( `.frm_single_${ type }_settings` ).length;
}
diff --git a/paypal/controllers/FrmPayPalLiteActionsController.php b/paypal/controllers/FrmPayPalLiteActionsController.php
new file mode 100644
index 0000000000..02d19328e2
--- /dev/null
+++ b/paypal/controllers/FrmPayPalLiteActionsController.php
@@ -0,0 +1,2417 @@
+form_id : $field['form_id'];
+ $actions = self::get_actions_before_submit( $form_id );
+
+ if ( ! $actions ) {
+ return $callback;
+ }
+
+ $field_id = is_object( $field ) ? $field->id : $field['id'];
+
+ foreach ( $actions as $action ) {
+ if ( (int) $action->post_content['credit_card'] === (int) $field_id ) {
+ return self::class . '::show_card';
+ }
+ }
+
+ return $callback;
+ }
+
+ /**
+ * Override the credit card field HTML if there is a PayPal action.
+ *
+ * @since x.x
+ *
+ * @param array $field
+ * @param string $field_name
+ * @param array $atts
+ *
+ * @return void
+ */
+ public static function show_card( $field, $field_name, $atts ) {
+ $actions = self::get_actions_before_submit( $field['form_id'] );
+
+ if ( $actions ) {
+ self::load_scripts( (int) $field['form_id'] );
+
+ $html_id = $atts['html_id'];
+ include FrmStrpLiteAppHelper::plugin_path() . '/views/payments/card-field.php';
+ return;
+ }
+
+ // Use the Pro function when there are no Stripe actions.
+ // This is required for other gateways like Authorize.Net.
+ if ( is_callable( 'FrmProCreditCardsController::show_in_form' ) ) {
+ FrmProCreditCardsController::show_in_form( $field, $field_name, $atts );
+ }
+ }
+
+ /**
+ * Get all published payment actions with the PayPal gateway that have an amount set.
+ *
+ * @since x.x
+ *
+ * @param int|string $form_id
+ *
+ * @return array
+ */
+ public static function get_actions_before_submit( $form_id ) {
+ $payment_actions = self::get_actions_for_form( $form_id );
+
+ foreach ( $payment_actions as $k => $payment_action ) {
+ $gateway = $payment_action->post_content['gateway'];
+ $is_paypal = $gateway === 'paypal' || ( is_array( $gateway ) && in_array( 'paypal', $gateway, true ) );
+
+ if ( ! $is_paypal || empty( $payment_action->post_content['amount'] ) ) {
+ unset( $payment_actions[ $k ] );
+ }
+ }
+
+ return $payment_actions;
+ }
+
+ /**
+ * Trigger a PayPal payment after a form is submitted.
+ * This is called for both one time and recurring payments.
+ *
+ * @param WP_Post $action
+ * @param stdClass $entry
+ * @param mixed $form
+ *
+ * @return array
+ */
+ public static function trigger_gateway( $action, $entry, $form ) {
+ $response = array(
+ 'success' => false,
+ 'run_triggers' => false,
+ 'show_errors' => true,
+ );
+ $atts = compact( 'action', 'entry', 'form' );
+ $amount = self::prepare_amount( $action->post_content['amount'], $atts );
+
+ // phpcs:ignore Universal.Operators.StrictComparisons
+ if ( ! $amount || $amount == 000 ) {
+ $response['error'] = __( 'Please specify an amount for the payment', 'formidable' );
+ return $response;
+ }
+
+ if ( ! self::paypal_is_configured() ) {
+ $response['error'] = __( 'PayPal still needs to be configured.', 'formidable' );
+ return $response;
+ }
+
+ $payment_args = compact( 'form', 'entry', 'action', 'amount' );
+
+ // Attempt to charge the customer's card.
+ if ( 'recurring' === $action->post_content['type'] ) {
+ $charge = self::trigger_recurring_payment( $payment_args );
+ } else {
+ $charge = self::trigger_one_time_payment( $payment_args );
+ }
+
+ if ( $charge === true ) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = $charge;
+ }
+
+ if ( ! self::$active_order_id ) {
+ return $response;
+ }
+
+ $paypal_message = '';
+ $email = false;
+ $address = false;
+ $order = FrmPayPalLiteConnectHelper::get_order( self::$active_order_id );
+
+ if ( is_object( $order ) && isset( $order->payer ) && is_object( $order->payer ) ) {
+ $payer = $order->payer;
+
+ if ( ! empty( $payer->email_address ) ) {
+ $email = $payer->email_address;
+ }
+ }
+
+ if ( is_object( $order ) && ! empty( $order->purchase_units[0]->shipping->address ) && is_object( $order->purchase_units[0]->shipping->address ) ) {
+ $address = $order->purchase_units[0]->shipping->address;
+ }
+
+ $paypal_message = '';
+ $source_type = self::$active_payment_source;
+
+ if ( $source_type ) {
+ $display_type = self::get_source_display_type( $source_type );
+ $paypal_message .= '' . esc_html__( 'Payment source: ', 'formidable' ) . ' ' . $display_type . ' ';
+ }
+
+ if ( $email ) {
+ $paypal_message .= '' . esc_html__( 'Payment made by: ', 'formidable' ) . ' ' . $email . ' ';
+ }
+
+ if ( $address && ! empty( $address->address_line_1 ) ) {
+ $paypal_message .= self::format_address( $address );
+ }
+
+ /**
+ * Filters the message to show in the main feedback area.
+ *
+ * @since x.x
+ *
+ * @param string $paypal_message The message to show.
+ * @param stdClass $order The order object.
+ */
+ $paypal_message = apply_filters( 'frm_paypal_message', $paypal_message, $order );
+
+ add_filter(
+ 'frm_main_feedback',
+ function ( $message ) use ( $paypal_message ) {
+ if ( $paypal_message ) {
+ $details = '' . $paypal_message . '
';
+ $message = preg_replace( '/(]*\bfrm_message\b[^>]*>)(.*?)(<\/div>)/s', '$1$2' . $details . '$3', $message );
+ }
+
+ return $message;
+ }
+ );
+
+ return $response;
+ }
+
+ /**
+ * Get the display label for a payment source type.
+ *
+ * @since x.x
+ *
+ * @param string $source_type The payment source identifier (e.g. 'paypal', 'paylater', 'google_pay').
+ *
+ * @return string The human-readable display label.
+ */
+ private static function get_source_display_type( $source_type ) {
+ switch ( $source_type ) {
+ case 'paypal':
+ return __( 'PayPal', 'formidable' );
+ case 'paylater':
+ return __( 'Pay Later', 'formidable' );
+ default:
+ return ucwords( str_replace( '_', ' ', $source_type ) );
+ }
+ }
+
+ /**
+ * Format a PayPal shipping address object into an HTML string.
+ *
+ * @since x.x
+ *
+ * @param object $address The PayPal shipping address object.
+ *
+ * @return string The formatted address HTML.
+ */
+ private static function format_address( $address ) {
+ $formatted = '' . esc_html__( 'Address: ', 'formidable' ) . ' ' . ' ';
+
+ $formatted .= $address->address_line_1 . ' ';
+
+ // City, State Zip
+ $city_line = '';
+
+ if ( ! empty( $address->admin_area_2 ) ) {
+ $city_line .= $address->admin_area_2;
+ }
+
+ if ( ! empty( $address->admin_area_1 ) ) {
+ $city_line .= $city_line ? ', ' . $address->admin_area_1 : $address->admin_area_1;
+ }
+
+ if ( ! empty( $address->postal_code ) ) {
+ $city_line .= $city_line ? ' ' . $address->postal_code : $address->postal_code;
+ }
+
+ if ( $city_line ) {
+ $formatted .= $city_line . ' ';
+ }
+
+ if ( ! empty( $address->country_code ) ) {
+ $formatted .= $address->country_code . ' ';
+ }
+
+ return $formatted;
+ }
+
+ /**
+ * Trigger a one time payment.
+ *
+ * @param array $atts The arguments for the payment.
+ *
+ * @return string|true string on error, true on success.
+ */
+ private static function trigger_one_time_payment( $atts ) {
+ $paypal_order_id = FrmAppHelper::get_post_param( 'paypal_order_id', '', 'sanitize_text_field' );
+
+ if ( ! $paypal_order_id ) {
+ return 'No PayPal order ID found.';
+ }
+
+ $order = FrmPayPalLiteConnectHelper::get_order( $paypal_order_id );
+
+ if ( false === $order ) {
+ return 'Failed to get order.';
+ }
+
+ if ( self::is_liability_error( $order ) ) {
+ return 'This payment was flagged as possible fraud and has been rejected.';
+ }
+
+ if ( ! self::validate_order_status( $order ) ) {
+ return 'This order status is not valid for capture.';
+ }
+
+ if ( ! self::validate_order_amount( $order, $atts['amount'] ) ) {
+ return 'This order amount appears to be tampered with.';
+ }
+
+ $response = FrmPayPalLiteConnectHelper::capture_order( $paypal_order_id );
+
+ if ( false === $response ) {
+ return 'Failed to confirm order.';
+ }
+
+ if ( ! isset( $response->status ) || $response->status !== 'COMPLETED' ) {
+ return 'Failed to capture order.';
+ }
+
+ $capture_id = self::get_capture_id_from_response( $response );
+
+ self::sync_entry_data_with_capture_response( $response, $atts );
+
+ // Create a payment record.
+ $atts['status'] = 'complete';
+ $atts['charge'] = new stdClass();
+ $atts['charge']->id = $capture_id ? $capture_id : $paypal_order_id;
+ $atts['charge']->amount = $atts['amount'];
+
+ $payment_id = self::create_new_payment( $atts );
+ $frm_payment = new FrmTransLitePayment();
+ $payment = $frm_payment->get_one( $payment_id );
+ $status = $atts['status'];
+
+ FrmTransLiteActionsController::trigger_payment_status_change( compact( 'status', 'payment' ) );
+
+ self::$active_order_id = $paypal_order_id;
+ self::$active_payment_source = FrmAppHelper::get_post_param( 'paypal_payment_source', '', 'sanitize_text_field' );
+
+ return true;
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param stdClass $order The order object.
+ *
+ * @return bool
+ */
+ private static function is_liability_error( $order ) {
+ if ( ! isset( $order->payment_source->card->authentication_result->liability_shift ) ) {
+ return false;
+ }
+
+ $liability_shift = $order->payment_source->card->authentication_result->liability_shift;
+ $is_liability_error = 'NO' === $liability_shift || 'UNKNOWN' === $liability_shift;
+
+ /**
+ * Filters whether the liability shift is an error.
+ *
+ * @since x.x
+ *
+ * @param bool $is_liability_error Whether the liability shift is an error.
+ * @param string $liability_shift The liability shift value. By default 'NO' and 'UNKNOWN' are errors.
+ * @param stdClass $order The order object.
+ */
+ return (bool) apply_filters(
+ 'frm_paypal_is_liability_error',
+ $is_liability_error,
+ $liability_shift,
+ $order
+ );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param stdClass $order
+ *
+ * @return bool
+ */
+ private static function validate_order_status( $order ) {
+ return isset( $order->status ) && 'APPROVED' === $order->status;
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param stdClass $order
+ * @param string $expected_amount This is as a whole number (in cents for currencies that include decimals).
+ *
+ * @return bool
+ */
+ private static function validate_order_amount( $order, $expected_amount ) {
+ $order_amount = $order->purchase_units[0]->amount->value ?? '';
+
+ // The order amount is in dollars, but the expected amount is in cents, so we need to convert.
+ $order_amount = str_replace( '.', '', $order_amount );
+
+ return $order_amount === $expected_amount;
+ }
+
+ /**
+ * Validate that the subscription status indicates it was approved by the payer.
+ *
+ * @since x.x
+ *
+ * @param stdClass $subscription The PayPal subscription object.
+ *
+ * @return bool
+ */
+ private static function validate_subscription_status( $subscription ) {
+ if ( ! isset( $subscription->status ) ) {
+ return false;
+ }
+
+ return in_array( $subscription->status, array( 'ACTIVE', 'APPROVED', 'APPROVAL_PENDING' ), true );
+ }
+
+ /**
+ * Validate that the subscription amount matches the expected amount.
+ *
+ * @since x.x
+ *
+ * @param stdClass $subscription The PayPal subscription object.
+ * @param string $expected_amount The expected amount as a whole number (in cents for currencies that include decimals).
+ *
+ * @return bool
+ */
+ private static function validate_subscription_amount( $subscription, $expected_amount ) {
+ // Vault-created subscriptions in APPROVAL_PENDING have no billing details yet.
+ if ( isset( $subscription->status ) && 'APPROVAL_PENDING' === $subscription->status ) {
+ return true;
+ }
+
+ $subscription_amount = $subscription->billing_info->last_payment->amount->value ?? $subscription->plan->billing_cycles[0]->pricing_scheme->fixed_price->value ?? '';
+
+ if ( ! $subscription_amount ) {
+ return false;
+ }
+
+ $subscription_amount = number_format( (float) $subscription_amount, 2, '.', '' );
+ $expected_amount = number_format( ( (float) $expected_amount ) / 100, 2, '.', '' );
+
+ return $subscription_amount === $expected_amount;
+ }
+
+ /**
+ * @param object $response
+ *
+ * @return string
+ */
+ private static function get_capture_id_from_response( $response ) {
+ if ( ! isset( $response->id ) ) {
+ return '';
+ }
+
+ foreach ( $response->purchase_units as $purchase_unit ) {
+ if ( empty( $purchase_unit->payments ) || ! is_object( $purchase_unit->payments ) ) {
+ continue;
+ }
+
+ $payments = $purchase_unit->payments;
+
+ if ( empty( $payments->captures ) || ! is_array( $payments->captures ) ) {
+ continue;
+ }
+
+ $captures = $payments->captures;
+
+ foreach ( $captures as $capture ) {
+ return $capture->id;
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Sync the entry data with the capture response.
+ *
+ * @since x.x
+ *
+ * @param object $response The response object.
+ * @param array $atts The arguments for the payment.
+ *
+ * @return void
+ */
+ private static function sync_entry_data_with_capture_response( $response, $atts ) {
+ if ( ! isset( $response->payer ) || ! is_object( $response->payer ) ) {
+ return;
+ }
+
+ $entry = $atts['entry'];
+ $action = $atts['action'];
+ $settings = $action->post_content;
+ $mode = $settings['entry_data_sync'] ?? 'overwrite';
+ $payer = $response->payer;
+
+ if ( 'new_fields' === $mode ) {
+ $updates = self::get_order_data_field_updates( $payer, $response, $settings );
+
+ // TODO: Make sure we're not adding the metas twice.
+ foreach ( $updates as $field_id => $new_value ) {
+ FrmEntryMeta::add_entry_meta( $entry->id, $field_id, '', $new_value );
+ }
+ } else {
+ $updates = self::get_payer_field_updates( $payer, $response, $action, $entry );
+
+ foreach ( $updates as $field_id => $new_value ) {
+ if ( ! FrmEntryMeta::update_entry_meta( $entry->id, $field_id, '', $new_value ) ) {
+ FrmEntryMeta::add_entry_meta( $entry->id, $field_id, '', $new_value );
+ }
+ }
+ }
+
+ if ( ! class_exists( 'FrmLog' ) ) {
+ return;
+ }
+
+ $log = new FrmLog();
+ $log->add(
+ array(
+ 'title' => 'PayPal Lite: Sync Entry Data with Capture Response',
+ 'content' => print_r( $updates, true ),
+ )
+ );
+ }
+
+ /**
+ * Sync the entry data with the subscription response.
+ *
+ * Normalizes the subscriber data from the subscription response into the
+ * same shape used by the capture response so the existing field-update
+ * helpers can be reused.
+ *
+ * @since x.x
+ *
+ * @param object $subscription The subscription object from PayPal.
+ * @param array $atts Includes 'entry', 'action', 'amount'.
+ *
+ * @return void
+ */
+ private static function sync_entry_data_with_subscription_response( $subscription, $atts ) {
+ if ( ! isset( $subscription->subscriber ) || ! is_object( $subscription->subscriber ) ) {
+ return;
+ }
+
+ $subscriber = $subscription->subscriber;
+
+ // Normalize subscriber into the payer shape expected by existing helpers.
+ $payer = (object) array(
+ 'email_address' => $subscriber->email_address ?? '',
+ 'name' => $subscriber->name ?? null,
+ );
+
+ // Normalize shipping address into the purchase_units shape expected by get_shipping_address_from_response.
+ $response = (object) array(
+ 'payer' => $payer,
+ 'purchase_units' => array(),
+ );
+
+ if ( isset( $subscriber->shipping_address->address ) ) {
+ $response->purchase_units = array(
+ (object) array(
+ 'shipping' => (object) array(
+ 'address' => $subscriber->shipping_address->address,
+ ),
+ ),
+ );
+ }
+
+ $entry = $atts['entry'];
+ $action = $atts['action'];
+ $settings = $action->post_content;
+ $mode = $settings['entry_data_sync'] ?? 'overwrite';
+
+ if ( 'new_fields' === $mode ) {
+ $updates = self::get_order_data_field_updates( $payer, $response, $settings );
+
+ foreach ( $updates as $field_id => $new_value ) {
+ FrmEntryMeta::add_entry_meta( $entry->id, $field_id, '', $new_value );
+ }
+ } else {
+ $updates = self::get_payer_field_updates( $payer, $response, $action, $entry );
+
+ foreach ( $updates as $field_id => $new_value ) {
+ if ( ! FrmEntryMeta::update_entry_meta( $entry->id, $field_id, '', $new_value ) ) {
+ FrmEntryMeta::add_entry_meta( $entry->id, $field_id, '', $new_value );
+ }
+ }
+ }
+
+ if ( ! class_exists( 'FrmLog' ) ) {
+ return;
+ }
+
+ $log = new FrmLog();
+ $log->add(
+ array(
+ 'title' => 'PayPal Lite: Sync Entry Data with Subscription Response',
+ 'content' => print_r( $updates, true ),
+ )
+ );
+ }
+
+ /**
+ * Build field updates for the dedicated PayPal order data fields.
+ *
+ * @since x.x
+ *
+ * @param stdClass $payer The payer object from the PayPal response.
+ * @param stdClass $response The full capture response.
+ * @param array $settings The action settings.
+ *
+ * @return array Field ID => value pairs.
+ */
+ private static function get_order_data_field_updates( $payer, $response, $settings ) {
+ $updates = array();
+
+ // Email (hidden field, single string value).
+ if ( ! empty( $settings['paypal_order_email'] ) && ! empty( $payer->email_address ) ) {
+ $updates[ (int) $settings['paypal_order_email'] ] = $payer->email_address;
+ }
+
+ // Name (name field with first/last sub-keys).
+ if ( ! empty( $settings['paypal_order_name'] ) && isset( $payer->name ) ) {
+ $new_value = array(
+ 'first' => ! empty( $payer->name->given_name ) ? $payer->name->given_name : '',
+ 'last' => ! empty( $payer->name->surname ) ? $payer->name->surname : '',
+ );
+
+ if ( array_filter( $new_value ) ) {
+ $updates[ (int) $settings['paypal_order_name'] ] = $new_value;
+ }
+ }
+
+ // Address (address field with line1/line2/city/state/zip/country sub-keys).
+ if ( empty( $settings['paypal_order_address'] ) ) {
+ return $updates;
+ }
+
+ $shipping = self::get_shipping_address_from_response( $response );
+
+ if ( $shipping ) {
+ $new_value = array(
+ 'line1' => ! empty( $shipping->address_line_1 ) ? $shipping->address_line_1 : '',
+ 'line2' => ! empty( $shipping->address_line_2 ) ? $shipping->address_line_2 : '',
+ 'city' => ! empty( $shipping->admin_area_2 ) ? $shipping->admin_area_2 : '',
+ 'state' => ! empty( $shipping->admin_area_1 ) ? $shipping->admin_area_1 : '',
+ 'zip' => ! empty( $shipping->postal_code ) ? $shipping->postal_code : '',
+ 'country' => ! empty( $shipping->country_code ) ? $shipping->country_code : '',
+ );
+
+ if ( array_filter( $new_value ) ) {
+ $updates[ (int) $settings['paypal_order_address'] ] = $new_value;
+ }
+ }
+
+ return $updates;
+ }
+
+ /**
+ * Build an array of field updates by comparing payer response data against current entry metas.
+ *
+ * @since x.x
+ *
+ * @param stdClass $payer The payer object from the PayPal response.
+ * @param stdClass $response The full capture response.
+ * @param WP_Post $action The payment action.
+ * @param stdClass $entry The entry object.
+ *
+ * @return array Field ID => new value pairs that differ from the current entry data.
+ */
+ private static function get_payer_field_updates( $payer, $response, $action, $entry ) {
+ $updates = array();
+ $settings = $action->post_content;
+
+ // Email: setting is a shortcode like [25], extract the field ID.
+ if ( ! empty( $settings['email'] ) && preg_match( '/\[(\d+)\]/', $settings['email'], $matches ) ) {
+ $email_field_id = (int) $matches[1];
+
+ if ( ! empty( $payer->email_address ) ) {
+ $current = $entry->metas[ $email_field_id ] ?? '';
+
+ if ( $current !== $payer->email_address ) {
+ $updates[ $email_field_id ] = $payer->email_address;
+ }
+ }
+ }
+
+ // Name fields.
+ if ( isset( $payer->name ) ) {
+ $first_name_field_id = ! empty( $settings['billing_first_name'] ) ? (int) $settings['billing_first_name'] : 0;
+ $last_name_field_id = ! empty( $settings['billing_last_name'] ) ? (int) $settings['billing_last_name'] : 0;
+
+ if ( $first_name_field_id && $first_name_field_id === $last_name_field_id ) {
+ // Both settings point to the same Name field. Store as a serialized array.
+ $new_value = array(
+ 'first' => ! empty( $payer->name->given_name ) ? $payer->name->given_name : '',
+ 'last' => ! empty( $payer->name->surname ) ? $payer->name->surname : '',
+ );
+ $current = $entry->metas[ $first_name_field_id ] ?? array();
+
+ if ( $current !== $new_value ) {
+ $updates[ $first_name_field_id ] = $new_value;
+ }
+ } else {
+ // Separate text fields for first and last name.
+ if ( $first_name_field_id && ! empty( $payer->name->given_name ) ) {
+ $current = $entry->metas[ $first_name_field_id ] ?? '';
+
+ if ( $current !== $payer->name->given_name ) {
+ $updates[ $first_name_field_id ] = $payer->name->given_name;
+ }
+ }
+
+ if ( $last_name_field_id && ! empty( $payer->name->surname ) ) {
+ $current = $entry->metas[ $last_name_field_id ] ?? '';
+
+ if ( $current !== $payer->name->surname ) {
+ $updates[ $last_name_field_id ] = $payer->name->surname;
+ }
+ }
+ }//end if
+ }//end if
+
+ // Address: pull from the first purchase unit's shipping address.
+ if ( empty( $settings['billing_address'] ) ) {
+ return $updates;
+ }
+
+ $field_id = (int) $settings['billing_address'];
+ $shipping = self::get_shipping_address_from_response( $response );
+
+ if ( ! $shipping ) {
+ return $updates;
+ }
+
+ $new_value = array(
+ 'line1' => ! empty( $shipping->address_line_1 ) ? $shipping->address_line_1 : '',
+ 'line2' => ! empty( $shipping->address_line_2 ) ? $shipping->address_line_2 : '',
+ 'city' => ! empty( $shipping->admin_area_2 ) ? $shipping->admin_area_2 : '',
+ 'state' => ! empty( $shipping->admin_area_1 ) ? $shipping->admin_area_1 : '',
+ 'zip' => ! empty( $shipping->postal_code ) ? $shipping->postal_code : '',
+ 'country' => ! empty( $shipping->country_code ) ? $shipping->country_code : '',
+ );
+
+ if ( ! array_filter( $new_value ) ) {
+ return $updates;
+ }
+
+ $current = $entry->metas[ $field_id ] ?? array();
+
+ if ( $current !== $new_value ) {
+ $updates[ $field_id ] = $new_value;
+ }
+
+ return $updates;
+ }
+
+ /**
+ * Get the shipping address object from the first purchase unit in the response.
+ *
+ * @since x.x
+ *
+ * @param object $response The capture response.
+ *
+ * @return false|object The address object, or false if not available.
+ */
+ private static function get_shipping_address_from_response( $response ) {
+ if ( empty( $response->purchase_units ) || ! is_array( $response->purchase_units ) ) {
+ return false;
+ }
+
+ $purchase_unit = reset( $response->purchase_units );
+
+ if ( empty( $purchase_unit->shipping ) || ! is_object( $purchase_unit->shipping ) || empty( $purchase_unit->shipping->address ) ) {
+ return false;
+ }
+
+ return $purchase_unit->shipping->address;
+ }
+
+ /**
+ * Add a payment row for the payments table.
+ *
+ * @param array $atts The arguments for the payment.
+ *
+ * @return int
+ */
+ private static function create_new_payment( $atts ) {
+ $atts['charge'] = (object) $atts['charge'];
+
+ $new_values = array(
+ 'amount' => FrmTransLiteAppHelper::get_formatted_amount_for_currency( $atts['charge']->amount, $atts['action'] ),
+ 'status' => $atts['status'],
+ 'paysys' => 'paypal',
+ 'item_id' => $atts['entry']->id,
+ 'action_id' => $atts['action']->ID,
+ 'receipt_id' => $atts['charge']->id,
+ 'sub_id' => $atts['charge']->sub_id ?? '',
+ 'test' => 'test' === FrmPayPalLiteAppHelper::active_mode() ? 1 : 0,
+ );
+
+ $frm_payment = new FrmTransLitePayment();
+ return $frm_payment->create( $new_values );
+ }
+
+ /**
+ * Create a new PayPal subscription and a subscription and payment for the payments tables.
+ *
+ * @param array $atts Includes 'customer', 'entry', 'action', 'amount'.
+ *
+ * @return bool|string True on success, error message on failure
+ */
+ private static function trigger_recurring_payment( $atts ) {
+ $subscription_id = FrmAppHelper::get_post_param( 'paypal_subscription_id', '', 'sanitize_text_field' );
+
+ if ( ! $subscription_id ) {
+ return __( 'No PayPal subscription ID found.', 'formidable' );
+ }
+
+ $subscription = FrmPayPalLiteConnectHelper::get_subscription( $subscription_id );
+
+ if ( false === $subscription ) {
+ return 'Failed to get subscription.';
+ }
+
+ if ( ! self::validate_subscription_status( $subscription ) ) {
+ return 'This subscription status is not valid.';
+ }
+
+ if ( ! self::validate_subscription_amount( $subscription, $atts['amount'] ) ) {
+ return 'This subscription amount appears to be tampered with.';
+ }
+
+ $sub_id = self::create_new_subscription( $subscription_id, $atts, $subscription );
+
+ self::$active_payment_source = FrmAppHelper::get_post_param( 'paypal_payment_source', '', 'sanitize_text_field' );
+
+ self::$active_order_id = FrmAppHelper::get_post_param( 'paypal_order_id', '', 'sanitize_text_field' );
+
+ self::sync_entry_data_with_subscription_response( $subscription, $atts );
+
+ self::maybe_create_initial_subscription_payment( $subscription_id, $sub_id, $atts );
+
+ return true;
+ }
+
+ /**
+ * Create a new subscription record in the payments tables.
+ *
+ * @param string $subscription_id The PayPal subscription ID.
+ * @param array $atts Includes 'entry', 'action', 'amount'.
+ * @param object $subscription The PayPal subscription API response.
+ *
+ * @return int
+ */
+ private static function create_new_subscription( $subscription_id, $atts, $subscription ) {
+ $next_bill_date = gmdate( 'Y-m-d' );
+
+ if ( ! empty( $subscription->billing_info->next_billing_time ) ) {
+ $next_bill_date = gmdate( 'Y-m-d', strtotime( $subscription->billing_info->next_billing_time ) );
+ }
+
+ $new_values = array(
+ 'amount' => FrmTransLiteAppHelper::get_formatted_amount_for_currency( $atts['amount'], $atts['action'] ),
+ 'paysys' => 'paypal',
+ 'item_id' => $atts['entry']->id,
+ 'action_id' => $atts['action']->ID,
+ 'sub_id' => $subscription_id,
+ 'interval_count' => $atts['action']->post_content['interval_count'],
+ 'time_interval' => $atts['action']->post_content['interval'],
+ 'status' => 'active',
+ 'next_bill_date' => $next_bill_date,
+ 'test' => 'test' === FrmPayPalLiteAppHelper::active_mode() ? 1 : 0,
+ );
+
+ $frm_sub = new FrmTransLiteSubscription();
+ return $frm_sub->create( $new_values );
+ }
+
+ /**
+ * Create the initial payment record for a new subscription.
+ *
+ * Uses the PayPal subscription ID as a temporary receipt_id. When the PAYMENT.SALE.COMPLETED
+ * webhook arrives later, the receipt_id is updated to the real capture/sale ID.
+ *
+ * @since x.x
+ *
+ * @param string $subscription_id The PayPal subscription ID.
+ * @param int $sub_id The local subscription record ID.
+ * @param array $atts Includes 'entry', 'action', 'amount'.
+ *
+ * @return void
+ */
+ private static function maybe_create_initial_subscription_payment( $subscription_id, $sub_id, $atts ) {
+ $atts['status'] = 'complete';
+ $atts['charge'] = new stdClass();
+ $atts['charge']->id = $subscription_id;
+ $atts['charge']->amount = $atts['amount'];
+ $atts['charge']->sub_id = $sub_id;
+
+ $payment_id = self::create_new_payment( $atts );
+ $frm_payment = new FrmTransLitePayment();
+ $payment = $frm_payment->get_one( $payment_id );
+ $status = $atts['status'];
+
+ FrmTransLiteActionsController::trigger_payment_status_change( compact( 'status', 'payment' ) );
+ }
+
+ /**
+ * Check if PayPal integration is enabled.
+ *
+ * @return bool true if PayPal is set up.
+ */
+ private static function paypal_is_configured() {
+ return (bool) FrmPayPalLiteConnectHelper::get_merchant_id();
+ }
+
+ /**
+ * Convert the amount from 10.00 to 1000.
+ *
+ * @param mixed $amount
+ * @param array $atts
+ *
+ * @return string
+ */
+ public static function prepare_amount( $amount, $atts = array() ) {
+ $amount = parent::prepare_amount( $amount, $atts );
+ $currency = self::get_currency_for_action( $atts );
+ return number_format( $amount, $currency['decimals'], '', '' );
+ }
+
+ /**
+ * If this form submits with ajax, load the scripts on the first page.
+ *
+ * @param array $params
+ *
+ * @return void
+ */
+ public static function maybe_load_scripts( $params ) {
+ // phpcs:ignore Universal.Operators.StrictComparisons
+ if ( $params['form_id'] == $params['posted_form_id'] ) {
+ // This form has already been posted, so we aren't on the first page.
+ return;
+ }
+
+ $form = FrmForm::getOne( $params['form_id'] );
+
+ if ( ! $form ) {
+ return;
+ }
+
+ $credit_card_field = FrmField::getAll(
+ array(
+ 'fi.form_id' => $form->id,
+ 'type' => 'credit_card',
+ )
+ );
+
+ if ( ! $credit_card_field ) {
+ return;
+ }
+
+ $payment_actions = self::get_actions_before_submit( $form->id );
+
+ if ( ! $payment_actions ) {
+ return;
+ }
+
+ $found_gateway = false;
+
+ foreach ( $payment_actions as $action ) {
+ $gateways = $action->post_content['gateway'];
+
+ if ( in_array( 'paypal', (array) $gateways, true ) ) {
+ $found_gateway = true;
+ break;
+ }
+ }
+
+ if ( ! $found_gateway ) {
+ return;
+ }
+
+ self::load_scripts( (int) $form->id );
+ }
+
+ /**
+ * Load front end JavaScript for a PayPal form.
+ *
+ * @param int $form_id
+ *
+ * @return void
+ */
+ public static function load_scripts( $form_id ) {
+ if ( FrmAppHelper::is_admin_page( 'formidable-entries' ) ) {
+ return;
+ }
+
+ if ( wp_script_is( 'formidable-paypal', 'enqueued' ) ) {
+ return;
+ }
+
+ if ( ! $form_id || ! is_int( $form_id ) ) {
+ _doing_it_wrong( __METHOD__, '$form_id parameter must be a non-zero integer', 'x.x' );
+ return;
+ }
+
+ $payment_action_by_id = array();
+
+ add_filter(
+ 'frm_trans_settings_for_js',
+ /**
+ * @param array $settings_for_action
+ * @param WP_Post $payment_action
+ *
+ * @return array
+ */
+ function ( $settings_for_action, $payment_action ) use ( &$payment_action_by_id ) {
+ $payment_action_by_id[ $payment_action->ID ] = $payment_action;
+ $settings_for_action['layout'] = ! empty( $payment_action->post_content['layout'] ) ? $payment_action->post_content['layout'] : 'card_and_checkout';
+ return $settings_for_action;
+ },
+ 10,
+ 2
+ );
+
+ $action_settings = self::prepare_settings_for_js( $form_id );
+ $action_setting_match = false;
+
+ foreach ( $action_settings as $action ) {
+ $gateways = $action['gateways'];
+
+ if ( ! $gateways || in_array( 'paypal', (array) $gateways, true ) ) {
+ $action_setting_match = $action;
+ break;
+ }
+ }
+
+ if ( false === $action_setting_match || ! array_key_exists( $action_setting_match['id'], $payment_action_by_id ) ) {
+ return;
+ }
+
+ $action = $payment_action_by_id[ $action_setting_match['id'] ];
+
+ // Use capture for one-time payments and subscription for recurring payments.
+ $intent = $action->post_content['type'] === 'single' ? 'capture' : 'subscription';
+
+ /**
+ * Build the PayPal SDK URL with required parameters.
+ *
+ * - Subscriptions require intent=subscription.
+ * - Subscriptions also require vault=true.
+ */
+ $query_args = array(
+ 'client-id' => self::get_client_id(),
+ 'intent' => $intent,
+ 'currency' => strtoupper( $action->post_content['currency'] ?? 'USD' ),
+ 'merchant-id' => FrmPayPalLiteConnectHelper::get_merchant_id(),
+ 'enable-funding' => 'venmo,applepay',
+ );
+
+ if ( 'subscription' === $intent ) {
+ $query_args['vault'] = 'true';
+ }
+
+ $include_buttons = false;
+ $include_card_fields = false;
+ $include_messages = true;
+
+ switch ( $action->post_content['layout'] ?? 'card_and_checkout' ) {
+ case 'card_only':
+ $include_card_fields = true;
+ break;
+
+ case 'checkout_only':
+ $include_buttons = true;
+ break;
+
+ default:
+ $include_buttons = true;
+ $include_card_fields = true;
+ break;
+ }
+
+ switch ( $action->post_content['pay_later'] ?? 'auto' ) {
+ case 'off':
+ $query_args['disable-funding'] = 'paylater';
+ break;
+ case 'no-messaging':
+ // PayPal throws a TypeError: can't access property "PAGE_TYPE", trackingDetails is undefined error
+ // a lot of the time if you include messages. If you see this error, try using this 'no-messaging' option.
+ $include_messages = false;
+ break;
+ }
+
+ $components = array();
+
+ if ( $include_buttons ) {
+ $components[] = 'buttons';
+ $components[] = 'googlepay';
+ $components[] = 'applepay';
+ }
+
+ if ( $include_card_fields ) {
+ $components[] = 'card-fields';
+ }
+
+ if ( $include_messages ) {
+ $components[] = 'messages';
+ }
+
+ // Enables .isEligible checks.
+ $components[] = 'funding-eligibility';
+
+ // Required for radio button option logos.
+ $components[] = 'marks';
+
+ $query_args['components'] = implode( ',', $components );
+ $locale = self::get_paypal_locale();
+
+ if ( $locale ) {
+ $query_args['locale'] = str_replace( '-', '_', $locale );
+ }
+
+ /**
+ * Allow customization of the PayPal SDK URL query arguments.
+ *
+ * @since x.x
+ *
+ * @param array $query_args
+ * @param WP_Post $action
+ */
+ $query_args = apply_filters( 'frm_paypal_sdk_url_query_args', $query_args, $action );
+
+ $sdk_url = add_query_arg( $query_args, 'https://www.paypal.com/sdk/js' );
+
+ wp_register_script( 'paypal-sdk', $sdk_url, array(), null, false );
+ wp_register_script( 'apple-pay-sdk', 'https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js', array(), null, false );
+
+ $has_break = FrmAppHelper::pro_is_installed() && (bool) FrmField::get_all_types_in_form( $form_id, 'break' );
+
+ add_filter(
+ 'script_loader_tag',
+ /**
+ * @param string $tag
+ * @param string $handle
+ *
+ * @return string
+ */
+ function ( $tag, $handle ) use ( $has_break ) {
+ if ( 'paypal-sdk' === $handle ) {
+ $attributes = ' async data-partner-attribution-id="' . esc_attr( FrmPayPalLiteConnectHelper::get_bn_code() ) . '"';
+ return str_replace( ' src=', $attributes . ' src=', $tag );
+ }
+
+ if ( in_array( $handle, array( 'apple-pay-sdk', 'google-pay' ), true ) ) {
+ return str_replace( ' src=', ' async src=', $tag );
+ }
+
+ if ( $has_break && 'formidable-paypal' === $handle ) {
+ return str_replace( ' src=', ' async src=', $tag );
+ }
+
+ return $tag;
+ },
+ 10,
+ 2
+ );
+
+ wp_enqueue_style(
+ 'formidable-paypal',
+ FrmPayPalLiteAppHelper::plugin_url() . 'css/frontend.css',
+ array(),
+ FrmAppHelper::plugin_version()
+ );
+
+ $dependencies = array( 'paypal-sdk', 'formidable' );
+
+ if ( $include_buttons ) {
+ $dependencies[] = 'apple-pay-sdk';
+ }
+
+ $script_url = FrmPayPalLiteAppHelper::plugin_url() . 'js/frontend.js';
+
+ wp_enqueue_script(
+ 'formidable-paypal',
+ $script_url,
+ $dependencies,
+ FrmAppHelper::plugin_version(),
+ false
+ );
+
+ if ( $include_buttons ) {
+ wp_enqueue_script(
+ 'google-pay',
+ 'https://pay.google.com/gp/p/js/pay.js',
+ array(),
+ '1.0',
+ false
+ );
+ }
+
+ $paypal_vars = array(
+ 'formId' => $form_id,
+ 'nonce' => wp_create_nonce( 'frm_paypal_ajax' ),
+ 'ajax' => esc_url_raw( FrmAppHelper::get_ajax_url() ),
+ 'settings' => $action_settings,
+ 'style' => self::get_style_for_js( $form_id ),
+ 'buttonStyle' => self::get_button_style_for_js( $action ),
+ 'imagesUrl' => FrmPayPalLiteAppHelper::plugin_url() . 'images/',
+ );
+
+ wp_localize_script( 'formidable-paypal', 'frmPayPalVars', $paypal_vars );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @return string
+ */
+ private static function get_paypal_locale() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, SlevomatCodingStandard.Functions.FunctionLength.FunctionLength
+ $locale = str_replace( '_', '-', get_locale() );
+ $parts = explode( '_', $locale );
+ $lang = strtolower( $parts[0] );
+ $country = isset( $parts[1] ) ? strtoupper( $parts[1] ) : '';
+
+ switch ( $lang ) {
+ case 'ar':
+ // Arabic
+ $countries = array( 'DZ', 'BH', 'EG', 'JO', 'KW', 'MA', 'OM', 'QA', 'SA', 'TN', 'AE', 'YE' );
+ break;
+
+ case 'bg':
+ // Bulgarian
+ $countries = array( 'BG' );
+ break;
+
+ case 'cs':
+ // Czech
+ $countries = array( 'CZ' );
+ break;
+
+ case 'da':
+ // Danish
+ $countries = array( 'DK', 'FO', 'GL' );
+ break;
+
+ case 'de':
+ // German
+ $countries = array( 'AT', 'DE', 'LU', 'CH' );
+ break;
+
+ case 'el':
+ // Greek
+ $countries = array( 'GR' );
+ break;
+
+ case 'en':
+ // English
+ $countries = array(
+ 'AL',
+ 'DZ',
+ 'AD',
+ 'AO',
+ 'AI',
+ 'AG',
+ 'AR',
+ 'AM',
+ 'AW',
+ 'AU',
+ 'AT',
+ 'AZ',
+ 'BS',
+ 'BH',
+ 'BB',
+ 'BY',
+ 'BE',
+ 'BZ',
+ 'BJ',
+ 'BM',
+ 'BT',
+ 'BO',
+ 'BA',
+ 'BW',
+ 'BR',
+ 'VG',
+ 'BN',
+ 'BG',
+ 'BF',
+ 'BI',
+ 'KH',
+ 'CM',
+ 'CA',
+ 'CV',
+ 'KY',
+ 'TD',
+ 'CL',
+ 'C2',
+ 'CN',
+ 'CO',
+ 'KM',
+ 'CG',
+ 'CD',
+ 'CK',
+ 'CR',
+ 'CI',
+ 'HR',
+ 'CY',
+ 'CZ',
+ 'DK',
+ 'DJ',
+ 'DM',
+ 'DO',
+ 'EC',
+ 'EG',
+ 'SV',
+ 'ER',
+ 'EE',
+ 'SZ',
+ 'ET',
+ 'FK',
+ 'FO',
+ 'FJ',
+ 'FI',
+ 'FR',
+ 'GF',
+ 'PF',
+ 'GA',
+ 'GM',
+ 'GE',
+ 'DE',
+ 'GI',
+ 'GR',
+ 'GL',
+ 'GD',
+ 'GP',
+ 'GT',
+ 'GN',
+ 'GW',
+ 'GY',
+ 'HN',
+ 'HK',
+ 'HU',
+ 'IS',
+ 'IN',
+ 'ID',
+ 'IE',
+ 'IL',
+ 'IT',
+ 'JM',
+ 'JP',
+ 'JO',
+ 'KZ',
+ 'KE',
+ 'KI',
+ 'KW',
+ 'KG',
+ 'LA',
+ 'LV',
+ 'LS',
+ 'LI',
+ 'LT',
+ 'LU',
+ 'MG',
+ 'MW',
+ 'MY',
+ 'MV',
+ 'ML',
+ 'MT',
+ 'MH',
+ 'MQ',
+ 'MR',
+ 'MU',
+ 'MX',
+ 'FM',
+ 'MD',
+ 'MC',
+ 'MN',
+ 'ME',
+ 'MS',
+ 'MA',
+ 'MZ',
+ 'NA',
+ 'NR',
+ 'NP',
+ 'NL',
+ 'AN',
+ 'NC',
+ 'NZ',
+ 'NI',
+ 'NE',
+ 'NG',
+ 'NU',
+ 'NF',
+ 'MK',
+ 'NO',
+ 'OM',
+ 'PW',
+ 'PA',
+ 'PG',
+ 'PY',
+ 'PE',
+ 'PH',
+ 'PN',
+ 'PL',
+ 'PT',
+ 'QA',
+ 'RE',
+ 'RO',
+ 'RU',
+ 'RW',
+ 'WS',
+ 'SM',
+ 'ST',
+ 'SA',
+ 'SN',
+ 'RS',
+ 'SC',
+ 'SL',
+ 'SG',
+ 'SK',
+ 'SI',
+ 'SB',
+ 'SO',
+ 'ZA',
+ 'KR',
+ 'ES',
+ 'LK',
+ 'SH',
+ 'KN',
+ 'LC',
+ 'PM',
+ 'VC',
+ 'SR',
+ 'SJ',
+ 'SE',
+ 'CH',
+ 'TW',
+ 'TJ',
+ 'TZ',
+ 'TH',
+ 'TG',
+ 'TO',
+ 'TT',
+ 'TN',
+ 'TR',
+ 'TM',
+ 'TC',
+ 'TV',
+ 'UG',
+ 'UA',
+ 'AE',
+ 'GB',
+ 'US',
+ 'UY',
+ 'VU',
+ 'VA',
+ 'VE',
+ 'VN',
+ 'WF',
+ 'YE',
+ 'ZM',
+ 'ZW',
+ );
+ break;
+
+ case 'es':
+ // Spanish
+ $countries = array(
+ 'DZ',
+ 'AD',
+ 'AO',
+ 'AI',
+ 'AG',
+ 'AR',
+ 'AM',
+ 'AW',
+ 'AZ',
+ 'BS',
+ 'BH',
+ 'BB',
+ 'BZ',
+ 'BJ',
+ 'BM',
+ 'BO',
+ 'BW',
+ 'VG',
+ 'BF',
+ 'BI',
+ 'CV',
+ 'KY',
+ 'TD',
+ 'CL',
+ 'CO',
+ 'KM',
+ 'CG',
+ 'CD',
+ 'CK',
+ 'CR',
+ 'DJ',
+ 'DM',
+ 'DO',
+ 'EC',
+ 'EG',
+ 'SV',
+ 'ER',
+ 'SZ',
+ 'ET',
+ 'FK',
+ 'FO',
+ 'FJ',
+ 'PF',
+ 'GA',
+ 'GM',
+ 'GE',
+ 'GI',
+ 'GL',
+ 'GD',
+ 'GT',
+ 'GN',
+ 'GW',
+ 'GY',
+ 'HN',
+ 'IE',
+ 'JM',
+ 'JO',
+ 'KZ',
+ 'KE',
+ 'KI',
+ 'KW',
+ 'KG',
+ 'LS',
+ 'LI',
+ 'LU',
+ 'MG',
+ 'MW',
+ 'ML',
+ 'MH',
+ 'MR',
+ 'MU',
+ 'MX',
+ 'MS',
+ 'MA',
+ 'MZ',
+ 'NA',
+ 'NR',
+ 'AN',
+ 'NC',
+ 'NZ',
+ 'NI',
+ 'NE',
+ 'NU',
+ 'NF',
+ 'OM',
+ 'PW',
+ 'PA',
+ 'PG',
+ 'PY',
+ 'PE',
+ 'PN',
+ 'QA',
+ 'RW',
+ 'SM',
+ 'ST',
+ 'SA',
+ 'SN',
+ 'RS',
+ 'SC',
+ 'SL',
+ 'SB',
+ 'SO',
+ 'ZA',
+ 'ES',
+ 'SH',
+ 'KN',
+ 'LC',
+ 'PM',
+ 'VC',
+ 'SR',
+ 'SJ',
+ 'TJ',
+ 'TZ',
+ 'TG',
+ 'TT',
+ 'TN',
+ 'TM',
+ 'TC',
+ 'TV',
+ 'UG',
+ 'UA',
+ 'AE',
+ 'US',
+ 'UY',
+ 'VU',
+ 'VA',
+ 'VE',
+ 'WF',
+ 'YE',
+ 'ZM',
+ );
+ break;
+
+ case 'et':
+ // Estonian
+ $countries = array( 'EE' );
+ break;
+
+ case 'fi':
+ // Finnish
+ $countries = array( 'FI' );
+ break;
+
+ case 'fr':
+ // French
+ $countries = array(
+ 'DZ',
+ 'AD',
+ 'AO',
+ 'AI',
+ 'AG',
+ 'AM',
+ 'AW',
+ 'AZ',
+ 'BS',
+ 'BH',
+ 'BB',
+ 'BE',
+ 'BZ',
+ 'BJ',
+ 'BM',
+ 'BO',
+ 'BW',
+ 'VG',
+ 'BF',
+ 'BI',
+ 'CM',
+ 'CA',
+ 'CV',
+ 'KY',
+ 'TD',
+ 'CL',
+ 'CO',
+ 'KM',
+ 'CG',
+ 'CD',
+ 'CK',
+ 'CR',
+ 'CI',
+ 'DJ',
+ 'DM',
+ 'DO',
+ 'EC',
+ 'EG',
+ 'SV',
+ 'ER',
+ 'SZ',
+ 'ET',
+ 'FK',
+ 'FO',
+ 'FJ',
+ 'FR',
+ 'GF',
+ 'PF',
+ 'GA',
+ 'GM',
+ 'GE',
+ 'GI',
+ 'GL',
+ 'GD',
+ 'GP',
+ 'GT',
+ 'GN',
+ 'GW',
+ 'GY',
+ 'HN',
+ 'IE',
+ 'JM',
+ 'JO',
+ 'KZ',
+ 'KE',
+ 'KI',
+ 'KW',
+ 'KG',
+ 'LS',
+ 'LI',
+ 'LU',
+ 'MG',
+ 'MW',
+ 'ML',
+ 'MH',
+ 'MQ',
+ 'MR',
+ 'MU',
+ 'YT',
+ 'MC',
+ 'MS',
+ 'MA',
+ 'MZ',
+ 'NA',
+ 'NR',
+ 'AN',
+ 'NC',
+ 'NZ',
+ 'NI',
+ 'NE',
+ 'NU',
+ 'NF',
+ 'OM',
+ 'PW',
+ 'PA',
+ 'PG',
+ 'PE',
+ 'PN',
+ 'QA',
+ 'RE',
+ 'RW',
+ 'SC',
+ 'SM',
+ 'ST',
+ 'SA',
+ 'SN',
+ 'RS',
+ 'SL',
+ 'SB',
+ 'SO',
+ 'ZA',
+ 'SH',
+ 'KN',
+ 'LC',
+ 'PM',
+ 'VC',
+ 'SR',
+ 'SJ',
+ 'CH',
+ 'TJ',
+ 'TZ',
+ 'TG',
+ 'TT',
+ 'TN',
+ 'TM',
+ 'TC',
+ 'TV',
+ 'UG',
+ 'UA',
+ 'AE',
+ 'US',
+ 'UY',
+ 'VU',
+ 'VA',
+ 'VE',
+ 'WF',
+ 'YE',
+ 'ZM',
+ );
+ break;
+
+ case 'he':
+ // Hebrew
+ $countries = array( 'IL' );
+ break;
+
+ case 'hu':
+ // Hungarian
+ $countries = array( 'HU' );
+ break;
+
+ case 'id':
+ // Indonesian
+ $countries = array( 'ID' );
+ break;
+
+ case 'it':
+ // Italian
+ $countries = array( 'IT' );
+ break;
+
+ case 'ja':
+ // Japanese
+ $countries = array( 'JP' );
+ break;
+
+ case 'ko':
+ // Korean
+ $countries = array( 'KR' );
+ break;
+
+ case 'lt':
+ // Lithuanian
+ $countries = array( 'LT' );
+ break;
+
+ case 'lv':
+ // Latvian
+ $countries = array( 'LV' );
+ break;
+
+ case 'ms':
+ // Malay
+ $countries = array( 'BN', 'MY' );
+ break;
+
+ case 'nl':
+ // Dutch
+ $countries = array( 'BE', 'NL' );
+ break;
+
+ case 'no':
+ // Norwegian
+ $countries = array( 'NO' );
+ break;
+
+ case 'pl':
+ // Polish
+ $countries = array( 'PL' );
+ break;
+
+ case 'pt':
+ // Portuguese
+ $countries = array( 'BR', 'PT' );
+ break;
+
+ case 'ro':
+ // Romanian
+ $countries = array( 'RO' );
+ break;
+
+ case 'ru':
+ // Russian
+ $countries = array( 'EE', 'LV', 'LT', 'RU', 'UA' );
+ break;
+
+ case 'si':
+ // Sinhala
+ $countries = array( 'LK' );
+ break;
+
+ case 'sk':
+ // Slovak
+ $countries = array( 'SK' );
+ break;
+
+ case 'sl':
+ // Slovenian
+ $countries = array( 'SI' );
+ break;
+
+ case 'sq':
+ // Albanian
+ $countries = array( 'AL' );
+ break;
+
+ case 'sv':
+ // Swedish
+ $countries = array( 'SE' );
+ break;
+
+ case 'th':
+ // Thai
+ $countries = array( 'TH' );
+ break;
+
+ case 'tl':
+ // Tagalog
+ $countries = array( 'PH' );
+ break;
+
+ case 'tr':
+ // Turkish
+ $countries = array( 'TR' );
+ break;
+
+ case 'vi':
+ // Vietnamese
+ $countries = array( 'VN' );
+ break;
+
+ case 'zh':
+ // Chinese
+ $countries = array(
+ 'C2',
+ 'CN',
+ 'HK',
+ 'TW',
+ 'DZ',
+ 'AD',
+ 'AO',
+ 'AI',
+ 'AG',
+ 'AM',
+ 'AW',
+ 'AZ',
+ 'BS',
+ 'BH',
+ 'BB',
+ 'BZ',
+ 'BJ',
+ 'BM',
+ 'BO',
+ 'BW',
+ 'VG',
+ 'BF',
+ 'BI',
+ 'CV',
+ 'KY',
+ 'TD',
+ 'CL',
+ 'CO',
+ 'KM',
+ 'CG',
+ 'CD',
+ 'CK',
+ 'CR',
+ 'DJ',
+ 'DM',
+ 'DO',
+ 'EC',
+ 'EG',
+ 'SV',
+ 'ER',
+ 'SZ',
+ 'ET',
+ 'FK',
+ 'FO',
+ 'FJ',
+ 'PF',
+ 'GA',
+ 'GM',
+ 'GE',
+ 'GI',
+ 'GL',
+ 'GD',
+ 'GT',
+ 'GN',
+ 'GW',
+ 'GY',
+ 'HN',
+ 'IE',
+ 'JM',
+ 'JO',
+ 'KZ',
+ 'KE',
+ 'KI',
+ 'KW',
+ 'KG',
+ 'LS',
+ 'LI',
+ 'LT',
+ 'LU',
+ 'MG',
+ 'MW',
+ 'ML',
+ 'MH',
+ 'MR',
+ 'MU',
+ 'MS',
+ 'MA',
+ 'MZ',
+ 'NA',
+ 'NR',
+ 'AN',
+ 'NC',
+ 'NZ',
+ 'NI',
+ 'NE',
+ 'NU',
+ 'NF',
+ 'OM',
+ 'PW',
+ 'PA',
+ 'PG',
+ 'PE',
+ 'PN',
+ 'QA',
+ 'RW',
+ 'SM',
+ 'ST',
+ 'SA',
+ 'SN',
+ 'RS',
+ 'SC',
+ 'SL',
+ 'SB',
+ 'SO',
+ 'ZA',
+ 'SH',
+ 'KN',
+ 'LC',
+ 'PM',
+ 'VC',
+ 'SR',
+ 'SJ',
+ 'TJ',
+ 'TZ',
+ 'TG',
+ 'TT',
+ 'TN',
+ 'TM',
+ 'TC',
+ 'TV',
+ 'UG',
+ 'UA',
+ 'AE',
+ 'US',
+ 'UY',
+ 'VU',
+ 'VA',
+ 'VE',
+ 'WF',
+ 'YE',
+ 'ZM',
+ );
+ break;
+
+ default:
+ $countries = array();
+ break;
+ }//end switch
+
+ if ( $country && in_array( $country, $countries, true ) ) {
+ return $lang . '-' . $country;
+ }
+
+ return 'en-US';
+ }
+
+ /**
+ * Get the style for the PayPal form.
+ *
+ * @param int $form_id
+ *
+ * @return array
+ */
+ public static function get_style_for_js( $form_id ) {
+ $settings = self::get_style_settings_for_form( $form_id );
+
+ $style = array(
+ 'body' => array(
+ 'padding' => 0,
+ ),
+ 'input' => array(
+ 'font-size' => $settings['field_font_size'],
+ 'color' => $settings['text_color'],
+ 'font-weight' => $settings['field_weight'],
+ 'padding' => $settings['field_pad'],
+ 'line-height' => 1.3,
+ 'border' => self::get_border_shorthand( $settings ),
+ 'border-radius' => self::get_border_radius( $settings ),
+ ),
+ 'input::placeholder' => array(
+ 'color' => $settings['text_color_disabled'],
+ ),
+ '.invalid' => array(
+ 'color' => $settings['border_color_error'],
+ ),
+ );
+
+ if ( ! empty( $settings['font'] ) ) {
+ $style['input']['font-family'] = $settings['font'];
+ }
+
+ /**
+ * Filter the PayPal card field styles.
+ *
+ * @since x.x
+ *
+ * @param array $style
+ * @param array $settings
+ * @param int $form_id
+ */
+ return apply_filters( 'frm_paypal_style', $style, $settings, $form_id );
+ }
+
+ /**
+ * Get PayPal button style configuration from form action settings.
+ * Documentation at https://developer.paypal.com/sdk/js/reference/#style
+ *
+ * @since x.x
+ *
+ * @param WP_Post $form_action The form action containing button settings.
+ *
+ * @return array The style configuration array for PayPal button.
+ */
+ private static function get_button_style_for_js( $form_action ) {
+ $button_color = $form_action->post_content['button_color'] ?? 'default';
+ $button_label = $form_action->post_content['button_label'] ?? 'paypal';
+ $button_border_radius = $form_action->post_content['button_border_radius'] ?? 10;
+
+ $style_for_js = array(
+ 'layout' => 'vertical',
+ 'color' => $button_color,
+ 'label' => $button_label,
+ 'borderRadius' => (int) $button_border_radius,
+ 'height' => 40,
+ );
+
+ // Unset the color so PayPal can use its defaults.
+ // Many buttons have different colors
+ if ( 'default' === $button_color ) {
+ unset( $style_for_js['color'] );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param array $style_for_js
+ * @param WP_Post $form_action
+ */
+ return apply_filters( 'frm_paypal_button_style', $style_for_js, $form_action );
+ }
+
+ /**
+ * Get and format the style settings for JavaScript to use with the get_style function.
+ *
+ * @param int $form_id
+ *
+ * @return array
+ */
+ private static function get_style_settings_for_form( $form_id ) {
+ if ( ! $form_id ) {
+ return array();
+ }
+
+ $style = FrmStylesController::get_form_style( $form_id );
+
+ if ( ! $style ) {
+ return array();
+ }
+
+ $settings = FrmStylesHelper::get_settings_for_output( $style );
+ $disallowed = array( ';', ':', '!important' );
+
+ foreach ( $settings as $k => $s ) {
+ if ( is_string( $s ) ) {
+ $settings[ $k ] = str_replace( $disallowed, '', $s );
+ }
+ }
+
+ return $settings;
+ }
+
+ /**
+ * Get the border width for PayPal card fields.
+ *
+ * @since x.x
+ *
+ * @param array $settings
+ *
+ * @return string
+ */
+ private static function get_border_width( $settings ) {
+ if ( ! empty( $settings['field_shape_type'] ) && 'underline' === $settings['field_shape_type'] ) {
+ return '0 0 ' . $settings['field_border_width'] . ' 0';
+ }
+ return $settings['field_border_width'];
+ }
+
+ /**
+ * Get the border radius for PayPal card fields.
+ *
+ * @since x.x
+ *
+ * @param array $settings
+ *
+ * @return string
+ */
+ private static function get_border_radius( $settings ) {
+ if ( ! empty( $settings['field_shape_type'] ) ) {
+ switch ( $settings['field_shape_type'] ) {
+ case 'underline':
+ case 'regular':
+ return '0px';
+ case 'circle':
+ return '30px';
+ }
+ }
+ return $settings['border_radius'];
+ }
+
+ /**
+ * Get the border shorthand for PayPal card fields.
+ *
+ * @since x.x
+ *
+ * @param array $settings
+ *
+ * @return string
+ */
+ private static function get_border_shorthand( $settings ) {
+ $width = self::get_border_width( $settings );
+ $style = $settings['field_border_style'];
+ $color = $settings['border_color'];
+
+ return "{$width} {$style} {$color}";
+ }
+
+ /**
+ * If the names are being used on the CC fields,
+ * make sure it doesn't prevent the submission if PayPal has approved.
+ *
+ * @since x.x
+ *
+ * @param array $errors
+ * @param stdClass $field
+ *
+ * @return array
+ */
+ public static function remove_cc_validation( $errors, $field ) {
+ $paypal_order_id = FrmAppHelper::get_post_param( 'paypal_order_id', '', 'sanitize_text_field' );
+ $paypal_subscription_id = FrmAppHelper::get_post_param( 'paypal_subscription_id', '', 'sanitize_text_field' );
+
+ if ( ! $paypal_order_id && ! $paypal_subscription_id ) {
+ return $errors;
+ }
+
+ return FrmTransLiteActionsController::remove_cc_errors( $errors, $field );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @return void
+ */
+ public static function actions_js() {
+ wp_enqueue_script(
+ 'frm_paypal_admin',
+ FrmPayPalLiteAppHelper::plugin_url() . 'js/action.js',
+ array( 'wp-hooks', 'wp-i18n' ),
+ FrmAppHelper::plugin_version()
+ );
+ }
+
+ /**
+ * Modify the new action post data to use the payment action type when the PayPal plugin is not active.
+ * This works better than having it disabled even when PayPal is supported.
+ *
+ * @since x.x
+ *
+ * @return void
+ */
+ public static function maybe_modify_new_action_post_data() {
+ $action_type = FrmAppHelper::get_param( 'type', '', 'post', 'sanitize_text_field' );
+
+ if ( 'paypal-legacy' === $action_type ) {
+ $_POST['type'] = 'paypal';
+ return;
+ }
+
+ if ( ! in_array( $action_type, array( 'paypal', 'stripe', 'square' ), true ) ) {
+ return;
+ }
+
+ $_POST['type'] = 'payment';
+
+ add_filter(
+ 'frm_form_payment_action_settings',
+ /**
+ * @param WP_Post $action_settings
+ *
+ * @return WP_Post
+ */
+ function ( $action_settings ) use ( $action_type ) {
+ return self::set_gateway_as_default( $action_settings, $action_type );
+ }
+ );
+ }
+
+ /**
+ * Set the gateway to PayPal as the default.
+ *
+ * @param WP_Post $action_settings
+ * @param string $action_type
+ *
+ * @return WP_Post
+ */
+ private static function set_gateway_as_default( $action_settings, $action_type ) {
+ $action_settings->post_content['gateway'] = array( $action_type );
+ return $action_settings;
+ }
+
+ /**
+ * Print additional options for Stripe action settings.
+ *
+ * @param array $atts
+ *
+ * @return void
+ */
+ public static function add_action_options( $atts ) {
+ $form_action = $atts['form_action'];
+ $action_control = $atts['action_control'];
+
+ include FrmPayPalLiteAppHelper::plugin_path() . '/views/settings/action-settings-options.php';
+ }
+
+ /**
+ * Print additional options for button settings.
+ *
+ * @param FrmFormAction $action_control
+ * @param WP_Post $form_action
+ *
+ * @return void
+ */
+ public static function add_button_settings_section( $action_control, $form_action ) {
+ include FrmPayPalLiteAppHelper::plugin_path() . '/views/settings/button-settings.php';
+ }
+
+ /**
+ * Add product name setting when formidable-payments plugin is active.
+ * This ensures the product name field appears when Stripe add-on is active.
+ *
+ * @since x.x
+ *
+ * @param array $args Arguments containing form_action and action_control.
+ *
+ * @return void
+ */
+ public static function add_product_name_setting_from_hook( $args ) {
+ $form_action = $args['form_action'];
+ $action_control = $args['action_control'];
+
+ // Include the product name setting
+ include FrmPayPalLiteAppHelper::plugin_path() . 'views/settings/product-name-action-setting.php';
+ }
+
+ /**
+ * Filter PayPal payment action settings on save.
+ * When entry_data_sync is set to 'new_fields', auto-create fields
+ * for storing PayPal order data (email, name, address).
+ *
+ * @since x.x
+ *
+ * @param array $settings The action settings being saved.
+ * @param array $action The full action data including menu_order (form_id).
+ *
+ * @return array
+ */
+ public static function before_save_settings( $settings, $action ) {
+ $gateway = ! empty( $settings['gateway'] ) ? (array) $settings['gateway'] : array();
+ $is_paypal = in_array( 'paypal', $gateway, true );
+
+ if ( ! $is_paypal ) {
+ return $settings;
+ }
+
+ if ( empty( $settings['entry_data_sync'] ) || 'new_fields' !== $settings['entry_data_sync'] ) {
+ return $settings;
+ }
+
+ $form_id = absint( $action['menu_order'] );
+
+ $settings = self::maybe_create_order_data_field( $settings, $form_id, 'paypal_order_email', __( 'PayPal Email', 'formidable' ), 'email' );
+ $settings = self::maybe_create_order_data_field( $settings, $form_id, 'paypal_order_name', __( 'PayPal Name', 'formidable' ), 'name' );
+
+ if ( is_callable( 'FrmProAddressesController::get_country_code' ) ) {
+ return self::maybe_create_order_data_field( $settings, $form_id, 'paypal_order_address', __( 'PayPal Address', 'formidable' ), 'address' );
+ }
+
+ return $settings;
+ }
+
+ /**
+ * Create a field for PayPal order data if it does not already exist.
+ * The field ID is stored in the action settings under the given key.
+ *
+ * @since x.x
+ *
+ * @param array $settings The action settings.
+ * @param int $form_id The form ID.
+ * @param string $setting_key The settings key to store the field ID in (e.g. 'paypal_order_email').
+ * @param string $field_name The human-readable field name.
+ * @param string $field_type The field type to create (e.g. 'hidden', 'name', 'address').
+ *
+ * @return array
+ */
+ private static function maybe_create_order_data_field( $settings, $form_id, $setting_key, $field_name, $field_type ) {
+ if ( ! empty( $settings[ $setting_key ] ) ) {
+ $existing = FrmField::getOne( (int) $settings[ $setting_key ] );
+
+ if ( $existing ) {
+ return $settings;
+ }
+ }
+
+ $field_id = self::add_a_field( $form_id, $field_type, $field_name );
+
+ if ( ! $field_id ) {
+ return $settings;
+ }
+
+ $field = FrmField::getOne( $field_id );
+ $field->field_options['is_paypal_order_field'] = 1;
+
+ FrmField::update( $field_id, array( 'field_options' => $field->field_options ) );
+
+ $settings[ $setting_key ] = $field_id;
+
+ return $settings;
+ }
+
+ /**
+ * @return string
+ */
+ private static function get_client_id() {
+ // TODO: This will need logic for a production client ID as well.
+ // This is currently just for testing.
+ return 'AYTiIIchQiekyGhJouWoLapPfjijirOtKHSN255SLhcP0TIaWBID-zxsYDaNmP4fXL6YcQxiSIMS0Lwu';
+ }
+
+ /**
+ * @param array $atts
+ *
+ * @return void
+ */
+ public static function show_paypal_button_settings( $atts ) {
+ $form_action = $atts['form_action'];
+ $action_control = $atts['action_control'];
+
+ // End the payment settings section.
+ echo '
';
+
+ self::add_button_settings_section( $action_control, $form_action );
+
+ // Open up a div tag since the payment section is closed after this and we already ended the section.
+ // This results in an empty div tag but it allows us to inject these options without requiring
+ // any updates in the payments submodule.
+ echo '';
+ }
+}
diff --git a/paypal/controllers/FrmPayPalLiteAppController.php b/paypal/controllers/FrmPayPalLiteAppController.php
new file mode 100644
index 0000000000..0608acf10a
--- /dev/null
+++ b/paypal/controllers/FrmPayPalLiteAppController.php
@@ -0,0 +1,586 @@
+ '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 x.x
+ *
+ * @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 x.x
+ *
+ * @param int $form_id The form ID.
+ *
+ * @return array Array of products with prices and quantities.
+ */
+ 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
+ $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' );
+
+ $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_preference = self::get_shipping_preference( $action );
+ $pricing_data = self::get_pricing_data_from_posted_values( $form_id );
+
+ // 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 );
+
+ if ( class_exists( 'FrmLog' ) ) {
+ $log = new FrmLog();
+ $log->add(
+ array(
+ 'title' => 'PayPal Order Response',
+ 'content' => print_r( $order_response, true ),
+ )
+ );
+ }
+
+ if ( false === $order_response ) {
+ wp_send_json_error( 'Failed to create PayPal order' );
+ }
+
+ if ( ! isset( $order_response->order_id ) ) {
+ wp_send_json_error( 'Failed to create PayPal order' );
+ }
+
+ wp_send_json_success( array( 'orderID' => $order_response->order_id ) );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @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'];
+ $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;
+ }
+
+ /**
+ * @since x.x
+ *
+ * @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 x.x
+ *
+ * @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'] ) || ! is_callable( 'FrmProAddressesController::get_country_code' ) ) {
+ 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 = FrmProAddressesController::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 x.x
+ *
+ * @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 x.x
+ *
+ * @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 x.x
+ *
+ * @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' );
+
+ $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.
+ $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_period'] ?? '';
+ $payment_limit = $action->post_content['payment_limit'] ?? '';
+
+ // TODO Process email properly.
+ $email = $action->post_content['email'] ?? '';
+
+ $data = array(
+ 'amount' => $amount,
+ 'currency' => $currency,
+ 'product_name' => $product_name,
+ 'interval' => $interval,
+ 'interval_count' => $interval_count,
+ 'trial_period' => $trial_period,
+ 'payment_limit' => $payment_limit,
+ 'email' => $email,
+ 'payer' => self::get_payer_data_from_posted_values( $action ),
+ 'shipping_preference' => self::get_shipping_preference( $action ),
+ );
+
+ $vault_setup_token = FrmAppHelper::get_post_param( 'vault_setup_token', '', 'sanitize_text_field' );
+
+ if ( $vault_setup_token ) {
+ $data['vault_setup_token'] = $vault_setup_token;
+ }
+
+ $response = FrmPayPalLiteConnectHelper::create_subscription( $data );
+
+ if ( false === $response ) {
+ wp_send_json_error( 'Failed to create PayPal subscription' );
+ }
+
+ if ( ! isset( $response->subscription_id ) ) {
+ wp_send_json_error( 'Failed to create PayPal subscription' );
+ }
+
+ wp_send_json_success( array( 'subscriptionID' => $response->subscription_id ) );
+ }
+
+ public static function create_vault_setup_token() {
+ check_ajax_referer( 'frm_paypal_ajax', 'nonce' );
+
+ $payment_source = FrmAppHelper::get_post_param( 'payment_source', 'card', 'sanitize_text_field' );
+
+ $data = array(
+ 'payment_source' => $payment_source,
+ );
+
+ $response = FrmPayPalLiteConnectHelper::create_vault_setup_token( $data );
+
+ if ( false === $response ) {
+ wp_send_json_error( 'Failed to create PayPal vault setup token' );
+ }
+
+ if ( ! isset( $response->token ) ) {
+ wp_send_json_error( 'Failed to create PayPal vault setup token' );
+ }
+
+ wp_send_json_success( array( 'token' => $response->token ) );
+ }
+
+ /**
+ * Process shortcodes in an action setting value using posted form data.
+ *
+ * @since x.x
+ *
+ * @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,
+ )
+ );
+ }
+}
diff --git a/paypal/controllers/FrmPayPalLiteEventsController.php b/paypal/controllers/FrmPayPalLiteEventsController.php
new file mode 100644
index 0000000000..dde69cf048
--- /dev/null
+++ b/paypal/controllers/FrmPayPalLiteEventsController.php
@@ -0,0 +1,799 @@
+flush_response();
+
+ $unprocessed_event_ids = FrmPayPalLiteConnectHelper::get_unprocessed_event_ids();
+
+ if ( $unprocessed_event_ids ) {
+ $this->process_event_ids( $unprocessed_event_ids );
+ }
+
+ wp_send_json_success();
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param array $event_ids
+ *
+ * @return void
+ */
+ private function process_event_ids( $event_ids ) {
+ foreach ( $event_ids as $event_id ) {
+ if ( $this->should_skip_event( $event_id ) ) {
+ continue;
+ }
+
+ set_transient( 'frm_paypal_last_process_' . $event_id, time(), 60 );
+
+ $this->event = FrmPayPalLiteConnectHelper::get_event( $event_id );
+
+ if ( ! is_object( $this->event ) ) {
+ $this->count_failed_event( $event_id );
+ continue;
+ }
+
+ $this->handle_event();
+ $this->track_handled_event( $event_id );
+ FrmPayPalLiteConnectHelper::process_event( $event_id );
+ }
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $event_id
+ *
+ * @return bool True if the event should be skipped.
+ */
+ private function should_skip_event( $event_id ) {
+ if ( $this->last_attempt_to_process_event_is_too_recent( $event_id ) ) {
+ return true;
+ }
+
+ $option = get_option( self::$events_to_skip_option_name );
+
+ return is_array( $option ) && in_array( $event_id, $option, true );
+ }
+
+ /**
+ * @param string $event_id
+ *
+ * @return bool
+ */
+ private function last_attempt_to_process_event_is_too_recent( $event_id ) {
+ $last_process_attempt = get_transient( 'frm_paypal_last_process_' . $event_id );
+ return is_numeric( $last_process_attempt ) && $last_process_attempt > time() - 60;
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $event_id
+ *
+ * @return void
+ */
+ private function count_failed_event( $event_id ) {
+ $transient_name = 'frm_paypal_failed_event_' . $event_id;
+ $transient = get_transient( $transient_name );
+ $failed_count = is_int( $transient ) ? $transient + 1 : 1;
+ $maximum_retries = 3;
+
+ if ( $failed_count >= $maximum_retries ) {
+ $this->track_handled_event( $event_id );
+ } else {
+ set_transient( $transient_name, $failed_count, 4 * DAY_IN_SECONDS );
+ }
+ }
+
+ /**
+ * Track an event to no longer process.
+ * This is called for successful events, and also for failed events after a number of retries.
+ *
+ * @since x.x
+ *
+ * @param string $event_id
+ *
+ * @return void
+ */
+ private function track_handled_event( $event_id ) {
+ $option = get_option( self::$events_to_skip_option_name );
+
+ if ( is_array( $option ) ) {
+ if ( count( $option ) > 1000 ) {
+ // Prevent the option from getting too big by removing the front item before adding the next.
+ array_shift( $option );
+ }
+ } else {
+ $option = array();
+ }
+
+ $option[] = $event_id;
+ update_option( self::$events_to_skip_option_name, $option, false );
+ }
+
+ /**
+ * @return void
+ */
+ private function handle_event() {
+ $this->resource = $this->event->resource ?? null;
+
+ if ( ! is_object( $this->resource ) ) {
+ FrmTransLiteLog::log_message( 'PayPal Webhook Message', 'No resource object found in event' );
+ return;
+ }
+
+ $payment_events = array(
+ 'PAYMENT.CAPTURE.COMPLETED' => 'complete',
+ 'PAYMENT.CAPTURE.DECLINED' => 'failed',
+ 'PAYMENT.CAPTURE.DENIED' => 'failed',
+ 'PAYMENT.CAPTURE.REFUNDED' => 'refunded',
+ 'PAYMENT.CAPTURE.REVERSED' => 'refunded',
+ 'PAYMENT.SALE.COMPLETED' => 'complete',
+ 'PAYMENT.SALE.DENIED' => 'failed',
+ 'PAYMENT.SALE.REFUNDED' => 'refunded',
+ 'PAYMENT.SALE.REVERSED' => 'refunded',
+ );
+
+ if ( isset( $payment_events[ $this->event->event_type ] ) ) {
+ $this->status = $payment_events[ $this->event->event_type ];
+ $this->handle_payment_event();
+ return;
+ }
+
+ switch ( $this->event->event_type ) {
+ case 'BILLING.SUBSCRIPTION.ACTIVATED':
+ case 'BILLING.SUBSCRIPTION.RE-ACTIVATED':
+ $this->handle_subscription_activated();
+ break;
+
+ case 'BILLING.SUBSCRIPTION.CANCELLED':
+ case 'BILLING.SUBSCRIPTION.EXPIRED':
+ case 'BILLING.SUBSCRIPTION.SUSPENDED':
+ $this->handle_subscription_canceled();
+ break;
+
+ case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED':
+ $this->handle_subscription_payment_failed();
+ break;
+
+ case 'BILLING.SUBSCRIPTION.UPDATED':
+ $this->handle_subscription_updated();
+ break;
+ }
+ }
+
+ /**
+ * Handle a payment capture or sale event by syncing the payment record.
+ *
+ * @since x.x
+ *
+ * @return void
+ */
+ private function handle_payment_event() {
+ $receipt_id = $this->get_receipt_id_for_event();
+
+ if ( ! $receipt_id ) {
+ FrmTransLiteLog::log_message( 'PayPal Webhook Message', 'No resource ID found in payment event' );
+ return;
+ }
+
+ $frm_payment = new FrmTransLitePayment();
+ $payment = $frm_payment->get_one_by( $receipt_id, 'receipt_id' );
+
+ if ( ! $payment && $this->status === 'refunded' ) {
+ FrmTransLiteLog::log_message( 'PayPal Webhook Message', 'No action taken. The refunded payment does not exist for ' . $receipt_id );
+ return;
+ }
+
+ $run_triggers = false;
+
+ if ( ! $payment ) {
+ $payment = $this->maybe_create_subscription_payment();
+
+ if ( $payment ) {
+ $run_triggers = true;
+ }
+ } elseif ( $payment->status !== $this->status ) {
+ $payment_values = (array) $payment;
+ $is_partial_refund = $this->is_partial_refund();
+
+ if ( $is_partial_refund ) {
+ $this->set_partial_refund( $payment_values );
+ // translators: %s: The amount of money that was refunded.
+ $note = sprintf( __( 'Payment partially refunded %s', 'formidable' ), $this->get_refunded_amount() );
+ } else {
+ $payment_values['status'] = $this->status;
+ $payment->status = $this->status;
+ // translators: %s: The status of the payment.
+ $note = sprintf( __( 'Payment %s', 'formidable' ), $payment_values['status'] );
+ }
+
+ FrmTransLiteAppHelper::add_note_to_payment( $payment_values, $note );
+
+ $frm_payment->update( $payment->id, $payment_values );
+
+ if ( ! $is_partial_refund ) {
+ $run_triggers = true;
+ }
+ }//end if
+
+ if ( $run_triggers && $payment && $payment->action_id ) {
+ FrmTransLiteActionsController::trigger_payment_status_change(
+ array(
+ 'status' => $this->status,
+ 'payment' => $payment,
+ )
+ );
+ }
+ }
+
+ /**
+ * Try to create a new payment record from a subscription payment event.
+ * This handles recurring payments after the first one.
+ *
+ * @since x.x
+ *
+ * @return false|object The new payment object or false if not a subscription payment.
+ */
+ private function maybe_create_subscription_payment() {
+ $subscription_id = $this->get_subscription_id_from_resource();
+
+ if ( ! $subscription_id ) {
+ return false;
+ }
+
+ $sub = $this->get_subscription( $subscription_id );
+
+ if ( ! $sub ) {
+ return false;
+ }
+
+ $receipt_id = $this->resource->id ?? '';
+ $amount = $this->get_amount_from_resource();
+
+ $payment_values = array(
+ 'paysys' => 'paypal',
+ 'amount' => $amount,
+ 'status' => $this->status,
+ 'item_id' => $sub->item_id,
+ 'action_id' => $sub->action_id,
+ 'receipt_id' => $receipt_id,
+ 'sub_id' => $sub->id,
+ 'begin_date' => gmdate( 'Y-m-d' ),
+ 'expire_date' => '0000-00-00',
+ 'meta_value' => array(),
+ 'created_at' => current_time( 'mysql', 1 ),
+ 'test' => 'test' === FrmPayPalLiteAppHelper::active_mode() ? 1 : 0,
+ );
+
+ FrmTransLiteAppHelper::add_note_to_payment( $payment_values );
+
+ $frm_payment = new FrmTransLitePayment();
+ $existing_payment = $frm_payment->get_one_by( $receipt_id, 'receipt_id' );
+
+ if ( $existing_payment ) {
+ return false;
+ }
+
+ $existing_sub_payment = $frm_payment->get_one_by( $sub->id, 'sub_id' );
+
+ if ( $existing_sub_payment && str_starts_with( $existing_sub_payment->receipt_id, 'I-' ) ) {
+ $frm_payment->update( $existing_sub_payment->id, array( 'receipt_id' => $receipt_id ) );
+
+ $this->update_next_bill_date( $sub );
+ $this->maybe_cancel_subscription_at_limit( $sub );
+
+ return $frm_payment->get_one( $existing_sub_payment->id );
+ }
+
+ $payment_id = $frm_payment->create( $payment_values );
+
+ $this->update_next_bill_date( $sub );
+ $this->maybe_cancel_subscription_at_limit( $sub );
+
+ return $frm_payment->get_one( $payment_id );
+ }
+
+ /**
+ * Get the PayPal subscription ID from the resource object.
+ *
+ * @since x.x
+ *
+ * @return string
+ */
+ private function get_subscription_id_from_resource() {
+ if ( ! empty( $this->resource->billing_agreement_id ) ) {
+ return $this->resource->billing_agreement_id;
+ }
+
+ if ( ! empty( $this->resource->supplementary_data->related_ids->subscription_id ) ) {
+ return $this->resource->supplementary_data->related_ids->subscription_id;
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the payment amount from the resource object.
+ *
+ * @since x.x
+ *
+ * @return string
+ */
+ private function get_amount_from_resource() {
+ if ( ! empty( $this->resource->amount->total ) ) {
+ return $this->resource->amount->total;
+ }
+
+ if ( ! empty( $this->resource->amount->value ) ) {
+ return $this->resource->amount->value;
+ }
+
+ return '0.00';
+ }
+
+ /**
+ * Get the refunded amount from the resource object.
+ *
+ * @since x.x
+ *
+ * @return string
+ */
+ private function get_refunded_amount() {
+ if ( ! empty( $this->resource->amount->total ) ) {
+ return $this->resource->amount->total;
+ }
+
+ if ( ! empty( $this->resource->amount->value ) ) {
+ return $this->resource->amount->value;
+ }
+
+ return '0.00';
+ }
+
+ /**
+ * Check if a refund is partial by comparing the refunded amount to the original payment amount.
+ *
+ * @since x.x
+ *
+ * @return bool
+ */
+ private function is_partial_refund() {
+ if ( $this->status !== 'refunded' ) {
+ return false;
+ }
+
+ $receipt_id = $this->get_receipt_id_for_event();
+
+ if ( ! $receipt_id ) {
+ return false;
+ }
+
+ $frm_payment = new FrmTransLitePayment();
+ $original_payment = $frm_payment->get_one_by( $receipt_id, 'receipt_id' );
+ $refunded_amount = (float) $this->get_refunded_amount();
+
+ if ( $original_payment ) {
+ return $refunded_amount < (float) $original_payment->amount;
+ }
+
+ return false;
+ }
+
+ /**
+ * Set the partial refund amount on a payment.
+ *
+ * @since x.x
+ *
+ * @param array $payment_values The payment values to update.
+ *
+ * @return void
+ */
+ private function set_partial_refund( &$payment_values ) {
+ $refunded = (float) $this->get_refunded_amount();
+ $original = (float) $payment_values['amount'];
+ $payment_values['amount'] = number_format( $original - $refunded, 2, '.', '' );
+ }
+
+ /**
+ * Get the receipt ID to use for looking up a payment record.
+ *
+ * For most events, this is the resource ID. For refund/reversal events,
+ * the resource is the refund object, so we need to extract the original
+ * capture or sale ID from the resource's sale_id property or HATEOAS links.
+ *
+ * @since x.x
+ *
+ * @return string The receipt ID of the original payment.
+ */
+ private function get_receipt_id_for_event() {
+ if ( $this->status !== 'refunded' ) {
+ return $this->resource->id ?? '';
+ }
+
+ $refund_id = $this->resource->id ?? '';
+
+ // For sale refunds, the sale_id property references the original sale.
+ if ( ! empty( $this->resource->sale_id ) ) {
+ return $this->resource->sale_id;
+ }
+
+ // For capture refunds, extract the capture ID from the HATEOAS 'up' link.
+ // The 'up' link points to the original capture: /v2/payments/captures/{capture_id}
+ if ( empty( $this->resource->links ) || ! is_array( $this->resource->links ) ) {
+ return $refund_id;
+ }
+
+ foreach ( $this->resource->links as $link ) {
+ if ( ! isset( $link->rel ) || 'up' !== $link->rel ) {
+ continue;
+ }
+
+ if ( empty( $link->href ) ) {
+ continue;
+ }
+
+ // Extract the ID from the URL path: .../captures/{id} or .../sale/{id}
+ $path = wp_parse_url( $link->href, PHP_URL_PATH );
+
+ if ( ! $path ) {
+ continue;
+ }
+
+ $segments = explode( '/', rtrim( $path, '/' ) );
+ $last_segment = end( $segments );
+
+ if ( $last_segment ) {
+ return $last_segment;
+ }
+ }//end foreach
+
+ // Fallback to the resource ID (the refund ID itself).
+ return $refund_id;
+ }
+
+ /**
+ * Get a subscription record by its PayPal subscription ID.
+ *
+ * @since x.x
+ *
+ * @param string $sub_id The PayPal subscription ID.
+ *
+ * @return object|null
+ */
+ private function get_subscription( $sub_id ) {
+ $frm_sub = new FrmTransLiteSubscription();
+ $sub = $frm_sub->get_one_by( $sub_id, 'sub_id' );
+
+ if ( ! $sub ) {
+ FrmTransLiteLog::log_message( 'PayPal Webhook Message', 'No action taken since there is not a matching subscription for ' . $sub_id );
+ }
+
+ return $sub;
+ }
+
+ /**
+ * Update the next bill date for a subscription using the PayPal API.
+ *
+ * @since x.x
+ *
+ * @param object $sub The local subscription record.
+ *
+ * @return void
+ */
+ private function update_next_bill_date( $sub ) {
+ $subscription = FrmPayPalLiteConnectHelper::get_subscription( $sub->sub_id );
+
+ if ( ! is_object( $subscription ) ) {
+ return;
+ }
+
+ $next_bill_date = '';
+
+ if ( ! empty( $subscription->billing_info->next_billing_time ) ) {
+ $next_bill_date = gmdate( 'Y-m-d', strtotime( $subscription->billing_info->next_billing_time ) );
+ }
+
+ if ( ! $next_bill_date ) {
+ return;
+ }
+
+ $frm_sub = new FrmTransLiteSubscription();
+ $frm_sub->update( $sub->id, array( 'next_bill_date' => $next_bill_date ) );
+ }
+
+ /**
+ * Check if a subscription has reached its payment limit.
+ * If it has, cancel the subscription.
+ *
+ * @since x.x
+ *
+ * @param object $sub The local subscription record.
+ *
+ * @return void
+ */
+ private function maybe_cancel_subscription_at_limit( $sub ) {
+ $action = FrmFormAction::get_single_action_type( $sub->action_id, 'payment' );
+
+ if ( ! is_object( $action ) || empty( $action->post_content['payment_limit'] ) ) {
+ return;
+ }
+
+ $frm_payment = new FrmTransLitePayment();
+ $all_payments = $frm_payment->get_all_by( $sub->id, 'sub_id' );
+ $count = FrmTransLiteAppHelper::count_completed_payments( $all_payments );
+
+ if ( $count < (int) $action->post_content['payment_limit'] ) {
+ return;
+ }
+
+ $cancelled = FrmPayPalLiteConnectHelper::cancel_subscription( $sub->sub_id );
+
+ if ( $cancelled ) {
+ FrmTransLiteSubscriptionsController::change_subscription_status(
+ array(
+ 'status' => 'future_cancel',
+ 'sub' => $sub,
+ )
+ );
+ }
+ }
+
+ /**
+ * Handle a subscription activated or re-activated event.
+ *
+ * @since x.x
+ *
+ * @return void
+ */
+ private function handle_subscription_activated() {
+ $subscription_id = $this->resource->id ?? '';
+
+ if ( ! $subscription_id ) {
+ return;
+ }
+
+ $sub = $this->get_subscription( $subscription_id );
+
+ if ( ! $sub ) {
+ return;
+ }
+
+ if ( $sub->status === 'active' ) {
+ return;
+ }
+
+ FrmTransLiteSubscriptionsController::change_subscription_status(
+ array(
+ 'status' => 'active',
+ 'sub' => $sub,
+ )
+ );
+
+ $this->update_next_bill_date( $sub );
+ }
+
+ /**
+ * Handle a subscription cancelled, expired, or suspended event.
+ *
+ * @since x.x
+ *
+ * @return void
+ */
+ private function handle_subscription_canceled() {
+ $subscription_id = $this->resource->id ?? '';
+
+ if ( ! $subscription_id ) {
+ FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: No subscription ID found in resource' );
+ return;
+ }
+
+ FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Looking for subscription ID: ' . $subscription_id );
+
+ $sub = $this->get_subscription( $subscription_id );
+
+ if ( ! $sub ) {
+ FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Subscription not found in database for ID: ' . $subscription_id );
+ return;
+ }
+
+ FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Found subscription, current status: ' . $sub->status );
+
+ if ( $sub->status === 'canceled' ) {
+ FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Subscription already canceled, no action needed' );
+ return;
+ }
+
+ FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Updating subscription status to canceled' );
+
+ FrmTransLiteSubscriptionsController::change_subscription_status(
+ array(
+ 'status' => 'canceled',
+ 'sub' => $sub,
+ )
+ );
+
+ FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Status update completed' );
+ }
+
+ /**
+ * Handle a subscription payment failed event.
+ * Increments the fail count and cancels after too many failures.
+ *
+ * @since x.x
+ *
+ * @return void
+ */
+ private function handle_subscription_payment_failed() {
+ $subscription_id = $this->resource->id ?? '';
+
+ if ( ! $subscription_id ) {
+ return;
+ }
+
+ $sub = $this->get_subscription( $subscription_id );
+
+ if ( ! $sub ) {
+ return;
+ }
+
+ $frm_sub = new FrmTransLiteSubscription();
+ $fail_count = (int) $sub->fail_count + 1;
+
+ $frm_sub->update(
+ $sub->id,
+ array( 'fail_count' => $fail_count )
+ );
+
+ if ( $fail_count > 3 ) {
+ FrmTransLiteSubscriptionsController::change_subscription_status(
+ array(
+ 'status' => 'canceled',
+ 'sub' => $sub,
+ )
+ );
+ }
+ }
+
+ /**
+ * Handle a subscription updated event.
+ * Syncs subscription data like amount and next bill date.
+ *
+ * @since x.x
+ *
+ * @return void
+ */
+ private function handle_subscription_updated() {
+ $subscription_id = $this->resource->id ?? '';
+
+ if ( ! $subscription_id ) {
+ return;
+ }
+
+ $sub = $this->get_subscription( $subscription_id );
+
+ if ( ! $sub ) {
+ return;
+ }
+
+ $new_values = array();
+
+ // Sync the subscription status.
+ $paypal_status = $this->resource->status ?? '';
+ $status_map = array(
+ 'ACTIVE' => 'active',
+ 'SUSPENDED' => 'canceled',
+ 'CANCELLED' => 'canceled',
+ 'EXPIRED' => 'canceled',
+ );
+
+ if ( $paypal_status && isset( $status_map[ $paypal_status ] ) && $sub->status !== $status_map[ $paypal_status ] ) {
+ FrmTransLiteSubscriptionsController::change_subscription_status(
+ array(
+ 'status' => $status_map[ $paypal_status ],
+ 'sub' => $sub,
+ )
+ );
+ }
+
+ // Sync next billing date and amount from the PayPal API.
+ $subscription = FrmPayPalLiteConnectHelper::get_subscription( $subscription_id );
+
+ if ( ! is_object( $subscription ) ) {
+ // If the subscription doesn't exist in PayPal's API (404 error), it's likely cancelled.
+ // Update the local status to canceled if it's not already.
+ if ( $sub->status !== 'canceled' ) {
+ FrmTransLiteSubscriptionsController::change_subscription_status(
+ array(
+ 'status' => 'canceled',
+ 'sub' => $sub,
+ )
+ );
+ }
+
+ return;
+ }
+
+ if ( ! empty( $subscription->billing_info->next_billing_time ) ) {
+ $new_values['next_bill_date'] = gmdate( 'Y-m-d', strtotime( $subscription->billing_info->next_billing_time ) );
+ }
+
+ if ( ! empty( $subscription->plan->billing_cycles[0]->pricing_scheme->fixed_price->value ) ) {
+ $new_values['amount'] = $subscription->plan->billing_cycles[0]->pricing_scheme->fixed_price->value;
+ }
+
+ if ( ! $new_values ) {
+ return;
+ }
+
+ $frm_sub = new FrmTransLiteSubscription();
+ $frm_sub->update( $sub->id, $new_values );
+ }
+}
diff --git a/paypal/controllers/FrmPayPalLiteFieldsController.php b/paypal/controllers/FrmPayPalLiteFieldsController.php
new file mode 100644
index 0000000000..181541bd43
--- /dev/null
+++ b/paypal/controllers/FrmPayPalLiteFieldsController.php
@@ -0,0 +1,40 @@
+This is a PayPal order field. It is automatically populated when a payment is processed, and is automatically excluded from the form HTML.
';
+ }
+ }
+}
diff --git a/paypal/controllers/FrmPayPalLiteHooksController.php b/paypal/controllers/FrmPayPalLiteHooksController.php
new file mode 100644
index 0000000000..2eabad5553
--- /dev/null
+++ b/paypal/controllers/FrmPayPalLiteHooksController.php
@@ -0,0 +1,92 @@
+ self::class,
+ 'function' => 'route',
+ 'icon' => 'frm_icon_font frm_paypal_icon',
+ );
+
+ return $sections;
+ }
+
+ /**
+ * Handle global settings routing.
+ *
+ * @return void
+ */
+ public static function route() {
+ self::global_settings_form();
+ }
+
+ /**
+ * Print the PayPal section for Global settings.
+ *
+ * @param array $atts
+ *
+ * @return void
+ */
+ public static function global_settings_form( $atts = array() ) {
+ include FrmPayPalLiteAppHelper::plugin_path() . '/views/settings/form.php';
+ }
+
+ /**
+ * Handle processing changes to global PayPal Settings.
+ *
+ * @return void
+ */
+ public static function process_form() {
+ $settings = FrmPayPalLiteAppHelper::get_settings();
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $settings->update( $_POST );
+ $settings->store();
+ }
+}
diff --git a/paypal/css/frontend.css b/paypal/css/frontend.css
new file mode 100644
index 0000000000..702dc51748
--- /dev/null
+++ b/paypal/css/frontend.css
@@ -0,0 +1,171 @@
+.frm-payment-method-selector {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin-bottom: 16px;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ overflow: hidden;
+ max-width: 500px;
+}
+
+.frm-payment-method-option {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ cursor: pointer;
+ border-bottom: 1px solid #e0e0e0;
+ background: #fff;
+ transition: background-color 0.15s ease;
+ position: relative;
+ margin: 0;
+}
+
+.frm-payment-method-option:last-child {
+ border-bottom: none;
+}
+
+.frm-payment-method-option:hover {
+ background-color: #f9f9fb;
+}
+
+.frm-payment-method-active {
+ background-color: #f0f4ff;
+}
+
+.frm-payment-method-active:hover {
+ background-color: #f0f4ff;
+}
+
+.frm-payment-method-option input[type="radio"] {
+ appearance: none !important;
+ -webkit-appearance: none !important;
+ width: 20px !important;
+ height: 20px !important;
+ min-width: 20px !important;
+ min-height: 20px !important;
+ border: 2px solid #c0c0c0 !important;
+ border-radius: 50% !important;
+ flex-shrink: 0;
+ margin: 0 !important;
+ padding: 0 !important;
+ cursor: pointer;
+ transition: border-color 0.15s ease, background 0.15s ease;
+ background: #fff !important;
+ box-shadow: none !important;
+ outline: none !important;
+}
+
+.frm-payment-method-option input[type="radio"]:checked {
+ border-color: #0070ba !important;
+ background: radial-gradient(circle, #0070ba 40%, #fff 40%, #fff 100%) !important;
+}
+
+.frm-payment-method-option input[type="radio"]:focus {
+ outline: 2px solid rgba(0, 112, 186, 0.3) !important;
+ outline-offset: 2px !important;
+}
+
+.frm-payment-method-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+ min-width: 0;
+}
+
+.frm-payment-method-label-text {
+ font-size: 15px;
+ font-weight: 500;
+ color: #1a1a2e;
+ line-height: 1.3;
+}
+
+.frm-payment-method-desc {
+ font-size: 13px;
+ color: #6b7280;
+ line-height: 1.3;
+}
+
+.frm-payment-method-mark {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.frm-payment-method-mark img,
+.frm-payment-method-mark svg {
+ height: 24px;
+ width: auto;
+}
+
+.frm-payment-method-google-pay-icon {
+ height: 24px;
+}
+
+.frm-payment-method-google-pay-icon svg {
+ height: 24px;
+ width: auto;
+}
+
+.frm-payment-method-apple-pay-icon {
+ height: 24px;
+}
+
+.frm-payment-method-apple-pay-icon svg {
+ height: 24px;
+ width: auto;
+}
+
+.frm-payment-method-paylater-wrap {
+ border-bottom: 1px solid #e0e0e0;
+ background: #fff;
+ transition: background-color 0.15s ease;
+}
+
+.frm-payment-method-paylater-wrap:hover {
+ background-color: #f9f9fb;
+}
+
+.frm-payment-method-paylater-wrap.frm-payment-method-active-wrap {
+ background-color: #f0f4ff;
+}
+
+.frm-payment-method-paylater-wrap.frm-payment-method-active-wrap:hover {
+ background-color: #f0f4ff;
+}
+
+.frm-payment-method-paylater-wrap.frm-payment-method-active-wrap .frm-payment-method-paylater-msg {
+ background-color: #f0f4ff;
+}
+
+.frm-payment-method-paylater-wrap:last-child {
+ border-bottom: none;
+}
+
+.frm-payment-method-paylater-wrap .frm-payment-method-option {
+ border-bottom: none;
+ background: transparent;
+}
+
+.frm-payment-method-paylater-wrap .frm-payment-method-option:hover {
+ background: transparent;
+}
+
+.frm-payment-method-paylater-msg {
+ padding: 0 16px 12px 48px;
+ font-size: 13px;
+ color: #6b7280;
+ background: transparent;
+}
+
+.frm-payment-method-area {
+ margin-top: 16px;
+}
+
+.frm-payment-method-container {
+ display: none;
+ max-width: 500px;
+}
diff --git a/paypal/helpers/FrmPayPalLiteApiHelper.php b/paypal/helpers/FrmPayPalLiteApiHelper.php
new file mode 100644
index 0000000000..78d75c6f7e
--- /dev/null
+++ b/paypal/helpers/FrmPayPalLiteApiHelper.php
@@ -0,0 +1,35 @@
+settings->test_mode ? 'test' : 'live';
+ }
+
+ /**
+ * Get PayPal button style configuration from form action settings
+ *
+ * @param object $form_action The form action object containing post_content.
+ *
+ * @return array PayPal button style configuration
+ */
+ public static function get_paypal_button_style( $form_action ) {
+ $button_layout = $form_action->post_content['button_layout'] ?? 'vertical';
+ $button_color = $form_action->post_content['button_color'] ?? 'default';
+ $button_label = $form_action->post_content['button_label'] ?? 'paypal';
+ $button_border_radius = $form_action->post_content['button_border_radius'] ?? 10;
+
+ return array(
+ 'style' => array(
+ 'layout' => $button_layout,
+ 'color' => $button_color,
+ 'shape' => 'rect',
+ // Default shape, could be made configurable
+ 'label' => $button_label,
+ 'messaging' => true,
+ // Show messaging under button
+ 'borderRadius' => (int) $button_border_radius,
+ ),
+ );
+ }
+
+ /**
+ * Add education about PayPal fees.
+ *
+ * @param string $medium Medium identifier for the tip (for example 'tip').
+ * @param array|false|string $gateway Gateway or list of gateways this applies to.
+ *
+ * @return void
+ */
+ public static function fee_education( $medium = 'tip', $gateway = false ) {
+ $license_type = FrmAddonsController::license_type();
+
+ if ( in_array( $license_type, array( 'elite', 'business' ), true ) ) {
+ return;
+ }
+
+ $classes = 'frm-light-tip show_paypal';
+
+ if ( $gateway && ! array_intersect( (array) $gateway, array( 'paypal' ) ) ) {
+ $classes .= ' frm_hidden';
+ }
+
+ FrmTipsHelper::show_tip(
+ array(
+ 'link' => array(
+ 'content' => 'paypal-fee',
+ 'medium' => $medium,
+ ),
+ 'tip' => 'Pay as you go pricing: 3% fee per-transaction + PayPal fees.',
+ 'call' => __( 'Upgrade to save on fees.', 'formidable' ),
+ 'class' => $classes,
+ ),
+ 'p'
+ );
+ }
+}
diff --git a/paypal/helpers/FrmPayPalLiteConnectHelper.php b/paypal/helpers/FrmPayPalLiteConnectHelper.php
new file mode 100644
index 0000000000..24e767f3c3
--- /dev/null
+++ b/paypal/helpers/FrmPayPalLiteConnectHelper.php
@@ -0,0 +1,1054 @@
+payments_receivable = true;
+ $status->primary_email_confirmed = true;
+ $status->oauth_integrations = true;
+ $status->primary_email = 'test@example.com';
+ */
+
+ if ( ! is_object( $status ) ) {
+ self::render_error( __( 'Unable to retrieve seller status.', 'formidable' ) );
+ return false;
+ }
+
+ $email = $status->primary_email ?? '';
+ $paypal_settings_url = self::get_paypal_account_settings_url( $mode );
+
+ if ( empty( $status->primary_email_confirmed ) ) {
+ self::render_error( __( 'Primary email not confirmed.', 'formidable' ), $email, '', $paypal_settings_url );
+ return false;
+ }
+
+ if ( ! $status->payments_receivable ) {
+ self::render_error( __( 'Payments are not receivable.', 'formidable' ), $email, '', $paypal_settings_url );
+ return false;
+ }
+
+ if ( ! $status->oauth_integrations ) {
+ self::render_error( __( 'OAuth integrations are not enabled.', 'formidable' ), $email );
+ return false;
+ }
+
+ $product = self::check_for_product( $status->products, 'PPCP_CUSTOM' );
+ $only_supports_checkout_button = false;
+
+ if ( ! $product || empty( $product->capabilities ) ) {
+ $product = self::check_for_product( $status->products, 'EXPRESS_CHECKOUT' );
+
+ if ( ! $product ) {
+ self::render_error( __( 'No data was found for expected PayPal product.', 'formidable' ), $email, $merchant_id );
+ return false;
+ }
+
+ if ( 'ACTIVE' !== $product->status ) {
+ self::render_error( __( 'PayPal Checkout is not available.', 'formidable' ), $email, $merchant_id );
+ return false;
+ }
+
+ $only_supports_checkout_button = true;
+ }
+
+ if ( $email ) {
+ update_option( self::get_paypal_seller_status_option_name( $mode ), $status, false );
+ }
+
+ echo '';
+ esc_html_e( 'Your seller status is valid.', 'formidable' );
+ echo '
';
+
+ self::echo_email( $email );
+ self::echo_merchant_id( $merchant_id );
+
+ echo '
';
+ echo '
';
+ echo '
' . esc_html__( 'Enabled scopes:', 'formidable' ) . ' ';
+ echo '
';
+ echo '';
+ /**
+ * @var string[] $scopes
+ */
+ $scopes = $status->oauth_integrations[0]->oauth_third_party[0]->scopes;
+ echo implode( ' ', array_map( 'esc_html', $scopes ) );
+ echo ' ';
+ echo ' ';
+
+ echo '
';
+ echo '
' . esc_html__( 'Enabled capabilities:', 'formidable' ) . ' ';
+ echo '
';
+
+ echo '' . esc_html__( 'PayPal Checkout', 'formidable' ) . ' ';
+
+ $can_process_card_fields = ! $only_supports_checkout_button && in_array( 'CUSTOM_CARD_PROCESSING', $product->capabilities, true );
+
+ if ( $can_process_card_fields ) {
+ echo '' . esc_html__( 'Card Processing', 'formidable' ) . ' ';
+ }
+ echo ' ';
+
+ if ( $can_process_card_fields ) {
+ self::render_acdc_vetting_status( $product );
+ }
+
+ echo '
';
+
+ return true;
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param bool|object $product
+ *
+ * @return void
+ */
+ private static function render_acdc_vetting_status( $product ) {
+ $vetting_status = $product && ! empty( $product->vetting_status ) ? $product->vetting_status : 'NOT_SET';
+
+ echo ' ';
+ echo '' . esc_html__( 'ACDC Application Vetting Status:', 'formidable' ) . ' ';
+ echo ' ';
+ echo esc_html( self::get_acdc_vetting_status_message( $vetting_status ) );
+
+ if ( ! in_array( $vetting_status, array( 'DECLINED', 'DENIED', 'NEED_MORE_DATA' ), true ) ) {
+ return;
+ }
+
+ echo ' ';
+ echo '';
+ esc_html_e( 'Reapply for Advanced Card Processing', 'formidable' );
+ echo ' ';
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $vetting_status
+ *
+ * @return string
+ */
+ private static function get_acdc_vetting_status_message( $vetting_status ) {
+ switch ( $vetting_status ) {
+ case 'NOT_SET':
+ return 'Unavailable';
+ case 'APPROVED':
+ case 'SUBSCRIBED':
+ return 'Approved';
+ case 'PENDING':
+ return 'Pending';
+ case 'IN_REVIEW':
+ return 'In Review';
+ case 'DECLINED':
+ return 'Declined';
+ case 'NEED_MORE_DATA':
+ return 'Needs More Data';
+ case 'DENIED':
+ return 'Denied';
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $mode
+ *
+ * @return void
+ */
+ public static function render_seller_status_placeholder( $mode ) {
+ include FrmPayPalLiteAppHelper::plugin_path() . '/views/settings/seller-status-placeholder.php';
+ }
+
+ /**
+ * Get the PayPal account settings URL for the given mode.
+ *
+ * @since x.x
+ *
+ * @param string $mode 'test' or 'live'.
+ *
+ * @return string
+ */
+ private static function get_paypal_account_settings_url( $mode ) {
+ if ( 'test' === $mode ) {
+ return 'https://www.sandbox.paypal.com/businessprofile/settings';
+ }
+ return 'https://www.paypal.com/businessprofile/settings';
+ }
+
+ /**
+ * @param array $products
+ * @param string $name
+ *
+ * @return bool|object
+ */
+ private static function check_for_product( $products, $name = 'PPCP_CUSTOM' ) {
+ foreach ( $products as $current_product ) {
+ if ( $name === $current_product->name ) {
+ return $current_product;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @param string $email
+ *
+ * @return void
+ */
+ private static function echo_email( $email ) {
+ if ( ! $email ) {
+ return;
+ }
+
+ echo ' ';
+ echo '' . esc_html__( 'Connected account:', 'formidable' ) . ' ';
+ echo ' ';
+ echo esc_html( $email );
+ }
+
+ /**
+ * @param string $merchant_id
+ *
+ * @return void
+ */
+ private static function echo_merchant_id( $merchant_id ) {
+ echo ' ';
+ echo '' . esc_html__( 'Merchant ID:', 'formidable' ) . ' ';
+ echo ' ';
+
+ if ( $merchant_id ) {
+ echo esc_html( $merchant_id );
+ } else {
+ esc_html_e( 'N/A', 'formidable' );
+ }
+ }
+
+ /**
+ * @param string $message
+ * @param string $email
+ * @param string $merchant_id
+ * @param string $link URL to help the user resolve the issue.
+ *
+ * @return void
+ */
+ private static function render_error( $message, $email = '', $merchant_id = '', $link = '' ) {
+ echo '';
+ }
+
+ /**
+ * @param string $mode
+ *
+ * @return void
+ */
+ public static function render_settings_for_mode( $mode ) {
+ $connected = (bool) self::get_merchant_id( $mode );
+ include FrmPayPalLiteAppHelper::plugin_path() . '/views/settings/connect-settings-box.php';
+ }
+
+ /**
+ * @return void
+ */
+ private static function register_settings_scripts() {
+ $script_url = FrmPayPalLiteAppHelper::plugin_url() . '/js/settings.js';
+ $dependencies = array( 'formidable_dom' );
+ wp_register_script( 'formidable_paypal_settings', $script_url, $dependencies, FrmAppHelper::plugin_version(), true );
+ wp_enqueue_script( 'formidable_paypal_settings' );
+ }
+
+ /**
+ * @return false|string
+ */
+ public static function get_oauth_redirect_url() {
+ $mode = FrmAppHelper::get_post_param( 'mode', 'test', 'sanitize_text_field' );
+
+ if ( self::get_merchant_id( $mode ) ) {
+ // Do not allow for initialize if there is already a configured account id.
+ return false;
+ }
+
+ $additional_body = array(
+ 'password' => self::generate_client_password( $mode ),
+ 'user_id' => get_current_user_id(),
+ 'frm_paypal_api_mode' => $mode,
+ );
+
+ // Clear the transient so it doesn't fail.
+ delete_option( 'frm_paypal_lite_last_verify_attempt' );
+ $data = self::post_to_connect_server( 'oauth_request', $additional_body );
+
+ if ( is_string( $data ) ) {
+ return false;
+ }
+
+ if ( ! empty( $data->password ) ) {
+ update_option( self::get_server_side_token_option_name( $mode ), $data->password, false );
+ }
+
+ if ( ! is_object( $data ) || empty( $data->redirect_url ) ) {
+ return false;
+ }
+
+ return $data->redirect_url;
+ }
+
+ /**
+ * @param string $action
+ * @param array $additional_body
+ *
+ * @return object|string
+ */
+ private static function post_to_connect_server( $action, $additional_body = array() ) {
+ $body = array(
+ 'frm_paypal_api_action' => $action,
+ 'frm_paypal_api_mode' => FrmPayPalLiteAppHelper::active_mode(),
+ );
+ $body = array_merge( $body, $additional_body );
+ $url = self::get_url_to_connect_server();
+ $headers = self::build_headers_for_post();
+
+ if ( ! $headers ) {
+ return 'Unable to build headers for post. Is your pro license configured properly?';
+ }
+
+ // (Seconds) default timeout is 5. we want a bit more time to work with.
+ $timeout = 45;
+
+ self::try_to_extend_server_timeout( $timeout );
+
+ $args = compact( 'body', 'headers', 'timeout' );
+ $response = wp_remote_post( $url, $args );
+
+ if ( ! self::validate_response( $response ) ) {
+ return 'Response from server is invalid';
+ }
+
+ $body = self::pull_response_body( $response );
+
+ if ( empty( $body->success ) ) {
+ if ( ! empty( $body->data ) && is_string( $body->data ) ) {
+ return $body->data;
+ }
+
+ return 'Response from server was not successful';
+ }
+
+ return $body->data ?? array();
+ }
+
+ /**
+ * @param array $response
+ *
+ * @return mixed
+ */
+ private static function pull_response_body( $response ) {
+ $http_response = $response['http_response'];
+ $response_object = $http_response->get_response_object();
+ return json_decode( $response_object->body );
+ }
+
+ /**
+ * @param mixed $response
+ *
+ * @return bool
+ */
+ private static function validate_response( $response ) {
+ return ! is_wp_error( $response ) && is_array( $response ) && isset( $response['http_response'] );
+ }
+
+ /**
+ * @return string
+ */
+ private static function get_url_to_connect_server() {
+ // return 'https://api.strategy11.com/';
+ return 'https://dev-site.local/';
+ // return 'https://qa.formidableforms.com/paypal/';
+ }
+
+ /**
+ * @return array
+ */
+ private static function build_headers_for_post() {
+ $password = self::maybe_get_pro_license();
+
+ if ( false === $password ) {
+ $password = 'lite_' . self::get_uuid();
+ }
+
+ $site_url = home_url();
+ $site_url = self::maybe_fix_wpml_url( $site_url );
+ // Remove protocol from url (our url cannot include the colon).
+ $site_url = preg_replace( '#^https?://#', '', $site_url );
+ // Remove port from url (mostly helpful in development).
+ $site_url = preg_replace( '/:[0-9]+/', '', $site_url );
+ $site_url = self::strip_lang_from_url( $site_url );
+
+ // $password is either a Pro license or a uuid (See FrmUsage::uuid).
+ return array(
+ 'Authorization' => 'Basic ' . base64_encode( $site_url . ':' . $password ),
+ );
+ }
+
+ /**
+ * Get a unique ID to use for connecting Lite users.
+ *
+ * @return string
+ */
+ private static function get_uuid() {
+ $usage = new FrmUsage();
+ return $usage->uuid();
+ }
+
+ /**
+ * WPML might add a language to the url. Don't send that to the server.
+ *
+ * @param string $url URL to strip language from.
+ *
+ * @return string
+ */
+ private static function strip_lang_from_url( $url ) {
+ $split_on_language = explode( '/?lang=', $url );
+ return 2 === count( $split_on_language ) ? $split_on_language[0] : $url;
+ }
+
+ /**
+ * WPML alters the output of home_url.
+ * If it is active, use the WPML "absolute home" URL which is not modified.
+ *
+ * @param string $url URL to maybe fix.
+ *
+ * @return string
+ */
+ private static function maybe_fix_wpml_url( $url ) {
+ if ( defined( 'ICL_SITEPRESS_VERSION' ) && ! ICL_PLUGIN_INACTIVE && class_exists( 'SitePress' ) ) {
+ global $wpml_url_converter;
+ $url = $wpml_url_converter->get_abs_home();
+ }
+ return $url;
+ }
+
+ /**
+ * Get a Pro license when Pro is active.
+ * Otherwise we'll use a uuid to support Lite.
+ *
+ * @return false|string
+ */
+ private static function maybe_get_pro_license() {
+ if ( FrmAppHelper::pro_is_installed() ) {
+ $pro_license = FrmAddonsController::get_pro_license();
+
+ if ( $pro_license ) {
+ $password = $pro_license;
+ }
+ }
+
+ return ! empty( $password ) ? $password : false;
+ }
+
+ /**
+ * Try to make sure the server time limit exceeds the request time limit.
+ *
+ * @param int $timeout seconds.
+ *
+ * @return void
+ */
+ private static function try_to_extend_server_timeout( $timeout ) {
+ if ( function_exists( 'set_time_limit' ) ) {
+ set_time_limit( $timeout + 10 );
+ }
+ }
+
+ /**
+ * @param string $mode either 'auto', 'live', or 'test'.
+ *
+ * @return string
+ */
+ private static function get_server_side_token_option_name( $mode = 'auto' ) {
+ return self::get_paypal_connect_option_name( 'server_password', $mode );
+ }
+
+ /**
+ * Generate a new client password for authenticating with Connect Service and save it locally as an option.
+ *
+ * @param string $mode 'live' or 'test'.
+ *
+ * @return string the client password.
+ */
+ private static function generate_client_password( $mode ) {
+ $client_password = wp_generate_password();
+ update_option( self::get_client_side_token_option_name( $mode ), $client_password, false );
+ return $client_password;
+ }
+
+ /**
+ * @param string $mode either 'auto', 'live', or 'test'.
+ *
+ * @return string
+ */
+ private static function get_client_side_token_option_name( $mode = 'auto' ) {
+ return self::get_paypal_connect_option_name( 'client_password', $mode );
+ }
+
+ /**
+ * @param string $mode
+ *
+ * @return string
+ */
+ private static function get_paypal_seller_status_option_name( $mode = 'auto' ) {
+ return self::get_paypal_connect_option_name( 'seller_status', $mode );
+ }
+
+ /**
+ * @return string
+ */
+ private static function get_mode_value() {
+ $settings = FrmPayPalLiteAppHelper::get_settings();
+ return $settings->settings->test_mode ? 'test' : 'live';
+ }
+
+ /**
+ * @param string $mode either 'auto', 'live', or 'test'.
+ *
+ * @return bool|string
+ */
+ public static function get_merchant_id( $mode = 'auto' ) {
+ if ( 'auto' === $mode ) {
+ $mode = self::get_mode_value();
+ }
+ return get_option( self::get_merchant_id_option_name( $mode ) );
+ }
+
+ /**
+ * @param string $mode either 'auto', 'live', or 'test'.
+ *
+ * @return string
+ */
+ private static function get_merchant_id_option_name( $mode = 'auto' ) {
+ return self::get_paypal_connect_option_name( 'merchant_id', $mode );
+ }
+
+ /**
+ * @param string $mode either 'auto', 'live', or 'test'.
+ *
+ * @return string
+ */
+ private static function get_merchant_currency_option_name( $mode = 'auto' ) {
+ return self::get_paypal_connect_option_name( 'merchant_currency', $mode );
+ }
+
+ /**
+ * @param string $key 'merchant_id', 'client_password', 'server_password'.
+ * @param string $mode either 'auto', 'live', or 'test'.
+ *
+ * @return string
+ */
+ private static function get_paypal_connect_option_name( $key, $mode = 'auto' ) {
+ return 'frm_paypal_connect_' . $key . self::get_active_mode_option_name_suffix( $mode );
+ }
+
+ /**
+ * @param string $mode either 'auto', 'live', or 'test'.
+ *
+ * @return string either _test or _live.
+ */
+ private static function get_active_mode_option_name_suffix( $mode = 'auto' ) {
+ if ( 'auto' !== $mode ) {
+ return '_' . $mode;
+ }
+ return '_' . FrmPayPalLiteAppHelper::active_mode();
+ }
+
+ public static function check_for_redirects() {
+ if ( self::user_landed_on_the_oauth_return_url() ) {
+ self::redirect_oauth();
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ private static function user_landed_on_the_oauth_return_url() {
+ return isset( $_GET['frm_paypal_api_return_oauth'] );
+ }
+
+ private static function redirect_oauth() {
+ $connected = self::check_server_for_oauth_merchant_id();
+ wp_safe_redirect( self::get_url_for_paypal_settings( $connected ) );
+ exit;
+ }
+
+ /**
+ * @param bool $connected
+ *
+ * @return string
+ */
+ private static function get_url_for_paypal_settings( $connected ) {
+ return admin_url( 'admin.php?page=formidable-settings&t=paypal_settings&connected=' . intval( $connected ) );
+ }
+
+ /**
+ * @return bool
+ */
+ private static function check_server_for_oauth_merchant_id() {
+ $mode = 'test' === FrmAppHelper::simple_get( 'mode' ) ? 'test' : 'live';
+
+ if ( self::get_merchant_id( $mode ) ) {
+ // Do not allow for initialize if there is already a configured merchant id.
+ return false;
+ }
+
+ $body = array(
+ 'server_password' => get_option( self::get_server_side_token_option_name( $mode ) ),
+ 'client_password' => get_option( self::get_client_side_token_option_name( $mode ) ),
+ 'frm_paypal_api_mode' => $mode,
+ );
+ $data = self::post_to_connect_server( 'oauth_merchant_status', $body );
+
+ if ( is_object( $data ) && ! empty( $data->merchant_id ) ) {
+ update_option( self::get_merchant_id_option_name( $mode ), $data->merchant_id, false );
+
+ FrmTransLiteAppController::install();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $action
+ * @param array $additional_body
+ *
+ * @return false|object
+ */
+ private static function post_with_authenticated_body( $action, $additional_body = array() ) {
+ $body = array_merge( self::get_standard_authenticated_body(), $additional_body );
+ $response = self::post_to_connect_server( $action, $body );
+
+ if ( is_object( $response ) ) {
+ return $response;
+ }
+
+ if ( is_array( $response ) ) {
+ // Reformat empty arrays as empty objects
+ // if the response is an array, it's because it's empty. Everything with data is already an object.
+ return new stdClass();
+ }
+
+ if ( is_string( $response ) ) {
+ self::$latest_error_from_paypal_api = $response;
+ FrmTransLiteLog::log_message( 'PayPal API Error', $response );
+ } else {
+ self::$latest_error_from_paypal_api = '';
+ }
+
+ return false;
+ }
+
+ /**
+ * @return array
+ */
+ private static function get_standard_authenticated_body() {
+ $mode = self::get_mode_value_from_post();
+ return array(
+ 'merchant_id' => get_option( self::get_merchant_id_option_name( $mode ) ),
+ 'server_password' => get_option( self::get_server_side_token_option_name( $mode ) ),
+ 'client_password' => get_option( self::get_client_side_token_option_name( $mode ) ),
+ );
+ }
+
+ /**
+ * Check $_POST for live or test mode value as it can be updated in real time from PayPal Settings and can be configured before the update is saved.
+ *
+ * @return string 'test' or 'live'
+ */
+ private static function get_mode_value_from_post() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ if ( empty( $_POST ) || ! array_key_exists( 'testMode', $_POST ) ) {
+ return FrmPayPalLiteAppHelper::active_mode();
+ }
+
+ $test_mode = FrmAppHelper::get_param( 'testMode', '', 'post', 'absint' );
+ return $test_mode ? 'test' : 'live';
+ }
+
+ /**
+ * @return string|null
+ */
+ public static function get_latest_error_from_paypal_api() {
+ return self::$latest_error_from_paypal_api;
+ }
+
+ /**
+ * @return array
+ */
+ public static function get_unprocessed_event_ids() {
+ $data = self::post_with_authenticated_body( 'get_unprocessed_event_ids' );
+
+ if ( false === $data || empty( $data->event_ids ) ) {
+ return array();
+ }
+
+ /**
+ * @var array $data->event_ids
+ */
+ return $data->event_ids;
+ }
+
+ /**
+ * @param string $event_id
+ *
+ * @return false|object
+ */
+ public static function get_event( $event_id ) {
+ $event = wp_cache_get( $event_id, 'frm_paypal' );
+
+ if ( is_object( $event ) ) {
+ return $event;
+ }
+
+ $event = self::post_with_authenticated_body( 'get_event', compact( 'event_id' ) );
+
+ if ( false === $event || empty( $event->event ) ) {
+ return false;
+ }
+
+ wp_cache_set( $event_id, $event->event, 'frm_paypal' );
+
+ return $event->event;
+ }
+
+ /**
+ * @param string $event_id
+ *
+ * @return false|object
+ */
+ public static function process_event( $event_id ) {
+ return self::post_with_authenticated_body( 'process_event', compact( 'event_id' ) );
+ }
+
+ public static function handle_disconnect() {
+ self::disconnect();
+ self::reset_paypal_api_integration();
+ wp_send_json_success();
+ }
+
+ /**
+ * @return false|object
+ */
+ private static function disconnect() {
+ $additional_body = array(
+ 'frm_paypal_api_mode' => self::get_mode_value_from_post(),
+ );
+ return self::post_with_authenticated_body( 'disconnect', $additional_body );
+ }
+
+ /**
+ * Delete every PayPal API option, calling when disconnecting.
+ *
+ * @return void
+ */
+ public static function reset_paypal_api_integration() {
+ $mode = self::get_mode_value_from_post();
+ delete_option( self::get_merchant_id_option_name( $mode ) );
+ delete_option( self::get_server_side_token_option_name( $mode ) );
+ delete_option( self::get_client_side_token_option_name( $mode ) );
+ delete_option( self::get_merchant_currency_option_name( $mode ) );
+ delete_option( self::get_paypal_seller_status_option_name( $mode ) );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @return bool
+ */
+ public static function at_least_one_mode_is_setup() {
+ return self::get_merchant_id( 'test' ) || self::get_merchant_id( 'live' );
+ }
+
+ /**
+ * Verify a site identifier is a match.
+ */
+ public static function verify() {
+ $option_name = 'frm_paypal_lite_last_verify_attempt';
+ $last_request = get_option( $option_name );
+
+ if ( $last_request && $last_request > strtotime( '-1 day' ) ) {
+ wp_send_json_error( 'Too many requests' );
+ }
+
+ $site_identifier = FrmAppHelper::get_post_param( 'site_identifier' );
+ $usage = new FrmUsage();
+
+ update_option( $option_name, time() );
+
+ if ( $site_identifier === $usage->uuid() ) {
+ wp_send_json_success();
+ }
+
+ wp_send_json_error();
+ }
+
+ /**
+ * Create a PayPal order.
+ *
+ * @param string $amount
+ * @param string $currency
+ * @param string $payment_source Valid values are 'card', 'paypal'.
+ * @param array $payer
+ * @param string $shipping_preference
+ * @param array $pricing_data Optional. Array of products with prices and quantities.
+ *
+ * @return false|object
+ */
+ public static function create_order( $amount, $currency, $payment_source, $payer, $shipping_preference, $pricing_data = array() ) {
+ $brand_name = self::get_brand_name();
+
+ // Log pricing data for debugging if FrmLog is available
+ if ( class_exists( 'FrmLog' ) && $pricing_data ) {
+ $log = new FrmLog();
+ $log->add(
+ array(
+ 'title' => 'PayPal Order: Pricing Data',
+ 'content' => print_r( $pricing_data, true ),
+ )
+ );
+ }
+
+ return self::post_with_authenticated_body( 'create_order', compact( 'amount', 'currency', 'payment_source', 'brand_name', 'payer', 'shipping_preference', 'pricing_data' ) );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @return string
+ */
+ private static function get_brand_name() {
+ $brand_name = get_bloginfo( 'name' );
+
+ /**
+ * Allow people to modify the brand name used in the PayPal order.
+ *
+ * @since x.x
+ *
+ * @param string $brand_name
+ *
+ * @return string
+ */
+ $filtered_brand_name = apply_filters( 'frm_paypal_brand_name', $brand_name );
+
+ if ( is_string( $filtered_brand_name ) ) {
+ return $filtered_brand_name;
+ }
+
+ _doing_it_wrong( 'FrmPayPalLiteConnectHelper::get_brand_name', 'The frm_paypal_brand_name filter must return a string.', 'x.x' );
+
+ return $brand_name;
+ }
+
+ /**
+ * @param string $order_id
+ *
+ * @return false|object
+ */
+ public static function capture_order( $order_id ) {
+ return self::post_with_authenticated_body( 'capture_order', compact( 'order_id' ) );
+ }
+
+ /**
+ * @param string $capture_id
+ *
+ * @return false|object
+ */
+ public static function refund_payment( $capture_id ) {
+ return self::post_with_authenticated_body( 'refund_capture', array( 'capture_id' => $capture_id ) );
+ }
+
+ /**
+ * @param string $subscription_id
+ *
+ * @return false|object
+ */
+ public static function cancel_subscription( $subscription_id ) {
+ return self::post_with_authenticated_body( 'cancel_subscription', compact( 'subscription_id' ) );
+ }
+
+ /**
+ * @param array $data Subscription data.
+ *
+ * @return false|object
+ */
+ public static function create_subscription( $data ) {
+ $data['brand_name'] = self::get_brand_name();
+ return self::post_with_authenticated_body( 'create_subscription', compact( 'data' ) );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param array $data Setup token data including payment_source.
+ *
+ * @return false|object
+ */
+ public static function create_vault_setup_token( $data = array() ) {
+ return self::post_with_authenticated_body( 'create_vault_setup_token', compact( 'data' ) );
+ }
+
+ /**
+ * @return false|object
+ */
+ public static function get_seller_status() {
+ $mode = self::get_mode_value_from_post();
+ $status = get_option( self::get_paypal_seller_status_option_name( $mode ) );
+
+ if ( is_object( $status ) ) {
+ return $status;
+ }
+
+ return self::post_with_authenticated_body( 'get_seller_status' );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $capture_id
+ *
+ * @return false|object
+ */
+ public static function get_capture( $capture_id ) {
+ return self::post_with_authenticated_body( 'get_capture', compact( 'capture_id' ) );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $order_id
+ *
+ * @return false|object
+ */
+ public static function get_order( $order_id ) {
+ return self::post_with_authenticated_body( 'get_order', compact( 'order_id' ) );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $subscription_id The PayPal subscription ID.
+ *
+ * @return false|object
+ */
+ public static function get_subscription( $subscription_id ) {
+ return self::post_with_authenticated_body( 'get_subscription', compact( 'subscription_id' ) );
+ }
+
+ /**
+ * Get the transactions for a PayPal subscription.
+ *
+ * @since x.x
+ *
+ * @param string $subscription_id The PayPal subscription ID.
+ *
+ * @return false|object
+ */
+ public static function get_subscription_transactions( $subscription_id ) {
+ return self::post_with_authenticated_body( 'get_subscription_transactions', compact( 'subscription_id' ) );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $mode
+ *
+ * @return string
+ */
+ public static function get_bn_code( $mode = 'auto' ) {
+ if ( 'auto' === $mode ) {
+ $mode = self::get_mode_value();
+ }
+
+ return 'test' === $mode ? 'FLAVORsb-wkozr49468583_MP' : 'Strategy11LLCPPCP_SP';
+ }
+}
diff --git a/paypal/images/amex.svg b/paypal/images/amex.svg
new file mode 100644
index 0000000000..22dafe4e86
--- /dev/null
+++ b/paypal/images/amex.svg
@@ -0,0 +1 @@
+
diff --git a/paypal/images/apple-pay.svg b/paypal/images/apple-pay.svg
new file mode 100644
index 0000000000..5733474aa1
--- /dev/null
+++ b/paypal/images/apple-pay.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/paypal/images/discover.svg b/paypal/images/discover.svg
new file mode 100644
index 0000000000..e6ee965659
--- /dev/null
+++ b/paypal/images/discover.svg
@@ -0,0 +1 @@
+
diff --git a/paypal/images/gpay.svg b/paypal/images/gpay.svg
new file mode 100644
index 0000000000..238108a6a4
--- /dev/null
+++ b/paypal/images/gpay.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/paypal/images/mastercard.svg b/paypal/images/mastercard.svg
new file mode 100644
index 0000000000..73b6a4d6ba
--- /dev/null
+++ b/paypal/images/mastercard.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/paypal/images/visa.svg b/paypal/images/visa.svg
new file mode 100644
index 0000000000..c2e790a0d9
--- /dev/null
+++ b/paypal/images/visa.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/paypal/js/action.js b/paypal/js/action.js
new file mode 100644
index 0000000000..0c40d6f40f
--- /dev/null
+++ b/paypal/js/action.js
@@ -0,0 +1,22 @@
+( function() {
+ const actions = document.getElementById( 'frm_notification_settings' );
+ if ( ! actions ) {
+ return;
+ }
+
+ jQuery( actions ).on( 'change', 'select[name*="[post_content][layout]"]', onLayoutChange );
+
+ function onLayoutChange() {
+ const settings = this.closest( '.frm_form_action_settings' );
+ if ( ! settings ) {
+ return;
+ }
+
+ const buttonSettings = settings.querySelector( '.frm_paypal_button_settings' );
+ if ( ! buttonSettings ) {
+ return;
+ }
+
+ buttonSettings.classList.toggle( 'frm_hidden', 'card_only' === this.value );
+ }
+}() );
diff --git a/paypal/js/frontend.js b/paypal/js/frontend.js
new file mode 100644
index 0000000000..769e226c4a
--- /dev/null
+++ b/paypal/js/frontend.js
@@ -0,0 +1,1793 @@
+/**
+ * PayPal Payment Integration - Radio-Based Payment Method Selector.
+ *
+ * Architecture:
+ * - A radio group lets the user pick their payment method (Card, PayPal, Venmo, etc.).
+ * - Only the selected method's UI is visible at a time.
+ * - Card + PayPal are pre-rendered on init (hybrid approach).
+ * - Other methods (Venmo, Google Pay, etc.) are lazy-rendered on first selection, then cached.
+ * - When Card is selected: card fields + native submit button are shown.
+ * - When any button method is selected: the submit button is hidden and only that button is shown.
+ */
+( function() {
+ if ( ! window.frmPayPalVars ) {
+ return;
+ }
+
+ // ---- State ----
+
+ let thisForm = null;
+ let running = 0;
+ let cardFieldsInstance = null;
+ let cardFieldsValid = false;
+ let submitEvent = null;
+ let isRecurring = false;
+
+ /**
+ * Registry of available payment methods.
+ * Populated during init based on SDK eligibility checks.
+ *
+ * @type {Map}
+ */
+ const paymentMethods = new Map();
+
+ /** Currently selected payment method key. */
+ let selectedMethod = null;
+
+ /** Cached Google Pay config from paypal.Googlepay().config(). */
+ let googlePayConfig = null;
+
+ /** Cached Apple Pay config from paypal.Applepay().config(). */
+ let applePayConfig = null;
+
+ // ---- Constants ----
+
+ /**
+ * Human-readable labels for funding sources.
+ */
+ const METHOD_LABELS = {
+ card: 'Credit Card',
+ paypal: 'PayPal',
+ venmo: 'Venmo',
+ paylater: 'Pay Later',
+ google_pay: 'Google Pay',
+ apple_pay: 'Apple Pay',
+ bancontact: 'Bancontact',
+ blik: 'BLIK',
+ eps: 'EPS',
+ p24: 'Przelewy24',
+ trustly: 'Trustly',
+ satispay: 'Satispay',
+ sepa: 'SEPA',
+ mybank: 'MyBank',
+ ideal: 'iDEAL',
+ };
+
+ /**
+ * Maps internal method keys to PayPal FUNDING constants for the Marks API.
+ * Card and Google Pay use local images instead of PayPal Marks.
+ */
+ const METHOD_FUNDING_SOURCE = {
+ paypal: 'paypal',
+ venmo: 'venmo',
+ paylater: 'paylater',
+ bancontact: 'bancontact',
+ blik: 'blik',
+ eps: 'eps',
+ p24: 'p24',
+ trustly: 'trustly',
+ satispay: 'satispay',
+ sepa: 'sepa',
+ mybank: 'mybank',
+ ideal: 'ideal',
+ };
+
+ /**
+ * Methods that should be pre-rendered on init (hybrid approach).
+ * Everything else is lazy-rendered on first selection.
+ */
+ const PRE_RENDER_METHODS = new Set( [ 'card', 'paypal' ] );
+
+ /**
+ * Base request object shared by isReadyToPay and PaymentDataRequest.
+ */
+ const googlePayBaseRequest = {
+ apiVersion: 2,
+ apiVersionMinor: 0
+ };
+
+ // ---- Initialization ----
+
+ /**
+ * Main entry point.
+ */
+ async function paypalInit() {
+ const cardElement = document.querySelector( '.frm-card-element' );
+ if ( ! cardElement ) {
+ return;
+ }
+
+ thisForm = cardElement.closest( 'form' );
+ if ( ! thisForm ) {
+ return;
+ }
+
+ const settings = getPayPalSettings()[ 0 ];
+ if ( ! settings ) {
+ return;
+ }
+
+ isRecurring = 'single' !== settings.one;
+ const { layout } = settings;
+ const cardFieldsAreSupported = layout !== 'checkout_only' && 'function' === typeof window.paypal.CardFields;
+ const buttonsAreEnabled = layout !== 'card_only' && 'function' === typeof window.paypal.Buttons;
+
+ // Clear the card element. We rebuild it entirely.
+ cardElement.innerHTML = '';
+
+ // 1. Discover eligible methods and register them.
+ await discoverPaymentMethods( {
+ cardFieldsAreSupported,
+ buttonsAreEnabled,
+ isRecurring
+ } );
+
+ if ( paymentMethods.size === 0 ) {
+ displayPaymentFailure( 'No payment methods available.' );
+ return;
+ }
+
+ // 2. Build the radio selector UI, then render marks after it's in the DOM.
+ const radioGroup = buildRadioGroup();
+ cardElement.append( radioGroup );
+ renderMarks();
+
+ // 3. Build a container area for each method's UI (buttons / card fields).
+ const methodArea = document.createElement( 'div' );
+ methodArea.classList.add( 'frm-payment-method-area' );
+ cardElement.append( methodArea );
+
+ for ( const [ key, method ] of paymentMethods ) {
+ const container = document.createElement( 'div' );
+ container.id = `frm-payment-method-${ key }`;
+ container.classList.add( 'frm-payment-method-container' );
+ methodArea.append( container );
+ method.containerEl = container;
+ }
+
+ // 4. Pre-render Card + PayPal (hybrid approach).
+ for ( const key of PRE_RENDER_METHODS ) {
+ const method = paymentMethods.get( key );
+ if ( method?.eligible ) {
+ try {
+ await method.render();
+ method.rendered = true;
+ } catch ( err ) {
+ console.error( `Failed to pre-render payment method: ${ key }`, err );
+ }
+ }
+ }
+
+ // 5. Auto-select the first eligible method.
+ const firstKey = paymentMethods.keys().next().value;
+ await selectPaymentMethod( firstKey );
+
+ // 6. Attach form submit handler (for card method).
+ thisForm.addEventListener( 'submit', handleFormSubmission );
+
+ // 7. Pay Later messages.
+ if ( paymentMethods.has( 'paylater' ) ) {
+ renderMessages();
+ jQuery( document ).on( 'frmFieldChanged', priceChanged );
+ checkPriceFieldsOnLoad();
+ }
+ }
+
+ // ---- Discovery ----
+
+ /**
+ * Discover which payment methods are eligible and register them.
+ *
+ * @param {Object} opts Config flags.
+ */
+ async function discoverPaymentMethods( opts ) {
+ const { cardFieldsAreSupported, buttonsAreEnabled, isRecurring } = opts;
+
+ // --- Card Fields ---
+ if ( cardFieldsAreSupported ) {
+ const cardFields = createCardFieldsSDKInstance();
+ if ( cardFields?.isEligible() ) {
+ cardFieldsInstance = cardFields;
+ registerMethod( 'card', {
+ eligible: true,
+ render: renderCardFields
+ } );
+ }
+ }
+
+ // --- PayPal button ---
+ if ( buttonsAreEnabled ) {
+ const paypalBtn = createPayPalButton( paypal.FUNDING.PAYPAL, isRecurring );
+ if ( paypalBtn.isEligible() ) {
+ registerMethod( 'paypal', {
+ eligible: true,
+ buttonInstance: paypalBtn,
+ render() {
+ this.buttonInstance.render( `#${ this.containerEl.id }` );
+ }
+ } );
+ }
+ }
+
+ // --- Alternative funding sources ---
+ if ( buttonsAreEnabled ) {
+ const fundingSources = [
+ { key: 'venmo', funding: paypal.FUNDING.VENMO },
+ { key: 'paylater', funding: paypal.FUNDING.PAYLATER },
+ { key: 'bancontact', funding: paypal.FUNDING.BANCONTACT },
+ { key: 'blik', funding: paypal.FUNDING.BLIK },
+ { key: 'eps', funding: paypal.FUNDING.EPS },
+ { key: 'p24', funding: paypal.FUNDING.P24 },
+ { key: 'trustly', funding: paypal.FUNDING.TRUSTLY },
+ { key: 'satispay', funding: paypal.FUNDING.SATISPAY },
+ { key: 'sepa', funding: paypal.FUNDING.SEPA },
+ { key: 'mybank', funding: paypal.FUNDING.MYBANK },
+ { key: 'ideal', funding: paypal.FUNDING.IDEAL },
+ ];
+
+ for ( const { key, funding } of fundingSources ) {
+ const btn = createPayPalButton( funding, isRecurring );
+ if ( btn.isEligible() ) {
+ registerMethod( key, {
+ eligible: true,
+ buttonInstance: btn,
+ render() {
+ this.buttonInstance.render( `#${ this.containerEl.id }` );
+ }
+ } );
+ }
+ }
+ }
+
+ // --- Google Pay ---
+ if ( buttonsAreEnabled && ! isRecurring ) {
+ const googlePayEligible = await checkGooglePayEligibility();
+ if ( googlePayEligible ) {
+ registerMethod( 'google_pay', {
+ eligible: true,
+ render: renderGooglePayButton
+ } );
+ }
+ }
+
+ // --- Apple Pay ---
+ if ( buttonsAreEnabled && ! isRecurring ) {
+ const applePayEligibilityResult = await checkApplePayEligibility();
+ if ( applePayEligibilityResult === '' ) {
+ registerMethod( 'apple_pay', {
+ eligible: true,
+ render: renderApplePayButton
+ } );
+ }
+ }
+ }
+
+ /**
+ * Register a payment method in the registry.
+ *
+ * @param {string} key Unique identifier.
+ * @param {Object} config Method configuration.
+ */
+ function registerMethod( key, config ) {
+ paymentMethods.set( key, {
+ key,
+ label: METHOD_LABELS[ key ] || key,
+ eligible: config.eligible || false,
+ rendered: false,
+ containerEl: null,
+ buttonInstance: config.buttonInstance || null,
+ render: config.render || ( () => {} ),
+ } );
+ }
+
+ // ---- Radio Group UI ----
+
+ /**
+ * Build the radio button group for payment method selection.
+ * Each option is a card-like row with a radio, label, description, and PayPal Mark logo.
+ *
+ * @return {HTMLElement} The radio group container.
+ */
+ function buildRadioGroup() {
+ const group = document.createElement( 'div' );
+ group.classList.add( 'frm-payment-method-selector' );
+ group.setAttribute( 'role', 'radiogroup' );
+ group.setAttribute( 'aria-label', 'Select payment method' );
+
+ for ( const [ key, method ] of paymentMethods ) {
+ const label = document.createElement( 'label' );
+ label.classList.add( 'frm-payment-method-option' );
+ label.setAttribute( 'for', `frm-payment-method-radio-${ key }` );
+
+ const radio = document.createElement( 'input' );
+ radio.type = 'radio';
+ radio.name = 'frm_payment_method';
+ radio.id = `frm-payment-method-radio-${ key }`;
+ radio.value = key;
+
+ radio.addEventListener( 'change', () => selectPaymentMethod( key ) );
+
+ // Text column: label + description.
+ const textWrap = document.createElement( 'div' );
+ textWrap.classList.add( 'frm-payment-method-text' );
+
+ const labelText = document.createElement( 'span' );
+ labelText.classList.add( 'frm-payment-method-label-text' );
+ labelText.textContent = method.label;
+ textWrap.append( labelText );
+
+ // Mark column: will be populated by renderMarks() after the group is in the DOM.
+ const markWrap = document.createElement( 'div' );
+ markWrap.classList.add( 'frm-payment-method-mark' );
+ markWrap.id = `frm-payment-mark-${ key }`;
+
+ const baseUrl = frmPayPalVars.imagesUrl || '';
+
+ if ( key === 'card' ) {
+ const cardBrands = [
+ { file: 'visa.svg', alt: 'Visa' },
+ { file: 'mastercard.svg', alt: 'Mastercard' },
+ { file: 'amex.svg', alt: 'American Express' },
+ { file: 'discover.svg', alt: 'Discover' },
+ ];
+ cardBrands.forEach( function( brand ) {
+ const img = document.createElement( 'img' );
+ img.src = baseUrl + brand.file;
+ img.alt = brand.alt;
+ img.height = 24;
+ markWrap.append( img );
+ } );
+ } else if ( key === 'google_pay' ) {
+ markWrap.classList.add( 'frm-payment-method-google-pay-icon' );
+ const img = document.createElement( 'img' );
+ img.src = `${ baseUrl }gpay.svg`;
+ img.alt = 'Google Pay';
+ img.height = 24;
+ markWrap.append( img );
+ } else if ( key === 'apple_pay' ) {
+ markWrap.classList.add( 'frm-payment-method-apple-pay-icon' );
+ const img = document.createElement( 'img' );
+ img.src = `${ baseUrl }apple-pay.svg`;
+ img.alt = 'Apple Pay';
+ img.height = 24;
+ img.style.width = 'auto';
+ markWrap.append( img );
+ }
+
+ label.append( radio );
+ label.append( textWrap );
+ label.append( markWrap );
+
+ if ( key === 'paylater' ) {
+ // Wrap the label and a message container in a div.
+ const wrapper = document.createElement( 'div' );
+ wrapper.classList.add( 'frm-payment-method-paylater-wrap' );
+ wrapper.append( label );
+
+ const msgContainer = document.createElement( 'div' );
+ msgContainer.id = 'frm-paylater-message';
+ msgContainer.classList.add( 'frm-payment-method-paylater-msg' );
+ wrapper.append( msgContainer );
+
+ group.append( wrapper );
+ } else {
+ group.append( label );
+ }
+ }
+
+ return group;
+ }
+
+ /**
+ * Render PayPal Marks into the radio group containers.
+ * Must be called AFTER the radio group is appended to the DOM,
+ * because the Marks API needs the containers to be in the document.
+ */
+ function renderMarks() {
+ if ( 'function' !== typeof paypal.Marks ) {
+ return;
+ }
+
+ for ( const [ key ] of paymentMethods ) {
+ const fundingSource = METHOD_FUNDING_SOURCE[ key ];
+ if ( ! fundingSource ) {
+ continue;
+ }
+
+ const markContainerId = `frm-payment-mark-${ key }`;
+ const container = document.getElementById( markContainerId );
+ if ( ! container ) {
+ continue;
+ }
+
+ try {
+ const mark = paypal.Marks( { fundingSource } );
+ if ( mark.isEligible() ) {
+ mark.render( `#${ markContainerId }` );
+ }
+ } catch ( err ) {
+ // Mark not available for this source, that's fine.
+ }
+ }
+ }
+
+ // ---- Method Selection ----
+
+ /**
+ * Handle switching to a new payment method.
+ *
+ * 1. Lazy-render if this method hasn't been rendered yet.
+ * 2. Hide all method containers.
+ * 3. Show the selected method's container.
+ * 4. Toggle submit button visibility.
+ *
+ * @param {string} key The payment method key to select.
+ */
+ async function selectPaymentMethod( key ) {
+ const method = paymentMethods.get( key );
+ if ( ! method ) {
+ return;
+ }
+
+ selectedMethod = key;
+
+ // Update radio checked state.
+ const radio = document.getElementById( `frm-payment-method-radio-${ key }` );
+ if ( radio && ! radio.checked ) {
+ radio.checked = true;
+ }
+
+ // Lazy-render if this is the first time selecting a non-pre-rendered method.
+ if ( ! method.rendered ) {
+ method.containerEl.innerHTML = ' ';
+ try {
+ await method.render();
+ method.rendered = true;
+ } catch ( err ) {
+ console.error( `Failed to render payment method: ${ key }`, err );
+ method.containerEl.innerHTML = '';
+ }
+ }
+
+ // Hide all method containers.
+ for ( const [ , m ] of paymentMethods ) {
+ if ( m.containerEl ) {
+ m.containerEl.style.display = 'none';
+ }
+ }
+
+ // Show the selected one.
+ if ( method.containerEl ) {
+ method.containerEl.style.display = 'block';
+ }
+
+ // Toggle submit button + card fields visibility.
+ updateSubmitButtonVisibility( key );
+
+ // Update active class on radio labels.
+ document.querySelectorAll( '.frm-payment-method-option' ).forEach( el => {
+ el.classList.remove( 'frm-payment-method-active' );
+ } );
+ document.querySelectorAll( '.frm-payment-method-paylater-wrap' ).forEach( el => {
+ el.classList.remove( 'frm-payment-method-active-wrap' );
+ } );
+ const activeLabel = radio?.closest( '.frm-payment-method-option' );
+ if ( activeLabel ) {
+ activeLabel.classList.add( 'frm-payment-method-active' );
+ const wrapper = activeLabel.closest( '.frm-payment-method-paylater-wrap' );
+ if ( wrapper ) {
+ wrapper.classList.add( 'frm-payment-method-active-wrap' );
+ }
+ }
+ }
+
+ /**
+ * Show/hide the native submit button based on the selected method.
+ *
+ * - Card: submit button visible (user fills card fields, clicks submit).
+ * - Everything else: submit button hidden (PayPal SDK button handles submission).
+ *
+ * @param {string} key The selected payment method key.
+ */
+ function updateSubmitButtonVisibility( key ) {
+ const submitButtons = thisForm.querySelectorAll(
+ 'input[type="submit"], input[type="button"], button[type="submit"]'
+ );
+ const isCardMethod = key === 'card';
+
+ submitButtons.forEach( btn => {
+ if ( btn.classList.contains( 'frm_prev_page' ) ) {
+ return;
+ }
+
+ if ( isCardMethod ) {
+ btn.style.display = '';
+ if ( cardFieldsValid ) {
+ btn.removeAttribute( 'disabled' );
+ } else {
+ btn.setAttribute( 'disabled', 'disabled' );
+ }
+ } else {
+ btn.style.display = 'none';
+ }
+ } );
+ }
+
+ // ---- Card Fields ----
+
+ /**
+ * Create the PayPal CardFields SDK instance (without rendering).
+ *
+ * @return {Object|null} The card fields instance.
+ */
+ function createCardFieldsSDKInstance() {
+ try {
+ const config = {
+ onError,
+ style: frmPayPalVars.style,
+ inputEvents: {
+ onChange: onCardFieldsChange
+ }
+ };
+
+ if ( isRecurring ) {
+ config.createVaultSetupToken = createVaultSetupToken;
+ config.onApprove = onVaultApprove;
+ } else {
+ config.createOrder = createOrder;
+ config.onApprove = onApprove;
+ }
+
+ return window.paypal.CardFields( config );
+ } catch ( err ) {
+ console.error( 'Failed to create CardFields instance', err );
+ return null;
+ }
+ }
+
+ /**
+ * Handle card field value changes.
+ *
+ * @param {Object} data The onChange event data.
+ */
+ function onCardFieldsChange( data ) {
+ cardFieldsValid = data.isFormValid;
+
+ if ( selectedMethod === 'card' ) {
+ if ( cardFieldsValid ) {
+ enableSubmit();
+ } else {
+ disableSubmit( thisForm );
+ }
+ }
+ }
+
+ /**
+ * Render the card number / expiry / CVV fields into the method container.
+ */
+ function renderCardFields() {
+ const method = paymentMethods.get( 'card' );
+ if ( ! method || ! cardFieldsInstance ) {
+ return;
+ }
+
+ const wrapper = document.createElement( 'div' );
+ wrapper.classList.add( 'frm-card-fields-wrapper', 'frm_grid_container' );
+
+ const cardNumberWrapper = document.createElement( 'div' );
+ cardNumberWrapper.id = 'frm-paypal-card-number';
+ cardNumberWrapper.classList.add( 'frm6', 'frm-payment-card-number' );
+
+ const expiryWrapper = document.createElement( 'div' );
+ expiryWrapper.id = 'frm-paypal-card-expiry';
+ expiryWrapper.classList.add( 'frm3', 'frm-payment-card-expiry' );
+
+ const cvvWrapper = document.createElement( 'div' );
+ cvvWrapper.id = 'frm-paypal-card-cvv';
+ cvvWrapper.classList.add( 'frm3', 'frm-payment-card-cvv' );
+
+ wrapper.append( cardNumberWrapper, expiryWrapper, cvvWrapper );
+ method.containerEl.innerHTML = '';
+ method.containerEl.append( wrapper );
+
+ cardFieldsInstance.NumberField().render( '#frm-paypal-card-number' );
+ cardFieldsInstance.ExpiryField().render( '#frm-paypal-card-expiry' );
+ cardFieldsInstance.CVVField().render( '#frm-paypal-card-cvv' );
+
+ setupCardFieldIframeObservers();
+ }
+
+ /**
+ * Watch for PayPal iframe height changes and add 1px to prevent border clipping.
+ */
+ function setupCardFieldIframeObservers() {
+ const ids = [ 'frm-paypal-card-number', 'frm-paypal-card-expiry', 'frm-paypal-card-cvv' ];
+ const wrappers = ids
+ .map( id => document.getElementById( id )?.querySelector( 'iframe' )?.parentNode )
+ .filter( Boolean );
+
+ if ( ! wrappers.length ) {
+ return;
+ }
+
+ const observerOptions = { attributes: true, attributeFilter: [ 'style' ] };
+
+ const observerCallback = ( mutationsList, observer ) => {
+ observer.disconnect();
+
+ for ( const mutation of mutationsList ) {
+ if ( mutation.type !== 'attributes' || mutation.attributeName !== 'style' ) {
+ continue;
+ }
+
+ const currentHeight = mutation.target.offsetHeight;
+ if ( currentHeight > 0 ) {
+ mutation.target.style.height = `${ currentHeight + 1 }px`;
+ }
+ }
+
+ wrappers.forEach( w => observer.observe( w, observerOptions ) );
+ };
+
+ const observer = new MutationObserver( observerCallback );
+ wrappers.forEach( w => observer.observe( w, observerOptions ) );
+ }
+
+ // ---- PayPal Button Creation ----
+
+ /**
+ * Create a PayPal Buttons instance for a given funding source (without rendering).
+ *
+ * @param {string} fundingSource The PayPal FUNDING constant.
+ * @param {boolean} isRecurring Whether this is a recurring payment.
+ *
+ * @return {Object} The PayPal Buttons instance.
+ */
+ function createPayPalButton( fundingSource, isRecurring ) {
+ const buttonConfig = {
+ fundingSource,
+ onApprove,
+ onError,
+ onCancel,
+ style: { ...frmPayPalVars.buttonStyle },
+ };
+
+ const supportedColors = [ 'silver', 'black', 'white' ];
+ const supportedColorsMap = {
+ venmo: [ 'blue' ],
+ paylater: [ 'gold', 'blue' ]
+ };
+
+ supportedColorsMap[ fundingSource ]?.forEach( color => supportedColors.push( color ) );
+
+ if ( ! supportedColors.includes( buttonConfig.style.color ) ) {
+ delete buttonConfig.style.color;
+ }
+
+ if ( isRecurring ) {
+ buttonConfig.createSubscription = createSubscription;
+ } else {
+ buttonConfig.createOrder = createOrder;
+ }
+
+ return paypal.Buttons( buttonConfig );
+ }
+
+ // ---- Google Pay ----
+
+ /**
+ * Check if Google Pay is eligible (without rendering).
+ *
+ * @return {Promise} Whether Google Pay is supported and ready to accept payments in the current environment.
+ */
+ async function checkGooglePayEligibility() {
+ if ( 'function' !== typeof paypal.Googlepay ) {
+ return false;
+ }
+
+ if ( 'undefined' === typeof google || google.payments === undefined ) {
+ return false;
+ }
+
+ try {
+ googlePayConfig = await paypal.Googlepay().config();
+ const paymentsClient = getGooglePaymentsClient();
+
+ const readyToPayRequest = Object.assign( {}, googlePayBaseRequest, {
+ allowedPaymentMethods: googlePayConfig.allowedPaymentMethods
+ } );
+
+ const response = await paymentsClient.isReadyToPay( readyToPayRequest );
+ return response.result;
+ } catch ( err ) {
+ console.error( 'Google Pay eligibility check failed', err );
+ return false;
+ }
+ }
+
+ /**
+ * Render the Google Pay button into its method container.
+ */
+ async function renderGooglePayButton() {
+ const method = paymentMethods.get( 'google_pay' );
+ if ( ! method || ! googlePayConfig ) {
+ return;
+ }
+
+ const paymentsClient = getGooglePaymentsClient();
+ const buttonOptions = Object.assign(
+ getGooglePayButtonStyle(),
+ {
+ onClick: () => onGooglePayButtonClicked( googlePayConfig ),
+ allowedPaymentMethods: googlePayConfig.allowedPaymentMethods
+ }
+ );
+ const button = paymentsClient.createButton( buttonOptions );
+
+ const container = method.containerEl;
+ container.innerHTML = '';
+ container.append( button );
+ }
+
+ /**
+ * Get a Google PaymentsClient configured for the current environment.
+ *
+ * @return {google.payments.api.PaymentsClient} The payments client instance.
+ */
+ function getGooglePaymentsClient() {
+ return new google.payments.api.PaymentsClient( {
+ environment: 'TEST',
+ paymentDataCallbacks: {
+ onPaymentAuthorized
+ }
+ } );
+ }
+
+ /**
+ * Map frmPayPalVars.buttonStyle to Google Pay ButtonOptions.
+ *
+ * @return {Object} Google Pay button style options.
+ */
+ function getGooglePayButtonStyle() {
+ const style = frmPayPalVars.buttonStyle || {};
+ const options = { buttonSizeMode: 'fill' };
+
+ const colorMap = { black: 'black', white: 'white', silver: 'white' };
+ if ( style.color && colorMap[ style.color ] ) {
+ options.buttonColor = colorMap[ style.color ];
+ }
+
+ const typeMap = { pay: 'pay', checkout: 'checkout', buynow: 'buy', donate: 'donate', subscribe: 'subscribe' };
+ if ( style.label && typeMap[ style.label ] ) {
+ options.buttonType = typeMap[ style.label ];
+ }
+
+ if ( style.borderRadius !== undefined ) {
+ options.buttonRadius = style.borderRadius;
+ }
+
+ return options;
+ }
+
+ /**
+ * Handle Google Pay button click.
+ *
+ * @param {Object} config The config from paypal.Googlepay().config().
+ *
+ * @return {Promise}
+ */
+ async function onGooglePayButtonClicked( config ) {
+ const settings = getPayPalSettings()[ 0 ];
+ const currency = ( settings.currency || 'USD' ).toUpperCase();
+
+ const paymentDataRequest = Object.assign( {}, googlePayBaseRequest );
+ paymentDataRequest.allowedPaymentMethods = config.allowedPaymentMethods;
+ paymentDataRequest.merchantInfo = config.merchantInfo;
+ paymentDataRequest.callbackIntents = [ 'PAYMENT_AUTHORIZATION' ];
+
+ paymentDataRequest.transactionInfo = {
+ currencyCode: currency,
+ totalPriceStatus: 'ESTIMATED',
+ totalPrice: '0.00'
+ };
+
+ try {
+ const amount = await new Promise( ( resolve, reject ) => {
+ getPrice( result => {
+ if ( result?.data?.amount ) {
+ resolve( result.data.amount );
+ } else {
+ reject( new Error( 'No amount' ) );
+ }
+ } );
+ } );
+
+ paymentDataRequest.transactionInfo.totalPrice = String( amount );
+ paymentDataRequest.transactionInfo.totalPriceStatus = 'FINAL';
+ } catch ( e ) {
+ // Fall back to ESTIMATED with 0.00 if we can't get the price.
+ }
+
+ const paymentsClient = getGooglePaymentsClient();
+ paymentsClient.loadPaymentData( paymentDataRequest );
+ }
+
+ /**
+ * Callback invoked by Google Pay when the buyer authorizes the payment.
+ *
+ * @param {Object} paymentData The Google Pay PaymentData response object.
+ *
+ * @return {Promise} Transaction state result for the Google Pay sheet.
+ */
+ async function onPaymentAuthorized( paymentData ) {
+ try {
+ const orderId = await createOrderForGooglePay();
+
+ const confirmOrderResponse = await paypal.Googlepay().confirmOrder( {
+ orderId,
+ paymentMethodData: paymentData.paymentMethodData
+ } );
+
+ if ( confirmOrderResponse.status === 'PAYER_ACTION_REQUIRED' ) {
+ await paypal.Googlepay().initiatePayerAction( { orderId } );
+ }
+
+ if ( confirmOrderResponse.status === 'APPROVED' || confirmOrderResponse.status === 'PAYER_ACTION_REQUIRED' ) {
+ await onApprove( {
+ orderID: orderId,
+ paymentSource: 'google_pay'
+ } );
+
+ return { transactionState: 'SUCCESS' };
+ }
+
+ return {
+ transactionState: 'ERROR',
+ error: {
+ intent: 'PAYMENT_AUTHORIZATION',
+ message: 'Payment could not be authorized'
+ }
+ };
+ } catch ( err ) {
+ return {
+ transactionState: 'ERROR',
+ error: {
+ intent: 'PAYMENT_AUTHORIZATION',
+ message: err.message || 'Payment failed'
+ }
+ };
+ }
+ }
+
+ // ---- Apple Pay ----
+
+ /**
+ * Map frmPayPalVars.buttonStyle to Apple Pay button attributes and CSS custom properties.
+ *
+ * The web component uses CSS custom properties for sizing:
+ * --apple-pay-button-width, --apple-pay-button-height, --apple-pay-button-border-radius,
+ * --apple-pay-button-padding, --apple-pay-button-box-sizing.
+ *
+ * @return {Object} Apple Pay button style options.
+ */
+ function getApplePayButtonStyle() {
+ const style = frmPayPalVars.buttonStyle || {};
+ const options = {
+ buttonStyle: 'black',
+ buttonType: 'buy'
+ };
+
+ const colorMap = {
+ black: 'black',
+ white: 'white',
+ silver: 'white-outline'
+ };
+ if ( style.color && colorMap[ style.color ] ) {
+ options.buttonStyle = colorMap[ style.color ];
+ }
+
+ const typeMap = {
+ pay: 'pay',
+ checkout: 'check-out',
+ buynow: 'buy',
+ donate: 'donate',
+ subscribe: 'subscribe',
+ buy: 'buy'
+ };
+ if ( style.label && typeMap[ style.label ] ) {
+ options.buttonType = typeMap[ style.label ];
+ }
+
+ if ( style.borderRadius !== undefined ) {
+ options.borderRadius = style.borderRadius;
+ }
+
+ return options;
+ }
+
+ /**
+ * Check if Apple Pay is eligible (without rendering).
+ *
+ * @return {Promise} An empty string if Apple Pay is supported and ready to accept payments in the current environment, or a string with the reason for ineligibility.
+ */
+ async function checkApplePayEligibility() {
+ if ( 'function' !== typeof paypal.Applepay ) {
+ return 'PayPal Apple Pay SDK not loaded';
+ }
+
+ if ( ! window.ApplePaySession ) {
+ return 'Not on Apple device';
+ }
+
+ if ( ! ApplePaySession.canMakePayments() ) {
+ return 'Apple Pay not configured on device';
+ }
+
+ // Use paypal.Applepay().config() as the definitive eligibility check (per PayPal multiparty docs).
+ try {
+ applePayConfig = await paypal.Applepay().config();
+
+ if ( ! applePayConfig || ! applePayConfig.isEligible ) {
+ return 'PayPal reports Apple Pay is not eligible for this merchant/domain';
+ }
+ } catch ( err ) {
+ return `Apple Pay config check failed: ${ err.message }`;
+ }
+
+ return '';
+ }
+
+ /**
+ * Render the Apple Pay button into its method container.
+ *
+ * The web component uses CSS custom properties for sizing,
+ * not standard CSS properties or inline styles.
+ */
+ async function renderApplePayButton() {
+ const method = paymentMethods.get( 'apple_pay' );
+ if ( ! method ) {
+ return;
+ }
+
+ const container = method.containerEl;
+ container.innerHTML = '';
+
+ const applePayStyle = getApplePayButtonStyle();
+
+ const btn = document.createElement( 'apple-pay-button' );
+ btn.setAttribute( 'buttonstyle', applePayStyle.buttonStyle );
+ btn.setAttribute( 'type', applePayStyle.buttonType );
+ btn.setAttribute( 'locale', 'en' );
+
+ // Use CSS custom properties (the only way to style the web component).
+ btn.style.setProperty( '--apple-pay-button-width', '100%' );
+ btn.style.setProperty( '--apple-pay-button-height', '40px' );
+ btn.style.setProperty( '--apple-pay-button-padding', '6px 0' );
+ btn.style.setProperty( '--apple-pay-button-box-sizing', 'border-box' );
+
+ if ( applePayStyle.borderRadius !== undefined ) {
+ btn.style.setProperty( '--apple-pay-button-border-radius', `${ applePayStyle.borderRadius }px` );
+ }
+
+ btn.addEventListener( 'click', onApplePayButtonClick );
+ container.append( btn );
+ }
+
+ /**
+ * Handle click on the Apple Pay button.
+ * Creates an ApplePaySession synchronously (required by Apple) and processes the payment via PayPal.
+ */
+ function onApplePayButtonClick() {
+ if ( ! applePayConfig ) {
+ console.error( 'Apple Pay config not available' );
+ return;
+ }
+
+ const paymentRequest = {
+ countryCode: applePayConfig.countryCode,
+ merchantCapabilities: applePayConfig.merchantCapabilities,
+ supportedNetworks: applePayConfig.supportedNetworks,
+ currencyCode: applePayConfig.currencyCode || 'USD',
+ total: {
+ label: document.title || 'Payment',
+ type: 'final',
+ amount: getFormTotal(),
+ },
+ };
+
+ // ApplePaySession MUST be created synchronously inside the click handler.
+ const session = new ApplePaySession( 4, paymentRequest );
+ const applepay = paypal.Applepay();
+
+ session.onvalidatemerchant = event => {
+ applepay.validateMerchant( {
+ validationUrl: event.validationURL,
+ displayName: document.title || 'Payment'
+ } )
+ .then( validateResult => {
+ session.completeMerchantValidation( validateResult.merchantSession );
+ } )
+ .catch( validateError => {
+ console.error( 'Apple Pay merchant validation failed', validateError );
+ session.abort();
+ } );
+ };
+
+ session.onpaymentauthorized = event => {
+ createOrderForApplePay()
+ .then( orderId => {
+ return applepay.confirmOrder( {
+ orderId,
+ token: event.payment.token,
+ billingContact: event.payment.billingContact
+ } )
+ .then( () => {
+ session.completePayment( ApplePaySession.STATUS_SUCCESS );
+ onApprove( {
+ orderID: orderId,
+ paymentSource: 'apple_pay'
+ } );
+ } );
+ } )
+ .catch( err => {
+ console.error( 'Apple Pay payment failed', err );
+ session.completePayment( ApplePaySession.STATUS_FAILURE );
+ } );
+ };
+
+ session.oncancel = () => {
+ onCancel();
+ };
+
+ session.begin();
+ }
+
+ /**
+ * Get the form total amount as a string.
+ *
+ * @return {string} The total amount.
+ */
+ function getFormTotal() {
+ const totalField = thisForm.querySelector( '[data-frmtotal]' );
+ if ( totalField?.value ) {
+ return parseFloat( totalField.value ).toFixed( 2 );
+ }
+ return '0.00';
+ }
+
+ // ---- AJAX / Order Creation ----
+
+ /**
+ * Create a PayPal order via AJAX.
+ *
+ * @param {Object} data
+ * @return {Promise} The order ID.
+ */
+ async function createOrder( data ) {
+ ++running;
+ thisForm.classList.add( 'frm_loading_form' );
+
+ const formData = new FormData( thisForm );
+ formData.append( 'action', 'frm_paypal_create_order' );
+ formData.append( 'nonce', frmPayPalVars.nonce );
+ formData.append( 'payment_source', data.paymentSource );
+
+ formData.delete( 'frm_action' );
+ formData.delete( 'form_key' );
+ formData.delete( 'item_key' );
+
+ const response = await fetch( frmPayPalVars.ajax, {
+ method: 'POST',
+ body: formData
+ } );
+
+ if ( ! response.ok ) {
+ thisForm.classList.remove( 'frm_loading_form' );
+ throw new Error( 'Failed to create PayPal order' );
+ }
+
+ const orderData = await response.json();
+
+ if ( ! orderData.success || ! orderData.data.orderID ) {
+ thisForm.classList.remove( 'frm_loading_form' );
+ throw new Error( orderData.data || 'Failed to create PayPal order' );
+ }
+
+ return orderData.data.orderID;
+ }
+
+ async function createSubscription( data ) {
+ thisForm.classList.add( 'frm_loading_form' );
+
+ const formData = new FormData( thisForm );
+ formData.append( 'action', 'frm_paypal_create_subscription' );
+ formData.append( 'nonce', frmPayPalVars.nonce );
+
+ formData.delete( 'frm_action' );
+ formData.delete( 'form_key' );
+ formData.delete( 'item_key' );
+
+ const response = await fetch( frmPayPalVars.ajax, {
+ method: 'POST',
+ body: formData
+ } );
+
+ if ( ! response.ok ) {
+ thisForm.classList.remove( 'frm_loading_form' );
+ throw new Error( 'Failed to create PayPal subscription' );
+ }
+
+ const orderData = await response.json();
+
+ if ( ! orderData.success || ! orderData.data.subscriptionID ) {
+ thisForm.classList.remove( 'frm_loading_form' );
+
+ if ( 'string' === typeof orderData.data ) {
+ throw new TypeError( orderData.data );
+ }
+
+ throw new Error( 'Failed to create PayPal subscription' );
+ }
+
+ return orderData.data.subscriptionID;
+ }
+
+ /**
+ * Create a PayPal order specifically for Google Pay.
+ *
+ * @return {Promise} The PayPal order ID.
+ */
+ async function createOrderForGooglePay() {
+ const formData = new FormData( thisForm );
+ formData.append( 'action', 'frm_paypal_create_order' );
+ formData.append( 'nonce', frmPayPalVars.nonce );
+ formData.append( 'payment_source', 'google_pay' );
+
+ formData.delete( 'frm_action' );
+ formData.delete( 'form_key' );
+ formData.delete( 'item_key' );
+
+ const response = await fetch( frmPayPalVars.ajax, {
+ method: 'POST',
+ body: formData
+ } );
+
+ if ( ! response.ok ) {
+ throw new Error( 'Failed to create PayPal order for Google Pay' );
+ }
+
+ const orderData = await response.json();
+
+ if ( ! orderData.success || ! orderData.data.orderID ) {
+ throw new Error( orderData.data || 'Failed to create PayPal order for Google Pay' );
+ }
+
+ return orderData.data.orderID;
+ }
+
+ /**
+ * Create a PayPal order specifically for Apple Pay.
+ *
+ * @return {Promise} The PayPal order ID.
+ */
+ async function createOrderForApplePay() {
+ const formData = new FormData( thisForm );
+ formData.append( 'action', 'frm_paypal_create_order' );
+ formData.append( 'nonce', frmPayPalVars.nonce );
+ formData.append( 'payment_source', 'apple_pay' );
+
+ formData.delete( 'frm_action' );
+ formData.delete( 'form_key' );
+ formData.delete( 'item_key' );
+
+ const response = await fetch( frmPayPalVars.ajax, {
+ method: 'POST',
+ body: formData
+ } );
+
+ if ( ! response.ok ) {
+ throw new Error( 'Failed to create PayPal order for Apple Pay' );
+ }
+
+ const orderData = await response.json();
+
+ if ( ! orderData.success || ! orderData.data.orderID ) {
+ throw new Error( orderData.data || 'Failed to create PayPal order for Apple Pay' );
+ }
+
+ return orderData.data.orderID;
+ }
+
+ async function createVaultSetupToken() {
+ const formData = new FormData( thisForm );
+ formData.append( 'action', 'frm_paypal_create_vault_setup_token' );
+ formData.append( 'nonce', frmPayPalVars.nonce );
+ formData.append( 'payment_source', 'card' );
+
+ formData.delete( 'frm_action' );
+ formData.delete( 'form_key' );
+ formData.delete( 'item_key' );
+
+ const response = await fetch( frmPayPalVars.ajax, {
+ method: 'POST',
+ body: formData
+ } );
+
+ if ( ! response.ok ) {
+ throw new Error( 'Failed to create PayPal vault setup token' );
+ }
+
+ const tokenData = await response.json();
+
+ if ( ! tokenData.success || ! tokenData.data.token ) {
+ console.error( 'Vault setup token response:', tokenData );
+ const errorMessage = 'string' === typeof tokenData.data ? tokenData.data : 'Failed to create PayPal vault setup token';
+ throw new Error( errorMessage );
+ }
+
+ return tokenData.data.token;
+ }
+
+ // ---- Payment Callbacks ----
+
+ /**
+ * Handle vault approval for card field subscriptions.
+ * Receives the vaultSetupToken, sends it to the server to create
+ * a payment token and subscription, then submits the form.
+ *
+ * @param {Object} data The approval data containing vaultSetupToken.
+ */
+ async function onVaultApprove( data ) {
+ if ( 'NO' === data.liabilityShift || 'UNKNOWN' === data.liabilityShift ) {
+ onError( new Error( 'This payment was flagged as possible fraud and has been rejected.' ) );
+ return;
+ }
+
+ try {
+ let vaultInput = thisForm.querySelector( 'input[name="vault_setup_token"]' );
+ if ( ! vaultInput ) {
+ vaultInput = document.createElement( 'input' );
+ vaultInput.type = 'hidden';
+ vaultInput.name = 'vault_setup_token';
+ thisForm.append( vaultInput );
+ }
+ vaultInput.value = data.vaultSetupToken;
+
+ const subscriptionID = await createSubscription( data );
+ await onApprove( {
+ subscriptionID,
+ paymentSource: 'card'
+ } );
+ } catch ( err ) {
+ onError( err );
+ }
+ }
+
+ /**
+ * Handle approved payment.
+ *
+ * @param {Object} data The approval data containing orderID.
+ */
+ async function onApprove( data ) {
+ if ( 'NO' === data.liabilityShift || 'UNKNOWN' === data.liabilityShift ) {
+ onError( new Error( 'This payment was flagged as possible fraud and has been rejected.' ) );
+ return;
+ }
+
+ if ( data.orderID ) {
+ const orderInput = document.createElement( 'input' );
+ orderInput.type = 'hidden';
+ orderInput.name = 'paypal_order_id';
+ orderInput.value = data.orderID;
+ thisForm.append( orderInput );
+ }
+
+ if ( data.subscriptionID ) {
+ const subscriptionInput = document.createElement( 'input' );
+ subscriptionInput.type = 'hidden';
+ subscriptionInput.name = 'paypal_subscription_id';
+ subscriptionInput.value = data.subscriptionID;
+ thisForm.append( subscriptionInput );
+ }
+
+ const paymentSourceInput = document.createElement( 'input' );
+ paymentSourceInput.type = 'hidden';
+ paymentSourceInput.name = 'paypal_payment_source';
+
+ // When onApprove is called for card fields, there is no paymentSource specified.
+ paymentSourceInput.value = data.paymentSource || 'card';
+
+ thisForm.append( paymentSourceInput );
+
+ if ( ! submitEvent ) {
+ submitEvent = new Event( 'submit', { cancelable: true, bubbles: true } );
+ submitEvent.target = thisForm;
+ }
+
+ if ( typeof frmFrontForm.submitFormManual === 'function' ) {
+ frmFrontForm.submitFormManual( submitEvent, thisForm );
+ } else {
+ thisForm.submit();
+ }
+ }
+
+ /**
+ * Handle payment errors.
+ *
+ * @param {Error} err The error object.
+ */
+ function onError( err ) {
+ console.error( 'PayPal onError:', err );
+ running--;
+ if ( running === 0 && thisForm ) {
+ if ( selectedMethod === 'card' && cardFieldsValid ) {
+ enableSubmit();
+ } else {
+ frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'disable', 0 );
+ }
+ }
+ const message = 'string' === typeof err
+ ? err
+ : ( err?.message ? err.message : 'Payment failed. Please try again.' );
+ displayPaymentFailure( message );
+ }
+
+ function onCancel() {
+ thisForm.classList.add( 'frm_loading_form' );
+ frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'disable', 0 );
+ }
+
+ // ---- Submit Button Helpers ----
+
+ /**
+ * Enable the submit button for the form.
+ */
+ function enableSubmit() {
+ if ( running > 0 ) {
+ return;
+ }
+
+ thisForm.classList.add( 'frm_loading_form' );
+ frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'enable', 0 );
+
+ const event = new CustomEvent( 'frmPayPalLiteEnableSubmit', {
+ detail: { form: thisForm }
+ } );
+ document.dispatchEvent( event );
+ }
+
+ /**
+ * Disable submit button for a target form.
+ *
+ * @param {Element} form
+ * @return {void}
+ */
+ function disableSubmit( form ) {
+ jQuery( form ).find( 'input[type="submit"],input[type="button"],button[type="submit"]' ).not( '.frm_prev_page' ).attr( 'disabled', 'disabled' );
+
+ const event = new CustomEvent( 'frmPayPalLiteDisableSubmit', {
+ detail: { form }
+ } );
+ document.dispatchEvent( event );
+ }
+
+ // ---- Error Display ----
+
+ /**
+ * Display an error message in the payment form.
+ *
+ * @param {string} errorMessage
+ * @return {void}
+ */
+ function displayPaymentFailure( errorMessage ) {
+ if ( ! thisForm ) {
+ return;
+ }
+
+ const statusContainer = thisForm.querySelector( '.frm-card-errors' );
+ if ( statusContainer ) {
+ statusContainer.textContent = errorMessage;
+ statusContainer.style.display = 'block';
+ }
+ }
+
+ /**
+ * Clear error messages.
+ */
+ function clearErrors() {
+ if ( ! thisForm ) {
+ return;
+ }
+
+ const statusContainer = thisForm.querySelector( '.frm-card-errors' );
+ if ( statusContainer ) {
+ statusContainer.textContent = '';
+ statusContainer.style.display = 'none';
+ }
+ }
+
+ // ---- Form Submission ----
+
+ /**
+ * Validate the form before submission.
+ *
+ * @param {Element} form
+ * @return {boolean} True if valid.
+ */
+ function validateFormSubmit( form ) {
+ if ( typeof frmFrontForm.validateFormSubmit !== 'function' ) {
+ return true;
+ }
+
+ const errors = frmFrontForm.validateFormSubmit( form );
+ const keys = Object.keys( errors );
+
+ if ( 1 === keys.length && errors[ keys[ 0 ] ] === '' ) {
+ keys.pop();
+ }
+
+ return 0 === keys.length;
+ }
+
+ /**
+ * Check if the current form action type should trigger payment processing.
+ *
+ * @return {boolean} True if current action type should be processed.
+ */
+ function currentActionTypeShouldBeProcessed() {
+ const action = jQuery( thisForm ).find( 'input[name="frm_action"]' ).val();
+
+ if ( 'object' !== typeof window.frmProForm || 'function' !== typeof window.frmProForm.currentActionTypeShouldBeProcessed ) {
+ return 'create' === action;
+ }
+
+ return window.frmProForm.currentActionTypeShouldBeProcessed(
+ action,
+ { thisForm }
+ );
+ }
+
+ /**
+ * Handle form submission. Routes to card submission when card is selected.
+ * For button-based methods (PayPal, Venmo, etc.) the SDK handles submission via onApprove.
+ *
+ * @param {Event} event
+ */
+ async function handleFormSubmission( event ) {
+ if ( ! currentActionTypeShouldBeProcessed() ) {
+ return;
+ }
+
+ // Only intercept submission when card is the selected method.
+ if ( selectedMethod !== 'card' ) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ submitEvent = event;
+
+ clearErrors();
+
+ thisForm.classList.add( 'frm_js_validate' );
+ if ( ! validateFormSubmit( thisForm ) ) {
+ return;
+ }
+
+ disableSubmit( thisForm );
+
+ const meta = addName( jQuery( thisForm ) );
+
+ const submitArgs = {};
+
+ if ( meta.name ) {
+ submitArgs.cardholderName = meta.name;
+ }
+
+ /*
+ TODO Add the billing address here as well.
+ Stripe calls a window.frmProForm.addAddressMeta function.
+ That's included in frmstrp.js though, so we need to add a script in Pro for PayPal as well.
+
+ billingAddress: {
+ addressLine1: '555 Billing Ave',
+ adminArea1: 'NY',
+ adminArea2: 'New York',
+ postalCode: '10001',
+ countryCode: 'US'
+ }
+ */
+
+ try {
+ await cardFieldsInstance.submit( submitArgs );
+ } catch ( err ) {
+ console.error( 'Card fields submit error:', err );
+ running--;
+ if ( running === 0 && thisForm ) {
+ enableSubmit();
+ }
+ const message = 'string' === typeof err
+ ? err
+ : ( err?.message ? err.message : 'Payment failed. Please try again.' );
+ displayPaymentFailure( message );
+ }
+ }
+
+ // ---- Price / Pay Later ----
+
+ /**
+ * Get PayPal settings from frmPayPalVars.settings.
+ *
+ * @return {Array} Array of PayPal settings.
+ */
+ function getPayPalSettings() {
+ const paypalSettings = [];
+ frmPayPalVars.settings.forEach( function( setting ) {
+ if ( setting.gateways.includes( 'paypal' ) ) {
+ paypalSettings.push( setting );
+ }
+ } );
+ return paypalSettings;
+ }
+
+ /**
+ * Get the field IDs that affect the price.
+ *
+ * @return {Array} Array of field IDs.
+ */
+ function getPriceFields() {
+ const priceFields = [];
+ getPayPalSettings().forEach( function( setting ) {
+ if ( -1 !== setting.fields ) {
+ setting.fields.forEach( function( field ) {
+ if ( isNaN( field ) ) {
+ priceFields.push( `field_${ field }` );
+ } else {
+ priceFields.push( field );
+ }
+ } );
+ }
+ } );
+ return priceFields;
+ }
+
+ /**
+ * Handle price field changes.
+ *
+ * @param {Event} _ The event object.
+ * @param {HTMLElement} field The changed field element.
+ * @param {string} fieldId The changed field ID.
+ */
+ function priceChanged( _, field, fieldId ) {
+ const price = getPriceFields();
+ let run = price.includes( fieldId ) || price.includes( field.id );
+
+ if ( ! run ) {
+ for ( let i = 0; i < price.length; i++ ) {
+ if ( field.id.indexOf( price[ i ] ) === 0 ) {
+ run = true;
+ break;
+ }
+ }
+ }
+
+ if ( ! run ) {
+ return;
+ }
+
+ const form = field.closest ? field.closest( 'form' ) : jQuery( field ).closest( 'form' )[ 0 ];
+ if ( ! form ) {
+ return;
+ }
+
+ getPrice(
+ function( result ) {
+ updatePayLaterMessage( result.data.amount );
+ }
+ );
+ }
+
+ function getPrice( callback ) {
+ const formData = new FormData( thisForm );
+ formData.append( 'action', 'frm_paypal_get_amount' );
+ formData.append( 'nonce', frmPayPalVars.nonce );
+
+ formData.delete( 'frm_action' );
+ formData.delete( 'form_key' );
+ formData.delete( 'item_key' );
+
+ fetch( frmPayPalVars.ajax, {
+ method: 'POST',
+ body: formData
+ } )
+ .then( response => response.json() )
+ .then( function( result ) {
+ if ( result.success && result.data?.amount ) {
+ callback( result );
+ }
+ } )
+ .catch( function( err ) {
+ console.error( 'Failed to get PayPal amount', err );
+ } );
+ }
+
+ /**
+ * Re-render the Pay Later message with the current amount.
+ *
+ * @param {number|string} amount
+ *
+ * @return {void}
+ */
+ function updatePayLaterMessage( amount ) {
+ const banner = document.getElementById( 'frm-paylater-message' );
+ if ( banner ) {
+ banner.setAttribute( 'data-pp-amount', amount );
+ }
+ }
+
+ function renderMessages() {
+ if ( 'function' !== typeof paypal.Messages ) {
+ return;
+ }
+
+ const container = document.getElementById( 'frm-paylater-message' );
+ if ( ! container ) {
+ return;
+ }
+
+ getPrice( function( result ) {
+ container.setAttribute( 'data-pp-amount', result.data.amount );
+ } );
+
+ paypal.Messages( {
+ style: {
+ layout: 'text',
+ logo: { type: 'primary' },
+ }
+ } ).render( '#frm-paylater-message' );
+ }
+
+ /**
+ * Check for price fields on load and trigger an initial price update.
+ */
+ function checkPriceFieldsOnLoad() {
+ getPriceFields().forEach( function( fieldId ) {
+ const fieldContainer = document.getElementById( `frm_field_${ fieldId }_container` );
+ if ( ! fieldContainer ) {
+ return;
+ }
+
+ const input = fieldContainer.querySelector( 'input[name^=item_meta]' );
+ if ( input && '' !== input.value ) {
+ priceChanged( null, input, fieldId );
+ }
+ } );
+ }
+
+ // ---- Name Fields ----
+
+ function addName( $form ) {
+ let i;
+ let firstField;
+ let lastField;
+ let firstFieldContainer;
+ let lastFieldContainer;
+ let firstNameID = '';
+ let lastNameID = '';
+ let subFieldEl;
+
+ const cardObject = {};
+ const { settings } = frmPayPalVars;
+
+ /**
+ * Gets first, middle or last name from the given field.
+ *
+ * @param {number|HTMLElement} field Field ID or Field element.
+ * @param {string} subFieldName Subfield name.
+ * @return {string} Name field value.
+ */
+ const getNameFieldValue = function( field, subFieldName ) {
+ if ( 'object' !== typeof field ) {
+ field = document.getElementById( `frm_field_${ field }_container` );
+ }
+
+ if ( ! field || 'object' !== typeof field || 'function' !== typeof field.querySelector ) {
+ return '';
+ }
+
+ subFieldEl = field.querySelector( `.frm_combo_inputs_container .frm_form_subfield-${ subFieldName } input` );
+ if ( ! subFieldEl ) {
+ return '';
+ }
+
+ return subFieldEl.value;
+ };
+
+ for ( i = 0; i < settings.length; i++ ) {
+ firstNameID = settings[ i ].first_name;
+ lastNameID = settings[ i ].last_name;
+ }
+
+ /**
+ * Returns a name field container or element.
+ *
+ * @param {number} fieldID
+ * @param {string} type Either 'container' or 'field'
+ * @param {object|null} $form
+ * @return {HTMLElement|null} Name field container or element.
+ */
+ function getNameFieldItem( fieldID, type, $form = null ) {
+ const queryForNameFieldIsFound = 'object' === typeof window.frmProForm && 'function' === typeof window.frmProForm.queryForNameField;
+
+ if ( type === 'container' ) {
+ return queryForNameFieldIsFound
+ ? window.frmProForm.queryForNameField( fieldID, 'container' )
+ : document.querySelector( `#frm_field_${ fieldID }_container, .frm_field_${ fieldID }_container` );
+ }
+
+ return queryForNameFieldIsFound
+ ? window.frmProForm.queryForNameField( fieldID, 'field', $form[ 0 ] )
+ : $form[ 0 ].querySelector( `#frm_field_${ fieldID }_container input, input[name="item_meta[${ fieldID }]"], .frm_field_${ fieldID }_container input` );
+ }
+
+ if ( firstNameID !== '' ) {
+ firstFieldContainer = getNameFieldItem( firstNameID, 'container' );
+ if ( firstFieldContainer?.querySelector( '.frm_combo_inputs_container' ) ) {
+ cardObject.name = getNameFieldValue( firstFieldContainer, 'first' );
+ } else {
+ firstField = getNameFieldItem( firstNameID, 'field', $form );
+ if ( firstField?.value ) {
+ cardObject.name = firstField.value;
+ }
+ }
+ }
+
+ if ( lastNameID !== '' ) {
+ lastFieldContainer = getNameFieldItem( lastNameID, 'container' );
+ if ( lastFieldContainer?.querySelector( '.frm_combo_inputs_container' ) ) {
+ cardObject.name = `${ cardObject.name } ${ getNameFieldValue( lastFieldContainer, 'last' ) }`;
+ } else {
+ lastField = getNameFieldItem( lastNameID, 'field', $form );
+ if ( lastField?.value ) {
+ cardObject.name = `${ cardObject.name } ${ lastField.value }`;
+ }
+ }
+ }
+
+ return cardObject;
+ }
+
+ // ---- Bootstrap ----
+
+ document.addEventListener( 'DOMContentLoaded', async function() {
+ if ( window.paypal ) {
+ paypalInit();
+ return;
+ }
+
+ const interval = setInterval(
+ function() {
+ if ( window.paypal ) {
+ paypalInit();
+ clearInterval( interval );
+ }
+ },
+ 50
+ );
+ } );
+
+ jQuery( document ).on( 'frmPageChanged', function() {
+ paypalInit();
+ } );
+}() );
diff --git a/paypal/js/settings.js b/paypal/js/settings.js
new file mode 100644
index 0000000000..5f09ad3daf
--- /dev/null
+++ b/paypal/js/settings.js
@@ -0,0 +1,72 @@
+( function() {
+ const buttons = document.querySelectorAll( '.frm-connect-paypal-with-oauth' );
+ buttons.forEach( function( button ) {
+ button.addEventListener( 'click', function( e ) {
+ e.preventDefault();
+
+ const { mode } = button.dataset;
+ const formData = new FormData();
+ formData.append( 'mode', mode );
+ frmDom.ajax.doJsonPost( 'paypal_oauth', formData ).then(
+ function( response ) {
+ if ( response.redirect_url !== undefined ) {
+ window.location = response.redirect_url;
+ }
+ }
+ );
+ } );
+ } );
+
+ document.addEventListener(
+ 'click',
+ function( event ) {
+ if ( ! event.target.id.startsWith( 'frm_disconnect_paypal_' ) ) {
+ return;
+ }
+
+ event.preventDefault();
+ const formData = new FormData();
+ formData.append( 'testMode', 'test' === event.target.id.replace( 'frm_disconnect_paypal_', '' ) ? 1 : 0 );
+ frmDom.ajax.doJsonPost( 'paypal_disconnect', formData ).then(
+ function( response ) {
+ if ( response.success ) {
+ window.location.reload();
+ }
+ }
+ );
+ }
+ );
+
+ document.querySelectorAll( '.frm_paypal_seller_status_placeholder' ).forEach(
+ function( placeholder ) {
+ const { mode } = placeholder.dataset;
+ const interval = setInterval(
+ function() {
+ if ( placeholder.offsetParent === null ) {
+ return;
+ }
+
+ clearInterval( interval );
+
+ const formData = new FormData();
+ formData.append( 'testMode', 'test' === mode ? 1 : 0 );
+ frmDom.ajax.doJsonPost( 'paypal_render_seller_status', formData )
+ .then(
+ function( sellerStatus ) {
+ placeholder.innerHTML = sellerStatus;
+ }
+ ).catch(
+ function( error ) {
+ if ( 'string' === typeof error ) {
+ placeholder.innerHTML = error;
+ }
+
+ clearInterval( interval );
+ }
+ );
+ },
+ 100
+ );
+ }
+ );
+}() );
diff --git a/paypal/models/FrmPayPalLiteSettings.php b/paypal/models/FrmPayPalLiteSettings.php
new file mode 100644
index 0000000000..3f90e5c70b
--- /dev/null
+++ b/paypal/models/FrmPayPalLiteSettings.php
@@ -0,0 +1,105 @@
+set_default_options();
+ }
+
+ /**
+ * @return string
+ */
+ public function param() {
+ return 'paypal';
+ }
+
+ /**
+ * @return array
+ */
+ public function default_options() {
+ return array(
+ 'test_mode' => 1,
+ );
+ }
+
+ /**
+ * @param mixed $settings
+ *
+ * @return void
+ */
+ public function set_default_options( $settings = false ) {
+ $default_settings = $this->default_options();
+
+ if ( ! $settings ) {
+ $settings = $this->get_options();
+ } elseif ( $settings === true ) {
+ $settings = new stdClass();
+ }
+
+ if ( ! isset( $this->settings ) ) {
+ $this->settings = new stdClass();
+ }
+
+ foreach ( $default_settings as $setting => $default ) {
+ if ( is_object( $settings ) && isset( $settings->{$setting} ) ) {
+ $this->settings->{$setting} = $settings->{$setting};
+ }
+
+ if ( ! isset( $this->settings->{$setting} ) ) {
+ $this->settings->{$setting} = $default;
+ }
+ }
+ }
+
+ /**
+ * @return object
+ */
+ public function get_options() {
+ $settings = get_option( 'frm_' . $this->param() . '_options' );
+
+ if ( is_object( $settings ) ) {
+ $this->set_default_options( $settings );
+ } elseif ( $settings ) {
+ // Workaround for W3 total cache conflict.
+ $this->settings = unserialize( serialize( $settings ) );
+ } else {
+ $this->set_default_options( true );
+ $this->store();
+ }
+
+ return $this->settings;
+ }
+
+ /**
+ * @param array $params
+ *
+ * @return void
+ */
+ public function update( $params ) {
+ $settings = $this->default_options();
+
+ foreach ( $settings as $setting => $default ) {
+ if ( isset( $params[ 'frm_' . $this->param() . '_' . $setting ] ) ) {
+ $this->settings->{$setting} = sanitize_text_field( $params[ 'frm_' . $this->param() . '_' . $setting ] );
+ }
+ }
+
+ $this->settings->test_mode = isset( $params[ 'frm_' . $this->param() . '_test_mode' ] ) ? absint( $params[ 'frm_' . $this->param() . '_test_mode' ] ) : 0;
+ }
+
+ /**
+ * @return void
+ */
+ public function store() {
+ // Save the posted value in the database.
+ update_option( 'frm_' . $this->param() . '_options', $this->settings );
+ }
+}
diff --git a/paypal/views/settings/action-settings-options.php b/paypal/views/settings/action-settings-options.php
new file mode 100644
index 0000000000..368d5cc99b
--- /dev/null
+++ b/paypal/views/settings/action-settings-options.php
@@ -0,0 +1,73 @@
+post_content['pay_later'] ?? 'auto';
+?>
+
+
+
+
+
+
+
+
+ post_content['layout'] ) ? $form_action->post_content['layout'] : 'card_and_checkout'; ?>
+
+ >
+ >
+ >
+
+
+
+
+
+
+
+ >
+ >
+ >
+
+
+
+
+
+
+ post_content['entry_data_sync'] ?? 'overwrite'; ?>
+
+ >
+ >
+
+
+
+ post_content['paypal_order_email'] ) ) : ?>
+
+
+ post_content['paypal_order_name'] ) ) : ?>
+
+
+ post_content['paypal_order_address'] ) ) : ?>
+
+
+
+
+
+
+
+ post_content['shipping_preference'] ?? 'use_paypal_account_data'; ?>
+
+
+ >
+
+ >
+ >
+
+
+
+
+
diff --git a/paypal/views/settings/button-settings.php b/paypal/views/settings/button-settings.php
new file mode 100644
index 0000000000..c15220e805
--- /dev/null
+++ b/paypal/views/settings/button-settings.php
@@ -0,0 +1,74 @@
+post_content['button_color'] ?? 'default';
+$button_label = $form_action->post_content['button_label'] ?? 'paypal';
+$button_border_radius = $form_action->post_content['button_border_radius'] ?? 10;
+?>
+post_content['layout'] ) ? $form_action->post_content['layout'] : 'card_and_checkout'; ?>
+
+
+
+
+
+
+
+
+
+ >
+ >
+ >
+ >
+ >
+ >
+
+
+
+
+
+
+
+
+ >
+ >
+ >
+ >
+
+
+
+
+
+
+
+ (int) $button_border_radius,
+ 'field_attrs' => array(
+ 'id' => 'button_border_radius',
+ 'name' => $action_control->get_field_name( 'button_border_radius' ),
+ ),
+ 'input_number_attrs' => array(
+ 'class' => 'frm-w-full',
+ ),
+ 'units' => array( 'px' ),
+ )
+ );
+ ?>
+
+
+
diff --git a/paypal/views/settings/connect-settings-box.php b/paypal/views/settings/connect-settings-box.php
new file mode 100644
index 0000000000..67acd22c4b
--- /dev/null
+++ b/paypal/views/settings/connect-settings-box.php
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+ 'width: 10px; position: relative; top: 2px; margin-right: 5px;' ) );
+ esc_html_e( 'Connected', 'formidable' );
+ } else {
+ esc_html_e( 'Not configured', 'formidable' );
+ }
+ ?>
+
+
+
+
+
+
+
+
+
diff --git a/paypal/views/settings/connect-settings-container.php b/paypal/views/settings/connect-settings-container.php
new file mode 100644
index 0000000000..a9717cef00
--- /dev/null
+++ b/paypal/views/settings/connect-settings-container.php
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/paypal/views/settings/form.php b/paypal/views/settings/form.php
new file mode 100644
index 0000000000..88f0bf2246
--- /dev/null
+++ b/paypal/views/settings/form.php
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/paypal/views/settings/seller-status-placeholder.php b/paypal/views/settings/seller-status-placeholder.php
new file mode 100644
index 0000000000..df6b320081
--- /dev/null
+++ b/paypal/views/settings/seller-status-placeholder.php
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
index 47e8356c01..74706268f7 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -14,6 +14,7 @@
+
@@ -33,6 +34,7 @@
+
@@ -51,7 +53,8 @@
-
+
+
@@ -63,6 +66,7 @@
+
@@ -86,6 +90,7 @@
+
@@ -94,6 +99,7 @@
+
@@ -101,6 +107,7 @@
+
@@ -109,6 +116,7 @@
+
@@ -116,6 +124,7 @@
+
@@ -133,6 +142,7 @@
+
@@ -140,6 +150,7 @@
+
@@ -147,6 +158,7 @@
+
@@ -154,6 +166,7 @@
+
@@ -161,6 +174,7 @@
+
@@ -175,6 +189,7 @@
+
@@ -187,6 +202,7 @@
+
@@ -219,6 +235,7 @@
+
@@ -226,6 +243,7 @@
+
@@ -233,6 +251,7 @@
+
@@ -240,6 +259,7 @@
+
@@ -247,6 +267,7 @@
+
@@ -254,6 +275,7 @@
+
@@ -261,6 +283,7 @@
+
@@ -269,6 +292,7 @@
+
@@ -276,6 +300,7 @@
+
@@ -283,6 +308,7 @@
+
@@ -290,6 +316,7 @@
+
@@ -297,6 +324,7 @@
+
@@ -304,6 +332,7 @@
+
@@ -311,6 +340,7 @@
+
@@ -319,6 +349,7 @@
+
@@ -326,6 +357,7 @@
+
@@ -342,6 +374,7 @@
+
@@ -349,6 +382,7 @@
+
@@ -356,6 +390,7 @@
+
@@ -369,6 +404,7 @@
+
@@ -376,6 +412,7 @@
+
@@ -383,6 +420,7 @@
+
@@ -448,6 +486,7 @@
+
@@ -455,6 +494,7 @@
+
@@ -499,6 +539,8 @@
+
+
@@ -506,6 +548,8 @@
+
+
diff --git a/rector.php b/rector.php
index d8eb684b7c..2e409197dc 100644
--- a/rector.php
+++ b/rector.php
@@ -90,6 +90,7 @@
__DIR__ . '/classes',
__DIR__ . '/stripe',
__DIR__ . '/square',
+ __DIR__ . '/paypal',
__DIR__ . '/css',
__DIR__ . '/tests',
)
diff --git a/resources/scss/admin/components/form/_form-actions.scss b/resources/scss/admin/components/form/_form-actions.scss
index e7590492b5..7728e770e2 100644
--- a/resources/scss/admin/components/form/_form-actions.scss
+++ b/resources/scss/admin/components/form/_form-actions.scss
@@ -314,3 +314,10 @@ span.frm-inner-circle.frm-inverse {
.frm_actions_list .frm_inactive_action i {
opacity: 0.4;
}
+
+.frm_square_action .frm_square_icon {
+ position: relative;
+ top: 2px;
+ left: 1px;
+ zoom: 1.2;
+}
diff --git a/square/controllers/FrmSquareLiteActionsController.php b/square/controllers/FrmSquareLiteActionsController.php
index c4d1a9cf65..25af41567c 100644
--- a/square/controllers/FrmSquareLiteActionsController.php
+++ b/square/controllers/FrmSquareLiteActionsController.php
@@ -116,7 +116,7 @@ public static function trigger_gateway( $action, $entry, $form ) {
}
if ( ! self::square_is_configured() ) {
- $response['error'] = __( 'There was a problem communicating with Square. Please try again.', 'formidable' );
+ $response['error'] = __( 'Square still needs to be configured.', 'formidable' );
return $response;
}
@@ -694,17 +694,7 @@ public static function remove_cc_validation( $errors, $field, $values ) {
return $errors;
}
- $field_id = $field->temp_id ?? $field->id;
-
- if ( isset( $errors[ 'field' . $field_id . '-cc' ] ) ) {
- unset( $errors[ 'field' . $field_id . '-cc' ] );
- }
-
- if ( isset( $errors[ 'field' . $field_id ] ) ) {
- unset( $errors[ 'field' . $field_id ] );
- }
-
- return $errors;
+ return FrmTransLiteActionsController::remove_cc_errors( $errors, $field );
}
/**
diff --git a/square/helpers/FrmSquareLiteConnectHelper.php b/square/helpers/FrmSquareLiteConnectHelper.php
index a9c8e3c222..6cb165cdc7 100644
--- a/square/helpers/FrmSquareLiteConnectHelper.php
+++ b/square/helpers/FrmSquareLiteConnectHelper.php
@@ -72,7 +72,7 @@ private static function render_settings_for_mode( $mode ) {
// phpcs:disable Generic.WhiteSpace.ScopeIndent
?>
-
+
temp_id ?? $field->id;
-
- if ( isset( $errors[ 'field' . $field_id . '-cc' ] ) ) {
- unset( $errors[ 'field' . $field_id . '-cc' ] );
- }
-
- if ( isset( $errors[ 'field' . $field_id ] ) ) {
- unset( $errors[ 'field' . $field_id ] );
- }
-
- return $errors;
+ return FrmTransLiteActionsController::remove_cc_errors( $errors, $field );
}
}
diff --git a/stripe/controllers/FrmTransLiteActionsController.php b/stripe/controllers/FrmTransLiteActionsController.php
index 2b1bb9ec4d..8632ebbf66 100755
--- a/stripe/controllers/FrmTransLiteActionsController.php
+++ b/stripe/controllers/FrmTransLiteActionsController.php
@@ -662,4 +662,26 @@ private static function get_field_order_before_submit( $form_id, $field_order )
FrmField::update( $submit_field->id, array( 'field_order' => $submit_order + 1 ) );
return $submit_order;
}
+
+ /**
+ * Remove credit card validation errors.
+ *
+ * @param array $errors
+ * @param stdClass $field
+ *
+ * @return array
+ */
+ public static function remove_cc_errors( $errors, $field ) {
+ $field_id = $field->temp_id ?? $field->id;
+
+ if ( isset( $errors[ 'field' . $field_id . '-cc' ] ) ) {
+ unset( $errors[ 'field' . $field_id . '-cc' ] );
+ }
+
+ if ( isset( $errors[ 'field' . $field_id ] ) ) {
+ unset( $errors[ 'field' . $field_id ] );
+ }
+
+ return $errors;
+ }
}
diff --git a/stripe/controllers/FrmTransLiteHooksController.php b/stripe/controllers/FrmTransLiteHooksController.php
index 695dfb3a19..109c51fb63 100755
--- a/stripe/controllers/FrmTransLiteHooksController.php
+++ b/stripe/controllers/FrmTransLiteHooksController.php
@@ -39,13 +39,32 @@ public static function load_admin_hooks() {
if ( class_exists( 'FrmTransHooksController', false ) ) {
add_action( 'frm_pay_show_square_options', 'FrmTransLiteAppController::add_repeat_cadence_value' );
+ add_action( 'frm_pay_show_paypal_options', 'FrmPayPalLiteActionsController::add_action_options' );
+
+ // Use 99 so this happens after all of the other payment options.
+ add_action( 'frm_pay_show_paypal_options', 'FrmPayPalLiteActionsController::show_paypal_button_settings', 99 );
+
remove_action( 'admin_head', 'FrmTransListsController::add_list_hooks' );
add_action( 'admin_head', 'FrmTransLiteListsController::add_list_hooks' );
self::maybe_set_admin_menu();
+ if ( self::on_form_settings_page() ) {
+ $gateways = array_keys( FrmTransLiteAppHelper::get_gateways() );
+
+ // If no additional gateways (Like Authorize.Net) are set, hide the Collect Payment action.
+ // Since we have icons for Stripe, Square, and PayPal, we don't need the Collect Payment action.
+ if ( ! array_diff( $gateways, array( 'stripe', 'square', 'paypal' ) ) ) {
+ self::hide_collect_payment_action();
+ }
+ }
+
// Exit early, let the Payments submodule handle everything else.
return;
+ }//end if
+
+ if ( self::on_form_settings_page() ) {
+ self::hide_collect_payment_action();
}
// Actions.
@@ -65,6 +84,30 @@ public static function load_admin_hooks() {
}
}
+ /**
+ * @since x.x
+ *
+ * @return bool
+ */
+ private static function on_form_settings_page() {
+ return 'formidable' === FrmAppHelper::simple_get( 'page' ) && 'settings' === FrmAppHelper::simple_get( 'frm_action' );
+ }
+
+ /**
+ * Hide the Collect Payment action if there are no additional gateways enabled (like Authorize.Net).
+ *
+ * @since x.x
+ *
+ * @return void
+ */
+ private static function hide_collect_payment_action() {
+ echo '
+
+ ';
+ }
+
/**
* @since 6.27
*
diff --git a/stripe/controllers/FrmTransLitePaymentsController.php b/stripe/controllers/FrmTransLitePaymentsController.php
index 8f81588453..6c908719e5 100755
--- a/stripe/controllers/FrmTransLitePaymentsController.php
+++ b/stripe/controllers/FrmTransLitePaymentsController.php
@@ -217,20 +217,58 @@ public static function refund_payment() {
break;
case 'square':
$refunded = FrmSquareLiteConnectHelper::refund_payment( $payment->receipt_id );
+ break;
+ case 'paypal':
+ $refunded = FrmPayPalLiteConnectHelper::refund_payment( $payment->receipt_id );
+
+ if ( false === $refunded ) {
+ $reason = self::convert_uppercase_underscores_to_ucwords(
+ FrmPayPalLiteConnectHelper::get_latest_error_from_paypal_api(),
+ array( 'REFUND_FAILED_', 'REFUND_' )
+ );
+ }
+
break;
default:
$refunded = false;
break;
- }
+ }//end switch
if ( $refunded ) {
self::change_payment_status( $payment, 'refunded' );
$message = __( 'Refunded', 'formidable' );
} else {
- $message = __( 'Failed', 'formidable' );
+ $message = __( 'Refund Failed', 'formidable' );
+ }
+
+ if ( ! empty( $reason ) ) {
+ $message .= ' (' . $reason . ')';
+ }
+
+ wp_die(
+ sprintf(
+ '%2$s
',
+ $refunded ? 'frm_updated_message' : 'frm_error_style',
+ esc_html( $message )
+ )
+ );
+ }
+
+ /**
+ * @since x.x
+ *
+ * @param string $error
+ * @param array $prefixes_to_strip
+ *
+ * @return string
+ */
+ private static function convert_uppercase_underscores_to_ucwords( $error, $prefixes_to_strip = array() ) {
+ if ( ! preg_match( '/^[A-Z_]+$/', $error ) ) {
+ return '';
}
- wp_die( esc_html( $message ) );
+ $reason = str_replace( $prefixes_to_strip, '', $error );
+ return ucwords( strtolower( str_replace( '_', ' ', $reason ) ) );
}
/**
@@ -251,4 +289,18 @@ public static function change_payment_status( $payment, $status ) {
$frm_payment->update( $payment->id, array( 'status' => $status ) );
FrmTransLiteActionsController::trigger_payment_status_change( compact( 'status', 'payment' ) );
}
+
+ /**
+ * @since x.x
+ *
+ * @param array|string $expected_gateways
+ * @param array|string $selected_gateways
+ *
+ * @return void
+ */
+ public static function maybe_hide_payment_setting( $expected_gateways, $selected_gateways ) {
+ if ( ! array_intersect( (array) $expected_gateways, (array) $selected_gateways ) ) {
+ echo ' frm_hidden';
+ }
+ }
}
diff --git a/stripe/controllers/FrmTransLiteSubscriptionsController.php b/stripe/controllers/FrmTransLiteSubscriptionsController.php
index ee1d1fd227..30049cb116 100755
--- a/stripe/controllers/FrmTransLiteSubscriptionsController.php
+++ b/stripe/controllers/FrmTransLiteSubscriptionsController.php
@@ -110,6 +110,9 @@ public static function cancel_subscription() {
case 'square':
$canceled = FrmSquareLiteConnectHelper::cancel_subscription( $sub->sub_id );
break;
+ case 'paypal':
+ $canceled = FrmPayPalLiteConnectHelper::cancel_subscription( $sub->sub_id );
+ break;
default:
$canceled = false;
break;
diff --git a/stripe/helpers/FrmStrpLiteAppHelper.php b/stripe/helpers/FrmStrpLiteAppHelper.php
index f78235a5aa..f408f3e947 100644
--- a/stripe/helpers/FrmStrpLiteAppHelper.php
+++ b/stripe/helpers/FrmStrpLiteAppHelper.php
@@ -142,8 +142,22 @@ public static function not_connected_warning() {
'width:24px' ) ); ?>
', '', '', ' ' ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong
+ printf(
+ /* translators: %1$s: Link HTML, %2$s: End link */
+ esc_html__( 'Credit Cards will not work without sconnecting %1$sStripe%2$s, %3$sSquare%4$s, or %5$sPayPal%6$s first.', 'formidable' ),
+ // %1$s
+ '',
+ // %2$s
+ ' ',
+ // %3$s
+ '',
+ // %4$s
+ ' ',
+ // %5$s
+ '',
+ // %6$s
+ ' '
+ );
?>
diff --git a/stripe/helpers/FrmTransLiteAppHelper.php b/stripe/helpers/FrmTransLiteAppHelper.php
index 561d6c30c2..b7c12c35a1 100755
--- a/stripe/helpers/FrmTransLiteAppHelper.php
+++ b/stripe/helpers/FrmTransLiteAppHelper.php
@@ -604,4 +604,41 @@ public static function should_fallback_to_paypal() {
_deprecated_function( __METHOD__, '6.27' );
return false;
}
+
+ /**
+ * Render the gateway icon buttons for the payment action settings.
+ *
+ * @param array $gateways
+ * @param WP_Post $form_action
+ * @param FrmFormAction $action_control
+ *
+ * @return void
+ */
+ public static function show_gateway_buttons( $gateways, $form_action, $action_control ) {
+ $gateway_order = array( 'stripe', 'square', 'paypal' );
+ $gateways = self::sort_gateways( $gateways, $gateway_order );
+
+ include self::plugin_path() . '/views/action-settings/gateway-buttons.php';
+ }
+
+ /**
+ * Sort gateways by a predefined order.
+ * Unlisted gateways are appended at the end.
+ *
+ * @param array $gateways
+ * @param array $order Gateway keys in desired order.
+ *
+ * @return array
+ */
+ private static function sort_gateways( $gateways, $order ) {
+ $sorted = array();
+
+ foreach ( $order as $key ) {
+ if ( isset( $gateways[ $key ] ) ) {
+ $sorted[ $key ] = $gateways[ $key ];
+ }
+ }
+
+ return $sorted + $gateways;
+ }
}
diff --git a/stripe/helpers/FrmTransLiteListHelper.php b/stripe/helpers/FrmTransLiteListHelper.php
index 93a2c9b1e4..91c3013df9 100755
--- a/stripe/helpers/FrmTransLiteListHelper.php
+++ b/stripe/helpers/FrmTransLiteListHelper.php
@@ -623,13 +623,6 @@ private function get_paysys_column( $item, $atts ) {
return $atts['gateways'][ $item->paysys ]['label'];
}
- if ( 'paypal' === $item->paysys ) {
- // The PayPal add-on does not use a gateway.
- // This should be safe to remove once we release
- // PayPal Commerce in Lite.
- return 'PayPal';
- }
-
return $item->paysys;
}
diff --git a/stripe/js/frmtrans_admin.js b/stripe/js/frmtrans_admin.js
index 752bfe4575..ed08c436e7 100755
--- a/stripe/js/frmtrans_admin.js
+++ b/stripe/js/frmtrans_admin.js
@@ -19,31 +19,35 @@
const opts = jQuery( opt ).closest( '.frm_form_action_settings' ).find( c );
if ( show ) {
opts.show();
+ opts.removeClass( 'frm_hidden' );
} else {
opts.hide();
+ opts.addClass( 'frm_hidden' );
}
}
function toggleGateway() {
- if ( ! this.checked ) {
- return;
- }
-
const gateway = this.value;
const { checked } = this;
toggleOpts( this, checked, `.show_${ gateway }` );
- const toggleOff = 'stripe' === gateway ? 'square' : 'stripe';
+ const gateways = [ 'stripe', 'square', 'paypal' ];
+ const toggleOff = gateways.filter( g => g !== gateway );
+
const settings = jQuery( this ).closest( '.frm_form_action_settings' );
const showClass = `show_${ settings.find( '.frm_gateway_opt input:checked' ).attr( 'value' ) }`;
- const gatewaySettings = settings.get( 0 ).querySelectorAll( `.show_${ toggleOff }` );
- gatewaySettings.forEach(
- setting => {
- if ( ! setting.classList.contains( showClass ) ) {
- setting.style.display = 'none';
- }
+ toggleOff.forEach(
+ function( gateway ) {
+ const gatewaySettings = settings.get( 0 ).querySelectorAll( `.show_${ gateway }` );
+ gatewaySettings.forEach(
+ setting => {
+ if ( ! setting.classList.contains( showClass ) ) {
+ setting.style.display = 'none';
+ }
+ }
+ );
}
);
@@ -56,7 +60,19 @@
const actions = document.getElementById( 'frm_notification_settings' );
if ( actions ) {
jQuery( actions ).on( 'change', '.frm_trans_type', toggleSub );
- jQuery( '.frm_form_settings' ).on( 'change', '.frm_gateway_opt input', toggleGateway );
+
+ document.addEventListener(
+ 'change',
+ function( event ) {
+ if ( ! event.target || ! event.target.checked || 'radio' !== event.target.type ) {
+ return;
+ }
+
+ if ( event.target.closest( '.frm-long-icon-buttons' ) && event.target.closest( '.frm_form_action_settings' ) ) {
+ toggleGateway.call( event.target );
+ }
+ }
+ );
}
document.querySelectorAll( '.frm_trans_ajax_link' ).forEach(
diff --git a/stripe/models/FrmTransLiteAction.php b/stripe/models/FrmTransLiteAction.php
index 5b9d4e868e..83b7a42b0e 100755
--- a/stripe/models/FrmTransLiteAction.php
+++ b/stripe/models/FrmTransLiteAction.php
@@ -87,6 +87,7 @@ public function get_defaults() {
'credit_card' => '',
'billing_first_name' => '',
'billing_last_name' => '',
+ 'entry_data_sync' => 'overwrite',
);
return (array) apply_filters( 'frm_pay_action_defaults', $defaults );
}
diff --git a/stripe/views/action-settings/gateway-buttons.php b/stripe/views/action-settings/gateway-buttons.php
new file mode 100644
index 0000000000..6506566d31
--- /dev/null
+++ b/stripe/views/action-settings/gateway-buttons.php
@@ -0,0 +1,46 @@
+
+
+ $gateway ) {
+ $is_active = in_array( $gateway_name, (array) $form_action->post_content['gateway'], true );
+ $name = $gateway['label'] ?? ucfirst( $gateway_name );
+ $gateway_classes = $gateway['recurring'] ? '' : 'frm_gateway_no_recur';
+
+ if ( $form_action->post_content['type'] === 'recurring' && ! $gateway['recurring'] ) {
+ $gateway_classes .= ' frm_hidden';
+ }
+
+ $toggle_id = "frm_toggle_{$gateway_name}_settings";
+
+ $input_params = array(
+ 'id' => $toggle_id,
+ 'type' => 'radio',
+ 'name' => $action_control->get_field_name( 'gateway' ),
+ 'value' => $gateway_name,
+ );
+
+ if ( $is_active ) {
+ $input_params['checked'] = 'checked';
+ }
+
+ $label_params = array(
+ 'for' => $toggle_id,
+ 'class' => trim( 'frm_payment_settings_tab frm_gateway_opt ' . $gateway_classes ),
+ 'tabindex' => '0',
+ 'role' => 'tab',
+ 'aria-selected' => $is_active ? 'true' : 'false',
+ );
+ ?>
+ />
+ >
+
+
+
+
+
diff --git a/stripe/views/action-settings/payments-options.php b/stripe/views/action-settings/payments-options.php
index bb85d5ee3d..42777c1e87 100755
--- a/stripe/views/action-settings/payments-options.php
+++ b/stripe/views/action-settings/payments-options.php
@@ -5,6 +5,7 @@
$stripe_connected = FrmStrpLiteConnectHelper::at_least_one_mode_is_setup();
$square_connected = FrmSquareLiteConnectHelper::at_least_one_mode_is_setup();
+$paypal_connected = FrmPayPalLiteConnectHelper::at_least_one_mode_is_setup();
if ( $stripe_connected ) {
FrmStrpLiteAppHelper::fee_education( 'stripe-action-tip', $form_action->post_content['gateway'] );
@@ -14,40 +15,63 @@
FrmSquareLiteAppHelper::fee_education( 'square-action-tip', $form_action->post_content['gateway'] );
}
-if ( ! $stripe_connected && ! $square_connected ) {
+if ( $paypal_connected ) {
+ FrmPayPalLiteAppHelper::fee_education( 'paypal-action-tip', $form_action->post_content['gateway'] );
+}
+
+if ( ! $stripe_connected && ! $square_connected && ! $paypal_connected ) {
FrmStrpLiteAppHelper::not_connected_warning();
}
?>
+
+
+ $form_action,
+ 'action_control' => $action_control,
+ )
+ );
+ ?>
+
-
+
-
+
- get_credit_card_field_id( $field_dropdown_atts ); ?>
-
+ get_credit_card_field_id( $field_dropdown_atts ); ?>
+
-
+
post_content['type'], 'one_time' ); ?>>
post_content['type'], 'recurring' ); ?>>
- echo_capture_payment_upsell( $form_action->post_content['gateway'] ); ?>
+ echo_capture_payment_upsell( $form_action->post_content['gateway'] ); ?>
@@ -55,13 +79,13 @@
-
-
+
+
$v ) { ?>
post_content['interval'], $k ); ?>>
-
+
@@ -78,64 +102,41 @@
-
+
-
+
-
+
- get_field_id( 'currency' ), $this->get_field_name( 'currency' ), $form_action->post_content ); ?>
-
-
-
- $gateway ) {
- $gateway_classes = $gateway['recurring'] ? '' : 'frm_gateway_no_recur';
- $gateway_classes .= $form_action->post_content['type'] === 'recurring' && ! $gateway['recurring'] ? ' frm_hidden' : '';
- $gateway_id = $this->get_field_id( 'gateways' ) . '_' . $gateway_name;
-
- $radio_atts = array(
- 'type' => 'radio',
- 'value' => $gateway_name,
- 'name' => $this->get_field_name( 'gateway' ),
- 'id' => $gateway_id,
- );
- ?>
-
- post_content['gateway'], $gateway_name );
- ?>
- />
-
-
-
+ get_field_id( 'currency' ), $action_control->get_field_name( 'currency' ), $form_action->post_content ); ?>
$form_action,
- 'action_control' => $this,
+ 'action_control' => $action_control,
+ )
+ );
+
+ FrmPayPalLiteActionsController::add_action_options(
+ array(
+ 'form_action' => $form_action,
+ 'action_control' => $action_control,
)
);
?>
+
diff --git a/stubs.php b/stubs.php
index f39b43a35d..6bf92cec99 100644
--- a/stubs.php
+++ b/stubs.php
@@ -513,6 +513,18 @@ public static function enqueue_pro_web_components_script(){
}
}
+ /**
+ * This class is in the PayPal add-on.
+ */
+ class FrmPaymentSettingsController {
+ /**
+ * @return void
+ */
+ public static function route() {
+
+ }
+ }
+
class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase {
}