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';

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

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';

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.

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

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é, l'écran d'attente s'arrête proprement après une minute
+237670000004Refus 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.

Que pensez-vous de ce contenu ?