'Database query returns null for 3D secure payments
I have a Stripe integration set up that uses Payment Intents and Stripe Elements' Payment Element, as outlined in the Quickstart guide in the documentation. I also have webhooks set up via Spatie's Stripe Webhooks package to retrieve and persist the remaining details of the Payment Intent (details that are not available at the time of payment, hence the webhook).
All of this works perfectly until I use the regulatory test cards to test 3D secure (which we need, as we are based in and will primarily serve customers from Europe).
When I use these 3D secure cards to test my payment element and click Complete Authentication, the database query that usually retrieves an instance of the Transaction model simply returns null.
According to the Stripe dev on customer support that I contacted yesterday, the Quickstart integration should support 3D secure out of the box without further work, although he did also say that the webhook event responses in my Stripe logs were returning as successful when they're actually returning with a status of requires_action, so maybe I just ended up with someone who doesn't know what they're talking about.
Here is the client-side code:
// This is your test publishable API key.
const stripe = Stripe(<TEST_KEY>);
const fonts = [
{
cssSrc:
"https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap",
},
];
let elements;
initialize();
checkStatus();
document
.querySelector("#payment-form")
.addEventListener("submit", handleSubmit);
// Fetches a payment intent and captures the client secret
async function initialize() {
const { clientSecret } = await fetch("/payment/stripe", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": document.querySelector('input[name="_token"]').value,
},
}).then((r) => r.json());
elements = stripe.elements({ fonts, clientSecret });
const paymentElement = elements.create("payment");
paymentElement.mount("#payment-element");
paymentElement.on("ready", function () {
document.querySelector(".loading-spinner").style.display = "none";
});
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: "http://localhost.rc/success",
},
});
// This point will only be reached if there is an immediate error when
// confirming the payment. Otherwise, your customer will be redirected to
// your `return_url`. For some payment methods like iDEAL, your customer will
// be redirected to an intermediate site first to authorize the payment, then
// redirected to the `return_url`.
if (error.type === "card_error" || error.type === "validation_error") {
showMessage(error.message);
} else {
showMessage(
"Your payment could not be processed. Try again or try selecting another payment method."
);
}
setLoading(false);
}
// Fetches the payment intent status after payment submission
async function checkStatus() {
const clientSecret = new URLSearchParams(window.location.search).get(
"payment_intent_client_secret"
);
if (!clientSecret) {
return;
}
const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret);
switch (paymentIntent.status) {
case "succeeded":
break;
case "processing":
showMessage("Your payment is processing.");
break;
case "requires_payment_method":
showMessage("Your payment was not successful, please try again.");
break;
default:
showMessage("Something went wrong.");
break;
}
}
// ------- UI helpers -------
function showMessage(messageText) {
const messageContainer = document.querySelector("#payment-message");
messageContainer.classList.remove("hidden");
messageContainer.textContent = messageText;
setTimeout(function () {
messageContainer.classList.add("hidden");
messageText.textContent = "";
}, 10000);
}
// Show a spinner on payment submission
function setLoading(isLoading) {
if (isLoading) {
// Disable the button and show a spinner
document.querySelector("#submit").disabled = true;
document.querySelector("#spinner").classList.remove("hidden");
document.querySelector("#button-text").classList.add("hidden");
} else {
document.querySelector("#submit").disabled = false;
document.querySelector("#spinner").classList.add("hidden");
document.querySelector("#button-text").classList.remove("hidden");
}
}
And here's the Job that gets executed for webhooks:
public function handle()
{
$paymentIntent = $this->webhookCall->payload["data"]["object"];
$charge = $paymentIntent["charges"]["data"][0];
// Usually returns an instance of the Transaction model, returns null for 3D secure cards
$transaction = Transaction::where("gateway_payment_id", $paymentIntent["id"])->first();
$transaction->payment_gateway = "Stripe";
$transaction->payment_method = $charge["payment_method_details"]["type"];
$transaction->payment_status = strtoupper($paymentIntent["status"]);
$transaction->amount = ($paymentIntent["amount_received"]/100);
$transaction->currency = strtoupper($paymentIntent["currency"]);
$transaction->postcode = $charge["billing_details"]["address"]["postal_code"] ?? "N/A";
$transaction->save();
}
I'm willing to add further code on request but due to the fact it's only failing on 3D secure, it seems unlikely that the problem is with the controller logic, and even the code I have included is probably mostly irrelevant.
Has anyone else run into something like this when using Stripe Elements, and if so, how did you solve it?
Solution 1:[1]
After going through this with two Stripe developers who couldn't help, I'm still left none the wiser about what the exact cause of this was, but at a guess this seems to have been because the 3D secure webhooks (and only those) were firing before my TransactionController could create a new transaction in the database.
I don't have a lot of experience with Stripe and backend code generally so it's entirely possible I'm wrong about all of this, but in the absence of any external help this is what I've managed to glean on my own.
As far as I've been able to tell there are two viable solutions for this.
1. Move all transaction logic into the webhook and simply retrieve it from the controller
This solution allows you to take full advantage of the asynchronous power of webhooks, but has the disadvantage that the database values may be created too late to be shown to the user in the payment success view, in which case these values will instead need to be passed from the controller into something like the session, and retrieved from there in the view.
If there are values you want to pass from the controller to the webhook - which isn't possible using arguments with jobs in the Spatie Stripe Webhooks package - you can pass them using the metadata object on the PaymentIntent when initially creating it in your controller, which will make them available in any subsequent retrieval of the PaymentIntent.
This solution wasn't suitable to me personally as there were values I needed to pass to my view that I couldn't make available to a session.
2. Wrap both the controller and webhook in logic that checks whether a transaction with the given payment intent ID exist
This solution is the most hassle and involves writing the most code, but is probably the most robust as it should work no matter how quickly or slowly a webhook fires.
Check if a transaction with the payment intent ID (which can be grabbed from the session or request) exists in the controller - if it doesn't, create it with all the records you need to retrieve in your view, then do a query from the webhook to retrieve this transaction and populate the rest of the records.
Use the same logic on your webhook to check if a transaction already exists - if it doesn't, create the remaining records not already being created in your controller, and persist them to the database ready to be retrieved by the controller logic you wrote above.
This approach - creating the necessary values in the controller while still doing most of the work in the webhook - allows you to access the data you need from the view while still making use of the asynchronicity of webhooks.
As an example, here is the logic I'm now using in my webhook:
public function handle()
{
// Payload returns an Event object that contains an object relevant to the event, such as a PaymentIntent
// for payment_intent.succeeded. The nested array below returns this PaymentIntent and other relevant objects.
$paymentIntent = $this->webhookCall->payload["data"]["object"];
$charge = $paymentIntent["charges"]["data"][0];
$transaction = Transaction::where("gateway_payment_id", $paymentIntent["id"])->first();
if(!$transaction) { // If the queried Transaction is null
$transaction = new Transaction;
}
$transaction->gateway_payment_id = $paymentIntent["id"];
$transaction->payment_gateway = "Stripe";
$transaction->payment_method = $charge["payment_method_details"]["type"];
$transaction->payment_status = strtoupper($paymentIntent["status"]);
$transaction->amount = ($paymentIntent["amount_received"]/100);
$transaction->currency = strtoupper($paymentIntent["currency"]);
$transaction->postcode = $charge["billing_details"]["address"]["postal_code"] ?? "N/A";
$transaction->email = $paymentIntent["metadata"]["email"];
$transaction->payment_id = $paymentIntent["metadata"]["payment_id"];
$transaction->manufacturer = $paymentIntent["metadata"]["manufacturer"];
$transaction->save();
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | Hashim Aziz |
