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 explicitementovertimeHours(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 sontovertimePay(days, wage, schedule)— le montant de l'article 107 pour une période, en halalascapStatus(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
- Déroulez les scénarios de fin de contrat de bout en bout — les heures supplémentaires impayées refont surface au moment du solde ; vérifiez les montants avec le calculateur de fin de service
- Routez les règlements en congé compensatoire vers le moteur d'acquisition des congés annuels pour que les deux soldes se rapprochent
- Alimentez la paie résultante dans votre générateur et validateur de fichier WPS — le fichier de protection des salaires porte une colonne dédiée aux heures supplémentaires, et tout écart y est visible pour le ministère
- Voyez comment les chiffres circulent ensuite dans l'intégration paie Mudad et la pile de plateformes Qiwa
- Laissez les salariés vérifier leurs propres chiffres avec le calculateur d'heures supplémentaires gratuit
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.