Skip to content

Brancher un paiement ​

Le problème

Entre le moment où l'utilisateur choisit sa place et celui où il paie, il se passe du temps — parfois plusieurs minutes, avec une redirection vers un prestataire de paiement. Il faut garantir la place pendant ce laps, ne pas la vendre deux fois, et ne pas la garder immobilisée si le paiement échoue.

Le flux, vu de haut ​

Navigateur                 Votre serveur              Mitoera
    │                           │                        │
    │ 1. ouvre la page          │                        │
    │──────────────────────────▶│ sessions.create()      │
    │                           │───────────────────────▶│
    │◀── sessionToken ──────────│◀── session ────────────│
    │                           │                        │
    │ 2. sélectionne des sièges │                        │
    │═════════════ hold automatique ════════════════════▶│
    │                           │                        │
    │ 3. onCheckout(seats)      │                        │
    │──────────────────────────▶│ crée la commande       │
    │◀── URL de paiement ───────│                        │
    │                           │                        │
    │ 4. paie chez le PSP       │                        │
    │                           │◀── webhook paiement OK │
    │                           │ holds.book() ─────────▶│
    │                           │                        │
    │ 5. page de confirmation   │                        │

Le point clé : c'est le webhook du prestataire qui déclenche book(), pas le retour navigateur. Un utilisateur qui ferme son onglet après avoir payé doit quand même obtenir ses places.

Étape 1 — Ouvrir la session ​

ts
app.get('/billetterie/:slug', async (req, res) => {
  const event   = await client.events.findByIdentifier(req.params.slug);
  const session = await client.sessions.create(event.id);

  req.session.mitoera = {
    eventId:   event.id,
    holdToken: session.holdToken,     // reste côté serveur
  };

  res.render('billetterie', {
    eventSlug:    req.params.slug,
    sessionToken: session.sessionToken,   // seul jeton exposé au navigateur
  });
});
php
#[Route('/billetterie/{slug}', methods: ['GET'])]
public function page(string $slug, Request $request): Response
{
    $event   = $this->mitoera->events->findByIdentifier($slug);
    $session = $this->mitoera->sessions->create($event->id);

    $request->getSession()->set('mitoera', [
        'eventId'   => $event->id,
        'holdToken' => $session->holdToken,   // reste côté serveur
    ]);

    return $this->render('billetterie.html.twig', [
        'eventSlug'    => $slug,
        'sessionToken' => $session->sessionToken,
    ]);
}

Étape 2 — Récupérer la sélection au checkout ​

Côté page, onCheckout envoie les sièges à votre serveur :

js
onCheckout: async (seats) => {
  const res = await fetch('/api/commande', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({ seatKeys: seats.map(s => s.seatKey) }),
  });

  const { paymentUrl } = await res.json();
  window.location.href = paymentUrl;
},

Côté serveur, on crée la commande et on part chez le PSP :

ts
app.post('/api/commande', async (req, res) => {
  const { eventId, holdToken } = req.session.mitoera;
  const seatKeys = req.body.seatKeys;

  // Les sièges sont déjà en hold : le widget l'a fait à la sélection.
  const commande = await db.commandes.create({
    eventId, seatKeys, montant: calculerMontant(seatKeys), statut: 'en_attente',
  });

  const paiement = await psp.createCheckout({
    montant:  commande.montant,
    metadata: { commandeId: commande.id },
  });

  res.json({ paymentUrl: paiement.url });
});
php
#[Route('/api/commande', methods: ['POST'])]
public function commande(Request $request): JsonResponse
{
    ['eventId' => $eventId, 'holdToken' => $holdToken] = $request->getSession()->get('mitoera');
    $seatKeys = $request->toArray()['seatKeys'];

    // Les sièges sont déjà en hold : le widget l'a fait à la sélection.
    $commande = $this->commandes->creer($eventId, $seatKeys, $this->calculerMontant($seatKeys));

    $paiement = $this->psp->createCheckout(
        montant:  $commande->montant,
        metadata: ['commandeId' => $commande->id],
    );

    return $this->json(['paymentUrl' => $paiement->url]);
}

Prolongez le hold si le tunnel est long

La durée de hold est configurée dans le back-office. Si votre parcours de paiement dépasse cette durée, appelez sessions.refresh(sessionToken) avant la redirection, ou reposez un hold explicite avec holds.hold().

Étape 3 — Confirmer au webhook ​

ts
app.post('/webhook/paiement', async (req, res) => {
  const evenement = psp.verifierSignature(req);        // toujours vérifier
  if (evenement.type !== 'paiement.reussi') return res.sendStatus(200);

  const commande = await db.commandes.get(evenement.metadata.commandeId);
  if (commande.statut === 'confirmee') return res.sendStatus(200);   // idempotence

  try {
    await client.holds.book(commande.eventId, commande.seatKeys, commande.holdToken);
    await db.commandes.update(commande.id, { statut: 'confirmee' });
  } catch (e) {
    if (e instanceof ApiException && e.statusCode === 409) {
      // Place perdue malgré le hold (expiration, intervention manuelle)
      await psp.rembourser(evenement.paiementId);
      await db.commandes.update(commande.id, { statut: 'rembourse' });
      await notifierClient(commande);
    } else {
      throw e;
    }
  }

  res.sendStatus(200);
});
php
#[Route('/webhook/paiement', methods: ['POST'])]
public function webhook(Request $request): Response
{
    $evenement = $this->psp->verifierSignature($request);        // toujours vérifier
    if ($evenement->type !== 'paiement.reussi') {
        return new Response('', 200);
    }

    $commande = $this->commandes->get($evenement->metadata['commandeId']);
    if ($commande->statut === 'confirmee') {
        return new Response('', 200);                            // idempotence
    }

    try {
        $this->mitoera->holds->book($commande->eventId, $commande->seatKeys, $commande->holdToken);
        $this->commandes->confirmer($commande->id);
    } catch (ApiException $e) {
        if ($e->statusCode !== 409) {
            throw $e;
        }
        // Place perdue malgré le hold (expiration, intervention manuelle)
        $this->psp->rembourser($evenement->paiementId);
        $this->commandes->rembourser($commande->id);
        $this->notifierClient($commande);
    }

    return new Response('', 200);
}

Étape 4 — Libérer en cas d'abandon ​

Trois cas d'abandon à couvrir :

CasDétectionAction
L'utilisateur revient sans payerRetour sur l'URL cancel_url du PSPholds.release()
Le paiement échoueWebhook paiement.echoueholds.release()
L'utilisateur disparaîtRien — le hold expire tout seulAucune, mais une tâche de ménage sur vos commandes en_attente fait du bien
ts
app.get('/paiement/annule', async (req, res) => {
  const { eventId, holdToken } = req.session.mitoera;
  const commande = await db.commandes.dernierEnAttente(req.session.id);

  if (commande) {
    await client.holds.release(eventId, commande.seatKeys, holdToken);
    await db.commandes.update(commande.id, { statut: 'annulee' });
  }

  res.redirect(`/billetterie/${commande.slug}`);
});
php
#[Route('/paiement/annule', methods: ['GET'])]
public function annule(Request $request): Response
{
    ['eventId' => $eventId, 'holdToken' => $holdToken] = $request->getSession()->get('mitoera');
    $commande = $this->commandes->dernierEnAttente($request->getSession()->getId());

    if ($commande) {
        $this->mitoera->holds->release($eventId, $commande->seatKeys, $holdToken);
        $this->commandes->annuler($commande->id);
    }

    return $this->redirectToRoute('billetterie', ['slug' => $commande->slug]);
}

Les cinq règles à respecter ​

  1. Confirmez au webhook, pas au retour navigateur. Le retour peut ne jamais arriver.
  2. Rendez le webhook idempotent. Les PSP rejouent leurs notifications ; book() deux fois sur la même commande doit être sans effet.
  3. Traitez le 409 comme un cas prévu. Un hold peut expirer pendant un paiement long : prévoyez le remboursement automatique.
  4. Gardez le holdToken côté serveur. Il ne doit jamais transiter par le navigateur.
  5. Libérez explicitement à l'abandon. Attendre l'expiration immobilise des places vendables.

Voir aussi ​

API, SDK et widget de plans de salle.