écrits/tutorial/2026/08
● Tutorial18 août 2026·30 min

Indemnité de fin de service saoudienne en TypeScript (art. 84/85)

Construisez un moteur de calcul de l'indemnité de fin de service pour la paie saoudienne en TypeScript, en implémentant les articles 84, 85, 87 et 88 du Code du travail — le barème à deux tranches, l'échelle de démission, l'ancienneté comptée en mois de trente jours sur le calendrier du contrat, hégirien ou grégorien, et la provision mensuelle qui transforme l'indemnité en charge anticipée plutôt qu'en surprise, et la continuité de l'ancienneté quand l'employeur change (article 18, décision 318 du Conseil des ministres).

Tout système de paie opérant en Arabie saoudite doit répondre correctement à une seule question : lorsque ce salarié franchit la porte pour la dernière fois, que lui devons-nous ?

L'article 84 du Code du travail fait de l'indemnité de fin de service — مكافأة نهاية الخدمة, souvent abrégée EOSB — une obligation légale pesant sur l'employeur, et non un avantage que l'entreprise accorde. L'article 88 vous laisse une semaine pour la payer. Et l'article 85 fait discrètement du même salarié, à salaire égal et à ancienneté égale, trois montants totalement différents selon qui a mis fin au contrat.

La plupart des implémentations internes se trompent sur l'un de ces quatre points : elles calculent l'ancienneté en divisant les jours par 365 au lieu de la compter en mois de trente jours sur le calendrier du contrat, elles appliquent le taux de la mauvaise tranche, elles retiennent le salaire de base là où la loi dit salaire, ou elles traitent l'indemnité comme un événement de paiement au lieu d'un passif qui s'accumule chaque mois depuis le premier jour du salarié. Chacune de ces erreurs produit un chiffre qui survit à la revue interne et échoue devant le tribunal du travail.

Ce tutoriel construit le calcul correctement en TypeScript — l'arithmétique, les cas limites légaux, les tests qui figent chacun d'eux, et la provision mensuelle qui transforme l'ensemble en une charge que votre direction financière peut voir venir.

Ceci est un guide d'ingénierie, pas un conseil juridique. Le texte légal fait foi, et un contrat peut toujours être plus généreux que le minimum. Lorsqu'un cas est réellement contesté — une rupture inhabituelle, une définition du salaire discutée — faites trancher les données d'entrée par un avocat en droit du travail saoudien avant de les coder.

Corrigé le 18 septembre 2026. Les versions précédentes de ce tutoriel mesuraient l'ancienneté en anniversaires, traitaient la date de fin comme le lendemain de la fin de service et supposaient un contrat grégorien. Confronté au calculateur du travail du ministère de la Justice, ce calcul divergeait sur chacun des 45 cas testés, et il sous-évaluait d'environ trois pour cent l'ancienneté d'un contrat hégirien. L'étape 3 suit désormais les articles 2 et 10 du Code du travail et retrouve le montant du ministère dans les 45 cas.

Mis à jour le 30 septembre 2026. L'étape 7 indiquait un préavis de trente jours au titre de l'article 75 sur un contrat à durée indéterminée payé au mois. Depuis l'entrée en vigueur des amendements M/44 le 19 février 2025, ce n'est vrai que lorsque le salarié met fin au contrat ; l'employeur qui paie un salaire mensuel doit soixante jours. L'étape 9 est nouvelle : elle maintient l'ancienneté continue quand l'employeur change, en application de l'article 18 du Code du travail et de la décision 318 du Conseil des ministres.

Prérequis

Avant de commencer, assurez-vous de disposer de :

  • Node.js 20 ou plus récent, et TypeScript 5.x
  • Une familiarité avec l'arithmétique entière et les raisons pour lesquelles le flottant est dangereux pour les montants
  • Un SIRH ou un système de paie exposant les dates d'entrée et une définition du salaire interrogeable
  • Le texte en vigueur du Code du travail saoudien sous les yeux — le ministère des Ressources humaines et du Développement social (HRSD) le publie, et le centre de connaissances de Qiwa en propose une version lisible des mêmes articles

Ce que vous allez construire

Une fonction pure, endOfService, qui prend une date d'entrée, une date de sortie, le dernier salaire et le motif de la rupture, et retourne une décomposition complète : jours d'ancienneté, mois de salaire acquis au titre de l'article 84, indemnité intégrale, quote-part de l'article 85 et montant réellement dû. Plus une fonction de provision mensuelle qui répond à la question financière : où en est ce passif aujourd'hui, avant que quiconque ait démissionné. Et une répartition pour le salarié dont l'employeur a changé en cours de route, qui dit qui paie quelle part.

Aucune dépendance, aucune bibliothèque de dates, aucun flottant.

Étape 1 : lire le texte avant d'écrire la formule

Quatre articles portent tout le calcul. Il vaut la peine d'être précis sur chacun, car les bugs d'implémentation courants sont tous des erreurs de lecture plutôt que des erreurs de code.

Article 84 — l'indemnité elle-même. Un demi-mois de salaire pour chacune des cinq premières années d'ancienneté, et un mois entier pour chaque année au-delà. Les fractions d'année sont payées au prorata.

Le piège est que les tranches sont cumulatives, non sélectives. Sept années d'ancienneté ne font pas sept mois, et ne font pas trois mois et demi. Elles font cinq années à un demi-mois, plus deux années à un mois entier : 2,5 plus 2, soit 4,5 mois.

Article 85 — la réduction pour démission. Lorsque c'est le salarié qui rompt le contrat, l'indemnité est proratisée selon l'ancienneté :

Ancienneté accompliePart de l'indemnité de l'article 84
Moins de 2 ansrien
De 2 ans à moins de 5 ansun tiers
De 5 ans à moins de 10 ansdeux tiers
10 ans et plusl'intégralité

Lorsque c'est l'employeur qui rompt le contrat, l'indemnité intégrale est due quelle que soit l'ancienneté. C'est la principale source de litiges, et la raison pour laquelle le motif doit être une donnée d'entrée de la fonction plutôt qu'une valeur par défaut que quelqu'un a supposée.

Article 87 — les exceptions qui priment sur l'article 85. Le salarié qui part pour un cas de force majeure indépendant de sa volonté perçoit l'indemnité intégrale quelle que soit son ancienneté. De même pour la salariée qui rompt le contrat dans les six mois de son mariage ou dans les trois mois de son accouchement. Ce ne sont pas des cas limites que l'on peut différer — ils sont assez fréquents dans les effectifs saoudiens pour mériter un indicateur explicite plutôt qu'une correction manuelle.

Article 88 — le délai. Lorsque l'employeur met fin à la relation, l'ensemble des droits est soldé dans la semaine suivant la sortie. Lorsque le salarié démissionne, dans les deux semaines. Un calcul qui demande dix jours à votre équipe financière est déjà non conforme sur le premier chemin, ce qui est l'argument pratique en faveur de son automatisation.

Une définition supplémentaire compte. L'article 84 se règle sur le salaire, et l'article des définitions du Code du travail distingue le salaire de base du salaire réel — le base augmenté des indemnités et primes versées régulièrement. Les indemnités de logement et de transport sont celles qui déplacent le chiffre. Retenir le seul salaire de base sous-évalue l'indemnité, parfois de trente pour cent ou plus, et c'est l'erreur qui transforme le plus souvent un départ de routine en contentieux.

Étape 2 : conserver les montants en halalas, jamais en flottants

0.1 + 0.2 ne vaut pas 0.3, et une indemnité est une chaîne de multiplications appliquée à un salaire. Travaillez en unités mineures entières — les halalas — et n'arrondissez qu'une seule fois, au moment de la sortie.

/** 100 halalas to the riyal. */
export const MINOR_PER = 100;
 
/** Divide and round half-away-from-zero, staying in integers. */
export function divRound(numerator: number, denominator: number): number {
  const sign = numerator < 0 ? -1 : 1;
  return sign * Math.round(Math.abs(numerator) / denominator);
}
 
/** '10000' or '10,000.50' to whole halalas. */
export function parseAmount(input: string): number {
  const cleaned = String(input).replace(/[,\s]/g, '');
  if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) throw new TypeError('Not an amount');
  const [whole, frac = ''] = cleaned.split('.');
  return Number(whole) * MINOR_PER + Number(frac.padEnd(2, '0'));
}
 
export function formatMinor(minor: number): string {
  const whole = Math.trunc(minor / MINOR_PER);
  const frac = String(Math.abs(minor % MINOR_PER)).padStart(2, '0');
  return `${whole}.${frac}`;
}

La règle qui en découle : dérivez les totaux des taux non arrondis, jamais de valeurs intermédiaires arrondies. Si vous arrondissez un taux d'un demi-mois puis le multipliez par cinq années, vous avez encaissé l'erreur d'arrondi cinq fois.

Étape 3 : mesurer l'ancienneté comme le ministère

Deux définitions régissent l'ancienneté avant même l'article 84. L'article 2 définit le mois comme trente jours, sauf disposition contraire du contrat de travail ou du règlement intérieur. L'article 10 compte toutes les durées du Code du travail selon le calendrier hégirien, avec la même exception.

Le calculateur du travail du ministère de la Justice (الحاسبة العمالية) applique les deux. Il demande d'abord le type de date, hégirien ou grégorien, avec une note : les dates grégoriennes valent pour les contrats grégoriens et les dates hégiriennes pour les contrats hégiriens, parce que les droits diffèrent. Il compte ensuite l'ancienneté en mois de trente jours sur une année de 360 jours, et il compte le dernier jour travaillé.

Nous avons confronté notre moteur à ce calculateur le 18 septembre 2026 sur 45 paires de dates : licenciements et démissions, débuts un 29, un 30 ou un 31, années bissextiles. La règle ci-dessous reproduit chacun de ces montants au halala près. Trois conséquences sont utiles à connaître avant de lire le code :

  • Le dernier jour travaillé compte. Du 1er janvier 2019 au 31 décembre 2023, il y a exactement cinq ans, soit 25 000,00 sur un salaire de 10 000. Si le dernier jour travaillé est le 1er janvier 2024, on ajoute un jour : 25 027,78.
  • Février compte trente jours. Du 1er février au 1er mars, il y a un mois et un jour, soit 430,56. Le 29 février ne joue plus aucun rôle, puisque les jours ne sont jamais divisés par la longueur réelle de l'année.
  • Le calendrier change le résultat. Pour un contrat hégirien, les mêmes dates représentent 5 années hégiriennes, 1 mois et 23 jours, car l'année hégirienne est plus courte d'environ onze jours. Le ministère chiffre ce cas à 26 472,22, soit 1 472,22 de plus que pour le contrat grégorien.

La version précédente de cette étape comptait des anniversaires et divisait l'année en cours par son nombre de jours. Les écarts étaient faibles, 49,89 riyals au plus sur un salaire de 10 000, mais ils apparaissaient dans tous les cas, et c'est la première chose que vérifie un auditeur.

const MS_PER_DAY = 86_400_000;
 
/** Article 10: periods run on the Hijri calendar unless the contract or the work regulation says otherwise. */
export type CalendarBasis = 'gregorian' | 'hijri';
 
/** A date as the calendar in force writes it: [year, month, day]. */
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 partsOf(date: Date, calendar: CalendarBasis): Ymd {
  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];
 
function addDays(date: Date, days: number): Date {
  return new Date(date.getTime() + days * MS_PER_DAY);
}
 
/**
 * The latest day whose date is on or before `target`. A date the month does
 * not have (31 April, or the 30th of a 29-day Hijri month) settles on the
 * month's last day. `guess` only needs to be close; the walk corrects it.
 */
function onOrBefore(target: Ymd, calendar: CalendarBasis, guess: Date): Date {
  let day = guess;
  while (compare(partsOf(addDays(day, 1), calendar), target) <= 0) day = addDays(day, 1);
  while (compare(partsOf(day, calendar), target) > 0) day = addDays(day, -1);
  return day;
}
 
const MEAN_MONTH_DAYS: Record<CalendarBasis, number> = { gregorian: 30.44, hijri: 29.53 };
 
/**
 * Service in days of a 360-day year: whole months of thirty days (Art. 2),
 * counted in the contract's calendar (Art. 10), plus the days left over.
 * `lastDay` is the last day worked, and it counts.
 */
export function serviceDays360(start: Date, lastDay: Date, calendar: CalendarBasis): number {
  const from = partsOf(start, calendar);
  const after = addDays(lastDay, 1);
  const until = partsOf(after, calendar);
 
  // The same day-of-month, `n` months after the start.
  const monthAt = (n: number): Ymd => {
    const k = from[1] - 1 + n;
    return [from[0] + Math.floor(k / 12), (k % 12) + 1, from[2]];
  };
 
  let months = 0;
  while (compare(monthAt(months + 1), until) <= 0) months++;
 
  const guess = addDays(start, Math.round(months * MEAN_MONTH_DAYS[calendar]));
  const anchor = onOrBefore(monthAt(months), calendar, guess);
  const leftover = Math.round((after.getTime() - anchor.getTime()) / MS_PER_DAY);
 
  return months * 30 + leftover;
}

Les mois sont comptés à partir de la date de début, jusqu'au même quantième. Si ce quantième n'existe pas dans un mois donné (le 31 avril, ou le 30 d'un mois hégirien de 29 jours), ce mois n'est acquis qu'au début du mois suivant, ce qui correspond au traitement d'un début au 31 janvier par le ministère. Le reste, après le dernier mois complet, se compte en jours réels, chacun valant un trentième de mois.

Intl.DateTimeFormat avec le calendrier islamic-umalqura fournit Umm al-Qura, le calendrier officiel saoudien, que Node 20 embarque avec l'ICU complet. Aucune bibliothèque de dates n'est donc nécessaire. Les dates sont interprétées à minuit UTC, de sorte qu'un serveur à Riyad et un serveur à Francfort s'accordent sur le jour où l'ancienneté a pris fin, et sur le jour hégirien correspondant :

function parseDate(iso: string, label: string): Date {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso).trim());
  if (!m) throw new TypeError(`${label} must be yyyy-mm-dd`);
  const [, y, mo, d] = m;
  const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
  if (
    date.getUTCFullYear() !== Number(y) ||
    date.getUTCMonth() !== Number(mo) - 1 ||
    date.getUTCDate() !== Number(d)
  ) {
    throw new TypeError(`${label} is not a real date`);
  }
  return date;
}

Rejeter 2026-02-30 à la frontière vaut bien ces huit lignes. Les constructeurs de dates qui reportent silencieusement transforment une faute de frappe en paiement erroné.

Étape 4 : le barème à deux tranches de l'article 84

Avec l'ancienneté exprimée en jours d'une année de 360 jours, les deux tranches deviennent une répartition plutôt qu'un branchement :

const YEAR_DAYS = 360;
const STEP_YEARS = 5;
 
const serviceDays = serviceDays360(from, lastDay, calendar);
 
const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
 
// Half a month per year for the first five, a whole month thereafter.
// Held as half-months times 360 so nothing is divided until the end.
const halfMonths = atHalf + 2 * atFull;
const fullMinor = divRound(monthly * halfMonths, 2 * YEAR_DAYS);

halfMonths compte des demi-mois de salaire multipliés par 360, ce qui maintient tout le calcul en entiers jusqu'à la division finale. Vérifiez-le sur les deux cas que tout le monde connaît :

  • Exactement cinq ans, soit 1 800 jours : halfMonths vaut 1 800, soit deux mois et demi. Sur 10 000 riyals : 25 000,00.
  • Exactement sept ans, soit 2 520 jours : 1 800 plus deux fois 720 donnent 3 240, soit quatre mois et demi. Sur 10 000 riyals : 45 000,00.

La proratisation se manifeste sur une première année partielle. Six mois civils font 180 jours, soit un quart de mois de salaire : l'indemnité est de 2 500,00, que l'année soit bissextile ou non.

Étape 5 : article 85 — la même ancienneté, trois montants différents

La réduction s'applique à l'indemnité, pas au salaire, et uniquement en cas de démission :

export type EndReason = 'termination' | 'resignation';
 
/** Service thresholds, in years, at which a resignation's share steps up. */
export const RESIGNATION_STEPS = [2, 5, 10] as const;
 
let numerator = 1;
let denominator = 1;
 
if (reason === 'resignation') {
  const [twoY, fiveY, tenY] = RESIGNATION_STEPS.map((y) => y * YEAR_DAYS);
  if (serviceDays < twoY) [numerator, denominator] = [0, 1];
  else if (serviceDays < fiveY) [numerator, denominator] = [1, 3];
  else if (serviceDays < tenY) [numerator, denominator] = [2, 3];
}
 
// From the unrounded award, so the one-third share is not rounded twice.
const payableMinor = divRound(monthly * halfMonths * numerator, 2 * YEAR_DAYS * denominator);

Notez ce que le code se garde délibérément de faire : il ne met pas fullMinor à zéro pour une démission courte. L'indemnité a bien été acquise — l'article 85 régit ce qui est dû, non ce qui a été acquis. Cette distinction devient déterminante dès que le départ est requalifié, ce qui arrive dans environ un tiers des cas contentieux : un salarié enregistré comme démissionnaire s'avère avoir été licencié de fait, et le montant dû bascule à l'indemnité intégrale sans le moindre recalcul de l'ancienneté.

Conservez les deux montants dans le type de retour, pour que la requalification ne soit qu'un changement de champ :

export type EndOfServiceBreakdown = {
  /** Days of service in 360-day years: months of thirty, plus the rest. */
  serviceDays: number;
  serviceYears: number;
  /** Months of wage earned under Art. 84, before any Art. 85 reduction. */
  monthsOfWage: string;
  /** The Art. 84 award in full. */
  full: string;
  /** The Art. 85 share actually payable, as a readable fraction. */
  share: string;
  /** What is owed after the share is applied. */
  payable: string;
  fullMinor: number;
  payableMinor: number;
};

L'exemple concret à mettre sous les yeux d'un décideur : trois ans d'ancienneté sur un salaire de 10 000 riyals. Licencié, le salarié perçoit 15 000,00. Démissionnaire, le même salarié perçoit 5 000,00 — un tiers. Mêmes dates, même salaire, même indemnité acquise de 15 000,00. Si votre interface n'affiche que le montant dû, personne ne peut voir où sont passés les 10 000 restants, et cette opacité est ce qui engendre le litige.

Exposer share sous forme de fraction lisible plutôt que de décimale est un petit détail qui se rembourse en tickets de support. « 1/3 » correspond directement au vocabulaire de l'article 85 ; « 0,3333 » non.

Étape 6 : article 87 — les dérogations

Deux conditions rendent l'indemnité intégrale exigible même sur une démission que l'article 85 aurait réduite. Elles doivent être des données d'entrée, car aucun système ne peut les déduire :

export type EndReason = 'termination' | 'resignation';
 
export type EndOfServiceInput = {
  start: string;
  /** The last day worked. It counts: service runs up to and including it. */
  end: string;
  /** Last actual monthly wage — basic plus every regular allowance. */
  wage: string | number;
  /**
   * Art. 10 — Hijri unless the contract or the work regulation says
   * otherwise. Required: the answer is in a document this code cannot see.
   */
  calendar: CalendarBasis;
  reason?: EndReason;
  /**
   * Art. 87 — force majeure beyond the worker's control, or a female worker
   * ending the contract within six months of marriage or three months of
   * giving birth. Either pays the full award regardless of service.
   */
  fullAwardException?: boolean;
};

Dans le corps de la fonction, l'exception court-circuite entièrement l'échelle de l'article 85 :

if (reason === 'resignation' && !fullAwardException) {
  // ... the Article 85 steps
}

Un indicateur explicite accompagné d'un commentaire nommant l'article vaut mieux qu'une déduction astucieuse. Les RH savent si le départ tient à un mariage ; votre arithmétique des dates ne le saura jamais. Il en va de même pour calendar : le champ est obligatoire et n'a pas de valeur par défaut, car l'article 10 fait de l'hégirien la règle, alors que la plupart des contrats rédigés aujourd'hui stipulent le grégorien. Seul le contrat peut trancher : obligez l'appelant à le lire.

Une fois assemblée, la fonction complète :

/** Days in a service year: twelve months of thirty. */
const YEAR_DAYS = 360;
const STEP_YEARS = 5;
/** Service thresholds, in years, at which a resignation's share steps up. */
export const RESIGNATION_STEPS = [2, 5, 10] as const;
 
export function endOfService({
  start,
  end,
  wage,
  calendar,
  reason = 'termination',
  fullAwardException = false,
}: EndOfServiceInput): EndOfServiceBreakdown {
  const from = parseDate(start, 'start');
  const lastDay = parseDate(end, 'end');
  if (lastDay < from) throw new RangeError('end is before start');
  const monthly = parseAmount(String(wage));
 
  const serviceDays = serviceDays360(from, lastDay, calendar);
 
  // Half a month per year for the first five, a whole month thereafter.
  // Held as half-months times 360 so nothing is divided until the end.
  const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
  const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
  const halfMonths = atHalf + 2 * atFull;
 
  // Article 85 — resignation only, unless Article 87 applies.
  let numerator = 1;
  let denominator = 1;
  if (reason === 'resignation' && !fullAwardException) {
    const [twoY, fiveY, tenY] = RESIGNATION_STEPS.map((y) => y * YEAR_DAYS);
    if (serviceDays < twoY) [numerator, denominator] = [0, 1];
    else if (serviceDays < fiveY) [numerator, denominator] = [1, 3];
    else if (serviceDays < tenY) [numerator, denominator] = [2, 3];
  }
 
  const fullMinor = divRound(monthly * halfMonths, 2 * YEAR_DAYS);
  // From the unrounded award, so the one-third share is not rounded twice.
  const payableMinor = divRound(monthly * halfMonths * numerator, 2 * YEAR_DAYS * denominator);
  const monthsCenti = divRound(halfMonths * MINOR_PER, 2 * YEAR_DAYS);
 
  return {
    serviceDays,
    serviceYears: Math.floor(serviceDays / YEAR_DAYS),
    monthsOfWage: formatMinor(monthsCenti),
    full: formatMinor(fullMinor),
    share: denominator === 1 ? String(numerator) : `${numerator}/${denominator}`,
    payable: formatMinor(payableMinor),
    fullMinor,
    payableMinor,
  };
}

Étape 7 : l'indemnité n'est qu'une ligne du solde de tout compte

L'article 88 exige le règlement de tous les droits dans le délai, pas seulement de l'indemnité. Un moteur de production qui ne calcule que l'indemnité remet à la finance un chiffre sur lequel elle ne peut toujours pas payer. Les autres lignes légales :

  • Congés annuels non pris, articles 109 et 111. Vingt et un jours par an, portés à trente après cinq années continues. Les congés s'acquièrent réellement au jour le jour, contrairement à l'indemnité — une période chevauchant le cinquième anniversaire doit donc être scindée et acquise aux deux taux. Calculer toute la période à un taux unique est l'erreur standard ici, en sens inverse de celle de l'indemnité.
  • Indemnité de préavis, articles 75 et 76. Sur un contrat à durée indéterminée, la part du préavis non exécutée, payée au salaire réel. Le délai est de soixante jours lorsqu'un employeur qui paie un salaire mensuel met fin au contrat, et de trente dans tous les autres cas : quand le salarié y met fin, ou quand le salaire n'est pas mensuel. Si l'employeur dispense le salarié de travailler pendant le préavis, l'article 78 compte l'ancienneté comme continue jusqu'au terme du délai : le end passé à endOfService est le dernier jour du préavis, pas le dernier jour de présence. Le guide de l'article 75 détaille les cas.
  • Indemnité de licenciement abusif, article 77. Quinze jours de salaire par année d'ancienneté sur un contrat à durée indéterminée, ou le salaire de la période restant à courir sur un contrat à durée déterminée — et dans tous les cas au moins deux mois de salaire. Ce plancher gouverne pour toute personne ayant moins de quatre ans d'ancienneté, c'est-à-dire la majorité de ceux qui se renseignent.
  • Salaires impayés, qui constituent la première ligne du calculateur du travail du ministère de la Justice lui-même.

Modélisez chacun de ces postes comme une fonction pure distincte retournant le même type monétaire, puis additionnez au niveau de la couche de solde. Une fonction monolithique calculateFinalSettlement devient intestable en l'espace d'un sprint.

Notre calculateur des droits du travail saoudien donne une estimation rapide de ces postes, et le calculateur de solde de congés couvre le seul volet de l'article 109. Pour le dernier halala, et pour tout contrat hégirien, prenez le calculateur du ministère de la Justice comme référence.

Étape 8 : provisionner mensuellement, sinon ce n'est pas une provision

C'est l'étape qui sépare une fonctionnalité de paie d'un système financier, et c'est celle que la plupart des développements internes sautent.

L'indemnité de fin de service est un passif qui croît chaque mois où le salarié reste. Si elle n'est calculée qu'au moment du départ, l'entreprise découvre une obligation à six chiffres le jour même où elle devient exigible sous une semaine. Au sens d'IAS 19, il s'agit d'un avantage du personnel qui devrait déjà être provisionné.

Le moteur que vous avez construit vous l'offre gratuitement — appelez-le avec la date du jour au lieu d'une date de sortie :

/** The award accrued as of a date, for provisioning. */
export function accruedLiability(
  employee: { start: string; wage: string; calendar: CalendarBasis },
  asOf: string,
) {
  // Provision against the full Art. 84 award, not the Art. 85 share:
  // the reduction is contingent on how the relationship ends, which is
  // not known on the reporting date.
  return endOfService({
    start: employee.start,
    end: asOf,
    wage: employee.wage,
    calendar: employee.calendar,
    reason: 'termination',
  }).fullMinor;
}

Le commentaire porte le jugement professionnel. Provisionner sur le montant dû — en supposant que tout le monde démissionne — sous-évalue systématiquement le passif, car les licenciements et les fins de contrat paient l'intégralité. Provisionnez sur l'indemnité intégrale et laissez la réduction de l'article 85 être une reprise le jour où elle survient.

Étendez cela à l'effectif et vous obtenez une série mensuelle sur laquelle la finance peut réellement planifier :

export function provisionByMonth(
  employees: Array<{ id: string; start: string; wage: string; calendar: CalendarBasis }>,
  months: string[], // ['2026-01-31', '2026-02-28', ...]
) {
  return months.map((asOf) => ({
    asOf,
    totalMinor: employees
      .filter((e) => e.start <= asOf)
      .reduce((sum, e) => sum + accruedLiability(e, asOf), 0),
  }));
}

Deux faits de reporting en découlent, que personne ne demande avant de les avoir vus. L'écart d'un mois sur l'autre est la charge de la période. Et la marche au cinquième anniversaire de chaque salarié est visible à l'avance — le taux double, passant d'un demi-mois à un mois entier par année, si bien qu'une cohorte recrutée ensemble produit un saut de la provision à une date que vous pouvez nommer dès aujourd'hui.

Cette série est la couche de reporting, et c'est la raison pour laquelle ce calcul mérite d'être fait correctement plutôt que dans un tableur.

Étape 9 : quand l'employeur change, l'ancienneté ne repart pas de zéro

Une date de début et une date de fin supposent un seul employeur. Deux règles disent que le compteur continue de tourner quand l'employeur change, et un système de paie qui ouvre une nouvelle fiche salarié dans la nouvelle entité, avec la date du transfert comme date d'entrée, se trompe sur les deux.

L'article 18 du Code du travail régit le secteur privé. Lorsque la propriété de l'établissement passe à un nouveau propriétaire, ou que sa forme juridique change par fusion, scission ou autrement, les contrats de travail restent en vigueur et l'ancienneté est réputée continue. Pour les droits nés avant le changement, dont les salaires et l'indemnité de fin de service « présumée exigible à la date du transfert de propriété », le prédécesseur et le successeur sont solidairement responsables : le salarié peut réclamer à l'un ou à l'autre. La loi ne dit pas comment les deux règlent cela entre eux ; c'est en général l'objet du contrat de cession ou de fusion. Pour un établissement individuel, les deux peuvent plutôt convenir de transférer l'ensemble des droits antérieurs des salariés au nouveau propriétaire, avec l'accord écrit de chaque salarié. Le salarié qui refuse peut demander la fin de son contrat et percevoir ses droits auprès du prédécesseur.

La décision 318 du Conseil des ministres du 4/4/1448H, publiée au journal officiel Umm al-Qura le 25 septembre 2026, régit les agents transférés d'une entité publique par la privatisation. Elle modifie les règles issues de la décision 616 du 20/10/1442H. L'ancienneté est continue pour l'indemnité de fin de service et pour les congés. L'entité de tutelle, le prédécesseur, prend en charge l'indemnité et les congés de la période antérieure au transfert. L'entité d'accueil, le successeur, prend en charge la période postérieure. Les deux parts sont calculées sur le dernier salaire. La décision supprime aussi les trois renvois qui rattachaient ces règles à l'article 18, et renvoie la méthode à un mécanisme que le conseil d'administration du Centre national de privatisation publiera après concertation avec les ministères des Finances et des Ressources humaines.

Trois conséquences pour le moteur, et aucune ne demande de nouvelle formule :

  1. La date d'entrée est le premier jour chez le premier employeur. Quatre ans chez le prédécesseur et trois chez le successeur font sept ans d'ancienneté. Sur un salaire de 10 000, cela donne 45 000,00. Remettre le compteur à zéro au transfert produit deux indemnités, de 20 000 et 15 000, soit 10 000 de moins, parce que les sixième et septième années valent un mois entier et non un demi-mois.
  2. L'article 85 lit lui aussi l'ancienneté continue. Le salarié qui démissionne après ces sept ans perçoit les deux tiers de l'indemnité, et non le tiers que donneraient quatre ans seuls.
  3. Ce qui change, c'est qui paie, et sur quel salaire. Sous la décision 318, les deux parts utilisent le dernier salaire. Sous l'article 18, le montant dû solidairement est l'indemnité présumée exigible à la date du transfert, qu'un système ne peut produire que s'il a conservé le salaire en vigueur ce jour-là.

La répartition est l'endroit où la décision s'arrête. Tant que le mécanisme n'est pas publié, deux lectures se défendent, et sur le même salarié elles déplacent 5 714,29 riyals d'une entité à l'autre :

MéthodePrédécesseurSuccesseur
Au temps : au prorata des jours passés dans chaque entité25 714,2919 285,71
À la tranche : le prédécesseur paie ce que ses quatre années rapportent seules20 000,0025 000,00

Dans la lecture au temps, le prédécesseur paie aussi une partie des années à un mois entier qui n'existent que grâce à la continuité. Dans la lecture à la tranche, le successeur les paie toutes. Nous ne savons pas laquelle le mécanisme retiendra, donc le code oblige l'appelant à le dire :

/**
 * Two ways a worker changes employer without a break in service.
 * - 'article-18': the establishment is sold, merged or split (Labour Law, Art. 18).
 * - 'decision-318': a government entity's staff move to the entity created by
 *   privatisation (Cabinet Decision 616/1442, as amended by Decision 318/1448).
 */
export type TransferRegime = 'article-18' | 'decision-318';
 
/** The two-tier weight from Step 4: half-months of wage, times 360. */
function halfMonthsFor(serviceDays: number): number {
  const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
  const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
  return atHalf + 2 * atFull;
}
 
const dayBefore = (date: Date) => new Date(date.getTime() - MS_PER_DAY);
const isoDate = (date: Date) => date.toISOString().slice(0, 10);
 
/**
 * Article 18: the award "presumed due on the date ownership passed", for which
 * predecessor and successor are jointly liable. Our reading, not the Law's words:
 * a presumed award has no resignation behind it, so it is the full Art. 84 figure,
 * on the wage in force on the transfer date.
 */
export function presumedDueAtTransfer(input: {
  start: string;
  /** The first day under the new owner. */
  transferDate: string;
  wageAtTransfer: string | number;
  calendar: CalendarBasis;
}): number {
  const from = parseDate(input.start, 'start');
  const transfer = parseDate(input.transferDate, 'transferDate');
  if (transfer <= from) throw new RangeError('transferDate must fall after start');
  return endOfService({
    start: input.start,
    end: isoDate(dayBefore(transfer)),
    wage: input.wageAtTransfer,
    calendar: input.calendar,
    reason: 'termination',
  }).fullMinor;
}
 
/**
 * Decision 318 names who pays each period but leaves the method to a mechanism
 * the National Center for Privatization board has not issued. So there is no
 * default: the caller states the method, and the report shows which one it used.
 * - 'time': in proportion to the days served with each entity.
 * - 'band': the predecessor pays what its own years would earn at the last wage;
 *   the successor pays the rest, including the full-month years continuity added.
 */
export type SplitMethod = 'time' | 'band';
 
export type TransferSplit = {
  method: SplitMethod;
  /** Service with the predecessor, in days of a 360-day year. */
  predecessorDays: number;
  total: string;
  predecessor: string;
  successor: string;
  totalMinor: number;
  predecessorMinor: number;
  successorMinor: number;
};
 
export function splitAtTransfer(
  input: EndOfServiceInput & { transferDate: string },
  method: SplitMethod,
): TransferSplit {
  const from = parseDate(input.start, 'start');
  const transfer = parseDate(input.transferDate, 'transferDate');
  const lastDay = parseDate(input.end, 'end');
  if (transfer <= from || transfer > lastDay) {
    throw new RangeError('transferDate must fall after start and on or before end');
  }
 
  // Continuous service on the last wage: the worker's total never depends on the split.
  const whole = endOfService(input);
  const monthly = parseAmount(String(input.wage));
  const [num, den = 1] = whole.share.split('/').map(Number);
 
  const predecessorDays = serviceDays360(from, dayBefore(transfer), input.calendar);
  const [weight, scale] =
    method === 'time'
      ? [halfMonthsFor(whole.serviceDays) * predecessorDays, 2 * YEAR_DAYS * whole.serviceDays]
      : [halfMonthsFor(predecessorDays), 2 * YEAR_DAYS];
 
  // One division, from the unrounded award, as in Step 5.
  const product = monthly * weight * num;
  if (!Number.isSafeInteger(product)) throw new RangeError('amount too large for exact arithmetic');
  const predecessorMinor = divRound(product, scale * den);
  // The successor takes the remainder, so the two parts always sum to the award.
  const successorMinor = whole.payableMinor - predecessorMinor;
 
  return {
    method,
    predecessorDays,
    total: whole.payable,
    predecessor: formatMinor(predecessorMinor),
    successor: formatMinor(successorMinor),
    totalMinor: whole.payableMinor,
    predecessorMinor,
    successorMinor,
  };
}

Deux choix de ce bloc sont délibérés. SplitMethod n'a pas de valeur par défaut : une valeur par défaut serait un pari sur un mécanisme que personne n'a publié, et elle se retrouverait dans chaque provision que l'entité d'accueil comptabilise. Et le successeur prend le reste plutôt que sa propre part arrondie, si bien que les deux parts font toujours exactement ce qui est dû au salarié. Le total du salarié est calculé une seule fois, sur l'ancienneté continue, et ne dépend jamais de la répartition.

presumedDueAtTransfer traite l'indemnité présumée comme l'indemnité intégrale de l'article 84, sans réduction de démission. C'est notre lecture : la loi présume l'indemnité exigible à la date du transfert et ne dit rien d'un motif de départ, puisque personne n'est parti. Faites-la confirmer par un avocat saoudien en droit du travail avant d'y adosser un passif.

Pour la provision de l'étape 8, le changement tient en un champ : l'entité d'accueil provisionne depuis la date d'embauche d'origine. La manière dont la part du prédécesseur lui parvient, par paiement ou par compensation, est ce que le mécanisme tranchera. Les congés suivent la même règle : le palier de trente jours de l'article 109 arrive après cinq années continues, comptées depuis le premier jour chez le premier employeur.

Tester votre implémentation

Figez chaque cas légal dans un test nommé. Chacun d'eux a fait l'objet d'un désaccord quelque part :

import test from 'node:test';
import assert from 'node:assert/strict';
import { endOfService } from './labour';
 
const gregorian = { wage: '10000', calendar: 'gregorian' } as const;
 
test('half a month for each of the first five years', () => {
  // The last day worked is 31 December 2023: exactly five years.
  const r = endOfService({ ...gregorian, start: '2019-01-01', end: '2023-12-31' });
  assert.equal(r.serviceDays, 1800);
  assert.equal(r.monthsOfWage, '2.50');
  assert.equal(r.full, '25000.00');
  assert.equal(r.payable, '25000.00'); // termination pays in full
});
 
test('the last day worked counts', () => {
  // One more day is five years and a day, which the Ministry of Justice
  // calculator prices at 25,027.78.
  const r = endOfService({ ...gregorian, start: '2019-01-01', end: '2024-01-01' });
  assert.equal(r.serviceDays, 1801);
  assert.equal(r.full, '25027.78');
});
 
test('and a full month for each year after the fifth', () => {
  // Seven years: 2.5 months for the first five, 2 for the rest.
  const r = endOfService({ ...gregorian, start: '2018-01-01', end: '2024-12-31' });
  assert.equal(r.monthsOfWage, '4.50');
  assert.equal(r.full, '45000.00');
});
 
test('a month is thirty days, whatever February says', () => {
  // 1 February to 1 March is one month and one day: 31/360 of a year.
  const r = endOfService({ ...gregorian, start: '2019-02-01', end: '2019-03-01' });
  assert.equal(r.serviceDays, 31);
  assert.equal(r.full, '430.56');
});
 
test('fractions of a year are paid in proportion', () => {
  // Six calendar months is 180/360 of a year, in a leap year or not.
  const r = endOfService({ ...gregorian, start: '2024-01-01', end: '2024-06-30' });
  assert.equal(r.monthsOfWage, '0.25');
  assert.equal(r.full, '2500.00');
});
 
test('a Hijri contract is longer service on the same dates', () => {
  // The same five Gregorian years are 5 Hijri years, 1 month and 23 days.
  // The Ministry of Justice calculator, in Hijri mode, gives 26,472.22.
  const r = endOfService({ ...gregorian, calendar: 'hijri', start: '2019-01-01', end: '2023-12-31' });
  assert.equal(r.serviceDays, 1853);
  assert.equal(r.full, '26472.22');
});
 
test('resignation below two years pays nothing, but still accrues', () => {
  const r = endOfService({
    ...gregorian,
    start: '2024-01-01',
    end: '2025-05-31',
    reason: 'resignation',
  });
  assert.ok(r.fullMinor > 0, 'the award still accrues');
  assert.equal(r.payable, '0.00');
});
 
test('the same service settles at three different numbers', () => {
  const args = { ...gregorian, start: '2021-01-01', end: '2023-12-31' };
  const sacked = endOfService({ ...args, reason: 'termination' });
  const quit = endOfService({ ...args, reason: 'resignation' });
  assert.equal(sacked.payable, '15000.00');
  assert.equal(quit.payable, '5000.00'); // one third, Art. 85
  assert.equal(sacked.fullMinor, quit.fullMinor, 'the award accrued is the same');
});
 
test('the Art. 85 share is taken from the unrounded award', () => {
  // Two thirds of 26,472.22 rounds to 17,648.15 only if it is not rounded twice.
  const r = endOfService({
    ...gregorian,
    calendar: 'hijri',
    start: '2019-01-01',
    end: '2023-12-31',
    reason: 'resignation',
  });
  assert.equal(r.share, '2/3');
  assert.equal(r.payable, '17648.15');
});
 
test('bad input throws rather than paying a wrong number', () => {
  assert.throws(
    () => endOfService({ ...gregorian, start: '2025-01-01', end: '2024-01-01' }),
    RangeError,
  );
  assert.throws(() => endOfService({ ...gregorian, start: 'nope', end: '2025-01-01' }), TypeError);
});

Les règles de transfert de l'étape 9 ont leurs propres tests. Chaque montant reprend l'un des exemples ci-dessus :

import test from 'node:test';
import assert from 'node:assert/strict';
import { endOfService, presumedDueAtTransfer, splitAtTransfer } from './labour';
 
const gregorian = { wage: '10000', calendar: 'gregorian' } as const;
// Four years with the predecessor, three with the successor.
const moved = { ...gregorian, start: '2018-01-01', transferDate: '2022-01-01', end: '2024-12-31' };
 
test('the clock does not restart at the transfer', () => {
  const whole = endOfService({ ...gregorian, start: '2018-01-01', end: '2024-12-31' });
  const before = endOfService({ ...gregorian, start: '2018-01-01', end: '2021-12-31' });
  const after = endOfService({ ...gregorian, start: '2022-01-01', end: '2024-12-31' });
  assert.equal(whole.full, '45000.00');
  // Restarted, the same seven years would earn 20,000 + 15,000.
  assert.equal(before.fullMinor + after.fullMinor, 3_500_000);
});
 
test('Decision 318 split by time', () => {
  const s = splitAtTransfer(moved, 'time');
  assert.equal(s.predecessorDays, 1440);
  assert.equal(s.predecessor, '25714.29');
  assert.equal(s.successor, '19285.71');
  assert.equal(s.predecessorMinor + s.successorMinor, s.totalMinor);
});
 
test('Decision 318 split by band', () => {
  const s = splitAtTransfer(moved, 'band');
  assert.equal(s.predecessor, '20000.00'); // four years at half a month
  assert.equal(s.successor, '25000.00'); // including both full-month years
  assert.equal(s.total, '45000.00');
});
 
test('a resignation takes its share from the continuous service', () => {
  // Seven years is two thirds under Art. 85; four years alone would be one third.
  const s = splitAtTransfer({ ...moved, reason: 'resignation' }, 'band');
  assert.equal(s.total, '30000.00');
  assert.equal(s.predecessor, '13333.33');
  assert.equal(s.successor, '16666.67');
});
 
test('Article 18 prices the presumed award on the wage at the transfer', () => {
  const presumed = presumedDueAtTransfer({
    start: '2018-01-01',
    transferDate: '2022-01-01',
    wageAtTransfer: '8000',
    calendar: 'gregorian',
  });
  assert.equal(presumed, 1_600_000); // 16,000: two months of the 8,000 wage
});
 
test('a transfer outside the service throws', () => {
  assert.throws(() => splitAtTransfer({ ...moved, transferDate: '2018-01-01' }, 'time'), RangeError);
  assert.throws(() => splitAtTransfer({ ...moved, transferDate: '2025-01-01' }, 'band'), RangeError);
});

Lancez avec node --test. Le dernier test du premier fichier compte plus qu'il n'y paraît : un moteur d'indemnité qui retourne NaN sur une date malformée écrira volontiers NaN dans un fichier de paiement. Lever une exception est le comportement correct pour de l'argent.

Recoupez ensuite quelques cas réels avec le calculateur du travail du ministère de la Justice, en choisissant le type de date prévu par le contrat. Si votre moteur et celui du ministère divergent ne serait-ce que d'un halala, trouvez pourquoi avant de livrer. L'écart tient presque toujours à la convention de décompte des jours, au calendrier ou à la définition du salaire, et mieux vaut les découvrir dans vos tests que par un auditeur.

Dépannage

Le montant s'écarte de quelques riyals de celui du ministère. En général, le dernier jour travaillé n'est pas compté, ou la fraction d'année est calculée en jours divisés par 365 (ou par la longueur réelle de l'année) au lieu de mois de trente jours. Cherchez / 365 et toute date de fin traitée comme exclue, et calculez l'ancienneté avec serviceDays360.

Le montant est loin de celui du ministère pour un salarié ancien. Vérifiez le type de date. L'année hégirienne est plus courte d'environ onze jours que l'année grégorienne : un contrat hégirien accumule donc environ trois pour cent d'ancienneté de plus par année grégorienne, soit près de quatre mois sur dix ans.

Le montant est très inférieur à l'attendu. Vérifiez la définition du salaire. Si vous alimentez le salaire de base alors que le salaire réel contractuel inclut les indemnités de logement et de transport, vous serez court de la proportion que représentent ces indemnités — couramment vingt-cinq à trente-cinq pour cent.

La sixième année paie moins que la cinquième. Les tranches sont appliquées sélectivement plutôt que cumulativement — le code facture un mois par année pour toutes les années dès que l'ancienneté dépasse cinq ans, puis soustrait quelque part. Vérifiez que atHalf et atFull totalisent bien serviceDays.

Une démission à exactement deux ans ne paie rien. Une erreur d'unité au seuil. L'article 85 accorde un tiers à deux ans d'ancienneté, la comparaison au seuil doit donc être strictement inférieure, et non inférieure ou égale.

L'indemnité d'un salarié transféré est courte d'environ un mois. L'entité d'accueil a ouvert une nouvelle fiche avec la date du transfert comme date d'entrée. Sous l'article 18 et la décision 318, l'ancienneté est continue : importez la date d'embauche d'origine et, lorsque l'article 18 s'applique, conservez aussi le salaire en vigueur à la date du transfert.

Les totaux dérivent de quelques halalas sur un effectif important. Arrondi appliqué plus d'une fois. N'arrondissez qu'à la sortie, et dérivez chaque total du taux non arrondi.

Étapes suivantes

Conclusion

L'indemnité de fin de service saoudienne est un petit calcul enveloppé dans quatre articles de loi, et presque toutes les erreurs d'implémentation sont des erreurs de lecture plutôt que des bugs. Mesurez l'ancienneté en mois de trente jours, sur le calendrier du contrat, en comptant le dernier jour. Appliquez les deux tranches cumulativement. Gardez l'indemnité acquise et la quote-part due comme deux montants distincts, pour qu'un départ requalifié soit un champ et non un recalcul. Réglez-vous sur le salaire réel. Gardez la date d'entrée d'origine quand l'employeur change. Et provisionnez mensuellement, afin que le délai d'une semaine de l'article 88 tombe sur une provision qui existe déjà plutôt que sur une découverte.

Si vous portez ce calcul dans un tableur, ou dans un SIRH dont les chiffres ne concordent pas avec le calculateur du ministère, cet écart mérite d'être mesuré avant qu'un salarié ne parte. Dites-nous à quoi ressemble votre stack et nous rapprocherons un échantillon de vos soldes réels du texte légal — les désaccords se logent en général aux trois mêmes endroits, et il est moins coûteux de les trouver maintenant que devant une commission du travail.