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, dont l'une n'est pas le salaire
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.
/** All money is integer halalas. 1 SAR = 100 halalas. Floats do not belong here. */
type Halalas = number;
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 the term was due to end. */
endsOn: string;
/** How many times this fixed term has already been renewed. */
renewals: number;
};La branche à durée déterminée recèle un piège. L'article 55 requalifie en contrat à durée
indéterminée le contrat à durée déterminée d'un salarié saoudien dès lors que les parties poursuivent
son exécution après trois renouvellements consécutifs, ou dès que sa durée atteint quatre ans, la
première de ces deux limites prévalant. Un système qui conserve indéfiniment kind: 'fixed' chiffrera
une relation de dix ans comme si le non-renouvellement y était licite.
const FIXED_TERM_RENEWAL_LIMIT = 3;
const FIXED_TERM_YEARS_LIMIT = 4;
/**
* Article 55 — normalise before pricing. Applies to Saudi workers; a non-Saudi's
* contract term is tied to the work permit under Article 37, so the conversion
* does not run for them.
*/
function normaliseContract(
contract: Contract,
serviceYears: number,
isSaudi: boolean,
): Contract {
if (contract.kind !== 'fixed' || !isSaudi) return contract;
const converted =
contract.renewals >= FIXED_TERM_RENEWAL_LIMIT || serviceYears >= 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 : 60 jours lorsque le salaire est mensuel, 30 jours dans les autres cas. L'article 76 oblige la partie qui ne l'a pas respecté à verser à l'autre le salaire de la période de préavis, ou de la fraction non exécutée.
Deux 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 donc exactement 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';
const noticeDaysRequired = (f: PayFrequency): number => (f === 'monthly' ? 60 : 30);
/**
* Article 76 — the shortfall between the notice the law requires and the notice
* actually served. Never negative: serving longer than required earns nothing back.
*/
function noticeInLieu(
wage: Wage,
frequency: PayFrequency,
daysServed: number,
contract: Contract,
): 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 shortfall = Math.max(0, noticeDaysRequired(frequency) - daysServed);
return shortfall * dailyWage(wage);
}É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;
serviceYears: number;
/** ISO date the relationship actually ended. */
terminatedOn: string;
/** 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, 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);
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: whole months, then residual days. */
function remainingTermWage(wage: Wage, from: string, to: string): Halalas {
const start = new Date(`${from}T00:00:00Z`);
const end = new Date(`${to}T00:00:00Z`);
if (end <= start) return 0;
let months =
(end.getUTCFullYear() - start.getUTCFullYear()) * 12 +
(end.getUTCMonth() - start.getUTCMonth());
const anniversary = new Date(start);
anniversary.setUTCMonth(anniversary.getUTCMonth() + months);
if (anniversary > end) {
months -= 1;
anniversary.setUTCMonth(anniversary.getUTCMonth() - 1);
}
const residualDays = Math.round((end.getTime() - anniversary.getTime()) / 86_400_000);
return months * actualMonthlyWage(wage) + residualDays * dailyWage(wage);
}Notez que serviceYears est ici fractionnaire, et c'est délibéré : les quinze jours de l'article 77
sont acquis par année d'ancienneté, fraction d'année comprise, à l'inverse du traitement par dates
anniversaires qu'utilise la gratification de l'article 84. Si vous partagez une même valeur
serviceYears entre les deux moteurs, l'un des deux sera faux. Calculez-les tous deux à partir des
mêmes dates d'entrée et de sortie, jamais l'un à partir de l'autre.
É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;
hiredOn: string;
terminatedOn: string;
noticeDaysServed: number;
contractualCompensation?: 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;
};
const MS_PER_DAY = 86_400_000;
function settleTermination(input: TerminationInput): Settlement {
const serviceYears =
(new Date(`${input.terminatedOn}T00:00:00Z`).getTime() -
new Date(`${input.hiredOn}T00:00:00Z`).getTime()) /
(MS_PER_DAY * 365.25);
const contract = normaliseContract(input.contract, serviceYears, 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, input.noticeDaysServed, contract);
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,
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(`${terminatedOn}T00:00:00Z`);
due.setUTCDate(due.getUTCDate() + days);
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
test('Article 77 floor lifts a short-service indefinite case to two months', () => {
const c = article77Compensation({
wage,
contract: { kind: 'indefinite' },
serviceYears: 0.5, // 7.5 days computed
terminatedOn: '2026-08-24',
});
assert.equal(c.amount, 2 * actualMonthlyWage(wage)); // 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: { kind: 'indefinite' },
serviceYears: 6,
terminatedOn: '2026-08-24',
contractualCompensation: actualMonthlyWage(wage), // one month, as written
});
assert.equal(c.amount, 2 * actualMonthlyWage(wage));
});
test('fixed term pays the remaining term, not fifteen days a year', () => {
const c = article77Compensation({
wage,
contract: { kind: 'fixed', endsOn: '2027-02-24', renewals: 0 },
serviceYears: 3,
terminatedOn: '2026-08-24',
});
assert.equal(c.basis, 'remaining-term');
assert.equal(c.amount, 6 * actualMonthlyWage(wage)); // six months left
});
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', 0, { kind: 'indefinite' });
assert.equal(lieu, 60 * dailyWage(wage)); // 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 et que le nombre de renouvellements est réellement stocké.
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.