écrits/tutorial/2026/08
● Tutorial26 août 2026·28 min

Congé maladie saoudien en TypeScript (art. 117)

Construisez un moteur de calcul du congé maladie pour la paie saoudienne en TypeScript, implémentant l'article 117 du droit du travail — l'échelle 30/60/30, l'année de maladie glissante comptée en hégirien sauf clause contraire du contrat, la base salariale commissions comprises, les accidents du travail qui ne doivent jamais toucher l'échelle, la vérification du code Sehhaty, et le garde-fou de licenciement de l'article 82.

Cherchez sur Google depuis l'Arabie saoudite comment se calcule le congé maladie et vous obtiendrez deux réponses totalement différentes sur la même page de résultats. La plupart des pages citent l'article 117 du droit du travail : trente jours à plein salaire, soixante jours aux trois quarts, trente jours sans salaire — cent vingt jours par année. D'autres résultats, aussi bien classés, décrivent une échelle de cent quatre-vingts jours à plein salaire, cent quatre-vingts à demi-salaire et une année supplémentaire au quart : sept cent vingt jours.

Les deux sont exactes. Elles régissent des personnes différentes. L'article 117 régit les contrats de travail du secteur privé soumis au droit du travail. L'échelle plus longue relève des règlements des ressources humaines du secteur public, qui couvrent les fonctionnaires. Un moteur de paie qui choisit le mauvais régime n'est pas légèrement faux — il est faux d'un facteur six environ, dans le sens qui fait le plus mal.

Cette confusion est vivante dans les résultats de recherche, ce qui veut dire qu'elle est vivante dans les tableurs et les SIRH que ces résultats alimentent. Ce tutoriel construit le moteur qui ne s'y trompe pas : il refuse de calculer avant de savoir quel régime s'applique, valorise chaque jour sur la bonne base salariale, suit les tranches sur une année glissante comptée dans le calendrier que le contrat utilise réellement, achemine les accidents du travail hors de l'échelle, et refuse d'enregistrer un jour comme congé maladie sans certificat médical vérifiable.

Correction, 23 septembre 2026. Une version précédente de ce tutoriel comportait quatre erreurs, toutes corrigées ci-dessous. Elle comptait l'année de maladie en années grégoriennes ; or l'article 10 compte toutes les durées du droit du travail selon le calendrier hégirien, sauf stipulation contraire du contrat ou du règlement intérieur, et une année hégirienne se referme environ onze jours plus tôt. Elle excluait les commissions du salaire, alors que l'article 2 définit le salaire comme le salaire réel et cite les commissions dans cette définition. Elle attribuait la protection contre le licenciement à l'article 117 ; la règle figure à l'article 82. Et son point d'entrée perdait les jours de maladie tombés avant une bordure de fenêtre dans la même période de paie : sur un salaire de 10 500 SAR, un certificat à cheval sur la bordure était payé 3 500 SAR au lieu de 5 950.

Prérequis

Avant de commencer, assurez-vous de disposer de :

  • Node.js 20+ et TypeScript 5.5+ installés
  • Une aisance avec les dates, l'arithmétique entière et les unions discriminées en TypeScript
  • Un système de paie ou de RH dont la fiche salarié connaît déjà le type de contrat, le salaire mensuel et les composantes de la rémunération
  • L'accès à vos registres de congés et de présence — le moteur ne vaut que les données d'absence qui l'alimentent
  • Aucune connaissance juridique préalable ; les règles légales qui comptent sont énoncées au moment où nous les implémentons

Si vous avez déjà suivi le moteur d'acquisition des congés annuels, les conventions de monnaie et de registre vous sembleront familières — délibérément, car les deux moteurs partagent une fiche salarié et doivent s'accorder au moment du solde de tout compte.

Ce que vous allez construire

Un module sickLeavePay qui prend un salarié, un ensemble d'absences certifiées médicalement et une période de paie, et renvoie une ligne de bulletin ainsi qu'un registre défendable. Concrètement :

  • Une porte de régime qui classe la relation de travail avant toute autre chose et lève une erreur plutôt que de deviner
  • Un résolveur de base salariale qui répond à la question laissée ouverte par l'article 117 : à quel salaire renvoie le mot « salaire »
  • Une fenêtre d'année de maladie glissante ouverte par le premier jour de maladie, pas par le mois de janvier, et comptée dans le calendrier du contrat — hégirien sauf clause contraire
  • L'échelle à trois tranches consommée jour par jour, absences continues ou fractionnées
  • Un routeur d'accidents qui envoie les accidents du travail vers la branche d'assurance sociale qui les paie réellement, sans consommer un seul jour du solde de l'article 117
  • Une porte de certificat exigeant un code de congé maladie vérifiable avant tout paiement
  • Un garde-fou de licenciement exposant les jours de protection restants, afin que votre processus de sortie ne rompe pas discrètement le contrat d'une personne que l'article 82 protège encore

Étape 1 : trancher le régime avant tout le reste

Le bug le plus coûteux de ce domaine consiste à calculer la bonne formule pour la mauvaise personne. La première fonction du module n'est donc pas un calcul — c'est un refus.

/**
 * Which statutory instrument governs this employment relationship.
 *
 * `labour-law` — a private-sector contract under نظام العمل. Article 117
 *   applies: 30 days full, 60 at three quarters, 30 unpaid, per sick year.
 *
 * `civil-service` — a government post under the public-sector human-resources
 *   regulations. A materially longer and differently tiered ladder applies.
 *   This engine does NOT implement it.
 */
export type Regime = 'labour-law' | 'civil-service';
 
export class RegimeNotSupportedError extends Error {
  constructor(public readonly regime: Regime) {
    super(
      `Sick leave under the ${regime} regime is not implemented by this engine. ` +
        `Article 117 of the Labour Law governs private-sector contracts only.`,
    );
    this.name = 'RegimeNotSupportedError';
  }
}
 
export function assertLabourLaw(regime: Regime): asserts regime is 'labour-law' {
  if (regime !== 'labour-law') throw new RegimeNotSupportedError(regime);
}

Faites de regime un champ obligatoire de la fiche salarié, sans valeur par défaut. C'est par une valeur par défaut que la mauvaise échelle finit appliquée en silence à quelques centaines de personnes. Si vos données de référence ne portent pas aujourd'hui cette distinction, c'est précisément le constat — remontez-le comme une erreur de qualité de données plutôt que de laisser le moteur inventer une réponse.

Pourquoi lever une erreur plutôt que retomber sur une valeur. Un moteur de paie qui renvoie un chiffre est cru sur parole. Celui qui refuse d'en renvoyer un fait l'objet d'une enquête. Face à un écart d'un facteur six, l'enquête est l'issue la moins chère.

Étape 2 : résoudre la base salariale — c'est là que l'argent fuit

L'article 117 dit que le salarié a droit à un congé maladie « avec salaire » pour les trente premiers jours et « aux trois quarts du salaire » pour les soixante suivants. Il ne précise pas lequel.

Il n'a pas besoin de le faire. L'article 2 définit deux salaires puis tranche la question en une ligne : là où la loi dit « salaire » sans qualificatif, elle vise le salaire réel. C'est le salaire de base majoré des autres sommes dues au salarié pour son effort, pour un risque ou en vertu du contrat. La même définition énumère ensuite ce qui en fait partie, et les commissions et pourcentages sur les ventes viennent en premier. Elle couvre aussi les indemnités comme le logement et le transport, les primes que le contrat ou l'usage ont intégrées à la rémunération, et les avantages en nature que l'employeur est tenu de fournir. Les heures supplémentaires, à l'inverse, sont explicitement valorisées à partir du salaire de base majoré de cinquante pour cent (article 107). Les systèmes qui réutilisent ici la base des heures supplémentaires sous-paient chaque jour de maladie, souvent de trente à quarante pour cent. Ceux qui s'arrêtent au salaire de base plus les indemnités sous-paient en plus chaque salarié commissionné. Personne n'audite cette ligne avant le départ d'un salarié.

/** All money is integer halalas. 1 SAR = 100 halalas. Never floats. */
export type Halalas = number;
 
export type WageComponents = {
  basic: Halalas;
  housing: Halalas;
  transport: Halalas;
  /** Other fixed monthly pay: allowances, and in-kind benefits at their contract value. */
  otherRegular: Halalas;
  /**
   * Variable pay — commission, percentage of sales, piece rates. Part of the
   * actual wage under Article 2, averaged per day actually worked under
   * Article 96. `earned` over the reference period, `workedDays` in it.
   */
  variable: { earned: Halalas; workedDays: number };
};
 
export type WageBase = 'actual' | 'basic';
 
/** The fixed monthly part of the wage. Variable pay has no monthly figure. */
export function fixedMonthlyWage(w: WageComponents, base: WageBase): Halalas {
  if (base === 'basic') return w.basic;
  return w.basic + w.housing + w.transport + w.otherRegular;
}
 
/** Article 96: variable pay divided by the days actually worked to earn it. */
export function variableDailyAverage(w: WageComponents): number {
  const { earned, workedDays } = w.variable;
  if (earned === 0) return 0;
  if (!Number.isInteger(workedDays) || workedDays <= 0) {
    throw new RangeError('variable.workedDays must be a positive whole number');
  }
  return earned / workedDays;
}
 
/**
 * The daily rate. The fixed part is priced at a thirtieth of the month
 * (Article 2 defines the month as thirty days), whatever the calendar month
 * holds. The variable part is already a daily figure. Kept fractional on
 * purpose — see Step 4.
 */
export function dailyRate(w: WageComponents, base: WageBase): number {
  const fixed = fixedMonthlyWage(w, base) / 30;
  return base === 'basic' ? fixed : fixed + variableDailyAverage(w);
}

Notez le diviseur. Un jour de maladie en février et un jour de maladie en août coûtent la même chose, parce que l'article 2 définit le mois comme trente jours sauf clause contraire du contrat. Les systèmes qui divisent par le nombre réel de jours produisent un taux qui change d'un mois à l'autre et un solde que personne ne sait reproduire.

Une commission n'a pas de montant mensuel à diviser, alors l'article 96 la valorise autrement : ce que le salarié a perçu, divisé par les jours effectivement travaillés pour le gagner. Pour une rémunération à la pièce, l'article fixe la période de référence à la dernière année de service. Nous retenons les mêmes douze mois pour les commissions, et nous ajoutons la moyenne au taux journalier fixe quand la rémunération est en partie fixe et en partie commissionnée. Ce cas mixte est notre lecture, car l'article ne traite que des salaires entièrement à la pièce ou entièrement en commissions. Dans les deux cas, enregistrez la période de référence avec le résultat. Un logement fourni en nature plutôt que versé en indemnité fait toujours partie du salaire : saisissez-le dans otherRegular à la valeur fixée par le contrat.

Faites de la base salariale une décision explicite et tracée. Si vos contrats ou votre règlement intérieur promettent mieux que la loi, c'est permis — le droit du travail fixe un plancher, pas un plafond — mais le moteur doit enregistrer qu'il a appliqué une politique au-dessus du plancher, pour qu'un inspecteur voie un choix au lieu d'en déduire un bug.

Étape 3 : l'année de maladie est glissante et démarre quand le salarié tombe malade

Voici le piège qui survit à la revue de code, parce que la version fausse a l'air évidemment juste.

L'article 117 mesure l'échelle sur « une année », puis la définit : l'année qui commence à la date du premier congé maladie. Pas le 1er janvier. Pas votre exercice comptable. Pas la date anniversaire d'embauche. L'horloge démarre le premier jour où le salarié justifie sa maladie, court un an, et les tranches ne se réinitialisent que lorsqu'un jour de maladie tombe hors de cette fenêtre — auquel cas ce jour ouvre une nouvelle fenêtre.

Quelle année ? C'est le second piège. L'article 10 dispose que toutes les durées et échéances du droit du travail se comptent selon le calendrier hégirien, sauf stipulation contraire du contrat de travail ou du règlement intérieur. Une année hégirienne compte 354 ou 355 jours. Une fenêtre ouverte le 10 mars 2026 se referme le 27 février 2027 en hégirien, et non le 9 mars. Un salarié de nouveau malade le 1er mars 2027 repart donc sur une tranche à plein salaire, alors qu'un moteur grégorien le placerait dans la tranche aux trois quarts. Le calendrier est donc un champ obligatoire de la fiche salarié, comme le régime, et il vient du contrat.

Le découpage par année civile se trompe dans les deux sens. Un salarié malade vingt-cinq jours en décembre et vingt-cinq jours en janvier a consommé cinquante jours d'une même année de maladie et devrait déjà être dans la tranche à soixante-quinze pour cent ; le découpage calendaire paie les deux périodes à plein tarif. Inversement, un salarié dont la fenêtre s'est ouverte en mars 2025 et refermée en mars 2026 récupère en mars une tranche à plein salaire que le découpage calendaire lui refuse.

/** Article 10: Hijri unless the contract or the work regulations say otherwise. */
export type CalendarBasis = 'hijri' | 'gregorian';
 
export type SickYear = {
  /** Inclusive ISO date on which this window opened. */
  start: string;
  /** Inclusive ISO date on which it closes: the day before the same date a year on. */
  end: string;
  calendar: CalendarBasis;
};
 
const DAY_MS = 86_400_000;
 
type Ymd = readonly [number, number, number];
 
const HIJRI = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura-nu-latn', {
  timeZone: 'UTC',
  year: 'numeric',
  month: 'numeric',
  day: 'numeric',
});
 
function toUTC(iso: string): number {
  const [y, m, d] = iso.split('-').map(Number);
  return Date.UTC(y, m - 1, d);
}
 
function toISO(ms: number): string {
  return new Date(ms).toISOString().slice(0, 10);
}
 
function partsOf(ms: number, calendar: CalendarBasis): Ymd {
  const date = new Date(ms);
  if (calendar === 'gregorian') {
    return [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()];
  }
  const parts = HIJRI.formatToParts(date);
  const pick = (type: string) => Number(parts.find((p) => p.type === type)?.value);
  return [pick('year'), pick('month'), pick('day')];
}
 
const compare = (a: Ymd, b: Ymd) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
 
/** The latest day whose date is on or before `target`. `guess` only needs to be close. */
function lastDayOnOrBefore(target: Ymd, calendar: CalendarBasis, guess: number): number {
  let t = guess;
  while (compare(partsOf(t + DAY_MS, calendar), target) <= 0) t += DAY_MS;
  while (compare(partsOf(t, calendar), target) > 0) t -= DAY_MS;
  return t;
}
 
const YEAR_DAYS: Record<CalendarBasis, number> = { gregorian: 365, hijri: 354 };
 
export function openSickYear(firstSickDay: string, calendar: CalendarBasis): SickYear {
  const start = toUTC(firstSickDay);
  const [y, m, d] = partsOf(start, calendar);
  // The day before the same date one year on. Day 0 sorts before day 1, so a
  // window opened on the 1st closes on the last day of the previous month,
  // and a date the closing month lacks settles on that month's last day.
  const guess = start + (YEAR_DAYS[calendar] - 1) * DAY_MS;
  const end = lastDayOnOrBefore([y + 1, m, d - 1], calendar, guess);
  return { start: firstSickDay, end: toISO(end), calendar };
}
 
export function isWithin(year: SickYear, day: string): boolean {
  const t = toUTC(day);
  return t >= toUTC(year.start) && t <= toUTC(year.end);
}

Trois détails à fixer. Travaillez partout en minuits UTC — un moteur de paie qui respecte le fuseau local décalera silencieusement un jour de bordure lors d'un déménagement de serveur, et un jour de maladie qui tombe un jour plus tôt peut basculer toute une période dans une autre tranche. Construisez la fenêtre à partir des dates du calendrier, jamais en ajoutant 365 ou 354 jours, pour que ni une année bissextile ni un mois hégirien de trente jours ne rogne un jour de droit à quelqu'un. Et Intl.DateTimeFormat avec islamic-umalqura vous donne Umm al-Qura, le calendrier officiel saoudien, à partir des données ICU livrées avec Node 20 — sans bibliothèque de dates. Nous avons comparé chaque fenêtre ouverte entre 2024 et mi-2028 à une implémentation indépendante d'Umm al-Qura, et elles concordent. À partir de l'an 1451 de l'hégire (août 2029), les deux tables de mois commencent à diverger : figez votre version d'ICU et vérifiez les fenêtres lointaines contre le calendrier officiel.

Étape 4 : consommer l'échelle jour par jour

L'échelle de l'article 117 s'applique « que les congés soient continus ou fractionnés ». L'unité de compte est donc le jour, et la tranche dans laquelle tombe un jour donné ne dépend que du nombre de jours de maladie déjà consommés dans la fenêtre en cours.

export type Tier = {
  /** Cumulative day number, inclusive, at which this tier ends. */
  throughDay: number;
  numerator: number;
  denominator: number;
  label: string;
};
 
/** Article 117: 30 days full, the next 60 at three quarters, the next 30 unpaid. */
export const ARTICLE_117: readonly Tier[] = [
  { throughDay: 30, numerator: 1, denominator: 1, label: 'full pay' },
  { throughDay: 90, numerator: 3, denominator: 4, label: 'three quarters' },
  { throughDay: 120, numerator: 0, denominator: 1, label: 'unpaid' },
];
 
/**
 * Split `days` new sick days, starting after `alreadyUsed` days consumed in
 * this window, into per-tier slices. Days beyond 120 fall outside the
 * entitlement entirely and are returned separately.
 */
export function splitAcrossTiers(
  alreadyUsed: number,
  days: number,
): { slices: { tier: Tier; days: number }[]; beyondEntitlement: number } {
  const slices: { tier: Tier; days: number }[] = [];
  let cursor = alreadyUsed;
  let remaining = days;
 
  for (const tier of ARTICLE_117) {
    if (remaining <= 0) break;
    const roomInTier = tier.throughDay - cursor;
    if (roomInTier <= 0) continue;
    const take = Math.min(roomInTier, remaining);
    slices.push({ tier, days: take });
    cursor += take;
    remaining -= take;
  }
 
  return { slices, beyondEntitlement: remaining };
}

Valorisez maintenant les tranches. La décision d'arrondi compte plus qu'il n'y paraît : les trois quarts d'un taux journalier tombent rarement sur un nombre entier de halalas, et arrondir chaque jour accumule une dérive de plusieurs riyals sur une période de soixante jours. Multipliez d'abord, arrondissez une seule fois par tranche.

export type PaySlice = {
  tier: string;
  days: number;
  amount: Halalas;
};
 
export function priceSlices(
  slices: { tier: Tier; days: number }[],
  rate: number,
): PaySlice[] {
  return slices.map(({ tier, days }) => ({
    tier: tier.label,
    days,
    // Multiply across the whole slice, then round once. Rounding per day
    // drifts by several riyals over a 60-day spell.
    amount: Math.round((rate * days * tier.numerator) / tier.denominator),
  }));
}

Les jours au-delà du cent vingtième ne sont pas un « congé maladie non payé » — ils sortent entièrement du droit ouvert par l'article 117. Traitez-les comme une absence non autorisée, sauf si les parties conviennent d'un congé sans solde au titre de l'article 116, qui exige l'accord de l'employeur et constitue un enregistrement différent aux conséquences différentes. Ne laissez pas le moteur gommer la distinction : renvoyez beyondEntitlement et laissez l'appelant décider.

Étape 5 : un accident du travail n'est pas un congé maladie

Un salarié blessé au travail n'est pas en congé au titre de l'article 117. Les accidents du travail relèvent de la branche des risques professionnels de la loi sur l'assurance sociale, où l'indemnisation prend la forme d'une allocation journalière financée par le régime d'assurance et non d'un jour de maladie payé par l'employeur, à un taux fixé par la réglementation de l'assurance.

Deux conséquences en découlent, et les systèmes de paie se trompent régulièrement sur les deux. Ces jours ne doivent pas consommer l'échelle de l'article 117 — un salarié qui passe quarante jours à se remettre d'un accident du travail conserve l'intégralité de ses trente jours à plein salaire s'il tombe malade plus tard. Et ces jours ne doivent pas être payés deux fois, une fois en congé maladie par la paie et une fois en allocation via la déclaration d'accident.

export type AbsenceCause = 'illness' | 'occupational-injury';
 
export type Routed =
  | { route: 'article-117'; days: string[] }
  | { route: 'occupational-hazards'; days: string[]; note: string };
 
export function routeByCause(cause: AbsenceCause, days: string[]): Routed {
  if (cause === 'occupational-injury') {
    return {
      route: 'occupational-hazards',
      days,
      note:
        'Compensated through the occupational hazards branch of social insurance. ' +
        'Does NOT consume Article 117 balance and must not be paid as sick leave.',
    };
  }
  return { route: 'article-117', days };
}

La classification est un problème de données avant d'être un problème de code. Si vos absences portent un motif en texte libre, vous ne pouvez pas router de façon fiable. Ajoutez la cause comme champ contraint au point de saisie, exigez la référence de la déclaration d'accident pour tout ce qui est classé professionnel, et rapprochez chaque mois les jours routés de vos déclarations d'assurance. Le moteur de cotisations GOSI traite déjà le versant cotisations de cette relation ; ceci en est le versant prestations, sur la même fiche salarié.

Étape 6 : pas de certificat vérifié, pas de jour de maladie payé

Le droit ouvert par l'article 117 appartient au salarié « qui justifie sa maladie ». La preuve est un rapport médical émis par un organisme reconnu, et en Arabie saoudite ces rapports sont émis et vérifiés par voie numérique — les congés maladie certifiés d'un salarié apparaissent dans la plateforme de santé Sehhaty et portent un code de service que l'employeur peut vérifier auprès de la plateforme nationale des services de santé.

Cela donne à la paie un contrôle qu'elle devrait réellement appliquer : un jour sans code vérifiable n'est pas un jour de maladie.

export type Certificate = {
  /** The sick leave service code issued with the certificate. */
  code: string;
  from: string;
  to: string;
  cause: AbsenceCause;
  /** Set only after checking the code against the issuing platform. */
  verified: boolean;
  verifiedAt?: string;
};
 
export class UnverifiedCertificateError extends Error {
  constructor(code: string) {
    super(
      `Sick leave certificate ${code} has not been verified. ` +
        `Days covered by it cannot be paid under Article 117.`,
    );
    this.name = 'UnverifiedCertificateError';
  }
}
 
export function assertVerified(cert: Certificate): void {
  if (!cert.verified) throw new UnverifiedCertificateError(cert.code);
}

Stockez verifiedAt et l'identité de celui qui a effectué la vérification. Lorsqu'un solde est contesté deux ans plus tard, « nous avons vérifié le code à cette date » est une défense ; « le manager a dit que c'était bon » ne l'est pas.

Résistez à la tentation de vérifier automatiquement en scrapant un portail. La vérification est une action délibérée et tracée, menée par un humain ou un compte de service contre un point de terminaison officiel, et sa place est derrière votre couche d'intégration, pas dans le calcul du salaire.

Étape 7 : exposer le garde-fou de licenciement

L'article 82 interdit à l'employeur de mettre fin au contrat pour cause de maladie avant que le salarié ait épuisé les durées de congé prévues par la loi — les cent vingt jours, y compris les trente non payés. La protection ne figure pas dans le même article que l'échelle, et c'est la règle la plus susceptible de transformer une sortie de routine en contentieux. Un licenciement qui la viole s'indemnise au titre de l'article 77 ; le moteur de solde de rupture calcule ce volet.

Un moteur qui ne renvoie que de l'argent ne suffit pas ici. Renvoyez les jours de protection restants comme un champ de premier ordre, et faites-le lire par votre processus de sortie.

export type Protection = {
  daysUsed: number;
  daysRemaining: number;
  /** Article 82: true while the statutory sick-leave periods are not yet exhausted. */
  protected: boolean;
  /** Null until a first sick day opens a window. */
  windowEnds: string | null;
};
 
const ENTITLEMENT_DAYS = 120;
 
export function protectionStatus(year: SickYear | null, daysUsed: number): Protection {
  const daysRemaining = Math.max(0, ENTITLEMENT_DAYS - daysUsed);
  return {
    daysUsed,
    daysRemaining,
    protected: daysRemaining > 0,
    windowEnds: year === null ? null : year.end,
  };
}

L'article 82 porte une seconde règle qui mérite d'apparaître dans le même objet : le salarié peut demander à rattacher son congé annuel à son congé maladie. Lorsque la demande est accordée, les jours rattachés sont des jours de congé annuel — payés à plein tarif, prélevés sur le solde annuel, et ne consommant pas les tranches de l'article 117. Modélisez-les comme des enregistrements de congé annuel pour qu'ils traversent le moteur d'acquisition, et laissez le moteur de congé maladie n'y voir qu'un trou dans la séquence des jours de maladie. Deux moteurs, un registre, aucun double comptage. Le chevauchement inverse relève de l'article 26 du règlement d'application : des jours de maladie survenant pendant un congé annuel le suspendent, et le reste du congé annuel reprend à la fin du congé maladie. Le moteur annuel doit restituer ces jours.

Étape 8 : assembler la ligne de bulletin et le registre

Tout ce qui précède se compose en un point d'entrée unique.

export type Employee = {
  id: string;
  regime: Regime;
  wage: WageComponents;
  wageBase: WageBase;
  /** Required, no default: Hijri unless the contract says otherwise (Article 10). */
  calendar: CalendarBasis;
};
 
export type SickLeaveResult = {
  employeeId: string;
  period: { from: string; to: string };
  /** The window open at the end of the period. Persist it with `daysUsed`. */
  year: SickYear | null;
  daysUsed: number;
  slices: (PaySlice & { windowStart: string })[];
  total: Halalas;
  beyondEntitlement: number;
  routedToInsurance: string[];
  protection: Protection;
};
 
export function computeSickLeavePay(
  employee: Employee,
  certificates: Certificate[],
  period: { from: string; to: string },
  priorDaysUsed: number,
  openWindow: SickYear | null,
): SickLeaveResult {
  assertLabourLaw(employee.regime);
  certificates.forEach(assertVerified);
 
  const illness = new Set<string>();
  const injury = new Set<string>();
 
  for (const cert of certificates) {
    const days = expandDays(cert.from, cert.to).filter(
      (d) => d >= period.from && d <= period.to,
    );
    const routed = routeByCause(cert.cause, days);
    const target = routed.route === 'occupational-hazards' ? injury : illness;
    routed.days.forEach((d) => target.add(d));
  }
 
  // Walk the sick days in order. A day outside the current window opens a
  // new one; the days before it stay priced in the window they fell in.
  let year = openWindow;
  let used = openWindow === null ? 0 : priorDaysUsed;
  const segments: { year: SickYear; used: number; days: number }[] = [];
 
  for (const day of [...illness].sort()) {
    if (year === null || !isWithin(year, day)) {
      year = openSickYear(day, employee.calendar);
      used = 0;
      segments.push({ year, used, days: 0 });
    } else if (segments.length === 0) {
      segments.push({ year, used, days: 0 });
    }
    segments[segments.length - 1].days += 1;
    used += 1;
  }
 
  const rate = dailyRate(employee.wage, employee.wageBase);
  const slices: (PaySlice & { windowStart: string })[] = [];
  let beyondEntitlement = 0;
 
  for (const seg of segments) {
    const split = splitAcrossTiers(seg.used, seg.days);
    for (const s of priceSlices(split.slices, rate)) {
      slices.push({ ...s, windowStart: seg.year.start });
    }
    beyondEntitlement += split.beyondEntitlement;
  }
 
  return {
    employeeId: employee.id,
    period,
    year,
    daysUsed: used,
    slices,
    total: slices.reduce((sum, s) => sum + s.amount, 0),
    beyondEntitlement,
    routedToInsurance: [...injury].sort(),
    protection: protectionStatus(year, used),
  };
}
 
function expandDays(from: string, to: string): string[] {
  const out: string[] = [];
  for (let t = toUTC(from); t <= toUTC(to); t += DAY_MS) out.push(toISO(t));
  return out;
}

Trois comportements de cette boucle sont voulus. Un certificat à cheval sur une bordure de fenêtre est valorisé des deux côtés : les jours d'avant la bordure terminent l'échelle de l'ancienne fenêtre, et ceux d'après ouvrent la nouvelle. Deux certificats qui se chevauchent ne comptent qu'une fois le jour commun. Et une période de paie sans jour de maladie n'ouvre aucune fenêtre, puisque seul un jour de maladie en ouvre une. Ouvrir une fenêtre au premier jour de la période décalerait le début de la prochaine vraie fenêtre à une mauvaise date.

Persistez le résultat, pas seulement le total. Les bornes de la fenêtre, la répartition par tranche, les codes de certificat et les jours routés vers l'assurance sont ce qui vous permettra de répondre à une question dans dix-huit mois sans tout recalculer contre des règles qui auront peut-être changé.

Tester votre implémentation

Les cas ci-dessous sont ceux qui attrapent de vrais bugs. Écrivez-les avant de faire confiance au module.

import { describe, expect, it } from 'vitest';
 
const wage: WageComponents = {
  basic: 800_000,      // 8,000 SAR
  housing: 200_000,    // 2,000 SAR
  transport: 50_000,   //   500 SAR
  otherRegular: 0,
  variable: { earned: 0, workedDays: 0 },
};
 
// 36,000 SAR of commission earned over 240 days actually worked.
const withCommission: WageComponents = {
  ...wage,
  variable: { earned: 3_600_000, workedDays: 240 },
};
 
const employee: Employee = {
  id: 'E-1',
  regime: 'labour-law',
  wage,
  wageBase: 'actual',
  calendar: 'gregorian',
};
 
const sick = (from: string, to: string): Certificate => ({
  code: `GSL-${from}`,
  from,
  to,
  cause: 'illness',
  verified: true,
  verifiedAt: '2026-01-01',
});
 
describe('Article 117 sick leave', () => {
  it('builds the fixed part of the actual wage from basic plus allowances', () => {
    expect(fixedMonthlyWage(wage, 'actual')).toBe(1_050_000);
    expect(fixedMonthlyWage(wage, 'basic')).toBe(800_000);
  });
 
  it('prices the month at 30 days regardless of the calendar', () => {
    // 10,500 SAR over 30 days = 350 SAR per day.
    expect(dailyRate(wage, 'actual')).toBe(35_000);
  });
 
  it('adds commission as a daily average over days actually worked', () => {
    // 350 SAR fixed + 36,000 / 240 = 150 SAR variable = 500 SAR per day.
    expect(dailyRate(withCommission, 'actual')).toBe(50_000);
    expect(dailyRate(withCommission, 'basic')).toBe(800_000 / 30);
  });
 
  it('refuses commission with no worked days to divide by', () => {
    const broken: WageComponents = { ...wage, variable: { earned: 100_000, workedDays: 0 } };
    expect(() => dailyRate(broken, 'actual')).toThrow(RangeError);
  });
 
  it('splits a 100-day spell across all three tiers', () => {
    const { slices, beyondEntitlement } = splitAcrossTiers(0, 100);
    expect(slices.map((s) => s.days)).toEqual([30, 60, 10]);
    expect(beyondEntitlement).toBe(0);
  });
 
  it('carries the tier cursor across intermittent spells', () => {
    // 25 days used in December, 25 more in January of the same sick year.
    const { slices } = splitAcrossTiers(25, 25);
    expect(slices.map((s) => [s.tier.label, s.days])).toEqual([
      ['full pay', 5],
      ['three quarters', 20],
    ]);
  });
 
  it('reports days beyond the 120-day entitlement separately', () => {
    const { beyondEntitlement } = splitAcrossTiers(115, 20);
    expect(beyondEntitlement).toBe(15);
  });
 
  it('closes a Hijri window one Hijri year after the first sick day', () => {
    // 21 Ramadan 1447 opens it; 20 Ramadan 1448 closes it.
    expect(openSickYear('2026-03-10', 'hijri')).toEqual({
      start: '2026-03-10',
      end: '2027-02-27',
      calendar: 'hijri',
    });
  });
 
  it('closes a Gregorian window one Gregorian year after the first sick day', () => {
    expect(openSickYear('2026-03-10', 'gregorian').end).toBe('2027-03-09');
  });
 
  it('handles a Gregorian leap year without losing a day', () => {
    expect(openSickYear('2027-03-01', 'gregorian').end).toBe('2028-02-29');
  });
 
  it('opens a new Hijri window where a Gregorian one would still be running', () => {
    const hijri = openSickYear('2026-03-10', 'hijri');
    const gregorian = openSickYear('2026-03-10', 'gregorian');
    expect(isWithin(hijri, '2027-03-01')).toBe(false);
    expect(isWithin(gregorian, '2027-03-01')).toBe(true);
  });
 
  it('prices days on both sides of a window boundary inside one period', () => {
    const openWindow = openSickYear('2025-06-01', 'gregorian'); // closes 2026-05-31
    const result = computeSickLeavePay(
      employee,
      [sick('2026-05-25', '2026-06-10')],
      { from: '2026-05-01', to: '2026-06-30' },
      20,
      openWindow,
    );
    // 7 days close the old window (days 21-27), 10 open the new one.
    expect(result.slices.map((s) => [s.windowStart, s.days])).toEqual([
      ['2025-06-01', 7],
      ['2026-06-01', 10],
    ]);
    expect(result.total).toBe(17 * 35_000);
    expect(result.year).toEqual(openSickYear('2026-06-01', 'gregorian'));
    expect(result.daysUsed).toBe(10);
  });
 
  it('counts a day covered by two certificates once', () => {
    const result = computeSickLeavePay(
      employee,
      [sick('2026-05-01', '2026-05-05'), sick('2026-05-04', '2026-05-06')],
      { from: '2026-05-01', to: '2026-05-31' },
      0,
      null,
    );
    expect(result.daysUsed).toBe(6);
  });
 
  it('opens no window for a period without sickness', () => {
    const result = computeSickLeavePay(employee, [], { from: '2026-05-01', to: '2026-05-31' }, 0, null);
    expect(result.year).toBeNull();
    expect(result.protection).toEqual({
      daysUsed: 0,
      daysRemaining: 120,
      protected: true,
      windowEnds: null,
    });
  });
 
  it('keeps the Article 82 protection until all 120 days are used', () => {
    const year = openSickYear('2026-03-10', 'hijri');
    expect(protectionStatus(year, 119).protected).toBe(true);
    expect(protectionStatus(year, 120).protected).toBe(false);
  });
 
  it('refuses to compute for a civil-service employee', () => {
    expect(() => assertLabourLaw('civil-service')).toThrow(RegimeNotSupportedError);
  });
 
  it('refuses to pay an unverified certificate', () => {
    const cert: Certificate = { ...sick('2026-05-01', '2026-05-05'), verified: false };
    expect(() => assertVerified(cert)).toThrow(UnverifiedCertificateError);
  });
 
  it('does not consume Article 117 balance for an occupational injury', () => {
    const injured: Certificate = { ...sick('2026-06-01', '2026-06-02'), cause: 'occupational-injury' };
    const result = computeSickLeavePay(
      employee,
      [injured],
      { from: '2026-06-01', to: '2026-06-30' },
      0,
      null,
    );
    expect(result.routedToInsurance).toEqual(['2026-06-01', '2026-06-02']);
    expect(result.daysUsed).toBe(0);
    expect(result.total).toBe(0);
  });
 
  it('rounds once per slice, not once per day', () => {
    // A daily rate of 333.33 SAR at three quarters over 60 days.
    const odd: WageComponents = { ...wage, housing: 0, transport: 0, basic: 999_990 };
    const rate = dailyRate(odd, 'actual');            // 33_333 halalas exactly
    const [slice] = priceSlices([{ tier: ARTICLE_117[1], days: 60 }], rate);
    expect(slice.amount).toBe(Math.round(rate * 60 * 0.75));
  });
});

Le test des périodes fractionnées est celui qui mérite d'être passé sur des données réelles. Exportez une année de vos propres absences, faites-la traverser splitAcrossTiers avec un curseur reporté, et comparez à ce que votre système actuel a payé. L'écart, s'il y en a un, se loge presque toujours en décembre et en janvier.

Dépannage

Tous les salariés atterrissent dans la tranche à plein salaire. Votre curseur se réinitialise. Soit vous passez priorDaysUsed à zéro à chaque exécution de paie, soit vous ouvrez une année de maladie par année civile. La fenêtre persiste d'une exécution à l'autre — stockez-la sur le salarié, pas dans la requête.

Le solde diverge de quelques riyals d'un calcul manuel. Presque toujours l'arrondi quotidien. Multipliez sur la tranche et arrondissez une seule fois, et vérifiez que vous divisez le salaire mensuel par trente et non par le nombre de jours du mois calendaire.

Le salaire de maladie paraît inférieur d'environ trente pour cent partout. Vous êtes sur le salaire de base au lieu du salaire réel. Vérifiez wageBase et confirmez que le logement et le transport alimentent bien fixedMonthlyWage.

Les commerciaux commissionnés sont sous-payés, pas les salariés au fixe. Les commissions manquent au salaire. Renseignez dans variable les commissions de la période de référence et les jours effectivement travaillés.

Une nouvelle année de maladie s'ouvre environ onze jours avant ce qu'attendent les RH. Les deux ont raison, chacun dans son calendrier. Le moteur compte en hégirien parce que le champ calendar du salarié le demande. Si le contrat prévoit réellement le grégorien, corrigez la fiche salarié plutôt que le moteur.

Des jours de maladie disparaissent le mois où la fenêtre se renouvelle. Vous utilisez une ancienne version du point d'entrée, qui perdait les jours d'avant la bordure. Le résultat doit porter un jeu de tranches par fenêtre, chacune marquée par windowStart.

Un salarié affiche plus de 120 jours consommés. Deux causes probables : des jours d'accident du travail enregistrés comme maladie, ou des jours de congé annuel rattachés comptés deux fois. Vérifiez d'abord le routage — c'est la plus fréquente des deux.

La frontière de tranche bouge selon l'heure d'exécution du job. Fuseau horaire. Chaque date de ce module est un minuit UTC ; un new Date(iso) interprété en heure locale quelque part dans votre chaîne décalera les jours de bordure.

La vérification passe pour des certificats jamais contrôlés. Quelqu'un met verified à true par défaut. Rendez le champ obligatoire sans valeur par défaut, et stockez verifiedAt pour qu'un enregistrement non vérifié soit visiblement incomplet plutôt que discrètement permissif.

Prochaines étapes

Conclusion

Le congé maladie a l'air d'être la ligne la plus simple de la paie saoudienne, et c'est l'une des plus régulièrement fausses. La formule tient en une phrase, mais cette phrase cache quatre décisions que votre code doit prendre explicitement : quel régime régit le salarié, à quel salaire renvoie le mot « salaire », quand a réellement commencé l'année qui porte les tranches et dans quel calendrier elle court, et si l'absence relève seulement de l'article 117. Réglez ces quatre points et l'arithmétique devient triviale. Trompez-vous sur un seul et l'erreur devient systématique — le même chiffre faux, tous les mois, pour tous les salariés concernés, jusqu'à ce que quelqu'un parte et compte.

Si les chiffres de congé maladie de votre SIRH ne correspondent pas à ceux de votre paie, ou si personne ne peut vous dire quelle base salariale utilise le calcul actuel, dites-nous à quoi ressemble votre système — nous ferons passer une année de vos absences réelles dans un moteur comme celui-ci et vous montrerons où les deux divergent, avant qu'un contentieux prud'homal ne s'en charge.