Tout système RH opérant en Arabie saoudite porte un passif qui grossit chaque jour et n'apparaît sur aucune facture : le solde de congés annuels. L'article 109 du droit du travail accorde à chaque salarié au moins 21 jours de congés payés par an — 30 une fois franchies cinq années consécutives de service — et l'article 111 convertit tout ce qui n'a pas été pris en argent le jour du départ.
La plupart des systèmes se trompent aux trois mêmes endroits. Ils créditent le droit d'un bloc au 1er janvier au lieu de l'acquérir jour après jour. Ils appliquent le taux de 30 jours à toute l'année du cinquième anniversaire, au lieu de panacher les deux taux autour de la date anniversaire. Et ils stockent le solde comme un nombre flottant de jours multiplié par un salaire journalier flottant — c'est ainsi qu'un solde de tout compte finit à 3 halalas près et qu'un dossier atterrit au tribunal du travail.
Ce tutoriel construit le moteur proprement : une fonction d'acquisition qui suit le texte, un registre qui justifie chaque solde qu'il annonce, et un calcul de sortie qui produit le même chiffre qu'un expert judiciaire. Tout ce que nous construisons ici est la logique de notre calculateur de congés gratuit, que vous pouvez utiliser pour contre-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) - Des bases en arithmétique de dates en JavaScript
- Les articles 109 à 111 du droit du travail saoudien ouverts dans un onglet — nous suivrons le texte, pas le folklore
Aucun framework n'est requis. Le moteur est une bibliothèque TypeScript pure, à déposer dans une route API Next.js, un batch de paie ou une Lambda.
Ce que vous allez construire
Une petite bibliothèque, saudi-leave-engine, exposant quatre fonctions :
accruedDays(hireDate, asOf, policy)— l'acquisition légale de l'embauche à n'importe quelle date, avec le passage de 21 à 30 géré à l'anniversaire, pas à l'année civileleaveBalance(employee, ledger, asOf)— l'acquis moins le pris, dérivé d'un registre d'événementsexitCashOut(employee, ledger, exitDate)— le montant de l'article 111, en halalasleaveLiability(employees, asOf)— la provision que la finance devrait comptabiliser chaque mois
Plus une suite de tests qui rattache chaque règle légale à un scénario présentable à un auditeur.
Étape 1 : lire le texte avant d'écrire la formule
Trois articles font tout le travail. Saisissez leur forme et le code devient presque mécanique.
L'article 109 fixe le droit : au moins 21 jours de congés annuels, portés à au moins 30 jours une fois que le salarié accomplit cinq années consécutives chez le même employeur. Deux détails comptent pour le code. D'abord, ce sont des planchers — un contrat peut accorder plus, jamais moins : les chiffres sont des paramètres de politique, pas des constantes. Ensuite, le salaire de congé se paie d'avance au moment du départ en congé, au salaire en vigueur à cette date.
L'article 110 régit la planification. Le salarié peut, avec l'accord de l'employeur, reporter ses congés sur l'année suivante. L'employeur peut différer les congés après la fin de leur année d'exigibilité pendant 90 jours au plus si les conditions de travail l'exigent ; aller au-delà requiert le consentement écrit du salarié, et même alors le congé ne peut glisser au-delà de la fin de l'année qui suit l'année d'exigibilité. Notez ce que l'article 110 ne dit pas : il ne dit jamais qu'un solde non pris est perdu. Il discipline la planification, pas le droit.
L'article 111 est l'article de l'argent. Le salarié qui part avant d'avoir pris ses congés acquis reçoit un salaire pour les jours non pris, fractions d'année comprises, au prorata du temps servi, sur la base du salaire à la date d'exigibilité du congé. En pratique — et dans les calculateurs du ministère lui-même — le taux journalier est le salaire mensuel divisé par 30.
Une dernière définition importe : depuis l'amendement de 2019 par décret royal M/46, le droit du travail définit l'année comme 365 jours sauf stipulation contractuelle contraire. Des contrats anciens rédigés sur le calendrier hégirien existent ; faites de la base de calcul un paramètre de configuration, avec 365 par défaut.
Attention : le bug d'implémentation le plus répandu consiste à créditer les congés annuellement au lieu de les acquérir quotidiennement. La règle du prorata de l'article 111 fait de la vision journalière la seule juridiquement pertinente : un salarié qui démissionne au jour 100 de son année de congés a droit à 100/365 du droit de cette année, que votre système le lui ait « accordé » ou non.
Étape 2 : l'argent en halalas, jamais en flottants
Même règle que dans chaque moteur de paie que nous construisons : l'argent est un nombre entier de la plus petite unité. Un riyal vaut 100 halalas. Les jours acquis peuvent être fractionnaires — c'est inévitable et sans danger — mais dès que les jours rencontrent l'argent, on arrondit une seule fois, à la fin, sans jamais accumuler d'erreur flottante.
// src/money.ts
/** Money is always an integer number of halalas (1 SAR = 100 halalas). */
export type Halalas = number;
export function sar(amount: number): Halalas {
return Math.round(amount * 100);
}
export function formatSar(h: Halalas): string {
return (h / 100).toFixed(2) + " SAR";
}
/**
* The daily wage under the Ministry's convention: monthly wage / 30.
* Kept as an exact rational (numerator over 30) until the final rounding.
*/
export function leaveValue(days: number, monthlyWage: Halalas): Halalas {
return Math.round((days * monthlyWage) / 30);
}leaveValue multiplie avant de diviser et arrondit une seule fois. 21,5 jours à
8 000 SAR donne Math.round(21.5 * 800000 / 30) soit 573 333 halalas, soit
5 733,33 SAR — reproductible au halala près sur toutes les machines.
Étape 3 : modéliser la politique, pas seulement la loi
L'article 109 donne des planchers. Les contrats réels accordent 22, 25 ou 30 jours dès le premier jour ; certains règlements internes améliorent les conditions d'ancienneté. Faites de la loi le défaut, et de chaque chiffre un paramètre :
// src/policy.ts
export interface LeavePolicy {
/** Days per year before the seniority threshold. Statutory floor: 21. */
baseDays: number;
/** Days per year after the threshold. Statutory floor: 30. */
seniorDays: number;
/** Consecutive years of service that trigger the senior rate. Statute: 5. */
seniorAfterYears: number;
/** Days in a leave year. 365 since the M/46 amendment, unless the contract says otherwise. */
yearBasis: number;
}
export const STATUTORY_POLICY: LeavePolicy = {
baseDays: 21,
seniorDays: 30,
seniorAfterYears: 5,
yearBasis: 365,
};
export function resolvePolicy(overrides?: Partial<LeavePolicy>): LeavePolicy {
const p = { ...STATUTORY_POLICY, ...overrides };
if (p.baseDays < STATUTORY_POLICY.baseDays || p.seniorDays < STATUTORY_POLICY.seniorDays) {
throw new Error(
"Policy below the Article 109 floor: contracts may improve on 21/30 days, never reduce them."
);
}
return p;
}Le garde-fou n'est pas décoratif. L'article 8 du droit du travail frappe de nullité toute clause moins favorable que la loi — un fichier de configuration ne doit pas pouvoir encoder un contrat illégal.
Étape 4 : acquérir au jour le jour, et panacher l'anniversaire
Les dates sont la partie qui mérite qu'on ralentisse. Nous travaillons en dates UTC pures pour éviter la dérive des fuseaux, et nous calculons les anniversaires de service par date calendaire — le cinquième anniversaire d'une embauche du 15/03/2021 est le 15/03/2026, années bissextiles comprises, et non « embauche plus 1 825 jours ».
// src/accrual.ts
import { LeavePolicy, resolvePolicy } from "./policy";
const DAY_MS = 86_400_000;
function utc(date: string): number {
const [y, m, d] = date.split("-").map(Number);
return Date.UTC(y, m - 1, d);
}
/** Whole days between two ISO dates (end exclusive). */
export function daysBetween(from: string, to: string): number {
return Math.round((utc(to) - utc(from)) / DAY_MS);
}
/** The nth service anniversary of a hire date, as an ISO date. */
export function anniversary(hireDate: string, n: number): string {
const [y, m, d] = hireDate.split("-").map(Number);
const t = new Date(Date.UTC(y + n, m - 1, d));
return t.toISOString().slice(0, 10);
}
/**
* Statutory accrued leave days from hire to `asOf` (exclusive).
* Days before the seniority anniversary accrue at baseDays/yearBasis,
* days after it at seniorDays/yearBasis — blended, not retroactive.
*/
export function accruedDays(
hireDate: string,
asOf: string,
overrides?: Partial<LeavePolicy>
): number {
const policy = resolvePolicy(overrides);
const switchDate = anniversary(hireDate, policy.seniorAfterYears);
const totalDays = Math.max(0, daysBetween(hireDate, asOf));
const baseServiceDays = Math.max(0, Math.min(totalDays, daysBetween(hireDate, switchDate)));
const seniorServiceDays = totalDays - baseServiceDays;
return (
(baseServiceDays * policy.baseDays) / policy.yearBasis +
(seniorServiceDays * policy.seniorDays) / policy.yearBasis
);
}Faites tourner le chiffre que tout le monde rate. Un salarié embauché le 15/03/2021, contrôlé le 15/09/2026 — six mois après son cinquième anniversaire :
accruedDays("2021-03-15", "2026-09-15");
// base period : 2021-03-15 → 2026-03-15 = 1,826 days at 21/365 = 105.06 days
// senior : 2026-03-15 → 2026-09-15 = 184 days at 30/365 = 15.12 days
// total ≈ 120.18 daysLa version naïve applique le taux de 30 jours à toute l'année contenant l'anniversaire — en remontant au 15/03/2025 — et surestime le solde de 9 jours, soit une erreur de 2 400 SAR sur un salaire de 8 000 SAR, qui dort dans les livres jusqu'à ce qu'un solde de tout compte la révèle. Le panachage est ce qu'exige la logique du prorata de l'article 111.
Étape 5 : le registre — un solde défendable
Un solde qui n'est qu'un nombre dans une colonne ne survit pas à un litige. Stockez des événements, dérivez le solde. Chaque congé pris, chaque ajustement manuel, chaque solde d'ouverture issu d'une migration est une ligne datée et motivée :
// src/ledger.ts
import { Halalas } from "./money";
import { LeavePolicy } from "./policy";
import { accruedDays } from "./accrual";
export type LeaveEvent =
| { type: "taken"; from: string; to: string; note?: string }
| { type: "adjustment"; date: string; days: number; note: string };
export interface Employee {
id: string;
hireDate: string;
/** Current monthly wage in halalas — the Article 111 basis at exit. */
monthlyWage: Halalas;
policy?: Partial<LeavePolicy>;
}
function takenDays(e: LeaveEvent): number {
if (e.type === "adjustment") return -e.days; // positive adjustment credits the balance
// Leave spans are inclusive on both ends: 2026-08-02 → 2026-08-06 is 5 days.
const ms = Date.parse(e.to) - Date.parse(e.from);
return Math.round(ms / 86_400_000) + 1;
}
export function leaveBalance(
employee: Employee,
ledger: LeaveEvent[],
asOf: string
): number {
const accrued = accruedDays(employee.hireDate, asOf, employee.policy);
const consumed = ledger
.filter((e) => (e.type === "taken" ? e.from : e.date) <= asOf)
.reduce((sum, e) => sum + takenDays(e), 0);
return accrued - consumed;
}Deux décisions de conception méritent une note.
Intervalles inclusifs. Dans la pratique RH saoudienne, un congé « du 2 au 6 » compte cinq jours, bornes comprises. Encoder l'intervalle plutôt qu'un nombre de jours permet au registre de répondre plus tard à des questions que le solde ignore — quelle part de ce congé tombait dans une fenêtre de report, s'il chevauchait un jour férié de l'Aïd que votre règlement intérieur exclut.
Les soldes négatifs sont des états légaux, pas des erreurs. La règle du paiement d'avance de l'article 109 fait que les employeurs accordent couramment des congés avant leur acquisition — une nouvelle recrue qui prend dix jours au quatrième mois est simplement à quelques jours en négatif, que l'acquisition future rembourse. Votre interface peut avertir ; votre moteur ne doit pas lever d'exception. À la sortie, un solde négatif devient une retenue sur le solde de tout compte — exactement ce que produira la fonction d'indemnisation, le signe faisant le travail.
Étape 6 : suivre l'horloge de l'article 110
L'article 110 n'efface pas les soldes, mais il leur appose des dates, et un moteur incapable de répondre à « lesquels de ces 34 jours sont dans leur fenêtre légale de report ? » laisse les RH exposées dans les deux sens — presser des salariés dont les congés sont légalement reportés, ou laisser s'entasser un passif vieillissant dont personne n'a recueilli les consentements écrits.
La règle se réduit à un horizon : les congés dus l'année N se prennent l'année N ; l'employeur seul peut les pousser jusqu'à 90 jours dans l'année N+1 ; avec le consentement écrit du salarié, jusqu'à la fin de l'année N+1, pas plus loin. Nous produisons un rapport d'ancienneté des soldes, et laissons la décision de politique aux humains :
// src/deferral.ts
import { anniversary, daysBetween } from "./accrual";
export interface LeaveYearAging {
/** Leave year index (1 = first year of service). */
year: number;
dueYearEnd: string;
employerDeferralEnd: string; // dueYearEnd + 90 days
consentDeferralEnd: string; // end of the following leave year
status: "current" | "employer-window" | "consent-required" | "beyond-limit";
}
export function agingFor(hireDate: string, year: number, asOf: string): LeaveYearAging {
const dueYearEnd = anniversary(hireDate, year);
const employerDeferralEnd = addDays(dueYearEnd, 90);
const consentDeferralEnd = anniversary(hireDate, year + 1);
const status =
asOf <= dueYearEnd ? "current"
: asOf <= employerDeferralEnd ? "employer-window"
: asOf <= consentDeferralEnd ? "consent-required"
: "beyond-limit";
return { year, dueYearEnd, employerDeferralEnd, consentDeferralEnd, status };
}
function addDays(date: string, n: number): string {
const t = new Date(Date.parse(date) + n * 86_400_000);
return t.toISOString().slice(0, 10);
}Un drapeau beyond-limit est un constat de conformité, pas une radiation : l'argent du
salarié reste protégé par l'article 111 dans tous les cas. Ce que le drapeau dit à
l'employeur, c'est que l'obligation de planification a été manquée — le genre de motif
qu'un inspecteur du travail lit comme systémique.
Étape 7 : l'indemnité de sortie — l'article 111 en une fonction
À la fin de la relation, tout converge : l'acquisition court jusqu'à la date de sortie, le registre déduit ce qui a été pris, et le reste est valorisé au salaire journalier. Les fractions d'année comptent — c'est l'instruction explicite de l'article — et un solde négatif se retourne en avance récupérable :
// src/settlement.ts
import { Employee, LeaveEvent, leaveBalance } from "./ledger";
import { Halalas, leaveValue } from "./money";
export interface LeaveCashOut {
balanceDays: number;
dailyWage: Halalas;
/** Positive: owed to the employee. Negative: advance leave recoverable from the settlement. */
amount: Halalas;
}
export function exitCashOut(
employee: Employee,
ledger: LeaveEvent[],
exitDate: string
): LeaveCashOut {
const balanceDays = leaveBalance(employee, ledger, exitDate);
return {
balanceDays,
dailyWage: Math.round(employee.monthlyWage / 30),
amount: leaveValue(balanceDays, employee.monthlyWage),
};
}Cette ligne s'inscrit à côté de l'indemnité de fin de service dans le solde de tout compte — article 111 pour les congés, articles 84 et 85 pour la gratification, dont vous pouvez vérifier les montants avec notre calculateur de fin de service. Les deux diffèrent sur un point important : la gratification se calcule sur le dernier salaire de par la loi, tandis que le salaire de congé suit le salaire à la date d'exigibilité du congé — pour la fraction de l'année en cours les deux coïncident, mais si vous avez accordé des augmentations en cours d'année et portez de vieux soldes, l'écart est de l'argent réel, et ce sont les dates de votre registre qui permettent de le calculer honnêtement.
Étape 8 : provisionner chaque mois, sinon le solde est une surprise
La même discipline que pour la provision de fin de service : un passif qui ne se matérialise qu'au départ a l'habitude de se matérialiser d'un coup. La finance doit voir le passif de congés bouger chaque mois :
// src/liability.ts
import { Employee, LeaveEvent, leaveBalance } from "./ledger";
import { Halalas, leaveValue } from "./money";
export function leaveLiability(
staff: Array<{ employee: Employee; ledger: LeaveEvent[] }>,
asOf: string
): Halalas {
return staff.reduce((total, { employee, ledger }) => {
const days = leaveBalance(employee, ledger, asOf);
return total + Math.max(0, leaveValue(days, employee.monthlyWage));
}, 0);
}Pour une entreprise de 40 personnes au salaire moyen de 7 000 SAR, chaque année légale non prise représente environ 196 000 SAR de passif silencieux. Le suivre mensuellement est aussi ce qui rend actionnable le rapport d'ancienneté de l'article 110 — les deux chiffres bougent ensemble.
Tester votre implémentation
Rattachez chaque règle légale à un scénario. Voici les tests qui attrapent les bugs classiques :
// test/engine.test.ts
import { describe, expect, it } from "vitest";
import { accruedDays, anniversary } from "../src/accrual";
import { leaveBalance } from "../src/ledger";
import { exitCashOut } from "../src/settlement";
import { sar } from "../src/money";
describe("Article 109 accrual", () => {
it("accrues 21 days over a full early year", () => {
expect(accruedDays("2025-01-01", "2026-01-01")).toBeCloseTo(21, 5);
});
it("blends the rate at the fifth anniversary, not the calendar year", () => {
const total = accruedDays("2021-03-15", "2026-09-15");
expect(total).toBeCloseTo((1826 * 21) / 365 + (184 * 30) / 365, 5);
});
it("rejects sub-statutory policies", () => {
expect(() => accruedDays("2025-01-01", "2026-01-01", { baseDays: 15 })).toThrow();
});
});
describe("Article 111 cash-out", () => {
const employee = {
id: "E-1001",
hireDate: "2023-06-01",
monthlyWage: sar(8000),
};
it("pays the pro-rated fraction on early exit", () => {
const { balanceDays, amount } = exitCashOut(employee, [], "2023-09-09");
expect(balanceDays).toBeCloseTo((100 * 21) / 365, 5); // 100 days of service
expect(amount).toBe(153_425); // 1,534.25 SAR, rounded once
});
it("turns advance leave into a negative settlement line", () => {
const ledger = [{ type: "taken", from: "2023-07-02", to: "2023-07-11" } as const];
const { amount } = exitCashOut(employee, ledger, "2023-09-09");
expect(amount).toBeLessThan(0); // 10 days taken, ~5.75 accrued
});
});
describe("service anniversaries", () => {
it("handles leap-day hires without drifting", () => {
expect(anniversary("2024-02-29", 5)).toBe("2029-03-01");
});
});Puis contre-vérifiez une poignée de salariés réels avec notre calculateur de congés — il exécute exactement cette logique d'acquisition ; tout désaccord signale un écart d'entrée qui mérite enquête, le plus souvent la date d'embauche ou un paramètre de politique oublié.
Dépannage
Des soldes à quelques halalas des rapports du prestataire de paie. Presque toujours un
arrondi prématuré de leur côté — un salaire journalier arrondi multiplié par des jours
fractionnaires. Votre leaveValue arrondit une seule fois ; quand les chiffres divergent,
le vôtre est le défendable. Montrez l'arithmétique.
Des sauts au cinquième anniversaire dans les rapports historiques. Si un rapport montre une marche de plusieurs jours à l'anniversaire, quelqu'un a appliqué le taux de 30 jours rétroactivement à toute l'année. Relancez avec l'acquisition panachée et corrigez.
Contrats sur base hégirienne. Les contrats antérieurs à l'amendement M/46, ou
explicitement hégiriens, demandent yearBasis: 354 et des anniversaires hégiriens. L'API
Intl de JavaScript avec le calendrier islamic-umalqura sait les dériver ; gardez ces
salariés sur un paramétrage explicite plutôt que sur un drapeau global.
Soldes d'ouverture migrés. Ne fabriquez jamais d'historique synthétique. Un seul
événement adjustment daté de la migration, avec une note pointant vers le rapport de
l'ancien système, garde le registre honnête et la piste d'audit courte.
Prochaines étapes
- Alimentez les mêmes données salariés vers votre générateur et validateur de fichier WPS — un congé sans solde enregistré dans ce registre doit se réconcilier avec le fichier de salaires que vous soumettez
- Suivez le parcours des chiffres en aval dans l'intégration paie Mudad et la pile de plateformes Qiwa
- Laissez les salariés vérifier leurs propres chiffres avec le calculateur de congés gratuit
Conclusion
Les congés annuels en Arabie saoudite ne sont pas un avantage à suivre de loin — c'est un passif légal avec une règle d'acquisition quotidienne, un seuil d'ancienneté, une horloge de planification et une conversion en argent garantie à la sortie. Le moteur qui les traite correctement est petit : argent en entiers, acquisition quotidienne panachée, registre d'événements, un seul arrondi à la fin. Ce qu'il rapporte est grand — des soldes de tout compte alignés sur l'arithmétique du juge, des provisions alignées sur la réalité, et une piste d'audit qui répond aux questions au lieu d'en soulever.
Si vos soldes de congés vivent dans un tableur, ou si les chiffres de votre SIRH et ceux de votre paie divergent sans que personne sache pourquoi, dites-nous à quoi ressemble votre système — nous ferons passer un échantillon de vos données réelles dans un moteur comme celui-ci et vous montrerons exactement où les deux divergent, avant qu'un solde de tout compte ne le fasse à notre place.