écrits/tutorial/2026/08
Tutorial23 août 2026·22 min

Heures supplémentaires saoudiennes en TypeScript (art. 107)

Construisez un moteur de calcul des heures supplémentaires pour la paie saoudienne en TypeScript, implémentant les articles 98, 106 et 107 du droit du travail — la distinction salaire réel / salaire de base, les diviseurs de 240 et 180 heures, les heures du jour de repos et des jours fériés, l'horaire du Ramadan, l'option de congé compensatoire introduite par les récents amendements, et le plafond annuel de 720 heures.

Les heures supplémentaires sont la ligne de paie que les tribunaux du travail saoudiens voient le plus souvent, et la raison est toujours la même : la formule de l'article 107 du droit du travail tient en une phrase, mais presque chaque système en implémente une autre. Le texte dit qu'une heure supplémentaire vaut le salaire horaire majoré de 50 % du salaire de base — deux assiettes salariales différentes dans une seule phrase. Les systèmes qui appliquent la majoration de 50 % au salaire complet surpaient chaque mois ; ceux qui calculent l'heure entière à partir du seul salaire de base sous-paient chaque mois, et l'écart refait surface des années plus tard sous la forme d'un contentieux prud'homal.

Ce tutoriel construit le moteur correctement : monnaie en entiers, la distinction salaire réel / salaire de base que le texte exige réellement, une classification des heures qui sait ce qu'est un jour de repos et un horaire de Ramadan, l'option de congé compensatoire que les récents amendements ont rendue explicite, et le plafond annuel de 720 heures que la plupart des systèmes de pointage ne vérifient jamais. C'est le troisième moteur de notre série sur la paie saoudienne, aux côtés du moteur d'acquisition des congés annuels, et tout ce que nous construisons ici est la logique qui anime notre calculateur d'heures supplémentaires gratuit, que vous pouvez utiliser pour vérifier votre implémentation à tout moment.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20 ou plus récent
  • TypeScript 5 ou plus récent (npm install -D typescript vitest)
  • Une familiarité de base avec les modules TypeScript et les tests unitaires
  • Le droit du travail saoudien ouvert dans un onglet, articles 98 à 108 — nous suivrons le texte, pas le folklore

Aucun framework n'est requis. Le moteur est une bibliothèque TypeScript pure que vous pouvez déposer dans une route API Next.js, un batch de paie ou un service de pointage.

Ce que vous allez construire

Une petite bibliothèque, saudi-overtime-engine, exposant quatre fonctions :

  • hourlyRates(wage, ramadan, policy) — les taux horaires réel et de base, avec les diviseurs de 240 et 180 heures traités explicitement
  • overtimeHours(day, schedule) — combien d'heures d'une journée sont supplémentaires, y compris les jours de repos et fériés où toutes les heures le sont
  • overtimePay(days, wage, schedule) — le montant de l'article 107 pour une période, en halalas
  • capStatus(hoursThisYear) — la position du salarié par rapport au plafond annuel de 720 heures

Plus une suite de tests qui fixe chaque règle dans un scénario présentable à un auditeur.

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

Trois articles portent tout, et chacun apporte une règle que votre moteur doit encoder :

L'article 98 fixe la norme : pas plus de 8 heures de travail par jour ou 48 par semaine. Pendant le Ramadan, pour les salariés musulmans, la norme descend à 6 heures par jour et 36 par semaine. Tout ce qui dépasse la norme applicable est supplémentaire.

L'article 106 énumère les situations où l'employeur peut dépasser ces limites — inventaire annuel, pics saisonniers, prévention d'un accident. Le cadre d'application plafonne le total à 720 heures supplémentaires par salarié et par an, au-delà desquelles le consentement écrit du salarié est requis. La plupart des moteurs ne suivent jamais ce chiffre ; le vôtre le fera.

L'article 107 en fixe le prix. Une heure supplémentaire est payée au taux horaire majoré de 50 % du salaire de base. Les heures travaillées le jour de repos hebdomadaire ou les jours fériés comptent comme supplémentaires dans leur intégralité. Et les récents amendements au droit du travail ont rendu explicite ce que beaucoup de contrats faisaient déjà : avec l'accord du salarié, les heures supplémentaires peuvent être réglées en jours de congé compensatoire au lieu d'être payées.

Le piège tient dans les deux assiettes. Le droit du travail saoudien distingue le salaire de base (الأجر الأساسي) du salaire réel (الأجر الفعلي), qui ajoute les indemnités fixes — logement, transport, et tout ce qui est versé régulièrement. La première moitié de l'heure supplémentaire se calcule sur le salaire réel ; la majoration de 50 % se calcule sur le seul salaire de base. Un moteur qui ne porte qu'un champ « salaire » unique ne peut pas implémenter l'article 107 correctement.

Étape 2 : garder l'argent en halalas, jamais en flottants

La même règle que dans tous les moteurs de cette série : l'argent est un nombre entier de halalas, les fractions ne survivent qu'à l'intérieur d'un calcul, et l'arrondi se produit exactement une fois, à la fin.

// money.ts
export type Halalas = number; // always an integer
 
export const fromSAR = (sar: number): Halalas => Math.round(sar * 100);
export const toSAR = (halalas: Halalas): number => halalas / 100;

Étape 3 : modéliser le salaire comme le texte le découpe

Deux champs, pas un. Si votre référentiel RH ne porte qu'un montant brut unique, le corriger est une tâche de données qui précède ce moteur, elle ne le suit pas.

// wage.ts
import type { Halalas } from './money';
 
export interface MonthlyWage {
  /** Basic wage — the contractual base, before any allowance. */
  basic: Halalas;
  /** Fixed, regularly paid allowances: housing, transport, and similar. */
  fixedAllowances: Halalas;
}
 
/** Actual wage: the base plus every fixed allowance (Labour Law, Art. 2). */
export const actualWage = (w: MonthlyWage): Halalas => w.basic + w.fixedAllowances;

Une décision à consigner par écrit : quelles indemnités sont « fixes ». Une indemnité de transport versée chaque mois appartient à fixedAllowances ; une prime exceptionnelle non. Les auditeurs demandent cette liste — gardez-la dans votre document de politique interne, pas dans la mémoire de quelqu'un.

Étape 4 : le taux horaire — et la question du diviseur

Le texte tarifie les heures supplémentaires à l'heure mais énonce les salaires au mois, donc toute implémentation a besoin d'un diviseur. La convention dominante — celle qu'applique notre calculateur d'heures supplémentaires — divise le salaire mensuel par 240 (8 heures × 30 jours). Pendant le Ramadan, le mois de travail se réduit à 6 heures par jour, donc le diviseur devient 180, ce qui rend chaque heure de Ramadan, et donc chaque heure supplémentaire de Ramadan, plus chère. Certaines paies dérivent plutôt le taux de la norme hebdomadaire (48 × 52 / 12 = 208 heures par mois). Les deux produisent des chiffres défendables ; ce qui ne l'est pas, c'est de les mélanger. Faites du diviseur une valeur de politique, fixez-le une fois, et laissez les tests le verrouiller.

// rates.ts
import type { MonthlyWage } from './wage';
import { actualWage } from './wage';
 
export interface RatePolicy {
  /** Hours dividing the monthly wage in a normal month. 240 = 8h x 30d. */
  monthlyDivisorHours: number;
  /** Hours dividing the monthly wage in Ramadan. 180 = 6h x 30d. */
  ramadanDivisorHours: number;
}
 
export const defaultRatePolicy: RatePolicy = {
  monthlyDivisorHours: 240,
  ramadanDivisorHours: 180,
};
 
export interface HourlyRates {
  /** Hourly rate from the actual wage, in halalas (may carry fractions). */
  actualHourly: number;
  /** Hourly rate from the basic wage, in halalas (may carry fractions). */
  basicHourly: number;
}
 
export function hourlyRates(
  wage: MonthlyWage,
  ramadan: boolean,
  policy: RatePolicy = defaultRatePolicy,
): HourlyRates {
  const divisor = ramadan ? policy.ramadanDivisorHours : policy.monthlyDivisorHours;
  return {
    actualHourly: actualWage(wage) / divisor,
    basicHourly: wage.basic / divisor,
  };
}

Notez que les deux taux restent ici des halalas à virgule flottante. C'est délibéré : ce sont des valeurs intermédiaires. L'unique Math.round attend l'étape 6.

Étape 5 : classifier les heures — la partie que les pointages ratent

L'article 107 ne tarifie pas seulement les heures au-delà de la norme quotidienne. Il dit que les heures travaillées le jour de repos hebdomadaire et les jours fériés sont supplémentaires dès la première minute. Un pointage qui ne mesure que « les heures au-delà de 8 » laisse tomber les deux cas en silence.

// classify.ts
export type DayKind = 'workday' | 'rest-day' | 'official-holiday';
 
export interface DayRecord {
  /** ISO date, e.g. "2026-08-21". */
  date: string;
  kind: DayKind;
  hoursWorked: number;
  /** True when the employee is Muslim and the date falls in Ramadan. */
  ramadan: boolean;
}
 
export interface SchedulePolicy {
  /** Daily standard outside Ramadan (Art. 98): 8. */
  dailyStandardHours: number;
  /** Daily standard during Ramadan for Muslim employees (Art. 98): 6. */
  ramadanDailyStandardHours: number;
}
 
export const defaultSchedule: SchedulePolicy = {
  dailyStandardHours: 8,
  ramadanDailyStandardHours: 6,
};
 
/** Overtime hours in one day, per Art. 98 and Art. 107(2)(3). */
export function overtimeHours(
  day: DayRecord,
  schedule: SchedulePolicy = defaultSchedule,
): number {
  if (day.kind !== 'workday') {
    // Rest day or official holiday: every hour is overtime.
    return day.hoursWorked;
  }
  const standard = day.ramadan
    ? schedule.ramadanDailyStandardHours
    : schedule.dailyStandardHours;
  return Math.max(0, day.hoursWorked - standard);
}

Deux points à inscrire dans votre document de politique. D'abord, votre établissement applique-t-il la norme quotidienne ou hebdomadaire — l'article 98 permet les deux, et le choix change quelles heures sont supplémentaires pour les horaires irréguliers. Ce moteur applique la norme quotidienne, le choix le plus courant et le plus strict pour l'employeur. Ensuite, quels jours sont fériés — Aïd al-Fitr, Aïd al-Adha, Fête nationale, Jour de la Fondation — parce que quelqu'un doit alimenter official-holiday dans les enregistrements journaliers, et « le pointage ne savait pas que c'était l'Aïd » n'est pas une défense qu'un tribunal du travail accepte.

Étape 6 : la formule de l'article 107 en une fonction

Avec les taux et la classification en place, la fonction de tarification est assez courte pour être relue ligne à ligne face au texte.

// pay.ts
import type { Halalas } from './money';
import type { MonthlyWage } from './wage';
import { hourlyRates, type RatePolicy } from './rates';
import { overtimeHours, type DayRecord, type SchedulePolicy } from './classify';
 
/** Price of one overtime hour: hourly wage + 50% of basic hourly (Art. 107(1)). */
export function overtimeHourRate(actualHourly: number, basicHourly: number): number {
  return actualHourly + 0.5 * basicHourly;
}
 
/** Article 107 overtime pay for a period, in halalas. One rounding, at the end. */
export function overtimePay(
  days: DayRecord[],
  wage: MonthlyWage,
  schedule?: SchedulePolicy,
  ratePolicy?: RatePolicy,
): Halalas {
  let total = 0;
  for (const day of days) {
    const hours = overtimeHours(day, schedule);
    if (hours === 0) continue;
    const rates = hourlyRates(wage, day.ramadan, ratePolicy);
    total += hours * overtimeHourRate(rates.actualHourly, rates.basicHourly);
  }
  return Math.round(total);
}

Déroulons l'exemple auquel tous les forums RH saoudiens finissent par arriver. Salaire de base 4 000 SAR, indemnités fixes 800 SAR, donc salaire réel 4 800 SAR. Hors Ramadan, le taux horaire réel est 4 800 / 240 = 20 SAR et le taux horaire de base 4 000 / 240 = 16,67 SAR. Une heure supplémentaire vaut 20 + 8,33 = 28,33 SAR. Dix heures supplémentaires paient 283,33 SAR — le moteur renvoie 28 333 halalas. Les implémentations fautives produisent 300 SAR (majoration sur le salaire réel) ou 250 SAR (heure entière sur le salaire de base). Cinquante riyals par mois, multipliés par un effectif, multipliés par des années : voilà la taille du passif que cette seule fonction décide.

Rejouez les mêmes dix heures pendant le Ramadan et le diviseur fait le travail : 4 800 / 180 = 26,67 SAR de taux réel, 4 000 / 180 = 22,22 SAR de taux de base, 37,78 SAR par heure supplémentaire — 377,78 SAR au total, sans un seul cas particulier dans le code de tarification.

Étape 7 : le congé compensatoire est un mode de règlement, pas une remise

Les récents amendements permettent de régler les heures supplémentaires en congé compensatoire au lieu de les payer — avec l'accord du salarié. Deux conséquences pour le moteur. Le consentement est un événement avec une date et une référence, pas un booléen sur la fiche du salarié ; stockez-le comme le registre du moteur de congés stocke les événements de congé, car la charge de prouver l'accord pèse sur l'employeur. Et les heures non réglées restent un passif dans tous les cas : les heures réglées en congé alimentent le solde de congés, celles réglées en paie alimentent la paie, et celles qui ne sont réglées ni par l'un ni par l'autre sont un contentieux qui attend son audience.

// settlement.ts
export type OvertimeSettlement =
  | { mode: 'pay' }
  | {
      mode: 'comp-leave';
      /** ISO date the employee agreed in writing. */
      consentDate: string;
      /** Reference to the signed consent document. */
      consentRef: string;
    };

Si l'enregistrement de règlement dit comp-leave et que votre système ne peut produire aucun consentRef sur demande, traitez-le comme pay. Ce défaut-là coûte de l'argent ; l'autre coûte un procès.

Étape 8 : suivre le plafond de 720 heures avant l'inspecteur

Le plafond annuel est la règle que personne ne code parce qu'elle vit dans le cadre d'application plutôt que dans l'article que tout le monde cite. Le travail du moteur n'est pas de bloquer la 721e heure — l'exploitation gagnera toujours cet arbitrage — mais de la voir venir et d'exiger le dossier de consentement quand elle arrive.

// cap.ts
export const ANNUAL_OVERTIME_CAP_HOURS = 720;
 
export interface CapStatus {
  used: number;
  remaining: number;
  exceeded: boolean;
}
 
export function capStatus(hoursThisYear: number): CapStatus {
  return {
    used: hoursThisYear,
    remaining: Math.max(0, ANNUAL_OVERTIME_CAP_HOURS - hoursThisYear),
    exceeded: hoursThisYear > ANNUAL_OVERTIME_CAP_HOURS,
  };
}

Affichez remaining sur le tableau de bord RH à 600 heures, pas à 719. L'exigence de consentement au-delà du plafond est individuelle et écrite — la même discipline de preuve qu'à l'étape 7.

Tester votre implémentation

Chaque règle ci-dessus devient un scénario. Voici ceux qui attrapent les implémentations réelles :

// engine.test.ts
import { describe, expect, it } from 'vitest';
import { fromSAR } from './money';
import { overtimePay } from './pay';
import type { DayRecord } from './classify';
 
const wage = { basic: fromSAR(4000), fixedAllowances: fromSAR(800) };
 
const workday = (hoursWorked: number, ramadan = false): DayRecord => ({
  date: '2026-03-02',
  kind: 'workday',
  hoursWorked,
  ramadan,
});
 
describe('Article 107 pricing', () => {
  it('prices the premium from the basic wage, not the actual wage', () => {
    // 2h overtime: 2 x (4800/240 + 0.5 x 4000/240) = 2 x 28.333 SAR
    expect(overtimePay([workday(10)], wage)).toBe(5667);
  });
 
  it('pays nothing at or under the daily standard', () => {
    expect(overtimePay([workday(8)], wage)).toBe(0);
  });
 
  it('treats every rest-day hour as overtime', () => {
    const friday: DayRecord = {
      date: '2026-03-06',
      kind: 'rest-day',
      hoursWorked: 5,
      ramadan: false,
    };
    // 5 x 28.333 = 141.67 SAR
    expect(overtimePay([friday], wage)).toBe(14167);
  });
 
  it('applies the 180-hour divisor and 6-hour standard in Ramadan', () => {
    // 8h worked in Ramadan = 2h overtime at (4800/180 + 0.5 x 4000/180)
    expect(overtimePay([workday(8, true)], wage)).toBe(7556);
  });
 
  it('rounds once at the end, not per day', () => {
    const days = Array.from({ length: 3 }, () => workday(9));
    // 3 x 28.333... rounds to 8500, not 3 x 2833 = 8499
    expect(overtimePay(days, wage)).toBe(8500);
  });
});

Le dernier test est celui qui compte le plus en production : arrondir par jour plutôt que par période dérive d'un halala à la fois jusqu'à ce qu'un rapprochement échoue. Vérifiez n'importe quel scénario avec notre calculateur d'heures supplémentaires gratuit — il exécute exactement cette logique.

Dépannage

Vos chiffres contredisent le calcul du salarié lui-même. Neuf fois sur dix, il a calculé l'heure supplémentaire entière sur le salaire réel (soit 1,5 × le taux horaire réel). Montrez la décomposition : le texte tarifie l'heure de base sur le salaire réel et la seule majoration sur le salaire de base.

Vos chiffres contredisent l'ancien système de paie. Vérifiez d'abord le diviseur — 240 contre 208 contre « les jours calendaires du mois » explique presque tous les écarts hérités. Décidez de la politique que vous adoptez, consignez-la, et migrez délibérément plutôt que de reproduire l'ancien système bug pour bug.

Les totaux du Ramadan semblent trop élevés. Ils doivent l'être, à l'heure : le diviseur descend à 180 et la norme quotidienne à 6, donc le taux et le nombre d'heures supplémentaires montent ensemble. Le résultat fautif, c'est un supplément de Ramadan tarifé au taux normal.

Le travail du vendredi affiche zéro heure supplémentaire. Votre pointage classe la journée comme jour ouvré avec des heures sous la norme. Le champ kind existe précisément pour que les jours de repos et fériés ne passent jamais par la branche de la norme quotidienne.

Prochaines étapes

Conclusion

L'article 107 tient en une phrase, et c'est exactement pour cela qu'il est si souvent mal implémenté : la phrase contient deux assiettes salariales, un diviseur que personne n'énonce, trois types de jours, une option de règlement qui exige des preuves, et un plafond annuel qui vit hors de l'article. Le moteur qui gère tout cela fait quelques centaines de lignes : monnaie entière, un modèle de salaire à deux champs, des diviseurs explicites, une classification des jours, une fonction de tarification et un seul arrondi.

Si vos heures supplémentaires se calculent dans un tableur, ou si votre pointage et votre paie divergent et que l'écart se paie au nom de la bonne volonté, dites-nous à quoi ressemble votre système — nous ferons passer un mois de vos données de pointage réelles dans un moteur comme celui-ci et vous montrerons exactement laquelle des trois erreurs classiques votre formule actuelle commet, avant qu'un inspecteur ou un tribunal du travail ne le fasse.