if ( $message instanceof ApiErrorException ) {
$body = $message->getJsonBody();
$message = isset( $body['error']['message'] ) ? $body['error'] : $message->getMessage();
'type' => [ 'payment', $level ],
'form_id' => $this->form_id,
* Collect errors from API and turn it into form errors.
* @param string $type Payment time (e.g. 'single' or 'subscription').
protected function process_api_error( $type ) {
$message = $this->api->get_error();
if ( empty( $message ) ) {
/* translators: %s - error message. */
esc_html__( 'Payment Error: %s', 'wpforms-lite' ),
$this->display_error( $message );
if ( $type === 'subscription' ) {
$title = esc_html__( 'Stripe subscription payment stopped by error', 'wpforms-lite' );
$title = esc_html__( 'Stripe payment stopped by error', 'wpforms-lite' );
$this->log_error( $title, $this->api->get_exception() );
* @param string $error Error to display.
private function display_error( $error ) {
$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
foreach ( $this->form_data['fields'] as $field ) {
if ( empty( $field['type'] ) || $field_slug !== $field['type'] ) {
if ( ! empty( $field['required'] ) ) {
wpforms()->obj( 'process' )->errors[ $this->form_id ]['footer'] = $error;
* Process card error from Stripe API exception and adds rate limit tracking.
* @param ApiErrorException $e Stripe API exception to process.
public function process_card_error( $e ) {
if ( Helpers::get_stripe_mode() === 'test' ) {
if ( ! is_a( $e, '\WPForms\Vendor\Stripe\Exception\CardException' ) ) {
* Allow to filter Stripe process card error.
* @param bool $flag True if any error.
if ( ! apply_filters( 'wpforms_stripe_process_process_card_error', true ) ) { // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
$this->rate_limit->increment_attempts();
* Check if rate limit is under threshold and passes.
protected function is_rate_limit_ok() {
return $this->rate_limit->is_ok();
* Check if any API errors occurs.
protected function is_api_errors() {
$this->api->setup_stripe();
$error = $this->api->get_error();
$this->process_api_error( 'general' );
* Check if recurring settings is configured correctly.
* @param {array} $settings Settings data.
protected function is_recurring_settings_ok( $settings ) {
// 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.
$this->log_error( $error );
$this->display_error( $error );
* Process subscription API call.
* @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;
// Store spam reason if exists.
if ( isset( $this->form_data['spam_reason'] ) ) {
$args['metadata']['spam_reason'] = $this->form_data['spam_reason'];
$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.
protected function get_base_subscription_args() {
'form_id' => $this->form_id,
'form_title' => sanitize_text_field( $this->form_data['settings']['form_title'] ),
'amount' => $this->amount * wpforms_get_currency_multiplier(),
* Map WPForms Address field to Stripe format.
* @param array $submitted_data Submitted address data.
* @param string $field_id Address field ID.
private function map_address_field( array $submitted_data, string $field_id ): array {
$line = sanitize_text_field( $submitted_data['address1'] );
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' ) {
'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'] ),
* Check the submitted payment data whether it was corrupted.
* If so, refund a payment / cancel subscription.
* @param array $entry Submitted entry data.
private function is_submitted_payment_data_corrupted( array $entry ): bool {
// Bail early if there are no payment intents.
if ( empty( $entry['payment_intent_id'] ) ) {
// 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' );
$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 * wpforms_get_currency_multiplier() );
// 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' );
'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 );
* Maybe create a subscription schedule if the cycles was set.
* @param object $subscription Stripe subscription object.
private function maybe_set_subscription_schedule( object $subscription ): void {
if ( empty( $subscription->metadata['cycles'] ) || $subscription->metadata['cycles'] === 'unlimited' || (int) $subscription->metadata['cycles'] < 1 || empty( $subscription->items->data ) ) {
$schedule = SubscriptionSchedule::create(
'from_subscription' => $subscription->id,
$subscription_item = $subscription->items->data[0];
'end_behavior' => 'cancel',
'start_date' => $subscription_item->current_period_start,
'plan' => $subscription_item->plan->id,
'iterations' => $subscription->metadata['cycles'],
} catch ( \Exception $e ) {
'Stripe: Unable to create Subscription Schedule.',
'type' => [ 'payment', 'error' ],