Tout SIRH stocke une période d'essai. Presque aucun ne sait répondre à la seule question qui compte à son sujet : le jour où vous rompez le contrat de ce salarié, la période d'essai court-elle encore ?
Se tromper là-dessus ne relève pas de l'erreur d'arrondi. À l'intérieur d'une période d'essai valide, chaque partie met fin au contrat sans préavis, sans indemnité et sans gratification de fin de service. Un jour plus tard, la même rupture est tarifée sous l'article 77, qui impose un plancher de deux mois de salaire quelle que soit la brièveté de l'ancienneté. L'écart entre les deux branches, pour un salarié à 12 000 SAR, est de 24 000 SAR — décidé entièrement par un calcul de date que la plupart des systèmes ratent.
Ils le ratent parce que le compteur de la période d'essai n'est pas un calendrier. L'article 53 l'arrête pour l'Aïd al-Fitr, l'Aïd al-Adha et les congés maladie. La date d'embauche plus quatre-vingt-dix jours n'est pas la date de fin d'essai, et c'est pourtant ce que calcule presque toute implémentation.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ et TypeScript 5.4+
- Une familiarité avec les unions discriminées et les switch exhaustifs
- Une idée générale des branches de rupture du droit du travail saoudien — le moteur de règlement de fin de contrat couvre les articles 75 à 81 et constitue la branche vers laquelle ce moteur bascule
- Aucune bibliothèque de dates n'est nécessaire. Nous utilisons
Dateen UTC et comptons les jours nous-mêmes, parce que la règle métier est un décompte de jours, pas une durée
Ce que vous allez construire
Une fonction pure qui prend un contrat de travail, sa clause d'essai et l'historique du salarié chez cet employeur, et renvoie une décision :
- si la clause d'essai est valide, tout simplement
- la durée d'essai effective après application des plafonds légaux
- la date de fin d'essai réelle, compteur mis en pause pour les congés que le texte exclut
- pour une date de rupture donnée, quelle branche tarife la sortie — article 53 ou article 77
- une liste de motifs lisibles pour chacune de ces réponses
Aucune date n'est codée en dur. C'est, il s'avère, la décision de conception la plus importante de tout le moteur, et l'étape 4 explique pourquoi.
Étape 1 : la clause est le verrou, et c'est là que l'argent se perd
L'article 53 exige que la période d'essai soit stipulée expressément dans le contrat de travail, sa durée clairement définie. Ce n'est pas de la paperasse. Si la clause est absente, vague ou non documentée, le contrat est traité comme définitif dès son premier jour — et une rupture que la direction croyait être une sortie d'essai gratuite devient un licenciement article 77 avec le plancher de deux mois attaché.
La première tâche du moteur n'est donc pas arithmétique. C'est un contrôle de validité.
/** ISO calendar date, 'YYYY-MM-DD'. Dates here are days, never instants. */
type IsoDate = string;
/**
* One agreed stretch of probation. Article 53 permits the period to be extended,
* so a clause is a list of tranches, not a single number.
*/
type ProbationTranche = {
days: number;
/** Article 53 requires extensions to rest on a written agreement. */
agreedInWriting: boolean;
};
type ProbationClause = {
/** Article 53: stated expressly in the contract, or the contract is final. */
statedInContract: boolean;
/** Duration must be clearly defined — an open-ended clause is not a clause. */
durationClearlyDefined: boolean;
tranches: ProbationTranche[];
};Notez ce qui n'est pas dans ce type : un booléen nommé isOnProbation. Ce champ est le bug. Il est
positionné à l'embauche, jamais recalculé, et diverge du contrat dès qu'il se passe quoi que ce soit
d'intéressant.
Étape 2 : deux plafonds, un seul est absolu
L'article 53 fixe un essai de base de quatre-vingt-dix jours, prolongeable par accord écrit entre les parties, à condition que le total en toutes circonstances n'excède pas cent quatre-vingts jours.
Lisez les résultats de recherche sur ce point et vous trouverez des cabinets affirmant un plafond plat de 180 jours et d'autres affirmant quatre-vingt-dix. Les deux décrivent la même règle par des côtés différents : quatre-vingt-dix est ce qu'une tranche unique peut atteindre seule, cent quatre-vingts est le plafond une fois les prolongations écrites comptées. Le plafond est l'invariant. Encodez-le comme tel.
/** Article 53 — a single agreed stretch, before any written extension. */
const SINGLE_TRANCHE_CAP_DAYS = 90;
/** Article 53 — the absolute total, in all circumstances. This one never bends. */
const ABSOLUTE_CAP_DAYS = 180;La lecture prudente, et celle qu'il faut livrer : les jours excédentaires sont nuls, mais ils n'empoisonnent pas toute la clause. Un contrat écrivant 200 jours d'essai a une période d'essai valide de 180 jours et 20 jours d'emploi ordinaire, pas une clause nulle. Tronquer est sûr ; annuler la clause pour le compte de l'employeur ne l'est pas.
type ClauseAssessment = {
valid: boolean;
effectiveDays: number;
reasons: string[];
};
function assessClause(clause: ProbationClause): ClauseAssessment {
const reasons: string[] = [];
if (!clause.statedInContract) {
return {
valid: false,
effectiveDays: 0,
reasons: ['probation not stated expressly in the contract (Art. 53) — contract is final'],
};
}
if (!clause.durationClearlyDefined) {
return {
valid: false,
effectiveDays: 0,
reasons: ['probation duration not clearly defined (Art. 53) — contract is final'],
};
}
if (clause.tranches.length === 0) {
return {
valid: false,
effectiveDays: 0,
reasons: ['probation clause carries no duration'],
};
}
// The first tranche stands on the contract. Every later one needs writing.
let counted = 0;
clause.tranches.forEach((tranche, index) => {
if (index > 0 && !tranche.agreedInWriting) {
reasons.push(`extension ${index} not agreed in writing (Art. 53) — not counted`);
return;
}
if (index === 0 && tranche.days > SINGLE_TRANCHE_CAP_DAYS) {
reasons.push(
`initial probation of ${tranche.days} days exceeds the ${SINGLE_TRANCHE_CAP_DAYS}-day base ` +
'and is only sustainable as a written extension',
);
}
counted += tranche.days;
});
const effectiveDays = Math.min(counted, ABSOLUTE_CAP_DAYS);
if (counted > ABSOLUTE_CAP_DAYS) {
reasons.push(
`total probation of ${counted} days truncated to the Art. 53 ceiling of ${ABSOLUTE_CAP_DAYS}`,
);
}
return { valid: effectiveDays > 0, effectiveDays, reasons };
}Étape 3 : l'article 54 — le contrôle que votre table de contrats ne peut pas faire
L'article 54 interdit de placer un salarié en période d'essai plus d'une fois chez le même employeur. Il existe exactement deux exceptions, et toutes deux exigent des faits qui vivent hors de la ligne du contrat courant :
- le salarié exerce une autre profession ou un autre emploi, ou
- au moins six mois se sont écoulés depuis la fin de la relation de travail précédente avec cet employeur.
C'est la règle qui casse silencieusement les réembauches. Un prestataire qui revient, un saisonnier, un salarié passé entre deux entités sous le même registre de commerce — chacun repasse par l'onboarding, obtient un contrat neuf avec une clause d'essai neuve, et personne ne vérifie. Le second essai est nul, et la sortie censée être gratuite se tarife sous l'article 77.
type PriorEngagement = {
profession: string;
endedOn: IsoDate;
hadProbation: boolean;
};
const SIX_MONTHS_IN_DAYS = 180;
function daysBetween(from: IsoDate, to: IsoDate): number {
const MS_PER_DAY = 86_400_000;
return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / MS_PER_DAY);
}
/**
* Article 54 — may this worker lawfully be placed under probation again?
* Only the prior engagements that actually carried probation are relevant.
*/
function repeatProbationAllowed(
newProfession: string,
startDate: IsoDate,
history: PriorEngagement[],
): { allowed: boolean; reason: string } {
const priorProbations = history.filter((h) => h.hadProbation);
if (priorProbations.length === 0) {
return { allowed: true, reason: 'no prior probation with this employer' };
}
if (priorProbations.every((h) => h.profession !== newProfession)) {
return { allowed: true, reason: 'different profession from every prior engagement (Art. 54)' };
}
const mostRecentEnd = priorProbations
.map((h) => h.endedOn)
.sort()
.at(-1) as IsoDate;
const gap = daysBetween(mostRecentEnd, startDate);
if (gap >= SIX_MONTHS_IN_DAYS) {
return { allowed: true, reason: `${gap} days since the previous engagement ended (Art. 54)` };
}
return {
allowed: false,
reason:
`same profession and only ${gap} days since the previous engagement — ` +
'a second probation is not permitted (Art. 54)',
};
}Deux détails méritent qu'on s'y arrête. La comparaison porte sur l'engagement antérieur le plus
récent, pas le premier — trier des chaînes de dates ISO est sûr ici précisément parce qu'elles sont
en ISO. Et hadProbation compte : un engagement antérieur qui n'a jamais porté d'essai ne consomme
pas l'unique usage autorisé.
Étape 4 : le compteur n'est pas un calendrier
Voici la règle que presque toute implémentation manque. L'article 53 exclut du décompte de l'essai les congés de l'Aïd al-Fitr et de l'Aïd al-Adha ainsi que tout congé maladie. Le compteur ne tourne pas pendant ces périodes — il se met en pause et reprend au retour du salarié. Dix jours de congé maladie documenté dans un essai de quatre-vingt-dix jours repoussent la date de fin de dix jours.
Et maintenant la partie qui décide de votre architecture. L'Aïd tombe sur le calendrier hégirien, et en Arabie saoudite les dates sont fixées par observation lunaire puis annoncées, non dérivées. Un système qui calcule l'Aïd arithmétiquement aura raison la plupart des années et tort précisément les années où un litige atterrit dessus. Les fenêtres de congé de l'Aïd sont des données, injectées et versionnées, jamais une formule.
type PauseKind = 'eid-al-fitr' | 'eid-al-adha' | 'sick-leave';
/** A stretch of days that does not count toward probation. Both ends inclusive. */
type PausePeriod = {
kind: PauseKind;
from: IsoDate;
to: IsoDate;
};
function eachDay(from: IsoDate, to: IsoDate): IsoDate[] {
const out: IsoDate[] = [];
const cursor = new Date(`${from}T00:00:00Z`);
const end = new Date(`${to}T00:00:00Z`);
while (cursor <= end) {
out.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return out;
}
/**
* Walk forward from the start date, counting only days the statute counts,
* until `effectiveDays` of probation have actually elapsed.
*
* Deliberately a day walk rather than date arithmetic: pauses overlap
* (sick leave across Eid is one excluded day, not two) and the only honest
* way to handle overlap is a set of excluded days.
*/
function probationEndDate(
startDate: IsoDate,
effectiveDays: number,
pauses: PausePeriod[],
): IsoDate {
const excluded = new Set<IsoDate>();
for (const pause of pauses) {
for (const day of eachDay(pause.from, pause.to)) excluded.add(day);
}
let counted = 0;
const cursor = new Date(`${startDate}T00:00:00Z`);
// Hard stop well past any lawful probation, so a pathological pause set
// cannot spin forever.
const MAX_ELAPSED_DAYS = ABSOLUTE_CAP_DAYS * 4;
for (let elapsed = 0; elapsed < MAX_ELAPSED_DAYS; elapsed++) {
const day = cursor.toISOString().slice(0, 10);
if (!excluded.has(day)) counted += 1;
if (counted === effectiveDays) return day;
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
throw new Error('probation end date not reached — check the pause periods for an open range');
}Le Set fait un vrai travail. Un congé maladie à cheval sur l'Aïd est une configuration courante, et
une implémentation naïve qui additionne les longueurs de pause compte le chevauchement deux fois,
prolongeant l'essai au-delà de son plafond légal en faveur du salarié — ce qui est un manquement à la
conformité dans l'autre sens.
Étape 5 : une décision, tarifée des deux côtés
Assemblons maintenant. Le moteur prend une date de rupture et dit quelle branche du droit la tarife.
type Contract = {
workerId: string;
profession: string;
startDate: IsoDate;
clause: ProbationClause;
};
type ExitBranch =
| {
branch: 'probation';
/** Article 53 — the termination carries none of these. */
noticeDays: 0;
compensationOwed: false;
endOfServiceAward: false;
probationEndsOn: IsoDate;
reasons: string[];
}
| {
branch: 'article-77';
/** Priced by the termination settlement engine, with its two-month floor. */
probationEndsOn: IsoDate | null;
reasons: string[];
};
function classifyExit(
contract: Contract,
terminationDate: IsoDate,
pauses: PausePeriod[],
history: PriorEngagement[],
): ExitBranch {
const reasons: string[] = [];
const repeat = repeatProbationAllowed(contract.profession, contract.startDate, history);
reasons.push(repeat.reason);
if (!repeat.allowed) {
return { branch: 'article-77', probationEndsOn: null, reasons };
}
const assessment = assessClause(contract.clause);
reasons.push(...assessment.reasons);
if (!assessment.valid) {
return { branch: 'article-77', probationEndsOn: null, reasons };
}
const relevant = pauses.filter((p) => p.from >= contract.startDate);
const endsOn = probationEndDate(contract.startDate, assessment.effectiveDays, relevant);
reasons.push(
`probation of ${assessment.effectiveDays} counted days runs to ${endsOn} ` +
`(${pauses.length} excluded period(s) applied)`,
);
if (terminationDate <= endsOn) {
return {
branch: 'probation',
noticeDays: 0,
compensationOwed: false,
endOfServiceAward: false,
probationEndsOn: endsOn,
reasons,
};
}
reasons.push(`termination on ${terminationDate} falls after probation ended on ${endsOn}`);
return { branch: 'article-77', probationEndsOn: endsOn, reasons };
}La borne est inclusive. Une rupture le dernier jour d'essai est encore dans l'essai ; le lendemain ne l'est plus. Cet unique opérateur de comparaison mérite son propre test unitaire, et l'étape 6 lui en donne un.
Une chose que cette fonction ne fait délibérément pas : réduire l'ancienneté du salarié. La période d'essai compte dans l'ancienneté totale pour la gratification de fin de service si le salarié continue au-delà. Ce que l'article 53 supprime, c'est la gratification sur une rupture pendant l'essai — pas les jours eux-mêmes. Un système qui soustrait l'essai de l'ancienneté sous-paie toutes les gratifications qu'il calcule.
Étape 6 : tester votre implémentation
Les cas qui comptent sont les bornes et les défauts de clause, pas le chemin heureux.
import test from 'node:test';
import assert from 'node:assert/strict';
const baseClause: ProbationClause = {
statedInContract: true,
durationClearlyDefined: true,
tranches: [{ days: 90, agreedInWriting: false }],
};
const contract: Contract = {
workerId: 'W-1',
profession: 'accountant',
startDate: '2026-01-01',
clause: baseClause,
};
test('an unwritten probation clause routes the exit to Article 77', () => {
const c = { ...contract, clause: { ...baseClause, statedInContract: false } };
const exit = classifyExit(c, '2026-02-01', [], []);
assert.equal(exit.branch, 'article-77');
});
test('the last probation day is still inside probation', () => {
// 90 counted days from 2026-01-01 inclusive lands on 2026-03-31.
const inside = classifyExit(contract, '2026-03-31', [], []);
assert.equal(inside.branch, 'probation');
const outside = classifyExit(contract, '2026-04-01', [], []);
assert.equal(outside.branch, 'article-77');
});
test('sick leave pauses the clock and pushes the end date out', () => {
const pauses: PausePeriod[] = [
{ kind: 'sick-leave', from: '2026-02-10', to: '2026-02-19' }, // 10 days
];
const exit = classifyExit(contract, '2026-04-01', pauses, []);
assert.equal(exit.branch, 'probation');
assert.equal(exit.probationEndsOn, '2026-04-10');
});
test('overlapping pauses are counted once', () => {
const pauses: PausePeriod[] = [
{ kind: 'eid-al-fitr', from: '2026-03-19', to: '2026-03-22' },
{ kind: 'sick-leave', from: '2026-03-20', to: '2026-03-23' },
];
// Union is 2026-03-19..2026-03-23 — five days, not eight.
const exit = classifyExit(contract, '2026-04-05', pauses, []);
assert.equal(exit.probationEndsOn, '2026-04-05');
});
test('the 180-day ceiling truncates rather than voiding the clause', () => {
const c: Contract = {
...contract,
clause: {
statedInContract: true,
durationClearlyDefined: true,
tranches: [
{ days: 90, agreedInWriting: false },
{ days: 150, agreedInWriting: true },
],
},
};
const exit = classifyExit(c, '2026-06-29', [], []);
assert.equal(exit.branch, 'probation'); // day 180
assert.equal(classifyExit(c, '2026-06-30', [], []).branch, 'article-77');
});
test('a rehire into the same profession inside six months cannot be re-probated', () => {
const history: PriorEngagement[] = [
{ profession: 'accountant', endedOn: '2025-10-01', hadProbation: true },
];
assert.equal(classifyExit(contract, '2026-02-01', [], history).branch, 'article-77');
});
test('a rehire into a different profession may be re-probated', () => {
const history: PriorEngagement[] = [
{ profession: 'driver', endedOn: '2025-12-01', hadProbation: true },
];
assert.equal(classifyExit(contract, '2026-02-01', [], history).branch, 'probation');
});Lancez avec node --test --experimental-strip-types. Le test de chevauchement est celui qui attrapera
la régression qu'une optimisation finira par introduire, quand quelqu'un remplacera le parcours
jour par jour par une somme de longueurs de pause parce que c'est plus rapide.
Dépannage
La date de fin d'essai est décalée d'un jour. Presque toujours une erreur d'inclusion ou
d'exclusion sur la date de début. Le jour un de l'essai est la date de début elle-même, donc
quatre-vingt-dix jours comptés depuis le 1er janvier finissent le 31 mars, pas le 1er avril. La boucle
ci-dessus renvoie le jour où le décompte atteint effectiveDays, soit la fin inclusive.
L'essai paraît durer plus de 180 jours. C'est correct et attendu dès que des pauses s'appliquent. Le plafond de l'article 53 porte sur les jours comptés, pas sur les jours calendaires écoulés. Un salarié ayant pris trois semaines de congé maladie dans un essai de 180 jours est légalement en essai pendant 201 jours calendaires.
Les dates de l'Aïd dérivent d'une année sur l'autre. C'est normal. Si vos fenêtres d'Aïd viennent d'une table codée en dur générée une fois, elles sont fausses pour toute année postérieure à celle de leur génération. Chargez-les depuis la source que votre paie utilise déjà pour les jours fériés et versionnez-les, afin qu'une date d'essai recalculée dans un litige reproduise la valeur que le système a réellement utilisée à l'époque.
Une gratification a chuté quand l'essai a été ajouté. Quelque chose soustrait l'essai de l'ancienneté. L'essai compte dans l'ancienneté ; seule la gratification sur une rupture à l'intérieur de celui-ci est exclue.
Les fuseaux horaires décalent une borne d'un jour. Chaque Date ici est construite avec un
T00:00:00Z explicite et relue via toISOString().slice(0, 10). Introduisez un new Date(y, m, d)
en heure locale n'importe où dans ce fichier et les tests de bornes échoueront dans la moitié des
fuseaux du monde.
Prochaines étapes
- Passez la branche article 77 au moteur de règlement de fin de contrat — il tarife le préavis, l'indemnité et le plancher de deux mois vers lequel ce moteur bascule
- Réconciliez les congés qui mettent le compteur en pause avec le moteur d'acquisition de congés payés, puisque la période d'essai n'acquiert pas les congés comme l'ancienneté ordinaire
- Tarifez le congé maladie qui met le compteur en pause avec le moteur de congé maladie — la même absence qui prolonge l'essai est aussi un palier payé sous l'article 117
- Vérifiez les conséquences d'une sortie d'essai sur les ratios d'effectifs dans le moteur de saoudisation Nitaqat — un effectif qui part pendant l'essai a tout de même bougé la moyenne glissante de votre bande
- Laissez un salarié ou un responsable RH vérifier les chiffres obtenus avec le calculateur des droits du travail et le calculateur d'indemnité de fin de service gratuits
Conclusion
L'article 53 tient en quatre phrases, et chacune est une branche : la clause doit être écrite, le total ne peut excéder cent quatre-vingts jours, l'Aïd et les congés maladie ne comptent pas, et une rupture à l'intérieur de la période ne porte rien. L'article 54 en ajoute une cinquième qu'aucune ligne de contrat ne peut trancher seule. Manquez-en une et le système ne lèvera pas d'erreur — il renverra tranquillement « toujours en période d'essai » et quelqu'un rompra un contrat sur cette base.
Le moteur fait quelques centaines de lignes, et sa sortie la plus précieuse n'est pas la date. C'est la liste de motifs : la trace de la règle qui a décidé de la branche, reproductible des mois plus tard quand la décision se plaide devant un tribunal du travail.
Si votre SIRH stocke la période d'essai comme un booléen posé à l'embauche, ou si vous êtes sur le point de rompre le contrat de quelqu'un au cinquième mois sur la foi d'une date que personne n'a recalculée depuis la lettre d'offre, envoyez-nous vos données de contrats et de congés — nous passerons vos cas réels dans un moteur comme celui-ci et vous dirons quelles sorties se trouvent en réalité du côté article 77 de la ligne.