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. L'API la refuse immédiatement lorsqu'une requête contient Origin ou Referer, puis 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 ne communique jamais directement avec api.wajub.com.
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';
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-nativeEncapsulez 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';
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 });
if ('cancelled' in result) return;
if (result.status === 'complete') {
navigation.navigate('Confirming', { orderId: order.id });
} else if (result.status === 'failed') {
showError(result.error.message);
}
} 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 la branche de réussite. Elle mène à l'écran Confirming, pas à un écran de confirmation. result.status === 'complete' signifie que la feuille s'est fermée correctement, pas que les fonds sont arrivés.
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';
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é, l'écran d'attente s'arrête proprement après une minute |
+237670000004 | Refus du client, aucun débit, retour au panier |
Consacrez du temps au troisième cas. C'est le seul moyen d'observer le comportement de votre application lorsqu'aucune réponse n'arrive. Sur un véritable réseau, ce cas survient plus souvent qu'un échec clair.
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.