Aller au contenu

iOS (Swift)

Le package Swift WajubMobile, une interface de paiement SwiftUI et aucune WebView.

Le SDK iOS encaisse les paiements dans votre application native. Il présente les paiements Mobile Money, par carte et par portefeuille dans une interface de paiement SwiftUI, sans WebView pour les écrans de Wajub, et ouvre dans Safari la page carte d'un prestataire ou une vérification 3-D Secure. Il implémente le contrat mobile commun, la spec v2 : les mêmes options, callbacks, états, résultats, erreurs et le même objet d'apparence que les SDKs Flutter, Android et React Native et que Wajub.js 1.8.

WajubMobileUI

Stable · GA

Swift Package Manager

Version
1.0.0
Runtime
iOS 15+, Swift 5.9+ (Xcode 15+)
Frameworks
SwiftUI sheet, UIKit presenter

Couvre

  • Payments
  • Mobile Money
  • Cards

Installer

Le package s'ajoute avec Swift Package Manager. Dans Xcode, File › Add Package Dependencies… avec https://github.com/wajubhq/wajub-ios, ou dans votre propre Package.swift :

Package.swift
dependencies: [
    .package(url: "https://github.com/wajubhq/wajub-ios.git", from: "1.0.0"),
],
targets: [
    .target(name: "App", dependencies: [
        .product(name: "WajubMobileUI", package: "wajub-ios"),
    ]),
]
PrérequisValeur
iOS15.0 ou version ultérieure
Swift5.9 ou version ultérieure, donc Xcode 15 ou version ultérieure
DépendancesAucune. Stripe est facultatif et vit dans votre application, voir Cartes
CibleUne application. L'interface se présente depuis les fenêtres de l'application (UIApplication.shared), donc pas depuis une extension d'application

Le package propose deux produits, et WajubMobileUI installe WajubMobile avec lui.

ProduitContenu
WajubMobileFoundation uniquement : les modèles, le client de l'API (délais, nouvelles tentatives, Idempotency-Key), les erreurs, les tables et règles Mobile Money, le suivi du statut (polling et connexion au protocole Pusher), et CheckoutController, la machine à états de l'interface sans aucune UI
WajubMobileUIL'interface de paiement SwiftUI et son présentateur UIKit, WajubCheckout.present(_:from:)

Importez WajubMobile seul quand vous dessinez vos propres écrans de paiement.

Démarrage rapide

Votre serveur crée le paiement, l'application présente l'interface avec son jeton, et votre serveur confirme le résultat. Aucun élément du package n'appelle /payments ni ne détient de clé secrète.

1. Créez le paiement sur votre serveur avec POST /payments et votre clé secrète, puis transmettez son authorization_token à l'application par votre propre endpoint.

Votre endpoint et votre client
struct CheckoutResponse: Decodable {
    let authorizationToken: String
}

func fetchToken(cartId: String) async throws -> String {
    var request = URLRequest(url: URL(string: "https://api.yourshop.com/checkout")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(["cart_id": cartId])

    let (data, _) = try await URLSession.shared.data(for: request)
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    return try decoder.decode(CheckoutResponse.self, from: data).authorizationToken
}

2. Présentez l'interface depuis un view controller. present se résout une seule fois, quand l'interface a disparu, avec la valeur que reçoit onComplete : un autre present peut suivre aussitôt. Appelé depuis onComplete, ou juste après la disparition de l'écran de présentation, il attend que l'interface réglée ait disparu ; seule une interface encore ouverte lui fait lever sheet_already_presented.

Toute l'intégration
import UIKit
import WajubMobile
import WajubMobileUI

@MainActor
func pay(token: String, from viewController: UIViewController) async {
    var options = PresentOptions(sessionToken: token)
    options.returnURL = "https://shop.example.com/app/return" // optional, a universal link into your app
    options.callbacks.onStateChange = { event in print("checkout:", event.state.rawValue) }

    do {
        switch try await WajubCheckout.present(options, from: viewController) {
        case .complete(let payment, _):
            // Paid. Confirm on your server before delivering.
            await confirmOnServer(paymentId: payment.id)
        case .processing:
            // Closed while a payment was running: money may still move.
            showPending()
        case .failed(let error, _):
            showError(error.message)
        case .cancelled(let reason, _):
            print("cancelled:", reason.rawValue)
        }
    } catch let error as WajubError {
        // Only sheet_already_presented: a sheet is already open.
        print(error.code)
    } catch {}
}

from: est facultatif. Sans lui, l'interface se présente depuis le view controller le plus haut de la fenêtre principale. Il n'existe ni vue ni modificateur SwiftUI pour l'interface : depuis un écran SwiftUI, appelez WajubCheckout.present sans from:, ou avec le view controller qui héberge votre vue.

3. Confirmez sur votre serveur avant toute livraison, avec GET /payments/{id} ou le webhook payment.succeeded. Le résultat dans l'application est ce que le payeur a vu, pas une preuve de paiement : une approbation Mobile Money peut arriver après la fermeture de l'interface.

Options

PresentOptions est une structure de propriétés var. Son initialiseur les prend toutes en arguments nommés, avec les valeurs par défaut ci-dessous : réglez-les là, ou sur la valeur ensuite.

OptionTypePar défautRôle
sessionTokenStringObligatoireLe authorization_token du paiement
localeString?nil"fr" ou "en". Sans elle, la langue de la session, sinon celle de l'appareil. Une balise qui commence par fr donne le français, toute autre l'anglais
appearanceAppearance?nilL'apparence de l'interface, voir Apparence
loaderLoaderStyle.skeleton.skeleton, des blocs de substitution qui pulsent, ou .spinner
readyTimeoutInt, millisecondes15000Passé ce délai, une interface encore en chargement affiche « Impossible de charger le paiement » avec Réessayer et transmet ready_timeout à onLoadError. 0 le désactive
returnURLString?nilUn lien universel https vers votre application, envoyé comme return_url aux pages des prestataires. Sans lui, le payeur revient dans l'application par lui-même
mobileMoneyMobileMoneyOptionsMobileMoneyOptions()country pour le pays de départ (ISO-2), countryLocked pour masquer le sélecteur de pays, operatorMode .choose (la liste des opérateurs, par défaut) ou .detect
realtimeRealtimeOptions?nilRealtimeOptions(enabled:host:port:tls:) : un autre hôte au protocole Pusher, un Reverb ou un Soketi auto-hébergé, ou enabled: false pour le polling seul
cancelOnDismissBooltrueFermer l'interface avant toute tentative annule le paiement. false le laisse ouvert pour le présenter à nouveau
autoCloseSuccessInt, millisecondes3000Le délai avant que l'écran de succès se ferme seul. 0 le garde
callbacksCheckoutCallbacksCheckoutCallbacks()Voir Callbacks et états
cardCollectorCardCollector?nilVotre collecteur de carte Stripe. Sans lui, une carte routée vers Stripe n'est pas proposée
Les options dans l'initialiseur
let options = PresentOptions(
    sessionToken: token,
    locale: "fr",
    loader: .spinner,
    returnURL: "https://shop.example.com/app/return",
    mobileMoney: MobileMoneyOptions(country: "CI", countryLocked: true, operatorMode: .detect),
    realtime: RealtimeOptions(host: "ws.shop.example", port: 443, tls: true),
    cancelOnDismiss: false
)

LoaderStyle(wajubJS:) lit la valeur loader d'une configuration Wajub.js : "skeleton" donne .skeleton, toute autre valeur, "none" comprise, donne .spinner.

Callbacks et états

Chaque callback est facultatif et appelé sur le thread principal, dans cet ordre : onReady, puis onStateChange au fil des écrans, onPaymentStarted et onRedirect, puis onSuccess, onError ou onCancel, et onComplete une seule fois. Chacun est typé (@MainActor (Payload) throws -> Void)? : un callback qui lève une erreur est intercepté et journalisé, et l'interface continue.

CallbackContenuQuand
onReadyReadyEvent : sessionId, environment ("sandbox" ou "live"), amount, currency, methods (les slugs des canaux affichés)Le premier écran est affiché, y compris pour un paiement déjà payé ou terminé
onStateChangeStateChangeEvent : state, method (mobile_money, card, wallet ou unknown)Après onReady, puis à chaque changement d'état
onLoadErrorWajubErrorL'interface ne peut proposer aucun paiement
onPaymentStartedPaymentStartedEvent : method, channelLe premier 2xx d'une tentative. Dès lors, fermer l'interface donne .processing
onRedirectRedirectEvent : url, method (toujours "GET")Juste avant l'ouverture de la page d'un prestataire
onSuccessPaymentSuccessLe paiement a réussi, avec method à "unknown" s'il était déjà payé au chargement
onErrorWajubErrorChaque échec que voit le payeur, y compris ceux qui permettent un nouvel essai. La validation des champs n'est pas transmise
onCancelCancelEvent : reason, redirectURLL'interface se ferme en .cancelled
onCompleteCheckoutResultL'interface se ferme, avec la valeur que renvoie present
Les callbacks dans l'initialiseur
options.callbacks = CheckoutCallbacks(
    onReady: { event in print("ready:", event.environment, event.methods) },
    onPaymentStarted: { event in print("paying with", event.channel) },
    onError: { error in print(error.code, error.correlationId ?? "-") }
)

CheckoutState est une énumération String dont les valeurs brutes sont les 17 noms de Wajub.js : .initiated (INITIATED), .collectingDetails, .processing, .approvalRequired, .ussdRequired, .otpRequired, .pinRequired, .phoneRequired, .birthdayRequired, .addressRequired, .threeDSRequired (3DS_REQUIRED), .verifying, .pending, .success, .failed, .expired et .cancelled. Comparez sur le cas, journalisez rawValue. Ce que montre chaque état est décrit sur SDKs mobiles.

Résultats

CheckoutResult est une énumération à quatre cas, et status donne son nom sous forme de chaîne, complete, processing, failed ou cancelled.

CasQuand
.complete(payment:transaction:)L'interface s'est fermée après l'écran de succès, y compris pour un paiement déjà payé à son ouverture
.processing(transaction:)L'interface s'est fermée pendant une tentative, ou après les 15 minutes de suivi : de l'argent peut encore bouger
.failed(error:transaction:)L'interface s'est fermée sur un échec, une expiration, une erreur de chargement, ou un paiement annulé ailleurs (payment_cancelled). Fermer sur un échec libère aussi un paiement encore annulable, voir Annuler et fermer
.cancelled(reason:redirectURL:)Annuler le paiement (.payerCancelled), une fermeture avant toute tentative (.dismissed), ou l'écran de présentation disparu avant (.hostGone)

PaymentSuccess est celui de Wajub.js : id (l'identifiant Wajub du paiement, celui que prend GET /payments/{id}), reference (la vôtre), amount (ce que le payeur a payé, frais compris, en unité principale), currency, method, status (toujours "success"), timestamp (ISO 8601) et receiptURL.

present se résout quoi qu'il arrive à l'écran depuis lequel vous l'avez présentée. Quand cet écran est fermé ou désalloué pendant que l'interface est ouverte, elle se résout en .cancelled avec .hostGone avant toute tentative, en .processing une fois une tentative lancée et en .complete après un succès, sans jamais annuler le paiement. Sans fenêtre depuis laquelle se présenter, ou si UIKit refuse la présentation, elle se résout aussitôt en .hostGone.

Si le processus de l'application meurt, rien ne peut se résoudre. Au lancement suivant, lisez le résultat sur votre serveur, ou avec WajubSession(sessionToken:).status().

Ouverture et paiements sans réponse

  • Un paiement qui n'est pas pending à l'ouverture est dessiné aussitôt à partir de la session, sans attendre la configuration des moyens de paiement (GET /pay/sdk-config) : l'écran de succès s'il est payé, un avis « Ce paiement est terminé » avec onLoadError s'il a échoué, a été annulé ou a expiré, et l'écran d'approbation ou d'attente où il en était, suivi, s'il est encore processing.
  • Quand la configuration des moyens de paiement ne peut pas être lue, le Mobile Money reste proposé. Si rien ne peut l'être sans elle, une carte ou un portefeuille seulement, l'interface signale load_error, qu'on peut réessayer, plutôt que no_payment_method.
  • Une requête de paiement restée sans réponse, un 5xx compris, n'est jamais affichée comme un échec : l'interface affiche « Paiement en cours » et suit le paiement. Un refus plus récent que la tentative s'affiche aussitôt. Si le paiement n'a toujours pas commencé 60 secondes après le dernier essai, l'interface revient au formulaire avec une erreur de connexion, et le Payer suivant envoie la même Idempotency-Key.
  • Un 400 fait lire le statut : une issue s'affiche, un paiement processing ou un statut illisible est suivi, et un paiement encore pending échoue avec not_processable.
  • Le suivi s'arrête 15 minutes après l'appui sur Payer, mesurées sur l'horloge monotone du téléphone, quoi que disent son heure ou l'expiration du paiement.

Les règles complètes sont sur SDKs mobiles.

Annuler et fermer

Le payeur ferme l'interface avec son bouton de fermeture ou d'un glissement vers le bas. Pendant une requête (un paiement, une vérification, une annulation, votre collecteur de carte), l'interface ne peut pas être glissée vers le bas.

Le payeur fermeRésultatAppel à Wajub
Pendant le chargement du paiement, avant la réponse de la session.cancelled, .dismissedAucun. Une fois la session arrivée, son statut décide aussitôt : processing donne .processing
Avant toute tentative.cancelled, .dismissed, avec le redirectURL de l'annulationPOST /pay/cancel, sauf si cancelOnDismiss vaut false
Pendant une tentative, ou pendant que le payeur approuve, compose un code ou confirme une étape.processingAucun
Sur l'écran d'échec.failed avec l'erreur affichée, sans appel à onCancelPOST /pay/cancel si le paiement peut encore être annulé et que cancelOnDismiss est actif. Pas après unsupported_action ou redirect_unavailable, quand le paiement peut encore avancer
Après l'écran de succès.completeAucun

L'interface n'annule qu'un paiement chargé, dont le dernier statut connu est pending, sans tentative non tranchée. Une tentative n'est pas tranchée de l'appui sur Payer jusqu'à une réponse définitive : un refus, une étape ou un 3-D Secure échoués pour de bon, une session carte refusée, ou un statut pending avec un nouveau code de refus. Après un tel refus, le paiement est de nouveau pending et peut être annulé.

Annuler le paiement est proposé sur la liste des moyens, sur un formulaire et sur un échec que le payeur peut réessayer, jamais pendant une tentative non tranchée. Il annule le paiement définitivement et donne .cancelled avec .payerCancelled. Une annulation que l'API refuse lit le statut du paiement et se résout avec ce qu'il indique. Les règles complètes sont sur SDKs mobiles.

Apparence

PresentOptions.appearance prend l'objet d'apparence de Wajub.js 1.8, clé pour clé : vos Components web et l'interface native partagent un même style.

Un préréglage, un schéma et quelques variables
options.appearance = Appearance(
    theme: .stripe,              // presets: .stripe, .night, .flat, .noPreset
    colorScheme: .auto,          // .light, .dark, .auto (follows the phone, live)
    variables: AppearanceVariables(
        colorPrimary: "#0F766E",
        fontFamily: "Poppins",   // used when your app ships it
        borderRadius: "12px"
    ),
    buttonTextColor: "#FFFFFF"
)
  • Les arguments suivent l'ordre de déclaration des initialiseurs, comme Swift l'exige : dans AppearanceVariables, fontFamily vient avant borderRadius.
  • Le "none" de Wajub.js s'écrit .noPreset pour theme et .noShadow pour shadow : aucun des deux ne peut se lire comme le none du type optionnel.
  • Les clés sont celles de Wajub.js : theme, colorScheme, inherit, variables (seize jetons de design, de colorPrimary à spacingGridColumn) et les clés de premier niveau primaryColor, secondaryColor, backgroundColor, fontFamily, borderRadius, buttonTextColor, inputBackgroundColor, inputBorderColor, textMutedColor, successColor, errorColor, shadow, labels et disableAnimations. Les valeurs sont des chaînes, comme sur le web, et une valeur illisible est ignorée.
  • L'objet que votre checkout web utilise déjà se lit tel quel : Appearance(json: try JSONValue.parse(jsonString)). Les clés inconnues, rules compris, sont ignorées.
  • inherit: true lit l'écran de présentation : sa tintColor comme couleur principale, systemBackground, label, secondaryLabel et separator résolues pour son apparence, et son style clair ou sombre. Une couleur n'est gardée que si elle contraste assez avec le fond.
  • Pour chaque jeton, la première valeur définie l'emporte : variables, les clés de premier niveau, le préréglage, la valeur héritée, l'image de marque du marchand dans le Dashboard, puis la palette par défaut de Wajub.
  • L'image de marque du marchand complète ce que vous laissez vide : les couleurs, l'arrondi, la police (utilisée seulement si votre application l'embarque), et le logo et le nom commercial dans l'en-tête. Un marchand en marque blanche masque « Sécurisé par Wajub ».
  • disableAnimations: true, ou Réduire les animations sur le téléphone, arrête la pulsation du squelette et les fondus.

Les règles du schéma de couleurs et les palettes par défaut sont sur SDKs mobiles, les clés elles-mêmes sur Apparence.

Mobile Money

Un seul formulaire couvre tous les opérateurs : le pays, le numéro avec son indicatif, mis en forme pendant la saisie, et les opérateurs de la session, un agrégat comme cm.mobile présentant un opérateur par ligne. L'opérateur auquel appartient le numéro est présélectionné tant que le payeur n'en a pas choisi un lui-même, et un numéro d'un autre opérateur proposé est refusé avant tout appel. Wave et les autres portefeuilles qui acceptent n'importe quel numéro ne sont jamais refusés. Le numéro part au format E.164, et la Côte d'Ivoire garde son 0 initial.

Après Payer, l'interface affiche « Validez le paiement sur votre téléphone » avec le numéro et l'instruction de l'opérateur, puis suit le paiement jusqu'à son issue.

MobileMoneyOptionsPar défautRôle
countrynilLe pays de départ, ISO-2. Sans lui, le pays du payeur quand un canal le couvre
countryLockedfalseMasque le sélecteur de pays, quand country est proposé
operatorMode.choose.choose liste les opérateurs. .detect affiche l'opérateur détecté en badge dans le champ du numéro, et la liste seulement quand le numéro ne tranche pas ou que le payeur l'ouvre

Les logos des opérateurs se chargent depuis leur URL https. Un logo absent ou qui ne se charge pas comme une image, un SVG par exemple, est remplacé par le badge de la marque de l'opérateur.

Pour vos propres écrans, MobileMoneyData.shared expose les mêmes règles : nationalDigits(_:country:), isValid(_:country:), detectOperator(_:country:), e164(_:country:), formatAsYouType(_:) et badge(operatorId:fallbackName:). Le comportement commun à tous les SDKs est sur SDKs mobiles.

Cartes

Le prestataire derrière le canal carte décide de l'écran. L'interface le lit dans la configuration SDK de la session.

Carte routée versDans l'interface
Paystack, Flutterwave ou Adyen (une session client)Les champs de contact dont le prestataire a encore besoin, puis sa page dans SFSafariViewController, vérifiée au retour du payeur
La page d'un prestataire (CinetPay, Kkiapay, Mollie…)Les required_fields du prestataire, puis sa page dans SFSafariViewController
StripePayer par carte par votre CardCollector. Sans lui, la carte n'est pas proposée
SandboxLe formulaire de carte de test. Les numéros de carte ne quittent jamais l'appareil en live

Un portefeuille en un geste comme Djamo, et une page Mobile Money comme celle de Wave, est confié à iOS avec UIApplication.open, qui ouvre l'application du portefeuille. Au retour du payeur, Safari fermé ou l'application revenue au premier plan, l'interface lit aussitôt le statut du paiement. Tant qu'une page est ouverte ailleurs, « Continuer le paiement » la rouvre.

Stripe par votre propre collecteur

Seul le SDK de Stripe peut créer un pm_… Stripe, et le package ne dépend pas de stripe-ios. Implémentez CardCollector dans votre application : l'interface l'appelle quand le payeur touche Payer par carte, puis envoie le pm_… qu'il renvoie.

Un CardCollector construit sur Stripe
import StripePaymentsUI
import WajubMobile

final class StripeCardCollector: CardCollector {
    @MainActor
    func collectPaymentMethod(_ request: CardCollectionRequest) async throws -> String? {
        STPAPIClient.shared.publishableKey = request.publishableKey
        // CardEntryScreen is your own screen with an STPPaymentCardTextField, presented over the sheet.
        guard let card = await CardEntryScreen.present(amount: request.amount, currency: request.currency) else {
            return nil // the payer backed out
        }
        let paymentMethod = try await STPAPIClient.shared.createPaymentMethod(with: card.paymentMethodParams)
        return paymentMethod.stripeId
    }
}

options.cardCollector = StripeCardCollector()

CardCollectionRequest contient publishableKey (la clé Stripe propre au paiement, issue de la session), amount, currency, customerEmail, customerName et language, celle de l'interface. Renvoyez nil quand le payeur renonce : l'interface reste sur le formulaire carte et rien n'est débité. Une erreur levée s'affiche sur le formulaire carte : une WajubError telle quelle, toute autre erreur comme validation_error, avec la description propre à l'erreur quand elle en a une (celles de Stripe en ont), sinon « Vérifiez les informations saisies. », et son texte conservé dans apiMessage. Dès le toucher sur Payer par carte, une fermeture donne .processing, et rien n'est envoyé une fois l'interface fermée.

Étapes de confirmation

Certains prestataires de carte demandent davantage au payeur après le premier envoi. Chaque étape a son écran, avec les essais restants dans l'erreur après une mauvaise réponse et « Renvoyer le code » proposé 60 secondes après l'ouverture de l'écran.

ÉtatÉcran
OTP_REQUIREDLe code reçu par le payeur
PIN_REQUIREDLe code PIN de la carte
PHONE_REQUIREDUn numéro de téléphone à confirmer
BIRTHDAY_REQUIREDUne date de naissance
ADDRESS_REQUIREDUne adresse de facturation
3DS_REQUIREDEn sandbox, le code 3-D Secure envoyé par e-mail, avec le compte à rebours de son expiration. En live, le 3-D Secure dans Safari

Un mauvais code garde l'écran. Une étape expirée ou à court d'essais se termine sur l'écran d'échec, et Réessayer ramène au choix d'un moyen de paiement. Les codes sont sur SDKs mobiles.

Erreurs

Chaque échec est une WajubError, une structure conforme à Error et LocalizedError, dont errorDescription est message.

ChampUtilité
typeWajubErrorType : .apiError, .authenticationError, .invalidRequestError, .paymentError, .rateLimitError, avec les noms de Wajub.js comme valeurs brutes
codeUn code du catalogue, listé en constantes dans WajubError.Code, ou le code propre de l'API transmis tel quel
messagePour le payeur, dans la langue de l'interface
declineCodeSur un refus (402), le code du prestataire
retryableSi Réessayer peut aboutir
paramLe champ en cause, data.phone par exemple
httpStatus, correlationIdLe statut HTTP et le X-Request-Id de la réponse. Communiquez le second au support
retryAfterSecondsSur rate_limited
detailsLes erreurs de champ de l'API, un message par champ
blockReferenceSur shield_blocked
apiMessageLe texte anglais de l'API, pour vos journaux

toJSON() donne la forme du contrat, avec des clés en snake_case.

Lire un résultat en échec
case .failed(let error, _):
    switch error.code {
    case WajubError.Code.insufficientFunds:
        showError(error.message) // retryable: another number or method
    case WajubError.Code.shieldBlocked:
        showError(error.message) // carries error.blockReference
    default:
        showError(error.message)
    }

Les codes que vous rencontrerez : insufficient_funds, payment_declined, payment_attempt_ended (refusé, rien n'a été débité), payment_failed, shield_blocked, merchant_restricted, rate_limited, session_terminal_expired, unsupported_action, redirect_unavailable, security_check_failed. Le catalogue complet, avec ses messages anglais et français, est sur SDKs mobiles.

Votre propre interface

WajubMobile vous donne le client de l'API et la machine à états de l'interface, pour une application qui dessine ses propres écrans de paiement.

WajubSession(sessionToken:language:) est le client bas niveau. Il envoie les en-têtes, les signaux de l'appareil, les délais, les nouvelles tentatives et la Idempotency-Key du contrat, et renvoie les formes de l'API.

MéthodeAppel
session()GET /pay/session : montant, canaux, image de marque, langue
sdkConfig()GET /pay/sdk-config : le prestataire et la variante de chaque canal
status()GET /pay/status. Son reference est l'identifiant Wajub du paiement, pas le vôtre
process(channel:data:idempotencyKey:)POST /pay/process, avec data._client ajouté. Renvoie un ProcessOutcome
startClientSession(fields:returnURL:restart:altcha:)POST /pay/client-session, une carte sur la page du prestataire
completeClientSession(id:)POST /pay/client-session/complete, au retour du payeur
cancel()POST /pay/cancel : le paiement est annulé définitivement
verify(_:id:fields:), resend(_:id:)Une étape de confirmation, ConfirmStepKind .otp, .pin, .phone, .birthday ou .address
send3DSCode(threeDSId:), verify3DSCode(threeDSId:code:)Le code 3-D Secure de la sandbox, envoyé par e-mail
broadcastingAuth(socketId:channelName:)La signature du canal temps réel privé du paiement
Un paiement sans l'interface
import WajubMobile

func payHeadless(token: String) async throws {
    let session = WajubSession(sessionToken: token)
    let payment = try await session.session()
    print(payment.transaction.amountTotal, payment.channels.map(\.slug))

    let outcome = await session.process(
        channel: "cm.mobile",
        data: ["phone": "+237677123456", "country": "CM", "operator": "mtn"]
    )
    switch outcome {
    case .complete(let transaction):
        print("paid", transaction.id)
    case .processing(_, let instruction, let kind):
        // .approval, .ussd or .pending: follow GET /pay/status, never show a failure.
        print(kind, instruction ?? "")
    case .requiresAction(let required):
        if required.action == .confirmOTP, let stepId = required.stepId {
            let answer = try await session.verify(.otp, id: stepId, fields: ["code": "123456"])
            print(answer)
        }
    case .failed(let error, _):
        print(error.code, error.message)
    }
}

process génère une nouvelle Idempotency-Key sauf si vous en passez une, et ses nouvelles tentatives automatiques la réutilisent. Une réponse jamais arrivée, ou un 5xx, revient en .processing de type .pending : suivez le statut, n'affichez jamais un échec.

Checkout.present(_:presenter:browser:) exécute tout le checkout sous votre propre interface, avec les mêmes règles que WajubCheckout.present : une seule interface à la fois, et un résultat une fois votre interface disparue. Il crée le CheckoutController, l'automate de l'interface, à partir de vos options, le confie à CheckoutPresenting.present(_:), le démarre, puis, une fois le checkout réglé, appelle votre dismiss(_:completion:) et se résout avec le CheckoutResult.

  • Votre présentateur dessine le screen du contrôleur (un CheckoutScreen), avec state, methods, mobileMoney, cardForm, fieldErrors, banner et le reste, redessine sur onChange, et relie vos boutons à ses actions : selectMethod, setPhone, pickOperator, submitMobileMoney, setCardField, submitCard, payWallet, submitStep, submitThreeDSCode, continuePayment, tryAgain, cancelPayment, dismiss, appWillEnterForeground, hostGone et les autres. Si votre interface ne peut pas s'afficher, appelez controller.hostGone(). Appelez la completion de dismiss une fois votre interface disparue.
  • Votre BrowserOpening ouvre les pages des prestataires : .inApp (pages carte, 3-D Secure, sessions client) dans un SFSafariViewController, en appelant onClose à sa fermeture, et .external (la page d'un portefeuille) avec UIApplication.open. Il renvoie false quand rien ne peut ouvrir la page.
  • completion et onClose sont tous deux @MainActor @Sendable : ils peuvent passer dans les gestionnaires de fin du système dans une application Swift 6.
Votre propre interface et votre navigateur
import Foundation
import WajubMobile

@MainActor
final class MySheet: CheckoutPresenting {
    private var controller: CheckoutController?

    func present(_ controller: CheckoutController) {
        self.controller = controller
        controller.onChange = { [weak self] in self?.render() }
        // Show your sheet; its buttons call the controller's actions.
    }

    func dismiss(_ controller: CheckoutController, completion: @escaping @MainActor @Sendable () -> Void) {
        // Hide your sheet, then:
        self.controller = nil
        completion()
    }

    private func render() {
        guard let controller else { return }
        print(controller.state?.rawValue ?? "loading") // draw controller.screen
    }
}

@MainActor
final class MyBrowser: BrowserOpening {
    func open(_ url: URL, mode: BrowserMode, onClose: @escaping @MainActor @Sendable () -> Void) async -> Bool {
        // .inApp: present an SFSafariViewController and call onClose() once it is closed.
        // .external: return await UIApplication.shared.open(url).
        false // nothing could open the page
    }
}

@MainActor
func payWithMySheet(_ options: PresentOptions) async throws -> CheckoutResult {
    try await Checkout.present(options, presenter: MySheet(), browser: MyBrowser())
}

Sans Checkout, vous pouvez aussi créer vous-même CheckoutController(options:browser:), définir son onChange et son onFinish, qui reçoit le CheckoutResult une seule fois, quand votre interface doit se fermer, et appeler start().

Confidentialité

Pour évaluer la fraude comme le fait le checkout web, chaque POST /pay/process envoie à Wajub quelques signaux de l'appareil, et chaque appel nomme le SDK.

  • Le fuseau horaire, la première langue préférée et les langues (Accept-Language), la taille de l'écran principal en pixels et une profondeur de couleur de 24.
  • Le SDK et le système, wajub-ios/1.0.0 (ios 18.1), dans X-Wajub-Client et à la fin du User-Agent, après le nom et la version de votre application.
  • Une empreinte d'installation : le SHA-256 d'un identifiant aléatoire que le SDK crée à la première utilisation. Elle ne dérive d'aucun identifiant matériel ou publicitaire. Elle est conservée dans un fichier du dossier Application Support de l'application, com.wajub.mobile/install-id, exclu des sauvegardes, et disparaît avec l'application quand elle est désinstallée.

WajubMobile fournit un manifeste de confidentialité, PrivacyInfo.xcprivacy, qu'Xcode ajoute au rapport de confidentialité de votre application (Product › Archive, puis Generate Privacy Report dans l'Organizer). Il déclare :

  • Aucun suivi, et aucun domaine de suivi.
  • Une seule API à raison requise, le temps depuis le démarrage du système (ProcessInfo.systemUptime), avec la raison 35F9.1 : elle mesure les attentes du SDK sur le téléphone et ne le quitte jamais. Aucun UserDefaults : l'identifiant d'installation est le fichier ci-dessus.
  • Les données collectées, toutes liées à l'utilisateur, non utilisées pour le suivi, pour le fonctionnement de l'app (App Functionality) : nom, adresse e-mail, numéro de téléphone, adresse postale, informations de paiement, identifiant de l'appareil et autres données.

Le manifeste ne remplit pas App Store Connect à votre place. Déclarez ce que le SDK envoie à Wajub, votre prestataire de paiement, sous App Privacy, comme des données que collecte votre application, liées à l'identité de l'utilisateur, non utilisées pour le suivi, pour App Functionality, avec ce que votre application collecte elle-même.

App Store ConnectCe qu'envoie le SDK
Contact Info, NamePrénom et nom, ou le titulaire de la carte, quand le moyen de paiement les demande
Contact Info, Email AddressQuand le prestataire de carte la demande
Contact Info, Phone NumberLe numéro Mobile Money, ou un numéro de téléphone demandé par un prestataire de carte
Contact Info, Physical AddressAdresse, ville, code postal et pays, quand le prestataire de carte les demande
Financial Info, Payment InfoLe compte Mobile Money, le pm_… Stripe que renvoie votre collecteur, un code PIN de carte demandé par un prestataire, et les cartes de test en sandbox. Les numéros de carte ne quittent jamais l'appareil en live
Identifiers, Device IDLe SHA-256 de l'identifiant d'installation aléatoire, ni l'identifiant publicitaire ni identifierForVendor, pour la prévention de la fraude
Other DataUne date de naissance quand un prestataire la demande, et le fuseau horaire, la langue et la taille d'écran de l'appareil, pour la prévention de la fraude

Un numéro de carte saisi sur la page d'un prestataire dans Safari va à ce prestataire, sans passer par le SDK. Les signaux qu'envoient tous les SDKs sont sur SDKs mobiles.

Avant 1.0

Il n'existe aucune version antérieure depuis laquelle migrer. Jusqu'à ce package, une application iOS encaissait un paiement Wajub par le checkout hébergé dans Safari, ou par le SDK Flutter ou React Native.

Passer une application native à l'interface de paiement ne change rien sur votre serveur. POST /payments renvoie le authorization_token que prend l'interface à côté du authorization_url qu'ouvre le checkout hébergé, et le même webhook payment.succeeded confirme le paiement.

Tests

L'origine de l'API est une constante, il n'y a donc aucune URL de base à rediriger. Un paiement que votre serveur crée avec une clé sandbox place tout le parcours en sandbox, interface comprise, et l'interface affiche « Mode test ».

  • Les numéros Mobile Money de test sont un préfixe d'opérateur et sept chiffres finissant par le scénario : 000000 succès, 000001 fonds insuffisants, 000002 échec, 000003 délai dépassé, 000004 annulé, +237670000000 par exemple. Les préfixes de la sandbox ne suivent pas les plans de numérotation réels : l'interface s'y contente d'avertir d'un opérateur qui ne correspond pas.
  • Le formulaire carte accepte les cartes de test de Wajub, et 4000056655665556 demande le code 3-D Secure envoyé par e-mail.

Tous les numéros et toutes les cartes sont sur Scénarios de test.

Que pensez-vous de ce contenu ?