#Nyxi

1 messages ยท Page 1 of 1 (latest)

storm peakBOT
tired pine
#

Hey there

#

Looking

marsh fulcrum
#

It seems to somehow show the 3DS to the user, which the user fails for whatever reason, and then it still thinks it succeeded. This shouldn't be possible. Our code inspects the result of handleCardAction from the Stripe JS SDK and only return success when there is no .error property on the callback

#

But when I test it, it works fine with both failures and success, and this only happens sometimes; it works in production most of the time and we always force 3ds

#

Which makes me wonder if there is some weird bug in the stripe SDK or if I'm doing something wrong

tired pine
#

So yeah looks to me like they just failed 3DS here. The request you mentioned is just an internal request canceling the 3DS Source since 3DS failed.

#

When you say "think it succeeded" what does that mean exactly.

#

You are saying there is no error thrown so you determine it succeeded?

marsh fulcrum
#

Our frontend shows "payment success" which should only be happening on success

#

Yea, basically

#

I've been looking at the code for while and I just don't see how this is possible

#

I would assume if I had done something wrong, that any 3ds failure would trigger this problem, but it doesn't

#

When I test it, it correctly show me that 3ds failed and lets me retry, so I'm kind of stuck as I can't reproduce it at all

tired pine
#

Hmm yeah I'm surprised you aren't seeing an error on your end when 3DS fails here and the PaymentIntent moves back to requires_payment_method

#

Can you share your relevant code for how you are handling that?

marsh fulcrum
#

Yeah hold on

#

It's a bit complicated though because of the manual confirmation flow

#
submitConfirmation(paymentMethod) {
      PaymentService.submitConfirmation(this.$route.params.bookingID, {
        email: this.formData.email,
        paymentMethod,
        lastEdit: this.booking.lastEdit,
      })
        .then((response) => {
          if (response.scaResponse) {
            this.$refs.nyxStripeComponent.handleCardAction(
              response.scaResponse.clientSecret,
              { stripeAccount: response.scaResponse.stripeAccount },
            )
              .then(() => {
                this.submitConfirmation(null);
              })
              .catch((err) => {
                this.loading = false;
                this.onCardError(err);
              });
          } else {
            this.booking.receiptEmail = this.formData.email;
            this.status = 'paid';
          }
        })
        .catch((err) => {
          this.loading = false;
          this.handleError(err);
        });
    },

So if the response to the request to our backend here does not contain and scaResponse property - which only happens if the payment has succeeded or if no 3ds is required (which also triggers payment success). Somehow, it ends up in the block with this.status = 'paid' anyway

#

This is vue 2

#

The reason it recursively calls itself with null is to confirm the payment intent post 3ds, which the server does not do automatically

#

so when the server sees a request with no payment method, it looks up the payment intent and calls confirm on it on the backend

#

or errors if no payment intent exists or it is not in requires_confirmation state

#

and this works perfectly fine in most cases

#
handleCardAction(clientSecret, options = {}) {
      if (!this.stripe) {
        throw Error('Stripe must be loaded before \'handleCardAction\' can be called');
      }

      return new Promise((resolve, reject) => {
        this.getStripeInstance(this.stripePublicKey, options).handleCardAction(clientSecret)
          .then((result) => {
            if (result.error) {
              reject(result.error);
            } else {
              resolve(result.paymentIntent);
            }
          });
      });
    },

this is the service it calls to handle the card action

tired pine
#

Okay one sec. Let me check on something

marsh fulcrum
#

The backend will only send back a response with no scaResponse property if the payment is confirmed on the backend, which does not happen

#

As if it did, the payment would have gone through

#

I've been looking at this for a long time now and I'm drawing a blank on how this is possible

#

backend looks like this after it has verified a payment intent exists (or creates one):

<?php

// Before this, we either create a payment intent or update the existing one with a provided payment method

if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD) {

    $this->sqlObject->commit();
    throw new NoPaymentMethodException();

}

if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CONFIRMATION) {

    // Stripe account parameter not needed as object was created for the connected account.
    $paymentIntent = $paymentIntent->confirm(
        [
            'expand' => ['charges.data.balance_transaction'],
        ]
    );

}

if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_ACTION) {

    $this->sqlObject->commit();

    return new PrepaymentResponse(
        new SCAResponse(
            $paymentIntent->status,
            $paymentIntent->payment_method, $paymentIntent->client_secret, $fetch_stripe_user_id
        ), null
    );

}

/** @var Charge $charge */
$charge = $paymentIntent->charges->data[0];

if (!$charge->paid) {

    $this->sqlObject->commit();
    throw new PaymentException($charge->failure_message ?? 'Payment failed.', $charge->failure_code);

}

This is the only place in the code a PrepaymentResponse with SCAResponse is returned

#

I know this is hard without all the required context, but I'm running out of ideas

#

You can look at pi_3M7EskIFDslEWhBA1A2Cxi4W- this failed multiple times and then succeeded, but without giving a false success

#

same card issuer, same payment flow etc

#

Difference being that it has "payment failed" and not "customer failed 3ds"

tired pine
#

Yep okay

#

So I see what is going on

marsh fulcrum
#

Thank god

tired pine
#

Basically the difference is in how 3DS is failing

#

So for your first example (pi_3M7I3iIFDslEWhBA1FfQzdN2) I can see that 3DS actually fails due to an ACS Timeout. A timeout at the issuers side. When this happens, we are canceling the Source and 3DS fails. However, there is no actual 4** error being thrown since the source just gets canceled.

marsh fulcrum
#

4** error ?

tired pine
#

But based on there not being a 4** from 3DS actually failing due to the customer explicitly failing authentication, I suppose we aren't throwing an error when the promise resolves.

marsh fulcrum
#

ah 400-range

tired pine
#

This doesn't feel right to me

marsh fulcrum
#

Okay so I'm not crazy

tired pine
#

I don't believe you are, no ๐Ÿ™‚

marsh fulcrum
#

Okay good

tired pine
#

But, I'm not 100%.

marsh fulcrum
#

So how do we move forward? You do something or I change something?

tired pine
#

Both really

marsh fulcrum
#

Alright

tired pine
#

I'm going to take this back internally

#

But for you, I'd recommend checking the PaymentIntent status when handleCardAction resolves

marsh fulcrum
#

in the meantime I inspect last_payment_error ?

tired pine
#

Yep exactly

marsh fulcrum
#

But on the frontend?

tired pine
#

You can check on frontend or backend, up to you really

marsh fulcrum
#

Yeah alright

#

I'll see if I can work this out

tired pine
#

And yeah, I'll report this internally and get some more seasoned eyes on it. But I do think we are probably just not properly throwing a card error here.

marsh fulcrum
#

Okay, that's good

#

Or

#

For me that's good

tired pine
#

๐Ÿ‘

marsh fulcrum
#

Thanks. Can you ping me if you find something? Doesn't matter if it's a lot later

#

like CC me on the issue email-wise or whatever

tired pine
#

If you want, I can have you open a Support ticket that I'll grab and then I can follow up with you over email for updates.

marsh fulcrum
#

Alright

tired pine
#

Then I'll go grab it

marsh fulcrum
#

normally it would ask me to confirm I sent the email

#

not sure how to proceed

tired pine
#

Did you go to the above link to send the email?

marsh fulcrum
#

link just sends me to chat

#

okay I got it

tired pine
#

Hmm should be a way to email from the above link. If you attempt to just email support@stripe.com it won't work any more.

marsh fulcrum
#

there

marsh fulcrum
#

I sent via the website form now

#

subject "Regarding a payment authorisation error"

tired pine
#

Perfect I see it

#

I'll follow up over email

marsh fulcrum
#

Okay cool. thanks

#

I'll adjust the code in the meantime

tired pine
#

Sounds good

marsh fulcrum
#

I'm not sure where to adjust this. Any status of the payment intent that is not succeeded should not render a success on the frontend.

tired pine
#

Before you update the PaymentIntent for a new PaymentMethod and confirm again

marsh fulcrum
#

but wouldn't this break cases where there actually was a properly handled error before?

#

because the last_payment_error doesn't go away

tired pine
#

No I assume this would just be a check in the case that you don't get an error

#

So if the handleCardPayment promise resolves without an error

#

But the PaymentIntent isn't actually in requires_confirmation

marsh fulcrum
#

So that would be frontend only in my case

#

I only do handleCardAction on the frontne

tired pine
#

Ah yeah most likely actually. Since other cases would go to backend

marsh fulcrum
#
this.$refs.nyxStripeComponent.handleCardAction(
              response.scaResponse.clientSecret,
              { stripeAccount: response.scaResponse.stripeAccount },
            )
              .then(() => {
                this.submitConfirmation(null);
              })
              .catch((err) => {
                this.loading = false;
                this.onCardError(err);
              });
tired pine
#

So yeah, I would add a short block in your handleCardAction to check the status

marsh fulcrum
#

but if I submit here, it should never return again with a success unless the backend explicitly succeeded

#

so I don't see how it helps

tired pine
#

Isn't the issue here:

      if (!this.stripe) {
        throw Error('Stripe must be loaded before \'handleCardAction\' can be called');
      }

      return new Promise((resolve, reject) => {
        this.getStripeInstance(this.stripePublicKey, options).handleCardAction(clientSecret)
          .then((result) => {
            if (result.error) {
              reject(result.error);
            } else {
              resolve(result.paymentIntent);
            }
          });
      });
    },
#

In this code you are running resolve(result.paymentIntent);

marsh fulcrum
#

maybe, but that just puts me back into

.then(() => {
                this.submitConfirmation(null);
              })
tired pine
#

Since you don't get a result.error

marsh fulcrum
#

which on the backend would never result in a success

#

(unless it succeeds)

tired pine
#

What triggers the success message exactly? I thought it was the fact that you weren't seeing an explicit error from handleCardAction and thus you were presenting the success UI

#

You are explicitly checking for a status: succeeded PaymentIntent before presenting success?

marsh fulcrum
#

If a response from our server does not contain an SCA challenge

#

then it renders the success

#
submitConfirmation(paymentMethod) {
      NyxService.submitConfirmation(this.$route.params.bookingID, {
        email: this.formData.email,
        paymentMethod,
        lastEdit: this.booking.lastEdit,
      })
        .then((response) => {
          if (response.scaResponse) {
            this.$refs.nyxStripeComponent.handleCardAction(
              response.scaResponse.clientSecret,
              { stripeAccount: response.scaResponse.stripeAccount },
            )
              .then(() => {
                this.submitConfirmation(null);
              })
              .catch((err) => {
                this.loading = false;
                this.onCardError(err);
              });
          } else {
            this.booking.receiptEmail = this.formData.email;
            this.status = 'paid';
          }
        })
        .catch((err) => {
          this.loading = false;
          this.handleError(err);
        });
    },
tired pine
#

Oh so if you confirm server-side and there is no next_action

marsh fulcrum
#

we end in the this.status = 'paid' block

#

but this cannot be happening unless the server sees a successful payment

#

server codes looks like this:

if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_ACTION) {

    $this->sqlObject->commit();

    return new PrepaymentResponse(
        new SCAResponse(
            $paymentIntent->status,
            $paymentIntent->payment_method, $paymentIntent->client_secret, $fetch_stripe_user_id
        ), null
    );

}

/** @var Charge $charge */
$charge = $paymentIntent->charges->data[0];

if (!$charge->paid) {

    $this->sqlObject->commit();
    throw new PaymentException($charge->failure_message ?? 'Payment failed.', $charge->failure_code);

}
#

without a successful charge in the payment intent charges array, there is no way it can return a response to the frontend without the scaResponse property

#

which is what renders the success page

#

I simply don't know where to add a check

tired pine
#

Hmm yeah now I'm confused

#

Because you are checking server-side there

#

It is almost like your promise is resolving before the Source cancellation....?

#

So the PaymentIntent is still in requires_action

#

Don't see how that would be possible though....

marsh fulcrum
#

Somehow the server is returning a response with no scaResponse property without the payment being a success

#

and without an exception

#

and I keep going through this and I don't see how I get there

#

I can give you more code if you want

#

I just have to take out some parts that are not stripe-related

tired pine
#

Well wait that checks out. The scaResponse property is only if the PaymentIntent is in requires_action. In this case the PaymentIntent moves back to requires_payment_method without there being an exception

#

Since we don't see any exception thrown based on what we discussed initially

marsh fulcrum
#

this goes before these

#
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD) {

                $this->sqlObject->commit();
                throw new NoPaymentMethodException();

            }
tired pine
#

(I do admit I'm having a hard time tracking all the code together so yeah let me know if incorrect)

marsh fulcrum
#

Yeah I know it's a bit tricky

#

I'll send a complete one

#

hold on

tired pine
#

No that may be harder. Let's go piece by piece.

#

So first step: you confirm PaymentIntent and require 3DS

#

You then use handleCardAction on front-end

marsh fulcrum
#

hold on

#

Let me send you each part you mention

#

so you can verify that's actually happening

#

first request:

if ($request->getPaymentMethodId()) {

    $payment_method_connect = PaymentMethod::create(
        [
            'payment_method' => $request->getPaymentMethodId()
        ],
        [
            'stripe_account' => $fetch_stripe_user_id
        ]
    );

    $intentDescription = $fetch_table_date . ' - Table ' . $fetch_table_number . ', #' . $fetch_table_id;

    if (!$fetch_payment_intent_id) {

        // Create a payment intent:
        $paymentIntent = PaymentIntent::create(
            [
                // payment params
            ],
            [
                'stripe_account' => $fetch_stripe_user_id,
            ]
        );

        // sql that saves the payment intent here

    } else {

        $paymentIntent = PaymentIntent::update(
            $fetch_payment_intent_id,
            [
                // params shorntended, discord msg length
            ],
            [
                'stripe_account' => $fetch_stripe_user_id,
            ]
        );

    }

}
#

I had to take params out because of the 2000 character limit

#

but it's just amount, currency etc

tired pine
#

Yep I can see all that on my end

#

Keep going

marsh fulcrum
#

followed by:

else {

    if (!$fetch_payment_intent_id) {

        $this->sqlObject->commit();
        throw new NoPaymentMethodException();
    }

    $paymentIntent = PaymentIntent::retrieve(
        $fetch_payment_intent_id,
        [
            'stripe_account' => $fetch_stripe_user_id
        ]
    );

}

if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD) {

    $this->sqlObject->commit();
    throw new NoPaymentMethodException();

}

if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CONFIRMATION) {

    // Stripe account parameter not needed as object was created for the connected account.
    $paymentIntent = $paymentIntent->confirm(
        [
            'expand' => ['charges.data.balance_transaction'],
        ]
    );

}

if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_ACTION) {

    $this->sqlObject->commit();

    return new PrepaymentResponse(
        new SCAResponse(
            $paymentIntent->status,
            $paymentIntent->payment_method, $paymentIntent->client_secret, $fetch_stripe_user_id
        ), null
    );

}

/** @var Charge $charge */
$charge = $paymentIntent->charges->data[0];

if (!$charge->paid) {

    $this->sqlObject->commit();
    throw new PaymentException($charge->failure_message ?? 'Payment failed.', $charge->failure_code);

}

// If we make it to here, the payment is a success and a receipt is sent, which did **not** happen. A lot of SQL here
// followed by:

return new PrepaymentResponse(null, $paymentIntent->receipt_email);
#

the else being attached to the first if

#

I just couldn't send it in one message

#

so this is the entire flow

tired pine
#

K

marsh fulcrum
#

everything else is just SQL

#

between the charge and last return

#

so I took that out

#

and that did not run

#

and I have a catch that would take any kind of exception and not return a 200

#

which would hit the catch block on the frontend

#

of this:

.then((response) => {
          if (response.scaResponse) {
            this.$refs.nyxStripeComponent.handleCardAction(
              response.scaResponse.clientSecret,
              { stripeAccount: response.scaResponse.stripeAccount },
            )
              .then(() => {
                this.submitConfirmation(null);
              })
              .catch((err) => {
                this.loading = false;
                this.onCardError(err);
              });
          } else {
            this.booking.receiptEmail = this.formData.email;
            this.status = 'paid';
          }
        })
        .catch((err) => {
          this.loading = false;
          this.handleError(err);
        });
#

the outer one

#

and the flow is:

  1. Create payment method
  2. Post payment method
  3. Handle response with scaResponse or go to 5
  4. Do 3DS; post null instead of payment method
  5. Handle response with no scaResponse => success
tired pine
#

Okay pause

#

To clarify, the above snippet is the handleCardAction promise resolving, correct?

#

Wait no.

#

Sorry

marsh fulcrum
#

it's the request to the backend from the vue application, the last snippet

tired pine
#

I see

marsh fulcrum
#

with the handleCardAction inbetween

tired pine
#

Right

marsh fulcrum
#

and it's the only place this.status = 'paid'

tired pine
#

Yep k one sec

#

Let me compare to the PI and what I see

marsh fulcrum
#

I realize this isn't exactly beautiful, but it does work fine (except for this weird problem)

#

It was built before payment intents were a thing

#

so it was migrated from normal charges

#

which kind of convoluted the flow

#

also because the API and frontend are on different services and it's a Vue SPA

#

Obviously, paying it is not advised

#

๐Ÿ˜„

#

I also do not know what "link" "pay faster" is - it's not something I built or integrated, so it must come from the standard stripe.js thing

#

It just showed up on its own where Apple Pay would normally be

tired pine
#

Yeah we just added that to Payment Request button recently. You can turn it off via the Dashboard if you don't want it.

#

Still looking

#

But yeah... I'm a bit stumped now.

marsh fulcrum
#

If it works without changing the backend code then it's not a problem

#

The link thing

tired pine
#

Yep no change needed

marsh fulcrum
#

Cool

#

I also don't have any errors server-side

tired pine
#

But yeah... don't see how you would get to this.status = 'paid' without the PaymentIntent being in a status of succeeded since the only other option really should be requires_action

#

Which then you run handleCardAction....

marsh fulcrum
#

Yeah I'm also really confused

#

I've looked at this before and I chalked it up to a "one-off" weird error

#

but it has come back now

#

and I simply don't know what to change

#

The only thing I see is if somehow the server returns a 200-range response with no scaResponse property

#

without logging an error

#

which is just... not happening

tired pine
#

Can you show me PrepaymentResponse real quick? Can't recall if you already shared that

marsh fulcrum
#
class PrepaymentResponse implements OpenAPIDocument, JsonSerializable
{

    private ?SCAResponse $scaResponse;
    private ?string      $receiptEmail;

    public function __construct(?SCAResponse $SCAResponse, ?string $receiptEmail)
    {

        $this->scaResponse = $SCAResponse;
        $this->receiptEmail = $receiptEmail;
    }

    public function jsonSerialize(): array
    {

        return [
            'receipt_email' => $this->receiptEmail,
            'sca_response'  => $this->scaResponse
        ];
    }
}
#

The keys are automatically converted to camelCase

#

on the frontend

#

(otherwise it would never work)

#

and SCAResponse:

class SCAResponse implements JsonSerializable, OpenAPIDocument
{

    private ?string $paymentMethod;
    private string  $clientSecret;
    private ?string $stripeAccount;
    private string  $status;

    public function __construct(string $status, ?string $paymentMethod, string $clientSecret, ?string $stripeAccount)
    {

        $this->paymentMethod = $paymentMethod;
        $this->clientSecret = $clientSecret;
        $this->stripeAccount = $stripeAccount;
        $this->status = $status;
    }

    public function getStatus(): string
    {

        return $this->status;
    }

    public function jsonSerialize(): array
    {

        return [
            'status'         => $this->status,
            'payment_method' => $this->paymentMethod,
            'client_secret'  => $this->clientSecret,
            'stripe_account' => $this->stripeAccount
        ];
    }
}
#

these being used here on the frontend:

if (response.scaResponse) {
    this.$refs.nyxStripeComponent.handleCardAction(
        response.scaResponse.clientSecret,
        { stripeAccount: response.scaResponse.stripeAccount },
    )
        .then(() => {
            this.submitConfirmation(null);
        })
        .catch((err) => {
            this.loading = false;
            this.onCardError(err);
        });
} else {
    this.booking.receiptEmail = this.formData.email;
    this.status = 'paid';
}
tired pine
#

Yeah nothing jumps out to me about that

marsh fulcrum
#

You know what. I could change the last snippet to this:

if (response.scaResponse) {
    this.$refs.nyxStripeComponent.handleCardAction(
        response.scaResponse.clientSecret,
        { stripeAccount: response.scaResponse.stripeAccount },
    )
        .then(() => {
            this.submitConfirmation(null);
        })
        .catch((err) => {
            this.loading = false;
            this.onCardError(err);
        });
} else if (response.receiptEmail) {
    this.booking.receiptEmail = response.receiptEmail;
    this.status = 'paid';
} else {
   // must be error
}
#

because it should always return a receipt on the response on success

tired pine
#

Alright well yeah I'm stumped too. My recommendation at this point is to add logging for right before that if/else block for response.scaResponse. I'd recommend logging out an exact timestamp and the PaymentIntent status at that point.

marsh fulcrum
#

so that could at least rule out that it's the server sending something weird back

tired pine
#

Ah yeah that would be good too

#

If we can get an example of the PaymentIntent status not being in succeeded but reaching that else block to set status = 'paid' then we can take a look internally to determine whether this is somehow a bug on our end or not. And at least rule some stuff out from there.

#

I think adding the above to catch any issue from your server is smart as well

marsh fulcrum
#

I never actually set the receiptEmail property on the payment intent, so I'll have to change that to use the parameter the user sends:

#
return new PrepaymentResponse(null, $paymentIntent->receipt_email);
#

This is a leftover from when the email was on the payment intent, which triggered stripe receipts

#

but i'll just change that

#

It's not related to this issue

tired pine
#

So yeah the other option here to prevent this would be to retrieve the PaymentIntent on the frontend in that else block

#

And ensure that it is status: succeeded

marsh fulcrum
#

where i ahve "must be error" ?

#

in the snippet above

tired pine
#

Just before this.status = 'paid'

marsh fulcrum
#

yeah ok

tired pine
#

And if that is the case add a log and capture the exact timestamp that occurs

#

"that occurs" being that you check the PI status and it isn't succeeded just before setting this.status = paid

marsh fulcrum
#

Alright

#

I'll see if I can figure out a way to log this. We use Sentry for the frontend so it should be possible

tired pine
#

Sounds good. Not sure we will be able to tell much more at this point until we have some more info

marsh fulcrum
#

No that's alright

#

I was mainly looking for something obviously wrong

tired pine
#

But yeah at least you can prevent it from showing your success UI if you explicitly check the PI status there

#

If you do get a log and another example, pop back in here and tag me

#

Would love to take another look

marsh fulcrum
#

I will

#

Thanks for the help