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 MonthBasis = 'statutory' | 'calendar';
export type Employment = {
hiredOn: string;
religion: Religion;
performedHajjBefore: boolean;
hajjLeaveUsedInService: boolean;
enrolmentApproved: boolean;
// Art. 2: a month is 30 days unless the contract or the work
// regulations say otherwise. Use 'calendar' only if yours do.
monthBasis: MonthBasis;
};
export type Event =
| { kind: 'marriage'; on: string; requestedStart?: string }
| { kind: 'newborn'; on: string; requestedStart?: string }
| { kind: 'bereavement'; on: string; relation: Relation; requestedStart?: string }
| { 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.
monthBasis est obligatoire à dessein. L'article 2 définit le mois comme trente jours « sauf
disposition contraire du contrat de travail ou du règlement intérieur », si bien que la durée de
tout congé exprimé en mois dépend d'un document que le moteur ne voit pas. Un champ facultatif
laisserait une valeur absente retomber en silence sur un défaut ; un champ obligatoire force
quelqu'un à ouvrir le contrat une fois.
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 STATUTORY_MONTH_DAYS = 30; // Art. 2 — unless the contract says otherwise
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 le jour où le décompte commence
// 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) });
// Art. 113: marriage and bereavement leave "are all counted from the date of
// the event". The period is fixed on the calendar; a late start shortens it.
const fromEvent = (on: string, days: number, requestedStart?: string): Award => {
const lastDay = addDays(on, days - 1);
const start = requestedStart ?? on;
if (daysBetween(on, start) < 0) return refuse('113', 'Leave cannot start before the event.');
if (daysBetween(start, lastDay) < 0)
return refuse('113', `All ${days} days ran from ${on} and ended on ${lastDay}.`);
const paid = daysBetween(start, lastDay) + 1;
const notes = [`Counted from the date of the event (${on}); ends ${lastDay}.`];
if (paid < days) notes.push(`${days - paid} of the ${days} days had already elapsed.`);
return {
article: '113', paidDays: paid, unpaidDays: 0,
startsOn: start, endsOn: lastDay, deductsFromAnnual: false, notes,
};
};
// --- marriage ---
case 'marriage': {
const award = fromEvent(ev.on, MARRIAGE_DAYS, ev.requestedStart);
award.notes.unshift('The event is the marriage date, not the date of the contract (aqd qiran).');
return award;
}
// --- 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 award = fromEvent(ev.on, days, ev.requestedStart);
award.notes.unshift(`Relation '${ev.relation}' carries ${days} days.`);
return award;
}Trois pièges vivent dans ce bloc.
Mariage et décès se comptent à partir de l'événement, pas de la demande. L'article 113, tel que
modifié par le décret royal M/44, dit que ces congés « sont tous calculés à partir de la date de
l'événement » (تحتسب جميعها من تاريخ الواقعة). La période est fixée sur le calendrier à partir du jour
du décès ou du mariage, et un salarié qui commence en retard ne repousse pas la date de fin. Celui
qui apprend le décès d'un parent deux jours après les faits dispose de trois des cinq jours, pas de
cinq. C'est pourquoi fromEvent fixe d'abord le dernier jour et en déduit les jours payés, et
pourquoi un départ après le dernier jour est un refus motivé. La version précédente de ce moteur
laissait requestedStart déplacer tout le congé de mariage, accordant en silence des jours que
l'article n'accorde pas.
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, et le mois que définit l'article 2
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 à compter du décès. 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, et elle ne peut pas bénéficier du reliquat du congé d'idda après l'accouchement.
Toute la question est de savoir ce qu'est « un mois », et le Code du travail y répond avant l'article 160. L'article 2 définit le mois comme trente jours, sauf disposition contraire du contrat de travail ou du règlement intérieur. La période légale est donc de 4 × 30 + 10 = 130 jours, quelle que soit la date du décès. Le calcul calendaire ne s'applique que si le contrat ou le règlement définit les mois comme des mois calendaires.
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.'],
};
}
// Art. 2 fixes the month at 30 days unless the contract says otherwise.
const full = emp.monthBasis === 'calendar'
? daysBetween(ev.on, addDays(addMonths(ev.on, IDDAH_MONTHS), IDDAH_EXTRA_DAYS))
: IDDAH_MONTHS * STATUTORY_MONTH_DAYS + IDDAH_EXTRA_DAYS;
const notes = [`Four months and ten days on a ${emp.monthBasis} month basis = ${full} days.`];
if (ev.pregnant && ev.deliveryOn && daysBetween(ev.on, ev.deliveryOn) >= 0) {
const toDelivery = daysBetween(ev.on, ev.deliveryOn) + 1; // delivery day included
if (toDelivery < full) {
notes.push('Delivered inside the iddah: the remainder lapses at delivery.');
return {
article: '160', paidDays: toDelivery, unpaidDays: 0,
startsOn: ev.on, endsOn: ev.deliveryOn, deductsFromAnnual: false, notes,
};
}
if (toDelivery > full) {
const unpaid = toDelivery - full;
notes.push(`Pregnant: unpaid extension of ${unpaid} days to delivery.`);
return {
article: '160', paidDays: full, unpaidDays: unpaid,
startsOn: ev.on, endsOn: ev.deliveryOn, deductsFromAnnual: false, notes,
};
}
}
return {
article: '160', paidDays: full, unpaidDays: 0,
startsOn: ev.on, endsOn: addDays(ev.on, full - 1), deductsFromAnnual: false, notes,
};
}Exécutez-la depuis deux dates, sur chaque base :
statutory month (Art. 2), any date = 130 days
calendar months, from 2026-02-01 = 130 days
calendar months, from 2026-10-01 = 133 days
Sur la base légale, la réponse ne bouge pas. Sur une base calendaire, elle suit les mois couverts : d'octobre à février, c'est trois jours de plus que de février à juin. Aucune des deux n'est fausse en soi. Ce qui est faux, c'est d'ignorer laquelle vos contrats vous imposent. Un système qui compte en mois calendaires pour un employeur dont les contrats sont muets surpaie ; un système qui stocke 130 pour un employeur dont le règlement définit des mois calendaires sous-paie une veuve d'octobre de trois jours.
Correction (18 septembre 2026). Une version antérieure de cette étape affirmait que l'idda « ne fait pas 130 jours » et que stocker 130 revient à sous-payer. Elle ignorait l'article 2. Selon le texte du Code, 130 jours est la règle par défaut ; le calcul calendaire ne s'applique que par contrat.
L'accouchement met fin à l'idda. Si une veuve enceinte accouche avant la fin des 130 jours, le reliquat tombe : le moteur paie jusqu'au jour de l'accouchement inclus et le consigne dans les notes, puis le congé de maternité de l'article 151 prend le relais. Si elle accouche après la fin de la période, l'écart constitue la prolongation sans solde.
L'article 10 est l'autre moitié. Les délais du Code du travail se comptent selon le calendrier
hégirien, sauf disposition contraire du contrat ou du règlement. Cela ne change rien à un décompte
en jours, mais compte dès que l'on ajoute des mois à une date. L'option calendar du moteur ajoute
des mois grégoriens, ce que produit un contrat daté en grégorien.
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.
É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
Vingt et une assertions 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,
monthBasis: 'statutory',
};
// 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 — counted from the event: a late start does not move the end
const lateMarriage = awardSpecialLeave(emp,
{ kind: 'marriage', on: '2026-03-01', requestedStart: '2026-03-04' });
assert.equal(lateMarriage.paidDays, 2);
assert.equal(lateMarriage.endsOn, '2026-03-05');
// Art. 113 — news of a death that arrives two days late leaves three of five days
const lateNews = awardSpecialLeave(emp,
{ kind: 'bereavement', on: '2026-03-01', relation: 'ascendant', requestedStart: '2026-03-03' });
assert.equal(lateNews.paidDays, 3);
assert.equal(lateNews.endsOn, '2026-03-05');
// 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. 2 + 160 — a statutory month is 30 days, so 130 whatever the date
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, 130);
assert.equal(feb.endsOn, '2026-06-10');
// ...unless the contract defines the month as a calendar month
const cal: Employment = { ...emp, monthBasis: 'calendar' };
assert.equal(awardSpecialLeave(cal, { kind: 'iddah', on: '2026-10-01' }).paidDays, 133);
// Art. 160 — a delivery inside the iddah ends it
const delivered = awardSpecialLeave(emp,
{ kind: 'iddah', on: '2026-02-01', pregnant: true, deliveryOn: '2026-03-02' });
assert.equal(delivered.paidDays, 30);
assert.equal(delivered.unpaidDays, 0);
// 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 assertions à conserver pour toujours
sont la paire du départ tardif de l'article 113, la base mensuelle de l'idda et le chevauchement du
Hajj : ce sont les premières qu'une refactorisation bien intentionnée casse.
Dépannage
La date de fin de l'idda revient un jour trop tôt. 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
- Les mêmes règles côté RH, sans code — y compris le cas du frère ou de la sœur introduit par M/44 et le jour où chaque décompte commence — sont dans le guide des congés spéciaux de l'article 113.
- 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 cent cinquante lignes. Il encode les articles 2, 113, 114, 115 et 160, quatre conditions préalables, un décompte qui part du jour de l'événement quel que soit le jour où le salarié se manifeste, et un mois dont l'article 2 fixe la durée sauf disposition contraire de votre contrat.
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.