Shopping : le paiement avec Stripe

Dans notre boutique on en est arrivés au stade de la confirmation de la commande. En cas de paiement par virement, chèque ou mandat administratif les choses en reste là en attendant le paiement effectif. Il n’en est pas de même dans le cas d’un paiement par carte bancaire parce que celui-ci va s’effectuer normalement immédiatement. J’ai opté pour Stripe comme fournisseur parce qu’il propose une API bien conçue et un package pratique.

Vous pouvez télécharger un ZIP du projet ici.

Stripe

Stripe est une solution de paiement en ligne. C’est une entreprise gigantesque qui a son siège social au États-Unis mais qui possède des bureaux dans la plupart des pays. Faut-il s’en méfier ? Lorsqu’on lit certains forums on pourrait le penser. Je n’en ai personnellement aucune expérience pour le moment et je lis autant d’avis positifs que négatifs. Je vous propose dans cet article de l’utiliser pour notre boutique mais c’est évidemment adaptable avec une autre solution de paiement, mais avec peut-être plus de difficultés pour utiliser l’API.

Vous pouvez ouvrir un compte gratuit pour essayer la solution et ainsi récupérer vos clés API (une secrète et une publique). J’ai trouvé un tutoriel en français pour l’intégration de la solution avec Laravel. Il est globalement plutôt bien fait mais curieusement il crée une redondance du 3D Secure, parce que Stripe s’en charge déjà avec une fenêtre modale et dans son code il relance la page en cas de paiement pas encore abouti. je n’ai pas très bien compris la démarche et j’ai simplifié largement le codage.

La première chose à faire est d’ajouter le package à la boutique :

composer require stripe/stripe-php

La documentation de l’API est très complète. Mais elle est tellement fournie qu’on a parfois du mal à trouver l’information qu’on cherche !

Un paiement passe par plusieurs étapes :

  1. création d’un PaymentIntent, c’est un objet créé sur le compte Stripe qui mémorise le montant à payer pour un client et les étapes de paiement. On obtient ainsi une clé secrète pour ce paiement. Il est judicieux de mémoriser cette clé en session pour éviter de créer plusieurs PaymentIntent pour un même paiement
  2. Stripe propose des éléments d’interface pour la collecte des informations de la carte bancaire, il y a création d’un iframe et envoie en mode sécurisé des informations à Stripe
  3. s’il y a besoin du 3D Secure une page modale est automatiquement ouverte pour collecter les informations complémentaires
  4. on peut récupérer des événements à l’issue du paiement, en particulier s’il est réussi

On va mémoriser les clés dans la configuration de la boutique. Déjà dans .env :

STRIPE_PUBLISHABLE_KEY=pk_test_***
STRIPE_SECRET_KEY=sk_test_***

Et on crée un fichier de configuration :

Dans lequel on récupère les clés :

<?php

return [
  'publishable_key' => env('STRIPE_PUBLISHABLE_KEY'),
  'secret_key' => env('STRIPE_SECRET_KEY'),
];

Pourquoi ne pas se contenter du fichier .env ? Si on génère un cache pour la configuration le fichier .env ne sera plus lu !

Contrôleur

Dans le contrôleur OrdersController on va ajouter une méthode pour le paiement :

/**
 * Stripe
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \App\Models\Order  $order
 * @return array
 */
protected function stripe($data, $request, $order)
{
    if($request->session()->has($order->reference)) {
        $data['secret'] = $request->session()->get($order->reference);
    } else {            
        \Stripe\Stripe::setApiKey(config('stripe.secret_key'));
        $intent = \Stripe\PaymentIntent::create([
            'amount' => (integer) ($order->totalOrder * 100),
            'currency' => 'EUR',
            'metadata' => [
              'reference' => $order->reference,
            ],
        ]);
        $request->session()->put($order->reference, $intent->client_secret);
        $data['secret'] =  $intent->client_secret;
    };
    
    return $data;
}

Et l’appel de cette méthode dans la méthode data :

protected function data($request, $order)
{
    ...

    if($order->state->slug === 'carte' || $order->state->slug === 'erreur') {
        $data = $this->stripe($data, $request, $order);
    }  
    
    return $data;
}

On commence par mémoriser la clé secrète :

\Stripe\Stripe::setApiKey(config('stripe.secret_key'));

Ensuite on crée l’objet PaymentIntent :

$intent = \Stripe\PaymentIntent::create([
    'amount' => (integer) ($order->totalOrder * 100),
    'currency' => 'EUR',
    'metadata' => [
      'reference' => $order->reference,
    ],
]);

On a la somme en centimes, la monnaie et on ajoute comme information la référence de la commande, ce qui facilitera la lecture des données dans l’interface de Stripe.

On mémorise ensuite la clé secrète du ce paiement (on utilise la référence de la commande comme clé) en session :

$request->session()->put($order->reference, $intent->client_secret);

Évidemment si la clé a déjà été générée et mémorisée en session (et donc on dispose déjà d’un objet PaymentIntent) on se contente de récupérer cette clé :

if($request->session()->has($order->reference)) {
    $data['secret'] = $request->session()->get($order->reference);

Les vues

Vue partielle du formulaire de paiement

On crée une vue partielle pour afficher le formulaire de paiement parce qu’on réutilisera cette vue si jamais le client n’a pas pu payer et veut le faire plus tard à partir de son compte :

Avec ce code :

<br>
<div id="payment-pending" class="card">
  <div class="card-content center-align">
    <span class="card-title">Vous avez choisi de payer par carte bancaire. Veuillez compléter le présent formulaire pour procéder à ce règlement</span>
    <p class="orange-text">Nous ne conservons aucune de ces informations sur notre site, elles sont directement transmises à notre prestataire de paiement <a href="https://stripe.com/fr">Stripe</a>.</p>
    <p class="orange-text">La transmission de ces informations est entièrement sécurisée.</p>
    <br>
    <div class="row">
      <form id="payment-form">        
        <div class="card blue-grey darken-1">
          <div class="card-content white-text">
            <div id="card-element"></div>
          </div>
        </div>
        <div id="card-errors" class="helper-text red-text"></div>
        <br>
        <div id="wait" class="hide">
          <div class="loader"></div>
          <br>
          <p><span class="red-text card-title center-align">Processus de paiement en cours, ne fermez pas cette fenêtre avant la fin du traitement !</span></p>
        </div>
        <button style="float: right;" class="btn waves-effect waves-light" id='submit' type="submit" name="action">Payer
          <i class="material-icons right">payment</i>
        </button>
      </form>
    </div>
  </div>
</div>

La partie qui concerne la génération des éléments du formulaire est ici :

<form id="payment-form">        
  ...
      <div id="card-element"></div>
  ...
  <div id="card-errors" class="helper-text red-text"></div>
  ...

  <button id='submit' type="submit" name="action">Payer</button>
</form>

Vue partielle pour le Javascript

On va aussi créer une vue partielle pour l’intégration du Javascript :

Avec ce code :

@if($order->state->slug === 'carte' || $order->state->slug === 'erreur')
<script src="https://js.stripe.com/v3/"></script>
<script>

    const stripe = Stripe('{{ config('stripe.publishable_key') }}');
    const elements = stripe.elements();
    const style = {
      base: {
        color: '#32325d',
        fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
        fontSmoothing: 'antialiased',
        fontSize: '16px',
        '::placeholder': {
          color: '#aab7c4'
        }
      },
      invalid: {
        color: '#fa755a',
        iconColor: '#fa755a'
      }
    };

    const card = elements.create("card", { style: style });
    card.mount("#card-element");
    const displayError = document.getElementById('card-errors');

    card.addEventListener('change', ({error}) => {
      displayError.textContent = error ? error.message : '';
    });

    document.getElementById('payment-form').addEventListener('submit', ev => {
      ev.preventDefault();
      displayError.textContent = '';
      document.getElementById('submit').classList.add('hide');
      document.getElementById('wait').classList.remove('hide');
      stripe.confirmCardPayment('{{ $secret }}', {
        payment_method: { card: card }
      }).then(result => {
        document.getElementById('wait').classList.add('hide');
        if (result.error) {
          document.getElementById('submit').classList.remove('hide');              
          displayError.textContent = result.error.message;
        } else {
          if (result.paymentIntent.status === 'succeeded') {
            document.getElementById('payment-pending').classList.add('hide');
            document.getElementById('payment-ok').classList.remove('hide');
          }
        }
        let info = result.error ? 'error' : result.paymentIntent.id;
        fetch('{{ route('commandes.payment', $order->id) }}', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': '{{ csrf_token() }}'
          },
          body: JSON.stringify({ payment_intent_id: info })
        });
      });
    });

</script>
@endif

La partie intéressant dans ce code est la création de l’élément Card :

const card = elements.create("card", { style: style });

On aura ainsi un input global qui va collecter toutes les informations.

On prévoit le code pour l’affichage des erreurs probables :

card.addEventListener('change', ({error}) => {
  const displayError = document.getElementById('card-errors');
  displayError.textContent = error ? error.message : '';
});

A la soumission on envoie les informations à Stripe :

stripe.confirmCardPayment('{{ $secret }}', {
  payment_method: { card: card }
}).then(result => {

Là on agit en fonction du succès ou de l’échec.

Pour le moment je n’ai pas précisé la route pour l’envoi des informations après le paiement parce qu’on ne l’a pas encore créée.

La vue de confirmation

On va compléter maintenant la vue de confirmation (views/command/confirmation.blade.php) qu’on a créée lors du précédent article.

On va ajouter un peu de style :

@extends('layouts.app')

@section('css')
  <style>
    .StripeElement {
      box-sizing: border-box;

      height: 40px;

      padding: 10px 12px;

      border: 1px solid transparent;
      border-radius: 4px;
      background-color: white;

      box-shadow: 0 1px 3px 0 #e6ebf1;
      -webkit-transition: box-shadow 150ms ease;
      transition: box-shadow 150ms ease;
    }

    .StripeElement--focus {
      box-shadow: 0 1px 3px 0 #cfd7df;
    }

    .StripeElement--invalid {
      border-color: #fa755a;
    }

    .StripeElement--webkit-autofill {
      background-color: #fefde5 !important;
    }
  </style>
@endsection

Et intégrer les vues partielles :

        @elseif($order->state->slug === 'carte' || $order->state->slug === 'erreur')
        
          @include('command.partials.stripe')

        @endif
   ...

@endsection

@section('javascript')

  @include('command.partials.stripejs')

@endsection

Maintenant quand on affiche la page de confirmation en cas de paiement par carte on a le formulaire qui apparaît :

On peut vérifier les messages d’erreur :

On peut aussi tester le 3D Secure :

Vous pouvez trouver une liste de numéros de cartes de test pour les différentes situations ici.

Sur le tableau de bors de Stripe on retrouve la tentative (si ça n’a pas abouti) de paiement :

On dispose de tout le détail, par exemple la référence de la commande qu’on a envoyée :

Et toutes les étapes :

Et de tout plein d’autres informations que je vous laisse découvrir…

Mise à jour du paiement

Maintenant qu’on peut payer par carte il faut pouvoir récupérer l’information de réussite ou d’achec de ce paiment dans la boutique.

On va créer un nouveau contrôleur :

php artisan make:controller PaymentController

On aura une seule méthode :

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\{ Order, State };

class PaymentController extends Controller
{
    
    /**
     * Manage payment
     *
     * @param  \App\Models\Order  $order
     * @return void
     */
    public function __invoke(Request $request, Order $order)
    {
        $this->authorize('manage', $order);

        $state = null;

        if($request->payment_intent_id === 'error') {
            $state = 'erreur'; 
        } else {
            \Stripe\Stripe::setApiKey(config('stripe.secret_key'));
            $intent = \Stripe\PaymentIntent::retrieve($request->payment_intent_id);
            if ($intent->status === 'succeeded') {
                $request->session()->forget($order->reference); 
                $order->payment_infos()->create(['payment_id' => $intent->id]);         
                $state = 'paiement_ok';  
                // Création de la facture à prévoir              
            } else {
                $state = 'erreur';     
            }
        }

        $order->state_id = State::whereSlug($state)->first()->id;              
        $order->save();
    }
}

Ici on va mettre à jour l’état de la commande. S’il y a une erreur de paiement c’est tout simple :

if($request->payment_intent_id === 'error') {
    $state = 'erreur';

Sinon on retrouve les informations de ce paiement chez Stripe (on ne va pas faire confiance à ce qui vient du client) :

\Stripe\Stripe::setApiKey(config('stripe.secret_key'));
$intent = \Stripe\PaymentIntent::retrieve($request->payment_intent_id);

Et là si le paiement a abouti :

if ($intent->status === 'succeeded') {
    $request->session()->forget($order->reference); 
    $order->payment_infos()->create(['payment_id' => $intent->id]);         
    $state = 'paiement_ok';

On vide la session, on renseigne la table payments avec l’identifiant du paiement, et on change l’état.

On prévoit la route pour aboutir à cette méthode :

// Utilisateur authentifié
Route::middleware('auth')->group(function () {
  // Commandes
  Route::prefix('commandes')->group(function () {
      ...
      Route::name('commandes.payment')->post('paiement/{order}', 'PaymentController');
  });

Et on ajoute cette route dans le Javascript de la vue partielle stripejs :

fetch('{{ route('commandes.payment', $order->id) }}', {

Lorsque le paiement a réussi on affiche une information claire au client :

On vérifie que l’état a bien changé dans la table orders :

Et que la table payments a bien été renseignée :

On peut vérifier la réussite dans le tableau de bord de Stripe :

Conclusion

On a réussi notre intégration d’une solution de paiment dans notre boutique ! Maintenant les clients peuvent payer immédiatement leurs achats. Mais nous avons encore beaucoup de travail en vue ! Rendez-vous au prochain article !

Print Friendly, PDF & Email

10 commentaires sur “Shopping : le paiement avec Stripe

  1. Bonjour !
    après validation de la commande par carte j’ai eu cette erreur :Aucune clé API fournie. (CONSEIL: définissez votre clé API à l’aide de « Stripe :: setApiKey () » or j’ai crée un compte sur stripe.com puis copié les clés de publishable et secret sur le fichier .env

          1. merci davantage !!
            mon compte stripe n’est pas activer car il n’est pas disponible ici au Sénégal. De ce fait est-il possible d’avoir un numéro de carte pour le test car la zone de texte du carte ne s’affiche pas chez moi

          2. Si Stripe n’est pas disponible alors ça ne pourra pas fonctionner. Il faut suivre le tutoriel en oubliant la partie paiement par carte.

      1. bonsoir bestmomo
        il ya un probleme de securité au niveau actualisation de la page de paiement stripe qui refait un payment de transport il quand on retour a la page d’avant « confirmation commande » on as seulement le prix du transport sans le prox du produit
        il ne faut pas autorisé l’actualisation de la page de paiement stripe et le retour en arriéré

        veuillez svp nous communiquez comment faire !!??

        mercii

        1. Salut,

          Effectivement un retour sur la page et une actualisation n’affiche que le port puisque le panier a été vidé à la confirmation. Une façon de faire est d’ajouter ce code au début de la méthode create de OrderController :

          if(Cart::isEmpty()) abort(404);

          A voir s’il y a une meilleure option…

Laisser un commentaire