écrits/tutorial/2026/08
Tutorial14 août 2026·30 min

Construire un moteur de réconciliation des règlements de paiement saoudien en TypeScript

Votre tableau de bord indique 480 000 SAR encaissés ce mois-ci. La banque a versé 472 318 SAR. Ce tutoriel construit le moteur qui explique la différence : arithmétique entière en halalas, calendrier où le vendredi et le samedi sont chômés, correspondance à niveaux multiples sur mada, Moyasar et Tabby, et rapport d'écarts actionnable par l'équipe financière. Chaque exemple est vérifié tsc-strict et couvert par 30 tests.

Intégrer une passerelle de paiement, c'est la partie que tout le monde budgétise. La réconciliation, c'est la partie qui surgit trois mois plus tard, quand l'équipe financière demande pourquoi le tableau de bord affiche 480 000 SAR et le relevé bancaire indique 472 318 SAR.

Les deux chiffres sont corrects. L'écart, c'est le taux d'escompte marchand, la TVA sur ce taux, deux captures qui ne se sont jamais réglées, un remboursement réglé dans un lot ultérieur à sa vente, et une transaction terminale que personne n'a enregistrée. Tant que rien ne réconcilie ces deux livres automatiquement, quelqu'un dans votre équipe financière le fait dans un tableur en fin de mois — et les erreurs commises sont invisibles jusqu'à ce qu'un audit les trouve.

Ce tutoriel construit ce quelque chose.

C'est la suite, pas le point de départ. Si vous n'avez pas encore construit le paiement, commencez par Intégrer les passerelles de paiement saoudiennes en TypeScript, qui couvre l'autorisation, le 3-D Secure et les webhooks. Celui-ci reprend là où l'autre s'arrête.

Ce que vous allez construire

Un moteur de réconciliation qui prend deux entrées — votre registre de paiements interne et les fichiers de règlement que votre acquéreur et vos PSP livrent — et produit un rapport en trois parties :

  1. Les paires correspondantes, chacune étiquetée avec le niveau de confiance ayant produit la correspondance.
  2. Les écarts, classés par cause, pour que chacun soit acheminé vers la personne qui peut réellement le corriger.
  3. Les totaux qui relient votre chiffre d'affaires brut au cash net arrivé en banque, avec les frais expliquant la différence.

Le principe de conception tout au long : une correspondance ambiguë est un écart. Un moteur qui devine est pire qu'aucun moteur, car il produit un rapport propre qui est silencieusement faux.

Prérequis

  • Node.js 20 ou supérieur, et TypeScript 5 avec strict activé
  • Une intégration de paiement fonctionnelle produisant un registre des paiements capturés
  • Des fichiers de règlement exemples de votre acquéreur et vos PSP — les vrais, pas les exemples de documentation
  • Vitest pour la suite de tests

Installez ce dont les exemples ont besoin :

npm install -D typescript vitest

Le tsconfig.json utilisé pour vérifier chaque extrait ci-dessous :

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

Étape 1 : L'argent comme entiers, parsés depuis des chaînes

Les fichiers de règlement arrivent en CSV. Les montants arrivent comme chaînes décimales. Le bug le plus courant dans tout ce domaine est parseFloat(row.amount) * 100.

Essayez : 8.29 * 100 vaut 828.9999999999999 en IEEE 754. Arrondissez et vous êtes tranquille ; tronquez et vous avez silencieusement perdu un halala sur chaque ligne affectée. Sur 40 000 transactions par mois, c'est un rapport d'écarts qui ne s'équilibre jamais et que personne ne peut expliquer.

Parsez la chaîne comme une chaîne.

/** A signed integer number of halalas. 1 SAR = 100 halalas. */
export type Halalas = number & { readonly __brand: unique symbol };
 
export function halalas(value: number): Halalas {
  if (!Number.isSafeInteger(value)) {
    throw new RangeError(`Halalas must be a safe integer, received: ${value}`);
  }
  return value as Halalas;
}
 
export function addHalalas(...values: Halalas[]): Halalas {
  return halalas(values.reduce<number>((sum, v) => sum + v, 0));
}
 
/** Accepts "1234.56", "1,234.5", "-80", "0.07". Rejects anything else. */
const SAR_DECIMAL = /^(-)?(\d{1,3}(?:,\d{3})*|\d+)(?:\.(\d{1,2}))?$/;
 
export function parseSarToHalalas(raw: string): Halalas {
  const trimmed = raw.trim();
  const match = SAR_DECIMAL.exec(trimmed);
  if (match === null) {
    throw new TypeError(`Unparseable SAR amount: ${JSON.stringify(raw)}`);
  }
  const [, sign, whole = '0', fraction = ''] = match;
  const units = Number.parseInt(whole.replace(/,/g, ''), 10);
  const cents = Number.parseInt(fraction.padEnd(2, '0'), 10);
  const magnitude = units * 100 + cents;
  return halalas(sign === '-' ? -magnitude : magnitude);
}

Le type marqué fait un vrai travail. Halalas est un number à l'exécution sans aucun surcoût, mais TypeScript ne laissera pas un number brut — un float SAR, un pourcentage, un index de tableau — se glisser dans un champ qui attend des halalas sans passer par halalas(), qui valide.

Notez ce que le parseur refuse. Une troisième décimale lève une exception au lieu d'arrondir. Dans un fichier de règlement, 1.005 n'est pas une opportunité d'arrondi ; cela signifie que vous parsez une colonne que vous pensez être en SAR et qui est autre chose. Échouer bruyamment à l'ingestion est bien moins cher que de le découvrir dans le rapport d'écarts.

La TVA sur les frais mérite sa propre fonction, à cause du cas de remboursement :

/**
 * VAT on the acquirer fee, rounded half-up on the absolute value so that a
 * refund's fee VAT mirrors the sale's exactly instead of drifting by 1 halala.
 */
export function vatOnFee(fee: Halalas, ratePercent: number): Halalas {
  const sign = fee < 0 ? -1 : 1;
  const raw = (Math.abs(fee) * ratePercent) / 100;
  return halalas(sign * Math.round(raw));
}

Arrondir la valeur signée directement utiliserait le comportement demi-vers-l'infini-positive de Math.round, qui est asymétrique : des frais de 11,5 halalas arrondissent à 12, et leur inverse de -11,5 arrondit à -11. Un halala, définitivement coincé, sur chaque inversion atteignant cette limite. Arrondir la magnitude et réappliquer le signe rend les inversions exactes.

La TVA s'applique aux frais, pas à la transaction. Les 15% sont prélevés sur le taux d'escompte marchand que conserve l'acquéreur, et c'est une ligne séparée dans le fichier de règlement. Si vous la modélisez comme TVA sur le montant de vente, chaque ligne sera en écart.

Étape 2 : Un calendrier où le weekend est vendredi et samedi

L'aide aux jours ouvrés par défaut de chaque bibliothèque de dates suppose samedi et dimanche. En Arabie Saoudite, le weekend est vendredi et samedi, et dimanche est un jour ouvré complet.

Ratez cela et votre SLA de règlement sera décalé de deux jours dans le sens qui compte : vous alerterez sur un règlement en retard chaque dimanche matin, et resterez silencieux sur les captures du jeudi qui n'arrivent genuinement pas.

/** Saudi Arabia's weekend is Friday and Saturday, not Saturday and Sunday. */
const FRIDAY = 5;
const SATURDAY = 6;
 
export type IsoDate = string; // YYYY-MM-DD
 
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
 
function toUtc(date: IsoDate): Date {
  if (!ISO_DATE.test(date)) {
    throw new TypeError(`Expected YYYY-MM-DD, received: ${JSON.stringify(date)}`);
  }
  const parsed = new Date(`${date}T00:00:00.000Z`);
  // Date rolls impossible days over silently: '2026-02-30' becomes March 2nd
  // rather than NaN. Only a round-trip comparison catches that.
  if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
    throw new TypeError(`Not a real calendar date: ${date}`);
  }
  return parsed;
}
 
export function isBusinessDay(date: IsoDate, holidays: ReadonlySet<IsoDate>): boolean {
  const day = toUtc(date).getUTCDay();
  return day !== FRIDAY && day !== SATURDAY && !holidays.has(date);
}
 
/** Walks forward `count` business days, skipping Fri/Sat and Eid closures. */
export function addBusinessDays(
  start: IsoDate,
  count: number,
  holidays: ReadonlySet<IsoDate> = new Set(),
): IsoDate {
  if (!Number.isInteger(count) || count < 0) {
    throw new RangeError(`count must be a non-negative integer, received: ${count}`);
  }
  const cursor = toUtc(start);
  let remaining = count;
  while (remaining > 0) {
    cursor.setUTCDate(cursor.getUTCDate() + 1);
    if (isBusinessDay(cursor.toISOString().slice(0, 10), holidays)) remaining -= 1;
  }
  return cursor.toISOString().slice(0, 10);
}

La vérification aller-retour dans toUtc n'est pas du rembourrage défensif — elle a découvert un vrai bug pendant l'écriture de ce code. new Date('2026-02-30T00:00:00.000Z') ne retourne pas une date invalide. Elle retourne le 2 mars. Une date malformée dans un fichier de règlement aurait décalé toute une fenêtre SLA sans rien signaler.

Les jours fériés sont injectés plutôt que codés en dur, car l'Aïd el-Fitr et l'Aïd el-Adha se déplacent par rapport au calendrier grégorien chaque année et les jours de fermeture bancaire sont annoncés, pas calculés. Chargez-les depuis la configuration et mettez-les à jour annuellement.

Tout fonctionne en UTC sur des chaînes de date uniquement. N'accédez pas au fuseau horaire local : un serveur tournant en UTC et un fichier de règlement horodaté à l'heure de Riyad ne seront pas d'accord sur quel jour appartient une capture en fin de soirée, et vous passerez une journée à chercher un écart qui n'existe pas.

Étape 3 : Modéliser les deux côtés honnêtement

Deux types d'enregistrements, et la discipline est qu'aucun ne prétend savoir quoi que ce soit que l'autre côté ne lui a pas dit.

export type Provider = 'mada' | 'moyasar' | 'tabby';
export type EntryKind = 'sale' | 'refund' | 'chargeback' | 'adjustment';
 
/** What our own system believes happened. */
export interface LedgerEntry {
  readonly paymentId: string;
  readonly orderId: string;
  readonly provider: Provider;
  /** The PSP's own id, when we stored it. Null for terminal-only sales. */
  readonly providerRef: string | null;
  /** Retrieval Reference Number — the only id a mada acquirer file guarantees. */
  readonly rrn: string | null;
  readonly kind: Extract<EntryKind, 'sale' | 'refund'>;
  /** Signed: sales positive, refunds negative. */
  readonly grossHalalas: Halalas;
  readonly capturedOn: IsoDate;
}
 
/** What the money actually did, according to the settlement file. */
export interface SettlementRow {
  readonly fileId: string;
  readonly rowId: string;
  readonly provider: Provider;
  readonly providerRef: string | null;
  readonly rrn: string | null;
  readonly kind: EntryKind;
  readonly grossHalalas: Halalas;
  /** What the acquirer kept. Positive on sales, zero on most refunds. */
  readonly feeHalalas: Halalas;
  readonly feeVatHalalas: Halalas;
  /** Must equal gross - fee - feeVat. Verified on ingest. */
  readonly netHalalas: Halalas;
  readonly settledOn: IsoDate;
}

LedgerEntry.kind est restreint avec Extract aux ventes et remboursements uniquement. Votre système peut initier ces deux. Il ne peut pas initier un chargeback ou un ajustement de schéma — ceux-ci n'arrivent que de l'extérieur, donc seul SettlementRow peut les porter. Encoder cela dans le type signifie qu'une entrée de registre impossible ne compilera pas.

La convention de signe est la décision porteuse : ventes positives, remboursements et chargebacks négatifs, partout, après normalisation. Faites-le correctement à la frontière et toute somme en aval est une simple addition.

Le SLA et la tolérance sont une politique par fournisseur, pas des constantes :

export interface ProviderPolicy {
  /** Business days from capture to money-in-bank before we call it overdue. */
  readonly settlementSlaBusinessDays: number;
  /** Tolerance for rounding drift between our fee model and theirs. */
  readonly feeToleranceHalalas: number;
}
 
export const DEFAULT_POLICIES: Readonly<Record<Provider, ProviderPolicy>> = {
  // Card rails settle fast; anything past this is a real operational break.
  mada: { settlementSlaBusinessDays: 2, feeToleranceHalalas: 2 },
  moyasar: { settlementSlaBusinessDays: 3, feeToleranceHalalas: 2 },
  // BNPL pays the merchant on its own cycle, unrelated to customer instalments.
  tabby: { settlementSlaBusinessDays: 7, feeToleranceHalalas: 5 },
};

Traitez ces chiffres comme des valeurs de remplacement et remplacez-les par votre propre contrat. Les calendriers de règlement et les taux d'escompte marchand sont négociés par commerçant. Les valeurs ci-dessus sont structurellement correctes — les cartes se règlent en quelques jours ouvrés, le BNPL prend plus de temps — mais les chiffres exacts appartiennent à votre contrat acquéreur, et un taux supposé plutôt que lu est un taux contre lequel vous réconcilierez éternellement.

Le commentaire BNPL est le point que la plupart des intégrations ratent. Quand un client achète via Tabby en quatre versements, le marchand n'est pas payé en quatre versements. Tabby paie le marchand le total de la commande moins la commission, une fois, sur son propre cycle de règlement, et supporte lui-même le risque de crédit client. Si vous modélisez le règlement marchand par rapport au calendrier de versements du client, vous construirez un moteur de réconciliation qui signale trois faux écarts pour chaque commande BNPL acceptée.

Étape 4 : Normaliser à la frontière, vérifier à l'ingestion

Les fichiers acquéreur publient les remboursements comme montant positif avec une colonne type indiquant REFUND. Additionnez cette colonne naïvement et les remboursements gonflent vos revenus au lieu de les réduire.

Inversez le signe une fois, à la frontière :

export interface RawMadaRow {
  readonly RRN: string;
  readonly AUTH_CODE: string;
  readonly TXN_TYPE: string;
  readonly TXN_AMOUNT: string;
  readonly MDR_AMOUNT: string;
  readonly MDR_VAT: string;
  readonly NET_AMOUNT: string;
  readonly SETTLEMENT_DATE: string;
}
 
export function normaliseMadaRow(fileId: string, index: number, raw: RawMadaRow): SettlementRow {
  const isCredit = raw.TXN_TYPE === 'REFUND' || raw.TXN_TYPE === 'CHARGEBACK';
  const sign = isCredit ? -1 : 1;
  return {
    fileId,
    rowId: `${fileId}:${index}`,
    provider: 'mada',
    providerRef: null,
    rrn: raw.RRN,
    kind: raw.TXN_TYPE === 'CHARGEBACK' ? 'chargeback' : isCredit ? 'refund' : 'sale',
    grossHalalas: halalas(sign * parseSarToHalalas(raw.TXN_AMOUNT)),
    feeHalalas: halalas(sign * parseSarToHalalas(raw.MDR_AMOUNT)),
    feeVatHalalas: halalas(sign * parseSarToHalalas(raw.MDR_VAT)),
    netHalalas: halalas(sign * parseSarToHalalas(raw.NET_AMOUNT)),
    settledOn: raw.SETTLEMENT_DATE,
  };
}

Écrivez une de ces fonctions par fournisseur. Le reste du moteur travaille ensuite sur SettlementRow et n'apprend jamais que mada, Moyasar et Tabby ne sont pas d'accord sur les noms de colonnes, les formats de date et les conventions de signe. Ajouter un quatrième fournisseur signifie ajouter un normaliseur, pas toucher au moteur de correspondance.

Maintenant, l'invariant qui détecte la corruption de fichier avant qu'elle n'atteigne le moteur de correspondance :

export class FeeInvariantError extends Error {
  constructor(readonly row: SettlementRow, readonly expected: Halalas) {
    super(
      `Row ${row.rowId}: net ${row.netHalalas} != gross ${row.grossHalalas} ` +
        `- fee ${row.feeHalalas} - vat ${row.feeVatHalalas} (expected ${expected})`,
    );
    this.name = 'FeeInvariantError';
  }
}
 
export function assertFeeInvariant(row: SettlementRow): void {
  const expected = addHalalas(
    row.grossHalalas,
    halalas(-row.feeHalalas),
    halalas(-row.feeVatHalalas),
  );
  if (expected !== row.netHalalas) throw new FeeInvariantError(row, expected);
}

Chaque ligne de règlement affirme sa propre arithmétique interne. Si net ne vaut pas gross - fee - feeVat, vous avez mal mappé une colonne, ou le fichier contient une catégorie de déduction que vous ne connaissez pas encore. Dans les deux cas, cette ligne ne doit pas entrer silencieusement dans le rapport.

L'ingestion a une autre exigence qui surprend les gens : les fichiers de règlement sont réémis. Un fichier corrigé arrive avec les mêmes transactions et un montant corrigé. L'idempotence doit donc être par ligne, pas par fichier.

export interface IngestResult {
  readonly accepted: readonly SettlementRow[];
  readonly duplicates: readonly string[];
  readonly rejected: readonly { readonly rowId: string; readonly reason: string }[];
}
 
export function ingest(
  rows: readonly SettlementRow[],
  alreadyIngested: ReadonlySet<string> = new Set(),
): IngestResult {
  const seen = new Set(alreadyIngested);
  const accepted: SettlementRow[] = [];
  const duplicates: string[] = [];
  const rejected: { rowId: string; reason: string }[] = [];
 
  for (const row of rows) {
    if (seen.has(row.rowId)) {
      duplicates.push(row.rowId);
      continue;
    }
    try {
      assertFeeInvariant(row);
      seen.add(row.rowId);
      accepted.push(row);
    } catch (error) {
      rejected.push({
        rowId: row.rowId,
        reason: error instanceof Error ? error.message : String(error),
      });
    }
  }
  return { accepted, duplicates, rejected };
}

Notez qu'une ligne incorrecte est mise en quarantaine avec une raison, pas propagée à travers tout le lot. Une ligne malformée sur 12 000 ne devrait pas vous empêcher de réconcilier les 11 999 autres — mais elle doit apparaître quelque part qu'un humain lira.

En production, persistez rowId avec une contrainte unique dans votre base de données et laissez la contrainte être la vraie garantie d'idempotence. L'ensemble en mémoire ci-dessus est la même logique, rendue testable.

Étape 5 : Correspondance à niveaux qui refuse de deviner

Trois niveaux, essayés dans l'ordre, chacun moins certain que le précédent.

Niveau 1 — la référence propre du PSP. Si vous avez stocké moy_... ou tby_... au moment de la capture et que la ligne de règlement porte le même identifiant, c'est une correspondance définitive.

Niveau 2 — RRN plus montant. Un fichier acquéreur mada n'a fréquemment aucune référence PSP ; ce qu'il garantit, c'est le Numéro de Référence de Récupération. Le RRN seul n'est pas tout à fait suffisant, donc il est couplé à un montant exact.

Niveau 3 — montant plus fenêtre temporelle. Pour les transactions terminales sans identifiant utilisable. Accepté uniquement quand exactement un candidat survit au filtre.

export function matchEntries(
  ledger: readonly LedgerEntry[],
  rows: readonly SettlementRow[],
  policies: Readonly<Record<Provider, ProviderPolicy>>,
  holidays: ReadonlySet<IsoDate>,
): { claims: Claim[]; unclaimed: SettlementRow[] } {
  const available = new Map(rows.map((row) => [row.rowId, row]));
  const claims: Claim[] = [];
 
  const take = (candidates: SettlementRow[]): SettlementRow[] => {
    for (const row of candidates) available.delete(row.rowId);
    return candidates;
  };
 
  const remaining = (predicate: (row: SettlementRow) => boolean): SettlementRow[] =>
    [...available.values()].filter(predicate);
 
  for (const entry of ledger) {
    const sameProvider = (row: SettlementRow): boolean => row.provider === entry.provider;
 
    if (entry.providerRef !== null) {
      const byRef = remaining((r) => sameProvider(r) && r.providerRef === entry.providerRef);
      if (byRef.length > 0) {
        claims.push({ entry, rows: take(byRef), tier: 1 });
        continue;
      }
    }
 
    if (entry.rrn !== null) {
      const byRrn = remaining(
        (r) => sameProvider(r) && r.rrn === entry.rrn && r.grossHalalas === entry.grossHalalas,
      );
      if (byRrn.length > 0) {
        claims.push({ entry, rows: take(byRrn), tier: 2 });
        continue;
      }
    }
 
    const policy = policies[entry.provider];
    const deadline = addBusinessDays(entry.capturedOn, policy.settlementSlaBusinessDays, holidays);
    const byWindow = remaining(
      (r) =>
        sameProvider(r) &&
        r.grossHalalas === entry.grossHalalas &&
        r.settledOn >= entry.capturedOn &&
        r.settledOn <= deadline,
    );
    // Exactly one, or we refuse — two identical amounts on the same day are a
    // genuinely ambiguous pair and a human has to look at them.
    if (byWindow.length === 1) {
      claims.push({ entry, rows: take(byWindow), tier: 3 });
    } else {
      claims.push({ entry, rows: [], tier: 3 });
    }
  }
 
  return { claims, unclaimed: [...available.values()] };
}

Trois propriétés méritent d'être nommées.

Les lignes sont réclamées, pas seulement lues. take() supprime les lignes correspondantes de available, donc aucune ligne de règlement ne peut satisfaire deux entrées de registre. Sans cela, un double débit réconcilie parfaitement contre deux commandes séparées et disparaît.

Le fournisseur fait toujours partie du prédicat. Deux fournisseurs peuvent facilement produire le même montant le même jour. Les croiser produit un rapport qui s'équilibre et n'a aucun sens.

Le niveau 3 refuse les égalités. byWindow.length === 1 est délibéré. Si deux ventes de 1 150,00 SAR se règlent le même jour et qu'aucune n'a d'identifiant, la sortie honnête est deux écarts, pas un tirage au sort. Comme les chaînes de date ISO se trient lexicographiquement, la comparaison de fenêtre est une simple comparaison de chaîne — pas de parsing de date dans la boucle chaude.

Étape 6 : Classer les écarts selon qui les corrige

Un rapport d'écarts qui dit "47 exceptions" n'est pas actionnable. Un rapport d'écarts qui sépare la banque est en retard de nous avons facturé le mauvais montant achemine chaque exception vers la personne qui peut la fermer.

export type BreakCode =
  | 'MISSING_IN_SETTLEMENT'
  | 'OVERDUE_IN_SETTLEMENT'
  | 'MISSING_IN_LEDGER'
  | 'AMOUNT_MISMATCH'
  | 'DUPLICATE_SETTLEMENT'
  | 'FEE_INVARIANT_BROKEN';
CodeCe que cela signifieQui en est responsable
MISSING_IN_SETTLEMENTCapturé, pas encore réglé, toujours dans le SLAPersonne — c'est normal, pas d'alerte
OVERDUE_IN_SETTLEMENTDépassé la date limite SLA et toujours pas payéOpérations, puis l'acquéreur
MISSING_IN_LEDGERDe l'argent est arrivé sans commande derrièreIngénierie — généralement un webhook perdu
AMOUNT_MISMATCHLe montant réglé diverge au-delà de la toléranceFinance — mauvais modèle de frais ou capture partielle
DUPLICATE_SETTLEMENTDeux lignes de règlement, un seul paiementLitige acquéreur
FEE_INVARIANT_BROKENL'arithmétique interne du fichier ne tient pasIngénierie — une colonne est mal mappée

La distinction entre les deux premières lignes est ce qui rend le rapport viable. Une capture de ce matin ne s'est pas encore réglée et ne devrait pas l'avoir. Si vous émettez une alerte, votre équipe regardera des centaines de non-événements quotidiennement et arrêtera de lire le rapport en une semaine.

export function reconcile(input: ReconcileInput): ReconciliationReport {
  const policies = input.policies ?? DEFAULT_POLICIES;
  const holidays = input.holidays ?? new Set<IsoDate>();
  const { claims, unclaimed } = matchEntries(input.ledger, input.settlement, policies, holidays);
 
  const breaks: Break[] = [];
  const matched: MatchedPair[] = [];
  let grossSettled = halalas(0);
  let netSettled = halalas(0);
  let fees = halalas(0);
  let unsettled = halalas(0);
 
  for (const claim of claims) {
    const { entry, rows } = claim;
    const policy = policies[entry.provider];
 
    if (rows.length === 0) {
      unsettled = addHalalas(unsettled, entry.grossHalalas);
      const deadline = addBusinessDays(entry.capturedOn, policy.settlementSlaBusinessDays, holidays);
      const overdue = input.asOf > deadline;
      breaks.push({
        code: overdue ? 'OVERDUE_IN_SETTLEMENT' : 'MISSING_IN_SETTLEMENT',
        provider: entry.provider,
        paymentId: entry.paymentId,
        rowIds: [],
        expectedHalalas: entry.grossHalalas,
        actualHalalas: null,
        detail: overdue
          ? `Captured ${entry.capturedOn}, due by ${deadline}, still unsettled on ${input.asOf}.`
          : `Captured ${entry.capturedOn}, within SLA until ${deadline}.`,
      });
      continue;
    }
 
    const gross = sumBy(rows, (r) => r.grossHalalas);
    const net = sumBy(rows, (r) => r.netHalalas);
    const fee = addHalalas(
      sumBy(rows, (r) => r.feeHalalas),
      sumBy(rows, (r) => r.feeVatHalalas),
    );
 
    if (rows.length > 1) {
      breaks.push({
        code: 'DUPLICATE_SETTLEMENT',
        provider: entry.provider,
        paymentId: entry.paymentId,
        rowIds: rows.map((r) => r.rowId),
        expectedHalalas: entry.grossHalalas,
        actualHalalas: gross,
        detail: `${rows.length} settlement rows point at one payment.`,
      });
    } else if (Math.abs(gross - entry.grossHalalas) > policy.feeToleranceHalalas) {
      breaks.push({
        code: 'AMOUNT_MISMATCH',
        provider: entry.provider,
        paymentId: entry.paymentId,
        rowIds: rows.map((r) => r.rowId),
        expectedHalalas: entry.grossHalalas,
        actualHalalas: gross,
        detail: `Ledger and settlement disagree by ${gross - entry.grossHalalas} halalas.`,
      });
    }
 
    grossSettled = addHalalas(grossSettled, gross);
    netSettled = addHalalas(netSettled, net);
    fees = addHalalas(fees, fee);
    matched.push({
      paymentId: entry.paymentId,
      tier: claim.tier,
      rowIds: rows.map((r) => r.rowId),
      grossHalalas: gross,
      netHalalas: net,
      feeHalalas: fee,
    });
  }
 
  for (const row of unclaimed) {
    breaks.push({
      code: 'MISSING_IN_LEDGER',
      provider: row.provider,
      paymentId: null,
      rowIds: [row.rowId],
      expectedHalalas: null,
      actualHalalas: row.grossHalalas,
      detail: `Settled ${row.settledOn} as ${row.kind}, no matching ledger entry.`,
    });
  }
 
  return {
    asOf: input.asOf,
    matched,
    breaks,
    totals: {
      grossSettledHalalas: grossSettled,
      netSettledHalalas: netSettled,
      feesHalalas: fees,
      unsettledHalalas: unsettled,
    },
  };
}

asOf est une entrée explicite plutôt qu'un appel à new Date(). C'est ce qui rend tout le moteur déterministe : le même registre et les mêmes fichiers produisent toujours le même rapport, donc vous pouvez ré-exécuter la réconciliation du mardi dernier et obtenir la réponse du mardi dernier. Lire l'horloge à l'intérieur d'un moteur de réconciliation le rend non testable et fait mentir le retraitement.

La comparaison de tolérance est Math.abs(gross - entry.grossHalalas) > policy.feeToleranceHalalas, donc une différence d'arrondi d'un ou deux halalas entre votre modèle de frais et celui de l'acquéreur passe silencieusement, tandis qu'une vraie divergence de montant est capturée. Réglez-la sur quelques halalas, jamais sur un pourcentage.

Étape 7 : Le remboursement qui se règle dans un lot ultérieur

C'est le cas qui brise les implémentations naïves, donc ça vaut la peine de le parcourir de bout en bout.

Un client achète pour 1 150,00 SAR le 13. Cela se règle le 17 : brut 115000, MDR 1150, TVA sur MDR 173, net 113677. Le 19 il est remboursé intégralement, et ce remboursement se règle le 20 — un fichier différent, un lot différent, une période de reporting différente.

Deux choses doivent être vraies pour que le rapport soit correct.

Le remboursement doit se compenser avec la vente, entre lots. Une correspondance ligne par ligne qui traite chaque fichier isolément signale la vente comme un crédit inexpliqué et le remboursement comme de l'argent sans correspondance sortant. La réconciliation est une position dans le temps, pas un diff par fichier.

Le MDR n'est pas restitué. Quand vous remboursez un client, l'acquéreur ne rend généralement pas le taux d'escompte gagné sur la vente originale. Donc l'état final correct est : le brut se compense à zéro, la position nette en cash est négative de 1 323 halalas, et ces 1 323 sont des charges de frais que vous avez absorbées.

C'est l'assertion dans la suite de tests :

it('nets a refund that settles in a later batch than its sale', () => {
  const report = reconcile({
    asOf: '2026-08-25',
    ledger: [
      entry({ paymentId: 'pay_1', providerRef: 'moy_a', provider: 'moyasar' }),
      entry({
        paymentId: 'pay_2',
        providerRef: 'moy_a_r',
        provider: 'moyasar',
        kind: 'refund',
        grossHalalas: halalas(-115000),
        capturedOn: '2026-08-19',
      }),
    ],
    settlement: [
      row({ providerRef: 'moy_a', provider: 'moyasar' }),
      row({
        rowId: 'MADA-20260820:0',
        providerRef: 'moy_a_r',
        provider: 'moyasar',
        kind: 'refund',
        grossHalalas: halalas(-115000),
        feeHalalas: halalas(0),
        feeVatHalalas: halalas(0),
        netHalalas: halalas(-115000),
        settledOn: '2026-08-20',
      }),
    ],
  });
 
  expect(report.breaks).toHaveLength(0);
  expect(report.totals.grossSettledHalalas).toBe(0);
  // The sale's MDR is not returned when the customer is refunded.
  expect(report.totals.netSettledHalalas).toBe(-1323);
  expect(report.totals.feesHalalas).toBe(1323);
});

Si votre moteur signale un net nul dans ce scénario, il absorbe silencieusement des charges de frais qui devraient être visibles au compte de résultat.

Étape 8 : Brancher la sortie vers la comptabilité

Le rapport est une structure de données. Il devient utile quand il pilote des écritures comptables.

Le schéma est un compte de transit. À la capture vous débitez un compte de transit des paiements et créditez le chiffre d'affaires. Au règlement vous débitez le cash, créditez le transit, et débitez la charge de frais avec sa TVA récupérable. Le solde du compte de transit à tout moment devrait égaler totals.unsettledHalalas — l'argent gagné qui n'est pas encore arrivé en banque.

Cette seule égalité est le contrôle le plus fort de tout le système. Quand le solde de transit et le total non réglé du moteur divergent, quelque chose va mal dans l'un d'eux, et vous le découvrez en un jour au lieu d'un audit de fin d'année.

Émettez le rapport selon un calendrier après l'arrivée du fichier de chaque fournisseur, acheminez les codes d'écart vers différentes destinations — les règlements en retard vers les opérations, les manquants dans le registre vers l'ingénierie — et stockez chaque exécution. L'historique stocké est ce qui vous permet de montrer à un auditeur qu'un écart a été détecté le 17 et fermé le 19.

Les totaux de frais importent aussi au-delà de la comptabilité : totals.feesHalalas divisé par totals.grossSettledHalalas, suivi par fournisseur par mois, est votre vrai coût mixte d'acceptation des paiements. La plupart des commerçants citent le taux dans leur contrat. Très peu connaissent le chiffre qu'ils paient réellement, et la différence entre les deux est une position de négociation.

Tester votre implémentation

La réconciliation est le domaine rare où les tests unitaires exhaustifs sont genuinement bon marché : fonctions pures, entrées entières, sortie déterministe. La suite qui supporte ce tutoriel comporte 30 tests sur la monnaie, le calendrier, l'ingestion et la réconciliation, et s'exécute en moins de 10 millisecondes.

npx tsc --noEmit && npx vitest run
 ✓ src/recon.test.ts (30 tests) 6ms

 Test Files  1 passed (1)
      Tests  30 passed (30)

Les cas qui méritent d'être écrits en premier, car ce sont eux qui échouent en production :

it('treats Friday and Saturday as the weekend', () => {
  expect(isBusinessDay('2026-08-14', new Set())).toBe(false); // Friday
  expect(isBusinessDay('2026-08-15', new Set())).toBe(false); // Saturday
  expect(isBusinessDay('2026-08-16', new Set())).toBe(true);  // Sunday is a work day
});
 
it('skips the weekend when computing a T+2 deadline', () => {
  // Thursday + 2 business days lands on Monday, not Saturday.
  expect(addBusinessDays('2026-08-13', 2)).toBe('2026-08-17');
});
 
it('refuses to guess between two identical amounts', () => {
  const report = reconcile({
    asOf: '2026-08-18',
    ledger: [entry({ rrn: null })],
    settlement: [row({ rrn: null }), row({ rrn: null, rowId: 'MADA-20260817:1' })],
  });
  expect(report.breaks.map((b) => b.code).sort()).toEqual([
    'MISSING_IN_LEDGER',
    'MISSING_IN_LEDGER',
    'OVERDUE_IN_SETTLEMENT',
  ]);
});
 
it('does not cross-match between providers', () => {
  const report = reconcile({
    asOf: '2026-08-18',
    ledger: [entry({ provider: 'tabby', rrn: null, providerRef: null })],
    settlement: [row({ rrn: null, providerRef: null })],
  });
  expect(report.breaks.map((b) => b.code).sort()).toEqual([
    'MISSING_IN_LEDGER',
    'MISSING_IN_SETTLEMENT',
  ]);
});

Ce troisième test encode le principe de conception comme assertion exécutable. Trois écarts d'une paire ambiguë est la sortie correcte, et l'écrire comme test empêche un futur contributeur d'"améliorer" le moteur vers le devinement.

Au-delà des tests unitaires, exécutez une période fantôme avant de faire confiance au moteur : réconciliez un mois en parallèle avec ce que l'équipe financière fait à la main, et comparez. Chaque désaccord vous apprend quelque chose — généralement une catégorie de déduction ou une ligne d'ajustement que personne n'a documentée. Budgétisez pour la période fantôme.

Dépannage

Chaque ligne brise l'invariant de frais. Votre mapping de colonnes est incorrect, ou le fichier rapporte le net avant une déduction supplémentaire. Imprimez une ligne brute à côté de sa forme normalisée et faites l'arithmétique à la main.

Tout est en écart d'un petit montant constant. Vous appliquez la TVA à la transaction au lieu des frais, ou votre pourcentage de frais est incorrect. Divisez l'écart par le brut pour retrouver le taux que l'acquéreur facture réellement.

Alertes de retard chaque dimanche. L'aide aux jours ouvrés d'une bibliothèque de dates traite dimanche comme un weekend. Les weekends saoudiens sont vendredi et samedi.

Le niveau 3 correspond tout, le niveau 1 ne correspond rien. Vous ne persistez pas la référence fournisseur au moment de la capture. Stockez-la dans la même transaction qui enregistre le paiement — c'est la différence entre correspondance certaine et inférence.

Les commandes BNPL montrent toujours trois faux écarts. Vous réconciliez par rapport au calendrier de versements du client. Le marchand est payé une fois, intégralement, moins la commission.

Le rapport est correct mais personne ne le lit. Vous alertez sur MISSING_IN_SETTLEMENT. Seul OVERDUE_IN_SETTLEMENT mérite une notification.

Les montants dérivent d'un halala sur les inversions. Vous arrondissez une valeur signée. Arrondissez la magnitude et réappliquez le signe.

Prochaines étapes

  • Ajoutez un normaliseur par fournisseur supplémentaire — le moteur de correspondance ne change pas
  • Persistez chaque exécution pour que la durée de vie des écarts, pas seulement leur nombre, devienne mesurable
  • Suivez le coût mixte d'acceptation par fournisseur par mois depuis totals.feesHalalas
  • Alimentez le rapport dans la facturation électronique ZATCA Phase 2, où les montants réglés doivent concorder avec ceux déclarés
  • Comparez l'approche avec le moteur de rapprochement des cotisations GOSI — même forme de correspondance à niveaux, domaine différent

Conclusion

La réconciliation ressemble à un problème de reporting et est en réalité un problème de modélisation. Une fois que l'argent est un entier, que le calendrier sait que le weekend est vendredi et samedi, que les fournisseurs sont normalisés à la frontière, et que le moteur de correspondance refuse de deviner, le rapport s'écrit de lui-même — et il est correct, ce qui est la seule propriété qui compte quand un auditeur le lit.

Les trois décisions qui portent le plus de poids : parser les chaînes décimales sans virgule flottante, faire de asOf une entrée plutôt qu'une lecture d'horloge, et traiter une correspondance ambiguë comme un écart. Tout le reste est de la comptabilité.

La partie la plus difficile est rarement l'algorithme. C'est de découvrir, fichier après fichier, les catégories de déduction et les lignes d'ajustement que personne n'a documentées. Budgétisez pour la période fantôme.


Réconciliation manuelle en fin de mois ? Si votre équipe financière fait correspondre les fichiers de règlement aux commandes dans un tableur, nous pouvons vous dire en une session ce qu'il faudrait construire un moteur comme celui-ci contre vos vrais fournisseurs et formats de fichiers — et où se cachent les écarts dans votre processus actuel. Contactez-nous.