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
switchexhaustif - 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énement | Article | Droit |
|---|---|---|
| Mariage | 113 | 5 jours, salaire plein |
| Naissance d'un enfant | 113 | 3 jours, salaire plein, à réclamer dans les 7 jours |
| Décès | 113 | 5 jours pour conjoint, ascendant ou descendant ; 3 jours pour un frère ou une sœur |
| Hajj | 114 | 10 à 15 jours, une seule fois dans toute la carrière, après 2 années continues |
| Examen | 115 | Jours d'examen réels, payés en première session, non payés en année redoublée |
| Idda (veuve) | 160 | 4 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. 114La 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
- Associez ceci au versant acquisition : le moteur de congé annuel pour les articles 109 à 111 gère le solde auquel ces congés ne doivent jamais toucher.
- Le congé maladie suit sa propre échelle et sa propre année glissante — voir le moteur de l'article 117.
- Le congé de maternité de l'article 151 est de douze semaines, et depuis juillet 2025 la GOSI le paie pour le compte de l'employeur. Les règles, et qui rembourse qui, sont dans le guide de l'article 151.
- Vérifiez n'importe lequel de ces calculs avec le calculateur de congés gratuit avant de livrer.
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.