écrits/tutorial/2026/08
Tutorial13 août 2026·28 min

Intégrer une passerelle de paiement saoudienne en TypeScript : mada, Moyasar et Tabby

Construisez un tunnel de paiement saoudien prêt pour la production en TypeScript : gestion sûre des halalas, 3-D Secure obligatoire sur mada, vérification infalsifiable du retour navigateur, cycle autorisation-capture de Tabby et webhooks idempotents. Chaque exemple compile en mode strict et passe ses tests.

Cherchez comment intégrer une passerelle de paiement saoudienne et vous trouverez le site marketing du fournisseur, une dizaine d'articles comparatifs « les meilleures passerelles de paiement en Arabie Saoudite », et une annonce sur une plateforme de freelance où quelqu'un propose de l'argent pour que le travail soit fait à sa place. Ce que vous ne trouverez pas, c'est un guide qui explique les parties qui cassent réellement.

Ce tutoriel est ce guide. Ce n'est pas une visite guidée d'un tableau de bord, mais le code qui se tient entre votre table de commandes et l'argent, écrit comme il doit l'être quand le client paie avec une carte mada à Riyad.

Trois éléments distinguent un tunnel de paiement saoudien d'un simple copier-coller du guide de démarrage de Stripe :

  1. Les deux principales passerelles ne s'accordent pas sur ce qu'est un nombre. Moyasar attend un entier en halalas. Tabby attend une chaîne décimale. Envoyez l'un à la place de l'autre et vous avez débité votre client cent fois trop, ou cent fois trop peu, sans la moindre erreur nulle part.
  2. 3-D Secure n'est pas une optimisation que l'on repousse. Sur mada, c'est le chemin normal et non l'exception : le cycle redirection-retour devient votre flux principal plutôt qu'un cas limite.
  3. Le paiement fractionné évalue l'acheteur, pas la carte. Tabby peut refuser un client que votre passerelle carte aurait débité sans hésiter, et ce avant l'affichage de la moindre page de paiement. C'est une issue normale que votre tunnel doit traiter avec élégance.

Ce que vous allez construire

Un noyau de paiement en quatre modules :

  • money.ts — un type marqué Halalas qui transforme l'incompatibilité d'unités en erreur de compilation plutôt qu'en remboursement
  • moyasar.ts — paiements par carte avec 3-D Secure, plus une vérification infalsifiable du retour navigateur
  • tabby.ts — tunnel BNPL, gestion du refus et capture à l'expédition
  • webhook.ts — vérification de signature en temps constant et traitement idempotent des événements

Tout ce qui suit passe la vérification de types en mode strict avec noUncheckedIndexedAccess et exactOptionalPropertyTypes, et est couvert par une suite de tests qui passe.

Prérequis

  • Node.js 20 ou plus récent, et TypeScript 5.5 ou plus
  • Un compte Moyasar avec des clés de test (pk_test_... et sk_test_...)
  • Un compte marchand Tabby avec une clé secrète de test et un code marchand
  • Une aisance avec async/await et les API HTTP
  • Un registre de commerce (CR) valide avant que l'une ou l'autre passerelle n'émette des clés de production — lancez ces démarches tôt, car elles conditionnent la mise en ligne, pas le développement

Initialisation du projet :

mkdir saudi-payments && cd saudi-payments
npm init -y
npm install -D typescript vitest @types/node
npx tsc --init

Puis activez la rigueur qui travaillera à votre place :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

Étape 1 : rendre l'erreur d'unité impossible

Voici le bug que cette étape entière existe pour empêcher.

L'API Moyasar reçoit amount sous forme d'entier dans la plus petite unité monétaire. Un riyal saoudien vaut 100 halalas, donc 100,00 SAR s'écrit 10000. L'API Tabby, elle, reçoit amount sous forme de chaîne décimale en unité principale : le même montant s'écrit "100.00".

Les deux champs portent le même nom. Et chaque passerelle accepte sans broncher ce que l'autre envoie — 10000 est un montant parfaitement valide pour Tabby, il signifie simplement dix mille riyals. Rien ne lève d'exception. C'est le client qui vous l'apprend.

La solution : ne jamais laisser un number nu atteindre une passerelle. Utilisez un type marqué :

// money.ts
 
/** Entier marqué comptant des halalas. 1 SAR = 100 halalas. */
export type Halalas = number & { readonly __brand: 'Halalas' };
 
export class MoneyError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'MoneyError';
  }
}
 
export function halalas(value: number): Halalas {
  if (!Number.isInteger(value)) {
    throw new MoneyError(`Halalas must be an integer, received ${value}`);
  }
  if (value < 0) {
    throw new MoneyError(`Halalas must not be negative, received ${value}`);
  }
  if (!Number.isSafeInteger(value)) {
    throw new MoneyError(`Halalas exceeds safe integer range: ${value}`);
  }
  return value as Halalas;
}

Le marquage est une fiction de compilation — à l'exécution il s'agit toujours d'un nombre, sans aucun surcoût. Mais une fonction qui exige un Halalas ne peut pas recevoir le résultat de price * quantity, et c'est là tout l'intérêt.

Passons à l'analyse des prix. Notez que la fonction prend une chaîne, délibérément :

/**
 * Convertit une chaîne SAR décimale ("100.00", "9.5", "1,250.75") en halalas.
 *
 * Chaîne d'abord, volontairement : `Math.round(19.99 * 100)` vaut 1999
 * aujourd'hui, et un ticket de support le jour où un prix tombe sur une
 * valeur que le flottant gère mal.
 */
export function sarToHalalas(input: string): Halalas {
  const raw = input.trim().replace(/,/g, '');
  const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(raw);
  if (!match) {
    throw new MoneyError(
      `Invalid SAR amount "${input}" — expected digits with at most 2 decimals`,
    );
  }
  const major = match[1] ?? '0';
  const minor = (match[2] ?? '').padEnd(2, '0');
  return halalas(Number(major) * 100 + Number(minor));
}
 
/** Rend les halalas sous la forme décimale attendue par Tabby. */
export function halalasToSar(amount: Halalas): string {
  const major = Math.trunc(amount / 100);
  const minor = amount % 100;
  return `${major}.${String(minor).padStart(2, '0')}`;
}
 
/** Additionne les lignes sans jamais quitter l'arithmétique entière. */
export function sumHalalas(amounts: readonly Halalas[]): Halalas {
  return halalas(amounts.reduce<number>((total, value) => total + value, 0));
}

Pourquoi analyser une chaîne plutôt que Math.round(price * 100) ? Parce que 19.99 * 100 vaut 1998.9999999999998 en virgule flottante IEEE 754. Math.round sauve ce cas précis, mais le réflexe finira par rencontrer une valeur qu'il ne sauve pas, et l'erreur d'arrondi se sera alors répandue sur tout un rapport de règlement. Analysez directement la représentation décimale et le mode de défaillance disparaît au lieu de devenir rare.

L'expression régulière rejette aussi les entrées à trois décimales au lieu de les tronquer en silence. Si un flux fournisseur vous transmet "1.005", vous voulez une exception à l'import, pas une demi-halala qui s'arrondit discrètement dans le sens que préfère votre base de données.

Étape 2 : TVA et répartition des échéances

Deux calculs dont tout tunnel de paiement saoudien a besoin.

La TVA saoudienne est de 15 %, et les prix affichés aux consommateurs sont TTC. La taxe est donc extraite du prix et non ajoutée — et la répartition doit tomber juste à la halala près, sinon votre facture électronique ne concordera pas avec ce que vous avez réellement encaissé :

export function extractVat(grossInclusive: Halalas, ratePercent = 15): Halalas {
  const net = Math.round((grossInclusive * 100) / (100 + ratePercent));
  return halalas(grossInclusive - net);
}

Calculer le net d'abord puis soustraire garantit que net + vat égale exactement gross. Calculer la TVA d'abord et soustraire celle-ci ne le garantit pas, car deux arrondis indépendants peuvent chacun dériver d'une demi-halala dans le même sens.

La répartition des échéances suit la même discipline. Le produit standard de Tabby compte quatre versements, et 100,01 SAR ne se divise pas par quatre :

/**
 * Répartit un total sur n échéances (quatre chez Tabby) de sorte que la
 * somme des parts égale exactement le total. Le reste va à la PREMIÈRE
 * échéance, celle que le client règle au moment de la commande.
 */
export function splitInstallments(
  total: Halalas,
  parts: number,
): readonly Halalas[] {
  if (!Number.isInteger(parts) || parts < 1) {
    throw new MoneyError(`Installment count must be a positive integer`);
  }
  const base = Math.floor(total / parts);
  const remainder = total - base * parts;
  return Array.from({ length: parts }, (_unused, index) =>
    halalas(index === 0 ? base + remainder : base),
  );
}

Vous n'en avez besoin que pour l'affichage — Tabby calcule son propre échéancier — mais les clients comparent votre récapitulatif à leur application Tabby, et un écart d'une halala génère un volume de tickets sans commune mesure avec sa taille.

Étape 3 : paiements par carte avec 3-D Secure obligatoire

mada est le réseau de paiement national saoudien, et la plupart des cartes de débit saoudiennes fonctionnent dessus. Concrètement, cela signifie que l'authentification forte du client fait partie du chemin normal : l'acheteur est envoyé vers sa banque, valide par code à usage unique ou via son application bancaire, puis revient.

Votre intégration ne peut donc pas traiter 3-D Secure comme une branche qui se déclenche occasionnellement. La redirection est le flux.

// moyasar.ts
import { halalasToSar, type Halalas } from './money.js';
 
const MOYASAR_API = 'https://api.moyasar.com/v1';
 
export type MoyasarStatus =
  | 'initiated'
  | 'paid'
  | 'authorized'
  | 'captured'
  | 'refunded'
  | 'failed'
  | 'voided';
 
export interface MoyasarPayment {
  readonly id: string;
  readonly status: MoyasarStatus;
  /** Halalas entiers, tels que renvoyés par Moyasar. */
  readonly amount: number;
  readonly currency: string;
  readonly metadata?: Record<string, string>;
}
 
export class GatewayError extends Error {
  constructor(
    message: string,
    readonly status?: number,
  ) {
    super(message);
    this.name = 'GatewayError';
  }
}
 
/** Auth basique : la clé est l'identifiant, le mot de passe est vide. */
function authHeader(key: string): string {
  return `Basic ${Buffer.from(`${key}:`).toString('base64')}`;
}

Et la création du paiement elle-même :

export interface CreatePaymentInput {
  readonly amount: Halalas;
  readonly orderId: string;
  readonly description: string;
  readonly callbackUrl: string;
  /** Jeton issu du formulaire Moyasar. Le PAN brut ne nous touche jamais. */
  readonly cardToken: string;
}
 
export async function createPayment(
  secretKey: string,
  input: CreatePaymentInput,
): Promise<MoyasarPayment> {
  const response = await fetch(`${MOYASAR_API}/payments`, {
    method: 'POST',
    headers: {
      Authorization: authHeader(secretKey),
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount: input.amount,
      currency: 'SAR',
      description: input.description,
      callback_url: input.callbackUrl,
      source: {
        type: 'token',
        token: input.cardToken,
        // 3ds vaut true par défaut. Le laisser actif n'est pas optionnel en
        // pratique : mada impose l'authentification forte, et le désactiver
        // transfère la responsabilité de la fraude vers vous.
        '3ds': true,
      },
      metadata: { order_id: input.orderId },
    }),
  });
 
  if (!response.ok) {
    throw new GatewayError(
      `Moyasar create payment failed: ${await response.text()}`,
      response.status,
    );
  }
  return (await response.json()) as MoyasarPayment;
}

Remarquez que amount: input.amount passe directement, sans conversion. C'est sûr précisément parce que le système de types a déjà prouvé qu'il s'agit de halalas.

À propos de la désactivation de 3DS : l'API accepte bien "3ds": false, mais uniquement pour les comptes explicitement autorisés au traitement par correspondance ou téléphone, et cela déplace la responsabilité de la fraude de l'émetteur vers vous. Pour un tunnel web ordinaire, traitez ce paramètre comme s'il n'existait pas.

Deux autres points à cadrer dès maintenant. Utilisez un jeton de carte produit par le formulaire hébergé de Moyasar plutôt que de faire transiter un numéro de carte brut par votre serveur — le code ci-dessus est écrit pour les jetons, et c'est ce qui maintient les données de carte hors de votre périmètre PCI. Et placez order_id dans metadata, car l'étape 4 en dépend.

Étape 4 : ne jamais faire confiance au retour navigateur

C'est le cœur sécuritaire de l'intégration, et l'étape que la plupart des tutoriels sautent.

Quand le client termine son authentification, Moyasar redirige le navigateur vers votre callback_url en y ajoutant des paramètres : id, status et message. Ces paramètres transitent par la barre d'adresse. Le client peut les lire. Il peut aussi les modifier.

Ainsi, ce gestionnaire, pourtant tout à fait raisonnable en apparence, est un moyen d'offrir votre stock :

// NE FAITES SURTOUT PAS CELA
app.get('/callback', async (req, res) => {
  if (req.query.status === 'paid') {
    await markOrderPaid(req.query.id);  // confiance en la barre d'adresse
  }
});

N'importe qui peut ajouter ?status=paid à cette URL. À la place, rechargez le paiement depuis l'API avec votre clé secrète, et vérifiez chaque champ qui compte :

export async function fetchPayment(
  secretKey: string,
  paymentId: string,
): Promise<MoyasarPayment> {
  const response = await fetch(`${MOYASAR_API}/payments/${paymentId}`, {
    headers: { Authorization: authHeader(secretKey) },
  });
  if (!response.ok) {
    throw new GatewayError(
      `Moyasar fetch payment failed: ${await response.text()}`,
      response.status,
    );
  }
  return (await response.json()) as MoyasarPayment;
}
 
export type VerdictReason =
  | 'ok'
  | 'not_paid'
  | 'amount_mismatch'
  | 'currency_mismatch'
  | 'order_mismatch';
 
export interface Verdict {
  readonly settled: boolean;
  readonly reason: VerdictReason;
}
 
export function verifyPayment(
  payment: MoyasarPayment,
  expected: { readonly amount: Halalas; readonly orderId: string },
): Verdict {
  if (payment.status !== 'paid' && payment.status !== 'captured') {
    return { settled: false, reason: 'not_paid' };
  }
  if (payment.currency !== 'SAR') {
    return { settled: false, reason: 'currency_mismatch' };
  }
  if (payment.amount !== expected.amount) {
    return { settled: false, reason: 'amount_mismatch' };
  }
  if (payment.metadata?.['order_id'] !== expected.orderId) {
    return { settled: false, reason: 'order_mismatch' };
  }
  return { settled: true, reason: 'ok' };
}

Les quatre contrôles méritent chacun leur place :

  • Le statut est le contrôle évident, et le seul que font la plupart des implémentations.
  • Le montant bloque l'attaque classique : mettre un article bon marché au panier, payer 1,00 SAR, puis réutiliser cet identifiant de paiement sur une commande coûteuse.
  • La devise bloque un règlement dans autre chose que des riyals.
  • L'identifiant de commande bloque le rejeu d'un paiement authentique sur plusieurs commandes — sans ce contrôle, un unique paiement légitime de 500 SAR peut marquer cinq commandes différentes de 500 SAR comme payées.

Le câblage ressemble alors à ceci :

const payment = await fetchPayment(process.env.MOYASAR_SECRET_KEY!, paymentId);
const order = await loadOrder(orderId);
const verdict = verifyPayment(payment, {
  amount: order.totalHalalas,
  orderId: order.id,
});
 
if (!verdict.settled) {
  logger.warn({ paymentId, reason: verdict.reason }, 'payment rejected');
  return res.redirect('/checkout/failed');
}
await markOrderPaid(order.id, payment.id);

Journalisez verdict.reason. Un amount_mismatch en production est soit un bug dans le calcul de vos totaux, soit quelqu'un qui vous sonde, et vous voulez savoir lequel.

Étape 5 : le BNPL est une bête différente

Tabby et Tamara dominent le paiement fractionné en Arabie Saoudite, et en intégrer un ne revient pas à « ajouter une passerelle carte de plus ». La différence qui change votre code est celle-ci : Tabby évalue la solvabilité de l'acheteur au moment de la création de la session, avant qu'aucune page de paiement n'apparaisse.

Vous appelez POST /api/v2/checkout et la réponse vous dit si ce client peut utiliser le BNPL pour ce panier. Un statut rejected n'est ni une erreur ni une opération à réessayer — c'est une décision d'octroi de crédit. Votre travail consiste à basculer vers la carte sans donner au client le sentiment d'être éconduit.

// tabby.ts
import { halalasToSar, type Halalas } from './money.js';
import { GatewayError } from './moyasar.js';
 
const TABBY_API = 'https://api.tabby.ai/api/v2';
 
export type TabbySessionStatus = 'created' | 'rejected' | 'expired';
export type TabbyPaymentStatus =
  | 'NEW'
  | 'AUTHORIZED'
  | 'CLOSED'
  | 'REJECTED'
  | 'EXPIRED';
 
export interface TabbyCheckoutResponse {
  readonly status: TabbySessionStatus;
  readonly payment: { readonly id: string };
  readonly configuration?: {
    readonly available_products?: {
      readonly installments?: readonly { readonly web_url: string }[];
    };
  };
}
 
export type CheckoutOutcome =
  | { readonly kind: 'redirect'; readonly url: string; readonly paymentId: string }
  | { readonly kind: 'rejected'; readonly paymentId: string };

La requête, avec la conversion isolée dans un seul appel :

export async function createCheckout(
  secretKey: string,
  merchantCode: string,
  input: TabbyCheckoutInput,
): Promise<CheckoutOutcome> {
  const response = await fetch(`${TABBY_API}/checkout`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${secretKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      payment: {
        // Tabby veut une CHAÎNE DÉCIMALE là où Moyasar voulait un entier.
        // `halalasToSar` est le seul endroit où cette conversion est permise.
        amount: halalasToSar(input.amount),
        currency: 'SAR',
        buyer: input.buyer,
        order: { reference_id: input.orderId },
      },
      lang: input.lang,
      merchant_code: merchantCode,
      merchant_urls: {
        success: input.successUrl,
        cancel: input.cancelUrl,
        failure: input.failureUrl,
      },
    }),
  });
 
  if (!response.ok) {
    throw new GatewayError(
      `Tabby checkout failed: ${await response.text()}`,
      response.status,
    );
  }
 
  const session = (await response.json()) as TabbyCheckoutResponse;
  const url =
    session.configuration?.available_products?.installments?.[0]?.web_url;
 
  if (session.status !== 'created' || url === undefined) {
    return { kind: 'rejected', paymentId: session.payment.id };
  }
  return { kind: 'redirect', url, paymentId: session.payment.id };
}

L'union discriminée fait un vrai travail ici. Impossible de lire url sans avoir d'abord restreint le type sur kind, donc le chemin de refus ne peut pas être oublié — et avec noUncheckedIndexedAccess activé, installments?.[0] est typé comme pouvant valoir undefined, ce qui vous force à traiter le cas du tableau vide que renvoie effectivement une session refusée.

Renseignez lang honnêtement. Si votre boutique est en arabe, envoyez "ar" pour que la page Tabby corresponde ; propulser un acheteur arabophone dans un tunnel en anglais provoque une perte mesurable.

Étape 6 : capturer à l'expédition, pas à la commande

Un paiement Tabby autorisé est une promesse, pas de l'argent. Il devient de l'argent quand vous le capturez, et le bon moment pour capturer est celui de l'expédition.

Capturez à la commande et chaque annulation de routine devient un remboursement — de l'argent qui a quitté le compte du client et qu'il faut lui renvoyer, pendant qu'il vous relance.

export async function capturePayment(
  secretKey: string,
  paymentId: string,
  amount: Halalas,
  idempotencyKey: string,
): Promise<void> {
  const response = await fetch(`${TABBY_API}/payments/${paymentId}/captures`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${secretKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount: halalasToSar(amount),
      reference_id: idempotencyKey,
    }),
  });
  if (!response.ok) {
    throw new GatewayError(
      `Tabby capture failed: ${await response.text()}`,
      response.status,
    );
  }
}
 
/** Seuls ces deux statuts autorisent la libération de la commande. */
export function isSettled(status: TabbyPaymentStatus): boolean {
  return status === 'AUTHORIZED' || status === 'CLOSED';
}

reference_id est la clé d'idempotence, pas une description. Dérivez-la de quelque chose de stable — l'identifiant d'expédition convient bien — pour qu'une capture réessayée après un délai réseau ne prélève pas deux fois.

La capture partielle est supportée : c'est ainsi que l'on traite une commande expédiée en plusieurs fois. Capturez ce qui est parti, et le paiement reste autorisé pour le solde.

Étape 7 : webhooks, signature et idempotence

Les redirections ne font que de leur mieux. Le client ferme l'onglet, le téléphone perd le réseau au retour de l'application bancaire, le navigateur restaure une session depuis son cache. Le webhook est le canal qui finit par vous dire la vérité, ce qui implique que votre gestionnaire doit pouvoir s'exécuter plusieurs fois sans dommage.

Les passerelles diffèrent dans leur façon d'authentifier leurs webhooks — certaines envoient un en-tête de signature HMAC, d'autres incluent un jeton secret partagé dans la charge utile. Vérifiez dans le tableau de bord de chacune. Quelle que soit la méthode, deux règles tiennent : comparez en temps constant, et dédupliquez. Le module ci-dessous implémente la variante en-tête HMAC ; si la vôtre utilise un jeton partagé, remplacez le corps de verifySignature par une comparaison en temps constant de ce jeton et gardez tout le reste à l'identique.

// webhook.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
 
export function verifySignature(
  rawBody: string,
  receivedSignature: string,
  secret: string,
): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = receivedSignature.trim().toLowerCase();
  // timingSafeEqual lève une exception si les longueurs diffèrent.
  if (received.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

Signez le corps brut. Vérifiez contre les octets exacts reçus, avant toute analyse JSON. Si vous analysez puis re-sérialisez, l'ordre des clés et les espaces changent, le HMAC ne correspond plus, et vous passerez un après-midi convaincu que la passerelle est cassée. Sous Express, cela veut dire express.raw() sur la route du webhook spécifiquement, pas le express.json() global.

Passons à l'idempotence :

export interface EventStore {
  /** Renvoie true si c'est la première fois que l'on voit `eventId`. */
  claim(eventId: string): Promise<boolean>;
}
 
export class InMemoryEventStore implements EventStore {
  private readonly seen = new Set<string>();
 
  async claim(eventId: string): Promise<boolean> {
    if (this.seen.has(eventId)) return false;
    this.seen.add(eventId);
    return true;
  }
}
 
export type WebhookResult = 'processed' | 'duplicate' | 'invalid_signature';
 
export async function handleWebhook(
  rawBody: string,
  signature: string,
  secret: string,
  store: EventStore,
  process: (event: unknown) => Promise<void>,
): Promise<WebhookResult> {
  if (!verifySignature(rawBody, signature, secret)) {
    return 'invalid_signature';
  }
 
  const event = JSON.parse(rawBody) as { id?: string };
  const eventId = event.id;
  if (typeof eventId !== 'string' || eventId.length === 0) {
    throw new Error('Webhook payload has no event id — cannot deduplicate');
  }
 
  if (!(await store.claim(eventId))) {
    // Déjà traité. Renvoyer 200 pour que la passerelle cesse de réessayer.
    return 'duplicate';
  }
 
  await process(event);
  return 'processed';
}

Le stockage en mémoire est réservé aux tests. En production, claim est un INSERT dans une table processed_events avec une contrainte UNIQUE sur l'identifiant d'événement, et il doit s'exécuter dans la même transaction que la mise à jour de la commande. Séparez les deux et un plantage entre elles vous ramène au point de départ : soit un événement marqué traité qui ne l'a jamais été, soit une commande mise à jour deux fois.

Renvoyez 200 pour les doublons. Toute réponse hors 2xx demande à la passerelle de réessayer, et réessayer un doublon indéfiniment est un déni de service que vous vous infligez.

Tester votre implémentation

Chaque affirmation de ce tutoriel est couverte par un test. Les plus intéressants :

describe('SAR money conversion', () => {
  it('avoids the float trap that Math.round(x * 100) walks into', () => {
    // Le bug classique : 19.99 * 100 === 1998.9999999999998
    expect(19.99 * 100).not.toBe(1999);
    expect(sarToHalalas('19.99')).toBe(1999);
  });
 
  it('rejects more than two decimals rather than silently truncating', () => {
    expect(() => sarToHalalas('1.005')).toThrow(MoneyError);
  });
});
 
describe('Saudi VAT extraction', () => {
  it('extracts 15% from a VAT-inclusive price', () => {
    // 115,00 SAR TTC => 100,00 SAR HT + 15,00 SAR de TVA
    expect(extractVat(sarToHalalas('115.00'))).toBe(1500);
  });
 
  it('produces a net that grosses back up to the original price', () => {
    for (const price of ['9.99', '19.99', '33.33', '250.75', '1.01']) {
      const gross = sarToHalalas(price);
      const net = gross - extractVat(gross);
      expect(Math.round(net * 1.15)).toBe(gross);
    }
  });
});
 
describe('Moyasar callback verification', () => {
  const expected = { amount: sarToHalalas('100.00'), orderId: 'ORD-1' };
 
  it('rejects a tampered amount', () => {
    // L'attaque : payer 1,00 SAR puis modifier l'URL de retour.
    expect(verifyPayment(payment({ amount: 100 }), expected)).toEqual({
      settled: false,
      reason: 'amount_mismatch',
    });
  });
 
  it('rejects a payment belonging to another order', () => {
    expect(
      verifyPayment(payment({ metadata: { order_id: 'ORD-999' } }), expected),
    ).toEqual({ settled: false, reason: 'order_mismatch' });
  });
});
 
describe('webhook handling', () => {
  it('processes an event once and ignores the retry', async () => {
    const store = new InMemoryEventStore();
    let calls = 0;
    const run = () =>
      handleWebhook(body, sign(body), secret, store, async () => {
        calls += 1;
      });
 
    expect(await run()).toBe('processed');
    expect(await run()).toBe('duplicate');
    expect(calls).toBe(1);
  });
});

Lancez-les avec npx vitest run, et vérifiez les types avec npx tsc --noEmit.

Au-delà des tests unitaires, exercez les bacs à sable réels avant la mise en production. Les deux passerelles publient des cartes de test et des identités clients de test qui produisent un refus de façon déterministe, et les chemins de refus sont ceux que vos utilisateurs rencontreront à trois heures du matin. Parcourez notamment : un défi 3-D Secure que le client abandonne, une session Tabby qui revient rejected, un webhook livré deux fois, et une capture réessayée après un délai d'attente.

Dépannage

Les montants sont faux d'un facteur exactement 100. Vous avez envoyé des halalas à Tabby ou une chaîne décimale à Moyasar. Si vous avez adopté le type Halalas, cela devient une erreur de compilation ; si c'est arrivé en production, c'est qu'un number brut a été converti de force quelque part.

La vérification de signature échoue systématiquement. Vous hachez le corps analysé puis re-sérialisé. Capturez les octets bruts au niveau du middleware, avant l'analyse JSON.

Les paiements restent initiated indéfiniment. Le client n'a jamais terminé le défi 3-D Secure. C'est normal et fréquent sur mobile — traitez-le comme un panier abandonné et non comme un échec, et assurez-vous que c'est le webhook, et non la redirection, qui clôture la commande.

Tabby refuse toutes les commandes de test. Utilisez les identités d'acheteurs de test de la documentation Tabby ; des numéros de téléphone et adresses e-mail arbitraires seront refusés par le modèle d'évaluation, et c'est voulu.

Certaines commandes sont marquées payées deux fois. Votre réservation d'idempotence et votre mise à jour de commande sont dans deux transactions distinctes.

Les clés de production sont refusées. Les deux passerelles exigent un enrôlement marchand complet, adossé à un registre de commerce valide, avant d'activer le mode production. Les clés de test fonctionnent immédiatement, les clés de production non.

Pour aller plus loin

  • Branchez le volet règlement en rapprochant quotidiennement les versements de la passerelle et votre table de commandes — la passerelle fait foi sur ce qui a réellement été déposé, et cela différera de ce que vous avez encaissé, du montant des frais et des décalages de date.
  • Émettez la facture fiscale correspondante : voyez notre intégration de la phase 2 de la facturation électronique ZATCA en TypeScript pour le volet déclaratif, qui consomme exactement la ventilation de TVA calculée à l'étape 2.
  • Si votre tunnel exige une identité vérifiée — produits réglementés, commandes de forte valeur, comptes B2B — ajoutez l'authentification nationale avec notre guide d'intégration Nafath OAuth2/OIDC.
  • Pour la vision commerciale plus large de la vente en Arabie Saoudite, lisez l'API Salla et le patron middleware.

Conclusion

Les parties difficiles d'un tunnel de paiement saoudien ne sont pas les appels HTTP. Ce sont les trois endroits où une implémentation d'apparence raisonnable perd discrètement de l'argent : une incompatibilité d'unités qu'aucune passerelle ne signalera, une redirection que le client peut modifier, et un webhook qui s'exécute deux fois.

Les parades sont toutes bon marché. Un type marqué Halalas transforme la première en erreur de compilation. Recharger le paiement et vérifier quatre champs transforme la deuxième en avertissement journalisé. Une contrainte UNIQUE dans la même transaction transforme la troisième en opération sans effet. Aucune ne dépasse quelques dizaines de lignes, et toutes les trois sont bien plus pénibles à rétro-installer une fois que vous avez des commandes réelles.

Construisez-les dès le premier jour.


Besoin d'une intégration de paiement saoudienne livrée plutôt que déboguée ? Nous construisons et exploitons des tunnels de paiement mada, BNPL et conformes à la facturation électronique pour les marchands saoudiens et du Golfe. Parlons-en.