ption->metadata['payment_id'] = $payment_id; $subscription->metadata['payment_url'] = esc_url_raw( $payment_url ); $subscription->update( $subscription->id, $subscription->serializeParameters(), Helpers::get_auth_opts() ); } wpforms()->obj( 'payment_meta' )->add_log( $payment_id, sprintf( 'Stripe charge processed. (Charge ID: %1$s)', isset( $payment->latest_charge ) ? $payment->latest_charge : $payment->id ) ); /** * Fire when processing is complete. * * @since 1.8.2 * * @param array $fields Final/sanitized submitted field data. * @param array $form_data Form data and settings. * @param int $payment_id Payment ID. * @param mixed $payment Stripe payment object. * @param mixed $subscription Stripe subscription object. * @param mixed $customer Stripe customer object. */ do_action( 'wpforms_stripe_process_complete', $fields, $form_data, $payment_id, $payment, $subscription, $this->api->get_customer() ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName } /** * Add details to payment data. * * @since 1.8.2 * * @param array $payment_data Payment data args. * * @return array */ public function prepare_payment_data( $payment_data ) { $payment = $this->api->get_payment(); if ( empty( $payment->id ) ) { return $payment_data; } $customer = $this->api->get_customer(); $subscription = $this->api->get_subscription(); $payment_data['status'] = 'processed'; $payment_data['gateway'] = 'stripe'; $payment_data['mode'] = Helpers::get_stripe_mode(); $payment_data['transaction_id'] = sanitize_text_field( $payment->id ); $payment_data['customer_id'] = ! empty( $customer->id ) ? sanitize_text_field( $customer->id ) : ''; $payment_data['title'] = $this->get_payment_title(); if ( ! empty( $subscription->id ) ) { $payment_data['subscription_id'] = sanitize_text_field( $subscription->id ); $payment_data['subscription_status'] = 'not-synced'; } return $payment_data; } /** * Get Payment title. * * @since 1.8.2 * * @return string Payment title. */ private function get_payment_title() { $customer_name = $this->get_customer_name(); if ( $customer_name ) { return $customer_name; } $customer_email = $this->get_customer_email(); if ( $customer_email ) { return $customer_email; } return ''; } /** * Get Customer name. * * @since 1.8.2 * * @return string Customer name. */ private function get_customer_name() { $customer_name = $this->api->get_customer( 'name' ); if ( $customer_name ) { return $customer_name; } $charge_details = $this->api->get_charge_details( [ 'name' ] ); if ( ! empty( $charge_details['name'] ) ) { return $charge_details['name']; } return ''; } /** * Get Customer email. * * @since 1.8.2 * * @return string Customer email. */ private function get_customer_email() { $customer_email = $this->api->get_customer( 'email' ); if ( $customer_email ) { return $customer_email; } $charge_details = $this->api->get_charge_details( [ 'email' ] ); if ( ! empty( $charge_details['email'] ) ) { return $charge_details['email']; } // phpcs:disable WordPress.Security.NonceVerification.Missing if ( isset( $_POST['wpforms']['payment_link_email'] ) ) { return sanitize_email( wp_unslash( $_POST['wpforms']['payment_link_email'] ) ); } // phpcs:enable WordPress.Security.NonceVerification.Missing return ''; } /** * Logic that helps decide if we should send completed payments notifications. * * @since 1.8.2 * * @param bool $process Whether to process or not. * @param array $fields Form fields. * @param array $form_data Form data. * @param int $notification_id Notification ID. * * @return bool */ public function process_email( $process, $fields, $form_data, $notification_id ) { if ( ! $process ) { return false; } if ( ! Helpers::has_stripe_enabled( [ $form_data ] ) ) { return $process; } if ( empty( $form_data['settings']['notifications'][ $notification_id ]['stripe'] ) ) { return $process; } if ( empty( $this->api->get_payment() ) ) { return false; } if ( ! $this->is_payment_processed ) { return false; } return empty( $this->api->get_error() ); } /** * Update entry details for a successful payment. * * @since 1.8.2 * * @param array $fields Final/sanitized submitted field data. * @param array $entry Copy of original $_POST. * @param array $form_data Form data and settings. * @param string $entry_id Entry ID. */ public function process_entry_data( $fields, $entry, $form_data, $entry_id ) { $payment = $this->api->get_payment(); if ( empty( $payment->id ) || empty( $entry_id ) ) { return; } wpforms()->obj( 'entry' )->update( $entry_id, [ 'type' => 'payment', ], '', '', [ 'cap' => false ] ); } /** * Get general errors before payment processing. * * @since 1.8.2 * * @return string */ protected function get_entry_errors() { // Check for Stripe payment tokens (card token or payment id). $error = $this->api->get_error(); // Check for Stripe keys. if ( ! Helpers::has_stripe_keys() ) { return esc_html__( 'Stripe payment stopped, missing keys.', 'wpforms-lite' ); } // Check that, despite how the form is configured, the form and // entry actually contain payment fields, otherwise no need to proceed. if ( ! wpforms_has_payment( 'form', $this->form_data ) || ! wpforms_has_payment( 'entry', $this->fields ) ) { return esc_html__( 'Stripe payment stopped, missing payment fields.', 'wpforms-lite' ); } // Check total charge amount. if ( empty( $this->amount ) || wpforms_sanitize_amount( 0 ) === $this->amount ) { return esc_html__( 'Stripe payment stopped, invalid/empty amount.', 'wpforms-lite' ); } if ( 50 > ( $this->amount * 100 ) ) { return esc_html__( 'Stripe payment stopped, amount less than minimum charge required.', 'wpforms-lite' ); } return $error; } /** * Process a payment. * * @since 1.8.2 */ public function process_payment() { if ( $this->is_api_errors() ) { return; } // Proceed to executing the purchase. if ( ! empty( $this->settings['enable_recurring'] ) || ! empty( $this->settings['recurring']['enable'] ) ) { $this->process_payment_subscription(); return; } $this->process_payment_single(); } /** * Process a subscription payment for forms with old payments interface. * * @since 1.8.4 */ protected function process_legacy_payment_subscription() { if ( ! $this->is_recurring_settings_ok( $this->settings['recurring'] ) ) { return; } $args = $this->get_base_subscription_args(); $args['settings'] = $this->settings['recurring']; $args['email'] = sanitize_email( $this->fields[ $args['settings']['email'] ]['value'] ); $args['customer_name'] = ! empty( $args['settings']['customer_name'] ) ? sanitize_text_field( $this->fields[ $args['settings']['customer_name'] ]['value'] ) : ''; // Customer address. if ( wpforms()->is_pro() && isset( $args['settings']['customer_address'] ) && $args['settings']['customer_address'] !== '' ) { $args['customer_address'] = $this->map_address_field( $this->fields[ $args['settings']['customer_address'] ], $args['settings']['customer_address'] ); } $this->process_subscription( $args ); // Set payment processing flag. $this->is_payment_processed = true; } /** * Process a single payment. * * @since 1.8.2 */ public function process_payment_single() { $amount_decimals = Helpers::get_decimals_amount(); // Define the basic payment details. $args = [ 'amount' => $this->amount * $amount_decimals, 'currency' => strtolower( wpforms_get_currency() ), 'metadata' => [ 'form_name' => sanitize_text_field( $this->form_data['settings']['form_title'] ), 'form_id' => $this->form_id, ], ]; if ( ! Helpers::is_license_ok() && Helpers::is_application_fee_supported() ) { $args['application_fee_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; } }