#Nyxi
1 messages ยท Page 1 of 1 (latest)
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
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?
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
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?
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
Okay one sec. Let me check on something
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"
Thank god
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.
What is odd, is that we do populate a last_payment_error. See: https://dashboard.stripe.com/logs/req_7ZQv0FuADUuk77 for example
4** error ?
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.
ah 400-range
This doesn't feel right to me
Okay so I'm not crazy
I don't believe you are, no ๐
Okay good
But, I'm not 100%.
So how do we move forward? You do something or I change something?
Both really
Alright
I'm going to take this back internally
But for you, I'd recommend checking the PaymentIntent status when handleCardAction resolves
in the meantime I inspect last_payment_error ?
Yep exactly
But on the frontend?
You can check on frontend or backend, up to you really
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.
๐
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
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.
Alright
If you want to do that, email at https://support.stripe.com/contact/login and mention my handle bismarck in the email and let me know when you have sent it.
Then I'll go grab it
support@stripe.com auto-replied with "not monitored"
normally it would ask me to confirm I sent the email
not sure how to proceed
Did you go to the above link to send the email?
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.
there
Yeah I did this. It used to work.
I sent via the website form now
subject "Regarding a payment authorisation error"
Sounds good
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.
Before you update the PaymentIntent for a new PaymentMethod and confirm again
but wouldn't this break cases where there actually was a properly handled error before?
because the last_payment_error doesn't go away
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
So that would be frontend only in my case
I only do handleCardAction on the frontne
Ah yeah most likely actually. Since other cases would go to backend
this.$refs.nyxStripeComponent.handleCardAction(
response.scaResponse.clientSecret,
{ stripeAccount: response.scaResponse.stripeAccount },
)
.then(() => {
this.submitConfirmation(null);
})
.catch((err) => {
this.loading = false;
this.onCardError(err);
});
So yeah, I would add a short block in your handleCardAction to check the status
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
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);
maybe, but that just puts me back into
.then(() => {
this.submitConfirmation(null);
})
Since you don't get a result.error
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?
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);
});
},
Oh so if you confirm server-side and there is no next_action
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
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....
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
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
this goes before these
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD) {
$this->sqlObject->commit();
throw new NoPaymentMethodException();
}
(I do admit I'm having a hard time tracking all the code together so yeah let me know if incorrect)
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
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
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
K
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:
- Create payment method
- Post payment method
- Handle response with
scaResponseor go to 5 - Do 3DS; post null instead of payment method
- Handle response with no
scaResponse=> success
Okay pause
To clarify, the above snippet is the handleCardAction promise resolving, correct?
Wait no.
Sorry
it's the request to the backend from the vue application, the last snippet
I see
with the handleCardAction inbetween
Right
and it's the only place this.status = 'paid'
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
If you want, you can inspect the application for this exact payment attempt here: https://booking.nyxapp.net/confirm/637e11715095302cbf0584d81f79e2d9
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
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.
If it works without changing the backend code then it's not a problem
The link thing
Yep no change needed
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....
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
Can you show me PrepaymentResponse real quick? Can't recall if you already shared that
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';
}
Yeah nothing jumps out to me about that
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
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.
so that could at least rule out that it's the server sending something weird back
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
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
I'll try
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
Just before this.status = 'paid'
yeah ok
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
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
Sounds good. Not sure we will be able to tell much more at this point until we have some more info