amount'] = (int) ( round( $this->amount * 0.03, 2 ) * $amount_decimals ); } // Payment description. if ( ! empty( $this->settings['payment_description'] ) ) { $args['description'] = html_entity_decode( $this->settings['payment_description'], ENT_COMPAT, 'UTF-8' ); } // Receipt email. if ( isset( $this->settings['receipt_email'] ) && $this->settings['receipt_email'] !== '' && ! empty( $this->fields[ $this->settings['receipt_email'] ]['value'] ) ) { $args['receipt_email'] = sanitize_email( $this->fields[ $this->settings['receipt_email'] ]['value'] ); } // Customer email. if ( isset( $this->settings['customer_email'] ) && $this->settings['customer_email'] !== '' && ! empty( $this->fields[ $this->settings['customer_email'] ]['value'] ) ) { $args['customer_email'] = sanitize_email( $this->fields[ $this->settings['customer_email'] ]['value'] ); } // Customer name. if ( isset( $this->settings['customer_name'] ) && $this->settings['customer_name'] !== '' && ! empty( $this->fields[ $this->settings['customer_name'] ]['value'] ) ) { $args['customer_name'] = sanitize_text_field( $this->fields[ $this->settings['customer_name'] ]['value'] ); } $args = $this->payment_single_map_address( $args ); $this->api->process_single( $args ); // Set payment processing flag. $this->is_payment_processed = true; $this->update_credit_card_field_value(); $this->process_api_error( 'single' ); } /** * Map address field for single payment. * * @since 1.9.0 * * @param array $args Payment arguments. * * @return array */ private function payment_single_map_address( array $args ): array { if ( ! wpforms()->is_pro() ) { return $args; } // Customer address. if ( isset( $this->settings['customer_address'] ) && $this->settings['customer_address'] !== '' ) { $args['customer_address'] = $this->map_address_field( $this->fields[ $this->settings['customer_address'] ], $this->settings['customer_address'] ); } // Shipping address. if ( isset( $this->settings['shipping_address'] ) && $this->settings['shipping_address'] !== '' ) { $args['shipping']['name'] = $args['customer_name'] ?? ''; $args['shipping']['address'] = $this->map_address_field( $this->fields[ $this->settings['shipping_address'] ], $this->settings['shipping_address'] ); } return $args; } /** * Process a subscription payment. * * @since 1.8.2 */ public function process_payment_subscription() { if ( Helpers::is_legacy_payment_settings( $this->form_data ) ) { $this->process_legacy_payment_subscription(); return; } $args = $this->get_base_subscription_args(); foreach ( $this->settings['recurring'] as $recurring ) { if ( ! $this->is_subscription_plan_valid( $recurring ) ) { continue; } $args['email'] = sanitize_email( $this->fields[ $recurring['email'] ]['value'] ); $args['settings'] = $recurring; $args['description'] = sanitize_text_field( $recurring['name'] ); // Customer name. if ( isset( $recurring['customer_name'] ) && $recurring['customer_name'] !== '' && ! empty( $this->fields[ $recurring['customer_name'] ]['value'] ) ) { $args['customer_name'] = sanitize_text_field( $this->fields[ $recurring['customer_name'] ]['value'] ); } // Customer address. if ( wpforms()->is_pro() && isset( $recurring['customer_address'] ) && $recurring['customer_address'] !== '' ) { $args['customer_address'] = $this->map_address_field( $this->fields[ $recurring['customer_address'] ], $recurring['customer_address'] ); } $this->process_subscription( $args ); return; } if ( ! empty( $this->settings['enable_one_time'] ) ) { $this->process_payment_single(); return; } $this->log_error( esc_html__( 'Stripe Subscription payment stopped validation error.', 'wpforms-lite' ), $this->fields, 'conditional_logic' ); } /** * Validate plan before process. * * @since 1.8.4 * * @param array $plan Plan settings. * * @return bool */ protected function is_subscription_plan_valid( $plan ) { return ! empty( $plan['email'] ) && $this->is_recurring_settings_ok( $plan ); } /** * Update the credit card field value to contain basic details. * * @since 1.8.2 */ public function update_credit_card_field_value() { foreach ( $this->fields as $field_id => $field ) { if ( empty( $field['type'] ) || $this->api->get_config( 'field_slug' ) !== $field['type'] ) { continue; } $details = $this->api->get_charge_details( [ 'name', 'last4', 'brand' ] ); if ( ! empty( $details['last4'] ) ) { $details['last4'] = 'xxxx xxxx xxxx ' . $details['last4']; } if ( ! empty( $details['brand'] ) ) { $details['brand'] = ucfirst( $details['brand'] ); } $details = is_array( $details ) && ! empty( $details ) ? implode( "\n", array_filter( $details ) ) : '-'; /** * This filter allows to overwrite a Style Credit Card value in saved entry. * * @since 1.8.2 * * @param array $details Card details. * @param object $payment Stripe payment objects. */ wpforms()->obj( 'process' )->fields[ $field_id ]['value'] = apply_filters( 'wpforms_stripe_creditcard_value', $details, $this->api->get_payment() ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName } } /** * Check if there is at least one visible (not hidden by conditional logic) card field in the form. * * @since 1.8.2 */ protected function is_card_field_visibility_ok() { // 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; } foreach ( $this->fields as $field ) { if ( empty( $field['type'] ) || $this->api->get_config( 'field_slug' ) !== $field['type'] ) { continue; } // if the field is NOT in array of conditional fields, it's visible. if ( ! in_array( $field['id'], $this->form_data['conditional_fields'], true ) ) { return true; } // if the field IS in array of conditional fields and marked as visible, it's visible. if ( ! empty( $field['visible'] ) ) { return true; } } return false; } /** * Log payment error. * * @since 1.8.2 * * @param string $title Error title. * @param string $message Error message. * @param string $level Error level to add to 'payment' error level. */ protected function log_error( $title, $message = '', $level = 'error' ) { if ( $message instanceof ApiErrorException ) { $body = $message->getJsonBody(); $message = isset( $body['error']['message'] ) ? $body['error'] : $message->getMessage(); } wpforms_log( $title, $message, [ 'type' => [ 'payment', $level ], 'form_id' => $this->form_id, ] ); } /** * Collect errors from API and turn it into form errors. * * @since 1.8.2 * * @param string $type Payment time (e.g. 'single' or 'subscription'). */ protected function process_api_error( $type ) { $message = $this->api->get_error(); if ( empty( $message ) ) { return; } $message = sprintf( /* translators: %s - error message. */ esc_html__( 'Payment Error: %s', 'wpforms-lite' ), $message ); $this->display_error( $message ); if ( $type === 'subscription' ) { $title = esc_html__( 'Stripe subscription payment stopped by error', 'wpforms-lite' ); } else { $title = esc_html__( 'Stripe payment stopped by error', 'wpforms-lite' ); } $this->log_error( $title, $this->api->get_exception() ); } /** * Display form error. * * @since 1.8.2 * * @param string $error Error to display. */ private function display_error( $error ) { if ( ! $error ) { return; } $field_slug = $this->api->get_config( 'field_slug' ); // 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. foreach ( $this->form_data['fields'] as $field ) { if ( empty( $field['type'] ) || $field_slug !== $field['type'] ) { continue; } if ( ! empty( $field['required'] ) ) { wpforms()->obj( 'process' )->errors[ $this->form_id ]['footer'] = $error; return; } } } /** * Process card error from Stripe API exception and adds rate limit tracking. * * @since 1.8.2 * * @param ApiErrorException $e Stripe API exception to process. */ public function process_card_error( $e ) { if ( Helpers::get_stripe_mode() === 'test' ) { return; } if ( ! is_a( $e, '\WPForms\Vendor\Stripe\Exception\CardException' ) ) { return; } /** * Allow to filter Stripe process card error. * * @since 1.8.2 * * @param bool $flag True if any error. */ if ( ! apply_filters( 'wpforms_stripe_process_process_card_error', true ) ) { // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName return; } $this->rate_limit->increment_attempts(); } /** * Check if rate limit is under threshold and passes. * * @since 1.8.2 */ protected function is_rate_limit_ok() { return $this->rate_limit->is_ok(); } /** * Check if any API errors occurs. * * @since 1.8.4 * * @return bool */ protected function is_api_errors() { $this->api->setup_stripe(); $error = $this->api->get_error(); if ( $error ) { $this->process_api_error( 'general' ); return true; } return false; } /** * Check if recurring settings is configured correctly. * * @since 1.8.4 * * @param {array} $settings Settings data. * * @return bool */ protected function is_recurring_settings_ok( $settings ) { $error = ''; // Check subscription settings are provided. if ( empty( $settings['period'] ) || empty( $settings['email'] ) ) { $error = esc_html__( 'Stripe subscription payment stopped, missing form settings.', 'wpforms-lite' ); } // Check for required customer email. if ( ! $error && empty( $this->fields[ $settings['email'] ]['value'] ) ) { $error = esc_html__( 'Stripe subscription payment stopped, customer email not found.', 'wpforms-lite' ); } // Before proceeding, check if any basic errors were detected. if ( $error ) { $this->log_error( $error ); $this->display_error( $error ); return false; } return true; } /** * Process subscription API call. * * @since 1.8.4 * * @param array $args Prepared subscription arguments. */ protected function process_subscription( $args ) { $this->subscription_settings = $args['settings']; if ( ! Helpers::is_license_ok() && Helpers::is_application_fee_supported() ) { $args['application_fee_percent'] = 3; } $this->api->process_subscription( $args ); // Set payment processing flag. $this->is_payment_processed = true; // Update the credit card field value to contain basic details. $this->update_credit_card_field_value(); $this->process_api_error( 'subscription' ); } /** * Get base subscription arguments. * * @since 1.8.4 * * @return array */ protected function get_base_subscription_args() { return [ 'form_id' => $this->form_id, 'form_title' => sanitize_text_field( $this->form_data['settings']['form_title'] ), 'amount' => $this->amount * Helpers::get_decimals_amount(), ]; } /** * Map WPForms Address field to Stripe format. * * @since 1.8.8 * * @param array $submitted_data Submitted address data. * @param string $field_id Address field ID. * * @return array */ private function map_address_field( array $submitted_data, string $field_id ): array { $line = sanitize_text_field( $submitted_data['address1'] ); $country = ''; if ( isset( $submitted_data['address2'] ) ) { $line .= ' ' . sanitize_text_field( $submitted_data['address2'] ); } if ( isset( $submitted_data['country'] ) ) { $country = sanitize_text_field( $submitted_data['country'] ); } elseif ( $this->form_data['fields'][ $field_id ]['scheme'] !== 'international' ) { $country = 'US'; } return [ 'line1' => $line, 'state' => isset( $submitted_data['state'] ) ? sanitize_text_field( $submitted_data['state'] ) : '', 'city' => sanitize_text_field( $submitted_data['city'] ), 'postal_code' => sanitize_text_field( $submitted_data['postal'] ), 'country' => $country, ]; } /** * Check the submitted payment data whether it was corrupted. * If so, refund a payment / cancel subscription. * * @since 1.8.8.2 * * @param array $entry Submitted entry data. * * @return bool */ private function is_submitted_payment_data_corrupted( array $entry ): bool { // Bail early if there are no payment intents. if ( empty( $entry['payment_intent_id'] ) ) { return false; } // Get stored corrupted payment intents if exist. $corrupted_intents = (array) Transient::get( 'corrupted-stripe-intents' ); // We must prevent a processing if payment intent was identified as corrupted. // Also if the transaction ID exists in DB (transaction ID is unique value). if ( in_array( $entry['payment_intent_id'], $corrupted_intents, true ) || wpforms()->obj( 'payment' )->get_by( 'transaction_id', $entry['payment_intent_id'] ) ) { wpforms()->obj( 'process' )->errors[ $this->form_id ]['footer'] = esc_html__( 'Secondary form submission was declined.', 'wpforms-lite' ); return true; } $intent = $this->api->retrieve_payment_intent( $entry['payment_intent_id'], [ 'expand' => [ 'invoice.subscription' ], ] ); // Round to the nearest whole number because $this->amount can contain a number close to, // but slightly under it, due to how it is stored in the memory. $submitted_amount = round( $this->amount * Helpers::get_decimals_amount() ); // Prevent form submission if a mismatch of the payment amount is detected. if ( ! empty( $intent ) && (int) $submitted_amount !== (int) $intent->amount ) { wpforms()->obj( 'process' )->errors[ $this->form_id ]['footer'] = esc_html__( 'Irregular activity detected. Your submission has been declined and payment refunded.', 'wpforms-lite' ); $args = [ 'reason' => 'fraudulent', ]; // We can't cancel a payment because it's already paid. // So we can perform a refund only. $this->api->refund_payment( $entry['payment_intent_id'], $args ); // Cancel subscription if exists. if ( ! empty( $intent->invoice->subscription ) ) { $this->api->cancel_subscription( $intent->invoice->subscription->id ); } // This payment indent is identified as corrupted. // Store it in order to prevent re-using it (form re-submitting). if ( ! in_array( $entry['payment_intent_id'], $corrupted_intents, true ) ) { $corrupted_intents[] = $entry['payment_intent_id']; Transient::set( 'corrupted-stripe-intents', $corrupted_intents, WEEK_IN_SECONDS ); } return true; } return false; } }