PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / trunk
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More vtrunk
6.35 6.34 6.33.1 6.33 6.32.1 6.32 6.31 6.25 6.25.1 6.26 6.26.1 6.27 6.28 6.29 6.3 6.3.1 6.3.2 6.30 6.4 6.4.1 6.4.2 6.5 6.5.1 6.5.2 6.5.3 All 141 releases
formidable / paypal / controllers / FrmPayPalLiteActionsController.php

FrmPayPalLiteActionsController.php in Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More trunk, at paypal/controllers/FrmPayPalLiteActionsController.php

2,742 lines 67.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:ignore SlevomatCodingStandard.Files.FileLength.FileTooLong
3 if ( ! defined( 'ABSPATH' ) ) {
4 die( 'You are not allowed to call this page directly.' );
5 }
6
7 class FrmPayPalLiteActionsController extends FrmTransLiteActionsController {
8
9 private static $active_order_id;
10
11 private static $active_payment_source;
12
13 private static $pending_capture = false;
14
15 private static $active_subscription;
16
17 /**
18 * @since 6.31
19 *
20 * @param string $callback
21 * @param array|false|object $field
22 *
23 * @return string
24 */
25 public static function maybe_show_card( $callback, $field = false ) {
26 if ( false === $field ) {
27 // Pro isn't up to date.
28 return $callback;
29 }
30
31 $form_id = is_object( $field ) ? $field->form_id : $field['form_id'];
32 $actions = self::get_actions_before_submit( $form_id );
33
34 if ( ! $actions ) {
35 return $callback;
36 }
37
38 $field_id = is_object( $field ) ? $field->id : $field['id'];
39
40 foreach ( $actions as $action ) {
41 if ( (int) $action->post_content['credit_card'] === (int) $field_id ) {
42 return self::class . '::show_card';
43 }
44 }
45
46 return $callback;
47 }
48
49 /**
50 * Override the credit card field HTML if there is a PayPal action.
51 *
52 * @since 6.31
53 *
54 * @param array $field
55 * @param string $field_name
56 * @param array $atts
57 *
58 * @return void
59 */
60 public static function show_card( $field, $field_name, $atts ) {
61 $actions = self::get_actions_before_submit( $field['form_id'] );
62
63 if ( $actions ) {
64 self::load_scripts( (int) $field['form_id'] );
65
66 $html_id = $atts['html_id'];
67 include FrmStrpLiteAppHelper::plugin_path() . '/views/payments/card-field.php';
68 return;
69 }
70
71 // Use the Pro function when there are no Stripe actions.
72 // This is required for other gateways like Authorize.Net.
73 if ( is_callable( 'FrmProCreditCardsController::show_in_form' ) ) {
74 FrmProCreditCardsController::show_in_form( $field, $field_name, $atts );
75 }
76 }
77
78 /**
79 * Get all published payment actions with the PayPal gateway that have an amount set.
80 *
81 * @since 6.31
82 *
83 * @param int|string $form_id
84 *
85 * @return array
86 */
87 public static function get_actions_before_submit( $form_id ) {
88 $payment_actions = self::get_actions_for_form( $form_id );
89
90 foreach ( $payment_actions as $k => $payment_action ) {
91 $gateway = $payment_action->post_content['gateway'];
92 $is_paypal = $gateway === 'paypal' || ( is_array( $gateway ) && in_array( 'paypal', $gateway, true ) );
93
94 if ( ! $is_paypal || empty( $payment_action->post_content['amount'] ) ) {
95 unset( $payment_actions[ $k ] );
96 }
97 }
98
99 return $payment_actions;
100 }
101
102 /**
103 * Trigger a PayPal payment after a form is submitted.
104 * This is called for both one time and recurring payments.
105 *
106 * @param WP_Post $action
107 * @param stdClass $entry
108 * @param mixed $form
109 *
110 * @return array
111 */
112 public static function trigger_gateway( $action, $entry, $form ) { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
113 $response = array(
114 'success' => false,
115 'run_triggers' => false,
116 'show_errors' => true,
117 );
118 $atts = compact( 'action', 'entry', 'form' );
119 $amount = self::prepare_amount( $action->post_content['amount'], $atts );
120
121 // phpcs:ignore Universal.Operators.StrictComparisons
122 if ( ! $amount || $amount == 000 ) {
123 $response['error'] = __( 'Please specify an amount for the payment', 'formidable' );
124 return $response;
125 }
126
127 if ( ! self::paypal_is_configured() ) {
128 $response['error'] = __( 'PayPal still needs to be configured.', 'formidable' );
129 return $response;
130 }
131
132 $payment_args = compact( 'form', 'entry', 'action', 'amount' );
133
134 // Attempt to charge the customer's card.
135 if ( 'recurring' === $action->post_content['type'] ) {
136 $charge = self::trigger_recurring_payment( $payment_args );
137 } else {
138 $charge = self::trigger_one_time_payment( $payment_args );
139 }
140
141 if ( $charge === true ) {
142 $response['success'] = true;
143 } else {
144 $response['error'] = $charge;
145 }
146
147 if ( ! self::$active_order_id && ! self::$active_subscription ) {
148 return $response;
149 }
150
151 $email = false;
152 $address = false;
153 $order = self::$active_order_id ? FrmPayPalLiteConnectHelper::get_order( self::$active_order_id ) : false;
154
155 if ( is_object( $order ) && isset( $order->payer ) && is_object( $order->payer ) ) {
156 $payer = $order->payer;
157
158 if ( ! empty( $payer->email_address ) ) {
159 $email = $payer->email_address;
160 }
161 }
162
163 if ( is_object( $order ) && ! empty( $order->purchase_units[0]->shipping->address ) && is_object( $order->purchase_units[0]->shipping->address ) ) {
164 $address = $order->purchase_units[0]->shipping->address;
165 }
166
167 // Fallback to subscription subscriber info when order data is not available.
168 if ( self::$active_subscription && isset( self::$active_subscription->subscriber ) && is_object( self::$active_subscription->subscriber ) ) {
169 $subscriber = self::$active_subscription->subscriber;
170
171 if ( ! $email && ! empty( $subscriber->email_address ) ) {
172 $email = $subscriber->email_address;
173 }
174
175 if ( ! $address && isset( $subscriber->shipping_address->address ) ) {
176 $address = $subscriber->shipping_address->address;
177 }
178 }
179
180 $paypal_message = '';
181
182 if ( self::$pending_capture ) {
183 $paypal_message .= '<strong>' . esc_html__( 'Payment status: ', 'formidable' ) . '</strong>' . esc_html__( 'Pending', 'formidable' ) . '<br>';
184 }
185
186 $source_type = self::$active_payment_source;
187
188 if ( $source_type ) {
189 $display_type = self::get_source_display_type( $source_type );
190 $paypal_message .= '<strong>' . esc_html__( 'Payment source: ', 'formidable' ) . '</strong>' . $display_type . '<br>';
191 }
192
193 if ( $email ) {
194 $paypal_message .= '<strong>' . esc_html__( 'Payment made by: ', 'formidable' ) . '</strong>' . $email . '<br>';
195 }
196
197 if ( $address && ! empty( $address->address_line_1 ) ) {
198 $paypal_message .= self::format_address( $address );
199 }
200
201 /**
202 * Filters the message to show in the main feedback area.
203 *
204 * @since 6.31
205 *
206 * @param string $paypal_message The message to show.
207 * @param stdClass $order The order object.
208 */
209 $paypal_message = apply_filters( 'frm_paypal_message', $paypal_message, $order );
210
211 add_filter(
212 'frm_main_feedback',
213 function ( $message ) use ( $paypal_message ) {
214 if ( $paypal_message ) {
215 $details = '<div class="frm_paypal_payment_details" style="margin-top: 10px;">' . $paypal_message . '</div>';
216 $message = preg_replace( '/(<div\b[^>]*\bfrm_message\b[^>]*>)(.*?)(<\/div>)/s', '$1$2' . $details . '$3', $message );
217 }
218
219 return $message;
220 }
221 );
222
223 return $response;
224 }
225
226 /**
227 * Get the display label for a payment source type.
228 *
229 * @since 6.31
230 *
231 * @param string $source_type The payment source identifier (e.g. 'paypal', 'paylater', 'google_pay').
232 *
233 * @return string The human-readable display label.
234 */
235 private static function get_source_display_type( $source_type ) {
236 switch ( $source_type ) {
237 case 'paypal':
238 return __( 'PayPal', 'formidable' );
239 case 'paylater':
240 return __( 'Pay Later', 'formidable' );
241 default:
242 return ucwords( str_replace( '_', ' ', $source_type ) );
243 }
244 }
245
246 /**
247 * Format a PayPal shipping address object into an HTML string.
248 *
249 * @since 6.31
250 *
251 * @param object $address The PayPal shipping address object.
252 *
253 * @return string The formatted address HTML.
254 */
255 private static function format_address( $address ) {
256 $formatted = '<strong>' . esc_html__( 'Address: ', 'formidable' ) . '</strong><br>';
257
258 $formatted .= $address->address_line_1 . '<br>';
259
260 // City, State Zip
261 $city_line = '';
262
263 if ( ! empty( $address->admin_area_2 ) ) {
264 $city_line .= $address->admin_area_2;
265 }
266
267 if ( ! empty( $address->admin_area_1 ) ) {
268 $city_line .= $city_line ? ', ' . $address->admin_area_1 : $address->admin_area_1;
269 }
270
271 if ( ! empty( $address->postal_code ) ) {
272 $city_line .= $city_line ? ' ' . $address->postal_code : $address->postal_code;
273 }
274
275 if ( $city_line ) {
276 $formatted .= $city_line . '<br>';
277 }
278
279 if ( ! empty( $address->country_code ) ) {
280 $formatted .= $address->country_code . '<br>';
281 }
282
283 return $formatted;
284 }
285
286 /**
287 * Trigger a one time payment.
288 *
289 * @param array $atts The arguments for the payment.
290 *
291 * @return string|true string on error, true on success.
292 */
293 private static function trigger_one_time_payment( $atts ) {
294 $paypal_order_id = FrmAppHelper::get_post_param( 'paypal_order_id', '', 'sanitize_text_field' );
295
296 if ( ! $paypal_order_id ) {
297 return 'No PayPal order ID found.';
298 }
299
300 $order = FrmPayPalLiteConnectHelper::get_order( $paypal_order_id );
301
302 if ( false === $order ) {
303 return 'Failed to get order.';
304 }
305
306 if ( self::is_liability_error( $order ) ) {
307 return 'This payment was flagged as possible fraud and has been rejected.';
308 }
309
310 if ( ! self::validate_order_status( $order ) ) {
311 return 'This order status is not valid for capture.';
312 }
313
314 if ( ! self::validate_order_amount( $order, $atts['amount'] ) ) {
315 return 'This order amount appears to be tampered with.';
316 }
317
318 $response = FrmPayPalLiteConnectHelper::capture_order( $paypal_order_id );
319
320 if ( false === $response ) {
321 // Get error message and debug_id from static properties
322 $reason = FrmPayPalLiteConnectHelper::get_latest_error_from_paypal_api();
323 $debug_id = FrmPayPalLiteConnectHelper::get_latest_debug_id_from_paypal_api();
324
325 if ( $reason ) {
326 $message = $reason;
327
328 if ( $debug_id ) {
329 $message .= ' {{debug_id:' . $debug_id . '}}';
330 }
331
332 return $message;
333 }
334
335 return 'Failed to confirm order.';
336 }
337
338 // If PayPal returned an error (e.g. ORDER_IS_PENDING_APPROVAL),
339 // create a pending payment instead of failing outright.
340 if ( ! empty( $response->capture_error ) ) {
341 return self::handle_pending_capture( $response, $atts, $paypal_order_id );
342 }
343
344 if ( ! isset( $response->status ) || $response->status !== 'COMPLETED' ) {
345 return self::get_paypal_error_message( $response, __( 'Failed to capture order.', 'formidable' ) );
346 }
347
348 $capture_id = self::get_capture_id_from_response( $response );
349
350 self::sync_entry_data_with_capture_response( $response, $atts );
351
352 // Create a payment record.
353 $atts['status'] = 'complete';
354 $atts['charge'] = new stdClass();
355 $atts['charge']->id = $capture_id ? $capture_id : $paypal_order_id;
356 $atts['charge']->amount = $atts['amount'];
357
358 $payment_id = self::create_new_payment( $atts );
359 $frm_payment = new FrmTransLitePayment();
360 $payment = $frm_payment->get_one( $payment_id );
361 $status = $atts['status'];
362
363 FrmTransLiteActionsController::trigger_payment_status_change( compact( 'status', 'payment' ) );
364
365 self::$active_order_id = $paypal_order_id;
366 self::$active_payment_source = FrmAppHelper::get_post_param( 'paypal_payment_source', '', 'sanitize_text_field' );
367
368 return true;
369 }
370
371 /**
372 * Extract a human-readable message from a PayPal error response.
373 *
374 * PayPal error payloads include a `details` array with per-issue
375 * `description` strings (e.g. "The instrument presented was either declined
376 * by the processor or bank..."). Prefer those over the generic top-level
377 * `message` so the buyer sees the actionable reason.
378 *
379 * @since 6.31
380 *
381 * @param mixed $response The PayPal response object.
382 * @param string $fallback The fallback message when no details are available.
383 *
384 * @return string
385 */
386 private static function get_paypal_error_message( $response, $fallback ) {
387 $message = $fallback;
388
389 if ( is_object( $response ) && isset( $response->details ) && is_array( $response->details ) ) {
390 foreach ( $response->details as $detail ) {
391 if ( is_object( $detail ) && ! empty( $detail->description ) ) {
392 $message = (string) $detail->description;
393 break;
394 }
395 }
396 } elseif ( is_object( $response ) && ! empty( $response->message ) ) {
397 $message = (string) $response->message;
398 }
399
400 return self::maybe_append_debug_id( $message, $response );
401 }
402
403 /**
404 * Conditionally append a PayPal debug ID to an error message.
405 *
406 * The debug ID is shown only to users who can edit forms, as a
407 * troubleshooting aid.
408 *
409 * @since 6.31
410 *
411 * @param string $message The error message.
412 * @param mixed $response The PayPal response that may contain a debug_id property.
413 *
414 * @return string
415 */
416 private static function maybe_append_debug_id( $message, $response ) {
417 if ( ! current_user_can( 'frm_edit_forms' ) ) {
418 return $message;
419 }
420
421 $debug_id = is_object( $response ) && ! empty( $response->debug_id ) ? $response->debug_id : '';
422
423 if ( ! $debug_id ) {
424 return $message;
425 }
426
427 return $message . '<br><br>Debug ID: ' . esc_html( $debug_id );
428 }
429
430 /**
431 * @since 6.31
432 *
433 * @param stdClass $order The order object.
434 *
435 * @return bool
436 */
437 private static function is_liability_error( $order ) {
438 if ( ! isset( $order->payment_source->card->authentication_result->liability_shift ) ) {
439 return false;
440 }
441
442 $liability_shift = $order->payment_source->card->authentication_result->liability_shift;
443 $is_liability_error = 'NO' === $liability_shift || 'UNKNOWN' === $liability_shift;
444
445 /**
446 * Filters whether the liability shift is an error.
447 *
448 * @since 6.31
449 *
450 * @param bool $is_liability_error Whether the liability shift is an error.
451 * @param string $liability_shift The liability shift value. By default 'NO' and 'UNKNOWN' are errors.
452 * @param stdClass $order The order object.
453 */
454 return (bool) apply_filters(
455 'frm_paypal_is_liability_error',
456 $is_liability_error,
457 $liability_shift,
458 $order
459 );
460 }
461
462 /**
463 * Handle a capture that returned a PayPal error instead of completing.
464 *
465 * For recoverable states like ORDER_IS_PENDING_APPROVAL, a pending
466 * payment record is created so the webhook can complete it later.
467 * For unrecoverable errors, the PayPal error description is returned.
468 *
469 * @since 6.31
470 *
471 * @param stdClass $response The PayPal error response object.
472 * @param array $atts The payment attributes.
473 * @param string $paypal_order_id The PayPal order ID.
474 *
475 * @return string|true String error message or true on success.
476 */
477 private static function handle_pending_capture( $response, $atts, $paypal_order_id ) {
478 self::$pending_capture = true;
479
480 $issue = self::get_capture_error_issue( $response );
481
482 // Only block on known non-recoverable errors. For pending states
483 // or unknown issues (empty details from PayPal), create a pending
484 // payment and let the webhook resolve it.
485 // Include mock error codes for testing.
486 $non_recoverable = array(
487 'INSTRUMENT_DECLINED',
488 'PAYER_CANNOT_PAY',
489 'MAX_NUMBER_OF_PAYMENT_ATTEMPTS_EXCEEDED',
490 'AUTHENTICATION_FAILURE',
491 'INTERNAL_SERVER_ERROR',
492 'INVALID_REQUEST',
493 'REFUND_FAILED_INSUFFICIENT_FUNDS',
494 'REFUND_FAILED_CREDIT_CARD_REFUND',
495 'REFUND_FAILED_REFUND_NOT_ALLOWED',
496 'REFUND_FAILED_TRANSACTION_ALREADY_REFUNDED',
497 'REFUND_FAILED_INVALID_ARGUMENT',
498 );
499
500 if ( in_array( $issue, $non_recoverable, true ) ) {
501 // Convert issue code to human-readable message
502 $error_message = self::convert_issue_to_message( $issue );
503 return self::get_paypal_error_message( $response, $error_message );
504 }
505
506 $atts['status'] = 'pending';
507 $atts['charge'] = new stdClass();
508 $atts['charge']->id = $paypal_order_id;
509 $atts['charge']->amount = $atts['amount'];
510
511 $payment_id = self::create_new_payment( $atts );
512 $frm_payment = new FrmTransLitePayment();
513 $payment = $frm_payment->get_one( $payment_id );
514 $status = $atts['status'];
515
516 FrmTransLiteActionsController::trigger_payment_status_change( compact( 'status', 'payment' ) );
517
518 self::$active_order_id = $paypal_order_id;
519 self::$active_payment_source = FrmAppHelper::get_post_param( 'paypal_payment_source', '', 'sanitize_text_field' );
520
521 return true;
522 }
523
524 /**
525 * Extract the issue code from a PayPal capture error response.
526 *
527 * @since 6.31
528 *
529 * @param stdClass $response The PayPal error response.
530 *
531 * @return string The issue code, or empty string if not found.
532 */
533 private static function get_capture_error_issue( $response ) {
534 // Check for details array first (standard PayPal error format)
535 if ( isset( $response->details ) && is_array( $response->details ) ) {
536 $first_detail = reset( $response->details );
537
538 if ( is_object( $first_detail ) && ! empty( $first_detail->issue ) ) {
539 return (string) $first_detail->issue;
540 }
541 }
542
543 // Check for name field (used by mock responses and some error formats)
544 if ( isset( $response->name ) && is_string( $response->name ) ) {
545 return $response->name;
546 }
547
548 return '';
549 }
550
551 /**
552 * Convert a PayPal issue code to a human-readable error message.
553 *
554 * @since 6.31
555 *
556 * @param string $issue The issue code (e.g. AUTHENTICATION_FAILURE).
557 *
558 * @return string The human-readable error message.
559 */
560 private static function convert_issue_to_message( $issue ) {
561 // Map of common PayPal issue codes to human-readable messages
562 $issue_map = array(
563 'AUTHENTICATION_FAILURE' => 'PayPal payment failed: Authentication failure',
564 'INSTRUMENT_DECLINED' => 'PayPal payment failed: Payment instrument declined',
565 'PAYER_CANNOT_PAY' => 'PayPal payment failed: Payer cannot pay',
566 'MAX_NUMBER_OF_PAYMENT_ATTEMPTS_EXCEEDED' => 'PayPal payment failed: Maximum payment attempts exceeded',
567 );
568
569 // Check if the issue is in our map
570 $upper_issue = strtoupper( $issue );
571
572 // Fallback: convert underscores to spaces and title case
573 return $issue_map[ $upper_issue ] ?? 'PayPal payment failed: ' . ucwords( strtolower( str_replace( '_', ' ', $issue ) ) );
574 }
575
576 /**
577 * @since 6.31
578 *
579 * @param stdClass $order
580 *
581 * @return bool
582 */
583 private static function validate_order_status( $order ) {
584 return isset( $order->status ) && 'APPROVED' === $order->status;
585 }
586
587 /**
588 * @since 6.31
589 *
590 * @param stdClass $order
591 * @param string $expected_amount This is as a whole number (in cents for currencies that include decimals).
592 *
593 * @return bool
594 */
595 private static function validate_order_amount( $order, $expected_amount ) {
596 $order_amount = $order->purchase_units[0]->amount->value ?? '';
597
598 // The order amount is in dollars, but the expected amount is in cents, so we need to convert.
599 $order_amount = str_replace( '.', '', $order_amount );
600
601 return $order_amount === $expected_amount;
602 }
603
604 /**
605 * Validate that the subscription status indicates it was approved by the payer.
606 *
607 * @since 6.31
608 *
609 * @param stdClass $subscription The PayPal subscription object.
610 *
611 * @return bool
612 */
613 private static function validate_subscription_status( $subscription ) {
614 if ( ! isset( $subscription->status ) ) {
615 return false;
616 }
617
618 return in_array( $subscription->status, array( 'ACTIVE', 'APPROVED' ), true );
619 }
620
621 /**
622 * Validate that the subscription amount matches the expected amount.
623 *
624 * @since 6.31
625 *
626 * @param stdClass $subscription The PayPal subscription object.
627 * @param string $expected_amount The expected amount as a whole number (in cents for currencies that include decimals).
628 *
629 * @return bool
630 */
631 private static function validate_subscription_amount( $subscription, $expected_amount ) {
632 $subscription_amount = $subscription->billing_info->last_payment->amount->value ?? $subscription->plan->billing_cycles[0]->pricing_scheme->fixed_price->value ?? '';
633
634 if ( ! $subscription_amount ) {
635 return false;
636 }
637
638 $subscription_amount = number_format( (float) $subscription_amount, 2, '.', '' );
639 $expected_amount = number_format( ( (float) $expected_amount ) / 100, 2, '.', '' );
640
641 return $subscription_amount === $expected_amount;
642 }
643
644 /**
645 * @param object $response
646 *
647 * @return string
648 */
649 private static function get_capture_id_from_response( $response ) {
650 if ( ! isset( $response->id ) ) {
651 return '';
652 }
653
654 foreach ( $response->purchase_units as $purchase_unit ) {
655 if ( empty( $purchase_unit->payments ) || ! is_object( $purchase_unit->payments ) ) {
656 continue;
657 }
658
659 $payments = $purchase_unit->payments;
660
661 if ( empty( $payments->captures ) || ! is_array( $payments->captures ) ) {
662 continue;
663 }
664
665 $captures = $payments->captures;
666
667 foreach ( $captures as $capture ) {
668 return $capture->id;
669 }
670 }
671
672 return '';
673 }
674
675 /**
676 * Sync the entry data with the capture response.
677 *
678 * @since 6.31
679 *
680 * @param object $response The response object.
681 * @param array $atts The arguments for the payment.
682 *
683 * @return void
684 */
685 private static function sync_entry_data_with_capture_response( $response, $atts ) {
686 if ( ! isset( $response->payer ) || ! is_object( $response->payer ) ) {
687 return;
688 }
689
690 $entry = $atts['entry'];
691 $action = $atts['action'];
692 $settings = $action->post_content;
693 $mode = $settings['entry_data_sync'] ?? 'overwrite';
694 $payer = $response->payer;
695
696 if ( 'new_fields' === $mode ) {
697 $updates = self::get_order_data_field_updates( $payer, $response, $settings );
698 } else {
699 $updates = self::get_payer_field_updates( $payer, $response, $action, $entry );
700 }
701
702 foreach ( $updates as $field_id => $new_value ) {
703 if ( ! FrmEntryMeta::update_entry_meta( $entry->id, $field_id, '', $new_value ) ) {
704 FrmEntryMeta::add_entry_meta( $entry->id, $field_id, '', $new_value );
705 }
706 }
707
708 if ( ! class_exists( 'FrmLog' ) ) {
709 return;
710 }
711
712 $log = new FrmLog();
713 $log->add(
714 array(
715 'title' => 'PayPal Lite: Sync Entry Data with Capture Response',
716 'content' => print_r( $updates, true ),
717 )
718 );
719 }
720
721 /**
722 * Sync the entry data with the subscription response.
723 *
724 * Normalizes the subscriber data from the subscription response into the
725 * same shape used by the capture response so the existing field-update
726 * helpers can be reused.
727 *
728 * @since 6.31
729 *
730 * @param object $subscription The subscription object from PayPal.
731 * @param array $atts Includes 'entry', 'action', 'amount'.
732 *
733 * @return void
734 */
735 private static function sync_entry_data_with_subscription_response( $subscription, $atts ) {
736 if ( ! isset( $subscription->subscriber ) || ! is_object( $subscription->subscriber ) ) {
737 return;
738 }
739
740 $subscriber = $subscription->subscriber;
741
742 // Normalize subscriber into the payer shape expected by existing helpers.
743 $payer = (object) array(
744 'email_address' => $subscriber->email_address ?? '',
745 'name' => $subscriber->name ?? null,
746 );
747
748 // Normalize shipping address into the purchase_units shape expected by get_shipping_address_from_response.
749 $response = (object) array(
750 'payer' => $payer,
751 'purchase_units' => array(),
752 );
753
754 if ( isset( $subscriber->shipping_address->address ) ) {
755 $response->purchase_units = array(
756 (object) array(
757 'shipping' => (object) array(
758 'address' => $subscriber->shipping_address->address,
759 ),
760 ),
761 );
762 }
763
764 $entry = $atts['entry'];
765 $action = $atts['action'];
766 $settings = $action->post_content;
767 $mode = $settings['entry_data_sync'] ?? 'overwrite';
768
769 if ( 'new_fields' === $mode ) {
770 $updates = self::get_order_data_field_updates( $payer, $response, $settings );
771
772 foreach ( $updates as $field_id => $new_value ) {
773 FrmEntryMeta::add_entry_meta( $entry->id, $field_id, '', $new_value );
774 }
775 } else {
776 $updates = self::get_payer_field_updates( $payer, $response, $action, $entry );
777
778 foreach ( $updates as $field_id => $new_value ) {
779 if ( ! FrmEntryMeta::update_entry_meta( $entry->id, $field_id, '', $new_value ) ) {
780 FrmEntryMeta::add_entry_meta( $entry->id, $field_id, '', $new_value );
781 }
782 }
783 }
784
785 if ( ! class_exists( 'FrmLog' ) ) {
786 return;
787 }
788
789 $log = new FrmLog();
790 $log->add(
791 array(
792 'title' => 'PayPal Lite: Sync Entry Data with Subscription Response',
793 'content' => print_r( $updates, true ),
794 )
795 );
796 }
797
798 /**
799 * Build field updates for the dedicated PayPal order data fields.
800 *
801 * @since 6.31
802 *
803 * @param stdClass $payer The payer object from the PayPal response.
804 * @param stdClass $response The full capture response.
805 * @param array $settings The action settings.
806 *
807 * @return array<int,mixed> Field ID => value pairs.
808 */
809 private static function get_order_data_field_updates( $payer, $response, $settings ) {
810 $updates = array();
811
812 // Email (hidden field, single string value).
813 if ( ! empty( $settings['paypal_order_email'] ) && ! empty( $payer->email_address ) ) {
814 $updates[ (int) $settings['paypal_order_email'] ] = $payer->email_address;
815 }
816
817 // Name (name field with first/last sub-keys).
818 if ( ! empty( $settings['paypal_order_name'] ) && isset( $payer->name ) ) {
819 $new_value = array(
820 'first' => ! empty( $payer->name->given_name ) ? $payer->name->given_name : '',
821 'last' => ! empty( $payer->name->surname ) ? $payer->name->surname : '',
822 );
823
824 if ( array_filter( $new_value ) ) {
825 $updates[ (int) $settings['paypal_order_name'] ] = $new_value;
826 }
827 }
828
829 // Address (address field with line1/line2/city/state/zip/country sub-keys).
830 if ( empty( $settings['paypal_order_address'] ) ) {
831 return $updates;
832 }
833
834 $shipping = self::get_shipping_address_from_response( $response );
835
836 if ( $shipping ) {
837 $new_value = array(
838 'line1' => ! empty( $shipping->address_line_1 ) ? $shipping->address_line_1 : '',
839 'line2' => ! empty( $shipping->address_line_2 ) ? $shipping->address_line_2 : '',
840 'city' => ! empty( $shipping->admin_area_2 ) ? $shipping->admin_area_2 : '',
841 'state' => ! empty( $shipping->admin_area_1 ) ? $shipping->admin_area_1 : '',
842 'zip' => ! empty( $shipping->postal_code ) ? $shipping->postal_code : '',
843 'country' => ! empty( $shipping->country_code ) ? $shipping->country_code : '',
844 );
845
846 if ( array_filter( $new_value ) ) {
847 $updates[ (int) $settings['paypal_order_address'] ] = $new_value;
848 }
849 }
850
851 return $updates;
852 }
853
854 /**
855 * Build an array of field updates by comparing payer response data against current entry metas.
856 *
857 * @since 6.31
858 *
859 * @param stdClass $payer The payer object from the PayPal response.
860 * @param stdClass $response The full capture response.
861 * @param WP_Post $action The payment action.
862 * @param stdClass $entry The entry object.
863 *
864 * @return array<int,mixed> Field ID => new value pairs that differ from the current entry data.
865 */
866 // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
867 private static function get_payer_field_updates( $payer, $response, $action, $entry ) {
868 $updates = array();
869 $settings = $action->post_content;
870
871 // Email: setting is a shortcode like [25], extract the field ID.
872 if ( ! empty( $settings['email'] ) && preg_match( '/\[(\d+)\]/', $settings['email'], $matches ) ) {
873 $email_field_id = (int) $matches[1];
874
875 if ( ! empty( $payer->email_address ) ) {
876 $current = $entry->metas[ $email_field_id ] ?? '';
877
878 if ( $current !== $payer->email_address ) {
879 $updates[ $email_field_id ] = $payer->email_address;
880 }
881 }
882 }
883
884 // Name fields.
885 if ( isset( $payer->name ) ) {
886 $first_name_field_id = ! empty( $settings['billing_first_name'] ) ? (int) $settings['billing_first_name'] : 0;
887 $last_name_field_id = ! empty( $settings['billing_last_name'] ) ? (int) $settings['billing_last_name'] : 0;
888
889 if ( $first_name_field_id && $first_name_field_id === $last_name_field_id ) {
890 // Both settings point to the same Name field. Store as a serialized array.
891 $new_value = array(
892 'first' => ! empty( $payer->name->given_name ) ? $payer->name->given_name : '',
893 'last' => ! empty( $payer->name->surname ) ? $payer->name->surname : '',
894 );
895 $current = $entry->metas[ $first_name_field_id ] ?? array();
896
897 if ( $current !== $new_value ) {
898 $updates[ $first_name_field_id ] = $new_value;
899 }
900 } else {
901 // Separate text fields for first and last name.
902 if ( $first_name_field_id && ! empty( $payer->name->given_name ) ) {
903 $current = $entry->metas[ $first_name_field_id ] ?? '';
904
905 if ( $current !== $payer->name->given_name ) {
906 $updates[ $first_name_field_id ] = $payer->name->given_name;
907 }
908 }
909
910 if ( $last_name_field_id && ! empty( $payer->name->surname ) ) {
911 $current = $entry->metas[ $last_name_field_id ] ?? '';
912
913 if ( $current !== $payer->name->surname ) {
914 $updates[ $last_name_field_id ] = $payer->name->surname;
915 }
916 }
917 }//end if
918 }//end if
919
920 // Address: pull from the first purchase unit's shipping address.
921 if ( empty( $settings['billing_address'] ) ) {
922 return $updates;
923 }
924
925 $field_id = (int) $settings['billing_address'];
926 $shipping = self::get_shipping_address_from_response( $response );
927
928 if ( ! $shipping ) {
929 return $updates;
930 }
931
932 $new_value = array(
933 'line1' => ! empty( $shipping->address_line_1 ) ? $shipping->address_line_1 : '',
934 'line2' => ! empty( $shipping->address_line_2 ) ? $shipping->address_line_2 : '',
935 'city' => ! empty( $shipping->admin_area_2 ) ? $shipping->admin_area_2 : '',
936 'state' => ! empty( $shipping->admin_area_1 ) ? $shipping->admin_area_1 : '',
937 'zip' => ! empty( $shipping->postal_code ) ? $shipping->postal_code : '',
938 'country' => ! empty( $shipping->country_code ) ? $shipping->country_code : '',
939 );
940
941 if ( ! array_filter( $new_value ) ) {
942 return $updates;
943 }
944
945 $current = $entry->metas[ $field_id ] ?? array();
946
947 if ( $current !== $new_value ) {
948 $updates[ $field_id ] = $new_value;
949 }
950
951 return $updates;
952 }
953
954 /**
955 * Get the shipping address object from the first purchase unit in the response.
956 *
957 * @since 6.31
958 *
959 * @param object $response The capture response.
960 *
961 * @return false|object The address object, or false if not available.
962 */
963 private static function get_shipping_address_from_response( $response ) {
964 if ( empty( $response->purchase_units ) || ! is_array( $response->purchase_units ) ) {
965 return false;
966 }
967
968 $purchase_unit = reset( $response->purchase_units );
969
970 if ( empty( $purchase_unit->shipping ) || ! is_object( $purchase_unit->shipping ) || empty( $purchase_unit->shipping->address ) ) {
971 return false;
972 }
973
974 return $purchase_unit->shipping->address;
975 }
976
977 /**
978 * Add a payment row for the payments table.
979 *
980 * @param array $atts The arguments for the payment.
981 *
982 * @return int
983 */
984 private static function create_new_payment( $atts ) {
985 $atts['charge'] = (object) $atts['charge'];
986
987 $new_values = array(
988 'amount' => FrmTransLiteAppHelper::get_formatted_amount_for_currency( $atts['charge']->amount, $atts['action'] ),
989 'status' => $atts['status'],
990 'paysys' => 'paypal',
991 'item_id' => $atts['entry']->id,
992 'action_id' => $atts['action']->ID,
993 'receipt_id' => $atts['charge']->id,
994 'sub_id' => $atts['charge']->sub_id ?? '',
995 'test' => 'test' === FrmPayPalLiteAppHelper::active_mode() ? 1 : 0,
996 );
997
998 $frm_payment = new FrmTransLitePayment();
999 return $frm_payment->create( $new_values );
1000 }
1001
1002 /**
1003 * Create a new PayPal subscription and a subscription and payment for the payments tables.
1004 *
1005 * @param array $atts Includes 'customer', 'entry', 'action', 'amount'.
1006 *
1007 * @return bool|string True on success, error message on failure
1008 */
1009 private static function trigger_recurring_payment( $atts ) {
1010 $subscription_id = FrmAppHelper::get_post_param( 'paypal_subscription_id', '', 'sanitize_text_field' );
1011
1012 if ( ! $subscription_id ) {
1013 return __( 'No PayPal subscription ID found.', 'formidable' );
1014 }
1015
1016 $subscription = FrmPayPalLiteConnectHelper::get_subscription( $subscription_id );
1017
1018 if ( false === $subscription ) {
1019 return 'Failed to get subscription.';
1020 }
1021
1022 if ( ! self::validate_subscription_status( $subscription ) ) {
1023 return 'This subscription status is not valid.';
1024 }
1025
1026 if ( ! self::validate_subscription_amount( $subscription, $atts['amount'] ) ) {
1027 return 'This subscription amount appears to be tampered with.';
1028 }
1029
1030 $sub_id = self::create_new_subscription( $subscription_id, $atts, $subscription );
1031
1032 self::$active_payment_source = FrmAppHelper::get_post_param( 'paypal_payment_source', '', 'sanitize_text_field' );
1033 self::$active_order_id = FrmAppHelper::get_post_param( 'paypal_order_id', '', 'sanitize_text_field' );
1034 self::$active_subscription = $subscription;
1035
1036 self::sync_entry_data_with_subscription_response( $subscription, $atts );
1037
1038 self::maybe_create_initial_subscription_payment( $subscription_id, $sub_id, $atts );
1039
1040 return true;
1041 }
1042
1043 /**
1044 * Create a new subscription record in the payments tables.
1045 *
1046 * @param string $subscription_id The PayPal subscription ID.
1047 * @param array $atts Includes 'entry', 'action', 'amount'.
1048 * @param object $subscription The PayPal subscription API response.
1049 *
1050 * @return int
1051 */
1052 private static function create_new_subscription( $subscription_id, $atts, $subscription ) {
1053 $next_bill_date = gmdate( 'Y-m-d' );
1054
1055 if ( ! empty( $subscription->billing_info->next_billing_time ) ) {
1056 $next_bill_date = gmdate( 'Y-m-d', strtotime( $subscription->billing_info->next_billing_time ) );
1057 }
1058
1059 $new_values = array(
1060 'amount' => FrmTransLiteAppHelper::get_formatted_amount_for_currency( $atts['amount'], $atts['action'] ),
1061 'paysys' => 'paypal',
1062 'item_id' => $atts['entry']->id,
1063 'action_id' => $atts['action']->ID,
1064 'sub_id' => $subscription_id,
1065 'interval_count' => $atts['action']->post_content['interval_count'],
1066 'time_interval' => $atts['action']->post_content['interval'],
1067 'status' => 'active',
1068 'next_bill_date' => $next_bill_date,
1069 'test' => 'test' === FrmPayPalLiteAppHelper::active_mode() ? 1 : 0,
1070 );
1071
1072 $frm_sub = new FrmTransLiteSubscription();
1073 return $frm_sub->create( $new_values );
1074 }
1075
1076 /**
1077 * Create the initial payment record for a new subscription.
1078 *
1079 * Uses the PayPal subscription ID as a temporary receipt_id. When the PAYMENT.SALE.COMPLETED
1080 * webhook arrives later, the receipt_id is updated to the real capture/sale ID.
1081 *
1082 * @since 6.31
1083 *
1084 * @param string $subscription_id The PayPal subscription ID.
1085 * @param int $sub_id The local subscription record ID.
1086 * @param array $atts Includes 'entry', 'action', 'amount'.
1087 *
1088 * @return void
1089 */
1090 private static function maybe_create_initial_subscription_payment( $subscription_id, $sub_id, $atts ) {
1091 $atts['status'] = 'complete';
1092 $atts['charge'] = new stdClass();
1093 $atts['charge']->id = $subscription_id;
1094 $atts['charge']->amount = $atts['amount'];
1095 $atts['charge']->sub_id = $sub_id;
1096
1097 $payment_id = self::create_new_payment( $atts );
1098 $frm_payment = new FrmTransLitePayment();
1099 $payment = $frm_payment->get_one( $payment_id );
1100 $status = $atts['status'];
1101
1102 FrmTransLiteActionsController::trigger_payment_status_change( compact( 'status', 'payment' ) );
1103 }
1104
1105 /**
1106 * Check if PayPal integration is enabled.
1107 *
1108 * @return bool true if PayPal is set up.
1109 */
1110 private static function paypal_is_configured() {
1111 return (bool) FrmPayPalLiteConnectHelper::get_merchant_id();
1112 }
1113
1114 /**
1115 * Convert the amount from 10.00 to 1000.
1116 *
1117 * @param mixed $amount
1118 * @param array $atts
1119 *
1120 * @return string
1121 */
1122 public static function prepare_amount( $amount, $atts = array() ) {
1123 $amount = parent::prepare_amount( $amount, $atts );
1124 $currency = self::get_currency_for_action( $atts );
1125 return number_format( floatval( $amount ), $currency['decimals'], '', '' );
1126 }
1127
1128 /**
1129 * If this form submits with ajax, load the scripts on the first page.
1130 *
1131 * @param array $params
1132 *
1133 * @return void
1134 */
1135 public static function maybe_load_scripts( $params ) {
1136 // phpcs:ignore Universal.Operators.StrictComparisons
1137 if ( $params['form_id'] == $params['posted_form_id'] ) {
1138 // This form has already been posted, so we aren't on the first page.
1139 return;
1140 }
1141
1142 $form = FrmForm::getOne( $params['form_id'] );
1143
1144 if ( ! $form ) {
1145 return;
1146 }
1147
1148 $credit_card_field = FrmField::getAll(
1149 array(
1150 'fi.form_id' => $form->id,
1151 'type' => 'credit_card',
1152 )
1153 );
1154
1155 if ( ! $credit_card_field ) {
1156 return;
1157 }
1158
1159 $payment_actions = self::get_actions_before_submit( $form->id );
1160
1161 if ( ! $payment_actions ) {
1162 return;
1163 }
1164
1165 $found_gateway = false;
1166
1167 foreach ( $payment_actions as $action ) {
1168 $gateways = $action->post_content['gateway'];
1169
1170 if ( in_array( 'paypal', (array) $gateways, true ) ) {
1171 $found_gateway = true;
1172 break;
1173 }
1174 }
1175
1176 if ( ! $found_gateway ) {
1177 return;
1178 }
1179
1180 self::load_scripts( (int) $form->id );
1181 }
1182
1183 /**
1184 * Load front end JavaScript for a PayPal form.
1185 *
1186 * @param int $form_id
1187 *
1188 * @return void
1189 */
1190 public static function load_scripts( $form_id ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, SlevomatCodingStandard.Functions.FunctionLength.FunctionLength, SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh, SlevomatCodingStandard.Files.LineLength.LineTooLong
1191 if ( FrmAppHelper::is_admin_page( 'formidable-entries' ) ) {
1192 return;
1193 }
1194
1195 if ( wp_script_is( 'formidable-paypal', 'enqueued' ) ) {
1196 return;
1197 }
1198
1199 if ( ! $form_id || ! is_int( $form_id ) ) {
1200 _doing_it_wrong( __METHOD__, '$form_id parameter must be a non-zero integer', '6.31' );
1201 return;
1202 }
1203
1204 $payment_action_by_id = array();
1205
1206 add_filter(
1207 'frm_trans_settings_for_js',
1208 /**
1209 * @param array $settings_for_action
1210 * @param WP_Post $payment_action
1211 *
1212 * @return array
1213 */
1214 function ( $settings_for_action, $payment_action ) use ( &$payment_action_by_id ) {
1215 $payment_action_by_id[ $payment_action->ID ] = $payment_action;
1216 $settings_for_action['paypalLayout'] = ! empty( $payment_action->post_content['paypal_layout'] ) ? $payment_action->post_content['paypal_layout'] : 'card_and_checkout'; // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong
1217 return $settings_for_action;
1218 },
1219 10,
1220 2
1221 );
1222
1223 $action_settings = self::prepare_settings_for_js( $form_id );
1224 $action_setting_match = false;
1225
1226 foreach ( $action_settings as $action ) {
1227 $gateways = $action['gateways'];
1228
1229 if ( ! $gateways || in_array( 'paypal', (array) $gateways, true ) ) {
1230 $action_setting_match = $action;
1231 break;
1232 }
1233 }
1234
1235 if ( false === $action_setting_match || ! array_key_exists( $action_setting_match['id'], $payment_action_by_id ) ) {
1236 return;
1237 }
1238
1239 $action = $payment_action_by_id[ $action_setting_match['id'] ];
1240
1241 // Use capture for one-time payments and subscription for recurring payments.
1242 $intent = $action->post_content['type'] === 'single' ? 'capture' : 'subscription';
1243
1244 /**
1245 * Build the PayPal SDK URL with required parameters.
1246 *
1247 * - Subscriptions require intent=subscription.
1248 * - Subscriptions also require vault=true.
1249 */
1250 $query_args = array(
1251 'client-id' => self::get_client_id(),
1252 'intent' => $intent,
1253 'currency' => strtoupper( $action->post_content['currency'] ?? 'USD' ),
1254 'merchant-id' => FrmPayPalLiteConnectHelper::get_merchant_id(),
1255 'enable-funding' => is_ssl() ? 'venmo,applepay' : 'venmo',
1256 );
1257
1258 if ( 'subscription' === $intent ) {
1259 $query_args['vault'] = 'true';
1260 }
1261
1262 if ( 'subscription' === $intent ) {
1263 $include_buttons = true;
1264 $include_card_fields = false;
1265 } else {
1266 // One time payments.
1267 $include_buttons = false;
1268 $include_card_fields = false;
1269
1270 switch ( $action->post_content['paypal_layout'] ?? 'card_and_checkout' ) {
1271 case 'card_only':
1272 $include_card_fields = true;
1273 break;
1274
1275 case 'checkout_only':
1276 $include_buttons = true;
1277 break;
1278
1279 default:
1280 $include_buttons = true;
1281 $include_card_fields = true;
1282 break;
1283 }
1284 }//end if
1285
1286 $include_messages = true;
1287
1288 switch ( $action->post_content['pay_later'] ?? 'auto' ) {
1289 case 'off':
1290 $query_args['disable-funding'] = 'paylater';
1291 break;
1292 case 'no-messaging':
1293 // PayPal throws a TypeError: can't access property "PAGE_TYPE", trackingDetails is undefined error
1294 // a lot of the time if you include messages. If you see this error, try using this 'no-messaging' option.
1295 $include_messages = false;
1296 break;
1297 }
1298
1299 $components = array();
1300 $supports_wallet_buttons = $include_buttons && 'subscription' !== $intent && is_ssl() && self::include_google_pay_apple_pay();
1301 $include_google_pay = $supports_wallet_buttons && self::include_google_pay();
1302 $include_apple_pay = $supports_wallet_buttons && self::include_apple_pay();
1303
1304 if ( $include_buttons ) {
1305 $components[] = 'buttons';
1306
1307 if ( $include_google_pay ) {
1308 $components[] = 'googlepay';
1309 }
1310
1311 if ( $include_apple_pay ) {
1312 $components[] = 'applepay';
1313 }
1314 }
1315
1316 if ( $include_card_fields ) {
1317 $components[] = 'card-fields';
1318 }
1319
1320 if ( $include_messages ) {
1321 $components[] = 'messages';
1322 }
1323
1324 // Enables .isEligible checks.
1325 $components[] = 'funding-eligibility';
1326
1327 // Required for radio button option logos.
1328 $components[] = 'marks';
1329
1330 $query_args['components'] = implode( ',', $components );
1331 $locale = self::get_paypal_locale();
1332
1333 if ( $locale ) {
1334 $query_args['locale'] = str_replace( '-', '_', $locale );
1335 }
1336
1337 /**
1338 * Allow customization of the PayPal SDK URL query arguments.
1339 *
1340 * @since 6.31
1341 *
1342 * @param array $query_args
1343 * @param WP_Post $action
1344 */
1345 $query_args = apply_filters( 'frm_paypal_sdk_url_query_args', $query_args, $action );
1346
1347 $sdk_url = add_query_arg( $query_args, 'https://www.paypal.com/sdk/js' );
1348
1349 wp_register_script( 'paypal-sdk', $sdk_url, array(), null, false );
1350
1351 if ( $include_apple_pay ) {
1352 wp_register_script( 'apple-pay-sdk', 'https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js', array(), null, false );
1353 }
1354
1355 $has_break = FrmAppHelper::pro_is_installed() && (bool) FrmField::get_all_types_in_form( $form_id, 'break' );
1356
1357 add_filter(
1358 'script_loader_tag',
1359 /**
1360 * @param string $tag
1361 * @param string $handle
1362 *
1363 * @return string
1364 */
1365 function ( $tag, $handle ) use ( $has_break ) {
1366 if ( 'paypal-sdk' === $handle ) {
1367 $attributes = ' async data-partner-attribution-id="' . esc_attr( FrmPayPalLiteConnectHelper::get_bn_code() ) . '"';
1368 return str_replace( ' src=', $attributes . ' src=', $tag );
1369 }
1370
1371 if ( in_array( $handle, array( 'apple-pay-sdk', 'google-pay' ), true ) ) {
1372 return str_replace( ' src=', ' async src=', $tag );
1373 }
1374
1375 if ( $has_break && 'formidable-paypal' === $handle ) {
1376 return str_replace( ' src=', ' async src=', $tag );
1377 }
1378
1379 return $tag;
1380 },
1381 10,
1382 2
1383 );
1384
1385 wp_enqueue_style(
1386 'formidable-paypal',
1387 FrmPayPalLiteAppHelper::plugin_url() . 'css/frontend.css',
1388 array(),
1389 FrmAppHelper::plugin_version()
1390 );
1391
1392 $dependencies = array( 'paypal-sdk', 'formidable' );
1393
1394 if ( $include_apple_pay ) {
1395 $dependencies[] = 'apple-pay-sdk';
1396 }
1397
1398 $script_url = FrmPayPalLiteAppHelper::plugin_url() . 'js/frontend.js';
1399
1400 wp_enqueue_script(
1401 'formidable-paypal',
1402 $script_url,
1403 $dependencies,
1404 FrmAppHelper::plugin_version(),
1405 false
1406 );
1407
1408 if ( $include_google_pay ) {
1409 wp_enqueue_script(
1410 'google-pay',
1411 'https://pay.google.com/gp/p/js/pay.js',
1412 array(),
1413 '1.0',
1414 false
1415 );
1416 }
1417
1418 $paypal_vars = array(
1419 'formId' => $form_id,
1420 'nonce' => wp_create_nonce( 'frm_paypal_ajax' ),
1421 'ajax' => esc_url_raw( FrmAppHelper::get_ajax_url() ),
1422 'settings' => $action_settings,
1423 'style' => self::get_style_for_js( $form_id ),
1424 'buttonStyle' => self::get_button_style_for_js( $action ),
1425 'imagesUrl' => FrmPayPalLiteAppHelper::plugin_url() . 'images/',
1426 'includeGooglePay' => $include_google_pay,
1427 'includeApplePay' => $include_apple_pay,
1428 'includeGooglePayApplePay' => $include_google_pay || $include_apple_pay,
1429 'mode' => FrmPayPalLiteAppHelper::active_mode(),
1430 );
1431
1432 wp_localize_script( 'formidable-paypal', 'frmPayPalVars', $paypal_vars );
1433 }
1434
1435 /**
1436 * Check if the Google Pay and Apple Pay wallet buttons are allowed at all.
1437 *
1438 * This gates both wallets together. To turn off just one of them, use
1439 * frm_paypal_commerce_include_google_pay or frm_paypal_commerce_include_apple_pay instead.
1440 *
1441 * @since 6.31
1442 *
1443 * @return bool
1444 */
1445 private static function include_google_pay_apple_pay() {
1446 /**
1447 * @since 6.31
1448 *
1449 * @param bool $include_google_pay_apple_pay
1450 */
1451 return (bool) apply_filters( 'frm_include_google_pay_apple_pay', true );
1452 }
1453
1454 /**
1455 * Check if the Google Pay wallet button is allowed for PayPal Commerce.
1456 *
1457 * Only called when frm_include_google_pay_apple_pay allows the wallets, so this
1458 * filter turns off Google Pay without affecting Apple Pay.
1459 *
1460 * @since 6.34
1461 *
1462 * @return bool
1463 */
1464 private static function include_google_pay() {
1465 /**
1466 * @since 6.34
1467 *
1468 * @param bool $include_google_pay
1469 */
1470 return (bool) apply_filters( 'frm_paypal_commerce_include_google_pay', true );
1471 }
1472
1473 /**
1474 * Check if the Apple Pay wallet button is allowed for PayPal Commerce.
1475 *
1476 * Only called when frm_include_google_pay_apple_pay allows the wallets, so this
1477 * filter turns off Apple Pay without affecting Google Pay.
1478 *
1479 * @since 6.34
1480 *
1481 * @return bool
1482 */
1483 private static function include_apple_pay() {
1484 /**
1485 * @since 6.34
1486 *
1487 * @param bool $include_apple_pay
1488 */
1489 return (bool) apply_filters( 'frm_paypal_commerce_include_apple_pay', true );
1490 }
1491
1492 /**
1493 * @since 6.31
1494 *
1495 * @return string
1496 */
1497 private static function get_paypal_locale() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, SlevomatCodingStandard.Functions.FunctionLength.FunctionLength
1498 $locale = str_replace( '_', '-', get_locale() );
1499 $parts = explode( '_', $locale );
1500 $lang = strtolower( $parts[0] );
1501 $country = isset( $parts[1] ) ? strtoupper( $parts[1] ) : '';
1502
1503 switch ( $lang ) {
1504 case 'ar':
1505 // Arabic
1506 $countries = array( 'DZ', 'BH', 'EG', 'JO', 'KW', 'MA', 'OM', 'QA', 'SA', 'TN', 'AE', 'YE' );
1507 break;
1508
1509 case 'bg':
1510 // Bulgarian
1511 $countries = array( 'BG' );
1512 break;
1513
1514 case 'cs':
1515 // Czech
1516 $countries = array( 'CZ' );
1517 break;
1518
1519 case 'da':
1520 // Danish
1521 $countries = array( 'DK', 'FO', 'GL' );
1522 break;
1523
1524 case 'de':
1525 // German
1526 $countries = array( 'AT', 'DE', 'LU', 'CH' );
1527 break;
1528
1529 case 'el':
1530 // Greek
1531 $countries = array( 'GR' );
1532 break;
1533
1534 case 'en':
1535 // English
1536 $countries = array(
1537 'AL',
1538 'DZ',
1539 'AD',
1540 'AO',
1541 'AI',
1542 'AG',
1543 'AR',
1544 'AM',
1545 'AW',
1546 'AU',
1547 'AT',
1548 'AZ',
1549 'BS',
1550 'BH',
1551 'BB',
1552 'BY',
1553 'BE',
1554 'BZ',
1555 'BJ',
1556 'BM',
1557 'BT',
1558 'BO',
1559 'BA',
1560 'BW',
1561 'BR',
1562 'VG',
1563 'BN',
1564 'BG',
1565 'BF',
1566 'BI',
1567 'KH',
1568 'CM',
1569 'CA',
1570 'CV',
1571 'KY',
1572 'TD',
1573 'CL',
1574 'C2',
1575 'CN',
1576 'CO',
1577 'KM',
1578 'CG',
1579 'CD',
1580 'CK',
1581 'CR',
1582 'CI',
1583 'HR',
1584 'CY',
1585 'CZ',
1586 'DK',
1587 'DJ',
1588 'DM',
1589 'DO',
1590 'EC',
1591 'EG',
1592 'SV',
1593 'ER',
1594 'EE',
1595 'SZ',
1596 'ET',
1597 'FK',
1598 'FO',
1599 'FJ',
1600 'FI',
1601 'FR',
1602 'GF',
1603 'PF',
1604 'GA',
1605 'GM',
1606 'GE',
1607 'DE',
1608 'GI',
1609 'GR',
1610 'GL',
1611 'GD',
1612 'GP',
1613 'GT',
1614 'GN',
1615 'GW',
1616 'GY',
1617 'HN',
1618 'HK',
1619 'HU',
1620 'IS',
1621 'IN',
1622 'ID',
1623 'IE',
1624 'IL',
1625 'IT',
1626 'JM',
1627 'JP',
1628 'JO',
1629 'KZ',
1630 'KE',
1631 'KI',
1632 'KW',
1633 'KG',
1634 'LA',
1635 'LV',
1636 'LS',
1637 'LI',
1638 'LT',
1639 'LU',
1640 'MG',
1641 'MW',
1642 'MY',
1643 'MV',
1644 'ML',
1645 'MT',
1646 'MH',
1647 'MQ',
1648 'MR',
1649 'MU',
1650 'MX',
1651 'FM',
1652 'MD',
1653 'MC',
1654 'MN',
1655 'ME',
1656 'MS',
1657 'MA',
1658 'MZ',
1659 'NA',
1660 'NR',
1661 'NP',
1662 'NL',
1663 'AN',
1664 'NC',
1665 'NZ',
1666 'NI',
1667 'NE',
1668 'NG',
1669 'NU',
1670 'NF',
1671 'MK',
1672 'NO',
1673 'OM',
1674 'PW',
1675 'PA',
1676 'PG',
1677 'PY',
1678 'PE',
1679 'PH',
1680 'PN',
1681 'PL',
1682 'PT',
1683 'QA',
1684 'RE',
1685 'RO',
1686 'RU',
1687 'RW',
1688 'WS',
1689 'SM',
1690 'ST',
1691 'SA',
1692 'SN',
1693 'RS',
1694 'SC',
1695 'SL',
1696 'SG',
1697 'SK',
1698 'SI',
1699 'SB',
1700 'SO',
1701 'ZA',
1702 'KR',
1703 'ES',
1704 'LK',
1705 'SH',
1706 'KN',
1707 'LC',
1708 'PM',
1709 'VC',
1710 'SR',
1711 'SJ',
1712 'SE',
1713 'CH',
1714 'TW',
1715 'TJ',
1716 'TZ',
1717 'TH',
1718 'TG',
1719 'TO',
1720 'TT',
1721 'TN',
1722 'TR',
1723 'TM',
1724 'TC',
1725 'TV',
1726 'UG',
1727 'UA',
1728 'AE',
1729 'GB',
1730 'US',
1731 'UY',
1732 'VU',
1733 'VA',
1734 'VE',
1735 'VN',
1736 'WF',
1737 'YE',
1738 'ZM',
1739 'ZW',
1740 );
1741 break;
1742
1743 case 'es':
1744 // Spanish
1745 $countries = array(
1746 'DZ',
1747 'AD',
1748 'AO',
1749 'AI',
1750 'AG',
1751 'AR',
1752 'AM',
1753 'AW',
1754 'AZ',
1755 'BS',
1756 'BH',
1757 'BB',
1758 'BZ',
1759 'BJ',
1760 'BM',
1761 'BO',
1762 'BW',
1763 'VG',
1764 'BF',
1765 'BI',
1766 'CV',
1767 'KY',
1768 'TD',
1769 'CL',
1770 'CO',
1771 'KM',
1772 'CG',
1773 'CD',
1774 'CK',
1775 'CR',
1776 'DJ',
1777 'DM',
1778 'DO',
1779 'EC',
1780 'EG',
1781 'SV',
1782 'ER',
1783 'SZ',
1784 'ET',
1785 'FK',
1786 'FO',
1787 'FJ',
1788 'PF',
1789 'GA',
1790 'GM',
1791 'GE',
1792 'GI',
1793 'GL',
1794 'GD',
1795 'GT',
1796 'GN',
1797 'GW',
1798 'GY',
1799 'HN',
1800 'IE',
1801 'JM',
1802 'JO',
1803 'KZ',
1804 'KE',
1805 'KI',
1806 'KW',
1807 'KG',
1808 'LS',
1809 'LI',
1810 'LU',
1811 'MG',
1812 'MW',
1813 'ML',
1814 'MH',
1815 'MR',
1816 'MU',
1817 'MX',
1818 'MS',
1819 'MA',
1820 'MZ',
1821 'NA',
1822 'NR',
1823 'AN',
1824 'NC',
1825 'NZ',
1826 'NI',
1827 'NE',
1828 'NU',
1829 'NF',
1830 'OM',
1831 'PW',
1832 'PA',
1833 'PG',
1834 'PY',
1835 'PE',
1836 'PN',
1837 'QA',
1838 'RW',
1839 'SM',
1840 'ST',
1841 'SA',
1842 'SN',
1843 'RS',
1844 'SC',
1845 'SL',
1846 'SB',
1847 'SO',
1848 'ZA',
1849 'ES',
1850 'SH',
1851 'KN',
1852 'LC',
1853 'PM',
1854 'VC',
1855 'SR',
1856 'SJ',
1857 'TJ',
1858 'TZ',
1859 'TG',
1860 'TT',
1861 'TN',
1862 'TM',
1863 'TC',
1864 'TV',
1865 'UG',
1866 'UA',
1867 'AE',
1868 'US',
1869 'UY',
1870 'VU',
1871 'VA',
1872 'VE',
1873 'WF',
1874 'YE',
1875 'ZM',
1876 );
1877 break;
1878
1879 case 'et':
1880 // Estonian
1881 $countries = array( 'EE' );
1882 break;
1883
1884 case 'fi':
1885 // Finnish
1886 $countries = array( 'FI' );
1887 break;
1888
1889 case 'fr':
1890 // French
1891 $countries = array(
1892 'DZ',
1893 'AD',
1894 'AO',
1895 'AI',
1896 'AG',
1897 'AM',
1898 'AW',
1899 'AZ',
1900 'BS',
1901 'BH',
1902 'BB',
1903 'BE',
1904 'BZ',
1905 'BJ',
1906 'BM',
1907 'BO',
1908 'BW',
1909 'VG',
1910 'BF',
1911 'BI',
1912 'CM',
1913 'CA',
1914 'CV',
1915 'KY',
1916 'TD',
1917 'CL',
1918 'CO',
1919 'KM',
1920 'CG',
1921 'CD',
1922 'CK',
1923 'CR',
1924 'CI',
1925 'DJ',
1926 'DM',
1927 'DO',
1928 'EC',
1929 'EG',
1930 'SV',
1931 'ER',
1932 'SZ',
1933 'ET',
1934 'FK',
1935 'FO',
1936 'FJ',
1937 'FR',
1938 'GF',
1939 'PF',
1940 'GA',
1941 'GM',
1942 'GE',
1943 'GI',
1944 'GL',
1945 'GD',
1946 'GP',
1947 'GT',
1948 'GN',
1949 'GW',
1950 'GY',
1951 'HN',
1952 'IE',
1953 'JM',
1954 'JO',
1955 'KZ',
1956 'KE',
1957 'KI',
1958 'KW',
1959 'KG',
1960 'LS',
1961 'LI',
1962 'LU',
1963 'MG',
1964 'MW',
1965 'ML',
1966 'MH',
1967 'MQ',
1968 'MR',
1969 'MU',
1970 'YT',
1971 'MC',
1972 'MS',
1973 'MA',
1974 'MZ',
1975 'NA',
1976 'NR',
1977 'AN',
1978 'NC',
1979 'NZ',
1980 'NI',
1981 'NE',
1982 'NU',
1983 'NF',
1984 'OM',
1985 'PW',
1986 'PA',
1987 'PG',
1988 'PE',
1989 'PN',
1990 'QA',
1991 'RE',
1992 'RW',
1993 'SC',
1994 'SM',
1995 'ST',
1996 'SA',
1997 'SN',
1998 'RS',
1999 'SL',
2000 'SB',
2001 'SO',
2002 'ZA',
2003 'SH',
2004 'KN',
2005 'LC',
2006 'PM',
2007 'VC',
2008 'SR',
2009 'SJ',
2010 'CH',
2011 'TJ',
2012 'TZ',
2013 'TG',
2014 'TT',
2015 'TN',
2016 'TM',
2017 'TC',
2018 'TV',
2019 'UG',
2020 'UA',
2021 'AE',
2022 'US',
2023 'UY',
2024 'VU',
2025 'VA',
2026 'VE',
2027 'WF',
2028 'YE',
2029 'ZM',
2030 );
2031 break;
2032
2033 case 'he':
2034 // Hebrew
2035 $countries = array( 'IL' );
2036 break;
2037
2038 case 'hu':
2039 // Hungarian
2040 $countries = array( 'HU' );
2041 break;
2042
2043 case 'id':
2044 // Indonesian
2045 $countries = array( 'ID' );
2046 break;
2047
2048 case 'it':
2049 // Italian
2050 $countries = array( 'IT' );
2051 break;
2052
2053 case 'ja':
2054 // Japanese
2055 $countries = array( 'JP' );
2056 break;
2057
2058 case 'ko':
2059 // Korean
2060 $countries = array( 'KR' );
2061 break;
2062
2063 case 'lt':
2064 // Lithuanian
2065 $countries = array( 'LT' );
2066 break;
2067
2068 case 'lv':
2069 // Latvian
2070 $countries = array( 'LV' );
2071 break;
2072
2073 case 'ms':
2074 // Malay
2075 $countries = array( 'BN', 'MY' );
2076 break;
2077
2078 case 'nl':
2079 // Dutch
2080 $countries = array( 'BE', 'NL' );
2081 break;
2082
2083 case 'no':
2084 // Norwegian
2085 $countries = array( 'NO' );
2086 break;
2087
2088 case 'pl':
2089 // Polish
2090 $countries = array( 'PL' );
2091 break;
2092
2093 case 'pt':
2094 // Portuguese
2095 $countries = array( 'BR', 'PT' );
2096 break;
2097
2098 case 'ro':
2099 // Romanian
2100 $countries = array( 'RO' );
2101 break;
2102
2103 case 'ru':
2104 // Russian
2105 $countries = array( 'EE', 'LV', 'LT', 'RU', 'UA' );
2106 break;
2107
2108 case 'si':
2109 // Sinhala
2110 $countries = array( 'LK' );
2111 break;
2112
2113 case 'sk':
2114 // Slovak
2115 $countries = array( 'SK' );
2116 break;
2117
2118 case 'sl':
2119 // Slovenian
2120 $countries = array( 'SI' );
2121 break;
2122
2123 case 'sq':
2124 // Albanian
2125 $countries = array( 'AL' );
2126 break;
2127
2128 case 'sv':
2129 // Swedish
2130 $countries = array( 'SE' );
2131 break;
2132
2133 case 'th':
2134 // Thai
2135 $countries = array( 'TH' );
2136 break;
2137
2138 case 'tl':
2139 // Tagalog
2140 $countries = array( 'PH' );
2141 break;
2142
2143 case 'tr':
2144 // Turkish
2145 $countries = array( 'TR' );
2146 break;
2147
2148 case 'vi':
2149 // Vietnamese
2150 $countries = array( 'VN' );
2151 break;
2152
2153 case 'zh':
2154 // Chinese
2155 $countries = array(
2156 'C2',
2157 'CN',
2158 'HK',
2159 'TW',
2160 'DZ',
2161 'AD',
2162 'AO',
2163 'AI',
2164 'AG',
2165 'AM',
2166 'AW',
2167 'AZ',
2168 'BS',
2169 'BH',
2170 'BB',
2171 'BZ',
2172 'BJ',
2173 'BM',
2174 'BO',
2175 'BW',
2176 'VG',
2177 'BF',
2178 'BI',
2179 'CV',
2180 'KY',
2181 'TD',
2182 'CL',
2183 'CO',
2184 'KM',
2185 'CG',
2186 'CD',
2187 'CK',
2188 'CR',
2189 'DJ',
2190 'DM',
2191 'DO',
2192 'EC',
2193 'EG',
2194 'SV',
2195 'ER',
2196 'SZ',
2197 'ET',
2198 'FK',
2199 'FO',
2200 'FJ',
2201 'PF',
2202 'GA',
2203 'GM',
2204 'GE',
2205 'GI',
2206 'GL',
2207 'GD',
2208 'GT',
2209 'GN',
2210 'GW',
2211 'GY',
2212 'HN',
2213 'IE',
2214 'JM',
2215 'JO',
2216 'KZ',
2217 'KE',
2218 'KI',
2219 'KW',
2220 'KG',
2221 'LS',
2222 'LI',
2223 'LT',
2224 'LU',
2225 'MG',
2226 'MW',
2227 'ML',
2228 'MH',
2229 'MR',
2230 'MU',
2231 'MS',
2232 'MA',
2233 'MZ',
2234 'NA',
2235 'NR',
2236 'AN',
2237 'NC',
2238 'NZ',
2239 'NI',
2240 'NE',
2241 'NU',
2242 'NF',
2243 'OM',
2244 'PW',
2245 'PA',
2246 'PG',
2247 'PE',
2248 'PN',
2249 'QA',
2250 'RW',
2251 'SM',
2252 'ST',
2253 'SA',
2254 'SN',
2255 'RS',
2256 'SC',
2257 'SL',
2258 'SB',
2259 'SO',
2260 'ZA',
2261 'SH',
2262 'KN',
2263 'LC',
2264 'PM',
2265 'VC',
2266 'SR',
2267 'SJ',
2268 'TJ',
2269 'TZ',
2270 'TG',
2271 'TT',
2272 'TN',
2273 'TM',
2274 'TC',
2275 'TV',
2276 'UG',
2277 'UA',
2278 'AE',
2279 'US',
2280 'UY',
2281 'VU',
2282 'VA',
2283 'VE',
2284 'WF',
2285 'YE',
2286 'ZM',
2287 );
2288 break;
2289
2290 default:
2291 $countries = array();
2292 break;
2293 }//end switch
2294
2295 if ( $country && in_array( $country, $countries, true ) ) {
2296 return $lang . '-' . $country;
2297 }
2298
2299 return 'en-US';
2300 }
2301
2302 /**
2303 * Get the style for the PayPal form.
2304 *
2305 * @param int $form_id
2306 *
2307 * @return array
2308 */
2309 public static function get_style_for_js( $form_id ) {
2310 $settings = self::get_style_settings_for_form( $form_id );
2311
2312 $style = array(
2313 'body' => array(
2314 'padding' => 0,
2315 ),
2316 'input' => array(
2317 'font-size' => $settings['field_font_size'],
2318 'color' => $settings['text_color'],
2319 'font-weight' => $settings['field_weight'],
2320 'padding' => $settings['field_pad'],
2321 'line-height' => 1.3,
2322 'border' => self::get_border_shorthand( $settings ),
2323 'border-radius' => self::get_border_radius( $settings ),
2324 ),
2325 'input::placeholder' => array(
2326 'color' => $settings['text_color_disabled'],
2327 ),
2328 '.invalid' => array(
2329 'color' => $settings['border_color_error'],
2330 ),
2331 );
2332
2333 if ( ! empty( $settings['font'] ) ) {
2334 $style['input']['font-family'] = $settings['font'];
2335 }
2336
2337 /**
2338 * Filter the PayPal card field styles.
2339 *
2340 * @since 6.31
2341 *
2342 * @param array $style
2343 * @param array $settings
2344 * @param int $form_id
2345 */
2346 return apply_filters( 'frm_paypal_style', $style, $settings, $form_id );
2347 }
2348
2349 /**
2350 * Get PayPal button style configuration from form action settings.
2351 * Documentation at https://developer.paypal.com/sdk/js/reference/#style
2352 *
2353 * @since 6.31
2354 *
2355 * @param WP_Post $form_action The form action containing button settings.
2356 *
2357 * @return array The style configuration array for PayPal button.
2358 */
2359 private static function get_button_style_for_js( $form_action ) {
2360 $button_color = $form_action->post_content['button_color'] ?? 'default';
2361 $button_label = $form_action->post_content['button_label'] ?? 'paypal';
2362 $button_border_radius = $form_action->post_content['button_border_radius'] ?? 10;
2363
2364 $style_for_js = array(
2365 'layout' => 'vertical',
2366 'color' => $button_color,
2367 'label' => $button_label,
2368 'borderRadius' => (int) $button_border_radius,
2369 'height' => 40,
2370 );
2371
2372 // Unset the color so PayPal can use its defaults.
2373 // Many buttons have different colors
2374 if ( 'default' === $button_color ) {
2375 unset( $style_for_js['color'] );
2376 }
2377
2378 /**
2379 * @since 6.31
2380 *
2381 * @param array $style_for_js
2382 * @param WP_Post $form_action
2383 */
2384 return apply_filters( 'frm_paypal_button_style', $style_for_js, $form_action );
2385 }
2386
2387 /**
2388 * Get and format the style settings for JavaScript to use with the get_style function.
2389 *
2390 * @param int $form_id
2391 *
2392 * @return array
2393 */
2394 private static function get_style_settings_for_form( $form_id ) {
2395 if ( ! $form_id ) {
2396 return array();
2397 }
2398
2399 $style = FrmStylesController::get_form_style( $form_id );
2400
2401 if ( ! $style ) {
2402 return array();
2403 }
2404
2405 $settings = FrmStylesHelper::get_settings_for_output( $style );
2406 $disallowed = array( ';', ':', '!important' );
2407
2408 foreach ( $settings as $k => $s ) {
2409 if ( is_string( $s ) ) {
2410 $settings[ $k ] = str_replace( $disallowed, '', $s );
2411 }
2412 }
2413
2414 return $settings;
2415 }
2416
2417 /**
2418 * Get the border width for PayPal card fields.
2419 *
2420 * @since 6.31
2421 *
2422 * @param array $settings
2423 *
2424 * @return string
2425 */
2426 private static function get_border_width( $settings ) {
2427 if ( ! empty( $settings['field_shape_type'] ) && 'underline' === $settings['field_shape_type'] ) {
2428 return '0 0 ' . $settings['field_border_width'] . ' 0';
2429 }
2430 return $settings['field_border_width'];
2431 }
2432
2433 /**
2434 * Get the border radius for PayPal card fields.
2435 *
2436 * @since 6.31
2437 *
2438 * @param array $settings
2439 *
2440 * @return string
2441 */
2442 private static function get_border_radius( $settings ) {
2443 if ( ! empty( $settings['field_shape_type'] ) ) {
2444 switch ( $settings['field_shape_type'] ) {
2445 case 'underline':
2446 case 'regular':
2447 return '0px';
2448 case 'circle':
2449 return '30px';
2450 }
2451 }
2452 return $settings['border_radius'];
2453 }
2454
2455 /**
2456 * Get the border shorthand for PayPal card fields.
2457 *
2458 * @since 6.31
2459 *
2460 * @param array $settings
2461 *
2462 * @return string
2463 */
2464 private static function get_border_shorthand( $settings ) {
2465 $width = self::get_border_width( $settings );
2466 $style = $settings['field_border_style'];
2467 $color = $settings['border_color'];
2468
2469 return "{$width} {$style} {$color}";
2470 }
2471
2472 /**
2473 * If the names are being used on the CC fields,
2474 * make sure it doesn't prevent the submission if PayPal has approved.
2475 *
2476 * @since 6.31
2477 *
2478 * @param array $errors
2479 * @param stdClass $field
2480 *
2481 * @return array
2482 */
2483 public static function remove_cc_validation( $errors, $field ) {
2484 $paypal_order_id = FrmAppHelper::get_post_param( 'paypal_order_id', '', 'sanitize_text_field' );
2485 $paypal_subscription_id = FrmAppHelper::get_post_param( 'paypal_subscription_id', '', 'sanitize_text_field' );
2486
2487 if ( ! $paypal_order_id && ! $paypal_subscription_id ) {
2488 return $errors;
2489 }
2490
2491 return FrmTransLiteActionsController::remove_cc_errors( $errors, $field );
2492 }
2493
2494 /**
2495 * @since 6.31
2496 *
2497 * @return void
2498 */
2499 public static function actions_js() {
2500 wp_enqueue_script(
2501 'frm_paypal_admin',
2502 FrmPayPalLiteAppHelper::plugin_url() . 'js/action.js',
2503 array( 'wp-hooks', 'wp-i18n' ),
2504 FrmAppHelper::plugin_version()
2505 );
2506 }
2507
2508 /**
2509 * Modify the new action post data to use the payment action type when the PayPal plugin is not active.
2510 * This works better than having it disabled even when PayPal is supported.
2511 *
2512 * @since 6.31
2513 *
2514 * @return void
2515 */
2516 public static function maybe_modify_new_action_post_data() {
2517 $action_type = FrmAppHelper::get_param( 'type', '', 'post', 'sanitize_text_field' );
2518
2519 if ( 'paypal-legacy' === $action_type ) {
2520 $_POST['type'] = 'paypal';
2521 return;
2522 }
2523
2524 if ( ! in_array( $action_type, array( 'paypal', 'stripe', 'square' ), true ) ) {
2525 return;
2526 }
2527
2528 $_POST['type'] = 'payment';
2529
2530 add_filter(
2531 'frm_form_payment_action_settings',
2532 /**
2533 * @param WP_Post $action_settings
2534 *
2535 * @return WP_Post
2536 */
2537 function ( $action_settings ) use ( $action_type ) {
2538 return self::set_gateway_as_default( $action_settings, $action_type );
2539 }
2540 );
2541 }
2542
2543 /**
2544 * Set the gateway to PayPal as the default.
2545 *
2546 * @param WP_Post $action_settings
2547 * @param string $action_type
2548 *
2549 * @return WP_Post
2550 */
2551 private static function set_gateway_as_default( $action_settings, $action_type ) {
2552 $action_settings->post_content['gateway'] = array( $action_type );
2553 return $action_settings;
2554 }
2555
2556 /**
2557 * Print additional options for Stripe action settings.
2558 *
2559 * @param array $atts
2560 *
2561 * @return void
2562 */
2563 public static function add_action_options( $atts ) {
2564 $form_action = $atts['form_action'];
2565 $action_control = $atts['action_control'];
2566
2567 include FrmPayPalLiteAppHelper::plugin_path() . '/views/settings/action-settings-options.php';
2568 }
2569
2570 /**
2571 * Print additional options for button settings.
2572 *
2573 * @since 6.31
2574 *
2575 * @param array|FrmFormAction $args_or_action_control Either the args array with form_action and action_control, or the action_control object.
2576 * @param WP_Post|null $form_action The form action object (when called directly).
2577 *
2578 * @return void
2579 */
2580 public static function add_button_settings_section( $args_or_action_control, $form_action = null ) {
2581 if ( is_array( $args_or_action_control ) ) {
2582 $form_action = $args_or_action_control['form_action'];
2583 $action_control = $args_or_action_control['action_control'];
2584 } else {
2585 $action_control = $args_or_action_control;
2586 }
2587
2588 include FrmPayPalLiteAppHelper::plugin_path() . '/views/settings/button-settings.php';
2589 }
2590
2591 /**
2592 * Add PayPal subscription settings (product name and product type) after payment type.
2593 *
2594 * @since 6.31
2595 *
2596 * @param array $args Arguments containing form_action and action_control.
2597 *
2598 * @return void
2599 */
2600 public static function add_paypal_subscription_settings_from_hook( $args ) {
2601 $form_action = $args['form_action'];
2602 $action_control = $args['action_control'];
2603 $product_type_value = $form_action->post_content['product_type'] ?? '';
2604 ?>
2605 <div class="frm_trans_sub_opts <?php echo $form_action->post_content['type'] === 'recurring' ? '' : 'frm_hidden'; ?>">
2606 <div class="frm_grid_container">
2607 <p class="frm6 show_paypal<?php FrmTransLitePaymentsController::maybe_hide_payment_setting( 'paypal', $form_action->post_content['gateway'] ); ?>">
2608 <label for="<?php echo esc_attr( $action_control->get_field_id( 'product_name' ) ); ?>">
2609 <?php esc_html_e( 'Product Name', 'formidable' ); ?> <span class="frm_required">*</span>
2610 </label>
2611 <?php // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong ?>
2612 <input type="text" name="<?php echo esc_attr( $action_control->get_field_name( 'product_name' ) ); ?>" id="<?php echo esc_attr( $action_control->get_field_id( 'product_name' ) ); ?>" value="<?php echo esc_attr( $form_action->post_content['product_name'] ?? '' ); ?>" class="frm_not_email_subject large-text" />
2613 </p>
2614
2615 <p class="frm6 show_paypal<?php FrmTransLitePaymentsController::maybe_hide_payment_setting( 'paypal', $form_action->post_content['gateway'] ); ?>">
2616 <label for="<?php echo esc_attr( $action_control->get_field_id( 'product_type' ) ); ?>">
2617 <?php esc_html_e( 'Product Type', 'formidable' ); ?>
2618 </label>
2619 <?php // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong ?>
2620 <select id="<?php echo esc_attr( $action_control->get_field_id( 'product_type' ) ); ?>" name="<?php echo esc_attr( $action_control->get_field_name( 'product_type' ) ); ?>">
2621 <option value="SERVICE" <?php selected( $product_type_value, 'SERVICE' ); ?>><?php esc_html_e( 'Service', 'formidable' ); ?></option>
2622 <option value="DIGITAL" <?php selected( $product_type_value, 'DIGITAL' ); ?>><?php esc_html_e( 'Digital', 'formidable' ); ?></option>
2623 <option value="PHYSICAL" <?php selected( $product_type_value, 'PHYSICAL' ); ?>><?php esc_html_e( 'Physical', 'formidable' ); ?></option>
2624 </select>
2625 </p>
2626 </div>
2627 </div>
2628 <?php
2629 }
2630
2631 /**
2632 * Filter PayPal payment action settings on save.
2633 * When entry_data_sync is set to 'new_fields', auto-create fields
2634 * for storing PayPal order data (email, name, address).
2635 *
2636 * @since 6.31
2637 *
2638 * @param array $settings The action settings being saved.
2639 * @param array $action The full action data including menu_order (form_id).
2640 *
2641 * @return array
2642 */
2643 public static function before_save_settings( $settings, $action ) {
2644 $gateway = ! empty( $settings['gateway'] ) ? (array) $settings['gateway'] : array();
2645 $is_paypal = in_array( 'paypal', $gateway, true );
2646
2647 if ( ! $is_paypal ) {
2648 return $settings;
2649 }
2650
2651 if ( empty( $settings['entry_data_sync'] ) || 'new_fields' !== $settings['entry_data_sync'] ) {
2652 return $settings;
2653 }
2654
2655 $form_id = absint( $action['menu_order'] );
2656
2657 $settings = self::maybe_create_order_data_field( $settings, $form_id, 'paypal_order_email', __( 'PayPal Email', 'formidable' ), 'email' );
2658 $settings = self::maybe_create_order_data_field( $settings, $form_id, 'paypal_order_name', __( 'PayPal Name', 'formidable' ), 'name' );
2659
2660 if ( is_callable( 'FrmProAddressesController::get_country_code' ) ) {
2661 return self::maybe_create_order_data_field( $settings, $form_id, 'paypal_order_address', __( 'PayPal Address', 'formidable' ), 'address' );
2662 }
2663
2664 return $settings;
2665 }
2666
2667 /**
2668 * Create a field for PayPal order data if it does not already exist.
2669 * The field ID is stored in the action settings under the given key.
2670 *
2671 * @since 6.31
2672 *
2673 * @param array $settings The action settings.
2674 * @param int $form_id The form ID.
2675 * @param string $setting_key The settings key to store the field ID in (e.g. 'paypal_order_email').
2676 * @param string $field_name The human-readable field name.
2677 * @param string $field_type The field type to create (e.g. 'hidden', 'name', 'address').
2678 *
2679 * @return array
2680 */
2681 private static function maybe_create_order_data_field( $settings, $form_id, $setting_key, $field_name, $field_type ) {
2682 if ( ! empty( $settings[ $setting_key ] ) ) {
2683 $existing = FrmField::getOne( (int) $settings[ $setting_key ] );
2684
2685 if ( $existing ) {
2686 return $settings;
2687 }
2688 }
2689
2690 $field_id = self::add_a_field( $form_id, $field_type, $field_name );
2691
2692 if ( ! $field_id ) {
2693 return $settings;
2694 }
2695
2696 $field = FrmField::getOne( $field_id );
2697
2698 if ( ! $field ) {
2699 return $settings;
2700 }
2701
2702 $field->field_options['is_paypal_order_field'] = 1;
2703
2704 FrmField::update( $field_id, array( 'field_options' => $field->field_options ) );
2705
2706 $settings[ $setting_key ] = $field_id;
2707
2708 return $settings;
2709 }
2710
2711 /**
2712 * @return string
2713 */
2714 private static function get_client_id() {
2715 if ( 'live' === FrmPayPalLiteAppHelper::active_mode() ) {
2716 return 'AZXLuLRGUnqHlo75Di6zRonnVy7mLBz-sXYIeHTmv9KsDIkNMp7au9rlapegcYKh8zhfPXvGdxal6PHG';
2717 }
2718
2719 return 'AYTiIIchQiekyGhJouWoLapPfjijirOtKHSN255SLhcP0TIaWBID-zxsYDaNmP4fXL6YcQxiSIMS0Lwu';
2720 }
2721
2722 /**
2723 * @param array $atts
2724 *
2725 * @return void
2726 */
2727 public static function show_paypal_button_settings( $atts ) {
2728 $form_action = $atts['form_action'];
2729 $action_control = $atts['action_control'];
2730
2731 // End the payment settings section.
2732 echo '</div>';
2733
2734 self::add_button_settings_section( $action_control, $form_action );
2735
2736 // Open up a div tag since the payment section is closed after this and we already ended the section.
2737 // This results in an empty div tag but it allows us to inject these options without requiring
2738 // any updates in the payments submodule.
2739 echo '<div>';
2740 }
2741 }
2742