timgavin's avatar

Stripe Payment Element Error

I'm having a hell of a time getting a Stripe single payment element to work with Cashier.

I've watched Andre's videos (including the updated one regarding the payment element), and, maybe I missed something but after watching them at least 5 times I'm more confused than before I started; it's a mess.

What's got me is that I've actually been able to make charges in the past, but I just created a new user and now it's broken. The payment element mounts, and I can enter card information, but after submit I receive this error

{
  "message": "The provided PaymentMethod cannot be attached. To reuse a PaymentMethod, you must attach it to a Customer first."
}

I've gone through the documentation on Cashier, and it shows how to get payment methods for Card Elements, but not for Payment Elements, which are quite different. So I think this is where I'm stuck? I've gone through Stripe's code as well and... well I just can't get it to work.

The Controller

// not sure I need the intent any more, but I'm just going through this step-by-step...
return view('payment')->with([
    'intent' => auth()->user()->createSetupIntent(),
]);

JS in the view

const stripe = Stripe('{{ config('stripe.public_key') }}');

let elements;

initialize();

document
    .querySelector("#payment-form")
    .addEventListener("submit", handleSubmit);

function initialize() {
    elements = stripe.elements({
        clientSecret: "{{ $intent->client_secret }}",
    });

    const paymentElementOptions = {
        layout: "tabs",
    };

    const paymentElement = elements.create("payment", paymentElementOptions);
    
    paymentElement.mount("#payment-element");
}

async function handleSubmit(e) {
    e.preventDefault();

    const { setupIntent, error } = await stripe.confirmSetup({
        elements,
        confirmParams: {
            return_url: "{{ url('/home') }}"
        },
        redirect: 'if_required'
    });

    if (error) {
        // do error stuff here...
    } else {
        let form = document.getElementById('payment-form');
        let hiddenInput = document.createElement('input');
        hiddenInput.setAttribute('type', 'hidden');
        hiddenInput.setAttribute('name', 'paymentMethod');
        hiddenInput.setAttribute('value', setupIntent.payment_method);

        form.appendChild(hiddenInput);
        form.submit();
    }
}

Where am I going wrong?

0 likes
9 replies
justrusty's avatar
Level 1

The issue is you are trying to append the payment method to the form then submit it in the else but the redirect to the return_url happens before that and adds the setup intent id as a query param. After submitting the form confirmSetup calls stripe to check the card is valid then if no error redirects you immediately to the return_url with the setup intent id added to the params. So the return url should go to a route to a controller method something like

$stripe = Cashier::stripe();
$setupIntent  = $stripe->setupIntents->retrieve(
    $request->setup_intent,
    ['expand' => ['payment_method']]
);
$paymentMethod = $setupIntent->payment_method;
if ($paymentMethod) {
    $this->User->addPaymentMethod($paymentMethod->id);
    $this->User->updateDefaultPaymentMethod($paymentMethod->id);
}

You handleSubmit should be like

async function handleSubmit(e) {
    e.preventDefault();

    const { error } = await stripe.confirmSetup({
        elements,
        confirmParams: {
            return_url: "{{ url('/handle-payment') }}" //Should redirect to a controller method that handles the card attachment server side
        },
        redirect: 'if_required'
    });

    if (error) {
        // do error stuff here...
    } else {
        // If confirmSetup is successful you should never get here, you should have been redirec ted to your return url and server side should use the $request->setup_intent to retrieve the setup intent from stripe and get the payment method from it
    }
}
1 like
timgavin's avatar

@justrusty Thank you very much. This pointed me in the right direction. So it turns out, that in my instance I needed BOTH the return url and the code in the error/else block. What I was actually doing wrong was that I didn't include the additional code in the controller method (because according to the series on Cashier it wasn't needed) that handled the return url.

This is what I ended up using and it's working perfectly now

javascript

const { setupIntent, error } = await stripe.confirmSetup({
    elements,
    confirmParams: {
        return_url: "{{ url('/wallet') }}"
    },
    redirect: 'if_required'
});

controller

public function create()
{
    return view('wallet.create')->with([
        'intent' => auth()->user()->createSetupIntent(),
    ]);
}

public function store(Request, $request, , DepositFundsAction $depositFunds)
{
    ...

    auth()->user()->createOrGetStripeCustomer();

    $paymentMethod = $request->get('paymentMethod');

    if ($paymentMethod) {
        auth()->user()->addPaymentMethod($paymentMethod);
        auth()->user()->updateDefaultPaymentMethod($paymentMethod);
    }
    
    try {
        $stripe = auth()->user()->charge($total, $request->get('paymentMethod'));
        .....
    }
}
1 like
justrusty's avatar

You don't need the code in the else block. The setup intent id gets appended to the return_url which you can use to retrieve the setup intent from the Stripe API. What you are doing is redirecting and submitting the post request with the form at the same time. You only need to do the redirect. Your code should be more like I posted above. I think the below should be basically be copy and paste

The {{ url('/process-payment') }} should be a GET route e.g.

Route::get('/process-payment', [WalletController::class, 'processPayment'])->name('process-payment');

Javascript

async function handleSubmit(e) {
    e.preventDefault();

    const { error } = await stripe.confirmSetup({
        elements,
        confirmParams: {
            return_url: "{{ route('process-payment') }}" //using a named route
        },
        redirect: 'if_required'
    });

    if (error) {
        // do error stuff here...
    }
}

WalletController method (GET)

public function processPayment(Request, $request, DepositFundsAction $depositFunds)
{
    ...
    auth()->user()->createOrGetStripeCustomer();

    $stripe = Cashier::stripe();
    $setupIntent  = $stripe->setupIntents->retrieve(
        $request->query('setup_intent'),
        ['expand' => ['payment_method']]
    );
    $paymentMethod = $setupIntent->payment_method;
    if ($paymentMethod) {
        auth()->user()->addPaymentMethod($paymentMethod->id);
        auth()->user()->updateDefaultPaymentMethod($paymentMethod->id);
    }
    
    try {
        $stripe = auth()->user()->charge($total, $request->get('paymentMethod'));
        ...
    }
}
timgavin's avatar

@justrusty Ok, making progress. Turn out I needed to change redirect: 'if_required' to redirect: 'always'. Now it's bypassing the else block and hitting the store method. Only problem I'm having is the stripe Api returns

{
  "message": "Invalid integer: "
}

not much to go on. investigating...

timgavin's avatar

Ok, I see what's going on. Previously I was able to get the product ID and then figure out the price, so that's what is throwing the error: my $total variable is null.

How do I retrieve the product id in Stripe's response? ['expand' => ['payment_method']] doesn't have it.

justrusty's avatar

@timgavin because it's a setup intent, you don't really have a product id. What I do is append the product id to the return_url before passing it to the confirmSetup() confirmParamsalong these line

let url = new URL("{{ route('wallet.store') }}");
url.searchParams.append("subscriber", subscriberId);
let plans = [...document.getElementsByName("plan")];
let plan = plans.find(element => element.checked);
url.searchParams.append("plan", plan.value);
let addons = [...document.getElementsByName("addons[]")];
addons.filter(a => a.checked).forEach(a => {
    url.searchParams.append("addons[]", a.value);
});

const {error} = await stripe.confirmSetup(
    {
        elements,
        confirmParams: {
            return_url: url.toString(),
        },
    },
);

Then server side $membershipPlan = Plan::getBySlug($request->plan);

1 like
justrusty's avatar

@timgavin Also I should add that redirect: 'if_required' is correct. If it is not redirecting it is generally because there is an error and you should handle the error in the front end, something like

if (error) {
    console.log(error);
    errorMessageContainer.innerText = error.message;
}
timgavin's avatar

What's happening is that when I submit the form, the javascript in the error else block is hit, so if I don't submit the form in there, then nothing happens, it just idles.

Here's my full handleSubmit function

async function handleSubmit(e) {
    e.preventDefault();

    const hideSpinner = document.getElementById('hide-spinner');
    const showSpinner = document.getElementById('show-spinner');

    hideSpinner.classList.add('hidden');
    showSpinner.classList.remove('hidden');

    const { error } = await stripe.confirmSetup({
        elements,
        confirmParams: {
            return_url: "{{ route('wallet.store') }}"
        },
        redirect: 'if_required'
    });

    if (error) {
        hideSpinner.classList.remove('hidden');
        showSpinner.classList.add('hidden');

        let errorDiv = document.getElementById('error-message');
        let errorMessage = '';

        if (error.type === 'card_error' || error.type === 'validation_error') {
            errorMessage = error.message;
        } else {
            errorMessage = 'An unknown error occurred.';
        }

        errorDiv.innerHTML = errorMessage;
    } else {
        console.log('here');
    // let form = document.getElementById('payment-form');
    // let hiddenInput = document.createElement('input');
    // hiddenInput.setAttribute('type', 'hidden');
    // hiddenInput.setAttribute('name', 'paymentMethod');
    // hiddenInput.setAttribute('value', setupIntent.payment_method);
    //
    // form.appendChild(hiddenInput);
    // form.submit();
    }
}

So when this is run, the browser's console log shows: 'here' and then nothing happens, the form just sits.

The form tag. Makes no difference which one I use

<form id="payment-form" method="POST">
<form id="payment-form" method="POST" action="{{ route('process-payment) }}">

My Route

Route::get('process-payment', [WalletController::class, 'store'])->name('process-payment');

And inside my WalletController store() method I've added a log dump. The log is empty after every submit, so the store() method isn't even getting accessed. Unless I submit the form in the error/else block. I'm scratching my head....

public function store(Request $request, DepositFundsAction $depositFunds)
{
    \Log::info('store method was hit');

Please or to participate in this conversation.