Aller au contenu

Angular

provideWajub, le service, les composants standalone et la configuration Angular SSR associée.

@wajub/angular enveloppe @wajub/js avec la syntaxe Angular : provideWajub() et un service qui charge une fois l'environnement d'exécution, des composants standalone pour le checkout et pour chacun des sept groupes de champs, et injectConfirmPayment(). L'API de paiement ne change pas. Le comportement de chaque option est présenté dans Afficher le checkout et Champs de paiement. Cette page traite des éléments propres à Angular.

@wajub/angular

Stable · GA

npm

Version
1.8.1
Runtime
Angular 17+ · Angular SSR

Couvre

  • Paiements
  • Liens de paiement

Absent de ce paquet : Facturation, Transferts, Sync, Shield, Taxes.

Installer

@wajub/js est une dépendance homologue, non intégrée. Installez-la donc sur la même ligne. Le package demande @wajub/js 1.8.0 ou une version ultérieure, et Angular 17 ou une version ultérieure, @angular/core et @angular/common. Il est publié depuis la 1.8.1, uniquement en ESM, et déclare sideEffects: false.

npm install @wajub/angular @wajub/js

Il est compilé dans le mode de compilation partielle d'Angular avec Angular 17 : l'Angular de votre projet, 17 ou toute version ultérieure, le lie au moment du build. Il fonctionne avec zone.js comme dans une application zoneless, et s'affiche sur le serveur, hydratation comprise.

provideWajub et le service

WajubService charge l'environnement d'exécution une seule fois, dans le navigateur uniquement, dès sa première injection, que fait un composant Wajub. provideWajub() le configure : dans app.config.ts pour toute l'application, ou dans les providers d'une route, qui reçoit alors son propre service. Sans lui, les composants utilisent le service de l'application, qui charge l'environnement d'exécution depuis js.wajub.com.

src/app/app.config.ts
import { type ApplicationConfig } from '@angular/core';
import { provideWajub } from '@wajub/angular';

export const appConfig: ApplicationConfig = {
  providers: [provideWajub()],
};
OptionTypeValeur par défautFonction
loadOptions{ jsOrigin?, jsUrl? }Valeurs du CDNRemplace l'origine de chargement de l'environnement d'exécution
deferbooleanfalseNe charge rien tant que vous n'appelez pas load() sur le service

Contrairement au provider des packages React, Vue et Svelte, defer fonctionne ici : load() garde l'environnement d'exécution dans le service, et les composants de la page se montent avec lui. Wajub.js se charge une seule fois par page : une fois là, load() avec d'autres options ne charge aucune autre copie, et la première origine ou URL reste. Un chargement échoué est retenté à l'appel suivant.

La forme sûre
import { Component, Input, inject } from '@angular/core';
import { WajubService } from '@wajub/angular';

@Component({
  selector: 'app-upgrade-button',
  standalone: true,
  template: `
    <button type="button" [disabled]="!wajub.runtime()" (click)="upgrade()">Upgrade plan</button>
  `,
})
export class UpgradeButtonComponent {
  @Input({ required: true }) sessionId!: string;
  readonly wajub = inject(WajubService);

  upgrade(): void {
    this.wajub.runtime()?.wajub.open({
      sessionId: this.sessionId,
      onSuccess: () => window.location.assign('/account'),
    });
  }
}

C'est le signal qui fait fonctionner ce bouton : il vaut null à la création du composant, puis il se remplit à l'arrivée de l'environnement d'exécution. Sur le serveur, il reste à null, et rien n'y est chargé.

MembreDescription
runtimeUn signal de { wajub, Wajub, WajubError }, null tant que l'environnement n'est pas chargé, et sur le serveur
runtime$La même valeur en observable : l'environnement actuel tout de suite, puis chaque nouveau
loading, errorDes signaux : l'environnement est en cours de chargement, et la raison de l'échec du dernier chargement, null sinon
load(options?)Une promesse de l'environnement, null sur le serveur. options vaut par défaut les loadOptions de provideWajub(), et ne compte que pour le premier chargement de la page
getRuntime()L'environnement. Génère une erreur pendant le chargement, après un chargement échoué et sur le serveur

WAJUB_OPTIONS est le jeton d'injection que définit provideWajub(), si vous préférez lire vous-même ses options.

Intégration du checkout

<wajub-checkout-embed>, la classe WajubCheckoutEmbedComponent, est le checkout hébergé dans votre propre mise en page. Comme en Vue, ses inputs forment une liste fermée : sessionId, minHeight, locale, embedOrigin, theme, appearance, layout, loadingText et showLoading. Ses outputs sont les onze callbacks de EmbeddedConfig, nommés sans on.

src/app/checkout/checkout.component.ts
import { Component, Input } from '@angular/core';
import { WajubCheckoutEmbedComponent, type WajubError } from '@wajub/angular';

@Component({
  selector: 'app-checkout',
  standalone: true,
  imports: [WajubCheckoutEmbedComponent],
  template: `
    <wajub-checkout-embed
      [sessionId]="sessionId"
      layout="tabs"
      [minHeight]="480"
      (success)="paid()"
      (error)="failed($event)"
    ></wajub-checkout-embed>
  `,
})
export class CheckoutComponent {
  @Input({ required: true }) sessionId!: string;

  paid(): void {
    window.location.assign('/order/complete');
  }

  failed(error: WajubError | Record<string, unknown>): void {
    console.error(error.code, error.message);
  }
}
Input ou outputTypeValeur par défautFonction
sessionIdstringObligatoireLa valeur authorization_token de POST /payments
minHeightnumber480Hauteur minimale de l'hôte en pixels, jusqu'à ce que le checkout fixe la sienne
(ready)CheckoutInstanceaucuneLe checkout est prêt, avec l'instance à piloter ensuite
(resize)numberaucuneLa nouvelle hauteur du checkout en pixels, le nombre que reçoit le onResize de mount()

Les autres outputs sont (success), (error), (loadError), (cancel), (expired), (stateChange), (methodChange), (breakdown) et (close). L'élément hôte est le conteneur, en display: block et pleine largeur : placez-y votre class et votre style. Le composant expose aussi l'instance montée sous checkoutInstance, null avant le montage, pour un viewChild ou une référence de template.

Ce que les quatre wrappers ont en commun, chaque input qui atteint le checkout par update() et une nouvelle session qui charge un nouveau checkout, est décrit dans Afficher le checkout, avec cet exemple dans chaque framework.

Champs de paiement

Les sept composants affichent chacun un groupe de champs : <wajub-card>, <wajub-mobile-money>, <wajub-wallet>, <wajub-payment>, <wajub-address>, <wajub-bank-transfer> et <wajub-crypto>, les classes WajubCardComponent à WajubCryptoComponent. <wajub-component>, la classe WajubComponentEmbedComponent, est le même composant avec un input type obligatoire, lorsque le choix intervient pendant l'exécution. Chaque option de ComponentConfig est un input du même nom, et chaque callback un output nommé sans on.

Votre mise en page et votre bouton
import { Component, Input, signal } from '@angular/core';
import {
  WajubAddressComponent,
  WajubPaymentComponent,
  injectConfirmPayment,
  type ComponentInstance,
} from '@wajub/angular';

@Component({
  selector: 'app-pay-form',
  standalone: true,
  imports: [WajubAddressComponent, WajubPaymentComponent],
  template: `
    <wajub-address [sessionId]="sessionId" addressMode="shipping"></wajub-address>
    <wajub-payment
      [sessionId]="sessionId"
      collectAddress="shipping"
      [collectName]="true"
      (instance)="field.set($event)"
    ></wajub-payment>
    <button type="button" [disabled]="payment.isConfirming() || !field()" (click)="pay()">
      {{ payment.isConfirming() ? 'Processing…' : 'Pay now' }}
    </button>
  `,
})
export class PayFormComponent {
  @Input({ required: true }) sessionId!: string;

  // Lue à chaque confirm() : une session qui change est suivie.
  readonly payment = injectConfirmPayment(() => this.sessionId);
  // Un signal : (instance) émet null en pleine détection des changements quand le formulaire part.
  readonly field = signal<ComponentInstance | null>(null);

  pay(): void {
    const field = this.field();
    if (field) this.payment.confirm(field);
  }
}
Input ou outputTypeValeur par défautFonction
typeComponentTypeObligatoireUniquement sur <wajub-component>
loader'skeleton' ou 'none''skeleton''skeleton' : l'hôte garde la hauteur du formulaire dès le premier rendu, avec un squelette dans le style de vos champs, et le formulaire apparaît en fondu une fois prêt. 'none' : l'iframe telle qu'elle arrive, la valeur par défaut de @wajub/js lui-même
minHeightnumberNon définieHauteur minimale de l'hôte en pixels, gardée pendant toute la vie du composant. Un min-height dans le style de l'hôte, en attribut ou en binding, compte de la même façon. Non définie, voir ci-dessous
readyTimeoutnumberNon définieMillisecondes d'attente du formulaire. Au-delà, (loadError) reçoit une WajubError de code ready_timeout. Non définie, jamais
beforeSubmit() => boolean | Promise<boolean>aucuneonBeforeSubmit, en input, car un output ne peut pas répondre. S'exécute avant le débit, pour passer votre commande. Il compte s'il est défini au chargement du formulaire, et il est appelé sans this
(instance)ComponentInstance | nullaucuneL'instance une fois montée, null une fois partie : un nouveau montage, ou le composant détruit
(ready)ComponentInstanceaucuneLe formulaire est prêt et interactif
(stateChange){ state, method }aucuneLe paiement passe à une autre étape, par exemple APPROVAL_REQUIRED pendant que le payeur valide sur son téléphone
(resize){ height }aucuneL'iframe prend une nouvelle hauteur, en pixels
(redirect){ url, method }aucuneLe paiement continue sur la page du prestataire, où Wajub.js emmène votre page juste après

Les autres outputs sont (change), (focus), (blur), (success), (error), (loadError) et (methodChange). L'élément hôte est le conteneur où Wajub.js monte l'iframe, en display: block et pleine largeur : placez-y votre class et votre style. Il ne contient que l'iframe : les événements DOM qui portent le nom d'un output, change, focus, blur et error, ne l'atteignent jamais, et (change) n'est que l'output. Le composant expose l'instance montée sous componentInstance, null avant le montage.

(instance) émet null pendant la destruction du formulaire, en pleine détection des changements. Gardez l'instance dans un signal, ou dans un champ que seul votre code lit : un simple champ lu par un binding placé plus haut dans le template déclenche NG0100, ExpressionChangedAfterItHasBeenChecked, en développement.

Sans minHeight, la place suit loader, de la même façon dans chaque wrapper : ce que tient l'hôte au rendu serveur, à l'hydratation et aux vérifications suivantes est décrit dans Champs de paiement. Il finit à la hauteur du formulaire : un bloc variant="fields" épouse ses champs sans [minHeight]="0".

Un [appearance]="{ inherit: true }" écrit en ligne, nouvel objet à chaque vérification, n'envoie rien, et un objet appearance ou fields que vous modifiez sur place compte à la vérification suivante, comme un nouvel objet. Plusieurs changements d'une même vérification partent dans un seul update(). Un nouveau montage émet null sur (instance), puis la nouvelle instance.

Ce que les composants de champs ont en commun, chaque input qui atteint le formulaire par update() ou par un nouveau montage, l'événement redirect et ce dont injectConfirmPayment() vous protège, est décrit dans Champs de paiement.

injectConfirmPayment

injectConfirmPayment(sessionId?) est le useConfirmPayment des autres packages. Appelez-le dans un contexte d'injection, l'initialiseur d'un champ ou un constructeur. Il renvoie { confirm(instance, options?), isConfirming }.

MembreFonction
confirm(instance, options?)Envoie le composant de champ qu'il reçoit et se résout avec l'issue. Un second appel pendant un paiement en cours renvoie la même promesse. Il rejette avant le chargement de l'environnement d'exécution, et sans session
isConfirmingUn signal : un paiement est en cours

La session est lue à chaque confirm() : passez un signal ou une fonction, () => this.sessionId, pour suivre une session qui change, ou une simple chaîne, le type SessionIdSource. Un sessionId dans options l'emporte, et un sessionId laissé à undefined ne l'emporte pas. options.components ne remplace jamais l'instance que vous passez.

Détection des changements

Wajub.js s'exécute hors de la zone d'Angular : les messages de l'iframe, son redimensionnement et son apparition en fondu ne lancent jamais une vérification de votre application. Les outputs sont émis de retour dans la zone : une application zone.js se rafraîchit après un événement qu'elle écoute, et un output que personne n'écoute ne coûte rien. Sans zone.js, avec provideZonelessChangeDetection(), le listener du template planifie la vérification, comme pour tout output. injectConfirmPayment() suit lui aussi le paiement hors de la zone, et règle sa promesse et isConfirming de retour dans celle-ci.

Les composants sont OnPush. Ils prennent en compte un nouvel input, et un objet modifié sur place, à la vérification suivante de leur parent : le montage, l'update() ou le nouveau montage a lieu juste après cette vérification, une fois la vue écrite.

Types

Chaque type de @wajub/js est aussi exporté : un seul import sert les deux packages, et import type { PaymentSuccess } from '@wajub/angular' fonctionne. Les inputs et les outputs sont typés sur les classes des composants, que lit la vérification des types de templates d'Angular.

Le package ajoute WajubOptions, ce que prend provideWajub(), SessionIdSource, la session que prend injectConfirmPayment(), et WajubConfirmPayment, ce qu'il renvoie. WajubComponentBase est la classe que partagent les composants de champs : leurs inputs, leurs outputs et componentInstance.

Mobile Money et carte dans votre propre checkout

Votre checkout garde son formulaire, son choix de moyen de paiement et son bouton Payer, et chaque moyen n'affiche que ses champs, dans le style de votre page. Les étapes, les appels serveur et la raison de chaque option sont dans Champs de paiement. Ici, votre serveur a préparé le paiement avec deferred: true à l'ouverture du checkout, et a transmis à la page son authorization_token et son authorization_url.

payment-step.component.ts
import { Component, Input, computed, signal } from '@angular/core';
import {
  WajubCardComponent,
  WajubMobileMoneyComponent,
  injectConfirmPayment,
  type ComponentInstance,
  type WajubError,
} from '@wajub/angular';

// Le paiement est en cours, ou attend le payeur : rien ne doit changer sur la page.
const RUNNING = [
  'PROCESSING', 'VERIFYING', 'PENDING', 'OTP_REQUIRED', 'PIN_REQUIRED', 'PHONE_REQUIRED',
  'BIRTHDAY_REQUIRED', 'ADDRESS_REQUIRED', '3DS_REQUIRED', 'USSD_REQUIRED', 'APPROVAL_REQUIRED',
];

@Component({
  selector: 'app-payment-step',
  standalone: true,
  imports: [WajubCardComponent, WajubMobileMoneyComponent],
  template: `
    <fieldset [disabled]="locked()">
      <label><input type="radio" name="method" [checked]="method() === 'mobileMoney'" (change)="method.set('mobileMoney')" /> Mobile Money</label>
      <label><input type="radio" name="method" [checked]="method() === 'card'" (change)="method.set('card')" /> Carte</label>
    </fieldset>

    @if (fallback()) {
      <p>Vous finirez sur la page de paiement sécurisée de Wajub.</p>
    } @else if (method() === 'card') {
      <!-- loader="skeleton" par défaut : le bloc garde sa hauteur pendant le chargement. -->
      <wajub-card
        [sessionId]="sessionId"
        variant="fields"
        [appearance]="{ inherit: true }"
        [readyTimeout]="10000"
        [fields]="{ customer: 'external' }"
        [beforeSubmit]="placeOrder"
        (instance)="mounted($event)"
        (change)="complete.set(!!$event.complete)"
        (stateChange)="step.set($event.state)"
        (loadError)="loadFailed($event)"
      ></wajub-card>
    } @else {
      <wajub-mobile-money
        [sessionId]="sessionId"
        variant="fields"
        [appearance]="{ inherit: true }"
        [readyTimeout]="10000"
        [fields]="{ customer: 'external', country: 'CM', operator: 'detect' }"
        [beforeSubmit]="placeOrder"
        (instance)="mounted($event)"
        (change)="complete.set(!!$event.complete)"
        (stateChange)="step.set($event.state)"
        (loadError)="loadFailed($event)"
      ></wajub-mobile-money>
    }

    @if (step() === 'APPROVAL_REQUIRED') {
      <p role="status">Validez le paiement sur votre téléphone.</p>
    }
    <button type="button" [disabled]="locked() || (!fallback() && !complete())" (click)="pay()">Payer</button>
  `,
})
export class PaymentStepComponent {
  // L'authorization_token et l'authorization_url du paiement préparé, transmis par votre serveur.
  @Input({ required: true }) sessionId!: string;
  @Input({ required: true }) hostedUrl!: string;

  readonly method = signal<'mobileMoney' | 'card'>('mobileMoney');
  readonly complete = signal(false);
  readonly step = signal('INITIATED');
  readonly fallback = signal(false);
  private readonly payment = injectConfirmPayment(() => this.sessionId);
  readonly locked = computed(() => this.payment.isConfirming() || RUNNING.includes(this.step()));
  private component: ComponentInstance | null = null;

  // Votre serveur vérifie le panier, passe la commande et active le paiement.
  // Une fonction fléchée, pour que this soit votre composant quand le champ l'appelle.
  readonly placeOrder = async (): Promise<boolean> => {
    const response = await fetch('/checkout/place-order', { method: 'POST' });
    return response.ok; // false : rien n'est débité
  };

  // Changer de moyen détruit un formulaire et monte l'autre : null entre les deux.
  mounted(instance: ComponentInstance | null): void {
    this.component = instance;
    this.complete.set(false);
  }

  loadFailed(error: WajubError | Record<string, unknown>): void {
    if (error.code === 'ready_timeout') this.fallback.set(true);
  }

  async pay(): Promise<void> {
    if (this.fallback()) {
      // Un paiement préparé n'est débité qu'une fois activé : passez d'abord la commande.
      if (await this.placeOrder()) window.location.assign(this.hostedUrl);
      return;
    }
    if (!this.component) return;
    try {
      await this.payment.confirm(this.component);
      window.location.assign('/thanks');
    } catch {
      // Le formulaire affiche la raison, et le payeur peut y réessayer.
    }
  }
}

Les champs affichent eux-mêmes un paiement refusé, et le payeur y réessaie. S'ils ne s'affichent pas dans le délai readyTimeout, le bouton Payer passe la commande puis envoie le payeur sur la page de Wajub : un paiement préparé n'est débité qu'une fois activé. appearance.inherit lit un champ texte de votre page, son libellé, votre bouton principal et le fond derrière les champs. Affichez donc les champs de votre propre formulaire avant cette étape, ou nommez-les avec [appearance]="{ inherit: { input: '#email', button: '#pay' } }". Marquez la commande payée sur votre serveur, à partir du webhook payment.succeeded, jamais depuis le navigateur.

Angular SSR

La session est créée sur votre serveur. Aucun élément de ce package ne nécessite ni ne doit recevoir de clé secrète.

server.ts
import express from 'express';
import { Wajub } from '@wajub/node';

const wajub = new Wajub({ secretKey: process.env['WAJUB_API_KEY']! });

// Sur l'application Express que crée server.ts, avant le handler qui affiche Angular.
app.post('/api/checkout/session', express.json(), async (req, res) => {
  const { cartId } = req.body;

  const payment = await wajub.payments.create(
    {
      amount: 25000,
      currency: 'XAF',
      description: `Cart ${cartId}`,
      customer: { email: '[email protected]' },
      metadata: { cart_id: cartId },
    },
    { idempotencyKey: `cart-${cartId}` },
  );

  res.json({ sessionId: payment.authorization_token });
});

Angular SSR impose trois autres règles :

  • Rien à exclure. Les composants s'affichent sur le serveur, où rien ne lit window ni document et où l'environnement d'exécution n'est pas chargé : l'hôte d'un composant de champ s'affiche vide, en tenant déjà la hauteur estimée du formulaire, la même à l'hydratation. Le formulaire se monte dans le navigateur après le premier rendu : provideClientHydration() ne demande rien de plus, ni ngSkipHydration, ni isPlatformBrowser() autour des composants.
  • Placez provideWajub() dans app.config.ts : le service vit aussi longtemps que l'application, et une navigation dans le flux de checkout ne recharge pas l'environnement d'exécution.
  • Récupérez la session depuis votre serveur et transmettez le jeton comme input.

Éléments exportés par le package

Les exports sont listés dans la Référence du SDK, les types que le package ajoute dans TypeScript.

Que pensez-vous de ce contenu ?