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, le congé compensatoire avec les quatre limites que lui fixe le règlement d'application, 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.
Correction, 28 septembre 2026. Une version précédente de ce tutoriel traitait le congé compensatoire comme un simple indicateur de consentement et proposait de le verser au solde de congés annuels. L'article 22 bis du règlement d'application pose quatre conditions qui changent les montants : au moins une heure et demie de congé par heure supplémentaire, un congé fixé dans les 60 jours, pas plus de 30 jours par an, et le paiement du reliquat au départ du salarié. Le congé jour pour jour, que décrivent beaucoup de guides arabophones, est la règle de la fonction publique ; dans le secteur privé, il règle la dette avec un tiers de moins. L'étape 7 implémente désormais les quatre conditions, l'étape 8 ajoute le plafond de l'article 106 de 10 heures effectives par jour et 60 par semaine, et le plafond de 720 heures est désormais rattaché à l'article 22 du règlement d'application.
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, et son règlement d'application, articles 22 et 22 bis — 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 les fonctions suivantes :
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 halalassettle(...)— la part d'une mission que le congé compensatoire peut légalement couvrir, la part qui reste à payer, et la date avant laquelle le congé doit être prisunusedCompLeavePayout(...)— ce que vaut le congé encore dû au départ du salariécapStatus(hoursThisYear)et les contrôles de l'article 106 — la position du salarié par rapport au plafond annuel de 720 heures et à la limite de 10 heures par jour
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 — et fixe une limite qui tient même alors : pas plus de 10 heures de travail effectif par jour ou 60 par semaine. L'article 22 du règlement d'application plafonne les heures supplémentaires à 720 heures par salarié et par an, et n'autorise davantage qu'avec l'accord du salarié. La plupart des moteurs ne suivent aucun de ces deux chiffres ; 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. Sous la norme hebdomadaire, les heures au-delà du total hebdomadaire sont supplémentaires (107(2)) ; les heures travaillées les jours fériés et de fête le sont dans leur intégralité (107(3)). Et les amendements de 2025 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 congé compensatoire payé au lieu d'être payées, selon des conditions que détaille l'article 22 bis du règlement d'application.
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(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 amendements de 2025 permettent de régler les heures supplémentaires en congé compensatoire payé au lieu de les payer, avec l'accord du salarié, et renvoient le détail au règlement d'application. Son article 22 bis pose quatre conditions, et chacune devient une ligne de code :
- L'accord précise la mission et le congé, et le congé vaut au moins une heure et demie par heure supplémentaire. Dix heures supplémentaires donnent droit à quinze heures de congé au moins, pas dix. Le jour pour jour est la règle de la fonction publique, que beaucoup de guides arabophones appliquent à tort au secteur privé. Un accord sous ce plancher ne peut pas être opposé : le moteur paie alors ces heures en numéraire.
- L'employeur peut fixer la date du congé dans un délai de 60 jours à compter du début des heures supplémentaires, sauf accord contraire des parties. Le moteur calcule cette date pour que le congé ne dérive pas vers un solde que personne ne planifie. Il compte le premier jour d'heures supplémentaires comme jour un, ce qui est la lecture prudente.
- Pas plus de 30 jours de congé compensatoire par an. Les heures que le plafond ne peut pas absorber restent dues, et le paiement est la règle de l'article 107(1). Le règlement ne dit pas de quelle année il s'agit ; l'article 10 compte les durées du droit du travail selon le calendrier hégirien sauf stipulation contraire du contrat, alors décidez par écrit quelle année vous suivez.
- Le congé encore dû au départ du salarié est payé. C'est un congé payé, donc payé au salaire, et l'article 2 fait du salaire le salaire réel.
Le consentement est un événement avec une date et une référence, pas un booléen sur la fiche du salarié, car la charge de prouver l'accord pèse sur l'employeur. Et le congé compensatoire a son propre solde : son propre délai, son propre plafond et sa propre règle de sortie. Le fondre dans les congés annuels fait perdre les trois.
// settlement.ts
import type { Halalas } from './money';
import type { MonthlyWage } from './wage';
import { hourlyRates, type RatePolicy } from './rates';
/** Implementing Regulation Art. 22 bis(1): at least 1.5 leave hours per overtime hour. */
export const MIN_LEAVE_HOURS_PER_OVERTIME_HOUR = 1.5;
/** Art. 22 bis(2): the employer schedules the leave within 60 days, unless agreed otherwise. */
export const SCHEDULING_WINDOW_DAYS = 60;
/** Art. 22 bis(3): no more than 30 days of compensatory leave in a year. */
export const ANNUAL_COMP_LEAVE_CAP_DAYS = 30;
export interface CompLeaveAgreement {
/** ISO date the employee agreed in writing. */
consentDate: string;
/** Reference to the signed agreement. */
consentRef: string;
/** Leave hours granted per overtime hour, as written in the agreement. */
leaveHoursPerOvertimeHour: number;
/** A different scheduling deadline the parties agreed in writing, if any. */
agreedScheduleBy?: string;
}
export type OvertimeSettlement =
| { mode: 'pay' }
| { mode: 'comp-leave'; agreement: CompLeaveAgreement };
export interface SettlementSplit {
/** Overtime hours that go to payroll at the Art. 107 rate. */
paidHours: number;
/** Hours credited to the compensatory-leave balance, never to annual leave. */
leaveHours: number;
/** ISO date by which the leave must be taken, when any hours went to leave. */
scheduleBy?: string;
}
const addDays = (iso: string, days: number): string => {
const d = new Date(`${iso}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
};
/**
* Split one overtime assignment between pay and compensatory leave (Reg. Art. 22 bis).
* Whatever the agreement cannot lawfully cover falls back to pay, the Art. 107(1) default.
*/
export function settle(
overtimeHours: number,
overtimeStart: string,
settlement: OvertimeSettlement,
compLeaveDaysUsedThisYear: number,
hoursPerLeaveDay: number,
): SettlementSplit {
if (settlement.mode === 'pay') return { paidHours: overtimeHours, leaveHours: 0 };
const a = settlement.agreement;
if (!a.consentRef || a.leaveHoursPerOvertimeHour < MIN_LEAVE_HOURS_PER_OVERTIME_HOUR) {
// No provable consent, or a ratio under the floor: the agreement cannot be relied on.
return { paidHours: overtimeHours, leaveHours: 0 };
}
const capLeftDays = Math.max(0, ANNUAL_COMP_LEAVE_CAP_DAYS - compLeaveDaysUsedThisYear);
const leaveHours = Math.min(
overtimeHours * a.leaveHoursPerOvertimeHour,
capLeftDays * hoursPerLeaveDay,
);
if (leaveHours === 0) return { paidHours: overtimeHours, leaveHours: 0 };
return {
paidHours: overtimeHours - leaveHours / a.leaveHoursPerOvertimeHour,
leaveHours,
// The first overtime day counts as day one: the cautious reading of "within 60 days".
scheduleBy: a.agreedScheduleBy ?? addDays(overtimeStart, SCHEDULING_WINDOW_DAYS - 1),
};
}
/** Reg. Art. 22 bis(4): compensatory leave still owed is paid when the employee leaves. */
export function unusedCompLeavePayout(
unusedLeaveHours: number,
wage: MonthlyWage,
ratePolicy?: RatePolicy,
): Halalas {
// Paid leave is paid at the wage, and Art. 2 makes the wage the actual wage.
return Math.round(unusedLeaveHours * hourlyRates(wage, false, ratePolicy).actualHourly);
}Deux chiffres méritent d'être montrés à quiconque présente le congé compensatoire comme une économie. Dix heures supplémentaires sur le salaire de l'étape 6 coûtent 283,33 SAR en numéraire. Réglées en congé, elles deviennent quinze heures d'absence, et si le salarié part avant de les prendre, l'indemnité vaut 15 x 20 = 300 SAR. Dès que le salaire comporte une indemnité, une heure et demie de salaire réel vaut plus qu'une heure réelle plus une demi-heure de base : le congé compensatoire ne coûte donc jamais moins que le paiement. C'est un moyen de régler les heures supplémentaires sans trésorerie, pas de dépenser moins.
hoursPerLeaveDay convertit les jours du règlement en heures, l'unité dans laquelle le ratio est
écrit. Utilisez la journée de travail contractuelle et consignez ce choix à côté du diviseur de
l'étape 4.
É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 l'article 22 du règlement 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
import type { DayRecord } from './classify';
/** Implementing Regulation Art. 22: 720 overtime hours a year, more only with consent. */
export const ANNUAL_OVERTIME_CAP_HOURS = 720;
/** Art. 106: even where overtime is allowed, 10 actual hours a day and 60 a week at most. */
export const MAX_ACTUAL_HOURS_PER_DAY = 10;
export const MAX_ACTUAL_HOURS_PER_WEEK = 60;
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,
};
}
/** Dates on which actual hours broke the Art. 106 daily ceiling. */
export function dailyCeilingBreaches(days: DayRecord[]): string[] {
return days.filter((d) => d.hoursWorked > MAX_ACTUAL_HOURS_PER_DAY).map((d) => d.date);
}
/** True when one week's actual hours broke the Art. 106 weekly ceiling. */
export function weekExceedsCeiling(week: DayRecord[]): boolean {
return week.reduce((sum, d) => sum + d.hoursWorked, 0) > MAX_ACTUAL_HOURS_PER_WEEK;
}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.
La limite de l'article 106 est d'une autre nature. Le plafond de 720 heures peut être dépassé avec
l'accord du salarié ; la limite de 10 heures effectives par jour et 60 par semaine ne peut être
levée par personne. C'est pourquoi les contrôles lisent hoursWorked, la journée entière, et non
les seules heures supplémentaires. Un poste de 12 heures est une infraction, quel que soit le nom
que lui donne le pointage.
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';
import { settle, unusedCompLeavePayout, type OvertimeSettlement } from './settlement';
import { dailyCeilingBreaches, weekExceedsCeiling } from './cap';
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);
});
});
const compLeave = (ratio: number, consentRef = 'OT-2026-014'): OvertimeSettlement => ({
mode: 'comp-leave',
agreement: { consentDate: '2026-02-20', consentRef, leaveHoursPerOvertimeHour: ratio },
});
describe('Compensatory leave (Implementing Regulation Art. 22 bis)', () => {
it('grants at least an hour and a half of leave per overtime hour', () => {
const split = settle(10, '2026-03-02', compLeave(1.5), 0, 8);
expect(split).toEqual({ paidHours: 0, leaveHours: 15, scheduleBy: '2026-04-30' });
});
it('pays in cash when the agreed ratio is under 1.5', () => {
// Day for day is the civil-service rule, not the private-sector one.
expect(settle(10, '2026-03-02', compLeave(1), 0, 8)).toEqual({ paidHours: 10, leaveHours: 0 });
});
it('pays in cash when there is no consent reference', () => {
expect(settle(10, '2026-03-02', compLeave(1.5, ''), 0, 8)).toEqual({ paidHours: 10, leaveHours: 0 });
});
it('sends overtime past the 30-day annual cap to payroll', () => {
// 29 days already taken: one 8-hour day left covers 8 / 1.5 overtime hours.
const split = settle(10, '2026-03-02', compLeave(1.5), 29, 8);
expect(split.leaveHours).toBe(8);
expect(split.paidHours).toBeCloseTo(4.667, 3);
});
it('pays unused leave at the actual wage when the employee leaves', () => {
// 15 leave hours x 4800/240 = 300 SAR, more than the 283.33 SAR cash settlement.
expect(unusedCompLeavePayout(15, wage)).toBe(30000);
expect(overtimePay(Array.from({ length: 5 }, () => workday(10)), wage)).toBe(28333);
});
});
describe('Article 106 ceilings', () => {
it('flags any day over 10 actual hours', () => {
expect(dailyCeilingBreaches([workday(10), { ...workday(11), date: '2026-03-03' }])).toEqual(['2026-03-03']);
});
it('flags a week over 60 actual hours', () => {
expect(weekExceedsCeiling(Array.from({ length: 6 }, () => workday(10)))).toBe(false);
expect(weekExceedsCeiling([...Array.from({ length: 6 }, () => workday(10)), workday(1)])).toBe(true);
});
});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 congé compensatoire règle moins que les heures supplémentaires. Vérifiez le ratio de
l'accord. Le jour pour jour est la règle de la fonction publique ; le plancher du secteur privé
est d'une heure et demie de congé par heure supplémentaire, et settle paie toute la mission en
numéraire quand l'accord est en dessous.
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
- Gardez le congé compensatoire hors du moteur d'acquisition des congés annuels : les deux soldes ont des délais, des plafonds et des règles de sortie différents, et ne se rejoignent qu'au solde de tout compte
- 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 avec quatre conditions propres, et deux plafonds qui vivent 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.