Cet article est autant un pense-bête pour moi-même qu’un tutoriel. Il se trouve qu’il m’arrive très couramment de tomber sur des clients qui veulent pré-enregistrer des cartes sur un profil puis payer avec. Cela permet de fidéliser les clients et de gagner en temps dans le tunnel de paiement. Et on le sait: plus c’est long, plus on perd d’utilisateurs.
Pourtant sur ce point précis, je trouve la documentation de Stripe et les tutos assez flous. Donc voilà un article qui présente précisément cet aspect là:

  • Enregistrer des cartes bancaires
  • Supprimer des cartes bancaires
  • Payer avec une carte déjà enregistrée

Notons que le tutoriel est fait sous Symfony (oui il m’arrive de faire autre chose que du CSS), mais je passe par le package composer de stripe, donc c’est normalement adaptable sous n’importe quel projet en PHP.

Le concept

Pour commencer: je passe par Stripe pour deux raisons: mes clients me le demandent plus régulièrement et je trouve la doc bien faite. Je n’ai rien contre les concurrents (Paypal, MangoPay, pour ne citer qu’eux) et je n’en conseille aucun plus qu’un autre.

Ceci étant dit, parlons de comment nous allons organiser tout ça. Déjà nous ne stockerons aucune information sensible de notre côté ! Le risque de faille dans notre propre code, soyons honnête, est plus probable que chez Stripe. Garder des informations aussi sensibles (même si par exemple seuls les 4 derniers chiffres de la carte sont sauvegardés) n’est globalement pas une bonne idée.

Nous allons donc faire en sorte de passer intégralement par l’API Stripe à la fois pour enregistrer les cartes, pour les récupérer et pour payer. Seul l’id stripe du client sera stocké de notre côté.

Configurer Stripe

Déjà installez « stripe/stripe-php » dans votre projet:

composer require stripe/stripe-php

Et ajoutez votre public key et votre private key dans votre .env (vous les trouvez dans la section « développeurs » de votre dashboard Stripe):

STRIPE_PK=""
STRIPE_SK=""

Chaque user aura besoin de son propre ID stripe. Pour ce faire, vous pouvez tout simplement faire:

$stripe = new \Stripe\StripeClient($_ENV['STRIPE_SK']);
$customer = $stripe->customers->create([
    'email' => $user->getEmail(),
]);
$user->setStripeId($customer->id);

Vous pouvez bien-sûr envoyer plus de données client. Pour des raisons de RGPD, ce choix doit être pris en fonction de vos besoins. Je vous conseille de lancer ça au register. Même si les paiements sont optionnels sur votre site, au moins ça sera fait.

Sauvegarder une card

Pour pouvoir enregistrer une card nous devons tout d’abord générer un intent (sans prix du coup). Pour cela, on créer un objet Stripe avec notre clé privée et nous générons un intent que nous envoyons ensuite dans notre vue. Nous renvoyons également notre public key dans la vue, nous en aurons besoin juste après:

$stripe = new \Stripe\StripeClient($_ENV['STRIPE_SK']);
$intent = $stripe->setupIntents->create([
  'payment_method_types' => ['card'],
  'usage' => 'on_session',
]);

return $this->render('add-card.html.twig', [
    "stripe_public_key" => $_ENV['STRIPE_PK'],
    "intent" => $intent
]);

Nous allons ensuite générer un petit form très court dans notre page twig:

<div id="payment-form">
    <div class="form-row">
        <div id="card-element"></div>
        <div id="card-errors" role="alert"></div>
    </div>
    <button id="card-button" class="blue-button" data-secret="{{ intent.client_secret }}">Ajouter une carte</button>
</div>

Bon, bien sûr vous gérez vos class comme vous l’entendez. Les parties importantes ici sont:

  • l’id card-element pour que Stripe puisse insérer son propre formulaire
  • l’id card-errors pour indiquer les différents soucis que peut rencontrer l’utilisateur
  • l’id card-button pour trigger les fonctions d’enregistrement
  • le data-secret qui est l’id de votre intent généré plus haut

À noter qu’un id d’intent ne peut servir qu’une fois, donc quand vous testez n’oubliez pas de refresh la page.

On va ensuite initier notre Javascript avec Elements de Stripe, pour ce faire vous devez récupérer le script en premier lieu puis l’instancier comme suit:

<script src="https://js.stripe.com/v3/"></script>
<script>
    var stripe = Stripe('{{ stripe_public_key }}');
    var elements = stripe.elements();

    // La suite ira ici
</scritp>

Notez donc que c’est ici que vous insérez votre clé publique. Nous allons maintenant pouvoir monter notre formulaire d’ajout de carte:

var card = elements.create('card', {style: style});
card.mount('#card-element');

On peut ajouter dès maintenant un petit listener qui servira à handle toutes les erreurs que nous renverra le formulaire (genre numéro de carte qui ne correspond à aucun format connu, …):

card.addEventListener('change', function(event) {
    var displayError = document.getElementById('card-errors');
    if (event.error) {
        displayError.textContent = event.error.message;
    } else {
        displayError.textContent = '';
    }
});

Ainsi si des erreurs sont détectées avant d’avoir à appeler l’API Stripe, vous le saurez. Nous allons maintenant traiter le moment où tout est bon et que l’utilisateur clique sur le bouton. On crée déjà notre listener, et on récupère le secret dont on aura besoin plus tard:

var cardButton = document.getElementById('card-button');
var clientSecret = cardButton.dataset.secret;

cardButton.addEventListener('click', function(ev) {
    // La suite ira ici
});

On peut désormais dans notre listener appeler la fonction handleCardSetup de stripe. On y met le secret récupéré plus tôt, le formulaire de carte et les informations qu’on veut y ajouter (genre ici le prénom/nom de l’utilisateur):

stripe.handleCardSetup(
    clientSecret, card, {
        payment_method_data: {
            billing_details: {name: '{{ app.user.lastname }} {{ app.user.firstname }}' }
        }
    }
).then(function(result) {
    // Pareil, la suite ira ici
});

Votre var result contiendra ensuite soit une erreur, soit un intent dans lequel vous trouverez l’id de votre payment method. Nous devons récupérer cet id et le lier à notre utilisateur, pour ça plusieurs façons. Nous allons nous appeler une route en lui transmettant cet id via un fetch. Si tout se passe bien nous pourrons refresh la page et nous ferons en sorte que les cards sauvegardées s’affichent sur cette même page. Ajoutons donc:

if (result.error) {
    const errorElement = document.getElementById('card-errors');
    errorElement.textContent = result.error.message;
} else {
    fetch('{{ path('mon_path') }}', {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({card: result.setupIntent.payment_method})
    })
    .then(res => {
        if (res.status) document.location.reload();
    });
}

Lier la carte à notre utilisateur

Pour ce faire, c’est très simple ! Nous devons simplement attach cet ID à notre utilisateur Stripe. Nous pouvons donc créer cet endpoint:

#[Route('/card', name: 'mon_path', methods: ['POST'])]
public function addCard(Request $request): Response
{
    $params = json_decode($request->getContent(), true);
    $pm = $params['card'];

    $stripe = new \Stripe\StripeClient($_ENV['STRIPE_SK']);
    $stripe->paymentMethods->attach($pm, [
        'customer' => $this->getUser()->getStripeId(),
    ]);

    return $this->redirectToRoute('dashboard_settings');
}

Ici nous récupérons tout d’abord notre body envoyé en JSON. Puis nous extrayons le param « card » qui contient l’id. Ensuite on attach à notre customer via son stripe_id.

Pour ensuite pouvoir afficher les cards qu’il possède déjà, vous pouvez ajouter une fonction directement dans votre objet User. Comme ça elle sera accessible depuis n’importe quel controller ou template Twig. Vous pouvez donc ajouter:

/**
 * @return Array
 * @throws ApiErrorException
 */
public function getCards(): \Stripe\Collection
{
    $stripe = new \Stripe\StripeClient($_ENV['STRIPE_SK']);
    $cards = $stripe->paymentMethods->all([
        'customer' => $this->stripe_id,
        'type' => 'card'
    ]);

    return $cards;
}

Ceci renverra un array de PaymentMethods. Comme nous n’avons que des cartes, chaque PM aura une propriété « card ».

On peut donc ajouter dans notre template twig (ou ailleurs, comme vous voulez):

{% if app.user.cards.count == 0 %}
    <p>Vous n'avez aucune carte enregistrée.</p>
{% endif %}
{% for card in app.user.cards %}
    <div class="card-item">
        <div>
            <p>Carte {{ card.card.brand | capitalize }} N°{{ card.card.last4 }}</p>
            <span class="small">Exp. le: {{ card.card.exp_month }}/{{ card.card.exp_year }}</span>
        </div>
        <a href="{{ path('card_delete', { pm: card.id })}}">Supprimer</a>
    </div>
{% endfor %}

Je vous laisse afficher les infos que vous voulez. Vous pouvez var_dump() l’object retourné par Stripe pour voir tout ce qu’elle contient (ou lire la doc). Mais pour moi ce sont les informations essentielles.

Supprimer une card

J’ai ajouté un petit path(‘card_delete’) comme vous avez pu le voir. Donc votre page devrait crash si vous l’exécutez telle quelle. On va donc ajouter l’endpoint de suppression. Pour cela, rien de plus simple:

#[Route('/card/delete/{pm}', name: 'card_delete')]
public function deleteCard(Request $request, $pm): Response
{
    $stripe = new \Stripe\StripeClient($_ENV['STRIPE_SK']);
    $stripe->paymentMethods->detach($pm, []);

    return $this->redirectToRoute('a_redirect_endpoint');
}

Payer avec une carte pré-enregistrée

Pour payer avec une carte, la subtilité est juste de créer un intent « off session » et de confirmer. Pour cela, il faut juste ajouter les bons params.

Nous voulons donc créer un intent avec:

  • un prix
  • un type de méthode de paiement (ici ‘card‘)
  • une currency (ici ‘EUR‘)
  • l’id de notre customer
  • notre payment method
  • un off_session à true
  • un confirm à true

Notre code va donc ressembler à:

$intent = $stripe->paymentIntents->create([
    'payment_method_types' => ['card'],
    'amount' => $command->getPrice(), // En centimes s'il vous plait
    'currency' => 'EUR',
    'customer' => $command->getUser()->getStripeId(),
    'payment_method' => $pm,
    'off_session' => true,
    'confirm' => true,
]);

if ($intent['status'] === 'succeeded') {
  // Whatever you want to do, you already have the money anyway
}

À noter que si la carte de l’utilisateur force l’utilisation du 3D Secure, ceci ne marchera pas et vous devrez passer par la méthode « classique » de paiement.

Mais voilà ! Vous savez tout. Nous sommes désormais capables d’enregistrer/supprimer une card puis de débiter le client lors d’une commande.