Boutique en ligne : retour sur votre site
Votre serveur crée un lien de paiement par commande, y redirige le client, et le client revient sur votre site une fois le paiement terminé. C'est le parcours recommandé pour une boutique en ligne.
Le parcours
- Créez un lien à montant fixe pour la commande, avec
redirect_url(la page de retour) et l'identifiant de la commande dansmetadata. Enregistrez l'identifiant du lien (id) avec la commande. Une expiration courte (expires_at, par exemple une heure) évite qu'un ancien lien soit payé après une modification du panier. - Redirigez le client vers
payment_url. - Le client revient sur
redirect_url, avec des paramètres ajoutés à l'adresse :payment_id,payment_token,status,amount,currency,phone. - Votre serveur vérifie le paiement avec
GET /api/v1/payments/{payment_id}/: la réponse contientpayment_link_id, l'identifiant du lien d'origine, à comparer avec celui de la commande. - Le webhook confirme le paiement de façon définitive, même si le client ne revient jamais sur votre site (navigateur fermé, coupure réseau).
Le client peut les modifier dans sa barre d'adresse, par exemple pour présenter le paiement d'une autre commande, moins chère. Interrogez toujours l'API, et contrôlez que le paiement provient du lien de cette commande (payment_link_id), qu'il est réussi, réel, et du bon montant.
Implémentation
- Python
- Node.js
- PHP
from datetime import timedelta
from decimal import Decimal
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
def checkout(request, order_id):
order = get_object_or_404(Order, id=order_id, customer=request.user)
resp = session.post(f"{API_BASE}/pay-links/", json={
"title": f"Commande #{order.id}",
"is_amount_fixed": True,
"fixed_amount": str(order.total),
"expires_at": (timezone.now() + timedelta(hours=1)).isoformat(),
"redirect_url": request.build_absolute_uri(f"/commandes/{order.id}/retour/"),
"metadata": {"order_id": str(order.id)},
}, timeout=30)
resp.raise_for_status()
link = resp.json()
order.payment_link_id = link["id"] # pour vérifier le paiement au retour
order.save(update_fields=["payment_link_id"])
return redirect(link["payment_url"])
def payment_return(request, order_id):
order = get_object_or_404(Order, id=order_id, customer=request.user)
payment_id = request.GET.get("payment_id", "")
resp = session.get(f"{API_BASE}/payments/{payment_id}/", timeout=30)
if resp.status_code != 200:
return render(request, "paiement_en_attente.html", {"order": order})
payment = resp.json()
paid = (
payment["payment_link_id"] == str(order.payment_link_id) # le lien de CETTE commande
and payment["status"] == "SUCCEEDED"
and not payment["test"] # en production
and Decimal(payment["amount"]) == order.total
)
if paid:
order.mark_paid(payment_id=payment["id"]) # idempotent : sans effet si déjà payée
return render(request, "paiement_reussi.html", {"order": order})
if payment["status"] == "PENDING":
return render(request, "paiement_en_attente.html", {"order": order})
return render(request, "paiement_echoue.html", {"order": order})
app.post('/commandes/:id/payer', async (req, res) => {
const order = await Orders.findForCustomer(req.params.id, req.user);
const { data } = await api('POST', '/pay-links/', {
title: `Commande #${order.id}`,
is_amount_fixed: true,
fixed_amount: String(order.total),
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
redirect_url: `https://boutique.bf/commandes/${order.id}/retour`,
metadata: { order_id: String(order.id) },
});
await order.savePaymentLinkId(data.id); // pour vérifier le paiement au retour
res.redirect(data.payment_url);
});
app.get('/commandes/:id/retour', async (req, res) => {
const order = await Orders.findForCustomer(req.params.id, req.user);
const { status, data: payment } = await api('GET', `/payments/${encodeURIComponent(req.query.payment_id || '')}/`);
if (status !== 200) return res.render('paiement_en_attente', { order });
const paid = payment.payment_link_id === order.paymentLinkId // le lien de CETTE commande
&& payment.status === 'SUCCEEDED'
&& !payment.test
&& Number(payment.amount) === Number(order.total);
if (paid) {
await order.markPaid(payment.id);
return res.render('paiement_reussi', { order });
}
res.render(payment.status === 'PENDING' ? 'paiement_en_attente' : 'paiement_echoue', { order });
});
<?php
// $order : la commande du client connecté, chargée depuis votre base ;
// $order->paymentLinkId : l'id du lien, enregistré à sa création
$paymentId = $_GET['payment_id'] ?? '';
$response = $client->get('payments/' . rawurlencode($paymentId) . '/', ['http_errors' => false]);
if ($response->getStatusCode() !== 200) {
afficher('paiement_en_attente', $order);
exit;
}
$payment = json_decode($response->getBody(), true);
$paid = $payment['payment_link_id'] === $order->paymentLinkId // le lien de CETTE commande
&& $payment['status'] === 'SUCCEEDED'
&& !$payment['test']
&& bccomp($payment['amount'], $order->total, 2) === 0;
if ($paid) {
$order->marquerPayee($payment['id']);
afficher('paiement_reussi', $order);
} elseif ($payment['status'] === 'PENDING') {
afficher('paiement_en_attente', $order);
} else {
afficher('paiement_echoue', $order);
}
Si le client ne revient pas
Le client peut fermer son navigateur juste après avoir confirmé sur son téléphone. Dans ce cas, seul le webhook vous prévient : il contient link_metadata.order_id, l'identifiant de commande que vous avez donné au lien. Traitez le webhook et la page de retour avec la même fonction, qui ne fait rien si la commande est déjà payée.
Un paiement « en attente » (PENDING), fréquent avec Moov Money, se confirme en général en quelques secondes : affichez une page d'attente qui se recharge, ou un message invitant le client à vérifier ses e-mails.