Every HR system stores a probation period. Almost none of them can answer the only question that matters about it: on the day you terminate this worker, is the probation still running?
Get that wrong and the cost is not a rounding error. Inside a valid probation, either party ends the contract with no notice, no compensation and no end-of-service award. One day outside it, the same termination is priced under Article 77, which carries a floor of two months' wage no matter how short the service was. The difference between the two branches, for a worker on 12,000 SAR, is 24,000 SAR — decided entirely by a date calculation that most systems get wrong.
They get it wrong because the probation clock is not a calendar. Article 53 stops it for Eid
al-Fitr, Eid al-Adha and sick leave. hireDate plus ninety days is not the probation end date, and
it is what nearly every implementation computes.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ and TypeScript 5.4+
- Familiarity with discriminated unions and exhaustive switches
- A rough grasp of the Saudi Labour Law's termination branches — the termination settlement engine covers Articles 75 to 81 and is the branch this engine hands off to
- No date library required. We use plain
Datein UTC and count days ourselves, because the business rule is a day count, not a duration
What You'll Build
A pure function that takes an employment contract, its probation clause and the worker's history with this employer, and returns a decision:
- whether the probation clause is valid at all
- the effective probation length after the statutory ceilings are applied
- the actual probation end date, with the clock paused for the leave the statute excludes
- for a given termination date, which branch prices the exit — Article 53 or Article 77
- a list of human-readable reasons for every one of those answers
No dates are hardcoded. That turns out to be the most important design decision in the whole engine, and Step 4 explains why.
Step 1: The clause is the gate, and it is usually where the money is lost
Article 53 requires the probation to be stated expressly in the employment contract, with its duration clearly defined. This is not paperwork. If the clause is missing, vague, or undocumented, the contract is treated as final from its first day — and a termination that management believed was a free probation exit becomes an Article 77 dismissal with the two-month floor attached.
So the engine's first job is not arithmetic. It is a validity gate.
/** 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[];
};Note what is not in this type: a boolean called isOnProbation. That field is the bug. It gets
set at hire, never recomputed, and drifts out of agreement with the contract the moment anything
interesting happens.
Step 2: Two ceilings, and only one of them is absolute
Article 53 sets a base probation of ninety days, extendable by written agreement between the parties, provided the total in all circumstances does not exceed one hundred and eighty days.
Read the SERP on this and you will find law firms asserting a flat 180-day cap and others asserting ninety. Both are describing the same rule from different sides: ninety is what a single tranche may run to on its own, one hundred and eighty is the ceiling once written extensions are counted. The ceiling is the invariant. Encode it as one.
/** 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;The conservative reading, and the one to ship: excess days are void, but they do not poison the whole clause. A contract writing 200 days of probation has a valid 180-day probation and 20 days of ordinary employment, not a void clause. Truncating is safe; voiding the clause on the employer's behalf is not.
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 };
}Step 3: Article 54 — the check your contract table cannot answer
Article 54 forbids placing a worker under probation more than once with the same employer. There are exactly two exceptions, and both require facts that live outside the current contract row:
- the worker is engaged in a different profession or job, or
- at least six months have passed since the previous employment relationship with that employer ended.
This is the rule that quietly breaks rehires. A returning contractor, a seasonal worker, an employee who moved between two entities under the same commercial registration — each comes back through onboarding, gets a fresh contract with a fresh probation clause, and nobody checks. The second probation is void, and the exit that was supposed to be free is priced under 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)',
};
}Two details worth pausing on. The comparison is against the most recent prior engagement, not
the first — sorting ISO date strings is safe here precisely because they are ISO. And hadProbation
matters: a prior engagement that never carried a probation does not consume the one permitted use.
Step 4: The clock is not a calendar
Here is the rule almost every implementation misses. Article 53 excludes from the probation count the Eid al-Fitr and Eid al-Adha holidays and any sick leave. The clock does not run through them — it pauses and resumes when the worker returns. Ten days of documented sick leave inside a ninety-day probation pushes the end date ten days out.
Now the part that decides your architecture. Eid falls on the Hijri calendar, and in Saudi Arabia the dates are fixed by moon sighting and announced, not derived. A system that computes Eid arithmetically will be right most years and wrong in exactly the years a dispute lands on it. The Eid holiday windows are data, injected and versioned, never a formula.
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');
}The Set is doing real work. Sick leave that straddles Eid is a common shape and a naive
implementation that sums pause lengths double-counts the overlap, extending probation past its
lawful ceiling in the worker's favour — which is a compliance failure in the other direction.
Step 5: One decision, priced both ways
Now assemble it. The engine takes a termination date and says which branch of the law prices it.
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 };
}The boundary is inclusive. A termination on the last probation day is still inside probation; the day after is not. That single comparison operator is worth a unit test of its own, and Step 6 gives it one.
One thing this function deliberately does not do: reduce the worker's service length. Probation counts toward total service for end-of-service purposes if the worker continues past it. What Article 53 removes is the award on a termination during probation — not the days themselves. A system that subtracts probation from service length underpays every gratuity it ever computes.
Step 6: Testing Your Implementation
The cases that matter are the boundaries and the clause defects, not the happy path.
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');
});Run with node --test --experimental-strip-types. The overlap test is the one that catches the
regression an optimisation will eventually introduce, when somebody replaces the day walk with a
sum of pause lengths because it is faster.
Troubleshooting
The probation end date is a day off. Almost always an inclusive-versus-exclusive error on the
start date. Day one of probation is the start date itself, so ninety counted days from 1 January
ends on 31 March, not 1 April. The loop above returns the day on which the count reaches
effectiveDays, which is the inclusive end.
Probation looks longer than 180 days. That is correct and expected once pauses apply. The ceiling in Article 53 is on counted days, not elapsed calendar days. A worker with three weeks of sick leave inside a 180-day probation is lawfully on probation for 201 calendar days.
Eid dates drift year to year. They are supposed to. If your Eid windows come from a hardcoded table generated once, they are wrong for every year past the one they were generated for. Load them from the same source your payroll uses for public holidays and keep them versioned, so a recomputed probation date in a dispute reproduces the value the system used at the time.
A gratuity dropped when probation was added. Something is subtracting probation from service length. Probation counts toward service; only the award on a termination inside it is excluded.
Timezones shift a boundary by one day. Every Date here is built with an explicit T00:00:00Z
and read back with toISOString().slice(0, 10). Introduce a local-time new Date(y, m, d) anywhere
in this file and the boundary tests will fail in half the world's timezones.
Next Steps
- Hand the Article 77 branch to the termination settlement engine — it prices the notice, the compensation and the two-month floor this engine routes to
- Reconcile the leave that pauses the clock against the annual leave accrual engine, since probation does not accrue leave the way ordinary service does
- Price the sick leave that pauses the clock with the sick leave pay engine — the same absence that extends probation is also a paid tier under Article 117
- Check the workforce-ratio consequences of a probation exit in the Nitaqat Saudization engine — headcount that leaves inside probation still moved your band's rolling average
- Let a worker or an HR officer sanity-check the resulting figures with the free labour rights calculator and the end-of-service calculator
Conclusion
Article 53 is four sentences long, and each one of them is a branch: the clause must be written, the total may not exceed one hundred and eighty days, Eid and sick leave do not count, and a termination inside the period carries nothing. Article 54 adds a fifth that no contract row can answer on its own. Miss any of them and the system does not throw an error — it quietly returns "still on probation" and someone terminates on the strength of it.
The engine is a few hundred lines, and its most valuable output is not the date. It is the reason list: a record of which rule decided the branch, reproducible months later when the decision is being argued in front of a labour court.
If your HR system stores probation as a boolean set at hire, or you are about to terminate someone in month five on the strength of a date nobody has recomputed since the offer letter, send us your contract and leave data — we will run your live cases through an engine like this one and tell you which exits are actually sitting on the Article 77 side of the line.