Aller au contenu

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

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.

server/checkout.ts
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.

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-storage

NetInfo 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.

App.tsx
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.

screens/CheckoutScreen.tsx
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.

screens/ConfirmingScreen.tsx
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.

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.

screens/HostedCheckoutScreen.tsx
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.

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.

server/return.ts
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.

hooks/useOrderDeepLink.ts
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.

app.json
{
"expo": {
"scheme": "mystore"
}
}

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éroComportement attendu de l'application
+237670000000La feuille se ferme, l'écran d'attente se termine et l'écran de réussite apparaît
+237670000001Fonds insuffisants, erreur affichée, nouvelle tentative toujours possible pour la commande
+237670000003Délai de l'opérateur dépassé, le paiement finit expired et l'écran d'attente s'arrête sur l'échec
+237670000004Refus 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.

Que pensez-vous de ce contenu ?