meta; } $payment_source = $order_data['payment_source']; // Add the credit card holder name, e.g., John Doe. if ( ! empty( $this->entry['fields'][ $this->field['id'] ]['cardname'] ) ) { $payment_meta['credit_card_name'] = sanitize_text_field( $this->entry['fields'][ $this->field['id'] ]['cardname'] ); } // Add the credit card brand name, e.g., Visa, MasterCard, etc. if ( ! empty( $payment_source['card']['brand'] ) ) { $payment_meta['credit_card_method'] = sanitize_text_field( strtolower( $payment_source['card']['brand'] ) ); } // Add credit card last 4 digits, e.g., 1234, 5678, etc. if ( ! empty( $payment_source['card']['last_digits'] ) ) { $payment_meta['credit_card_last4'] = sanitize_text_field( $payment_source['card']['last_digits'] ); } // Add credit card expiry date, e.g., 2029-11, 2024-10, etc. if ( ! empty( $payment_source['card']['expiry'] ) ) { $payment_meta['credit_card_expires'] = sanitize_text_field( $payment_source['card']['expiry'] ); } return $payment_meta; } /** * Get the subscription period by plan id. * * @since 1.10.0 * * @param array $form_data Form data. * @param string $pp_plan_id Subscription plan id. * * @return string */ private function get_subscription_period( array $form_data, string $pp_plan_id ): string { $plan_setting = $this->get_plan_settings_by_plan_id( $form_data, $pp_plan_id ); return str_replace( '-', '', $plan_setting['recurring_times'] ?? '' ); } /** * Get the subscription total cycles by plan id. * * @since 1.10.0 * * @param array $form_data Form data. * @param string $pp_plan_id Subscription plan id. * * @return int */ private function get_subscription_total_cycles( array $form_data, string $pp_plan_id ): int { $plan_setting = $this->get_plan_settings_by_plan_id( $form_data, $pp_plan_id ); return (int) $plan_setting['total_cycles'] ?? 0; } /** * Get the subscription plan settings by plan id. * * @since 1.10.0 * * @param array $form_data Form data. * @param string $pp_plan_id Subscription plan id. * * @return array */ private function get_plan_settings_by_plan_id( array $form_data, string $pp_plan_id ): array { foreach ( $form_data['payments'][ PayPalCommerce::SLUG ]['recurring'] as $recurring ) { if ( $recurring['pp_plan_id'] !== $pp_plan_id ) { continue; } return $recurring; } return []; } /** * Check if the form has errors before payment processing. * * @since 1.10.0 * * @return bool */ private function is_form_processed(): bool { // Bail in case there are form processing errors. if ( ! empty( wpforms()->obj( 'process' )->errors[ $this->form_id ] ) ) { return false; } return $this->is_card_field_visibility_ok(); } /** * Check if there is at least one visible (not hidden by conditional logic) card field in the form. * * @since 1.10.0 * * @return bool */ private function is_card_field_visibility_ok(): bool { if ( empty( $this->field ) ) { return false; } // If the form contains no fields with conditional logic, the card field is visible by default. if ( empty( $this->form_data['conditional_fields'] ) ) { return true; } // If the field is NOT in the array of conditional fields, it's visible. if ( ! in_array( $this->field['id'], $this->form_data['conditional_fields'], true ) ) { return true; } // If the field IS in the array of conditional fields and marked as visible, it's visible. if ( ! empty( $this->field['visible'] ) ) { return true; } return false; } /** * Display form errors. * * @since 1.10.0 */ private function display_errors(): void { if ( ! $this->errors || ! is_array( $this->errors ) ) { return; } // Check if the form contains a required credit card. If it does // and there was an error, return the error to the user and prevent // the form from being submitted. This should not occur under normal // circumstances. if ( empty( $this->field ) || empty( $this->form_data['fields'][ $this->field['id'] ] ) ) { return; } if ( ! empty( $this->form_data['fields'][ $this->field['id'] ]['required'] ) ) { wpforms()->obj( 'process' )->errors[ $this->form_id ]['footer'] = implode( '
', $this->errors ); } } /** * Determine if payment saving allowed, by checking if the form has a payment field, and the API is available. * * @since 1.10.0 * * @return bool */ private function is_payment_saving_allowed(): bool { return ! empty( $this->field ) && $this->api; } /** * Check the submitted payment amount whether it was corrupted. * If so, throw an error and block submission. * * @since 1.10.0 * * @param array $entry Submitted entry data. * * @return bool */ private function is_submitted_payment_amount_corrupted( array $entry ): bool { $amount_corrupted = false; $source = ! empty( $entry['fields'][ $this->field['id'] ]['source'] ) ? $entry['fields'][ $this->field['id'] ]['source'] : ''; // Skip for Fastlane with since the order is not created yet. if ( $source === 'fastlane' ) { return false; } // Check form amount for a single payment. if ( ! empty( $entry['fields'][ $this->field['id'] ]['orderID'] ) ) { $order = $this->api->get_order( $this->entry['fields'][ $this->field['id'] ]['orderID'] ); // Add tax if it has been applied through WP filter. $tax_total = ! empty( $order['purchase_units'][0]['amount']['breakdown']['tax_total']['value'] ) ? (float) $order['purchase_units'][0]['amount']['breakdown']['tax_total']['value'] : 0; $submitted_amount = Helpers::format_amount_for_api_call( (float) $this->amount + $tax_total ); $amount_corrupted = ! empty( $order ) && (float) $submitted_amount !== (float) $order['purchase_units'][0]['amount']['value']; } // Check the form amount for subscription processor payment. if ( ! $amount_corrupted && ! empty( $entry['fields'][ $this->field['id'] ]['subscriptionProcessorID'] ) ) { $subscription_processor = $this->api->subscription_processor_get( $this->entry['fields'][ $this->field['id'] ]['subscriptionProcessorID'] ); // Add tax if it has been applied through WP filter. $tax_total = ! empty( $subscription_processor['purchase_units'][0]['amount']['breakdown']['tax_total']['value'] ) ? (float) $subscription_processor['purchase_units'][0]['amount']['breakdown']['tax_total']['value'] : 0; $submitted_amount = Helpers::format_amount_for_api_call( (float) $this->amount + $tax_total ); $amount_corrupted = ! empty( $subscription_processor ) && (float) $submitted_amount !== (float) $subscription_processor['purchase_units'][0]['amount']['value']; } // Check form amount for subscription payment. if ( ! $amount_corrupted && ! empty( $entry['fields'][ $this->field['id'] ]['subscriptionID'] ) ) { $subscription = $this->api->get_subscription( $entry['fields'][ $this->field['id'] ]['subscriptionID'], [ 'fields' => 'plan' ] ); $amount_corrupted = ! empty( $subscription ) && (float) $this->amount !== (float) $subscription['plan']['billing_cycles'][0]['pricing_scheme']['fixed_price']['value']; } // Prevent form submission and throw an error. if ( $amount_corrupted ) { wpforms()->obj( 'process' )->errors[ $this->form_id ]['footer'] = esc_html__( 'Irregular activity detected. Your submission has been declined.', 'wpforms-lite' ); return true; } return false; } /** * Log if more than one plan matched on the form submission. * * @since 1.10.0 * * @param string $matched_plan_id Already matched and executed plan. * * @noinspection PhpMissingParamTypeInspection */ protected function maybe_log_matched_subscriptions( $matched_plan_id ): void { } /** * Add a log record if a payment was stopped by conditional logic. * * @since 1.10.0 */ protected function maybe_add_conditional_logic_log(): void { } /** * Build Fastlane order payload to match ProcessSingleAjax::prepare_single_order_data(). * Intentionally skipping billing address as it comes with a single use token. * * @since 1.10.0 * * @param string $fastlane_token Fastlane single-use token. * * @return array */ private function build_fastlane_order_data( string $fastlane_token ): array { $settings = $this->form_data['payments'][ PayPalCommerce::SLUG ] ?? []; $this->currency = $this->get_currency(); $amount_string = Helpers::format_amount_for_api_call( (float) $this->amount ); $is_shipping_address = isset( $settings['shipping_address'] ) && $settings['shipping_address'] !== '' && $this->is_address_field_valid_from_fields( $settings['shipping_address'] ); $order_data = []; $order_data['intent'] = 'CAPTURE'; $order_data['application_context']['shipping_preference'] = $is_shipping_address ? 'SET_PROVIDED_ADDRESS' : 'NO_SHIPPING'; $order_data['application_context']['user_action'] = 'CONTINUE'; $order_data['purchase_units'][0] = [ 'amount' => [ 'value' => $amount_string, 'currency_code' => $this->currency, 'breakdown' => [ 'item_total' => [ 'value' => $amount_string, 'currency_code' => $this->currency, ], 'shipping' => [ 'value' => 0, 'currency_code' => $this->currency, ], ], ], 'description' => $this->get_order_description(), 'items' => $this->get_order_items(), 'shipping' => [ 'name' => [ 'full_name' => '', ], ], ]; if ( $is_shipping_address ) { $order_data['purchase_units'][0]['shipping']['address'] = $this->map_address_field_from_fields( $settings['shipping_address'] ); } // Build the payment source for the card (Fastlane token). $order_data['payment_source']['card'] = [ 'single_use_token' => $fastlane_token, 'attributes' => [ 'vault' => [ 'store_in_vault' => 'ON_SUCCESS', ], ], ]; /** * Allow 3rd-parties to filter Fastlane order data in the Process context. * * @since 1.10.0 * * @param array $order_data Order data. * @param array $form_data Form data. * @param float $amount Order amount. */ return (array) apply_filters( 'wpforms_paypal_commerce_process_fastlane_order_data', $order_data, $this->form_data, (float) $this->amount ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName } /** * Retrieve order items. * * @since 1.10.0 * * @return array */ protected function get_order_items(): array { /** * Filter order items types. * * @since 1.10.0 * * @param array $types The order items types. */ $types = (array) apply_filters( 'wpforms_paypal_commerce_process_single_ajax_get_types', wpforms_payment_fields() ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName $items = []; foreach ( $this->form_data['fields'] as $field_id => $field ) { if ( empty( $field['type'] ) || ! in_array( $field['type'], $types, true ) ) { continue; } // Skip the payment field that is not filled in or hidden by CL. if ( ! isset( $this->entry['fields'][ $field_id ] ) || wpforms_is_empty_string( $this->entry['fields'][ $field_id ] ) ) { continue; } $items = $this->prepare_order_line_item( $items, $field ); } return $items; } /** * Prepare order line item. * * @since 1.10.0 * * @param array $items Items. * @param array $field Field data. * * @return array */ protected function prepare_order_line_item( array $items, array $field ): array { $field_id = absint( $field['id'] ); $quantity = 1; $name = empty( $field['label'] ) ? sprintf( /* translators: %d - Field ID. */ esc_html__( 'Field #%d', 'wpforms-lite' ), $field_id ) : $field['label']; if ( ! empty( $field['enable_quantity'] ) ) { $quantity = isset( $this->entry['quantities'][ $field['id'] ] ) ? (int) $this->entry['quantities'][ $field['id'] ] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing } if ( ! $quantity ) { return $items; } if ( empty( $field['choices'] ) ) { $items[] = [ 'name' => wp_html_excerpt( $name, 124, '...' ), // Limit to 127 characters. 'quantity' => $quantity, 'unit_amount' => [ 'value' => Helpers::format_amount_for_api_call( wpforms_sanitize_amount( $this->entry['fields'][ $field_id ] ) ), 'currency_code' => $this->currency, ], ]; return $items; } $choices = ! is_array( $this->entry['fields'][ $field_id ] ) ? [ $this->entry['fields'][ $field_id ] ] : $this->entry['fields'][ $field_id ]; foreach ( $choices as $choice ) { if ( empty( $field['choices'][ $choice ] ) ) { continue; } $choice_name = empty( $field['choices'][ $choice ]['label'] ) ? sprintf( /* translators: %d - choice ID. */ esc_html__( 'Choice %d', 'wpforms-lite' ), absint( $choice ) ) : $field['choices'][ $choice ]['label']; $items[] = [ 'name' => wp_html_excerpt( $name . ': ' . $choice_name, 124, '...' ), // Limit to 127 characters. 'quantity' => $quantity, 'unit_amount' => [ 'value' => Helpers::format_amount_for_api_call( wpforms_sanitize_amount( $field['choices'][ $choice ]['value'] ) ), 'currency_code' => $this->currency, ], ]; } return $items; } /** * Retrieve the customer title associated with the processing method for the given order data. * * @since 1.10.0 * * @param array $order_data The order data used to determine the processing method. * * @return string */ private function get_customer_title_for_method( array $order_data ): string { $process_method = $this->get_supported_process_method_for_order( $order_data ); if ( ! $process_method ) { return ''; } return $process_method->get_customer_name( $order_data ); } /** * Sets the form field value using the appropriate processing method for the given order data. * * @since 1.10.0 * * @param array $order_data The order data used to determine the processing method and extract the field value. */ private function set_form_field_value_for_method( array $order_data ): void { $process_method = $this->get_supported_process_method_for_order( $order_data ); if ( ! $process_method ) { wpforms()->obj( 'process' )->fields[ $this->field['id'] ]['value'] = 'Checkout'; return; } wpforms()->obj( 'process' )->fields[ $this->field['id'] ]['value'] = $process_method->get_form_field_value( $order_data ); } }