La plupart des systèmes de paie saoudiens savent calculer une gratification de fin de service. Très peu savent décider si elle est due.
C'est pourtant cette décision qui coûte cher. Le même salarié, le même salaire, les mêmes sept années d'ancienneté produisent quatre soldes de tout compte radicalement différents selon la manière dont la relation de travail a pris fin — et l'écart entre la branche la moins chère et la plus chère se compte couramment en centaines de milliers de riyals. L'employeur qui a licencié sur le fondement de l'article 80 sans l'avertissement écrit exigé par le texte ne perd pas seulement le bénéfice de cet article. Le licenciement devient illicite, la gratification intégrale de l'article 84 revient, l'indemnité de l'article 77 s'y ajoute, et l'indemnité compensatrice du préavis de l'article 75 s'ajoute encore par-dessus.
Ce tutoriel construit cette décision en code : un moteur de solde de tout compte qui qualifie la rupture d'abord et la chiffre ensuite. C'est la branche qui surplombe le moteur de fin de service et décide avec quel barème l'appeler.
Prérequis
- Node.js 20 ou plus récent et TypeScript 5.x
- De l'aisance avec les unions discriminées et les
switchexhaustifs - Le texte publié du Code du travail saoudien ouvert dans un autre onglet (laws.boe.gov.sa). Les numéros d'articles sont donnés ici pour que vous vérifiiez chaque règle à la source — faites-le, le texte a été amendé à plusieurs reprises et votre date de conformité prime sur celle de cet article
- Une familiarité avec l'arithmétique monétaire en entiers. Si vous ne l'avez pas lu, le moteur de calcul des heures supplémentaires détaille la représentation en halalas que ce tutoriel réutilise
Ce que vous allez construire
Un module pur, sans dépendances, exposant un point d'entrée unique :
settleTermination(input: TerminationInput): SettlementIl renvoie un solde détaillé — une ligne par poste légal, chacune portant l'article dont elle découle — accompagné d'une qualification expliquant pourquoi la rupture a été chiffrée ainsi, et de la date à laquelle l'article 88 impose le paiement.
Étape 1 : qualifier la rupture avant de chiffrer quoi que ce soit
L'erreur d'architecture la plus répandue dans ce domaine est une fonction nommée
calculateEndOfService(salary, years). Elle présuppose que la rupture était ordinaire, et chaque
licenciement illicite doit alors être traité par un humain qui contourne le système.
Modélisez plutôt la rupture comme une donnée. Cinq manières de mettre fin à la relation de travail ont une incidence financière :
/** Article 74 lists the lawful causes of termination that carry no fault. */
type Article74Cause =
| 'mutual-written-agreement'
| 'fixed-term-expiry'
| 'retirement-age'
| 'force-majeure'
| 'establishment-closure'
| 'activity-cessation';
type Exit =
/** Employer ends an indefinite contract for a valid reason, with notice (Art. 75). */
| { by: 'employer'; ground: 'article-75-valid-reason'; reasonInWriting: boolean }
/** Employer ends without a lawful cause — the Article 77 exposure. */
| { by: 'employer'; ground: 'no-lawful-cause' }
/** Employer dismisses for one of the nine Article 80 cases. */
| { by: 'employer'; ground: 'article-80'; case: Article80Case; evidence: Article80Evidence }
/** A neutral Article 74 ending: expiry, retirement, closure, force majeure. */
| { by: 'neither'; ground: 'article-74'; cause: Article74Cause }
/** The worker resigns in the ordinary way. */
| { by: 'worker'; ground: 'resignation' }
/** The worker leaves under Article 81 — priced as an employer termination. */
| { by: 'worker'; ground: 'article-81'; case: Article81Case; noticeGiven: boolean };Chaque branche ci-dessous est pilotée par cette union. Lorsque le texte est amendé, vous ajoutez une
variante et le compilateur vous signale chaque endroit qui exige une décision — c'est toute la raison
de modéliser ainsi plutôt qu'avec des booléens nommés isUnfairDismissal.
Étape 2 : deux bases salariales et un calendrier, dont aucun ne va de soi
L'article 2 du Code du travail définit le salaire (الأجر) comme le salaire de base augmenté de toutes les majorations et indemnités dues au salarié en contrepartie de son travail. Le salaire de base (الأجر الأساسي) est le montant contractuel avant ces ajouts.
L'indemnité de l'article 77, l'indemnité compensatrice de préavis de l'article 76 et la gratification de l'article 84 se calculent toutes sur le salaire réel, non sur le salaire de base. Les systèmes qui les chiffrent sur la base sous-évaluent chaque solde du montant des indemnités de logement et de transport — soit, en pratique saoudienne, couramment 25 % à 35 % du total.
L'article 2 définit également le mois comme trente jours, sauf stipulation contraire du contrat ou du règlement intérieur : l'année légale fait donc 360 jours, et non 365 ni 365,25. Quant à l'article 10, il tranche la question du calendrier : «تحسب جميع المدد والمواعيد المنصوص عليها في هذا النظام بالتقويم الهجري، ما لم ينص في عقد العمل أو لائحة تنظيم العمل على خلاف ذلك» — toutes les durées du Code courent sur le calendrier hégirien, sauf stipulation contraire. Une année hégirienne fait environ 354 jours : un contrat muet sur ce point accumule l'ancienneté environ 3 % plus vite qu'une lecture grégorienne.
Ne reconstruisez pas cette arithmétique ici. Le
moteur de gratification de fin de service
exporte une fonction serviceDays360 qui reproduit le calculateur du travail du ministère de la
Justice au halala près sur 45 cas de référence. Importez-la, pour que les deux moteurs ne puissent
jamais diverger sur la durée travaillée :
import { serviceDays360, parseDate, type CalendarBasis } from './end-of-service';
/** All money is integer halalas. 1 SAR = 100 halalas. Floats do not belong here. */
type Halalas = number;
const MS_PER_DAY = 86_400_000;
type Wage = {
/** الأجر الأساسي — the contractual basic. */
basic: Halalas;
/** Housing, transport and any other allowance due for the work itself. */
allowances: Halalas;
};
/** الأجر — Article 2. This is the base for Articles 76, 77 and 84. */
const actualMonthlyWage = (w: Wage): Halalas => w.basic + w.allowances;
/**
* Labour entitlements are priced per calendar day on a 30-day month, not on the
* actual length of the month. A 31-day month does not make the day rate cheaper.
*/
const dailyWage = (w: Wage): Halalas => divideRoundHalfUp(actualMonthlyWage(w), 30);
function divideRoundHalfUp(numerator: number, denominator: number): number {
return Math.floor((numerator * 2 + denominator) / (denominator * 2));
}Commissions et rémunération au rendement. L'article 86 exclut les composantes variables selon le rendement de la règle directe du « dernier salaire » et impose une moyenne. Si vos effectifs sont commissionnés, le salaire transmis à ce moteur doit déjà être cette moyenne — résolvez-la en amont et gardez le moteur pur.
Étape 3 : le type de contrat détermine la forme de l'indemnité de l'article 77
L'article 77 chiffre la rupture illicite de deux façons, et le type de contrat est l'aiguillage :
- Contrat à durée indéterminée : quinze jours de salaire par année d'ancienneté
- Contrat à durée déterminée : le salaire de la période restante du contrat
Les deux sont ensuite soumis à un plancher : l'indemnité ne peut être inférieure à deux mois de salaire. C'est ce plancher que les implémentations oublient, et c'est lui qui tranche la plupart des dossiers à faible ancienneté. Un salarié licencié illicitement après six mois en contrat à durée indéterminée aboutit à 7,5 jours — et perçoit deux mois.
type Contract =
| { kind: 'indefinite' }
| {
kind: 'fixed';
/** ISO date this term began. Article 55 measures the contract, not the person. */
startedOn: string;
/** ISO date the term was due to end. */
endsOn: string;
/** How many times this fixed term has already been renewed. */
renewals: number;
/** Article 55/2 only runs on a term that carries a renewal clause. */
hasRenewalClause: boolean;
/** Both limbs of Article 55 require that the parties kept performing. */
stillPerforming: boolean;
};Sous la branche à durée déterminée se cache un piège, et ce n'est pas celui contre lequel la plupart des implémentations se prémunissent. L'article 55 comporte deux branches distinctes, et ce qui les sépare est la clause de renouvellement :
- 55/1 — un terme sans clause de renouvellement arrive à échéance, «فإذا استمر طرفاه في تنفيذه عُد العقد مجدداً لمدة غير محددة». Les parties ont poursuivi : le contrat est désormais à durée indéterminée. Rien d'autre n'est exigé, ni quatre ans, ni trois renouvellements.
- 55/2 — un terme qui comporte une clause de renouvellement se renouvelle pour la durée convenue, jusqu'à ce qu'il ait été renouvelé trois fois consécutives ou que la durée initiale augmentée des renouvellements atteigne quatre ans, «أيهما أقل» — le premier des deux — les parties ayant poursuivi l'exécution.
Les deux branches mesurent le contrat, pas le salarié. Les quatre ans de 55/2 sont «مدة العقد الأصلي مع مدة التجديد» : la durée propre du terme. Un salarié de vingt ans d'ancienneté qui signe un nouveau contrat de deux ans est en contrat à durée déterminée, et un moteur alimenté par son ancienneté requalifiera ce contrat le jour de sa signature. L'erreur coûte cher dans les deux sens — elle chiffre un non-renouvellement licite comme une exposition à l'article 77.
L'article 37 place les non-Saoudiens hors de tout cela. L'article 12 du règlement d'application est sans détour : le contrat d'un non-Saoudien est à durée déterminée «مهما طالت مدته، أو مدد تجديده، وفي جميع الأحوال لا يتحول إلى عقد غير محدد المدة حتى لو اتفق الطرفان على ذلك» — il ne se requalifie pas, même si les deux parties en conviennent.
const FIXED_TERM_RENEWAL_LIMIT = 3;
const FIXED_TERM_YEARS_LIMIT = 4;
/** Article 2 defines the month as thirty days, so the statutory year is 360. */
const DAYS_PER_YEAR = 360;
/**
* Article 55 — normalise before pricing. Two separate limbs, and both measure the
* contract rather than the worker:
*
* 55/1 — a term with no renewal clause runs out, the parties keep performing, and the
* contract is deemed renewed for an indefinite period.
* 55/2 — a term that does carry a renewal clause renews for the agreed period, until it
* has been renewed three consecutive times or the original term plus the renewals
* reaches four years, whichever comes first, and the parties keep performing.
*
* Service with the same employer under earlier contracts does not convert the current
* one. Article 37, and Article 12 of the Implementing Regulation, put a non-Saudi
* outside this altogether.
*/
function normaliseContract(
contract: Contract,
asOf: string,
calendar: CalendarBasis,
isSaudi: boolean,
): Contract {
if (contract.kind !== 'fixed' || !isSaudi || !contract.stillPerforming) return contract;
const expired = parseDate(asOf, 'terminatedOn') > parseDate(contract.endsOn, 'contract.endsOn');
if (!contract.hasRenewalClause) {
return expired ? { kind: 'indefinite' } : contract;
}
const termYears =
serviceDays360(
parseDate(contract.startedOn, 'contract.startedOn'),
parseDate(contract.endsOn, 'contract.endsOn'),
calendar,
) / DAYS_PER_YEAR;
const converted =
contract.renewals >= FIXED_TERM_RENEWAL_LIMIT || termYears >= FIXED_TERM_YEARS_LIMIT;
return converted ? { kind: 'indefinite' } : contract;
}Étape 4 : le préavis de l'article 75, et l'article 76 lorsqu'il n'a pas été donné
L'article 75 impose à la partie qui met fin à un contrat à durée indéterminée de notifier un préavis
écrit — et les deux parties ne doivent pas le même préavis. Lorsque le salaire est mensuel,
75/1/أ donne au salarié «ثلاثين يوماً» et 75/1/ب donne à l'employeur «ستين يوماً». Lorsque le
salaire n'est pas mensuel, 75/2 place les deux parties à trente jours. Une fonction
noticeDaysRequired(frequency) unique qui renvoie 60 pour toute rupture à salaire mensuel facture au
salarié démissionnaire deux mois de préavis qu'il n'a jamais dus.
L'article 76 oblige la partie qui n'a pas respecté le préavis à verser à l'autre le salaire de la même période, «ما لم يتفق الطرفان على أكثر من ذلك» — sauf accord sur davantage. Un montant contractuel supérieur au montant légal s'impose donc ; un montant inférieur ne s'impose pas, car l'article 8 frappe de nullité toute réduction d'un droit conféré par la loi.
Deux autres détails décident de la justesse du calcul. D'abord, l'indemnité compensatrice se calcule sur le salaire réel, au taux journalier de trente jours de l'étape 2 — 60 jours valent deux mois de salaire, et non « deux cycles de paie ». Ensuite, elle est due indépendamment de l'article 77. Un salarié licencié illicitement et raccompagné le jour même a droit aux deux : deux mois minimum au titre de l'article 77 et 60 jours au titre de l'article 76. Les systèmes qui les traitent comme des alternatives divisent par deux tout solde de cette forme.
type PayFrequency = 'monthly' | 'other';
type TerminatingParty = 'employer' | 'worker';
/**
* Article 75 — the notice period is not symmetric. Where the wage is paid monthly the
* employer owes sixty days (75/1/b) and the worker owes thirty (75/1/a). Where it is not
* paid monthly, 75/2 puts both parties on thirty.
*/
const noticeDaysRequired = (f: PayFrequency, by: TerminatingParty): number =>
f === 'monthly' && by === 'employer' ? 60 : 30;
/**
* Article 76 — the shortfall between the notice the law requires of the party who ended
* the contract and the notice actually served. Never negative: serving longer than
* required earns nothing back. An agreed figure above the statutory one stands; one
* below it does not.
*/
function noticeInLieu(
wage: Wage,
frequency: PayFrequency,
by: TerminatingParty,
daysServed: number,
contract: Contract,
agreedNoticeCompensation?: Halalas,
): Halalas {
// Notice is an indefinite-contract obligation. A fixed term ending on its own
// date needs no notice — the end date was the notice.
if (contract.kind === 'fixed') return 0;
const required = noticeDaysRequired(frequency, by);
const shortfall = Math.max(0, required - daysServed);
if (shortfall === 0) return 0;
const agreedDaily =
agreedNoticeCompensation === undefined
? 0
: divideRoundHalfUp(agreedNoticeCompensation, required);
return shortfall * Math.max(dailyWage(wage), agreedDaily);
}Étape 5 : l'indemnité de l'article 77 et le plancher qui prime sur le contrat
L'article 77 s'ouvre par une réserve : sauf si le contrat prévoit une indemnité déterminée en cas de rupture pour un motif illicite. Les contrats de travail du marché saoudien exploitent constamment cette ouverture, et une large part d'entre eux fixent un montant inférieur au plancher légal — un mois est le grand classique.
Cette clause ne tient pas. L'article 8 frappe de nullité toute renonciation ou transaction portant sur les droits que la loi confère au salarié : une indemnité contractuelle inférieure au plancher de deux mois est donc nulle à concurrence de l'écart. Modélisez le montant contractuel comme une entrée, puis appliquez le plancher après :
const MIN_COMPENSATION_MONTHS = 2;
const INDEFINITE_DAYS_PER_YEAR = 15;
type Article77Input = {
wage: Wage;
contract: Contract;
/** Service on the Article 2 / Article 10 basis — see `settleTermination`. */
serviceYears: number;
/** ISO date the relationship actually ended. */
terminatedOn: string;
/** The calendar the contract runs on (Article 10). */
calendar: CalendarBasis;
/** A figure written into the contract under the Article 77 opening clause. */
contractualCompensation?: Halalas;
};
function article77Compensation(input: Article77Input): {
amount: Halalas;
basis: 'contractual' | 'per-year' | 'remaining-term';
flooredUp: boolean;
} {
const { wage, contract, serviceYears, terminatedOn, calendar, contractualCompensation } = input;
const monthly = actualMonthlyWage(wage);
const floor = MIN_COMPENSATION_MONTHS * monthly;
let amount: Halalas;
let basis: 'contractual' | 'per-year' | 'remaining-term';
if (contractualCompensation !== undefined) {
amount = contractualCompensation;
basis = 'contractual';
} else if (contract.kind === 'indefinite') {
amount = Math.round(serviceYears * INDEFINITE_DAYS_PER_YEAR * dailyWage(wage));
basis = 'per-year';
} else {
amount = remainingTermWage(wage, terminatedOn, contract.endsOn, calendar);
basis = 'remaining-term';
}
// Article 8 — a contract may improve on the statute, never reduce it.
return amount < floor
? { amount: floor, basis, flooredUp: true }
: { amount, basis, flooredUp: false };
}
/**
* Wage for the unexpired part of a fixed term, on the Article 2 thirty-day month and the
* Article 10 calendar. The first unexpired day is the day *after* termination; the
* contract's own end date counts.
*/
function remainingTermWage(
wage: Wage,
from: string,
to: string,
calendar: CalendarBasis,
): Halalas {
const firstUnexpired = new Date(parseDate(from, 'terminatedOn').getTime() + MS_PER_DAY);
const end = parseDate(to, 'contract.endsOn');
if (end < firstUnexpired) return 0;
return serviceDays360(firstUnexpired, end, calendar) * dailyWage(wage);
}serviceYears est ici fractionnaire, et il provient de la mesure des articles 2 et 10 de l'étape 2 —
non d'une année grégorienne divisée par 365,25.
Correction du 2026-09-21. Une version antérieure de ce tutoriel affirmait que les quinze jours de l'article 77 sont acquis sur la fraction d'année « à l'inverse du traitement par dates anniversaires qu'utilise la gratification de l'article 84 ». Les deux articles y étaient inversés. C'est l'article 84 qui énonce explicitement la règle du prorata — «ويستحق العامل مكافأة عن أجزاء السنة بنسبة ما قضاه منها في العمل» — tandis que l'article 77 ne comporte aucune clause de fraction d'année. Traiter la fraction d'année au prorata sous l'article 77 est la pratique dominante, mais c'est une lecture de «عن كل سنة من سنوات خدمة العامل», non une citation. Dites-le dans votre commentaire de code plutôt que d'invoquer une phrase qui figure dans l'autre article.
Cette distinction pèse moins qu'il n'y paraît, et l'arithmétique dit pourquoi. Quinze jours par an n'atteignent soixante jours — le plancher de deux mois — qu'à quatre ans d'ancienneté : en deçà de quatre ans, c'est le plancher qui fixe le montant et la lecture de la fraction d'année ne peut rien y changer. Au-delà, la fraction d'année vaut 15/360 d'un salaire mensuel par jour supplémentaire, soit environ 41 SAR sur un salaire de 10 000.
Un piège d'arrondi se loge exactement sur cette frontière. Le taux journalier est un trentième
arrondi du salaire mensuel : soixante jours au taux journalier sont donc vingt halalas en dessous
de deux mois. Comparez le montant calculé au plancher en halalas, comme le fait le code ci-dessus —
un contrôle écrit sous la forme « est-ce plus de 60 jours » renvoie flooredUp: false sur un montant
qui est en réalité sous le plancher.
Étape 6 : l'article 80 — neuf cas et la porte probatoire qui les tranche
L'article 80 autorise l'employeur à rompre sans gratification, sans préavis et sans indemnité. Il est limitatif : neuf cas énumérés, sans clause résiduelle de « toute autre faute grave ». En substance :
- Voies de fait contre l'employeur, le directeur responsable ou un supérieur, pendant le travail ou à cause de celui-ci
- Inexécution des obligations essentielles du contrat, désobéissance aux ordres licites ou non-respect des consignes écrites de sécurité — après un avertissement écrit
- Mauvaise conduite établie, ou acte portant atteinte à l'honneur ou à la probité
- Acte ou omission intentionnels causant une perte matérielle à l'employeur, à condition que celui-ci en informe les autorités compétentes dans les 24 heures suivant sa connaissance des faits
- Recours au faux pour obtenir l'emploi
- Salarié encore en période d'essai
- Absence sans motif valable de plus de 30 jours sur une même année, ou de plus de 15 jours consécutifs — et seulement après un avertissement écrit délivré une fois l'absence parvenue à 20 jours dans le premier cas, à 10 jours dans le second
- Exploitation illicite de la fonction à des fins personnelles
- Divulgation de secrets industriels ou commerciaux liés au travail
Pour les neuf cas, le texte impose à l'employeur de mettre le salarié en mesure de présenter ses explications. C'est sur cette exigence procédurale que les employeurs perdent réellement. Encodez-la comme une porte susceptible d'échouer, et faites que son échec requalifie la rupture au lieu de simplement journaliser un avertissement :
type Article80Case = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Article80Evidence = {
/** Required for all nine cases: the worker was allowed to state their case. */
investigationHeld: boolean;
/** Case 2: the prior written warning. */
writtenWarningIssued?: boolean;
/** Case 4: hours between the employer becoming aware and notifying the authorities. */
hoursToAuthorityNotice?: number;
/** Case 7. */
absence?: { daysInYear: number; consecutiveDays: number; warningIssued: boolean };
};
const AUTHORITY_NOTICE_HOURS = 24;
const ABSENCE_DAYS_IN_YEAR = 30;
const ABSENCE_CONSECUTIVE_DAYS = 15;
/** Returns null when the dismissal stands, or the reason it collapses. */
function article80Failure(c: Article80Case, e: Article80Evidence): string | null {
if (!e.investigationHeld) return 'no opportunity to state the case (Art. 80, final paragraph)';
switch (c) {
case 2:
return e.writtenWarningIssued ? null : 'no prior written warning (Art. 80/2)';
case 4:
return (e.hoursToAuthorityNotice ?? Infinity) <= AUTHORITY_NOTICE_HOURS
? null
: 'authorities not notified within 24 hours (Art. 80/4)';
case 7: {
const a = e.absence;
if (!a) return 'absence record missing (Art. 80/7)';
const threshold =
a.daysInYear > ABSENCE_DAYS_IN_YEAR || a.consecutiveDays > ABSENCE_CONSECUTIVE_DAYS;
if (!threshold) return 'absence below the 30-day or 15-consecutive-day threshold (Art. 80/7)';
return a.warningIssued ? null : 'no written warning before dismissal (Art. 80/7)';
}
default:
return null;
}
}Observez l'inégalité stricte sur les seuils d'absence. Le texte dit plus de 30 jours : un salarié absent exactement 30 jours ne peut donc pas être licencié au titre du cas 7 — c'est le trente et unième jour qui compte. Une erreur d'une unité ici n'est pas un arrondi, c'est la différence entre un licenciement licite et un solde intégral majoré de deux mois.
Étape 7 : l'article 81 — le salarié part et l'employeur paie quand même
L'article 81 est le miroir de l'article 80. Il permet au salarié de quitter son emploi sans préavis tout en conservant l'intégralité de ses droits légaux, lorsque l'employeur, en substance : a manqué à ses obligations contractuelles ou légales essentielles ; l'a trompé, lors de la conclusion, sur les conditions de travail ; lui a confié un travail substantiellement différent de celui convenu sans son accord ; a exercé des voies de fait contre lui ; l'a traité avec injustice ou cruauté ; l'a exposé à un danger grave connu de lui et non corrigé ; ou a agi de manière à faire apparaître le salarié comme l'auteur de la rupture.
Sur le plan du chiffrage, une sortie sous l'article 81 est une rupture à l'initiative de l'employeur. Le salarié conserve la gratification intégrale de l'article 84 — sans barème de démission — et l'exposition à l'article 77 s'applique. Les systèmes de paie qui indexent le barème sur « qui a appuyé sur le bouton » se trompent exactement ici, puisque, sur le papier, le salarié a démissionné.
type Article81Case = 1 | 2 | 3 | 4 | 5 | 6 | 7;
type Classification = {
/** Which end-of-service scale the award engine should be called with. */
awardScale: 'full' | 'resignation-scale' | 'none';
/** Whether Article 77 compensation is owed. */
article77Owed: boolean;
/** Whether notice was owed by the employer at all. */
noticeOwedByEmployer: boolean;
/** Human-readable trail: what the engine decided and under which article. */
rationale: string[];
};
function classify(exit: Exit): Classification {
switch (exit.ground) {
case 'article-80': {
const failure = article80Failure(exit.case, exit.evidence);
if (failure) {
return {
awardScale: 'full',
article77Owed: true,
noticeOwedByEmployer: true,
rationale: [`Article 80 dismissal collapses: ${failure}`, 'priced as unlawful termination'],
};
}
return {
awardScale: 'none',
article77Owed: false,
noticeOwedByEmployer: false,
rationale: [`Article 80 case ${exit.case} established`],
};
}
case 'no-lawful-cause':
return {
awardScale: 'full',
article77Owed: true,
noticeOwedByEmployer: true,
rationale: ['termination without a lawful cause (Art. 77)'],
};
case 'article-81':
return {
awardScale: 'full',
article77Owed: true,
noticeOwedByEmployer: true,
rationale: [`Article 81 case ${exit.case} — worker exit priced as employer termination`],
};
case 'article-75-valid-reason':
return {
awardScale: 'full',
article77Owed: false,
noticeOwedByEmployer: true,
rationale: [
exit.reasonInWriting
? 'lawful termination with written reason (Art. 75)'
: 'reason not stated in writing — Article 75 defect, review before paying',
],
};
case 'article-74':
return {
awardScale: 'full',
article77Owed: false,
noticeOwedByEmployer: false,
rationale: [`Article 74 — ${exit.cause}`],
};
case 'resignation':
return {
awardScale: 'resignation-scale',
article77Owed: false,
noticeOwedByEmployer: false,
rationale: ['ordinary resignation — Article 85 scale applies'],
};
}
}Le tableau rationale n'est pas décoratif. Lorsque ce solde est contesté sur la plateforme de
règlement amiable Wuddi (ودّي) ou devant le tribunal du travail, la question posée est : de quel
article provient chaque montant ? Un moteur qui émet le raisonnement à côté du chiffre y répond en
quelques secondes ; celui qui n'émet qu'un total renvoie quelqu'un dans un tableur.
Étape 8 : assembler le solde, avec l'horloge de l'article 88
Les postes d'un solde de tout compte, dans l'ordre où ils figurent au document :
type SettlementLine = { head: string; article: string; amount: Halalas };
type TerminationInput = {
exit: Exit;
wage: Wage;
contract: Contract;
frequency: PayFrequency;
isSaudi: boolean;
/**
* Article 10 — every period in the Labour Law is Hijri unless the contract or the
* work regulation says otherwise. Required, so that nobody defaults it by accident.
*/
calendar: CalendarBasis;
hiredOn: string;
terminatedOn: string;
noticeDaysServed: number;
contractualCompensation?: Halalas;
/** Article 76 — a notice compensation agreed above the statutory one. */
agreedNoticeCompensation?: Halalas;
/** Already-computed balances from the other engines. */
unpaidWages: Halalas;
unusedLeavePay: Halalas;
unpaidOvertime: Halalas;
/** Article 84/85 award, computed by the end-of-service engine for the scale we pass it. */
award: (scale: 'full' | 'resignation-scale' | 'none') => Halalas;
};
type Settlement = {
classification: Classification;
lines: SettlementLine[];
total: Halalas;
/** Article 88 — the date the money is legally due. */
dueBy: string;
};
function settleTermination(input: TerminationInput): Settlement {
// Articles 2 and 10: whole thirty-day months on the contract's calendar, then the
// days left over. The same function the end-of-service engine uses, so the two
// engines cannot disagree about how long this person worked.
const serviceYears =
serviceDays360(
parseDate(input.hiredOn, 'hiredOn'),
parseDate(input.terminatedOn, 'terminatedOn'),
input.calendar,
) / DAYS_PER_YEAR;
const contract = normaliseContract(
input.contract,
input.terminatedOn,
input.calendar,
input.isSaudi,
);
const classification = classify(input.exit);
const lines: SettlementLine[] = [];
if (input.unpaidWages > 0) {
lines.push({ head: 'Unpaid wages', article: 'Art. 90', amount: input.unpaidWages });
}
if (input.unpaidOvertime > 0) {
lines.push({ head: 'Unpaid overtime', article: 'Art. 107', amount: input.unpaidOvertime });
}
if (input.unusedLeavePay > 0) {
lines.push({ head: 'Unused annual leave', article: 'Art. 111', amount: input.unusedLeavePay });
}
const award = input.award(classification.awardScale);
if (award > 0) {
lines.push({ head: 'End-of-service award', article: 'Art. 84/85', amount: award });
}
if (classification.noticeOwedByEmployer) {
const lieu = noticeInLieu(
input.wage,
input.frequency,
'employer',
input.noticeDaysServed,
contract,
input.agreedNoticeCompensation,
);
if (lieu > 0) {
lines.push({ head: 'Pay in lieu of notice', article: 'Art. 76', amount: lieu });
}
}
if (classification.article77Owed) {
const c = article77Compensation({
wage: input.wage,
contract,
serviceYears,
terminatedOn: input.terminatedOn,
calendar: input.calendar,
contractualCompensation: input.contractualCompensation,
});
lines.push({ head: 'Unlawful-termination compensation', article: 'Art. 77', amount: c.amount });
classification.rationale.push(
c.flooredUp
? `Article 77 basis "${c.basis}" raised to the two-month statutory floor`
: `Article 77 basis "${c.basis}"`,
);
}
return {
classification,
lines,
total: lines.reduce((sum, l) => sum + l.amount, 0),
dueBy: settlementDeadline(input.terminatedOn, input.exit.by),
};
}
/**
* Article 88 — one week where the employer ended the relationship, two weeks
* where the worker did. The clock does not wait for the next payroll run.
*/
function settlementDeadline(terminatedOn: string, by: Exit['by']): string {
const days = by === 'worker' ? 14 : 7;
const due = new Date(parseDate(terminatedOn, 'terminatedOn').getTime() + days * MS_PER_DAY);
return due.toISOString().slice(0, 10);
}Le passage de award sous forme de fonction est délibéré. Ce moteur décide du barème, il ne
réimplémente pas les articles 84 et 85. Injectez-y la fonction de votre moteur de fin de service et
les deux resteront cohérents à chaque amendement de l'un ou l'autre texte.
Tester votre implémentation
Testez contre le texte légal, pas contre la sortie actuelle. Chaque cas ci-dessous est une règle de droit, non un instantané de non-régression :
import test from 'node:test';
import assert from 'node:assert/strict';
const wage: Wage = { basic: 800_000, allowances: 200_000 }; // 8,000 + 2,000 SAR
const monthly = actualMonthlyWage(wage);
const daily = dailyWage(wage);
const indefinite: Contract = { kind: 'indefinite' };
const fixedTerm = (o: Partial<Extract<Contract, { kind: 'fixed' }>>): Contract => ({
kind: 'fixed',
startedOn: '2025-01-01',
endsOn: '2026-12-31',
renewals: 0,
hasRenewalClause: false,
stillPerforming: true,
...o,
});
test('Article 77 floor lifts a short-service indefinite case to two months', () => {
const c = article77Compensation({
wage,
contract: indefinite,
serviceYears: 0.5, // 7.5 days computed
terminatedOn: '2026-08-24',
calendar: 'hijri',
});
assert.equal(c.amount, 2 * monthly); // 20,000 SAR
assert.equal(c.flooredUp, true);
});
test('a contractual one-month clause is raised to the statutory floor', () => {
const c = article77Compensation({
wage,
contract: indefinite,
serviceYears: 6,
terminatedOn: '2026-08-24',
calendar: 'hijri',
contractualCompensation: monthly, // one month, as written
});
assert.equal(c.amount, 2 * monthly);
});
test('fifteen days a year only clears the floor past four years of service', () => {
const at = (serviceYears: number) =>
article77Compensation({ wage, contract: indefinite, serviceYears, terminatedOn: '2026-08-24', calendar: 'hijri' });
for (const years of [0.25, 1, 2, 3, 3.9, 4]) {
assert.equal(at(years).amount, 2 * monthly, `${years} years should be floored`);
}
// The day rate is a rounded thirtieth, so sixty days is twenty halalas under two months.
assert.equal(60 * daily, 2 * monthly - 20);
assert.equal(at(4 + 1 / 360).flooredUp, false);
});
test('the unexpired term starts the day after termination, and the end date counts', () => {
const c = article77Compensation({
wage,
contract: fixedTerm({ startedOn: '2026-01-01', endsOn: '2026-12-31' }),
serviceYears: 0.5,
terminatedOn: '2026-06-30',
calendar: 'gregorian',
});
assert.equal(c.basis, 'remaining-term');
assert.equal(c.amount, 180 * daily); // six thirty-day months, not 181 actual days
});
test('Article 2 and Article 10 — the contract decides the calendar, and the calendar decides the money', () => {
const settle = (calendar: CalendarBasis) =>
settleTermination({
exit: { by: 'employer', ground: 'no-lawful-cause' },
wage,
contract: indefinite,
frequency: 'monthly',
isSaudi: true,
calendar,
hiredOn: '2019-01-01',
terminatedOn: '2023-12-31',
noticeDaysServed: 60,
unpaidWages: 0,
unusedLeavePay: 0,
unpaidOvertime: 0,
award: () => 0,
}).lines.find((l) => l.article === 'Art. 77')!.amount;
// 1,800 days of thirty-day months = exactly five years.
assert.equal(settle('gregorian'), Math.round(5 * 15 * daily));
// The same dates in Umm al-Qura are 1,853 days: a silent contract is Hijri, and pays more.
assert.equal(settle('hijri'), Math.round((1853 / 360) * 15 * daily));
assert.ok(settle('hijri') > settle('gregorian'));
});
test('Article 55 measures the contract, not the worker', () => {
const kind = (c: Contract, asOf: string, isSaudi = true) =>
normaliseContract(c, asOf, 'gregorian', isSaudi).kind;
// A two-year term signed by a long-serving worker is still a two-year term.
assert.equal(kind(fixedTerm({ hasRenewalClause: true }), '2026-06-30'), 'fixed');
// 55/2 — the term's own length reaching four years converts it.
assert.equal(
kind(fixedTerm({ hasRenewalClause: true, startedOn: '2023-01-01' }), '2026-06-30'),
'indefinite',
);
// 55/2 — so do three consecutive renewals, whatever the length.
assert.equal(kind(fixedTerm({ hasRenewalClause: true, renewals: 3 }), '2026-06-30'), 'indefinite');
// 55/1 — no renewal clause, term expired, both parties carried on.
assert.equal(kind(fixedTerm({ endsOn: '2026-01-31' }), '2026-06-30'), 'indefinite');
// 55/1 — but only if they carried on.
assert.equal(
kind(fixedTerm({ endsOn: '2026-01-31', stillPerforming: false }), '2026-06-30'),
'fixed',
);
// Article 37 and Regulation 12 — a non-Saudi contract never converts.
assert.equal(
kind(fixedTerm({ hasRenewalClause: true, renewals: 9, endsOn: '2026-01-31' }), '2026-06-30', false),
'fixed',
);
});
test('Article 75 notice is asymmetric', () => {
assert.equal(noticeDaysRequired('monthly', 'employer'), 60);
assert.equal(noticeDaysRequired('monthly', 'worker'), 30);
assert.equal(noticeDaysRequired('other', 'employer'), 30);
assert.equal(noticeDaysRequired('other', 'worker'), 30);
});
test('Article 76 honours a higher agreed figure and ignores a lower one', () => {
const agreed = 4 * monthly; // 40,000 SAR for the sixty-day period
assert.equal(
noticeInLieu(wage, 'monthly', 'employer', 0, indefinite, agreed),
60 * divideRoundHalfUp(agreed, 60),
);
assert.equal(noticeInLieu(wage, 'monthly', 'employer', 0, indefinite, 50_000), 60 * daily);
});
test('absence of exactly 30 days does not establish Article 80 case 7', () => {
const failure = article80Failure(7, {
investigationHeld: true,
absence: { daysInYear: 30, consecutiveDays: 4, warningIssued: true },
});
assert.match(failure ?? '', /threshold/);
});
test('a case 7 dismissal without the written warning collapses into Article 77', () => {
const c = classify({
by: 'employer',
ground: 'article-80',
case: 7,
evidence: {
investigationHeld: true,
absence: { daysInYear: 41, consecutiveDays: 41, warningIssued: false },
},
});
assert.equal(c.awardScale, 'full');
assert.equal(c.article77Owed, true);
});
test('Article 81 keeps the full award even though the worker left', () => {
const c = classify({ by: 'worker', ground: 'article-81', case: 4, noticeGiven: false });
assert.equal(c.awardScale, 'full');
assert.equal(c.article77Owed, true);
});
test('notice in lieu and Article 77 are cumulative, not alternatives', () => {
const lieu = noticeInLieu(wage, 'monthly', 'employer', 0, indefinite);
assert.equal(lieu, 60 * daily); // two months on top of Article 77
});
test('Article 88 gives one week when the employer terminated', () => {
assert.equal(settlementDeadline('2026-08-24', 'employer'), '2026-08-31');
assert.equal(settlementDeadline('2026-08-24', 'worker'), '2026-09-07');
});Faites ensuite passer une sortie réelle de chaque forme dans le moteur et rapprochez-la de ce qui a été payé. Dans tous les audits de paie que nous avons menés sur ce périmètre, le rapprochement révèle au moins l'un des trois écarts classiques : indemnités absentes de la base, plancher de deux mois jamais appliqué, ou indemnité de préavis déduite de l'indemnité de rupture.
Dépannage
Tous les soldes sont amputés d'environ un tiers. La base est le salaire de base. Les articles 76, 77 et 84 utilisent le salaire réel de l'article 2 — base plus indemnités.
Les licenciements illicites à faible ancienneté ne rendent presque rien. Le plancher de deux mois est absent, ou il est appliqué avant la clause contractuelle au lieu de l'être après.
Les fins de contrat à durée déterminée génèrent des lignes article 77. L'arrivée du terme est une
cause de l'article 74, pas une rupture. Vérifiez que votre couche d'ingestion mappe « contrat
terminé » sur ground: 'article-74' et non sur no-lawful-cause.
Un contrat « à durée déterminée » de longue haleine voit son non-renouvellement chiffré comme
licite. La requalification de l'article 55 ne s'exécute pas. Vérifiez que normaliseContract est
appelée avant le chiffrage, que le nombre de renouvellements est réellement stocké et que
stillPerforming n'est pas figé à false par une couche d'ingestion qui ne l'a jamais capté.
Un contrat à durée déterminée fraîchement signé est chiffré comme indéterminé. La requalification est alimentée par l'ancienneté du salarié au lieu de la durée du contrat. L'article 55 mesure le contrat : un salarié de vingt ans d'ancienneté qui signe un nouveau contrat de deux ans est en contrat de deux ans.
Tous les soldes sont amputés de deux ou trois pour cent et personne ne trouve où. L'ancienneté
est comptée en années grégoriennes. L'article 10 rend la durée hégirienne sauf stipulation contraire
du contrat ou du règlement intérieur, et l'article 2 fixe le mois à trente jours. Transmettez
explicitement le calendrier du contrat et comptez avec serviceDays360 ; ne divisez pas un écart de
millisecondes par 365 ou 365,25.
Un salarié démissionnaire se voit facturer soixante jours de préavis. noticeDaysRequired est
indexée sur la seule périodicité de paie. L'article 75/1 donne soixante jours à l'employeur et trente
au salarié ; seul l'article 75/2, pour les salaires non mensuels, est symétrique.
La période restante du contrat à durée déterminée compte un jour de trop. La période non échue est mesurée depuis la date de rupture au lieu du lendemain : le dernier jour travaillé est payé deux fois, une fois au solde de salaire et une fois sur la ligne article 77.
Les licenciements article 80 ne s'effondrent jamais. L'objet de preuve est optionnel dans votre
couche d'ingestion et arrive vide : investigationHeld est alors falsy et — selon la façon dont vous
avez écrit la porte — soit tout échoue, soit rien n'échoue. Rendez les champs de preuve obligatoires à
la frontière et rejetez l'entrée si les RH ne les ont pas fournis.
Les taux journaliers divergent entre moteurs. Un module divise par 30, un autre par le nombre
réel de jours du mois. Exportez un seul dailyWage et importez-le partout ; les diviseurs de 240 et
180 heures du moteur d'heures supplémentaires relèvent d'un autre sujet et ne doivent pas s'infiltrer
dans les postes calculés en jours calendaires.
Pour aller plus loin
- Transmettez la décision de barème au moteur de fin de service, qui applique les articles 84, 85 et 87 selon le barème choisi ici, et dont le guide du calculateur des droits du travail saoudien détaille chaque poste
- Soldez le compteur de congés avec le moteur d'acquisition des congés annuels — un montant de congés non pris calculé sur un autre taux journalier ne se rapprochera pas de ce solde
- Récupérez les heures supplémentaires impayées depuis le moteur de calcul des heures supplémentaires avant de clore le dossier ; elles ressortent au départ plus qu'à tout autre moment
- Poussez le dernier mois dans votre générateur de fichier WPS, car le fichier de protection des salaires est ce à quoi le ministère confronte votre version des faits
- Comprenez le versant plateforme dans l'intégration de Qiwa aux systèmes RH et l'intégration de Mudad à la paie
- Laissez le salarié concerné vérifier les mêmes chiffres avec le calculateur des droits du travail gratuit
Conclusion
La branche rupture d'un système de paie saoudien n'est pas une formule, c'est une qualification suivie d'une formule. Réussissez la qualification et l'arithmétique tient en quelques centaines de lignes : deux bases salariales, un taux journalier sur trente jours, un aiguillage sur le type de contrat, une porte probatoire à inégalités strictes, et un plancher appliqué en dernier. Ratez-la et le système sera précis avec assurance sur un chiffre qui n'a jamais été le bon — précisément la forme des dossiers que voient les tribunaux du travail.
Les trois règles à écrire au mur : le plancher de deux mois prime sur toute clause contractuelle, l'indemnité de préavis et l'indemnité de l'article 77 se cumulent au lieu de se substituer, et un licenciement article 80 sans ses pièces n'est qu'un licenciement illicite avec des étapes en plus.
Si vos ruptures sont aujourd'hui chiffrées dans un tableur, ou si votre SIRH calcule une gratification sans jamais demander pourquoi la relation a pris fin, décrivez-nous votre stack — nous ferons passer vos douze derniers mois de ruptures dans un moteur de ce type et vous montrerons quelle branche votre processus actuel ne modélise pas, avant que le délai de douze mois de l'article 222 ne se referme aux conditions de quelqu'un d'autre.