Skip to content

Angular

provideWajub, the service, standalone components, and the Angular SSR wiring that goes with them.

@wajub/angular wraps @wajub/js in Angular syntax: provideWajub() and a service that loads the runtime once, standalone components for the checkout and each of the seven field groups, and injectConfirmPayment(). The payment API itself does not change, so the behaviour of every option is documented on Display the checkout and Payment fields. This page covers what is specific to Angular.

@wajub/angular

Stable · GA

npm

Version
1.8.1
Runtime
Angular 17+ · Angular SSR

Covers

  • Payments
  • Payment Links

Not in this package: Billing, Transfers, Sync, Shield, Tax.

Install

@wajub/js is a peer dependency, not a bundled one, so it goes on the same line. The package needs @wajub/js 1.8.0 or later and Angular 17 or later, @angular/core and @angular/common. It is published from 1.8.1, ESM only, and declares sideEffects: false.

npm install @wajub/angular @wajub/js

It is built in Angular's partial compilation mode with Angular 17, so the Angular of your project, 17 or any later version, links it at build time. It works with zone.js and in a zoneless application, and renders on the server, hydration included.

provideWajub and the service

WajubService loads the runtime once, in the browser only, from the moment it is first injected, which a Wajub component does. provideWajub() configures it: in app.config.ts for the whole application, or in the providers of a route, which then gets a service of its own. Without it, the components use the application's service, which loads the runtime from 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()],
};
OptionTypeDefaultWhat it does
loadOptions{ jsOrigin?, jsUrl? }CDN defaultsOverrides where the runtime is fetched from
deferbooleanfalseLoads nothing until you call load() on the service

Unlike the provider of the React, Vue and Svelte packages, defer works here: load() keeps the runtime in the service, and the components on the page mount with it. Wajub.js loads once per page: once it is there, load() with other options loads no other copy, and the first origin or URL stays. A failed load is tried again on the next call.

The safe form
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'),
    });
  }
}

The signal is what makes this button work: it is null when the component is created, and fills when the runtime arrives. On the server it stays null, and nothing is loaded there.

MemberWhat it is
runtimeA signal of { wajub, Wajub, WajubError }, null until the runtime has loaded, and on the server
runtime$The same as an observable: the current runtime at once, then each new one
loading, errorSignals: the runtime is loading, and why the last load failed, null otherwise
load(options?)A promise of the runtime, null on the server. options default to the loadOptions of provideWajub(), and count only for the first load of the page
getRuntime()The runtime. Throws while loading, after a failed load and on the server

WAJUB_OPTIONS is the injection token provideWajub() sets, if you would rather read its options yourself.

Checkout embed

<wajub-checkout-embed>, the WajubCheckoutEmbedComponent class, is the hosted checkout inside your own layout. As in Vue, its inputs are a closed list: sessionId, minHeight, locale, embedOrigin, theme, appearance, layout, loadingText and showLoading. Its outputs are the eleven callbacks of EmbeddedConfig, named without 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 or outputTypeDefaultWhat it does
sessionIdstringRequiredThe authorization_token from POST /payments
minHeightnumber480The host's minimum height in pixels, until the checkout sets its own
(ready)CheckoutInstancenoneThe checkout is up, with the instance to drive afterwards
(resize)numbernoneThe checkout's new height in pixels, the number onResize receives from mount()

The other outputs are (success), (error), (loadError), (cancel), (expired), (stateChange), (methodChange), (breakdown) and (close). The host element is the container, display: block and full width: put your class and style on it. The component also exposes the mounted instance as checkoutInstance, null before it mounts, for a viewChild or a template reference.

What the four wrappers share, every input reaching the checkout through update() and a new session loading a new checkout, is on Display the checkout, with this example in each framework.

Payment fields

Seven components render one field group each: <wajub-card>, <wajub-mobile-money>, <wajub-wallet>, <wajub-payment>, <wajub-address>, <wajub-bank-transfer> and <wajub-crypto>, the classes WajubCardComponent to WajubCryptoComponent. <wajub-component>, the WajubComponentEmbedComponent class, is the same with a required type input, when the choice is made at runtime. Every option of ComponentConfig is an input of the same name, and every callback an output named without on.

Your own layout, your own button
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;

  // Read at each confirm(): a session that changes is followed.
  readonly payment = injectConfirmPayment(() => this.sessionId);
  // A signal: (instance) emits null during change detection when the form goes.
  readonly field = signal<ComponentInstance | null>(null);

  pay(): void {
    const field = this.field();
    if (field) this.payment.confirm(field);
  }
}
Input or outputTypeDefaultWhat it does
typeComponentTypeRequiredOnly on <wajub-component>
loader'skeleton' or 'none''skeleton''skeleton': the host holds the form's height from the first render, with a placeholder in your inputs' style, and the form fades in once ready. 'none': the frame as it comes, the default of @wajub/js itself
minHeightnumberUnsetThe host's minimum height in pixels, kept for the component's whole life. A min-height in the host's style, attribute or binding, counts the same. Unset, see below
readyTimeoutnumberUnsetMilliseconds to wait for the form. Past it, (loadError) receives a WajubError with the code ready_timeout. Unset, never
beforeSubmit() => boolean | Promise<boolean>noneonBeforeSubmit, as an input, since an output cannot answer. Runs before the charge, to place your order. It counts when it is set as the form loads, and is called without this
(instance)ComponentInstance | nullnoneThe instance once mounted, null once gone: a new mount, or the component destroyed
(ready)ComponentInstancenoneThe form is up and interactive
(stateChange){ state, method }noneThe payment moved to another step, APPROVAL_REQUIRED while the payer approves on the phone for example
(resize){ height }noneThe frame took a new height, in pixels
(redirect){ url, method }noneThe payment continues on the provider's own page, where Wajub.js takes your page right after

The other outputs are (change), (focus), (blur), (success), (error), (loadError) and (methodChange). The host element is the container Wajub.js mounts the frame in, display: block and full width: put your class and style on it. It holds the frame alone, so the DOM events named like the outputs, change, focus, blur and error, never reach it, and (change) is the output alone. The component exposes the mounted instance as componentInstance, null before it mounts.

(instance) emits null while the form is destroyed, during change detection. Keep the instance in a signal, or in a field only your code reads: a plain field that a binding higher in the template reads raises NG0100, ExpressionChangedAfterItHasBeenChecked, in development.

Leave minHeight unset and the room follows loader, the same in every wrapper: what the host holds on the server, at hydration and on later checks is on Payment fields. It ends at the form's own height, so a variant="fields" box fits its inputs without [minHeight]="0".

An inline [appearance]="{ inherit: true }", a new object on each check, sends nothing, and an appearance or fields object you change in place counts at the next check, as a new object does. Several changes in one check go in one update(). A new mount emits null on (instance), then the new instance.

What the field components share, every input reaching the form through update() or a new mount, the redirect event and what injectConfirmPayment() guards against, is on Payment fields.

injectConfirmPayment

injectConfirmPayment(sessionId?) is the useConfirmPayment of the other packages. Call it in an injection context, a field initializer or a constructor. It returns { confirm(instance, options?), isConfirming }.

MemberWhat it does
confirm(instance, options?)Submits the field component it is given and resolves with the outcome. A second call while a payment is under way returns the same promise. It rejects before the runtime has loaded, and without a session
isConfirmingA signal: a payment is under way

The session is read at each confirm(): pass a signal or a function, () => this.sessionId, to follow one that changes, or a plain string, the SessionIdSource type. A sessionId in options wins over it, and one left undefined does not. options.components never replaces the instance you pass.

Change detection

Wajub.js runs outside Angular's zone: the frame's messages, its resizing and its fade-in never start a check of your application. The outputs are emitted back in the zone, so a zone.js application refreshes after an event it listens to, and an output nobody listens to costs nothing. Without zone.js, under provideZonelessChangeDetection(), the template's listener schedules the check, as for any output. injectConfirmPayment() follows the payment outside the zone as well, and settles its promise and isConfirming back in it.

The components are OnPush. They act on a new input, and on an object changed in place, at the next check of their parent: the mount, the update() or the new mount happens right after that check, once the view is written.

Types

Every type of @wajub/js is exported too, so one import serves both packages: import type { PaymentSuccess } from '@wajub/angular' works. The inputs and outputs are typed on the component classes, which Angular's template type checking reads.

The package adds WajubOptions, what provideWajub() takes, SessionIdSource, the session injectConfirmPayment() takes, and WajubConfirmPayment, what it returns. WajubComponentBase is the class the field components share: their inputs, their outputs and componentInstance.

Mobile Money and Card in your own checkout

Your checkout keeps its own form, its method choice and its Pay button, and each method shows only its inputs, in your page's style. The steps, the server calls and the reason for each option are on Payment fields. Here, your server has prepared the payment with deferred: true when the checkout opened, and handed the page its authorization_token and 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';

// The payment is under way, or waits on the payer: nothing on the page may change.
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')" /> Card</label>
    </fieldset>

    @if (fallback()) {
      <p>You will finish on Wajub's secure payment page.</p>
    } @else if (method() === 'card') {
      <!-- loader="skeleton" is the default: the box keeps its height while the form loads. -->
      <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">Approve the payment on your phone.</p>
    }
    <button type="button" [disabled]="locked() || (!fallback() && !complete())" (click)="pay()">Pay</button>
  `,
})
export class PaymentStepComponent {
  // The prepared payment's authorization_token and authorization_url, from your server.
  @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;

  // Your server checks the cart, places the order and activates the payment.
  // An arrow function, so that this is your component when the field calls it.
  readonly placeOrder = async (): Promise<boolean> => {
    const response = await fetch('/checkout/place-order', { method: 'POST' });
    return response.ok; // false: nothing is charged
  };

  // Switching method destroys one form and mounts the other: null in between.
  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()) {
      // A prepared payment is charged only once activated: place the order first.
      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 {
      // The form shows the reason, and the payer can try again in it.
    }
  }
}

The fields show a declined payment themselves, and the payer tries again in them. When they do not come up within readyTimeout, the Pay button places the order and then sends the payer to Wajub's page: a prepared payment is charged only once activated. appearance.inherit reads a text input of your page, its label, your main button and the background behind the fields, so render your own form's fields before this step, or name them with [appearance]="{ inherit: { input: '#email', button: '#pay' } }". Mark the order paid on your server, from the payment.succeeded webhook, never from the browser.

Angular SSR

The session is created on your server. Nothing in this package needs a secret key, and nothing should ever receive one.

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

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

// On the Express app server.ts creates, before the handler that renders 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 });
});

Three more things Angular SSR asks for:

  • Nothing to skip. The components render on the server, where nothing reads window or document and the runtime is not loaded: a field component's host renders empty, already holding the form's estimated height, the same at hydration. The form mounts in the browser after the first render, so provideClientHydration() needs nothing more: no ngSkipHydration, and no isPlatformBrowser() around the components.
  • Put provideWajub() in app.config.ts: the service lives as long as the application, so a navigation inside the checkout flow does not load the runtime again.
  • Fetch the session from your server and pass the token down as an input.

What the package exports

The exports are listed in the SDK reference, the types the package adds in TypeScript.

What did you think of this content?