Une usine de cent employés, dont vingt-cinq Saoudiens. Le taux de saoudisation est de 25 %, et la bande est Vert bas en 2026. Personne ne démissionne, personne n'est recruté, rien ne change dans l'établissement — et en 2027 la bande devient Rouge.
Ce n'est pas une erreur de calcul. C'est la conception même du programme. Nitaqat Développeur (نطاقات المطور) a remplacé l'ancien tableau fixe par une courbe, et les constantes de cette courbe montent d'une année sur l'autre. Un établissement posé sur la ligne aujourd'hui passe dessous l'an prochain simplement en ne bougeant pas.
Tout SIRH qui affiche le « taux de saoudisation » comme un chiffre unique du mois cache ce fait à celui qui le lit. Ce tutoriel construit l'alternative : un moteur qui calcule la bande avec la formule officielle, qui sait quelles têtes comptent réellement et lesquelles non, et qui vous dit en quelle année vous chutez et combien de personnes recruter avant.
Nous avons traité la connexion des SIRH à Qiwa — authentification des contrats, niveaux d'intégration, erreurs d'API courantes — dans le guide d'intégration Qiwa pour les SIRH. Cet article-là explique comment atteindre les données. Celui-ci explique quoi en faire une fois obtenues.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ et TypeScript 5+
vitestou un lanceur de tests équivalent- Une familiarité de base avec les discriminated unions et les fonctions mathématiques de JavaScript
- Une copie du guide procédural du programme Nitaqat Développeur publié par le ministère des Ressources humaines et du Développement social — précisément l'annexe 1, source de chaque constante de ce tutoriel
Ce que ce moteur fait, et ne fait pas. Ce code calcule les taux publiés pour une entité d'une activité et d'une taille données, avec la formule même qu'applique le ministère. Il ne lit pas le dossier de votre établissement : Qiwa seul connaît votre code d'activité enregistré, vos périodes de grâce, votre structure de filiales et les effectifs qu'il retiendra réellement. En cas de désaccord entre le moteur et Qiwa, c'est Qiwa qui fait foi. Le rôle du moteur est de calculer la règle et de révéler l'écart tôt, pas de remplacer la plateforme.
Ce que vous allez construire
Un module en six pièces :
- La table des constantes — l'annexe 1 en TypeScript typé
- Le calculateur de seuils — la courbe y = m × ln(x) + c, plus la classification des bandes
- Le calcul de l'écart — combien de Saoudiens il faut réellement, pas combien il semble en falloir
- L'effectif comptabilisable — du registre de paie aux chiffres que le programme reconnaît
- La prévision par année — la même lecture face aux constantes 2027 et 2028
- Le moteur d'alertes — l'avertissement avant la chute, pas après
Étape 1 : la formule, et pourquoi un tableau fixe échoue
L'ancienne version de Nitaqat demandait un pourcentage à chercher dans un tableau. Nitaqat Développeur le calcule :
y = m × ln(x) + c
- y le taux minimal de saoudisation pour cette bande
- m une constante de courbe, par activité et bande
- c une constante de nivellement, par activité, bande et année
- x l'effectif total de l'entité
- ln le logarithme naturel — le guide précise « القيمة اللوغاريثمية الطبيعية », donc
Math.loget nonMath.log10
Ce dernier point mérite qu'on s'y arrête : utiliser le logarithme décimal au lieu du naturel produit un nombre d'apparence plausible et un résultat entièrement faux, et aucun test ne vous préviendra si vous ne l'écrivez pas vous-même.
La conséquence pratique d'une courbe est que le taux requis bouge avec l'effectif. Dans la plupart des activités il monte à mesure que l'entité grandit. Dans le bâtiment et le nettoyage, la constante de courbe est négative : l'exigence s'allège quand l'établissement grandit. Aucun tableau fixe ne peut répondre à cela, et c'est pourquoi le moteur demande l'activité et pas seulement un effectif :
| Activité | Effectif total | Seuil Vert bas (2026) |
|---|---|---|
| Infrastructure informatique | 30 | 30,05 % |
| Entreprises de construction et de bâtiment | 30 | 12,91 % |
| Entreprises de construction et de bâtiment | 1000 | 11,61 % |
Le même effectif, et deux fois et demie l'obligation. Toute interface qui affiche un « taux de saoudisation requis » sans connaître l'activité affiche un nombre inventé.
Étape 2 : typer l'annexe 1
Commencez par les types. Les bandes sont ordonnées, et cet ordre fait partie de la logique plutôt que d'être un détail :
// nitaqat/types.ts
export const BAND_ORDER = ['lowGreen', 'midGreen', 'highGreen', 'platinum'] as const;
export type BandKey = (typeof BAND_ORDER)[number];
/** Red is not a threshold — it is where you are when you clear none of them. */
export type BandStatus = BandKey | 'red';
export const NITAQAT_YEARS = [2026, 2027, 2028] as const;
export type NitaqatYear = (typeof NITAQAT_YEARS)[number];
/**
* Annex 1 gives, per activity and band, one curve constant and one levelling
* constant per commitment year.
*/
export type BandConstants = {
m: number;
c: readonly [number, number, number];
};
export type Activity = {
id: string;
label: string;
bands: Record<BandKey, BandConstants>;
};Puis les constantes. L'annexe 1 porte 41 activités réparties sur 656 constantes ; trois activités suffisent pour ce tutoriel, le reste s'ajoutant de la même manière :
// nitaqat/annex1.ts
import type { Activity } from './types';
export const ACTIVITIES: Record<string, Activity> = {
manufacturing: {
id: 'manufacturing',
label: 'الصناعات',
bands: {
lowGreen: { m: 1.68, c: [15.08, 18.08, 21.08] },
midGreen: { m: 1.87, c: [21.87, 24.87, 27.87] },
highGreen: { m: 2.08, c: [23.97, 26.97, 29.97] },
platinum: { m: 2.08, c: [29.87, 32.87, 35.87] },
},
},
construction: {
id: 'construction',
label: 'مقاولات التشييد والبناء',
bands: {
lowGreen: { m: -0.37, c: [14.17, 16.17, 18.17] },
midGreen: { m: -0.37, c: [16.17, 18.17, 20.17] },
highGreen: { m: 0, c: [17.5, 19.5, 21.5] },
platinum: { m: 0, c: [22.5, 24.5, 26.5] },
},
},
itInfrastructure: {
id: 'itInfrastructure',
label: 'البنية التحتية لتقنية المعلومات',
bands: {
lowGreen: { m: 3.61, c: [17.77, 19.77, 21.77] },
midGreen: { m: 3.61, c: [24.64, 26.64, 28.64] },
highGreen: { m: 3.61, c: [40, 42, 44] },
platinum: { m: 3.61, c: [50, 52, 54] },
},
},
};Notez la constante négative du bâtiment.
m: -0.37n'est pas une coquille. Le bâtiment et le nettoyage sont les deux activités dont l'obligation s'allège avec la taille, et « corriger » ce signe casse le moteur en silence.
Étape 3 : seuils et classification des bandes
// nitaqat/thresholds.ts
import { BAND_ORDER, NITAQAT_YEARS } from './types';
import type { Activity, BandKey, BandStatus, NitaqatYear } from './types';
/** The curve applies from six workers up; below that a flat rule governs. */
export const CURVE_MIN_HEADCOUNT = 6;
export function bandThresholds(
activity: Activity,
totalWorkforce: number,
year: NitaqatYear = 2026,
): Record<BandKey, number> {
const yearIndex = Math.max(0, NITAQAT_YEARS.indexOf(year));
// ln(0) is -Infinity and ln of a fraction is negative, so floor the size.
const ln = Math.log(Math.max(totalWorkforce, 1));
const out = {} as Record<BandKey, number>;
for (const band of BAND_ORDER) {
const { m, c } = activity.bands[band];
// Clamped: the curve is an empirical fit, not an identity. A negative
// constant at a small headcount can produce a faithful negative percentage.
out[band] = Math.min(100, Math.max(0, m * ln + c[yearIndex]));
}
return out;
}
/** The highest band a rate actually clears. */
export function classifyBand(
rate: number,
thresholds: Record<BandKey, number>,
): BandStatus {
let status: BandStatus = 'red';
for (const band of BAND_ORDER) {
if (rate >= thresholds[band]) status = band;
}
return status;
}Le plancher (Math.max(totalWorkforce, 1)) n'est pas décoratif : un établissement à zéro employé produit Math.log(0) === -Infinity, tous les seuils deviennent -Infinity, et le moteur classe l'établissement vide en platine. C'est le genre de bug qui passe la revue et ressort dans un rapport au comité de direction.
Le bornage entre zéro et cent existe pour une raison voisine : la courbe est un ajustement statistique et non une identité mathématique, et une constante négative à faible effectif peut produire un pourcentage inférieur à zéro — fidèle arithmétiquement, dénué de sens en pratique.
Étape 4 : la règle des petits établissements
Une entité de cinq travailleurs ou moins n'est pas régie par la formule logarithmique — mais l'obligation ne disparaît pas. Le ministère l'énonce clairement : un établissement de cinq travailleurs ou moins doit ajouter exactement un employé saoudien.
La différence entre « Nitaqat ne s'applique pas » et « un Saoudien est requis » est celle entre un établissement conforme et un établissement qui découvre le problème à sa première demande de visa :
// nitaqat/small-entity.ts
import { CURVE_MIN_HEADCOUNT } from './thresholds';
export const SMALL_ENTITY_SAUDI_REQUIREMENT = 1;
export type SmallEntityCheck = {
applies: true;
met: boolean;
required: number;
} | null;
/** Null once the curve takes over — the caller should read the band instead. */
export function smallEntityCheck(total: number, saudis: number): SmallEntityCheck {
if (total === 0 || total >= CURVE_MIN_HEADCOUNT) return null;
return {
applies: true,
met: saudis >= SMALL_ENTITY_SAUDI_REQUIREMENT,
required: SMALL_ENTITY_SAUDI_REQUIREMENT,
};
}Quand vous ignorez dans quel sens vous tromper, choisissez celui qui surestime l'obligation. Dire à un client qu'il est conforme alors qu'il ne l'est pas est bien pire que l'inverse.
Étape 5 : l'écart — le dénominateur grandit avec vous
C'est ici que presque tout le monde se trompe, y compris les tableurs qui pilotent la saoudisation dans de grandes entreprises.
Avec 20 Saoudiens sur 100 et un objectif de 30 %, on calcule : 30 moins 20 égale 10 recrutements. La réponse est 15. Chaque recrutement saoudien augmente le numérateur et le dénominateur ensemble : après dix recrutements vous avez 30 Saoudiens sur 110, soit 27,27 % et non 30 %.
La relation correcte :
(saudis + x) / (total + x) >= target
therefore: x >= (target × total − saudis) / (1 − target)
// nitaqat/gap.ts
export type Gap = {
hiresNeeded: number;
nonSaudiReduction: number;
};
export function gapTo(targetPercent: number, saudis: number, total: number): Gap {
const t = targetPercent / 100;
if (t >= 1) throw new RangeError('a 100% target has no finite hiring solution');
const rate = total === 0 ? 0 : (saudis / total) * 100;
if (rate >= targetPercent) return { hiresNeeded: 0, nonSaudiReduction: 0 };
// Hiring lifts both terms: (saudis + x) / (total + x) >= t
const hiresNeeded = Math.max(0, Math.ceil((t * total - saudis) / (1 - t)));
// The other lever — shrink the denominator: saudis / (saudis + y) >= t
const nonSaudis = total - saudis;
const allowedNonSaudis = t === 0 ? Infinity : Math.floor(saudis / t) - saudis;
const nonSaudiReduction = Number.isFinite(allowedNonSaudis)
? Math.max(0, nonSaudis - Math.max(0, allowedNonSaudis))
: 0;
return { hiresNeeded, nonSaudiReduction };
}Affichez toujours les deux nombres. Les dirigeants décident autrement lorsqu'ils voient qu'atteindre la bande suivante coûte soit huit recrutements saoudiens, soit dix-huit départs ; n'en montrer qu'un seul transforme une décision en fait accompli.
Étape 6 : quelles têtes comptent réellement
C'est l'étape qui sépare une calculatrice d'un système de conformité.
Depuis le 15 avril 2026, un employé saoudien ne compte dans votre taux de saoudisation que si son contrat est authentifié électroniquement sur Qiwa. Un Saoudien qui travaille réellement chez vous, payé chaque mois et enregistré à la GOSI, peut malgré tout ne pas être compté parce que son contrat n'a jamais été authentifié.
Votre SIRH le compte ; Nitaqat non. L'écart entre ces deux nombres est ce qui fait mentir le tableau de bord :
// nitaqat/countable.ts
export type EmployeeRecord = {
id: string;
nationality: 'SA' | 'NON_SA';
/** Qiwa contract authentication state, mirrored from the platform. */
contractAuthenticated: boolean;
/** Whether GOSI shows an open contribution record for the period. */
gosiActive: boolean;
/** For the cases the programme weights differently than one head. */
weight?: number;
};
export type CountableWorkforce = {
/** Saudis that actually count toward the ratio. */
saudis: number;
/** Everyone on an open GOSI record, counted toward Saudization or not. */
total: number;
/** Saudis sitting in the denominator but contributing nothing to the numerator. */
uncountedSaudis: string[];
};
export function countableWorkforce(roster: EmployeeRecord[]): CountableWorkforce {
let saudis = 0;
let total = 0;
const uncountedSaudis: string[] = [];
for (const e of roster) {
// No open GOSI contribution record, no place in either term.
if (!e.gosiActive) continue;
const weight = e.weight ?? 1;
total += weight;
if (e.nationality !== 'SA') continue;
// Since 15 April 2026 a Saudi whose Qiwa contract is not authenticated
// still occupies the denominator but adds nothing to the numerator.
if (!e.contractAuthenticated) {
uncountedSaudis.push(e.id);
continue;
}
saudis += weight;
}
return { saudis, total, uncountedSaudis };
}Notez le traitement d'un Saoudien non authentifié : il reste au dénominateur et sort du numérateur. C'est la lecture prudente, et le bon sens de l'erreur. Mais rapprochez toujours total et saudis des effectifs que Qiwa affiche lui-même, et traitez tout écart comme un incident à investiguer plutôt qu'un nombre à arrondir. Voyez le moteur de cotisations GOSI pour le détail du rapprochement des enregistrements d'assurance.
Étape 7 : l'année où vous chutez
Revenons à l'usine du début. La constante de nivellement c monte de trois points par an sur la plupart des bandes de l'industrie, et lire le même effectif face aux constantes de l'année suivante révèle la falaise :
// nitaqat/forecast.ts
import { bandThresholds, classifyBand } from './thresholds';
import { NITAQAT_YEARS } from './types';
import type { Activity, BandStatus, NitaqatYear } from './types';
export type YearOutlook = {
year: NitaqatYear;
rate: number;
band: BandStatus;
lowGreenThreshold: number;
};
/** One unchanging workforce, read against each published year's constants. */
export function ratchetOutlook(
activity: Activity,
saudis: number,
total: number,
): YearOutlook[] {
const rate = total === 0 ? 0 : (saudis / total) * 100;
return NITAQAT_YEARS.map((year) => {
const thresholds = bandThresholds(activity, total, year);
return {
year,
rate: Number(rate.toFixed(2)),
band: classifyBand(rate, thresholds),
lowGreenThreshold: Number(thresholds.lowGreen.toFixed(2)),
};
});
}Et sa sortie pour notre usine — 25 Saoudiens sur 100 :
| Année | Taux | Seuil Vert bas | Bande |
|---|---|---|---|
| 2026 | 25,00 % | 22,82 % | Vert bas |
| 2027 | 25,00 % | 25,82 % | Rouge |
| 2028 | 25,00 % | 28,82 % | Rouge |
Et le chiffre qui rend cela actionnable : atteindre le seuil 2027 depuis la position actuelle demande deux recrutements. Attendre la chute en rouge, c'est voir les services de visas et de transfert de parrainage suspendus avant même d'avoir commencé à recruter. La différence entre une alerte précoce et une crise tardive tient ici à deux personnes.
Étape 8 : alerter avant le bord, pas au bord
Un système qui vous alerte quand vous chutez est un système en retard. Ce qu'il faut, c'est une marge :
// nitaqat/alerts.ts
import { bandThresholds, classifyBand } from './thresholds';
import { ratchetOutlook } from './forecast';
import { BAND_ORDER } from './types';
import type { Activity, BandKey, NitaqatYear } from './types';
export type Alert = {
level: 'info' | 'warn' | 'critical';
code: string;
message: string;
};
/** Percentage points between the current rate and the floor it sits on. */
export function bandBuffer(
rate: number,
thresholds: Record<BandKey, number>,
): number {
const current = classifyBand(rate, thresholds);
if (current === 'red') return 0;
return Number((rate - thresholds[current]).toFixed(2));
}
export function reviewCompliance(input: {
activity: Activity;
saudis: number;
total: number;
uncountedSaudis: string[];
year?: NitaqatYear;
bufferPoints?: number;
}): Alert[] {
const {
activity, saudis, total, uncountedSaudis,
year = 2026, bufferPoints = 2,
} = input;
const alerts: Alert[] = [];
const rate = total === 0 ? 0 : (saudis / total) * 100;
const thresholds = bandThresholds(activity, total, year);
const band = classifyBand(rate, thresholds);
const buffer = bandBuffer(rate, thresholds);
if (band === 'red') {
alerts.push({
level: 'critical',
code: 'BAND_RED',
message: `Red band: ${rate.toFixed(2)}% against a ${thresholds.lowGreen.toFixed(2)}% floor.`,
});
} else if (buffer < bufferPoints) {
alerts.push({
level: 'warn',
code: 'BAND_MARGIN_THIN',
message: `Only ${buffer} points above the ${band} floor — one departure may cost the band.`,
});
}
if (uncountedSaudis.length > 0) {
alerts.push({
level: 'warn',
code: 'CONTRACTS_UNAUTHENTICATED',
message: `${uncountedSaudis.length} Saudi employees have no authenticated Qiwa contract and are not counting.`,
});
}
const falls = ratchetOutlook(activity, saudis, total).find((o) => o.band === 'red');
if (falls && band !== 'red') {
alerts.push({
level: 'critical',
code: 'RATCHET_FALL',
message: `Unchanged, this workforce falls to red in ${falls.year}.`,
});
}
return alerts;
}La marge par défaut de deux points de pourcentage est un choix, pas une règle : dans un établissement de cent personnes, une démission saoudienne coûte environ un point entier. Réglez bufferPoints sur la taille de l'entité, pas au jugé.
Tester le moteur
Les tests ici ne sont pas décoratifs. Les quatre premiers nombres viennent de la courbe de l'industrie à cent employés, et toute dérive signifie que quelqu'un a changé de logarithme ou mal recopié une constante :
// nitaqat/engine.test.ts
import { describe, expect, it } from 'vitest';
import { ACTIVITIES } from './annex1';
import { bandThresholds, classifyBand } from './thresholds';
import { gapTo } from './gap';
import { ratchetOutlook } from './forecast';
import { countableWorkforce } from './countable';
describe('thresholds', () => {
it('computes the 2026 manufacturing curve at 100 employees', () => {
const t = bandThresholds(ACTIVITIES.manufacturing, 100, 2026);
expect(t.lowGreen).toBeCloseTo(22.82, 2);
expect(t.midGreen).toBeCloseTo(30.48, 2);
expect(t.highGreen).toBeCloseTo(33.55, 2);
expect(t.platinum).toBeCloseTo(39.45, 2);
});
it('eases with size where the curve constant is negative', () => {
const small = bandThresholds(ACTIVITIES.construction, 10, 2026).lowGreen;
const large = bandThresholds(ACTIVITIES.construction, 1000, 2026).lowGreen;
expect(small).toBeCloseTo(13.32, 2);
expect(large).toBeCloseTo(11.61, 2);
expect(large).toBeLessThan(small);
});
it('separates two activities that share a headcount', () => {
const it = bandThresholds(ACTIVITIES.itInfrastructure, 30, 2026).lowGreen;
const con = bandThresholds(ACTIVITIES.construction, 30, 2026).lowGreen;
expect(it).toBeCloseTo(30.05, 2);
expect(con).toBeCloseTo(12.91, 2);
});
it('does not call an empty establishment platinum', () => {
const t = bandThresholds(ACTIVITIES.manufacturing, 0, 2026);
expect(Number.isFinite(t.lowGreen)).toBe(true);
expect(classifyBand(0, t)).toBe('red');
});
});
describe('the gap', () => {
it('accounts for the denominator growing with each hire', () => {
expect(gapTo(30.48, 25, 100).hiresNeeded).toBe(8);
});
it('offers the reduction path as well', () => {
expect(gapTo(30.48, 25, 100).nonSaudiReduction).toBe(18);
});
it('returns zero once the target is already met', () => {
expect(gapTo(20, 25, 100)).toEqual({ hiresNeeded: 0, nonSaudiReduction: 0 });
});
});
describe('the ratchet', () => {
it('drops a static workforce a band without anyone moving', () => {
const outlook = ratchetOutlook(ACTIVITIES.manufacturing, 25, 100);
expect(outlook[0].band).toBe('lowGreen');
expect(outlook[1].band).toBe('red');
expect(outlook[1].lowGreenThreshold).toBeCloseTo(25.82, 2);
});
});
describe('countable workforce', () => {
it('keeps an unauthenticated Saudi in the denominator only', () => {
const w = countableWorkforce([
{ id: 'a', nationality: 'SA', contractAuthenticated: true, gosiActive: true },
{ id: 'b', nationality: 'SA', contractAuthenticated: false, gosiActive: true },
{ id: 'c', nationality: 'NON_SA', contractAuthenticated: true, gosiActive: true },
]);
expect(w.saudis).toBe(1);
expect(w.total).toBe(3);
expect(w.uncountedSaudis).toEqual(['b']);
});
});Vous pouvez tester les mêmes nombres à la main dans le calculateur Nitaqat avant de faire confiance à la sortie du moteur : si les deux divergent, l'un d'eux porte une constante mal recopiée.
Dépannage
Utiliser Math.log10 au lieu de Math.log. L'erreur la plus fréquente et la plus difficile à repérer, car le résultat reste un pourcentage plausible. Le guide impose le logarithme naturel.
Lire c dans la mauvaise colonne d'année. Les trois constantes par bande sont 2026, 2027 et 2028, dans cet ordre. Les mélanger donne une bande correcte pour une mauvaise année.
« Corriger » le signe négatif du bâtiment. -0.37 est intentionnel.
Compter les têtes de la paie au lieu des têtes comptabilisables. Un contrat non authentifié sur Qiwa ne compte pas, si fidèlement que le salaire soit versé.
Afficher le taux sans la bande. 22 % est un nombre dénué de sens : confortablement vert pour une entreprise de bâtiment, rouge pour une société d'infrastructure informatique.
Ignorer la structure des filiales. Le calcul se fait au niveau de l'entité telle que la définit le dossier d'établissement Qiwa, pas au niveau de la succursale qui se trouve dans votre base de données.
Prochaines étapes
Le moteur ci-dessus calcule la règle. Ce qui en fait un vrai système, c'est la source qui l'alimente : une synchronisation quotidienne depuis Qiwa pour les contrats authentifiés, depuis la GOSI pour les enregistrements ouverts, et depuis la paie pour les dates de fin de contrat. Dès lors, ratchetOutlook devient un rapport mensuel de direction plutôt qu'une fonction dans un fichier.
- Intégration Qiwa pour les SIRH — comment atteindre les données de contrats authentifiés
- Moteur de cotisations et rapprochement GOSI — la source des enregistrements ouverts
- Mudad et WPS pour les développeurs — l'autre bout du fichier de paie
- Calculateur Nitaqat — pour vérifier n'importe quel cas à la main
Conclusion
La saoudisation n'est pas un chiffre unique. C'est une position sur une courbe qui se déplace sous vos pieds. Le moteur que nous avons construit calcule les seuils à partir des constantes de l'annexe 1 plutôt que d'un tableau figé, calcule l'écart de recrutement avec un dénominateur qui grandit, sépare les têtes comptabilisables des têtes de la paie, et lit les années à venir avec leurs propres constantes.
Le vrai gain n'est pas la précision, c'est le temps. Deux recrutements aujourd'hui coûtent moins cher qu'une bande rouge dans quatre mois.
Vous suivez encore la saoudisation dans un tableur ? Si votre taux est calculé à la main une fois par mois, vous connaissez toujours votre position en retard — et vous découvrez les contrats non authentifiés à la première demande de visa rejetée. Nous connectons les SIRH à Qiwa, à la GOSI et à la paie pour que les indicateurs se calculent seuls et que les alertes arrivent avant le bord plutôt qu'après. Parlez-nous pour une revue de la position de votre établissement.