Contrôler des coordonnées bancaires avant un virement

Vérifier qu’un IBAN saisi ou collé est bien formé, avant d’émettre le virement.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que le contrôle est deux identités arithmétiques et une table, et que l’enjeu — un virement qui part une fois — exige précisément ce que le déterminisme donne : la même réponse sur la même entrée, et la retenue qui dit laquelle des deux clés a échoué. La clé RIB n’est pas un ornement : sur cinq cents IBAN français, la clé ISO seule laisse passer 31 des 6 882 lettres sosies saisies à la place d’un chiffre — 0,45 % —, parce que le numéro de compte français est alphanumérique au registre, et la clé RIB ferme les 6 882 cas. Les trois niveaux au-dessus sont fermés pour la même raison, et la question qui reste — est-ce bien le compte de votre fournisseur — se pose désormais au prestataire de paiement, pas à un modèle.

Clés de contrôle ISO 13616 et clé RIB, sur le registre des IBAN

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/check-bank-details-before-a-transfer/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Contrôler un IBAN avant qu'un virement parte, hors ligne et exactement.

Niveau N0. ISO 13616 place deux chiffres de contrôle en troisième et
quatrième position de tout IBAN, calculés sur le reste du numéro par le
modulo 97 d'ISO 7064. Le code pays fixe la longueur — 27 caractères en
France, 22 en Allemagne, 16 en Belgique — et la forme de ce qui suit. Tout
cela est une table et une identité arithmétique : la réponse est exacte et
gratuite.

La table est la part qu'on n'écrit pas. `python-stdnum` ici et `ibantools`
en JavaScript portent tous deux le registre des IBAN, révisé à mesure que
des pays y entrent.

Par-dessus ISO 13616, plusieurs pays posent une seconde clé à l'intérieur de
la partie nationale, et les deux bibliothèques ne couvrent pas les mêmes
pays — mesuré, pas supposé : `python-stdnum` n'a pas de module français, donc
`iban.is_valid` ne contrôle que la clé ISO et le format du registre, tandis
qu'`ibantools` refuse bien `FR0630006000011234567890188`, dont la clé RIB est
fausse. Deux bibliothèques qui répondent différemment sur le même numéro,
ce sont deux extraits qui répondraient différemment sur la même page : cet
extrait calcule donc lui-même la clé RIB, dans les deux langages. Ailleurs,
un numéro qui passe ici peut encore être refusé par la banque du
bénéficiaire.

Mesuré, chaque chiffre sur son propre jeu : aucune des 20 700 fautes d'un
chiffre de cent IBAN français fabriqués ne passe, ni aucune de leurs 1 973
inversions de deux caractères voisins, ni aucune des 6 882 lettres sosies de
cinq cents d'entre eux. La clé ISO seule en laisse passer 31 sur ces 6 882 —
0,45 % —, parce que le numéro de compte français est alphanumérique au
registre et qu'un « I » y est un caractère légal à la place d'un « 1 » ;
c'est la clé RIB qui ferme cela. L'extrait JavaScript fabrique ses propres
numéros et mesure 32 sur 6 892, soit 0,46 %.

Et rien de tout cela ne dit à qui appartient le compte. C'est le service de
vérification du bénéficiaire que tout prestataire de la zone euro doit
proposer depuis le 9 octobre 2025 qui répond à cette question-là, pas un
calcul plus long.
"""

from __future__ import annotations

from stdnum import iban
from stdnum.exceptions import ValidationError

# What people paste around an IBAN: the printed grouping in spaces of every
# kind, and the hyphens a word processor inserts. Anything else left in the
# string is refused rather than dropped, because dropping it would turn a
# mistyped account into a different valid one.
SEPARATORS = "    -‐‑–"

# ISO 13616 caps an IBAN at 34 characters and no country is under 15. Sixty-four
# leaves room for every separator and refuses a pasted paragraph.
MIN_LENGTH, MAX_LENGTH = 15, 34
MAX_CHARACTERS = 64

# The countries whose national part carries the RIB key. Monaco uses the French
# banking standard: its national part is twenty-three characters too and
# satisfies the same modulo 97.
RIB_COUNTRIES = ("FR", "MC")

# The letter-to-digit table of the French RIB key, from the French banking
# standard: A and J are 1, B, K and S are 2, and so on to I, R and Z at 9.
RIB_LETTERS = {c: str((i % 9) + 1) for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ".replace("S", ""))}
RIB_LETTERS.update({c: str(i + 2) for i, c in enumerate("STUVWXYZ")})


def national_key_ok(country: str, bban: str) -> bool | None:
    """
    The country's own key inside the account number, or None when this snippet
    has none for that country. France and Monaco: letters become digits, and
    the whole twenty-three-character national part, key included, is a multiple
    of 97.
    """
    if country not in RIB_COUNTRIES:
        return None
    digits = "".join(RIB_LETTERS.get(c, c) for c in bban)
    return int(digits) % 97 == 0


def check_bank_details(raw, *, expected_country: str | None = None) -> dict:
    """
    Say whether `raw` carries a well-formed IBAN, and for which country.

    `expected_country` is the two-letter code the caller is expecting, from the
    contract or the invoice. Supplied, a mismatch is refused: the country
    changing between the quote and the payment details is the shape the
    redirected-invoice fraud takes, and it is the one thing on this page that
    code can still see.

    `country` comes back whether the number is valid or not, so the caller can
    decide about reachability — a Brazilian IBAN is perfectly well-formed and
    no SEPA transfer will get there.

    Nothing raises: a validator that throws in a request path leaves the payer
    with no answer. Every refusal is `valid: False` and a `reason` in words.
    """
    if not isinstance(raw, str):
        return _report(False, None, "", None, f"an IBAN is text, not {type(raw).__name__}")
    if len(raw) > MAX_CHARACTERS:
        return _report(False, None, "", None, f"longer than {MAX_CHARACTERS} characters")

    # Upper case is the electronic format of ISO 13616; lower case carries no
    # other reading, so it is raised rather than refused.
    compact = "".join(c for c in raw if c not in SEPARATORS).upper()
    if not compact.isascii() or not compact.isalnum():
        return _report(False, None, compact, None, "not letters, digits and separators only")
    if not (MIN_LENGTH <= len(compact) <= MAX_LENGTH):
        return _report(False, None, compact, None,
                       f"{len(compact)} characters: an IBAN has {MIN_LENGTH} to {MAX_LENGTH}")

    country = compact[:2]
    if not country.isalpha() or not compact[2:4].isdigit():
        return _report(False, None, compact, None, "an IBAN starts with two letters then two digits")
    if expected_country and country != expected_country.upper():
        return _report(False, country, compact, None,
                       f"expected a {expected_country.upper()} account, this one is {country}")

    try:
        # The country modules are left out on purpose: the two libraries do not
        # have the same ones, and the national key is computed below instead.
        iban.validate(compact, check_country=False)
    except ValidationError:
        return _report(False, country, compact, None,
                       "ISO 13616 check digits, length or registry format do not match")

    key = national_key_ok(country, compact[4:])
    if key is False:
        return _report(False, country, compact, key,
                       "the RIB key inside the account number does not match")
    return _report(True, country, compact, key, None)


def _report(valid: bool, country: str | None, compact: str,
            national_key: bool | None, reason: str | None) -> dict:
    # The printed grouping in fours, which is how a payer reads a number back.
    printed = " ".join(compact[i:i + 4] for i in range(0, len(compact), 4))
    return {"valid": valid, "country": country, "compact": compact,
            "printed": printed, "national_key": national_key, "reason": reason}

JavaScript

snippets/check-bank-details-before-a-transfer/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Contrôler un IBAN avant qu'un virement parte, hors ligne et exactement.
 *
 * Niveau N0. ISO 13616 place deux chiffres de contrôle en troisième et
 * quatrième position de tout IBAN, calculés sur le reste du numéro par le
 * modulo 97 d'ISO 7064. Le code pays fixe la longueur — 27 caractères en
 * France, 22 en Allemagne, 16 en Belgique — et la forme de ce qui suit. Tout
 * cela est une table et une identité arithmétique : la réponse est exacte et
 * gratuite.
 *
 * La table est la part qu'on n'écrit pas. `ibantools` ici et `python-stdnum`
 * en Python portent tous deux le registre des IBAN, révisé à mesure que des
 * pays y entrent.
 *
 * Par-dessus ISO 13616, plusieurs pays posent une seconde clé à l'intérieur de
 * la partie nationale, et les deux bibliothèques ne couvrent pas les mêmes
 * pays — mesuré, pas supposé : `python-stdnum` n'a pas de module français, donc
 * `iban.is_valid` ne contrôle que la clé ISO et le format du registre, tandis
 * qu'`ibantools` refuse bien `FR0630006000011234567890188`, dont la clé RIB est
 * fausse. Deux bibliothèques qui répondent différemment sur le même numéro,
 * ce sont deux extraits qui répondraient différemment sur la même page : cet
 * extrait calcule donc lui-même la clé RIB, dans les deux langages. Ailleurs,
 * un numéro qui passe ici peut encore être refusé par la banque du
 * bénéficiaire.
 *
 * Mesuré, chaque chiffre sur son propre jeu : aucune des 20 700 fautes d'un
 * chiffre de cent IBAN français fabriqués ne passe, ni aucune de leurs 2 000
 * inversions de deux caractères voisins, ni aucune des 6 892 lettres sosies de
 * cinq cents d'entre eux. La clé ISO seule en laisse passer 32 sur ces 6 892 —
 * 0,46 % —, parce que le numéro de compte français est alphanumérique au
 * registre et qu'un « I » y est un caractère légal à la place d'un « 1 » ;
 * c'est la clé RIB qui ferme cela. L'extrait Python fabrique ses propres
 * numéros et mesure 31 sur 6 882, soit 0,45 %.
 *
 * Et rien de tout cela ne dit à qui appartient le compte. C'est le service de
 * vérification du bénéficiaire que tout prestataire de la zone euro doit
 * proposer depuis le 9 octobre 2025 qui répond à cette question-là, pas un
 * calcul plus long.
 */

import { validateIBAN, ValidationErrorsIBAN } from 'ibantools';

// What people paste around an IBAN: the printed grouping in spaces of every
// kind, and the hyphens a word processor inserts. Anything else left in the
// string is refused rather than dropped, because dropping it would turn a
// mistyped account into a different valid one.
export const SEPARATORS = '    -‐‑–';

// ISO 13616 caps an IBAN at 34 characters and no country is under 15.
// Sixty-four leaves room for every separator and refuses a pasted paragraph.
export const MIN_LENGTH = 15;
export const MAX_LENGTH = 34;
export const MAX_CHARACTERS = 64;

// The countries whose national part carries the RIB key. Monaco uses the
// French banking standard: its national part is twenty-three characters too
// and satisfies the same modulo 97.
export const RIB_COUNTRIES = ['FR', 'MC'];

// The letter-to-digit table of the French RIB key, from the French banking
// standard: A and J are 1, B, K and S are 2, and so on to I, R and Z at 9.
export const RIB_LETTERS = Object.fromEntries([
  ...[...'ABCDEFGHIJKLMNOPQRTUVWXYZ'].map((c, i) => [c, String((i % 9) + 1)]),
  ...[...'STUVWXYZ'].map((c, i) => [c, String(i + 2)]),
]);

/**
 * The country's own key inside the account number, or null when this snippet
 * has none for that country. France and Monaco: letters become digits, and the
 * whole twenty-three-character national part, key included, is a multiple of
 * 97.
 */
export function nationalKeyOk(country, bban) {
  if (!RIB_COUNTRIES.includes(country)) return null;
  const digits = [...bban].map((c) => RIB_LETTERS[c] ?? c).join('');
  return BigInt(digits) % 97n === 0n;
}

/**
 * Say whether `raw` carries a well-formed IBAN, and for which country.
 *
 * `expectedCountry` is the two-letter code the caller is expecting, from the
 * contract or the invoice. Supplied, a mismatch is refused: the country
 * changing between the quote and the payment details is the shape the
 * redirected-invoice fraud takes, and it is the one thing on this page that
 * code can still see.
 *
 * `country` comes back whether the number is valid or not, so the caller can
 * decide about reachability — a Brazilian IBAN is perfectly well-formed and no
 * SEPA transfer will get there.
 *
 * Nothing throws: a validator that throws in a request path leaves the payer
 * with no answer. Every refusal is `valid: false` and a `reason` in words.
 *
 * @param {string} raw
 * @param {{expectedCountry?: string}} [options]
 */
export function checkBankDetails(raw, { expectedCountry } = {}) {
  if (typeof raw !== 'string') {
    return report(false, null, '', null, `an IBAN is text, not ${kindOf(raw)}`);
  }
  if (raw.length > MAX_CHARACTERS) {
    return report(false, null, '', null, `longer than ${MAX_CHARACTERS} characters`);
  }

  // Upper case is the electronic format of ISO 13616; lower case carries no
  // other reading, so it is raised rather than refused.
  const compact = [...raw].filter((c) => !SEPARATORS.includes(c)).join('').toUpperCase();
  if (!/^[A-Z0-9]+$/.test(compact)) {
    return report(false, null, compact, null, 'not letters, digits and separators only');
  }
  if (compact.length < MIN_LENGTH || compact.length > MAX_LENGTH) {
    return report(false, null, compact, null,
      `${compact.length} characters: an IBAN has ${MIN_LENGTH} to ${MAX_LENGTH}`);
  }

  const country = compact.slice(0, 2);
  if (!/^[A-Z]{2}$/.test(country) || !/^[0-9]{2}$/.test(compact.slice(2, 4))) {
    return report(false, null, compact, null, 'an IBAN starts with two letters then two digits');
  }
  if (expectedCountry && country !== expectedCountry.toUpperCase()) {
    return report(false, country, compact, null,
      `expected a ${expectedCountry.toUpperCase()} account, this one is ${country}`);
  }

  // The library's own national checks are left out on purpose: the two
  // libraries do not have the same ones, and the national key is computed
  // below instead.
  const { errorCodes } = validateIBAN(compact);
  const national = ValidationErrorsIBAN.WrongAccountBankBranchChecksum;
  if (errorCodes.some((code) => code !== national)) {
    return report(false, country, compact, null,
      'ISO 13616 check digits, length or registry format do not match');
  }

  const key = nationalKeyOk(country, compact.slice(4));
  if (key === false) {
    return report(false, country, compact, key,
      'the RIB key inside the account number does not match');
  }
  return report(true, country, compact, key, null);
}

const kindOf = (value) => (value === null ? 'null' : typeof value);

function report(valid, country, compact, nationalKey, reason) {
  // The printed grouping in fours, which is how a payer reads a number back.
  const printed = (compact.match(/.{1,4}/g) ?? []).join(' ');
  return { valid, country, compact, printed, national_key: nationalKey, reason };
}

Essayer

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

Chaque sortie est celle de l’extrait, exécutée à la construction du site. Les numéros affichés sont fabriqués pour cette page : ils sont valides et n’appartiennent à personne.

  1. Un IBAN français collé d’une facture, groupé par quatre

    Donné

    FR76 3000 6000 0112 3456 7890 189

    Ce qu’il renvoie

    { "valid": true, "country": "FR", "compact": "FR7630006000011234567890189", "printed": "FR76 3000 6000 0112 3456 7890 189", "national_key": true, "reason": null }

    Ce qu’il en conclut

    IBAN bien formé — FR

    Les deux clés passent : celle d’ISO 13616, et la clé RIB française.

    Aucune de ces clés ne dit à qui appartient le compte.

  2. Le même numéro, un chiffre du compte mal saisi

    Donné

    FR76 3000 6000 0112 3456 7891 189

    Ce qu’il renvoie

    { "valid": false, "country": "FR", "compact": "FR7630006000011234567891189", "printed": "FR76 3000 6000 0112 3456 7891 189", "national_key": null, "reason": "ISO 13616 check digits, length or registry format do not match" }

    Ce qu’il en conclut

    Refusé

    ISO 13616 check digits, length or registry format do not match

  3. Le même numéro, une lettre glissée dans le compte

    Donné

    FR76 3000 6000 0112 3456 7A90 189

    Ce qu’il renvoie

    { "valid": false, "country": "FR", "compact": "FR7630006000011234567A90189", "printed": "FR76 3000 6000 0112 3456 7A90 189", "national_key": false, "reason": "the RIB key inside the account number does not match" }

    Ce qu’il en conclut

    Refusé

    the RIB key inside the account number does not match

  4. Un IBAN allemand, qui n’a pas de clé nationale calculée ici

    Donné

    DE89 3704 0044 0532 0130 00

    Ce qu’il renvoie

    { "valid": true, "country": "DE", "compact": "DE89370400440532013000", "printed": "DE89 3704 0044 0532 0130 00", "national_key": null, "reason": null }

    Ce qu’il en conclut

    IBAN bien formé — DE

    La clé d’ISO 13616 passe. Ce pays n’a pas de clé nationale calculée ici.

    Aucune de ces clés ne dit à qui appartient le compte.

  5. Un IBAN brésilien, parfaitement formé

    Donné

    BR18 0036 0305 0000 1000 9795 493C 1

    Ce qu’il renvoie

    { "valid": true, "country": "BR", "compact": "BR1800360305000010009795493C1", "printed": "BR18 0036 0305 0000 1000 9795 493C 1", "national_key": null, "reason": null }

    Ce qu’il en conclut

    IBAN bien formé — BR

    La clé d’ISO 13616 passe. Ce pays n’a pas de clé nationale calculée ici.

    Aucune de ces clés ne dit à qui appartient le compte.

    Là, ça casseLe numéro passe, et aucun virement SEPA n’arrivera au Brésil. La clé contrôle la forme, pas la joignabilité : c’est pour cela que le rapport rend le pays même quand il dit oui.

  6. Un autre compte du même établissement

    Donné

    FR76 3000 6000 0109 8765 4321 028

    Ce qu’il renvoie

    { "valid": true, "country": "FR", "compact": "FR7630006000010987654321028", "printed": "FR76 3000 6000 0109 8765 4321 028", "national_key": true, "reason": null }

    Ce qu’il en conclut

    IBAN bien formé — FR

    Les deux clés passent : celle d’ISO 13616, et la clé RIB française.

    Aucune de ces clés ne dit à qui appartient le compte.

    Là, ça casseMême pays, même code banque, même guichet, et les deux clés passent : c’est la forme que prend la facture au RIB changé. Rien dans le numéro ne dit lequel des deux comptes est celui de votre fournisseur — c’est la vérification du bénéficiaire, obligatoire en zone euro depuis le 9 octobre 2025, qui le dit.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : l’IBAN est contrôlé dans votre processus et n’est transmis à personne
  • Un IBAN est une donnée personnelle dès qu’il désigne un particulier : le contrôler localement ne le communique pas, l’envoyer à un service en ligne le communique
  • Ne dispense pas de la vérification du bénéficiaire que votre prestataire de paiement doit vous proposer : c’est un service du prestataire, pas un contrôle que vous exécutez

Point de rupture

Les deux clés disent qu’un numéro est bien formé, jamais à qui appartient le compte : FR76 3000 6000 0112 3456 7890 189 et FR76 3000 6000 0109 8765 4321 028 passent toutes deux, même banque, même guichet — la forme exacte d’une facture au RIB changé. Témoin : aucune des 20 700 fautes d’un chiffre ne passe.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus. Le jour où vous devez savoir que le compte est bien celui de votre fournisseur, la réponse est la vérification du bénéficiaire que tout prestataire de la zone euro doit proposer depuis le 9 octobre 2025, et elle se demande au prestataire, pas à un modèle.

Les quatre options

  1. RecommandéN0Clés de contrôle ISO 13616 et clé RIB, sur le registre des IBANGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Il n’y a rien à apprendre. Les chiffres de contrôle d’un IBAN sont une fonction du reste du numéro, publiée par ISO 13616, et la clé RIB en est une seconde ; un classifieur entraîné sur des IBAN étiquetés approcherait deux identités que deux modulos calculent exactement. Il ne saurait de surcroît pas ce que le registre des IBAN contient, alors que ce registre change à chaque pays qui rejoint la liste, et que c’est une table, pas une régularité.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Même argument, avec un service à tenir en plus. Et l’objection décisive tient à ce qu’on contrôle : un virement part une fois, et ce qu’on attend d’un contrôle avant émission, c’est qu’il donne toujours la même réponse et qu’il sache dire laquelle des deux clés a échoué. Un petit modèle spécialisé rend un score, pas une retenue qu’on puisse relire dans un journal d’audit.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Un modèle généraliste rend une phrase sur un calcul qu’il n’exécute pas, et il l’envoie chez un tiers : sur un chemin de paiement, c’est un coût par virement, une latence ajoutée avant la confirmation, et une coordonnée bancaire sortie de votre infrastructure pour une réponse que deux modulos donnent hors ligne. La seule question qu’il resterait à poser — ce compte est-il celui du bon bénéficiaire — a une réponse réglementée depuis le 9 octobre 2025, et c’est le prestataire de paiement qui la donne.

Pour aller plus loin

Source des chiffres

Métadonnées