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

Congés spéciaux saoudiens en TypeScript (art. 113, 114, 115, 160)

Construire le moteur de congés spéciaux que les systèmes de paie saoudiens calculent mal : mariage, naissance, décès, Hajj, examen et idda selon les articles 113, 114, 115 et 160. Couvre les fenêtres de réclamation qui expirent, la distinction cinq jours contre trois selon le degré de parenté, pourquoi la période d'idda ne fait pas 130 jours, et pourquoi le congé de Hajj ne doit jamais payer l'Aïd deux fois.

La plupart des systèmes de paie saoudiens modélisent le congé comme un seul nombre. Un salarié a un solde, il prend des jours, le solde diminue. Ce modèle est juste pour le congé annuel, et faux pour tout le reste.

Les articles 113, 114, 115 et 160 du droit du travail saoudien créent une seconde catégorie de congés qui ne se comporte en rien comme un solde. Elle ne s'acquiert pas, ne se reporte pas, et ne peut pas être refusée au motif que le salarié dispose encore de jours annuels. Elle est attachée à un événement — un mariage, une naissance, un décès, une saison de Hajj, un examen — et dans plusieurs cas elle expire si le salarié ne la réclame pas dans une fenêtre qui se compte en jours.

L'erreur est ici silencieuse. Personne ne remarque un type de congé qui n'a jamais été accordé. Le salarié, lui, le remarque environ un an plus tard, devant une commission du travail, sous forme de réclamation.

Ce tutoriel construit le moteur qui traite cela correctement. Chaque règle ci-dessous renvoie à un numéro d'article, chaque branche est couverte par un test, et la suite de tests passe au vert.

Prérequis

  • Node.js 22 ou plus (les exemples utilisent node --experimental-strip-types, sans build)
  • Une familiarité TypeScript : unions discriminées et switch exhaustif
  • Une compréhension pratique de la paie saoudienne (assiette de salaire, lignes de bulletin)
  • Aucune base de données : le moteur est constitué de fonctions pures sur des dates

Ce que vous allez construire

Une fonction unique awardSpecialLeave qui prend un dossier d'emploi et un événement de congé, et retourne un droit : combien de jours payés, combien de non payés, quand le congé commence et se termine, s'il touche au solde annuel, et surtout un motif de refus lorsque le droit ne naît pas.

Les six événements traités :

ÉvénementArticleDroit
Mariage1135 jours, salaire plein
Naissance d'un enfant1133 jours, salaire plein, à réclamer dans les 7 jours
Décès1135 jours pour conjoint, ascendant ou descendant ; 3 jours pour un frère ou une sœur
Hajj11410 à 15 jours, une seule fois dans toute la carrière, après 2 années continues
Examen115Jours d'examen réels, payés en première session, non payés en année redoublée
Idda (veuve)1604 mois et 10 jours pour une salariée musulmane ; 15 jours sinon

Étape 1 : modéliser l'événement, pas le solde

La première décision de conception est celle qui vous sauve ensuite. N'ajoutez pas ces congés à la table du congé annuel avec une colonne type. Leur forme est différente : pas de solde d'ouverture, pas de taux d'acquisition, pas de report. Modélisez-les comme des événements qui produisent des droits.

// src/special-leave/types.ts
export type Religion = 'muslim' | 'non-muslim';
export type Relation = 'spouse' | 'ascendant' | 'descendant' | 'sibling';
 
export type Employment = {
  hiredOn: string;
  religion: Religion;
  performedHajjBefore: boolean;
  hajjLeaveUsedInService: boolean;
  enrolmentApproved: boolean;
};
 
export type Event =
  | { kind: 'marriage'; on: string; requestedStart?: string }
  | { kind: 'newborn'; on: string; requestedStart?: string }
  | { kind: 'bereavement'; on: string; relation: Relation }
  | { kind: 'iddah'; on: string; pregnant?: boolean; deliveryOn?: string }
  | { kind: 'hajj'; on: string; requestedDays?: number }
  | { kind: 'exam'; on: string; examDays: number; repeatYear: boolean };
 
export type Award = {
  article: string;
  paidDays: number;
  unpaidDays: number;
  startsOn: string | null;
  endsOn: string | null;
  deductsFromAnnual: boolean;
  notes: string[];
  refused?: string;
};

Remarquez deductsFromAnnual porté par le droit lui-même plutôt que par une constante. Sa valeur est toujours false sous ces articles, et l'énoncer explicitement sur chaque droit est ce qui empêche une refactorisation ultérieure de déduire discrètement ces jours du solde annuel — le défaut le plus courant dans ce domaine.

La règle que l'on comprend mal : un employeur ne peut pas refuser un congé de l'article 113 au motif que le salarié dispose encore de jours annuels. Le droit est indépendant du solde annuel. La question revient constamment dans les discussions RH saoudiennes, et elle est tranchée : l'article accorde le congé d'emblée.

Étape 2 : une arithmétique de dates qui survit aux fins de mois

Chaque règle de ce moteur est une règle de dates, donc les utilitaires de date sont le moteur. Utilisez UTC partout ; un objet Date en heure locale décalera une borne de congé d'une journée pour quiconque lance la paie depuis une machine réglée sur un autre fuseau.

// src/special-leave/dates.ts
const MS_DAY = 86_400_000;
 
export const parseDate = (iso: string) => {
  const [y, m, d] = iso.split('-').map(Number);
  return new Date(Date.UTC(y, m - 1, d));
};
 
export const toISO = (d: Date) => d.toISOString().slice(0, 10);
 
export const addDays = (iso: string, n: number) =>
  toISO(new Date(parseDate(iso).getTime() + n * MS_DAY));
 
export const daysBetween = (a: string, b: string) =>
  Math.round((parseDate(b).getTime() - parseDate(a).getTime()) / MS_DAY);
 
export function addMonths(iso: string, n: number): string {
  const d = parseDate(iso);
  const day = d.getUTCDate();
  const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
  const last = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 0)).getUTCDate();
  t.setUTCDate(Math.min(day, last));
  return toISO(t);
}
 
export const overlapDays = (aS: string, aE: string, bS: string, bE: string) => {
  const s = Math.max(parseDate(aS).getTime(), parseDate(bS).getTime());
  const e = Math.min(parseDate(aE).getTime(), parseDate(bE).getTime());
  return e < s ? 0 : Math.round((e - s) / MS_DAY) + 1;
};

addMonths écrête : ajouter un mois au 31 janvier donne le 28 février, pas le 3 mars. Cet écrêtage n'est pas décoratif. Il est porteur à l'étape 5.

Étape 3 : les constantes, chacune rattachée à son article

// src/special-leave/constants.ts
import type { Relation } from './types';
 
export const BEREAVEMENT_DAYS: Record<Relation, number> = {
  spouse: 5,      // Art. 113
  ascendant: 5,   // parents, grandparents
  descendant: 5,  // children, grandchildren
  sibling: 3,     // brother or sister
};
 
export const MARRIAGE_DAYS = 5;                 // Art. 113
export const NEWBORN_DAYS = 3;                  // Art. 113
export const NEWBORN_CLAIM_WINDOW_DAYS = 7;     // Art. 113 — from the date of birth
 
export const IDDAH_MONTHS = 4;                  // Art. 160
export const IDDAH_EXTRA_DAYS = 10;             // Art. 160
export const IDDAH_NON_MUSLIM_DAYS = 15;        // Art. 160
 
export const HAJJ_MIN_DAYS = 10;                // Art. 114
export const HAJJ_MAX_DAYS = 15;                // Art. 114
export const HAJJ_MIN_TENURE_YEARS = 2;         // Art. 114

La distinction cinq contre trois mérite une pause, car les résultats de recherche se contredisent sur ce point. L'article 113 accorde cinq jours pour le décès du conjoint, d'un ascendant ou d'un descendant. Un frère ou une sœur ouvre droit à trois jours. Plusieurs pages RH saoudiennes très lues ne citent que le chiffre de cinq et laissent le lecteur supposer qu'il couvre tout parent — ce qui surpaie dans le cas d'un frère et, pire, enseigne aux RH une règle qui s'effondre dès que quelqu'un la vérifie.

Étape 4 : article 113 — les trois congés événementiels et leurs fenêtres

// src/special-leave/engine.ts
const refuse = (article: string, why: string): Award => ({
  article, paidDays: 0, unpaidDays: 0, startsOn: null, endsOn: null,
  deductsFromAnnual: false, notes: [], refused: why,
});
 
const span = (start: string, days: number) => ({ start, end: addDays(start, days - 1) });
 
// --- marriage ---
case 'marriage': {
  const { start, end } = span(ev.requestedStart ?? ev.on, MARRIAGE_DAYS);
  return {
    article: '113', paidDays: MARRIAGE_DAYS, unpaidDays: 0,
    startsOn: start, endsOn: end, deductsFromAnnual: false,
    notes: ['Runs from the marriage date, not the date of the contract (aqd qiran).'],
  };
}
 
// --- newborn ---
case 'newborn': {
  const start = ev.requestedStart ?? ev.on;
  const elapsed = daysBetween(ev.on, start);
  if (elapsed < 0) return refuse('113', 'Leave cannot start before the birth.');
  if (elapsed >= NEWBORN_CLAIM_WINDOW_DAYS)
    return refuse('113', `Claim window of ${NEWBORN_CLAIM_WINDOW_DAYS} days from birth has expired.`);
  const { end } = span(start, NEWBORN_DAYS);
  return {
    article: '113', paidDays: NEWBORN_DAYS, unpaidDays: 0,
    startsOn: start, endsOn: end, deductsFromAnnual: false,
    notes: [`Started on day ${elapsed} of the 7-day window.`],
  };
}
 
// --- bereavement ---
case 'bereavement': {
  const days = BEREAVEMENT_DAYS[ev.relation];
  const { start, end } = span(ev.on, days);
  return {
    article: '113', paidDays: days, unpaidDays: 0,
    startsOn: start, endsOn: end, deductsFromAnnual: false,
    notes: [`Relation '${ev.relation}' carries ${days} days.`],
  };
}

Deux pièges vivent dans ce bloc.

La fenêtre de la naissance se referme. Les trois jours sont attachés à la naissance et doivent être pris dans les sept jours qui la suivent. Un père qui demande le congé deux semaines plus tard n'a plus de droit à réclamer. Un système qui l'accorde malgré tout n'est pas généreux : il produit un bulletin qui ne se réconcilie pas avec le texte. Et le cas inverse — un système qui abandonne la demande en silence sans dire pourquoi — est précisément la manière dont le droit se perd. C'est pour cela que refuse porte un motif au lieu de retourner zéro jour.

Le congé de mariage court à partir du mariage, pas des fiançailles. La distinction entre la date de l'acte et la date du mariage est un débat vivant dans la pratique saoudienne, et le moteur devrait consigner la date qu'il a retenue plutôt que la laisser implicite.

Étape 5 : article 160 — la période d'idda ne fait pas 130 jours

C'est la règle que la plupart des implémentations codent en dur, et la coder en dur est faux.

L'article 160 accorde à une salariée musulmane dont le mari décède un congé à salaire plein d'une durée qui ne peut être inférieure à quatre mois et dix jours. Une salariée non musulmane dans la même situation reçoit quinze jours. Si elle est enceinte, elle peut prolonger le congé sans solde jusqu'à l'accouchement.

« Quatre mois et dix jours » est une arithmétique calendaire. Ce n'est pas un décompte fixe, car les mois n'ont pas la même longueur.

case 'iddah': {
  if (emp.religion === 'non-muslim') {
    const { start, end } = span(ev.on, IDDAH_NON_MUSLIM_DAYS);
    return {
      article: '160', paidDays: IDDAH_NON_MUSLIM_DAYS, unpaidDays: 0,
      startsOn: start, endsOn: end, deductsFromAnnual: false,
      notes: ['Non-Muslim widow: 15 days at full pay.'],
    };
  }
 
  const end = addDays(addMonths(ev.on, IDDAH_MONTHS), IDDAH_EXTRA_DAYS);
  const paid = daysBetween(ev.on, end);
  const notes = [`Four calendar months plus ten days resolved to ${paid} days.`];
 
  let unpaid = 0;
  if (ev.pregnant && ev.deliveryOn && daysBetween(end, ev.deliveryOn) > 0) {
    unpaid = daysBetween(end, ev.deliveryOn);
    notes.push(`Pregnant: unpaid extension of ${unpaid} days to delivery.`);
  }
 
  return {
    article: '160', paidDays: paid, unpaidDays: unpaid,
    startsOn: ev.on, endsOn: unpaid ? ev.deliveryOn! : end,
    deductsFromAnnual: false, notes,
  };
}

Exécutez-la depuis deux dates différentes de la même année et le moteur retourne deux réponses différentes :

iddah from 2026-02-01 = 130 days
iddah from 2026-10-01 = 133 days

Un décès en février se résout à 130 jours. Le même décès en octobre donne 133 jours, parce que les quatre mois couverts sont plus longs. Tout système qui stocke 130 comme constante sous-paie une veuve d'octobre de trois jours à salaire plein — discrètement, indéfiniment, au détriment de la salariée la moins susceptible de vérifier son propre bulletin.

Notez le plancher. Le texte dit pas moins de quatre mois et dix jours. La valeur calculée est un minimum, pas un plafond. Une politique d'employeur plus généreuse est licite ; une politique qui accorde 130 jours forfaitaires ne l'est pas.

Étape 6 : article 114 — le congé de Hajj ne doit pas payer l'Aïd deux fois

Le congé de Hajj porte le plus grand nombre de conditions préalables de tout le texte, et un piège arithmétique qui coûte de l'argent réel.

Le droit est de dix à quinze jours à salaire plein, y compris le congé de l'Aïd al-Adha, une seule fois pendant toute la durée du service, et seulement si le salarié n'a pas déjà accompli le Hajj. Il exige au moins deux années continues chez le même employeur, et l'employeur peut plafonner le nombre de salariés qui en bénéficient chaque année selon les nécessités du travail.

case 'hajj': {
  const tenureDays = daysBetween(emp.hiredOn, ev.on);
  if (tenureDays < HAJJ_MIN_TENURE_YEARS * 365)
    return refuse('114', 'Requires two continuous years with the same employer.');
  if (emp.performedHajjBefore)
    return refuse('114', 'Worker has already performed Hajj.');
  if (emp.hajjLeaveUsedInService)
    return refuse('114', 'Hajj leave is once for the whole service.');
 
  const granted = Math.min(
    HAJJ_MAX_DAYS,
    Math.max(HAJJ_MIN_DAYS, ev.requestedDays ?? HAJJ_MIN_DAYS),
  );
  const { start, end } = span(ev.on, granted);
 
  const eid = holidays
    .filter((h) => h.name === 'eid-al-adha')
    .reduce((n, h) => n + overlapDays(start, end, h.start, addDays(h.start, h.days - 1)), 0);
 
  const notes = [`Granted ${granted} days, of which ${eid} fall inside the Eid al-Adha holiday.`];
  if (eid) notes.push('Eid days are already paid as a public holiday and are not paid twice.');
 
  return {
    article: '114', paidDays: granted - eid, unpaidDays: 0,
    startsOn: start, endsOn: end, deductsFromAnnual: false, notes,
  };
}

La formule « y compris le congé de l'Aïd al-Adha » signifie que les jours de l'Aïd se situent à l'intérieur de l'octroi. Ils sont déjà payés à tout salarié en tant que jour férié. Un octroi de quinze jours qui chevauche un Aïd de quatre jours produit onze jours de coût marginal, pas quinze. Les systèmes qui traitent les deux comme des lignes distinctes surestiment la provision de congés de jusqu'à quatre jours par pèlerin.

Les dates de l'Aïd sont des données, pas une formule. L'Aïd al-Adha est fixé par observation lunaire et annoncé chaque année. Ne le calculez pas. Injectez-le :

export type Holiday = { name: string; start: string; days: number };
 
// Replace with the officially announced dates each year — these are astronomical
// estimates and the announced dates routinely differ by a day.
export const HOLIDAYS_2026: Holiday[] = [
  { name: 'eid-al-adha', start: '2026-05-27', days: 4 },
];

C'est la leçon qu'avait déjà apprise le moteur de période d'essai : toute date que l'État annonce au lieu de la dériver appartient à une table qu'un humain met à jour une fois par an, avec l'estimation étiquetée comme telle. Une formule juste quatre années sur cinq est pire qu'une table, parce que personne ne vérifie une formule.

Étape 7 : article 115 — le congé d'examen qui bascule en non payé

L'article 115 est conditionnel d'une manière que les autres ne sont pas. Si l'employeur a approuvé l'inscription du salarié dans un établissement d'enseignement, celui-ci perçoit son salaire plein pour les jours d'examen réels d'une première session. Pour une année redoublée, le congé reste dû, mais sans salaire.

case 'exam': {
  if (!emp.enrolmentApproved)
    return refuse('115', 'Employer never approved the enrolment, so no entitlement arises.');
 
  const { start, end } = span(ev.on, ev.examDays);
 
  return ev.repeatYear
    ? {
        article: '115', paidDays: 0, unpaidDays: ev.examDays,
        startsOn: start, endsOn: end, deductsFromAnnual: false,
        notes: ['Repeated year: leave is owed, but unpaid.'],
      }
    : {
        article: '115', paidDays: ev.examDays, unpaidDays: 0,
        startsOn: start, endsOn: end, deductsFromAnnual: false,
        notes: ['Non-repeated year: actual exam days at full pay.'],
      };
}

« Jours d'examen réels » se prend au pied de la lettre. Ce n'est pas la durée de la session d'examens, ce sont les jours où le salarié compose. Une session de trois semaines avec quatre épreuves représente quatre jours, pas vingt et un.

Étape 8 : assembler et exposer le refus

Reliez les branches dans une seule fonction exhaustive :

export function awardSpecialLeave(
  emp: Employment,
  ev: Event,
  holidays: Holiday[] = [],
): Award {
  switch (ev.kind) {
    // ... the six cases from Steps 4 to 7
  }
}

Parce que Event est une union discriminée et que chaque branche retourne, TypeScript fera échouer le build si un septième type de congé est ajouté sans être traité. C'est exactement la propriété recherchée : le compilateur, et non un relecteur, détecte l'évolution du texte.

Le champ refused compte autant que les jours payés. Un écran RH qui affiche « 0 jour » n'apprend rien à personne. Celui qui affiche « la fenêtre de réclamation de 7 jours à compter de la naissance a expiré » dit au manager ce qui s'est passé, dit au salarié pourquoi, et vous donne une piste d'audit le jour où la réclamation arrive.

Tester votre implémentation

Treize tests couvrent chaque branche et chaque piège. Cette suite passe au vert :

import assert from 'node:assert/strict';
import { awardSpecialLeave, addMonths, type Employment } from './engine.ts';
 
const emp: Employment = {
  hiredOn: '2022-01-10',
  religion: 'muslim',
  performedHajjBefore: false,
  hajjLeaveUsedInService: false,
  enrolmentApproved: true,
};
 
// Art. 113 — marriage never touches the annual balance
const marriage = awardSpecialLeave(emp, { kind: 'marriage', on: '2026-03-01' });
assert.equal(marriage.paidDays, 5);
assert.equal(marriage.endsOn, '2026-03-05');
assert.equal(marriage.deductsFromAnnual, false);
 
// Art. 113 — the newborn window closes on day 7
const late = awardSpecialLeave(emp, {
  kind: 'newborn', on: '2026-03-01', requestedStart: '2026-03-08',
});
assert.equal(late.paidDays, 0);
assert.match(late.refused!, /window/);
 
// Art. 113 — five days for an ascendant, three for a sibling
assert.equal(awardSpecialLeave(emp,
  { kind: 'bereavement', on: '2026-03-01', relation: 'ascendant' }).paidDays, 5);
assert.equal(awardSpecialLeave(emp,
  { kind: 'bereavement', on: '2026-03-01', relation: 'sibling' }).paidDays, 3);
 
// Art. 160 — the iddah period is calendar arithmetic, not a constant
const feb = awardSpecialLeave(emp, { kind: 'iddah', on: '2026-02-01' });
const oct = awardSpecialLeave(emp, { kind: 'iddah', on: '2026-10-01' });
assert.equal(feb.paidDays, 130);
assert.equal(oct.paidDays, 133);
 
// Art. 114 — fifteen granted days over a four-day Eid cost eleven
const hajj = awardSpecialLeave(emp,
  { kind: 'hajj', on: '2026-05-25', requestedDays: 15 },
  [{ name: 'eid-al-adha', start: '2026-05-27', days: 4 }]);
assert.equal(hajj.paidDays, 11);
 
// Art. 115 — a repeated year is owed, but unpaid
const repeat = awardSpecialLeave(emp,
  { kind: 'exam', on: '2026-06-01', examDays: 4, repeatYear: true });
assert.equal(repeat.paidDays, 0);
assert.equal(repeat.unpaidDays, 4);
 
// month-end clamp
assert.equal(addMonths('2026-01-31', 1), '2026-02-28');

Lancez avec node --experimental-strip-types test.ts. Les deux assertions à conserver pour toujours sont la paire d'idda et le chevauchement du Hajj : ce sont les deux premières qu'une refactorisation bien intentionnée casse.

Dépannage

La période d'idda revient un jour trop courte. Vous avez construit la date de fin en heure locale au lieu d'UTC, et une machine à l'ouest de Riyad a reculé la borne. Chaque date de ce moteur doit passer par parseDate.

Le congé de Hajj est refusé à un salarié ancien. Le contrôle d'ancienneté utilise 2 * 365 jours et ignore les années bissextiles. C'est un conservatisme délibéré : il refuse un cas limite plutôt que d'en accorder un qui échouerait à l'audit. Si votre politique compte les années calendaires, remplacez le contrôle par daysBetween(emp.hiredOn, addMonths(ev.on, -24)) >= 0.

Le congé spécial apparaît en déduction du solde annuel. Quelque chose en aval lit paidDays et le déduit. Le drapeau deductsFromAnnual existe précisément pour que le consommateur puisse l'asserter — vérifiez-le à l'étape de comptabilisation de la paie, pas seulement dans le moteur.

Un type de congé retourne undefined. Vous avez ajouté une variante à Event sans ajouter de case. Activez noImplicitReturns et le compilateur l'attrapera avant la clôture de paie.

Étapes suivantes

Conclusion

Les congés spéciaux sont une petite partie d'un système de paie qui génère une part disproportionnée de ses contentieux prud'homaux, parce que chaque règle y est une règle de dates et que chaque défaut y est invisible sur le bulletin. Le moteur ci-dessus fait environ deux cents lignes. Il encode six articles, quatre conditions préalables, deux fenêtres de réclamation qui expirent, et un calcul calendaire que la plupart des implémentations remplacent par une constante en se trompant de trois jours à la fois.

Le motif qui le rend maintenable n'a rien d'astucieux : une union discriminée que le compilateur peut vérifier, des constantes annotées de l'article dont elles proviennent, un motif de refus sur chaque rejet, et une table pour les dates que l'État annonce au lieu de les dériver.

Si votre SIRH calcule déjà correctement le congé annuel tout en traitant le mariage, le décès et le Hajj comme des validations manuelles dans un tableur, cet écart est la source des réclamations. Nous connectons les SIRH à la paie pour que les droits soient calculés à partir du texte plutôt que mémorisés par un manager — dites-nous ce que vous utilisez et nous vous dirons ce qui lui manque.