1

I am trying to implement Stripe to create a subscription (with a price depending on the quantity chosen), in my Symfony 5 app (backend : PHP, fronted JS)

I would like this scenario:

  1. The user chooses the quantity for his subscription

  2. It is redirected to the checkout page (created myself, not the Stripe Checkout utility hosted by themselves)

  3. He enters his payment information.

  4. If necessary, SCA authentication (3D SECURE).

  5. If everything is OK, I create the subscription.

So, from step 3, I tried this :

My back-end endpoint :

    /**
     * @Route("/paiement/process", name="website_payment_process", options={"expose"=true})
     */
    public function process(Request $request, $stripeSecretKey, ShoppingCart $cart, StripeClient $stripeClient, EntityManagerInterface $manager)
    {
        \Stripe\Stripe::setApiKey($stripeSecretKey);

        $json_obj = json_decode($request->getContent());
        $intent = null;

        try {
            if (isset($json_obj->payment_method_id)) {
                // Create the PaymentIntent
                $intent = \Stripe\PaymentIntent::create([
                    'payment_method' => $json_obj->payment_method_id,
                    'confirmation_method' => 'manual',
                    'confirm' => true,
                    'amount' => $cart->getTotalWithDiscount(false) * 100,
                    'currency' => 'eur',
                    'description' => 'Mon paiement',
                    'setup_future_usage' => 'off_session',
                ]);
            }
            if (isset($json_obj->payment_intent_id)) {
                $intent = \Stripe\PaymentIntent::retrieve(
                    $json_obj->payment_intent_id
                );
                $intent->confirm();
            }
            if ('requires_action' == $intent->status &&
                'use_stripe_sdk' == $intent->next_action->type) {
                // Tell the client to handle the action
                return new JsonResponse([
                    'requires_action' => true,
                    'payment_intent_client_secret' => $intent->client_secret,
                ]);
            } elseif ('succeeded' == $intent->status) {
                // Paiement Stripe accepté
                
                $customer = \Stripe\Customer::create([
                   'description' => 'Abonnement à mon application',
                   'preferred_locales' => ['fr'],
                   'email' => $json_obj->email,
                   'payment_method' => $intent->payment_method,
                ]);

                $stripeCustomer = new StripeCustomer();
                $stripeCustomer->setStripeCustomerId($customer->id);
                $this->getUser()->setStripeCustomer($stripeCustomer);

                $manager->persist($this->getUser());
                $manager->flush();


                // Created the subscription in association with Customer Id
                $stripeClient->createSubscription(
                    $this->getUser(),
                    $cart->getPlan(),
                    true,
                    $cart->getQuantity(),
                    $cart->getCouponId()
                );
                
                return new JsonResponse([
                    'success' => true,
                ]);
            } else {
                return new JsonResponse(['error' => 'Invalid PaymentIntent status'], 500);
            }
        } catch (\Exception $e) {
            // Display error on client
            return new JsonResponse([
                'error' => $e->getMessage(),
            ]);
        }
    }

And the JS :

if($("#payment-form").length !== 0){

    const STRIPE_PUBLIC_KEY = process.env.STRIPE_PUBLIC_KEY;

    // Stripe.setPublishableKey(STRIPE_PUBLIC_KEY);
    var stripe = Stripe(STRIPE_PUBLIC_KEY);
    var processUrl = Routing.generate('website_payment_process');
    var elements = stripe.elements();
    var card = elements.create('card', {
        style: {
            base: {
                iconColor: '#666EE8',
                color: '#31325F',
                lineHeight: '40px',
                fontWeight: 300,
                fontFamily: 'Helvetica Neue',
                fontSize: '15px',

                '::placeholder': {
                    color: '#CFD7E0',
                },
            },
        }
    });
    card.mount('#card-element');

    function setOutcome(result) {
        var errorElement = document.querySelector('.error');
        errorElement.classList.remove('visible');

        if (result.token) {
            $('#stripeToken').val(result.token.id);
            $('#payment-form').submit();
        } else if (result.error) {
            errorElement.textContent = result.error.message;
            errorElement.classList.add('visible');
        }
    }

    card.on('change', function(event) {
        setOutcome(event);
    });

    document.getElementById('payment-form').addEventListener('submit', function(e) {
        e.preventDefault();

        var form = document.getElementById('payment-form');
        stripe.createPaymentMethod('card', card, {
            billing_details: {name: form.querySelector('input[name=cardholder-name]').value}
        }).then(function(result) {
            if (result.error) {
                var errorElement = document.querySelector('.error');
                errorElement.textContent = result.error.message;
                errorElement.classList.add('visible');
            } else {
                $('#buttonPayment').hide();
                $('#spanWaitPayement').show();
                // Otherwise send paymentMethod.id to your server (see Step 2)
                fetch(processUrl, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content'),
                    },
                    body: JSON.stringify({ payment_method_id: result.paymentMethod.id })
                }).then(function(result) {
                    result.json().then(function(json) {
                        handleServerResponse(json);
                    })
                });
            }
        });
    });

    function handleServerResponse(response) {
        if (response.error) {
            $('#buttonPayment').show();
            $('#spanWaitPayement').hide();
            var errorElement = document.querySelector('.error');
            errorElement.textContent = result.error.message;
            errorElement.classList.add('visible');
        } else if (response.requires_action) {
            // Use Stripe.js to handle required card action
            stripe.handleCardAction(
                response.payment_intent_client_secret
            ).then(function(result) {
                if (result.error) {
                    $('#buttonPayment').show();
                    $('#spanWaitPayement').hide();
                    var errorElement = document.querySelector('.error');
                    errorElement.textContent = result.error.message;
                    errorElement.classList.add('visible');
                } else {
                    // The card action has been handled
                    // The PaymentIntent can be confirmed again on the server
                    fetch(processUrl, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                            'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content'),
                        },
                        body: JSON.stringify({
                            payment_intent_id: result.paymentIntent.id,
                            email: $('#email').val(),
                        })
                    }).then(function(confirmResult) {
                        return confirmResult.json();
                    }).then(handleServerResponse);
                }
            });
        } else {
            // window.location.replace('/paiement-ok');
        }
    }
}

I manage to retrieve the payment information, use the SCA authentication, and make the payment. But I must certainly be wrong for the rest because if the payment is successful, it's just after that I create the customer, and the subscription, and associate the PaymentIntent previously used. And inevitably it does not work, the subscription is "incomplete" and he tries again to rebill the customer

eronn
  • 1,690
  • 3
  • 21
  • 53

2 Answers2

0

What you need to add is passing off_session: true in your call to create the Subscription. This tells Stripe that the customer is not present and prompts the usage of the previous authentication via 3DS. See the docs for more info.

taintedzodiac
  • 2,658
  • 1
  • 18
  • 16
  • I tried this, but I've the following message in the stripe dashboard : `"message": "This customer has no attached payment source or default payment method.",`. However, when I created my customer, I set to it the paymentMethod – eronn Jan 15 '21 at 14:12
0

You should not be creating a PaymentIntent yourself. This will charge the customer immediately which means they will be double charged once you also create the Subscription afterwards (unless you put them on a trial).

Instead, you should flip things over and create the Subscription first and then ensure that you complete the payment if it requires client-side confirmation for example for 3D Secure.

The problem you experience with the error on Subscription creation is because you are not defining which payment method to use for the payments. Your code does attach the PaymentMethod (pm_123) properly to the Customer but it does not explicitly make it the default for invoice payments. So whenever the subscription is created, it finds no default payment method and ends up stuck not being able to attempt a payment. On Customer creation, make sure to also set invoice_settings[default_payment_method] as documented here.

Your customer creation should look like this:

$customer = \Stripe\Customer::create([
  'description' => 'Abonnement à mon application',
  'preferred_locales' => ['fr'],
  'email' => $json_obj->email,
  'payment_method' => 'pm_123',
  'invoice_settings' => [
    'default_payment_method' => 'pm_123',
  ],
]);

Now with that in mind the entire flow you should follow which is covered in this doc looks like this:

  1. (Client-side) Collect card details with Elements and create a PaymentMethod (pm_123)
  2. (Server-side) Create a customer and attach the PaymentMethod and make it the default (code above)
  3. (Server-side) Create a subscription for that customer and the right prices. Expand the latest_invoice.payment_intent at the same time to confirm whether the invoice is paid or needs 3D Secure client-side
  4. (Client-side) If needed, confirm the Invoice's PaymentIntent client-side to finalize the payment and render the subscription active.
koopajah
  • 23,792
  • 9
  • 78
  • 104