Mobile Money avec React Native
Un checkout mobile sans clé dans le bundle, avec un retour qui fonctionne réellement.
Une application mobile est un espace public. Tout ce qui se trouve dans le fichier binaire peut être lu. Le checkout doit donc s'appuyer sur un backend qui conserve la clé et détermine le prix.
Cette recette présente deux méthodes avec React Native : la feuille de paiement native à privilégier et la page hébergée dans une WebView utilisée comme solution de repli. Les deux partagent la même partie serveur.
La règle suivie par toute la recette
Aucune clé ne doit se trouver dans le bundle
Une clé secrète (sk.) placée dans une application est compromise : n'en embarquez jamais. Lorsque Wajub la détecte utilisée depuis un navigateur, il refuse la requête et envoie une alerte de sécurité au propriétaire. Une clé publique (pk.) permettrait techniquement de créer un paiement, mais ne doit pas non plus être utilisée ici : son détenteur pourrait créer des paiements avec le montant de son choix. Votre backend crée le paiement. L'application n'atteint api.wajub.com que par le SDK, avec le jeton de session.
1. L'endpoint backend partagé par les deux méthodes
Une seule route lit le montant dans votre propre base de données. Un montant reçu depuis un téléphone est une suggestion, pas une instruction.
import express from 'express';
import { Wajub } from '@wajub/node';
// Your own order store and session check.
import { orders, requireAuth } from './app-helpers';
const app = express();
app.use(express.json());
const wajub = new Wajub({ apiKey: process.env.WAJUB_API_KEY! });
app.post('/api/checkout', requireAuth, async (req, res) => {
const order = await orders.findForUser(req.user.id, req.body.orderId);
if (!order) return res.status(404).json({ error: 'Unknown order' });
const payment = await wajub.payments.create(
{
amount: order.amount,
currency: order.currency,
customer: { email: req.user.email, phone: order.phone },
reference: order.id,
description: `Order ${order.id}`,
callback: `${process.env.API_URL}/orders/${order.id}/return`,
},
{ idempotencyKey: order.id },
);
await orders.update(order.id, { payment_id: payment.id });
res.json({
sessionToken: payment.authorization_token,
authorizationUrl: payment.authorization_url,
returnUrl: `${process.env.API_URL}/orders/${order.id}/return`,
});
});La réponse contient les deux valeurs. sessionToken pilote la feuille native, authorizationUrl la WebView et l'application surveille returnUrl. Les deux premières ne peuvent ni lister vos paiements, ni effectuer un remboursement, ni agir sur un autre client. Elles sont limitées à ce paiement et expirent avec lui.
`callback` doit être une URL `https`
Un schéma personnalisé comme myapp://payment/return est refusé à la création avec une erreur 422, car le champ doit contenir une véritable URL. Faites pointer callback vers une route https de votre backend. Cette route peut ensuite rediriger vers l'application si vous souhaitez utiliser un lien profond. Toute la suite repose sur cette méthode.
2. La feuille native à privilégier
@wajub/react-native présente une feuille de paiement native. L'application n'affiche aucun formulaire, ne traite aucun numéro de téléphone et ne voit jamais de clé.
npm install @wajub/react-native @react-native-community/netinfo @react-native-async-storage/async-storageNetInfo n'est pas facultatif : le client temps réel du package en a besoin. AsyncStorage conserve
d'un lancement à l'autre l'empreinte de l'appareil que lisent les contrôles antifraude de Wajub.
@stripe/stripe-react-native ne sert qu'à accepter des cartes par Stripe. Sur iOS, lancez ensuite
pod install dans ios/. Cette recette suit @wajub/react-native 1.5.
Encapsulez l'arborescence une seule fois.
import { WajubProvider } from '@wajub/react-native';
export default function App() {
return (
<WajubProvider>
<RootNavigator />
</WajubProvider>
);
}L'écran de checkout se résume ensuite à un bouton et un hook.
import { useState } from 'react';
import { Button, Text, View } from 'react-native';
import { usePayment } from '@wajub/react-native';
// Your HTTP client for your own backend.
import { api } from '../lib/api';
export function CheckoutScreen({ order }) {
const { present, PaymentSheet } = usePayment();
const [busy, setBusy] = useState(false);
async function pay() {
setBusy(true);
try {
const { sessionToken } = await api.post('/api/checkout', { orderId: order.id });
const result = await present({ sessionToken });
switch (result.status) {
case 'cancelled':
return;
case 'failed':
showError(result.error.message);
return;
default:
// complete or processing: your webhook decides.
navigation.navigate('Confirming', { orderId: order.id });
}
} finally {
setBusy(false);
}
}
return (
<View>
<Text>
{order.amount} {order.currency}
</Text>
<Button title="Pay with Mobile Money" onPress={pay} disabled={busy} />
{PaymentSheet}
</View>
);
}Observez la destination de complete et processing. Elle mène à l'écran Confirming, pas à
un écran de confirmation. La feuille affiche elle-même l'écran d'approbation, les étapes de
confirmation et les pages du prestataire, et ne se résout qu'à sa fermeture. complete signifie
qu'elle s'est fermée sur son écran de succès, pas que votre backend a enregistré le paiement.
processing signifie que le payeur l'a fermée pendant que l'opérateur attendait encore le code
PIN. Dans les deux cas, le résultat parvient à votre backend par le webhook. cancelled signifie
que le payeur est parti avant de payer : le prochain appui sur le bouton crée un nouveau paiement.
La page React Native détaille chaque résultat.
3. L'écran d'attente indispensable et souvent oublié
Une personne confirme un paiement Mobile Money en saisissant un code PIN. La feuille peut se fermer pendant que l'opérateur traite encore l'opération. L'application revient donc souvent vers un paiement toujours processing.
Interrogez votre propre backend, pas Wajub, et faites-le plusieurs fois plutôt qu'en continu.
import { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
import { api } from '../lib/api';
export function ConfirmingScreen({ route, navigation }) {
const { orderId } = route.params;
const [status, setStatus] = useState('pending');
useEffect(() => {
let attempts = 0;
const timer = setInterval(async () => {
attempts += 1;
const { status } = await api.get(`/api/orders/${orderId}`);
setStatus(status);
if (status !== 'pending' || attempts >= 20) {
clearInterval(timer);
if (status === 'fulfilled') navigation.replace('Success', { orderId });
}
}, 3000);
return () => clearInterval(timer);
}, [orderId]);
return (
<View>
<ActivityIndicator />
<Text>
Check your phone. Confirm the prompt with your PIN and this screen will update on its own.
</Text>
<Text>You can close the app. We will send you a confirmation either way.</Text>
</View>
);
}Vingt tentatives espacées de trois secondes représentent une minute de polling. L'écran s'arrête ensuite et indique au client qu'il recevra un e-mail. Cette dernière phrase évite un ticket de support : la commande n'est pas perdue, elle attend une action sur un téléphone.
L'application ne décide jamais qu'une commande est payée
Le polling de votre backend améliore seulement l'expérience utilisateur. La commande devient fulfilled à un seul endroit : le webhook payment.succeeded sur votre serveur, comme dans Accepter un paiement, guide complet.
4. La solution de repli avec WebView
Si vous ne pouvez pas installer le module natif, ouvrez la page hébergée dans une WebView. Cette solution fonctionne. Seule la détection de la fin demande une attention particulière.
À la fin, la page navigue vers le callback défini, une URL https de votre backend. Détectez-la dans onNavigationStateChange, puis fermez la vue.
import { WebView } from 'react-native-webview';
export function HostedCheckoutScreen({ route, navigation }) {
const { authorizationUrl, returnUrl, orderId } = route.params;
function onNavigationStateChange({ url }) {
if (!url.startsWith(returnUrl)) return;
navigation.replace('Confirming', { orderId });
}
return (
<WebView
source={{ uri: authorizationUrl }}
onNavigationStateChange={onNavigationStateChange}
startInLoadingState
/>
);
}Comparez l'adresse à returnUrl avec startsWith, car Wajub y ajoute ses propres paramètres d'URL. Leurs noms prêtent à confusion : reference contient l'identifiant du paiement Wajub, trxref la référence envoyée et status la valeur communiquée au navigateur. Ne lisez aucun de ces paramètres. Envoyez plutôt le client vers le même écran d'attente.
5. Ajouter un lien profond vers l'application
Certains parcours quittent entièrement l'application, par exemple lorsque l'opérateur ouvre la sienne pour obtenir la confirmation. Un lien profond ramène le client dans votre application.
Ne faites pas pointer callback vers votre schéma. Utilisez votre backend, puis laissez cette route effectuer la redirection.
app.get('/orders/:id/return', async (req, res) => {
const order = await orders.find(req.params.id);
if (!order) return res.status(404).send('Unknown order');
if (req.get('user-agent')?.includes('MyStoreApp')) {
return res.redirect(`mystore://orders/${order.id}`);
}
res.render('order-status', { order });
});Dans l'application, écoutez le schéma et analysez-le avec Linking.parse. La classe globale URL n'analyse pas toujours correctement un schéma personnalisé sous Hermes. C'est pourquoi l'implémentation la plus évidente échoue sur un appareil tout en fonctionnant dans un simulateur.
import { useEffect } from 'react';
import * as Linking from 'expo-linking';
export function useOrderDeepLink(onOrder: (orderId: string) => void) {
useEffect(() => {
function handle(url: string | null) {
if (!url) return;
const { path } = Linking.parse(url);
const match = path?.match(/^orders\/(.+)$/);
if (match) onOrder(match[1]);
}
const subscription = Linking.addEventListener('url', ({ url }) => handle(url));
Linking.getInitialURL().then(handle);
return () => subscription.remove();
}, [onOrder]);
}Déclarez le schéma afin que le système d'exploitation le dirige vers votre application.
6. Tester sur un appareil
La sandbox détermine le résultat à partir des six derniers chiffres du numéro. Vous pouvez ainsi parcourir tous les écrans précédents sans opérateur.
| Numéro | Comportement attendu de l'application |
|---|---|
+237670000000 | La feuille se ferme, l'écran d'attente se termine et l'écran de réussite apparaît |
+237670000001 | Fonds insuffisants, erreur affichée, nouvelle tentative toujours possible pour la commande |
+237670000003 | Délai de l'opérateur dépassé, le paiement finit expired et l'écran d'attente s'arrête sur l'échec |
+237670000004 | Refus du client, aucun débit, retour au panier |
Consacrez du temps au troisième cas. Un délai dépassé n'est pas un silence : il arrive sur votre serveur sous la forme de payment.failed quelques secondes plus tard, et l'écran d'attente doit donc aussi se fermer sur un échec.
Faites pointer l'application vers un tunnel, pas vers localhost
Un appareil ne peut pas atteindre le localhost de votre machine. Exposez votre backend avec un tunnel, définissez API_URL sur cette adresse pour rendre callback accessible, puis transférez les événements Wajub avec wajub listen --forward-to localhost:3001/webhooks/wajub.
Pages associées
- SDK React NativeLe provider, le hook et la feuille de paiement en détail.
- Checkout Mobile MoneyLe déroulement sur le téléphone et toutes les causes d'échec.
- Accepter un paiement, guide completLa partie backend sur laquelle repose cette recette.
- Sessions et sécuritéCe qu'un jeton de session peut et ne peut pas faire.
- Checkout hébergéToutes les décisions prises automatiquement par la page de la WebView.