Aller au contenu

Champs de paiement

Votre mise en page et votre bouton de paiement, Wajub affiche uniquement les champs sécurisés.

Les champs de paiement placent les champs sécurisés de Wajub dans votre propre checkout : vous gardez la page, le récapitulatif de commande et le bouton de paiement, et vous envoyez les champs quand vous êtes prêt. S'il vous faut vos propres champs de checkout personnalisés dans la page de paiement, utilisez plutôt le checkout complet.

Les étapes qu'un paiement peut ajouter après le premier envoi restent dans le composant : un code reçu par SMS (OTP), le code PIN d'un portefeuille, un numéro à confirmer, une date de naissance, une adresse, une validation sur le téléphone, un code USSD à composer et le 3D Secure. Vous n'avez rien à brancher pour elles, et confirmPayment() attend qu'elles soient terminées.

Les sept composants

TypeAffichageDéclenchement du paiement
cardDes champs de carte, ou un bouton qui ouvre la fenêtre de paiement sécurisée du prestataireVous, avec confirmPayment()
mobileMoneyOpérateur et numéro de téléphoneVous, avec confirmPayment()
bankTransferLe compte bancaire du payeurVous, avec confirmPayment()
cryptoL'actif avec lequel payerVous, avec confirmPayment()
paymentSélecteur de moyen de paiement et formulaire correspondantVous, avec confirmPayment()
addressAdresse de livraison ou de facturationPersonne, lisez-la avec getValue()
walletApple Pay ou Google PayBouton natif dans le composant

Structure générale

Une fabrique conserve la session et les options partagées. Chaque appel create() crée un composant, puis mount() le place sur la page.

Champs de carte et bouton personnalisé
import { components, confirmPayment } from '@wajub/js';

const factory = await components(sessionId, {
  locale: 'fr',
  appearance: { primaryColor: '#2563eb', labels: 'floating' },
});

const card = factory
  .create('card')
  .on('change', (event) => (payButton.disabled = !event.complete))
  .mount('#card');

payButton.onclick = () => confirmPayment({ sessionId, components: card });

Les options transmises à la fabrique deviennent les valeurs par défaut de tous ses composants. Les options transmises à create() sont prioritaires pour le composant concerné.

Les composants se dimensionnent automatiquement

Vous n'avez rien à mesurer. Par défaut, la page bouge une fois, quand le composant apparaît ; loader: 'skeleton' tient la place dès mount(), voir Charger sans décaler la page. Les écrans qui suivent un envoi, un code OTP ou PIN, une validation sur le téléphone, un code USSD, le 3D Secure, s'affichent en entier dans le composant.

Configuration

OptionTypeComposants concernésFonction
sessionIdstringTousObligatoire, défini une fois sur la fabrique
localestringTousfr, en, es, pt, ar
appearanceAppearanceConfigTousApparence
layoutCheckoutLayoutpaymentDisposition du sélecteur de moyen de paiement
collectAddressshipping ou billingpaymentAjoute un bloc d'adresse dans le formulaire
addressModeshipping ou billingaddressEnsemble de libellés à utiliser
collectNamebooleanpayment, addressAjoute le champ de nom du payeur
fields.phonealways, auto, neverpayment, addressVisibilité du champ de téléphone
fields.customerauto ou externalTous sauf addressexternal ne demande plus le contact, le nom ni l'adresse du payeur, votre serveur les envoie
fields.countryISO 3166 alpha-2mobileMoney, paymentDémarre sur ce pays, et masque le sélecteur de pays quand le moyen le couvre
fields.operatorchoose ou detectmobileMoney, paymentdetect lit l'opérateur dans le numéro au lieu de lister les opérateurs, et ne les liste que si le numéro ne permet pas de reconnaître l'opérateur
variantdefault ou fieldsTous sauf addressfields retire la marge intérieure et le fond, pour votre propre bloc de moyen de paiement
onBeforeSubmit() => boolean | Promise<boolean>Tous sauf addressS'exécute avant le débit. false ou une exception l'arrête
frameTitlestringTousLe nom du composant pour les lecteurs d'écran, dans la langue de votre page. Par défaut Wajub <type> component, par exemple Wajub mobileMoney component
loadernone ou skeletonTousCe que votre page affiche pendant le chargement du composant. none par défaut. Voir Charger sans décaler la page
readyTimeoutnumber, en millisecondesTousDurée d'attente de ready avant que onLoadError reçoive ready_timeout. 0 ou absent : aucune limite
Une adresse seule et un formulaire qui en contient une
factory
  .create('address', { addressMode: 'shipping', collectName: true, fields: { phone: 'auto' } })
  .mount('#address');

factory
  .create('payment', { collectAddress: 'shipping', collectName: true, layout: 'tabs' })
  .mount('#payment');

Charger sans décaler la page

Par défaut, avec loader: 'none', votre page bouge une fois, quand le composant prend sa hauteur. loader: 'skeleton' tient la place dès mount().

La place tenue dès mount()
const momo = factory
  .create('mobileMoney', {
    variant: 'fields',
    fields: { country: 'CM', operator: 'detect' },
    loader: 'skeleton',
    readyTimeout: 10000,
  })
  .mount('#momo-fields');

momo.focus(); // appelé pendant le chargement : le focus entre quand le formulaire est prêt
MomentCe que montre votre page
Dès mount()Le conteneur garde la hauteur du formulaire, et un squelette du formulaire, dessiné dans le style de vos champs, pulse légèrement. Le conteneur porte aria-busy="true"
À readyLe formulaire apparaît en fondu pendant que le squelette s'efface
focus() pendant le chargementLe focus entre dans le formulaire une fois celui-ci prêt, sauf si le payeur a tapé au clavier, cliqué ou déplacé le focus entre-temps
unmount(), destroy()Le conteneur retrouve ses propres styles, et un composant peut y être monté de nouveau

Pour tenir la même place dans votre propre mise en page avant que Wajub.js soit chargé, dans un rendu serveur et son hydratation par exemple, utilisez componentEstimatedHeight(type, variant, fields), exporté par @wajub/js : il renvoie la même hauteur sur le serveur et dans le navigateur.

Avec une Content Security Policy stricte, la pulsation a besoin du nonce des scripts ou des styles en ligne de votre page ; sans lui, le squelette reste simplement immobile. Il l'est aussi pour un payeur qui demande moins d'animations, ou avec appearance.disableAnimations.

@wajub/js utilise loader: 'none' par défaut. Les composants de champs React, Vue, Svelte et Angular utilisent skeleton par défaut.

Quand le formulaire n'arrive pas

readyTimeout, en millisecondes, borne l'attente de ready, comptée depuis chaque mount(). Au-delà, onLoadError et l'événement loaderror reçoivent une WajubError de code ready_timeout, marquée retryable, et un confirmPayment() qui attend le composant rejette avec la même erreur. Sans readyTimeout, vous n'êtes pas prévenu : donnez-le.

Un cadre bloqué, une origine que votre compte n'autorise pas ou une panne réseau en sont les causes habituelles. Proposez au payeur un autre moyen de payer plutôt qu'un bloc vide : retirez les champs et envoyez le payeur sur la page hébergée, comme le fait Mobile Money et carte dans votre propre checkout.

onLoadError et loaderror disent pourquoi un composant n'a jamais démarré. Rien n'a été payé.

CodeCe qui s'est passéQue faire
session_not_foundLe jeton est inconnu, ou la session n'a pas pu être lueVérifiez le jeton que votre serveur a remis à la page
session_terminal_expired, session_terminal_cancelled, session_terminal_failedLa session est déjà terminée sans paiement : elle a expiré, a été annulée, ou s'est terminée sur un paiement échoué. Le composant affiche un message à la place du formulaireCréez un nouveau paiement et montez les champs sur son jeton
component_origin_missingLe composant n'a pas été monté avec Wajub.js ou un paquet frameworkMontez-le avec l'un d'eux
invalid_componentUn type de composant qui n'existe pasUtilisez l'un des sept composants
method_unavailableLa session ne propose aucun moyen que ce composant accepteLisez fetchSession().payment_methods avant le montage, et montez un composant avec lequel la session peut être payée
load_errorLe composant n'a pas pu se chargerVérifiez l'onglet réseau et votre Content Security Policy
ready_timeoutPas de ready dans le délai de readyTimeoutProposez un autre moyen de payer, comme ci-dessus

Un composant dont la session est déjà payée affiche son message et envoie ready, pas une erreur : le paiement est déjà fait, traitez donc la commande depuis votre webhook.

Événements

Abonnez-vous avec .on(event, handler), qui renvoie le composant et permet de chaîner les appels.

ÉvénementPayloadDéclenchement
readyComponentInstanceFormulaire affiché et interactif, ou aussitôt quand le composant n'a aucun formulaire à afficher
change{ complete, empty, error, value }Modification d'un champ ou de sa validité
focusRienPrise de focus par un champ
blurRienPerte de focus par un champ
successTransactionRéussite du paiement
errorWajubErrorÉchec du paiement, ou refus d'un envoi que vous avez demandé
loaderrorWajubErrorLe composant n'a pas pu démarrer : sa session est terminée, il n'a pas pu se charger, ou pas de ready dans le délai readyTimeout. Les codes
resize{ height }Agrandissement ou réduction du composant, ou écran de suite d'un envoi qui le recouvre
statechange{ state, method }Passage du checkout à un autre état
methodchange{ methodId }Choix d'un autre moyen de paiement par le payeur
redirect{ url, method }Départ imminent de votre page vers la page du prestataire

Chaque événement a aussi un callback dans ComponentConfig, appelé juste avant lui : onReady, onChange et les autres. Le onResize d'un composant reçoit { height }, alors que celui du checkout hébergé reçoit la hauteur elle-même.

on() et off() typent la donnée de chaque événement connu avec ComponentEventMap : component.on('statechange', (event) => event.state) passe la vérification des types.

Contrôler votre propre bouton
component.on('change', (event) => {
  payButton.disabled = !event.complete;
  errorLabel.textContent = event.error ? event.error.message : '';
});

complete signifie valide, pas payé

change.complete indique que les champs réussiraient la validation. L'argent est transféré lors de success, mais seul votre webhook le confirme.

Verrouiller votre checkout pendant le paiement

Une fois le paiement activé par votre serveur, son montant est fixé. Pendant le paiement, un payeur qui change de moyen, de livraison ou de code promo sur votre page lui laisserait un total qui ne correspond plus. statechange indique quand le paiement est en cours : gardez votre propre checkout verrouillé tant que l'état est PROCESSING, VERIFYING, PENDING, OTP_REQUIRED, PIN_REQUIRED, PHONE_REQUIRED, BIRTHDAY_REQUIRED, ADDRESS_REQUIRED, 3DS_REQUIRED, USSD_REQUIRED ou APPROVAL_REQUIRED. APPROVAL_REQUIRED correspond au payeur qui valide sur son téléphone.

Votre checkout suit le paiement
const RUNNING = [
  'PROCESSING', 'VERIFYING', 'PENDING', 'OTP_REQUIRED', 'PIN_REQUIRED', 'PHONE_REQUIRED',
  'BIRTHDAY_REQUIRED', 'ADDRESS_REQUIRED', '3DS_REQUIRED', 'USSD_REQUIRED', 'APPROVAL_REQUIRED',
];

const momo = factory.create('mobileMoney', {
  onStateChange: ({ state }) => {
    const locked = RUNNING.includes(state);
    checkoutOptions.disabled = locked; // un <fieldset> : moyens, livraison, code promo
    payButton.disabled = locked;
    hint.textContent = state === 'APPROVAL_REQUIRED' ? 'Validez le paiement sur votre téléphone.' : '';
  },
});

Verrouillez vos propres contrôles, jamais le composant : laissez son conteneur visible et accessible, car les étapes auxquelles répond le payeur s'y trouvent.

Envoyer le paiement

confirmPayment reçoit la session et un composant monté, l'envoie et attend le résultat.

Réussite ou rejet avec une WajubError
import type { WajubError } from '@wajub/js';

try {
  const { status, transaction } = await confirmPayment({ sessionId, components: card });
  showReceipt(status, transaction);
} catch (error) {
  // A caught value is `unknown` in strict mode: confirmPayment rejects with a WajubError.
  const { code, message } = error as WajubError;
  showRetry(code, message);
}
OptionValeur par défautFonction
sessionIdObligatoireSession utilisée pour créer le composant
componentsObligatoireUn composant card, mobileMoney, bankTransfer, crypto ou payment monté
timeout60000Millisecondes d'attente du résultat avant le rejet avec confirmation_timeout. 0 attend indéfiniment. Le temps pendant lequel le paiement attend le payeur ne compte pas

Trois rejets se produisent avant tout envoi. Ils signalent donc des erreurs d'intégration, pas des échecs de paiement.

CodeCause
missing_componentAucun composant transmis ou objet incapable d'envoyer le paiement
wallet_not_supportedComposant wallet, dont le bouton natif déclenche le paiement
address_not_paymentComposant address, à lire avec getValue()

Une fois l'envoi fait, c'est le paiement qui met fin à l'attente, ou le délai.

CodeCause
confirmation_timeoutAucun résultat reçu avant timeout. Le paiement continue et peut encore réussir
checkout_validationonBeforeSubmit a renvoyé false, a levé une exception ou a mis plus de 30 secondes
submission_failedLe composant a refusé votre envoi avant tout débit : un champ qui n'est pas prêt, un numéro de carte refusé, ou une fenêtre de paiement fermée ou qui n'a pas pu s'ouvrir
payment_errorL'API a refusé le débit. Sur un paiement préparé, il n'était pas encore activé
payment_cancelledLe payeur a annulé. Peut être retenté
session_expiredLa session a expiré avant la fin du paiement
component_unmountedLe composant a été démonté, détruit ou retiré de la page pendant l'attente

timeout ne court pas pendant que le paiement attend le payeur, OTP_REQUIRED, PIN_REQUIRED, PHONE_REQUIRED, BIRTHDAY_REQUIRED, ADDRESS_REQUIRED, APPROVAL_REQUIRED, USSD_REQUIRED, 3DS_REQUIRED ou PENDING, ni une fois que votre page est partie chez le prestataire (redirect). Un composant remonté repart de INITIATED.

Un refus porte son code de refus, et un composant qui ne se charge pas rejette avec son erreur de chargement, ready_timeout compris quand vous définissez readyTimeout.

submission_failed répond à un envoi que vous avez demandé, avec confirmPayment() ou submit(), et que le composant a refusé avant tout débit : un numéro Mobile Money vide ou invalide, aucun opérateur choisi, une carte laissée incomplète, des champs encore en chargement, un virement sans IBAN ni numéro de compte, un paiement en crypto sans actif choisi, un numéro de carte refusé, l'e-mail du payeur manquant. Son message indique pourquoi, dans la langue du payeur. Affichez-le et réactivez votre bouton : le payeur corrige le champ et vous envoyez de nouveau. Si le payeur appuie sur Entrée dans un formulaire qui n'est pas prêt, le composant lui montre la raison et vous ne recevez rien, puisque vous n'avez rien envoyé.

Pendant le paiement, un nouvel envoi ne fait rien : votre bouton, ou Entrée dans un champ, ne débite pas une seconde fois et ne rappelle pas onBeforeSubmit, et c'est le paiement en cours qui vous donne son résultat.

Certaines cartes se paient dans la fenêtre sécurisée du prestataire, qui s'ouvre au-dessus de votre page une fois la commande acceptée par onBeforeSubmit. Quand le navigateur la bloque, error apporte submission_failed avec un message qui le dit, et le composant affiche un bouton qui ouvre la fenêtre pour le même paiement, sans rappeler onBeforeSubmit.

Quand le paiement se poursuit sur la page du prestataire

Certains paiements se terminent sur la page du prestataire, un paiement en crypto par exemple. Toute votre page s'y rend, et le composant émet redirect avec { url, method } juste avant son départ.

La dernière chose qu'entend votre page
component.on('redirect', () => {
  // La page part dès le retour de ce handler, une tâche asynchrone peut ne pas se terminer.
  sessionStorage.setItem('pending-order', orderId);
});

Le callback onRedirect de create() reçoit le même { url, method }, juste avant l'événement. Votre page part dans tous les cas, même quand le callback ou un écouteur lève une erreur.

Votre page est partie, et le composant avec elle : success n'y est pas émis, et confirmPayment() ne se termine pas. Selon le moyen de paiement, le payeur revient soit par la page Wajub de la session, qui affiche le résultat puis l'envoie sur le callback du paiement avec status, reference et trxref, comme après un checkout avec redirection, soit directement sur votre callback, sans ces paramètres. Sans callback, le résultat reste affiché sur la page Wajub.

La page callbackPourquoi
Existe, en HTTPS en liveDéfinissez-la sur POST /payments, ou à l'activation d'un paiement préparé. Sans elle, le payeur n'est pas ramené sur votre site. Dans la sandbox, http://localhost et http://127.0.0.1 sont aussi acceptés
Lit le résultat sur votre serveurLe payeur peut revenir sans status, reference ni trxref : faites porter la commande par l'URL, /orders/4172/return par exemple. Dans tous les cas, lisez GET /payments/{id}, ou attendez le webhook payment.succeeded
Affiche un paiement encore en attenteUn paiement en crypto peut encore être en cours de confirmation sur la blockchain au retour du payeur. Indiquez qu'il est en route, et laissez le webhook finaliser la commande

Avant que la commande existe

Un checkout affiche ses moyens de paiement avant que la commande soit passée : l'acheteur choisit encore une livraison, saisit un code promo, change de pays. Créez la session à ce moment-là avec deferred: true : le paiement est seulement préparé. Il reste hors de vos paiements et n'envoie aucun webhook tant que votre serveur ne l'a pas activé, une fois la commande passée. Un acheteur qui s'en va ne laisse aucune trace, tant que vous n'envoyez aucun client avant l'activation : un client envoyé à la préparation est créé, avec son customer.created.

ÉtapeOùAppel
Le checkout s'ouvreVotre serveurPOST /payments avec deferred: true, le montant et la devise
Le total changeVotre serveurPATCH /payments/{id} avec le nouveau amount et la currency
L'acheteur paieVotre serveur, depuis onBeforeSubmitPasser la commande, puis POST /payments/{id}/activate
Le débitLe composantS'exécute dès que onBeforeSubmit a renvoyé true
Préparer, à l'ouverture du checkout
curl https://api.wajub.com/payments \
-H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{ "amount": 35000, "currency": "XOF", "deferred": true, "expires": { "in": 30 } }'

Aucun client n'est encore nécessaire. La réponse a la forme d'un paiement créé : envoyez authorization_token au navigateur comme sessionId, et gardez transaction.id avec le panier. Quand le total change, modifiez ce même paiement au lieu d'en préparer un autre. Les composants montés gardent leur session.

L'acheteur a choisi une livraison
curl -X PATCH https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
-H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{ "amount": 37500, "currency": "XOF" }'

PATCH soumet le nouveau total aux règles de POST /payments. Un montant hors des limites de la devise répond 422 avec errors.amount. Sur un paiement préparé avec accept, un total qu'aucun des moyens conservés ne peut prendre répond 422 avec errors.accept. Dans les deux cas, le paiement garde son total précédent.

PATCH accepte aussi expires, { "in": 120 } par exemple : une nouvelle expiration comptée à partir de maintenant, de 5 à 43 200 minutes, que le paiement garde une fois activé. Envoyez-la quand le checkout reste ouvert plus longtemps que prévu. Sans elle, l'expiration ne change pas. Une fois le paiement expiré, annulé ou activé, PATCH répond 409 : préparez-en alors un nouveau.

Dans le navigateur, c'est dans onBeforeSubmit que la commande est passée. Le composant l'appelle une fois les champs remplis par le payeur, avant tout débit.

Monter maintenant, passer la commande à l'envoi
const factory = await components(sessionId, { locale: 'fr' });

const momo = factory
  .create('mobileMoney', {
    fields: { customer: 'external' },
    onBeforeSubmit: async () => {
      // Votre serveur vérifie le panier, passe la commande, puis active le paiement.
      const response = await fetch('/checkout/place-order', { method: 'POST' });
      return response.ok;
    },
  })
  .mount('#momo');

payButton.onclick = () => confirmPayment({ sessionId, components: momo });
Activer, depuis votre endpoint de commande
curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx/activate \
-H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{
  "amount": 37500,
  "currency": "XOF",
  "reference": "order-4172",
  "description": "Order #4172",
  "customer": { "email": "buyer@example.com", "name": "Amina Diallo" },
  "metadata": { "order_id": "4172" }
}'

Le paiement existe désormais, avec l'id que vous avez gardé, et l'événement payment.created est envoyé. Le jeton de session du navigateur reste le même : le composant débite sans être remonté.

reference et description nomment le paiement d'après la commande, qui n'existait pas encore à la préparation : la référence sur laquelle vos webhooks font la correspondance, et le libellé que lit le payeur. Elles remplacent celles données à la préparation. Absentes, celles de la préparation restent.

RèglePourquoi
L'activation reprend le total préparéUn autre amount ou une autre currency répond 422. Faites d'abord un PATCH
L'activation peut être rejouéeLe même corps répond de nouveau 200. Un autre corps, y compris une autre reference ou description, répond 409
L'activation demande la clé secrèteUne clé publique répond 406
onBeforeSubmit doit répondre en moins de 30 secondesUne attente plus longue arrête le paiement avec checkout_validation
fields.customer: 'external'Le payeur ne ressaisit pas ses coordonnées : l'activation apporte le client. Un prestataire de carte qui redirige peut encore demander un e-mail
DELETE /payments/{id} avant l'activationMet fin à la session, sans webhook
callback à l'activationL'adresse où arrive le payeur après un paiement poursuivi sur la page du prestataire. Voir Quand le paiement se poursuit sur la page du prestataire

Dans votre propre bloc de moyen de paiement

Quand votre checkout liste lui-même les moyens de paiement, un bloc par moyen avec un bouton radio, montez le composant dans le bloc sélectionné avec variant: 'fields'. Il retire sa marge intérieure et son fond : les champs s'insèrent dans votre bloc, qui apporte le titre, la bordure et le logo. Le Mobile Money remplace aussi son panneau d'instructions par une indication d'une ligne. Les autres types gardent leur panneau. Le composant prend la hauteur de ses champs.

Les composants de champs React, Vue, Svelte et Angular utilisent loader: 'skeleton' par défaut : votre bloc finit à la hauteur des champs. Avec loader: 'none', ils tiennent une hauteur minimale à eux : passez 0, minHeight={0}, :min-height="0" ou [minHeight]="0", pour que votre bloc épouse les champs. Un minHeight que vous passez, ou en React, Vue et Angular un min-height dans style, est gardé pendant toute la vie du composant.

Le Mobile Money dans son bloc radio
factory
  .create('mobileMoney', {
    variant: 'fields',
    fields: { customer: 'external', country: 'CM', operator: 'detect' },
    frameTitle: 'Paiement Mobile Money',
    onBeforeSubmit: placeOrder,
  })
  .mount('#method-mobile-money .fields');
OptionEffet sur le Mobile Money
fields.country: 'CM'Démarre sur ce pays et masque le sélecteur quand le moyen le couvre. Passez le pays que votre formulaire a déjà demandé
fields.operator: 'detect'Pas de liste d'opérateurs. L'opérateur est lu dans le numéro et affiché dans le champ de téléphone. La liste n'apparaît que si le numéro saisi ne correspond à aucun opérateur accepté par ce paiement

Mobile Money et carte dans votre propre checkout

Votre checkout garde son propre formulaire, ses propres blocs de moyen de paiement et son propre bouton « Payer ». Chaque bloc n'affiche que les champs de son moyen, dans le style de votre page, et la commande est passée quand le payeur paie. Cette recette assemble les sections ci-dessus.

  1. Votre serveur prépare la session à l'ouverture du checkout. POST /payments avec deferred: true, le montant, la devise et une expiration, avec votre clé secrète. Envoyez à la page authorization_token, son sessionId, et authorization_url, la page hébergée de secours. Gardez transaction.id avec le panier, et faites un PATCH du même paiement quand le total change. Voir Avant que la commande existe.
  2. La page monte les champs du moyen choisi par le payeur, dans son bloc : variant: 'fields', appearance: { inherit: true }, loader: 'skeleton', readyTimeout et fields: { customer: 'external' }, avec country et operator: 'detect' pour le Mobile Money. inherit lit un champ texte de votre formulaire, son libellé, son bouton d'envoi et le fond derrière le bloc : affichez donc vos propres champs d'abord. Changer de moyen détruit un composant et monte l'autre, sur la même session.
  3. onBeforeSubmit passe la commande. Votre serveur vérifie le panier, crée la commande et active le paiement avec POST /payments/{id}/activate : le total final, le client, votre reference et le callback où revient le payeur. Renvoyez false pour arrêter : rien n'est débité.
  4. Votre bouton « Payer » envoie le paiement, avec confirmPayment(), ou avec submit() et les callbacks onSuccess et onError. Le timeout par défaut suffit : il ne court pas pendant que le payeur valide.
  5. onStateChange garde votre checkout verrouillé pendant le paiement, et demande au payeur de valider sur son téléphone pendant APPROVAL_REQUIRED. Voir Verrouiller votre checkout pendant le paiement.
  6. onLoadError avec ready_timeout se replie sur la page hébergée. Retirez les champs et faites de votre bouton « Payer » un passage vers la page hébergée. Un paiement préparé ne peut pas être débité avant son activation : ce bouton passe donc d'abord la commande, comme onBeforeSubmit, puis envoie le navigateur sur authorization_url.
  7. Après success, emmenez le payeur sur votre page de confirmation, et livrez sur le webhook payment.succeeded ou GET /payments/{id}, jamais sur le seul navigateur. Un refus s'affiche dans le composant, et le payeur peut réessayer dans le même formulaire.
checkout.ts
import { components, confirmPayment } from '@wajub/js';

// Préparé par votre serveur à l'ouverture du checkout (POST /payments, deferred: true).
const { sessionId, hostedUrl } = await fetch('/checkout/payment', { method: 'POST' }).then((r) => r.json());

const RUNNING = [
  'PROCESSING', 'VERIFYING', 'PENDING', 'OTP_REQUIRED', 'PIN_REQUIRED', 'PHONE_REQUIRED',
  'BIRTHDAY_REQUIRED', 'ADDRESS_REQUIRED', '3DS_REQUIRED', 'USSD_REQUIRED', 'APPROVAL_REQUIRED',
];
const factory = await components(sessionId, { locale: 'fr' });
const state = { complete: false, running: false, fallback: false };
let fields = null;

function render() {
  checkoutOptions.disabled = state.running; // un <fieldset> : moyens, livraison, code promo
  payButton.disabled = state.running || !(state.complete || state.fallback);
}

// Votre serveur vérifie le panier, crée la commande et active le paiement.
async function placeOrder() {
  const response = await fetch('/checkout/place-order', { method: 'POST', body: new FormData(checkoutForm) });
  return response.ok; // false : rien n'est débité
}

function mountFields(method) {
  fields?.destroy();
  state.complete = false;
  render();
  fields = factory
    .create(method, {
      variant: 'fields',
      appearance: { inherit: true },
      loader: 'skeleton',
      readyTimeout: 10000,
      fields: method === 'mobileMoney'
        ? { customer: 'external', country: shippingCountry.value, operator: 'detect' }
        : { customer: 'external' },
      frameTitle: method === 'mobileMoney' ? 'Paiement Mobile Money' : 'Paiement par carte',
      onChange: ({ complete }) => { state.complete = Boolean(complete); render(); },
      onBeforeSubmit: placeOrder,
      onStateChange: ({ state: step }) => {
        state.running = RUNNING.includes(step);
        hint.textContent = step === 'APPROVAL_REQUIRED' ? 'Validez le paiement sur votre téléphone.' : '';
        render();
      },
      onLoadError: (error) => {
        if (error.code !== 'ready_timeout') return;
        fields.destroy();
        fields = null;
        state.fallback = true;
        payButton.textContent = 'Continuer sur la page de paiement sécurisée';
        render();
      },
    })
    .mount(`#method-${method} .fields`);
}

for (const radio of methodRadios) {
  radio.addEventListener('change', () => state.fallback || mountFields(radio.value));
}
mountFields('mobileMoney');

payButton.addEventListener('click', async () => {
  if (state.fallback) {
    // Un paiement préparé n'est débité qu'une fois activé : passez d'abord la commande.
    if (await placeOrder()) window.location.assign(hostedUrl);
    return;
  }
  try {
    await confirmPayment({ sessionId, components: fields });
    window.location.assign('/order/complete');
  } catch {
    // Le composant affiche la raison. Le payeur corrige et paie de nouveau.
  }
});

onBeforeSubmit peut s'exécuter de nouveau quand le payeur réessaie après un refus. Faites répondre votre endpoint de commande avec la même commande : une activation avec le même corps répond de nouveau 200. La même recette en React, Vue, Svelte et Angular utilise les composants de champs de chaque paquet.

Piloter un composant

MéthodeFonction
mount(container)S'attache à un sélecteur ou un élément
update({ locale, appearance, layout, frameTitle })Applique la modification en direct sans nouveau montage
focus(), blur()Donne ou retire le focus au premier champ. Avec loader: 'skeleton', focus() appelé pendant le chargement attend le formulaire
submit()Envoie sans confirmPayment, vous gérez les événements
selectMethod(id)Change de moyen de paiement sur un composant payment
isComplete()Indique si les champs réussiraient la validation
getState()CheckoutState actuel, INITIATED avant toute action
getError()Dernière WajubError, ou null
getValue()Valeurs de l'adresse, { name, phone, address, mode }
unmount(), destroy()Retire le composant du DOM, en conservant ou non l'instance

update({ appearance }) change les clés que vous donnez et garde les autres. Une clé donnée à undefined ou null est effacée, et appearance: undefined ou null efface toute l'apparence : le composant reprend le style de votre Dashboard Wajub, et son prochain mount() part sans elle. Avec appearance.inherit, la page est relue à chaque appel, et retirer inherit retire aussi ce qui avait été lu. update({ appearance: {} }) ne change aucune clé.

Fusionnée, effacée, réinitialisée
component.update({ appearance: { borderRadius: '16px' } }); // les autres clés restent
component.update({ appearance: { primaryColor: undefined } }); // efface cette clé
component.update({ appearance: undefined }); // retour au style de votre Dashboard Wajub

getValue() renvoie null jusqu'à ce que le composant d'adresse signale une valeur lors de son premier change. Lisez-la dans le handler ou lors de l'envoi par le payeur, pas au montage.

Avec un framework

En React, Vue et Svelte, chaque type a son propre composant, de CardComponent à CryptoComponent, et ComponentEmbed prend le type en prop. En Angular, chacun est un composant standalone, de <wajub-card> à <wajub-crypto>, et <wajub-component> prend son type. Dans chaque framework :

  • Chaque option est une prop, loader, readyTimeout, onStateChange, onResize et onRedirect compris. En Angular, les options sont des inputs et les callbacks des outputs, (stateChange), (resize), (redirect) et les autres, et onBeforeSubmit est l'input beforeSubmit, car un output ne peut pas répondre. loader vaut skeleton par défaut.
  • Avec skeleton, le conteneur tient la place du formulaire dès le premier rendu, rendu serveur compris. minHeight est décrit dans Dans votre propre bloc de moyen de paiement.
  • Un changement d'appearance, de locale, de layout ou de frameTitle s'applique au composant monté sans rechargement : un objet écrit en ligne convient donc.
  • Une clé de premier niveau d'appearance retirée depuis le dernier rendu est effacée, et une appearance devenue undefined ou {} revient à votre image de marque.
  • Un nouveau sessionId, type, variant, fields, loader, readyTimeout, collectAddress, addressMode ou collectName monte un nouveau composant dans le même conteneur : le précédent est d'abord détruit, et le rappel d'instance reçoit null entre les deux.
  • Un nouveau handler prend le relais sans recharger le formulaire.
  • useConfirmPayment, injectConfirmPayment() en Angular, lit la session au moment de confirmer : un confirm gardé d'un rendu précédent paie la session actuelle, et un sessionId passé à confirm() l'emporte, et le composant confirmé est toujours celui que vous passez.
  • En Angular, un objet appearance ou fields modifié sur place compte, comme un nouvel objet.
const { confirm, isConfirming } = useConfirmPayment(sessionId);
const field = useRef<ComponentInstance | null>(null);

<PaymentComponent sessionId={sessionId} onInstance={(i) => (field.current = i)} />;
<button disabled={isConfirming} onClick={() => field.current && confirm(field.current)}>
  Pay now
</button>;

useConfirmPayment protège contre les doubles envois. Un second clic lorsque isConfirming vaut true est ignoré. Il réussit avec { status } et rejette avec une WajubError, dont confirmation_timeout.

<MobileMoneyComponent
  sessionId={sessionId}
  frameTitle="Paiement Mobile Money"
  // La page part juste après : rien d'asynchrone n'est sûr de finir.
  onRedirect={rememberPendingOrder}
/>

Le provider, les tableaux de props et l'intégration côté serveur figurent dans React, Vue, Svelte et Angular, chacun avec Mobile Money et carte dans votre propre checkout écrit pour ce framework.

Fonctionnalités abandonnées

FonctionnalitéCheckout hébergéChamps de paiement
Moyens de paiement enregistrésOuiOui
3DSOuiOui
PortefeuillesOuiOui, avec le bouton natif
CouponsNonNon
Champs de checkout personnalisésOuiNon
Flux de livraison completNonBloc d'adresse uniquement
OTP, PIN, USSD et les autres étapes de suiteOuiOui

Que pensez-vous de ce contenu ?