: str_replace(): Passing null to parameter #2 ($replace) of type array|string is deprecated in
* Finalize single payment after 3D Secure authorization is finished successfully.
* @throws ApiErrorException If the request fails.
protected function finalize_single() {
// Saving payment info is important for a future form entry meta update.
$this->intent = $this->retrieve_payment_intent( $this->payment_intent_id, [ 'expand' => [ 'customer' ] ] );
if ( $this->intent->status !== 'succeeded' ) {
// This error is unlikely to happen because the same check is done on a frontend.
$this->error = esc_html__( 'Stripe payment was not confirmed. Please check your Stripe dashboard.', 'wpforms-lite' );
// Saving customer and subscription info is important for a future form meta update.
$this->customer = $this->intent->customer;
* @param array $args Subscription arguments.
* @throws ApiErrorException If the request fails.
public function process_subscription( $args ) {
if ( $this->payment_method_id ) {
$this->charge_subscription( $args );
} elseif ( $this->payment_intent_id ) {
$this->finalize_subscription();
* Request a subscription charge to be made by Stripe.
* @param array $args Subscription payment arguments.
protected function charge_subscription( $args ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded
if ( empty( $this->payment_method_id ) ) {
$this->error = esc_html__( 'Stripe subscription stopped, missing PaymentMethod id.', 'wpforms-lite' );
'plan' => $this->get_plan_id( $args ),
'form_name' => $args['form_title'],
'form_id' => $args['form_id'],
'expand' => [ 'latest_invoice.payment_intent' ],
if ( isset( $args['application_fee_percent'] ) ) {
$sub_args['application_fee_percent'] = $args['application_fee_percent'];
$this->set_customer( $args['email'], $args['customer_name'] ?? '', $args['customer_address'] ?? [] );
$sub_args['customer'] = $this->get_customer( 'id' );
if ( Helpers::is_payment_element_enabled() ) {
$sub_args['payment_behavior'] = 'default_incomplete';
$sub_args['off_session'] = true;
$sub_args['payment_settings'] = [
'save_default_payment_method' => 'on_subscription',
if ( Helpers::is_link_supported() ) {
$sub_args['payment_settings']['payment_method_types'] = [ 'card', 'link' ];
$new_payment_method = $this->attach_customer_to_payment();
if ( is_null( $new_payment_method ) ) {
// Check whether a default PaymentMethod needs to be explicitly set.
$selected_payment_method_id = $this->select_subscription_default_payment_method( $new_payment_method );
if ( $selected_payment_method_id ) {
// Explicitly set a PaymentMethod for this Subscription because default Customer's PaymentMethod cannot be used.
$sub_args['default_payment_method'] = $selected_payment_method_id;
// Create the subscription.
$this->subscription = Subscription::create( $sub_args, Helpers::get_auth_opts() );
$this->intent = $this->subscription->latest_invoice->payment_intent;
if ( ! $this->intent || ! in_array( $this->intent->status, [ 'succeeded', 'requires_action', 'requires_confirmation', 'requires_payment_method' ], true ) ) {
$this->error = esc_html__( 'Stripe subscription stopped. invalid PaymentIntent status.', 'wpforms-lite' );
if ( $this->intent->status === 'succeeded' ) {
$this->set_bypass_captcha_3dsecure_token();
if ( in_array( $this->intent->status , [ 'requires_confirmation', 'requires_payment_method' ], true ) ) {
$this->request_confirm_payment_ajax( $this->intent );
$this->request_3dsecure_ajax( $this->intent );
} catch ( Exception $e ) {
$this->handle_exception( $e );
* Finalize a subscription after 3D Secure authorization is finished successfully.
* @throws ApiErrorException If the request fails.
protected function finalize_subscription() {
// Saving payment info is important for a future form entry meta update.
$this->intent = $this->retrieve_payment_intent( $this->payment_intent_id, [ 'expand' => [ 'invoice.subscription', 'customer' ] ] );
if ( $this->intent->status !== 'succeeded' ) {
// This error is unlikely to happen because the same check is done on a frontend.
$this->error = esc_html__( 'Stripe subscription was not confirmed. Please check your Stripe dashboard.', 'wpforms-lite' );
// Saving customer and subscription info is important for a future form meta update.
$this->customer = $this->intent->customer;
$this->subscription = $this->intent->invoice->subscription;
* Attach customer to payment method.
* @return PaymentMethod|null
private function attach_customer_to_payment() {
$payment_method = PaymentMethod::retrieve(
$this->payment_method_id,
// Attaching a PaymentMethod to a Customer validates CVC and throws an exception if PaymentMethod is invalid.
$payment_method->attach( [ 'customer' => $this->get_customer( 'id' ) ] );
} catch ( Exception $e ) {
$this->handle_exception( $e );
* Get saved Stripe PaymentIntent object or its key.
* @param string $key Name of the key to retrieve.
public function get_payment( $key = '' ) {
return $this->get_var( 'intent', $key );
* Get details from a saved Charge object.
* @param string|array $keys Key or an array of keys to retrieve.
public function get_charge_details( $keys ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
$charge = isset( $this->intent->charges->data[0] ) ? $this->intent->charges->data[0] : null;
if ( empty( $charge ) || empty( $keys ) ) {
if ( is_string( $keys ) ) {
foreach ( $keys as $key ) {
if ( isset( $charge->payment_method_details->card, $charge->payment_method_details->card->{$key} ) ) {
$result[ $key ] = sanitize_text_field( $charge->payment_method_details->card->{$key} );
if ( isset( $charge->payment_method_details->{$key} ) ) {
$result[ $key ] = sanitize_text_field( $charge->payment_method_details->{$key} );
if ( isset( $charge->billing_details->{$key} ) ) {
$result[ $key ] = sanitize_text_field( $charge->billing_details->{$key} );
* Request a frontend 3D Secure authorization from a user.
* @param PaymentIntent $intent PaymentIntent to authorize.
protected function request_3dsecure_ajax( $intent ) {
if ( ! isset( $intent->status, $intent->next_action->type ) ) {
if ( $intent->status !== 'requires_action' || $intent->next_action->type !== 'use_stripe_sdk' ) {
'action_required' => true,
'payment_intent_client_secret' => $intent->client_secret,
* Request a frontend payment confirmation from a user.
* @param PaymentIntent $intent PaymentIntent to authorize.
protected function request_confirm_payment_ajax( $intent ) {
'action_required' => true,
'payment_intent_client_secret' => $intent->client_secret,
* Select 'default_payment_method' for Subscription if it needs to be explicitly set
* and cleanup remote PaymentMethods in the process.
* @param PaymentMethod $new_payment_method PaymentMethod object.
* @throws Exception In case of Stripe API error.
protected function select_subscription_default_payment_method( $new_payment_method ) {
// Stripe does not set the first PaymentMethod attached to a Customer as Customer's 'default_payment_method'.
// Setting it manually if Customer's 'default_payment_method' is empty.
if ( isset( $new_payment_method->id ) && empty( $this->customer->invoice_settings->default_payment_method ) ) {
$this->update_remote_customer_default_payment_method( $new_payment_method->id );
// In this case Subscription's 'default_payment_method' doesn't have to be explicitly set and defaults to Customer's 'default_payment_method'.
// Return early if not a credit card is used for a payment ( e.g. Link ).
if ( ! isset( $new_payment_method->card->fingerprint ) ) {
$default_payment_method = PaymentMethod::retrieve(
$this->customer->invoice_settings->default_payment_method,
// Update Customer's 'default_payment_method' with a new PaymentMethod if it has the same fingerprint.
if ( isset( $new_payment_method->card->fingerprint, $default_payment_method->card->fingerprint ) && $new_payment_method->card->fingerprint === $default_payment_method->card->fingerprint ) {
$this->update_remote_customer_default_payment_method( $new_payment_method->id );
$default_payment_method->detach();
// In this case Subscription's 'default_payment_method' doesn't have to be explicitly set and defaults to Customer's 'default_payment_method'.
// In case Customer's 'default_payment_method' is set and its fingerprint doesn't match with a new PaymentMethod, several things need to be done:
// - Scan all active subscriptions for 'default_payment_method' with a same fingerprint as a new PaymentMethod.
// - Change all matching subscriptions 'default_payment_method' to a new PaymentMethod.
// - Delete all PaymentMethods previously set as 'default_payment_method' for matching subscriptions.
$this->detach_remote_subscriptions_duplicated_payment_methods( $new_payment_method );
// In this case Subscription's 'default_payment_method' has to be explicitly set
// because Customer's 'default_payment_method' contains a different PaymentMethod and cannot be defaulted to.
return $new_payment_method->id;
* Update 'default_payment_method' for a Customer stored on a Stripe side.
* @param string $payment_method_id PaymentMethod id.
* @throws Exception If a Customer fails to update.
protected function update_remote_customer_default_payment_method( $payment_method_id ) {
$this->get_customer( 'id' ),
'default_payment_method' => $payment_method_id,
* Detach all active Subscriptions PaymentMethods having the same fingerprint as a given PaymentMethod.
* @param PaymentMethod $new_payment_method PaymentMethod object.
* @throws Exception In case of Stripe API error.
protected function detach_remote_subscriptions_duplicated_payment_methods( $new_payment_method ) {
$subscriptions = Subscription::all(
'customer' => $this->get_customer( 'id' ),
'limit' => 100, // Maximum limit allowed by Stripe (https://stripe.com/docs/api/subscriptions/list#list_subscriptions-limit).
'expand' => [ 'data.default_payment_method' ],
foreach ( $subscriptions as $subscription ) {
if ( empty( $subscription->default_payment_method ) ) {
if ( $new_payment_method->card->fingerprint === $subscription->default_payment_method->card->fingerprint ) {
[ 'default_payment_method' => $new_payment_method->id ],
$detach_methods[ $subscription->default_payment_method->id ] = $subscription->default_payment_method;
foreach ( $detach_methods as $detach_method ) {
$detach_method->detach();
* Set an encrypted token as a PaymentIntent metadata item.
* @throws ApiErrorException In case payment intent save wasn't successful.
private function set_bypass_captcha_3dsecure_token() {
$form_data = wpforms()->get( 'process' )->form_data;
// Set token only if captcha is enabled for the form.
if ( empty( $form_data['settings']['recaptcha'] ) ) {
$this->intent->metadata['captcha_3dsecure_token'] = Crypto::encrypt( $this->intent->id );
$this->intent->update( $this->intent->id, $this->intent->serializeParameters(), Helpers::get_auth_opts() );
* Bypass CAPTCHA check on successful 3dSecure check.
* @param bool $is_bypassed True if CAPTCHA is bypassed.
* @param array $entry Form entry data.
* @param array $form_data Form data and settings.
* @throws ApiErrorException In case payment intent save wasn't successful.
public function bypass_captcha_on_3dsecure_submit( $is_bypassed, $entry, $form_data ) {
// Firstly, run checks that may prevent bypassing:
// 1) Sanity check to prevent possible tinkering with captcha on non-payment forms.
// 2) Both reCAPTCHA and hCaptcha are enabled by the same setting.
! Helpers::is_payments_enabled( $form_data ) ||
empty( $form_data['settings']['recaptcha'] ) ||
empty( $entry['payment_intent_id'] )
// This is executed before payment processing kicks in and fills `$this->intent`.
// PaymentIntent intent has to be retrieved from Stripe instead of getting it from `$this->intent`.
$intent = $this->retrieve_payment_intent( $entry['payment_intent_id'] );
if ( empty( $intent->status ) || $intent->status !== 'succeeded' ) {
$token = ! empty( $intent->metadata['captcha_3dsecure_token'] ) ? $intent->metadata['captcha_3dsecure_token'] : '';
if ( Crypto::decrypt( $token ) !== $intent->id ) {
// Cleanup the token to prevent its repeated usage and declutter the metadata.
$intent->metadata['captcha_3dsecure_token'] = null;
$intent->update( $intent->id, $intent->serializeParameters(), Helpers::get_auth_opts() );
* Retrieve Mandate object from Stripe.
* @param string $id Mandate id.
* @param array $args Additional arguments.
* @throws ApiErrorException If the request fails.
public function retrieve_mandate( string $id, array $args = [] ) {
$defaults = [ 'id' => $id ];
if ( isset( $args['mode'] ) ) {
$auth_opts = [ 'api_key' => Helpers::get_stripe_key( 'secret', $args['mode'] ) ];