é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 les congés de mariage et de décès courent à partir de l'événement et non de la demande, ce que le mois de trente jours de l'article 2 fait à la période d'idda, 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 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. 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 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

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.