écrits/tutorial/2026/10
● Tutorial26 oct. 2026·24 min

Contrôle du numéro de TVA fournisseur saoudien en TypeScript

Construisez le contrôle là où il doit être, dans le circuit fournisseurs et non dans un tableur trimestriel : la règle de format BR-KSA-40 de la ZATCA à la création du fournisseur, une vérification d'immatriculation datée avant chaque déclaration, le QR code de chaque facture reçue décodé et rapproché des livres, et une porte de TVA déductible qui suspend une facture douteuse au lieu de la perdre, puisque l'article 49(8) laisse cinq ans pour la déduire.

Toutes les équipes financières en Arabie saoudite vérifient les numéros de TVA de leurs fournisseurs. Presque aucune ne le fait au bon moment. Le schéma habituel : une personne, un onglet ouvert sur le service de vérification de la ZATCA, et un échantillon de factures tiré une semaine avant l'échéance de la déclaration. À ce stade les factures sont comptabilisées, la TVA déductible est dans le grand livre, et un numéro erroné oblige à rouvrir des écritures au lieu de corriger un champ.

La règle à l'origine de ce coût est courte. L'article 49(7) du règlement d'application de la loi sur la TVA n'autorise la déduction de la TVA d'amont que si l'assujetti détient la preuve exigée par l'accord du Golfe, c'est-à-dire en pratique une facture fiscale valide. L'article 53(5) énumère ce que cette facture doit contenir, à commencer par le numéro d'identification fiscale du fournisseur et les nom et adresse des deux parties. Un numéro erroné, ou appartenant à une entité non immatriculée, emporte la déduction avec lui.

Ce tutoriel construit le contrôle à sa place : dans le circuit fournisseurs, sous la forme de quatre petits modules TypeScript avec leurs tests. Il ne remplace pas le service de vérification de la ZATCA. Rien ne le peut, car seule la ZATCA sait qui est immatriculé aujourd'hui. Il garantit que cette vérification a lieu pour chaque fournisseur, avant chaque déclaration, et que son résultat est conservé avec sa date.

Prérequis

  • Node.js 20 ou plus récent, et TypeScript en mode strict
  • tsx pour exécuter les tests (npm i -D tsx typescript @types/node)
  • Un système de comptabilité fournisseurs auquel vous pouvez vous brancher : un référentiel fournisseurs, une boîte de réception des factures et une étape de déclaration de TVA. Le moteur de déclaration de TVA saoudienne est l'étape de déclaration qu'alimente ce contrôle
  • Des notions de facturation électronique ZATCA. Si vous émettez des factures, le tutoriel d'intégration Phase 2 couvre l'autre face du QR code lu ici

Ce que vous allez construire

Quatre modules, chacun répondant à une seule question :

  1. vatNumber.ts : cette chaîne a-t-elle seulement la forme d'un numéro de TVA saoudien ? La règle est la BR-KSA-40 de la ZATCA, et rien de plus.
  2. supplier.ts : ce fournisseur est-il immatriculé, selon quelle source, et quand cela a-t-il été vérifié pour la dernière fois ? Le module produit aussi la liste des numéros à coller dans la vérification groupée de la ZATCA.
  3. qr.ts : que dit réellement le QR code d'une facture reçue ? Il décode la charge TLV définie dans les Security Features Implementation Standards de la ZATCA.
  4. gate.ts : compte tenu de tout cela, la TVA de cette facture entre-t-elle dans la déclaration, attend-elle, ou n'y entre-t-elle jamais ?

Le contrôle rend l'une de trois décisions : deduct, hold ou no_deduction. La différence entre les deux dernières est l'idée centrale de ce tutoriel, et l'étape 6 explique pourquoi elle compte.

Étape 1 : la seule règle publiée

La norme de mise en œuvre XML de la facture électronique de la ZATCA (version 1.2, mai 2023) fixe la règle de format du numéro du vendeur dans la BR-KSA-40 : il « must contain 15 digits. The first and the last digits are "3" », soit 15 chiffres dont le premier et le dernier valent 3. La BR-KSA-44 applique la même règle au numéro de l'acheteur lorsqu'il est présent et que la facture n'est pas une exportation.

C'est tout ce que la ZATCA publie sur le format. Vous trouverez des articles décrivant un chiffre de contrôle et des segments internes. La ZATCA ne publie aucun algorithme de contrôle, donc un code qui prétend en valider un devine. Le validateur ci-dessous applique la BR-KSA-40 et s'arrête là.

Il fait deux choses avant de tester la règle, parce que c'est là que les saisies réelles échouent. Il convertit les chiffres arabes orientaux, qui arrivent dès qu'on tape le numéro sur un clavier arabe. Et il nomme à part une saisie de dix chiffres : un numéro de dix chiffres dans le champ TVA est presque toujours le numéro de registre du commerce du fournisseur, et la personne qui l'a saisi doit l'apprendre en ces termes.

// src/vatNumber.ts
// BR-KSA-40 (XML Implementation Standard v1.2): the seller VAT number
// "must contain 15 digits. The first and the last digits are '3'".
// That is the whole published rule. Nothing here checks more than that.
 
export type VatNumberResult =
  | { ok: true; value: string }
  | { ok: false; reason: VatNumberError; value: string };
 
export type VatNumberError =
  | "EMPTY"
  | "NOT_DIGITS"
  | "LOOKS_LIKE_CR" // 10 digits: almost always a commercial registration number
  | "WRONG_LENGTH"
  | "FIRST_OR_LAST_NOT_3";
 
const ARABIC_INDIC = "٠١٢٣٤٥٦٧٨٩";
const EXTENDED_ARABIC_INDIC = "۰۱۲۳۴۵۶۷۸۹";
 
export function normalizeVatNumber(raw: string): string {
  let out = "";
  for (const ch of raw.trim()) {
    const a = ARABIC_INDIC.indexOf(ch);
    const e = EXTENDED_ARABIC_INDIC.indexOf(ch);
    if (a >= 0) out += String(a);
    else if (e >= 0) out += String(e);
    else if (ch === " " || ch === "-" || ch === " ") continue;
    else out += ch;
  }
  return out;
}
 
export function checkVatNumber(raw: string): VatNumberResult {
  const value = normalizeVatNumber(raw);
  if (value === "") return { ok: false, reason: "EMPTY", value };
  if (!/^[0-9]+$/.test(value)) return { ok: false, reason: "NOT_DIGITS", value };
  if (value.length === 10) return { ok: false, reason: "LOOKS_LIKE_CR", value };
  if (value.length !== 15) return { ok: false, reason: "WRONG_LENGTH", value };
  if (value[0] !== "3" || value[14] !== "3") {
    return { ok: false, reason: "FIRST_OR_LAST_NOT_3", value };
  }
  return { ok: true, value };
}

LOOKS_LIKE_CR est un code d'erreur distinct parce qu'il appelle un message distinct. « Longueur incorrecte » renvoie quelqu'un recompter les chiffres. « Ceci ressemble à un numéro de registre du commerce ; demandez au fournisseur le numéro de TVA à 15 chiffres figurant sur son certificat d'immatriculation » obtient le bon numéro dès la première réponse.

Vous pouvez essayer la même règle à la main avec le vérificateur gratuit de format de numéro de TVA. Il fonctionne dans le navigateur et n'envoie le numéro nulle part.

Étape 2 : le référentiel fournisseurs refuse les mauvais numéros

L'endroit le moins coûteux pour arrêter un mauvais numéro est le formulaire qui crée le fournisseur. Une fois le numéro enregistré, chaque facture de ce fournisseur en hérite, et chaque contrôle ultérieur s'y compare.

// src/supplier.ts
import { checkVatNumber, VatNumberError } from "./vatNumber";
 
// Only ZATCA's registration lookup can say a number is registered.
// The supplier record keeps what was checked, when, and how.
 
export type Registration =
  | { status: "unchecked" }
  | { status: "registered"; checkedOn: string; evidence: string }
  | { status: "not_registered"; checkedOn: string; evidence: string };
 
export interface Supplier {
  id: string;
  registeredName: string; // as shown by the ZATCA lookup
  vatNumber: string; // normalised, passed checkVatNumber
  registration: Registration;
}
 
export type CreateResult =
  | { ok: true; supplier: Supplier }
  | { ok: false; reason: VatNumberError };
 
// The gate at onboarding: a malformed number is never saved.
export function createSupplier(id: string, registeredName: string, rawVat: string): CreateResult {
  const vat = checkVatNumber(rawVat);
  if (!vat.ok) return { ok: false, reason: vat.reason };
  return {
    ok: true,
    supplier: { id, registeredName, vatNumber: vat.value, registration: { status: "unchecked" } },
  };
}
 
// Our policy, not a ZATCA rule: a registration check is fresh for the
// period being filed only if it was made on or after the period start.
export function needsLookup(s: Supplier, periodStart: string): boolean {
  return s.registration.status === "unchecked" || s.registration.checkedOn < periodStart;
}
 
// One column of numbers, to paste into the template ZATCA's lookup
// service offers for checking several establishments at once.
export function lookupBatch(suppliers: Supplier[], periodStart: string): string[] {
  const out = new Set<string>();
  for (const s of suppliers) if (needsLookup(s, periodStart)) out.add(s.vatNumber);
  return [...out].sort();
}
 
export interface LookupRow {
  vatNumber: string;
  registered: boolean;
}
 
export function applyLookup(
  suppliers: Supplier[],
  rows: LookupRow[],
  checkedOn: string,
  evidence: string,
): Supplier[] {
  const byNumber = new Map(rows.map((r) => [r.vatNumber, r.registered]));
  return suppliers.map((s) => {
    const registered = byNumber.get(s.vatNumber);
    if (registered === undefined) return s;
    return {
      ...s,
      registration: registered
        ? { status: "registered", checkedOn, evidence }
        : { status: "not_registered", checkedOn, evidence },
    };
  });
}

Trois choix de conception méritent d'être explicités :

  • createSupplier renvoie une erreur plutôt qu'un fournisseur. Le système de types oblige alors l'écran de création à traiter l'échec. Aucun chemin ne permet d'enregistrer un numéro mal formé « à corriger plus tard ».
  • registration est une preuve, pas un indicateur. Un booléen isRegistered ne dit rien à un auditeur. Un statut accompagné de checkedOn et evidence (le nom du fichier de résultat de la vérification, ou la référence d'une capture d'écran conservée) lui dit ce qui a été vérifié, quand et comment.
  • La fraîcheur est notre politique, pas une règle de la ZATCA. needsLookup ne considère une vérification comme valable que si elle a été faite au début de la période déclarée ou après. Une immatriculation peut être annulée, et une vérification de l'an dernier ne prouve rien pour ce trimestre. Si vos volumes sont faibles, vous pouvez assouplir ce point. Écrivez ce que vous choisissez.

Étape 3 : l'immatriculation est une vérification datée

Le service « Vérifier l'immatriculation à la TVA » de la ZATCA accepte trois types de recherche : le numéro de TVA, le numéro du certificat de TVA ou le numéro unifié du registre du commerce. Pour plusieurs établissements à la fois, la même page propose un modèle xlsx à télécharger, remplir et déposer.

lookupBatch produit la colonne de numéros pour ce modèle, en ne retenant que les fournisseurs non encore vérifiés sur la période. Nous ne reproduisons pas ici la mise en page du modèle : la ZATCA en est propriétaire et peut la modifier, alors reprenez le modèle actuel sur la page du service à chaque fois.

Au retour des résultats, applyLookup les enregistre sur chaque fournisseur avec la date et le nom du fichier de preuve. Un fournisseur dont le numéro ne figure pas dans les résultats conserve son état précédent. L'absence d'un lot ne prouve rien.

Pour une équipe qui reçoit les factures de quarante fournisseurs par mois, cela transforme un contrôle par échantillon en contrôle complet, et le passage avant déclaration devient un seul dépôt de fichier au lieu de quarante onglets.

Étape 4 : lire le QR code de ce que vous recevez

Chaque facture électronique saoudienne porte un QR code. Les Security Features Implementation Standards de la ZATCA (section 4) le définissent : une chaîne Base64 d'enregistrements Tag-Length-Value, où l'étiquette tient sur un octet, la longueur sur un octet, et la valeur correspond aux octets UTF-8 du champ. Le tableau de cette section fixe les étiquettes :

ÉtiquetteChampObligatoire depuis
1Nom du vendeur4 décembre 2021
2Numéro d'immatriculation TVA du vendeur4 décembre 2021
3Horodatage de la facture, ISO 86014 décembre 2021
4Total de la facture TVA comprise4 décembre 2021
5Total de la TVA4 décembre 2021
6Empreinte (hash) de la facture XML1er janvier 2023
7Signature ECDSA de l'empreinte1er janvier 2023
8Clé publique ECDSA1er janvier 2023
9Signature du cachet de la ZATCA (factures simplifiées et leurs notes)1er janvier 2023

Pour une facture fiscale standard, les Detailed Guidelines de la facturation électronique précisent que la chaîne du QR code est générée par la ZATCA au moment de la validation (clearance). C'est donc la meilleure source unique de ce que le vendeur a réellement soumis : si l'en-tête porte un numéro de TVA et le QR code un autre, celui du QR code est celui que la ZATCA a vu.

// src/qr.ts
// Reads the QR payload of a Saudi e-invoice (Security Features
// Implementation Standards, section 4): Base64 of TLV records, one byte
// for the tag, one byte for the length, then the value.
// Tags 1-5 are UTF-8 text. Tags 6-9 (hash, signature, public key, ZATCA
// stamp signature) are kept as raw bytes: this reader does not verify them.
 
export interface ZatcaQr {
  sellerName: string; // tag 1
  sellerVatNumber: string; // tag 2
  timestamp: string; // tag 3, ISO 8601
  totalWithVat: string; // tag 4
  vatTotal: string; // tag 5
  phase2Tags: number[]; // which of 6-9 are present
}
 
export class QrError extends Error {}
 
export function decodeZatcaQr(base64: string): ZatcaQr {
  const bytes = Buffer.from(base64.trim(), "base64");
  if (bytes.length === 0) throw new QrError("QR payload is empty or not Base64");
 
  const text = new Map<number, string>();
  const binary: number[] = [];
  let i = 0;
  while (i < bytes.length) {
    if (i + 2 > bytes.length) throw new QrError(`truncated header at byte ${i}`);
    const tag = bytes[i];
    const len = bytes[i + 1];
    const start = i + 2;
    const end = start + len;
    if (end > bytes.length) throw new QrError(`tag ${tag} runs past the end`);
    if (tag >= 1 && tag <= 5) {
      if (text.has(tag)) throw new QrError(`tag ${tag} appears twice`);
      text.set(tag, bytes.subarray(start, end).toString("utf8"));
    } else if (tag >= 6 && tag <= 9) {
      binary.push(tag);
    } else {
      throw new QrError(`unknown tag ${tag}`);
    }
    i = end;
  }
 
  for (const t of [1, 2, 3, 4, 5]) {
    if (!text.has(t)) throw new QrError(`mandatory tag ${t} is missing`);
  }
  const timestamp = text.get(3)!;
  if (Number.isNaN(Date.parse(timestamp))) {
    throw new QrError(`tag 3 is not a date: ${timestamp}`);
  }
 
  return {
    sellerName: text.get(1)!,
    sellerVatNumber: text.get(2)!,
    timestamp,
    totalWithVat: text.get(4)!,
    vatTotal: text.get(5)!,
    phase2Tags: binary,
  };
}
 
// "1150.00" -> 115000. Integer halalas, never floats.
export function toHalalas(amount: string): number {
  const m = /^(\d+)(?:\.(\d{1,2}))?$/.exec(amount.trim());
  if (!m) throw new QrError(`not an amount: ${amount}`);
  return Number(m[1]) * 100 + Number((m[2] ?? "").padEnd(2, "0"));
}

Le décodeur est strict à dessein. Un enregistrement tronqué, une étiquette texte en double ou une étiquette inconnue lève une QrError, que le contrôle transforme en hold, jamais en deduct. Les étiquettes 6 à 9 sont notées présentes ou absentes, mais pas vérifiées. Vérifier la signature exige la chaîne de certificats, et l'application TVA de la ZATCA le fait déjà quand on scanne le code. Ce lecteur vous donne les cinq champs texte, que vous pouvez rapprocher automatiquement des livres.

toHalalas convertit les montants en halalas entiers sans jamais passer par un nombre à virgule flottante. 1150.10 en flottant ne vaut pas exactement 1150.10, et un rapprochement fondé sur des flottants produit des écarts que personne ne sait expliquer.

Étape 5 : le contrôle

Le contrôle compare quatre sources qui devraient concorder : le numéro saisi depuis l'en-tête de la facture, le référentiel fournisseurs, la preuve d'immatriculation et le QR code. Tout désaccord devient un motif nommé.

// src/gate.ts
import { checkVatNumber } from "./vatNumber";
import { decodeZatcaQr, QrError, toHalalas } from "./qr";
import { Supplier, needsLookup } from "./supplier";
 
export interface InboundInvoice {
  supplierId: string;
  kind: "tax" | "simplified";
  printedSellerVat: string; // as typed from the invoice header
  supplyDate: string; // YYYY-MM-DD
  totalWithVat: number; // halalas, as booked
  vat: number; // halalas, as booked
  qr?: string; // Base64 payload, if one was scanned
}
 
export type Decision =
  | { action: "deduct"; notes: string[] }
  | { action: "hold"; reasons: string[] }
  | { action: "no_deduction"; reasons: string[] };
 
// IR Art. 49(8): no deduction "in any period falling after five years
// from the calendar year in which the supply took place". Our reading:
// the last eligible period must end on or before 31 December of year + 5.
export function lastDeductibleDay(supplyDate: string): string {
  const year = Number(supplyDate.slice(0, 4));
  return `${year + 5}-12-31`;
}
 
// Heuristic, ours: enough to catch a different company, not a legal test.
export function sameName(a: string, b: string): boolean {
  const norm = (s: string) =>
    s
      .normalize("NFKC")
      .replace(/[ً-ْـ]/g, "") // harakat and tatweel
      .replace(/[أإآ]/g, "ا")
      .replace(/ة/g, "ه")
      .replace(/ى/g, "ي")
      .toLowerCase()
      .replace(/[^\p{L}\p{N}]+/gu, " ")
      .trim();
  return norm(a) === norm(b);
}
 
export function assessInvoice(
  inv: InboundInvoice,
  supplier: Supplier,
  periodStart: string,
  periodEnd: string,
): Decision {
  if (periodEnd > lastDeductibleDay(inv.supplyDate)) {
    return { action: "no_deduction", reasons: ["OUT_OF_TIME_ART_49_8"] };
  }
  if (supplier.registration.status === "not_registered") {
    return { action: "no_deduction", reasons: ["SUPPLIER_NOT_REGISTERED"] };
  }
 
  const reasons: string[] = [];
  const notes: string[] = [];
 
  const printed = checkVatNumber(inv.printedSellerVat);
  if (!printed.ok) reasons.push(`PRINTED_VAT_${printed.reason}`);
  else if (printed.value !== supplier.vatNumber) reasons.push("PRINTED_VAT_NOT_SUPPLIER_VAT");
 
  if (needsLookup(supplier, periodStart)) reasons.push("REGISTRATION_NOT_CHECKED_THIS_PERIOD");
 
  if (!inv.qr) {
    reasons.push("NO_QR_SCANNED");
  } else {
    try {
      const qr = decodeZatcaQr(inv.qr);
      if (qr.sellerVatNumber !== supplier.vatNumber) reasons.push("QR_VAT_NOT_SUPPLIER_VAT");
      if (!sameName(qr.sellerName, supplier.registeredName)) reasons.push("QR_NAME_NOT_REGISTERED_NAME");
      if (toHalalas(qr.totalWithVat) !== inv.totalWithVat) reasons.push("QR_TOTAL_NOT_BOOKED_TOTAL");
      if (toHalalas(qr.vatTotal) !== inv.vat) reasons.push("QR_VAT_NOT_BOOKED_VAT");
      if (qr.phase2Tags.length === 0) notes.push("QR_HAS_NO_PHASE2_TAGS");
    } catch (e) {
      if (!(e instanceof QrError)) throw e;
      reasons.push(`QR_UNREADABLE: ${e.message}`);
    }
  }
 
  if (inv.kind === "simplified") notes.push("SIMPLIFIED_INVOICE_ART_49_7_A");
 
  return reasons.length > 0 ? { action: "hold", reasons } : { action: "deduct", notes };
}

Comment il décide :

  • no_deduction n'intervient que dans deux cas. Le fournisseur a été vérifié et n'est pas immatriculé, donc aucune facture fiscale valide n'existe. Ou la période dépasse le délai de l'article 49(8). Aucun des deux ne se corrige en rectifiant un champ.
  • hold couvre tout ce qui peut être une faute de saisie, un défaut de modèle ou une vérification périmée : un numéro d'en-tête qui échoue à la BR-KSA-40 ou ne correspond pas au fournisseur, un QR code dont le numéro du vendeur, le total ou la TVA diffèrent de ce qui a été comptabilisé, aucun QR code scanné, ou aucune vérification d'immatriculation sur la période.
  • deduct signifie que les quatre sources concordent. La décision peut tout de même porter des notes. QR_HAS_NO_PHASE2_TAGS signale un QR code limité aux cinq champs de la phase 1. SIMPLIFIED_INVOICE_ART_49_7_A signale une facture simplifiée, que l'article 49(7)(a) admet comme preuve alternative lorsqu'elle a été correctement émise. L'article 53(8) énumère ce qu'elle doit contenir, dont le nom, l'adresse et le numéro de TVA du fournisseur.

La comparaison des noms est volontairement sommaire. Elle supprime les voyelles brèves et le tatweel, unifie les formes de l'alif, le ta marbuta et l'alif maqsura, et retire la ponctuation. Cela suffit à distinguer « شركه المورد للتجاره » et « شركة المورد للتجارة » d'une autre société. Ce n'est pas un critère juridique : une discordance suspend la facture pour qu'une personne l'examine, elle ne la rejette jamais.

Un contrôle est laissé hors du code à dessein. L'article 53(5)(e) exige que la facture porte aussi votre nom et votre adresse, et le QR code n'encode pas l'acheteur. Ce contrôle relève de la saisie de la facture, face à votre propre certificat d'immatriculation. Ajoutez-le là, pas ici.

Étape 6 : suspendu n'est pas perdu

La raison de séparer hold de no_deduction est l'article 49(8) : l'assujetti peut déduire la TVA d'amont au titre d'une période postérieure à celle qui contient la date de livraison, s'il dispose du droit à déduction, mais pas « au titre d'une période postérieure de plus de cinq ans à l'année civile de la livraison » (notre traduction de l'arabe).

Notre lecture, que le code rend explicite dans lastDeductibleDay : la dernière période pouvant porter la déduction doit se terminer au plus tard le 31 décembre de l'année de livraison plus cinq. Une livraison d'octobre 2026 peut encore être déduite dans une déclaration couvrant une période qui se termine le 31 décembre 2031.

L'effet pratique est important. Une équipe qui déduit chaque facture à réception, puis découvre un numéro erroné lors d'un contrôle, s'expose à un redressement. Une équipe qui suspend une facture douteuse, demande au fournisseur une facture rectifiée et la déduit deux trimestres plus tard ne perd que le décalage. Le contrôle fait du second comportement le comportement par défaut.

Quand une facture suspendue est levée, elle entre dans la déclaration de la période au cours de laquelle elle est levée. Le moteur de déclaration la place dans la case des achats au taux normal de cette période, sans rouvrir la période d'origine.

Étape 7 : tester votre implémentation

// tests/gate.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkVatNumber } from "../src/vatNumber";
import { decodeZatcaQr, QrError, toHalalas } from "../src/qr";
import { createSupplier, lookupBatch, applyLookup, Supplier } from "../src/supplier";
import { assessInvoice, lastDeductibleDay, InboundInvoice } from "../src/gate";
 
function qr(fields: [number, string][]): string {
  const parts = fields.map(([tag, value]) => {
    const v = Buffer.from(value, "utf8");
    return Buffer.concat([Buffer.from([tag, v.length]), v]);
  });
  return Buffer.concat(parts).toString("base64");
}
 
const VAT = "310122393500003";
const NAME = "شركة المورد للتجارة";
const goodQr = qr([
  [1, NAME],
  [2, VAT],
  [3, "2026-10-12T09:30:00Z"],
  [4, "1150.00"],
  [5, "150.00"],
]);
 
function supplier(): Supplier {
  const r = createSupplier("S1", NAME, VAT);
  if (!r.ok) throw new Error(r.reason);
  return applyLookup([r.supplier], [{ vatNumber: VAT, registered: true }], "2026-10-02", "lookup-2026-10-02.xlsx")[0];
}
 
const invoice: InboundInvoice = {
  supplierId: "S1",
  kind: "tax",
  printedSellerVat: VAT,
  supplyDate: "2026-10-12",
  totalWithVat: 115000,
  vat: 15000,
  qr: goodQr,
};
 
test("BR-KSA-40: 15 digits, first and last are 3", () => {
  assert.equal(checkVatNumber("310122393500003").ok, true);
  assert.equal(checkVatNumber("٣١٠١٢٢٣٩٣٥٠٠٠٠٣").ok, true);
  assert.equal(checkVatNumber("310 1223 9350 0003").ok, true);
  assert.deepEqual(checkVatNumber("1010123456"), { ok: false, reason: "LOOKS_LIKE_CR", value: "1010123456" });
  assert.equal((checkVatNumber("31012239350000") as { reason: string }).reason, "WRONG_LENGTH");
  assert.equal((checkVatNumber("310122393500001") as { reason: string }).reason, "FIRST_OR_LAST_NOT_3");
  assert.equal((checkVatNumber("31012239350000O") as { reason: string }).reason, "NOT_DIGITS");
});
 
test("a malformed number is never saved to the supplier master", () => {
  assert.deepEqual(createSupplier("S2", "x", "7001234567"), { ok: false, reason: "LOOKS_LIKE_CR" });
});
 
test("QR: reads tags 1-5 and notes phase 2 tags", () => {
  const withHash = qr([[1, NAME], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"], [6, "x".repeat(44)]]);
  const d = decodeZatcaQr(withHash);
  assert.equal(d.sellerName, NAME);
  assert.equal(d.sellerVatNumber, VAT);
  assert.deepEqual(d.phase2Tags, [6]);
});
 
test("QR: missing tag, truncation and bad Base64 are errors", () => {
  assert.throws(() => decodeZatcaQr(qr([[1, NAME], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"]])), QrError);
  const cut = Buffer.from(goodQr, "base64").subarray(0, 20).toString("base64");
  assert.throws(() => decodeZatcaQr(cut), QrError);
  assert.throws(() => decodeZatcaQr(""), QrError);
});
 
test("amounts are compared in halalas", () => {
  assert.equal(toHalalas("1150.00"), 115000);
  assert.equal(toHalalas("1150.5"), 115050);
  assert.equal(toHalalas("1150"), 115000);
  assert.throws(() => toHalalas("1,150.00"), QrError);
});
 
test("lookup batch lists only numbers not checked this period", () => {
  const s = supplier();
  assert.deepEqual(lookupBatch([s], "2026-10-01"), []);
  assert.deepEqual(lookupBatch([s], "2027-01-01"), [VAT]);
});
 
test("a clean invoice is deducted", () => {
  const d = assessInvoice(invoice, supplier(), "2026-10-01", "2026-12-31");
  assert.equal(d.action, "deduct");
});
 
test("QR seller VAT that differs from the supplier is held", () => {
  const other = qr([[1, NAME], [2, "300000000000003"], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"]]);
  const d = assessInvoice({ ...invoice, qr: other }, supplier(), "2026-10-01", "2026-12-31");
  assert.deepEqual(d, { action: "hold", reasons: ["QR_VAT_NOT_SUPPLIER_VAT"] });
});
 
test("booked VAT that differs from the QR is held", () => {
  const d = assessInvoice({ ...invoice, vat: 15100 }, supplier(), "2026-10-01", "2026-12-31");
  assert.deepEqual(d, { action: "hold", reasons: ["QR_VAT_NOT_BOOKED_VAT"] });
});
 
test("no QR, stale lookup and a CR number in the header all hold", () => {
  const d = assessInvoice({ ...invoice, qr: undefined, printedSellerVat: "1010123456" }, supplier(), "2027-01-01", "2027-03-31");
  assert.equal(d.action, "hold");
  assert.deepEqual((d as { reasons: string[] }).reasons, [
    "PRINTED_VAT_LOOKS_LIKE_CR",
    "REGISTRATION_NOT_CHECKED_THIS_PERIOD",
    "NO_QR_SCANNED",
  ]);
});
 
test("names are compared after Arabic normalisation", () => {
  const variant = qr([[1, "شركه المورد  للتجاره"], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"]]);
  assert.equal(assessInvoice({ ...invoice, qr: variant }, supplier(), "2026-10-01", "2026-12-31").action, "deduct");
});
 
test("an unregistered supplier gives no right to deduct", () => {
  const s = applyLookup([supplier()], [{ vatNumber: VAT, registered: false }], "2026-11-02", "lookup-2026-11-02.xlsx")[0];
  assert.deepEqual(assessInvoice(invoice, s, "2026-10-01", "2026-12-31"), {
    action: "no_deduction",
    reasons: ["SUPPLIER_NOT_REGISTERED"],
  });
});
 
test("Art. 49(8): a held invoice can still be deducted until year + 5", () => {
  assert.equal(lastDeductibleDay("2026-10-12"), "2031-12-31");
  assert.equal(assessInvoice(invoice, supplier(), "2026-10-01", "2031-12-31").action, "deduct");
  assert.deepEqual(assessInvoice(invoice, supplier(), "2032-01-01", "2032-03-31"), {
    action: "no_deduction",
    reasons: ["OUT_OF_TIME_ART_49_8"],
  });
});

Lancez-les avec :

npx tsx --test tests/*.test.ts

Les 13 tests passent. Chaque règle essentielle a aussi été vérifiée dans l'autre sens : supprimer le contrôle du dernier chiffre, le délai de l'article 49(8), le rapprochement de la TVA, la détection du numéro de registre à dix chiffres ou la normalisation du ta marbuta fait échouer au moins un test. Une suite de tests qui passe encore après la suppression d'une règle ne teste pas cette règle.

Les numéros des tests sont illustratifs. 310122393500003 a la bonne forme et a été inventé pour les tests ; ne le lisez pas comme l'immatriculation de qui que ce soit. Pour tester sur des données réelles, utilisez un jeu de vos propres fournisseurs, avec des numéros vérifiés par votre équipe.

Où ce contrôle s'insère dans votre système

MomentAppelCe qu'il empêche
Un acheteur crée un fournisseurcreateSupplierUn numéro de registre, un numéro à 14 chiffres ou une faute de frappe dans le référentiel
Avant chaque déclaration de TVAlookupBatch, puis le dépôt ZATCA, puis applyLookupDéduire sur une immatriculation annulée depuis la dernière vérification
Une facture arrivedecodeZatcaQr, puis assessInvoiceComptabiliser un total ou une TVA que le vendeur n'a pas soumis
Préparation de la déclarationseules les décisions deduct atteignent les cases de TVA déductibleUne facture douteuse dans la déclaration au lieu de la liste des suspendues

Rien de tout cela ne demande un nouveau système. C'est une couche au-dessus du logiciel comptable que vous utilisez déjà, le type de travail d'intégration que cette série de tutoriels existe pour montrer.

Dépannage

Le QR code se décode, mais le nom du vendeur ressemble à des caractères aléatoires. La charge a été lue en Latin-1 en amont, souvent par une application de scan ou une bibliothèque PDF. Prenez la chaîne Base64 brute fournie par le scanner et passez-la directement à decodeZatcaQr. Les étiquettes 1 à 5 sont en UTF-8 selon la norme.

tag 6 runs past the end ou unknown tag. La charge ne suit pas la structure étiquette sur un octet, longueur sur un octet, soit parce qu'elle a été tronquée à l'impression du code, soit parce que le générateur s'écarte de la norme. Le décodeur la rejette et le contrôle suspend la facture. Demandez au fournisseur de vérifier son QR code avec l'application TVA de la ZATCA. N'assouplissez pas l'analyseur pour l'accepter.

Le numéro de TVA du QR code diffère de l'en-tête d'un chiffre. C'est exactement le cas pour lequel ce contrôle existe. Le QR code d'une facture fiscale validée provient de la ZATCA, c'est donc probablement l'en-tête qui est faux. Demandez une facture rectifiée avant de déduire.

Le fournisseur est un groupe TVA. La BR-KSA-39 autorise le numéro d'immatriculation du groupe sur la facture. Enregistrez le numéro du groupe sur la fiche de chaque membre. Le nom immatriculé renvoyé par la vérification peut alors être celui du membre représentant du groupe plutôt que celui de l'entité avec laquelle vous traitez, et la comparaison des noms suspendra ces factures jusqu'à ce que quelqu'un les confirme une fois.

Le lot de vérification est vide alors que vous attendiez des numéros. Tous les fournisseurs ont été vérifiés au début de la période ou après. Utilisez un periodStart plus tardif pour voir qui devra être vérifié à la période suivante.

Prochaines étapes

Conclusion

La règle de format tient en une ligne : 15 chiffres, le premier et le dernier valent 3. Tout le travail est dans son placement. À la création du fournisseur, elle garde les mauvais numéros hors du référentiel. Avant chaque déclaration, une vérification datée prouve l'immatriculation pour la période. Sur chaque facture reçue, le QR code est rapproché des livres. Et le contrôle suspend ce qu'il ne peut confirmer au lieu de le déduire, parce que l'article 49(8) laisse cinq ans pour déduire une facture rectifiée et qu'un redressement n'en laisse aucun.

Commencez par le plus simple : vérifiez dès maintenant le format du numéro de TVA d'un fournisseur, gratuitement et dans votre navigateur, puis complétez avec la vérification de la ZATCA elle-même.

Si votre équipe vérifie encore les numéros fournisseurs un par un, après comptabilisation, le problème tient à l'emplacement du contrôle, pas au soin des personnes. Parlez-nous du branchement de ce contrôle sur votre circuit fournisseurs. Nous travaillons comme une couche d'intégration au-dessus du système que vous utilisez déjà, et nous vous dirons franchement si votre configuration actuelle le couvre déjà.