get_one_by( $intent_id, 'receipt_id' ); if ( ! $payment ) { $redirect_helper->handle_error( 'no_payment_record' ); die(); } $intent = FrmStrpLiteAppHelper::call_stripe_helper_class( 'get_intent', $intent_id ); if ( ! is_object( $intent ) ) { $redirect_helper->handle_error( 'intent_does_not_exist' ); die(); } if ( $client_secret !== $intent->client_secret ) { // Do an extra check against the client secret so the request isn't as easy to spoof. $redirect_helper->handle_error( 'unable_to_verify' ); die(); } $status = 'succeeded' === $intent->status ? 'complete' : 'authorized'; $new_payment_values = (array) $payment; $new_payment_values['status'] = $status; if ( 'complete' === $status ) { $charge = reset( $intent->charges->data ); $new_payment_values['receipt_id'] = $charge->id; } $entry_id = $payment->item_id; $entry = FrmEntry::getOne( $entry_id ); if ( ! $entry ) { $redirect_helper->handle_error( 'no_entry_found' ); die(); } $redirect_helper->set_entry_id( $entry->id ); $action = FrmStrpLiteActionsController::get_stripe_link_action( $entry->form_id ); if ( ! $action ) { $redirect_helper->handle_error( 'no_stripe_link_action' ); die(); } $currency = FrmTransLiteAppHelper::get_action_setting( 'currency', array( 'payment' => $payment ) ); $currency = FrmCurrencyHelper::get_currency( $currency ); $actual_amount = intval( $intent->amount ); $expected_amount = round( floatval( $payment->amount ), 2 ); if ( 0 !== $currency['decimals'] ) { // Convert 10 to 1000 for example for Stripe. // But avoid for this a 0-decimal currency like JPY. $expected_amount *= 100; } $expected_amount = intval( round( $expected_amount ) ); if ( $expected_amount !== $actual_amount ) { $redirect_helper->handle_error( 'amount_mismatch' ); die(); } if ( 'succeeded' !== $intent->status ) { if ( 'processing' === $intent->status ) { FrmTransLitePaymentsController::change_payment_status( $payment, 'processing' ); $redirect_helper->handle_success( $entry, '' ); die(); } FrmTransLitePaymentsController::change_payment_status( $payment, 'failed' ); $payment_failed = 'requires_payment_method' === $intent->status && 'payment_intent_authentication_failure' === $intent->last_payment_error->code; $error_type = $payment_failed ? 'payment_failed' : 'did_not_complete'; $redirect_helper->handle_error( $error_type ); die(); } $status = 'succeeded' === $intent->status ? 'complete' : 'authorized'; $new_payment_values = compact( 'status' ); if ( 'complete' === $status ) { $charge = reset( $intent->charges->data ); $new_payment_values['receipt_id'] = $charge->id; } self::maybe_update_intent( $intent, $action, $entry ); // A webhook event may have already updated this payment, so check the status again before running triggers. $needs_triggers = $status !== $payment->status && self::payment_status_still_needs_to_update( $payment->id, $status ); $updated = $frm_payment->update( $payment->id, $new_payment_values ); if ( $needs_triggers && $updated ) { FrmTransLiteActionsController::trigger_payment_status_change( compact( 'status', 'payment' ) ); } $redirect_helper->handle_success( $entry, isset( $charge ) ? $charge->id : '' ); die(); } /** * Check that the payment status has not been updated by another request already. * This is to avoid running the payment actions twice. * * @since 6.35 * * @param int $payment_id The id of the payment to check. * @param string $status The status the payment is about to be updated to. * * @return bool */ private static function payment_status_still_needs_to_update( $payment_id, $status ) { $frm_payment = new FrmTransLitePayment(); $payment = $frm_payment->get_one( $payment_id ); return $payment && $payment->status !== $status; } /** * Try to add the description to a Stripe link payment after it was confirmed. * * @param object $intent * @param stdClass|WP_Post $action * @param stdClass $entry * * @return void */ private static function maybe_update_intent( $intent, $action, $entry ) { if ( empty( $action->post_content['description'] ) ) { return; } $shortcode_atts = array( 'entry' => $entry, 'form' => $entry->form_id, 'value' => $action->post_content['description'], ); $new_values = array( 'description' => FrmTransLiteAppHelper::process_shortcodes( $shortcode_atts ) ); FrmStrpLiteAppHelper::call_stripe_helper_class( 'update_intent', $intent->id, $new_values ); } /** * Handle return URL for a stripe link recurring payment which uses setup intents. * This will redirect and get handled by FrmStrpLiteAuth::maybe_show_message or possibly by redirected if there is a success URL set. * If the setup intent is completed, the subscription will be created as well. * * @since 6.5, introduced in v3.0 of the Stripe add on. * * @param string $setup_id * @param string $client_secret * * @return void */ private static function handle_recurring_stripe_link_return_url( $setup_id, $client_secret ) { $redirect_helper = new FrmStrpLiteLinkRedirectHelper( $setup_id, $client_secret ); $frm_payment = new FrmTransLitePayment(); $payment = $frm_payment->get_one_by( $setup_id, 'receipt_id' ); if ( ! is_object( $payment ) ) { $redirect_helper->handle_error( 'no_payment_record' ); die(); } // Verify the setup intent. $setup_intent = FrmStrpLiteAppHelper::call_stripe_helper_class( 'get_setup_intent', $setup_id ); if ( ! is_object( $setup_intent ) ) { $redirect_helper->handle_error( 'intent_does_not_exist' ); die(); } // Verify the client secret. if ( $setup_intent->client_secret !== $client_secret ) { $redirect_helper->handle_error( 'unable_to_verify' ); die(); } // Verify the entry. $entry = FrmEntry::getOne( $payment->item_id ); if ( ! is_object( $entry ) ) { $redirect_helper->handle_error( 'no_entry_found' ); die(); } $redirect_helper->set_entry_id( $entry->id ); // Verify it's an action with Stripe link enabled. $action = FrmStrpLiteActionsController::get_stripe_link_action( $entry->form_id ); if ( ! is_object( $action ) ) { $redirect_helper->handle_error( 'no_stripe_link_action' ); die(); } $customer_id = $setup_intent->customer; $payment_method_id = self::get_link_payment_method( $setup_intent ); if ( ! $payment_method_id ) { FrmTransLitePaymentsController::change_payment_status( $payment, 'failed' ); $redirect_helper->handle_error( 'did_not_complete' ); die(); } $amount = $payment->amount * 100; $new_charge = array( 'customer' => $customer_id, 'default_payment_method' => $payment_method_id, 'plan' => FrmStrpLiteSubscriptionHelper::get_plan_from_atts( array( 'action' => $action, 'amount' => $amount, ) ), 'expand' => array( 'latest_invoice.charge' ), ); if ( ! FrmStrpLitePaymentTypeHandler::should_use_automatic_payment_methods( $action ) ) { $new_charge['payment_settings'] = array( 'payment_method_types' => FrmStrpLitePaymentTypeHandler::get_payment_method_types( $action ), ); } $atts = array( 'action' => $action, 'entry' => $entry, ); $trial_end = FrmStrpLiteActionsController::get_trial_end_time( $atts ); if ( $trial_end ) { $new_charge['trial_end'] = $trial_end; } $subscription = FrmStrpLiteAppHelper::call_stripe_helper_class( 'create_subscription', $new_charge ); $subscription = FrmStrpLiteSubscriptionHelper::maybe_create_missing_plan_and_create_subscription( $subscription, $new_charge, $action, $amount ); if ( ! is_object( $subscription ) ) { $redirect_helper->handle_error( 'create_subscription_failed' ); die(); } if ( 'succeeded' !== $setup_intent->status ) { FrmTransLitePaymentsController::change_payment_status( $payment, 'failed' ); $redirect_helper->handle_error( 'payment_failed' ); die(); } $customer_has_been_charged = ! empty( $subscription->latest_invoice->charge ); $atts['charge'] = FrmStrpLiteSubscriptionHelper::prepare_charge_object_for_subscription( $subscription, $amount ); $new_payment_values = (array) $payment; if ( $customer_has_been_charged ) { $charge = $subscription->latest_invoice->charge; $new_payment_values['receipt_id'] = $charge->id; if ( 'failed' === $charge->status ) { FrmTransLitePaymentsController::change_payment_status( $payment, 'failed' ); $new_payment_values['receipt_id'] = $charge->id; $frm_payment->update( $payment->id, $new_payment_values ); $redirect_helper->handle_error( 'payment_failed', $charge->id ); } $new_payment_values['status'] = 'pending' === $charge->status ? 'processing' : 'complete'; $new_payment_values['expire_date'] = '0000-00-00'; foreach ( $subscription->latest_invoice->lines->data as $line ) { $new_payment_values['expire_date'] = gmdate( 'Y-m-d', $line->period->end ); } } elseif ( $trial_end ) { $new_payment_values['amount'] = 0; $new_payment_values['begin_date'] = gmdate( 'Y-m-d', time() ); $new_payment_values['expire_date'] = gmdate( 'Y-m-d', $trial_end ); }//end if $new_payment_values['sub_id'] = FrmStrpLiteSubscriptionHelper::create_new_subscription( $atts ); $frm_payment->update( $payment->id, $new_payment_values ); if ( $customer_has_been_charged ) { // Set the payment to complete. $status = 'complete'; FrmTransLiteActionsController::trigger_payment_status_change( compact( 'status', 'payment' ) ); // Update the next billing date. $next_bill_date = gmdate( 'Y-m-d' ); foreach ( $subscription->latest_invoice->lines->data as $line ) { $next_bill_date = gmdate( 'Y-m-d', $line->period->end ); } $frm_sub = new FrmTransLiteSubscription(); $frm_sub->update( $new_payment_values['sub_id'], array( 'next_bill_date' => $next_bill_date ) ); } $redirect_helper->handle_success( $entry, isset( $charge ) ? $charge->id : '' ); die(); } /** * Check for a link payment method associated with a customer for a Stripe link recurring payment/subscription. * This gets created on Stripe's end after confirmSetup is called client-side in the Stripe add on. * This is required in order to associate a payment method with the subscription that gets created. * * @since 6.5, introduced in v3.0 of the Stripe add on. * * @param object $setup_intent * * @return false|string */ private static function get_link_payment_method( $setup_intent ) { if ( is_object( $setup_intent->latest_attempt ) && ! empty( $setup_intent->latest_attempt->payment_method_details ) ) { $payment_method_details = $setup_intent->latest_attempt->payment_method_details; foreach ( array( 'ideal', 'sofort', 'bancontact' ) as $payment_method_type ) { if ( ! empty( $payment_method_details->$payment_method_type ) ) { return $payment_method_details->$payment_method_type->generated_sepa_debit; } } } if ( ! empty( $setup_intent->payment_method ) ) { return $setup_intent->payment_method; } return false; } /** * Create a pending Stripe link payment on entry creation. * Stripe link uses confirmPayment with a return URL which gets called after this. * The payment is then updated from pending status later in another request, either when the return URL is loaded or with a webhook. * * @since 6.5, introduced in v3.0 of the Stripe add on. * * @param array $atts { * The details needs to create a payment. * * @type stdClass $form * @type stdClass $entry * @type WP_Post $action * @type string $amount * @type object $customer * } * * @return bool True on success, false on failure. */ public static function create_pending_stripe_link_payment( $atts ) { if ( empty( $atts['form'] ) || empty( $atts['entry'] ) || empty( $atts['action'] ) || ! isset( $atts['amount'] ) || empty( $atts['customer'] ) ) { return false; } $form = $atts['form']; $action = $atts['action']; $intent_id = self::verify_intent( $form->id, $action ); if ( ! $intent_id ) { return false; } $is_setup_intent = str_starts_with( $intent_id, 'seti_' ); $entry = $atts['entry']; $amount = $atts['amount']; $customer = $atts['customer']; if ( ! $is_setup_intent ) { // Update the amount and set the customer before confirming the payment. $updated = FrmStrpLiteAppHelper::call_stripe_helper_class( 'update_intent', $intent_id, array( 'amount' => $amount, 'customer' => $customer->id, ) ); if ( ! $updated ) { return false; } } self::add_temporary_referer_meta( (int) $entry->id ); $frm_payment = new FrmTransLitePayment(); $payment_id = $frm_payment->create( array( 'paysys' => 'stripe', 'amount' => FrmTransLiteAppHelper::get_formatted_amount_for_currency( $amount, $action ), 'status' => 'pending', 'item_id' => $entry->id, 'action_id' => $action->ID, 'receipt_id' => $intent_id, 'sub_id' => '', 'test' => 'test' === FrmStrpLiteAppHelper::active_mode() ? 1 : 0, ) ); return (bool) $payment_id; } /** * Verify a payment intent or setup intent client secret is in the POST data and is valid. * * @since 6.5, introduced in v3.0 of the Stripe add on. * * @param int|string $form_id * @param WP_Post $action * * @return false|string String intent id on success, False if intent is missing or cannot be verified. */ private static function verify_intent( $form_id, $action ) { $client_secrets = FrmAppHelper::get_post_param( 'frmintent' . $form_id, array(), 'sanitize_text_field' ); if ( ! $client_secrets ) { return false; } $client_secret = reset( $client_secrets ); list( $prefix, $intent_id ) = explode( '_', $client_secret ); $intent_id = $prefix . '_' . $intent_id; $is_setup_intent = str_starts_with( $intent_id, 'seti_' ); $function_name = $is_setup_intent ? 'get_setup_intent' : 'get_intent'; $intent = FrmStrpLiteAppHelper::call_stripe_helper_class( $function_name, $intent_id ); if ( ! $intent || $intent->client_secret !== $client_secret || ! self::intent_matches_form_action( $intent, $action ) ) { return false; } if ( isset( $intent->charges ) && is_object( $intent->charges ) && ! empty( $intent->charges->data ) ) { // The intent should not have any charges yet. // If it does, the intent is invalid. return false; } $frm_payment = new FrmTransLitePayment(); $payment = $frm_payment->get_one_by( $intent_id, 'receipt_id' ); if ( $payment ) { // A duplicate payment should not exist. return false; } return $intent_id; } /** * Check if an intent matches a form action. * * @since 6.29 * * @param object $intent * @param WP_Post $action * * @return bool */ private static function intent_matches_form_action( $intent, $action ) { if ( ! isset( $intent->metadata ) || ! is_object( $intent->metadata ) || empty( $intent->metadata->action ) ) { // Avoid false positive if the intent is missing metadata. return true; } return (int) $intent->metadata->action === $action->ID; } /** * Set the referer URL as field ID 0 in entry meta. * This is required for iDEAL, sofort, and other payment methods that include an additional redirect step. * It is used for the redirect in FrmStrpLinkRedirectHelper. * It is deleted after the redirect happens. * * @param int $entry_id * * @return void */ private static function add_temporary_referer_meta( $entry_id ) { $referer = FrmAppHelper::get_server_value( 'HTTP_REFERER' ); $query_args_to_strip_from_referer = array( 'frm_link_error', 'payment_intent', 'payment_intent_client_secret', 'setup_intent', 'setup_intent_client_secret', ); foreach ( $query_args_to_strip_from_referer as $arg ) { $referer = remove_query_arg( $arg, $referer ); } $meta_value = json_encode( compact( 'referer' ) ); FrmEntryMeta::add_entry_meta( $entry_id, 0, '', $meta_value ); } /** * Flag a form with the frm_stripe_link_form class so it is identifiable when initializing in JavaScript. * * @since 6.5, introduced in v3.0 of the Stripe add on. * * @param stdClass $form * * @return void */ public static function add_form_classes( $form ) { if ( false === FrmStrpLiteActionsController::get_stripe_link_action( $form->id ) ) { return; } echo ' frm_stripe_link_form '; } /** * We need to force AJAX submit with Stripe link to avoid the page reloading before confirmPayment is called after entry creation. * * @since 6.5, introduced in v3.0 of the Stripe add on. * * @param mixed $form * * @return mixed */ public static function force_ajax_submit_for_stripe_link( $form ) { if ( ! is_object( $form ) ) { return $form; } if ( ! empty( $form->options['ajax_submit'] ) ) { // AJAX is already on so we can exit early. return $form; } if ( false !== FrmStrpLiteActionsController::get_stripe_link_action( $form->id ) ) { $form->options['ajax_submit'] = '1'; } return $form; } }