écrits/tutorial/2026/08
Tutorial28 août 2026·24 min

Moteur de calcul de la redevance des permis de travail saoudiens en TypeScript

Construire en TypeScript le module de la redevance des permis de travail saoudiens (المقابل المالي) : les deux paliers et la moyenne GOSI sur 26 semaines qui tranche entre eux, le mois de 30 jours réellement facturé, le taux journalier publié qui ne se réconcilie pas avec son propre montant annuel, et la falaise du 21 janvier 2027 qui donne deux prix à un seul renouvellement.

La plupart des SIRH saoudiens modélisent la redevance des permis de travail — المقابل المالي — comme une constante. Une valeur dans un fichier de configuration, multipliée par l'effectif, affichée dans une ligne budgétaire. C'est la simplification la plus coûteuse de toute la couche paie, et elle casse à trois endroits distincts en même temps.

Elle casse parce que le taux n'est pas un nombre mais deux, et que la règle qui choisit entre eux lit un effectif que vous ne détenez pas aujourd'hui. Elle casse parce que le ministère facture sur un mois de 30 jours, donc l'année facturée compte 360 jours et votre bibliothèque de dates l'ignore. Et à partir du 21 janvier 2027 elle casse pour toutes les micro-entreprises du pays, parce qu'un permis renouvelé avant cette date et expirant après n'est ni exonéré ni facturé — il est les deux, de part et d'autre d'une seule ligne.

Ce tutoriel construit le moteur qui traite correctement les trois, en TypeScript, avec les tests qui le prouvent. Les règles proviennent du document de règles de gestion du ministère des Ressources humaines et du Développement social, pris en application de la décision ministérielle 197 du 1438-03-23H, de la décision du Conseil des ministres 325 du 1442-06-13H pour l'échelonnement, et de l'avis de Qiwa sur la fin de l'exonération des micro-entreprises.

Prérequis

  • Node.js 22+ (les exemples utilisent le lanceur de tests intégré et le strip-types natif)
  • TypeScript 5.6+
  • Une aisance avec l'arithmétique des dates et le traitement des montants en entiers
  • Aucun identifiant d'API Qiwa — tout ici est la règle publiée, calculée localement

Ce que vous allez construire

Un module qui prend la période d'un permis de travail, les données d'effectif d'un établissement et un historique GOSI, et renvoie une facture chiffrée : segment par segment, avec le palier et le taux appliqués à chacun, le nombre de jours facturables, et un total qui se réconcilie au halala près avec le montant annuel publié par le ministère lui-même.

En chemin, il refusera de répondre à deux questions, délibérément, et ces refus comptent autant que l'arithmétique.

Étape 1 : deux paliers, et le nombre qui tranche a six mois d'âge

La redevance a deux colonnes. Par travailleur étranger, par mois, depuis le 1er janvier 2020 :

ConditionMensuelAnnuel
Les travailleurs étrangers n'excèdent pas l'effectif saoudien700 SAR8 400 SAR
Les travailleurs étrangers excèdent l'effectif saoudien800 SAR9 600 SAR

Indépendamment du palier, le permis lui-même coûte 100 SAR par travailleur étranger et par an.

Voici maintenant ce que presque toutes les implémentations ratent. « L'effectif saoudien » n'est pas le nombre de Saoudiens sur votre paie aujourd'hui. Les règles de gestion le définissent ainsi :

«يتم احتساب عدد الوحدات المستحقة بناء على بيانات عدد العمالة الوافدة ومتوسط عدد السعوديين المدفوع عنهم التأمينات خلال 26 أسبوع على مستوى الرقم الموحد»

La comparaison se fait avec la moyenne du nombre de Saoudiens cotisant à la GOSI sur 26 semaines, au niveau du numéro unifié de l'établissement — ni par succursale, ni par registre de commerce — alimentée chaque semaine par la GOSI.

Deux conséquences en découlent directement, et toutes deux sont commerciales, pas techniques :

  1. Recruter un Saoudien aujourd'hui ne baisse pas votre taux aujourd'hui. Il entre dans une moyenne sur 26 semaines, donc il déplace la base d'environ un vingt-sixième par semaine. Un changement de palier acheté par le recrutement arrive environ six mois plus tard. Quiconque vous vend une réduction immédiate vous vend autre chose.
  2. La sortie est aussi lente, et c'est la seule bonne nouvelle ici. Une démission ne fait pas non plus bondir votre taux la semaine suivante.

Si cette fenêtre de moyenne vous semble familière, c'est celle sur laquelle tourne la bande de saoudisation — nous en avons traité les conséquences dans pourquoi votre bande Nitaqat est passée au rouge.

Une poignée de travailleurs comptent chacun pour un Saoudien dans cette comparaison. La liste est courte et précise :

export type SpecialCategory =
  | 'disabledFullTime'      // عامل معاق بدوام كامل
  | 'prisonerFullTime'      // مسجون بدوام كامل
  | 'remoteFullTime'        // عامل عن بعد بدوام كامل
  | 'student'               // الطالب
  | 'saudiOwnerFullTime';   // المالك السعودي المتفرغ
 
export const AVERAGE_WEEKS = 26;
 
export function saudiEquivalents(
  weeklyGosiSaudis: number[],
  special: SpecialCategory[] = [],
) {
  const window = weeklyGosiSaudis.slice(-AVERAGE_WEEKS);
  if (window.length === 0) throw new Error('no GOSI weeks supplied');
 
  const average = window.reduce((a, b) => a + b, 0) / window.length;
  return {
    weeksUsed: window.length,
    // Signaler une fenêtre incomplète plutôt que moyenner en silence sur 9 semaines.
    complete: window.length === AVERAGE_WEEKS,
    average,
    basis: average + special.length,
  };
}
 
export type Tier = 'notExceeding' | 'exceeding';
 
export function tierFor(expats: number, saudiBasis: number): Tier {
  return expats > saudiBasis ? 'exceeding' : 'notExceeding';
}

Ne transposez aucune pondération depuis Nitaqat. Un travailleur handicapé compte pour un Saoudien ici. Le programme de saoudisation pondère le même travailleur autrement, parce que c'est une autre règle au service d'un autre objectif. Partager une constante entre les deux modules est un bug qui attend un audit.

Notez aussi que complete est renvoyé plutôt que levé en exception. Un établissement récent a réellement moins de 26 semaines d'historique ; le moteur doit le dire et laisser l'appelant décider, pas inventer une base.

Étape 2 : le mois fait 30 jours, et le taux journalier publié ne tombe pas juste

Les règles de gestion sont explicites sur la base de facturation :

«سيتم حساب المقابل المالي على أساس يومي على أن يكون عدد أيام الشهر 30 يوم»

La redevance se calcule au jour, sur un mois de 30 jours. Une année facturée compte donc 360 jours. Février n'est pas court. Le 31 n'existe pas. Si vous appelez differenceInDays de date-fns ici, chaque facture produite sera fausse de quelques jours par an, dans un sens qui s'accumule.

Ça, c'est la partie connue. Voici celle qui ne l'est pas : le tableau de la décision publie trois valeurs par palier — annuelle, mensuelle et journalière — et elles ne concordent pas entre elles. La valeur journalière publiée du palier bas est 23,3. Multipliez-la par l'année de 360 jours du ministère lui-même :

23,3 × 360 = 8 388     alors que la colonne annuelle indique 8 400
26,6 × 360 = 9 576     alors que la colonne annuelle indique 9 600

Les valeurs journalières publiées sont tronquées à une décimale, pas arrondies — 800 ÷ 30 vaut 26,67 et le tableau imprime 26,6. Ce sont des valeurs d'affichage. Le taux qui tombe réellement juste est monthly / 30, et le moteur doit déclarer lequel il utilise :

export const DAYS_PER_MONTH = 30;
 
/**
 * Base 30/360. L'année fait 360 jours et février n'est pas court — la décision
 * fixe le mois à 30 jours, donc le calendrier n'est pas consulté.
 */
export function days360(from: string, to: string): number {
  const a = parse(from);
  const b = parse(to);
  const d1 = Math.min(a.d, 30);
  const d2 = a.d >= 30 && b.d === 31 ? 30 : Math.min(b.d, 31);
  return Math.max(0, (b.y - a.y) * 360 + (b.m - a.m) * 30 + (Math.min(d2, 30) - d1));
}
 
/**
 * Le taux sur lequel la facture se réconcilie. La valeur journalière publiée
 * est une valeur d'affichage tronquée : 23,3 × 360 fait 8 388, pas les 8 400
 * imprimés dans la colonne voisine. Facturez sur mensuel / 30.
 */
export function dailyRateHalalas(rates: Rates): number {
  return (rates.monthly * 100) / DAYS_PER_MONTH;
}

Travaillez en halalas — centièmes entiers — et arrondissez exactement une fois, à la fin. 700 / 30 n'est pas représentable en virgule flottante binaire ; accumulez-le sur 360 jours et la dérive par travailleur devient visible sur un établissement de deux cents permis.

Le barème complet mérite d'être encodé plutôt que de figer le taux du jour, parce que les arriérés existent et qu'une période de permis peut chevaucher un palier :

export type Rates = { annual: number; monthly: number; publishedDaily: number };
export type ScheduleStep = { from: string; notExceeding: Rates; exceeding: Rates };
 
export const LEVY_SCHEDULE: ScheduleStep[] = [
  { from: '2018-01-01',
    notExceeding: { annual: 3600, monthly: 300, publishedDaily: 10 },
    exceeding:    { annual: 4800, monthly: 400, publishedDaily: 13.3 } },
  { from: '2019-01-01',
    notExceeding: { annual: 6000, monthly: 500, publishedDaily: 16.6 },
    exceeding:    { annual: 7200, monthly: 600, publishedDaily: 20 } },
  { from: '2020-01-01',
    notExceeding: { annual: 8400, monthly: 700, publishedDaily: 23.3 },
    exceeding:    { annual: 9600, monthly: 800, publishedDaily: 26.6 } },
];
 
export function ratesOn(date: string, tier: Tier): Rates {
  const step = [...LEVY_SCHEDULE].reverse().find((s) => s.from <= date);
  if (!step) throw new Error(`no levy step in force on ${date}`);
  return step[tier];
}

Conserver publishedDaily dans le type, inutilisé pour la facturation, est délibéré. C'est ce que le lecteur trouvera dans le PDF du ministère, et un test qui affirme qu'il ne se réconcilie pas est le moyen le moins cher d'empêcher un futur mainteneur de « corriger » le moteur pour l'utiliser.

Étape 3 : l'exonération est une date, pas un statut

Les micro-entreprises paient 100 SAR par permis et aucune redevance depuis des années. Ce dispositif, prolongé à plusieurs reprises depuis avril 2020, a une fin : le 21 janvier 2027, correspondant au 14 chaabane 1448H. Qiwa a publié l'avis. Aucune quatrième prolongation n'est annoncée à ce jour.

Les conditions pour en bénéficier sont étroites, et chacune est un booléen que votre SIRH ne stocke probablement pas :

  • Neuf travailleurs ou moins au total sous le numéro unifié — saoudiens et non saoudiens, propriétaire compris
  • Un propriétaire unique — une seule personne physique
  • Le propriétaire travaillant à temps plein dans l'établissement et enregistré à la GOSI en tant que propriétaire

Remplir ces conditions couvre deux permis de travail étrangers. Ajoutez au moins un employé saoudien à temps plein et cela en couvre quatre. Les permis au-delà des places couvertes paient la redevance normalement.

export type ExemptionInput = {
  totalWorkers: number;          // tout le monde sous le numéro unifié, propriétaire compris
  singleOwner: boolean;
  ownerFullTimeInsured: boolean; // temps plein ET enregistré GOSI comme propriétaire
  saudiFullTimeEmployees: number;
};
 
export function exemptSlots(input: ExemptionInput): number {
  if (input.totalWorkers > 9) return 0;
  if (!input.singleOwner || !input.ownerFullTimeInsured) return 0;
  return input.saudiFullTimeEmployees >= 1 ? 4 : 2;
}

Modélisez cela comme des places, pas comme un drapeau sur l'établissement. Une entreprise avec six étrangers et un propriétaire à temps plein n'est pas « exonérée » — elle a deux permis exonérés et quatre facturables, et savoir quel travailleur occupe quelle place change la facture. Un booléen isExempt sur la fiche société ne peut pas représenter cela, et c'est la première forme vers laquelle la plupart des systèmes se tournent.

Étape 4 : chiffrer un permis qui franchit la falaise

C'est ici que la date compte. Un permis renouvelé en novembre 2026 pour un an ne tombe ni entièrement dans l'exonération ni entièrement en dehors. Les frais s'appliquent uniquement aux jours postérieurs à la fin de l'exonération — le permis est chiffré en deux segments.

La fonction de tarification ne peut donc pas prendre un taux. Elle doit prendre une période, la découper à chaque frontière qui change le prix, et chiffrer chaque morceau :

export const EXEMPTION_LAST_DAY = '2027-01-21';
export const WORK_PERMIT_FEE = 100;
 
export function priceWorkPermit(input: PriceInput) {
  const cliff = input.exemptionLastDay ?? EXEMPTION_LAST_DAY;
  const firstChargeable = dayAfter(cliff);
 
  // Toute date à laquelle le prix peut changer, bornes de la période incluses.
  const boundaries = new Set<string>([input.start, input.end]);
  for (const step of LEVY_SCHEDULE) {
    if (step.from > input.start && step.from < input.end) boundaries.add(step.from);
  }
  if (input.exempt && firstChargeable > input.start && firstChargeable < input.end) {
    boundaries.add(firstChargeable);
  }
  const cuts = [...boundaries].sort();
 
  const segments: PricedSegment[] = [];
  for (let i = 0; i < cuts.length - 1; i++) {
    const from = cuts[i];
    const to = cuts[i + 1];
    const days = days360(from, to);
    if (days === 0) continue;
 
    const exemptHere = input.exempt && to <= firstChargeable;
    const rates = ratesOn(from, input.tier);
    segments.push({
      from, to, days, tier: input.tier, monthly: rates.monthly, exempt: exemptHere,
      levyHalalas: exemptHere ? 0 : Math.round(dailyRateHalalas(rates) * days),
    });
  }
 
  const levyHalalas = segments.reduce((a, s) => a + s.levyHalalas, 0);
  return {
    segments,
    chargeableDays: segments.filter((s) => !s.exempt).reduce((a, s) => a + s.days, 0),
    levy: levyHalalas / 100,
    permitFee: WORK_PERMIT_FEE,
    total: (levyHalalas + WORK_PERMIT_FEE * 100) / 100,
  };
}

Deux choix de conception à défendre en revue.

exemptionLastDay est une entrée avec valeur par défaut. Cette date a bougé plusieurs fois depuis 2020. Si elle bouge encore, c'est un changement de configuration, pas un déploiement. Enterrer une date de politique publique dans une constante, c'est ainsi que le code de conformité pourrit.

La falaise est le dernier jour couvert, donc le premier jour facturable est le lendemain. Les sources indiquent que les frais s'appliquent à la période postérieure au 21 janvier 2027. Faire de firstChargeable une valeur nommée explicite plutôt qu'une comparaison en ligne rend l'hypothèse visible, testable et facile à inverser si la facture Qiwa dit autre chose le jour venu.

Exécutez-la sur un permis renouvelé le 1er novembre 2026 pour un an, au palier haut :

segment 1  2026-11-01 → 2027-01-22   81 jours   exonéré       0 SAR
segment 2  2027-01-22 → 2027-11-01  279 jours   800 SAR/mois  7 440 SAR
                                                frais permis    100 SAR
                                                total         7 540 SAR

L'entreprise qui avait budgété 100 SAR pour ce renouvellement est à court de 7 440 SAR, sur un seul travailleur.

Étape 5 : échelonnement, arriérés, et les deux questions que le moteur doit refuser

Échelonnement. La décision du Conseil des ministres 325 du 1442-06-13H autorise le fractionnement de la redevance en tranches d'au moins trois mois. La main-d'œuvre domestique et assimilée en est exclue et paie en totalité :

export function fractionQuarterly(totalSar: number, months: number, domesticLabour = false) {
  if (domesticLabour || months < 3) return [{ months, amount: totalSar }];
 
  const tranches = Math.floor(months / 3);
  const perHalalas = Math.floor((totalSar * 100) / tranches);
  const out = Array.from({ length: tranches }, () => perHalalas);
  // Le reliquat tombe sur la dernière tranche ; rien ne se perd à l'arrondi.
  out[out.length - 1] += totalSar * 100 - perHalalas * tranches;
  return out.map((h) => ({ months: months / tranches, amount: h / 100 }));
}

Les arriérés ne se chiffrent pas historiquement. Les règles de gestion précisent que les frais en retard des années précédentes se calculent au taux dû actuellement, et non au taux en vigueur l'année où la dette est née. Un moteur qui reconstitue le taux de 2019 pour un arriéré de 2019 sous-facturera, et le manque apparaîtra au pire moment. Chiffrez les arriérés au taux du jour ; le tableau du barème sert aux périodes qui chevauchent légitimement un palier, pas à l'antidatage.

Et les refus :

Le moteur ne doit annoncer sa facture à personne. Il calcule la règle publiée. Qiwa connaît l'activité enregistrée de l'établissement, son flux GOSI réel, ses périodes de grâce, sa structure de filiales et ses drapeaux d'exonération. En cas de divergence, Qiwa fait foi. Dites-le dans la docstring du module, puis redites-le dans toute interface qui affiche le nombre.

Le moteur ne doit pas recommander de recruter un Saoudien pour réduire la redevance. Faites le calcul honnêtement. Vingt étrangers face à une moyenne saoudienne sur 26 semaines de douze relèvent du palier haut : 20 × 9 600 = 192 000 SAR par an. Porter la moyenne à vingt fait tomber cela à 20 × 8 400 = 168 000 SAR. L'économie est de 24 000 SAR — soit 1 200 SAR par étranger et par an — et elle exige huit recrutements saoudiens durables qui n'affecteront pas la moyenne avant environ six mois. Huit salaires coûtent plusieurs fois l'économie.

La redevance seule ne justifie jamais le recrutement. Ce qui le justifie, c'est la bande de saoudisation, le quota de visas et les services qu'une bande rouge suspend — un autre calcul, dans le moteur Nitaqat. Tout outil qui présente l'économie de redevance comme la raison de recruter dessert son lecteur, et le marché en est plein.

Étape 6 : tester votre implémentation

Les tests sont ici le livrable plus encore que le code. Tous passent :

import test from 'node:test';
import assert from 'node:assert/strict';
 
test('a 30/360 year is 360 days', () => {
  assert.equal(days360('2026-01-01', '2027-01-01'), 360);
  assert.equal(days360('2026-02-01', '2026-03-01'), 30);
});
 
test('the published daily rate does not reconcile to the published annual', () => {
  const r = ratesOn('2026-06-01', 'notExceeding');
  assert.equal(r.annual, 8400);
  assert.equal(r.publishedDaily * 360, 8388);          // la valeur d'affichage
  assert.equal((dailyRateHalalas(r) * 360) / 100, 8400); // la valeur de facturation
});
 
test('both tiers close on monthly / 30, in every step', () => {
  for (const step of LEVY_SCHEDULE) {
    for (const tier of ['notExceeding', 'exceeding'] as const) {
      const r = step[tier];
      assert.equal((dailyRateHalalas(r) * 360) / 100, r.annual);
    }
  }
});
 
test('the tier is decided by a 26-week average, not by today', () => {
  const weeks = [...Array(25).fill(4), 12];  // huit recrutements cette semaine
  const basis = saudiEquivalents(weeks);
  assert.equal(Number(basis.average.toFixed(4)), 4.3077);
  assert.equal(tierFor(6, basis.basis), 'exceeding');   // toujours le palier haut
  assert.equal(tierFor(6, 12), 'notExceeding');         // ce que dirait l'effectif du jour
});
 
test('exemption slots follow the owner and the Saudi employee', () => {
  const base = { totalWorkers: 6, singleOwner: true,
                 ownerFullTimeInsured: true, saudiFullTimeEmployees: 0 };
  assert.equal(exemptSlots(base), 2);
  assert.equal(exemptSlots({ ...base, saudiFullTimeEmployees: 1 }), 4);
  assert.equal(exemptSlots({ ...base, totalWorkers: 10 }), 0);
  assert.equal(exemptSlots({ ...base, singleOwner: false }), 0);
});
 
test('a non-exempt full year is the published annual plus the permit fee', () => {
  const r = priceWorkPermit({ start: '2026-03-01', end: '2027-03-01',
                              tier: 'exceeding', exempt: false });
  assert.equal(r.chargeableDays, 360);
  assert.equal(r.levy, 9600);
  assert.equal(r.total, 9700);
});
 
test('an exempt permit renewed across the cliff pays only the days after it', () => {
  const r = priceWorkPermit({ start: '2026-11-01', end: '2027-11-01',
                              tier: 'exceeding', exempt: true });
  assert.equal(r.segments.length, 2);
  assert.equal(r.segments.filter((s) => !s.exempt)[0].from, '2027-01-22');
  assert.equal(r.chargeableDays, 279);
  assert.equal(r.total, 7540);
});
 
test('a period spanning a schedule step is priced on both rates', () => {
  const r = priceWorkPermit({ start: '2019-07-01', end: '2020-07-01',
                              tier: 'notExceeding', exempt: false });
  assert.deepEqual(r.segments.map((s) => s.monthly), [500, 700]);
  assert.equal(r.levy, 3000 + 4200);
});

Lancez-les avec node --experimental-strip-types --test levy.test.ts.

Le deuxième test est celui à garder pour toujours. Il affirme une contradiction dans le tableau source, donc il documente pourquoi le moteur ignore un nombre publié par le ministère — exactement le genre de chose qu'on « corrige » en silence en relisant le PDF dans un an.

Dépannage

Le total diffère de quelques riyals de la facture Qiwa. Vérifiez d'abord le décompte de jours. Si vous avez utilisé un vrai calendrier quelque part, une année de 365 jours contre un taux basé sur 360 donne une surfacturation prévisible. Si l'écart est d'exactement 12 ou 24 riyals par travailleur et par an, vous facturez sur la valeur journalière publiée au lieu de monthly / 30.

Le palier bascule une semaine après une embauche. Vous comparez à un effectif instantané, pas à la moyenne GOSI sur 26 semaines. La moyenne est la règle ; le nombre instantané est au mieux un aperçu.

L'établissement exonéré est facturé dès le premier jour. Vérifiez le sens de la frontière. Si firstChargeable est la date de falaise elle-même plutôt que le lendemain, chaque permis exonéré perd un jour et tout permis expirant exactement le 21 janvier 2027 est facturé en entier.

Les succursales divergent du siège. La comparaison se fait au niveau du numéro unifié (الرقم الموحد), pas par registre de commerce. Agréger par succursale produit un palier différent de celui que le ministère appliquera. D'où viennent les effectifs est une question d'intégration avant d'être une question d'arithmétique — voir l'intégration Qiwa pour les SIRH.

Des centimes flottants dans la facture. Accumulez les halalas en entiers, arrondissez une seule fois à la fin.

Prochaines étapes

  • Vérifiez où se situe réellement votre établissement avec le calculateur Nitaqat — le ratio Saoudiens/étrangers est ce qui vous place au palier 700 SAR ou 800 SAR, et c'est la même moyenne sur 26 semaines que lit ce moteur.
  • Construisez la bande elle-même avec le moteur Nitaqat en TypeScript.
  • Comprenez les conséquences de la fenêtre de moyenne dans pourquoi votre bande est passée au rouge.
  • Étendez le moteur avec une projection : à partir des permis actuels et de leurs échéances, à quoi ressemble la ligne budgétaire 2027 une fois l'exonération expirée ? C'est le rapport que la direction financière demande réellement.

Conclusion

La redevance des permis de travail ressemble à une constante et se comporte comme une série temporelle. Le taux dépend d'une moyenne que vous ne pouvez pas changer vite, le calendrier de facturation compte 360 jours, le tableau du ministère se contredit lui-même de 12 riyals par an, et le 21 janvier 2027 un seul renouvellement se met à porter deux prix.

Rien de tout cela n'est difficile à implémenter. Tout cela est facile à rater en silence, ce qui est pire — la facture arrive, elle dépasse la ligne budgétaire, et personne ne sait quelle hypothèse a produit l'écart. Un moteur qui segmente la période, nomme ses dates-frontières, travaille en entiers et refuse d'usurper Qiwa ne surprendra personne en janvier.

Si votre paie porte encore la redevance comme un nombre unique dans un fichier de configuration, cela mérite un examen avant l'expiration de l'exonération plutôt qu'après. Parlons-en — nous lisons la configuration actuelle et vous disons ce que devient réellement la ligne 2027.