Most Saudi payroll systems can compute an end-of-service award. Very few can decide whether the award is owed at all.
That decision is the expensive one. The same worker, the same salary, the same seven years of service produce four completely different final settlements depending on how the employment relationship ended — and the difference between the cheapest and the most expensive branch is routinely six figures in riyals. An employer who dismissed someone under Article 80 without the written warning the article requires does not simply lose the Article 80 defence. The dismissal becomes unlawful, the full award under Article 84 comes back, Article 77 compensation is added on top, and pay in lieu of the Article 75 notice period is added on top of that.
This tutorial builds that decision as code: a termination settlement engine that classifies the exit first and prices it second. It is the branch that sits above the end-of-service gratuity engine and decides which scale that engine should be called with.
Prerequisites
- Node.js 20 or later and TypeScript 5.x
- Comfort with discriminated unions and exhaustive
switchstatements - The published text of the Saudi Labour Law open in another tab (laws.boe.gov.sa). Article numbers in this tutorial are given so you can check every rule against the source — do that, because the law has been amended repeatedly and your compliance date matters more than this article's
- Familiarity with integer money arithmetic. If you have not read it, the overtime pay engine covers the halala representation this tutorial reuses
What You'll Build
A pure, dependency-free module exposing one entry point:
settleTermination(input: TerminationInput): SettlementIt returns an itemised settlement — one line per statutory head, each carrying the article it comes from — plus a classification explaining why the exit was priced that way, and the date by which Article 88 requires the money to be paid.
Step 1: Classify the exit before you price anything
The single most common architectural mistake in this domain is a function called
calculateEndOfService(salary, years). It bakes in the assumption that the exit was ordinary, and
every unlawful-termination case then has to be handled by a human overriding the system.
Model the exit as data instead. There are five ways an employment relationship ends that matter to money:
/** 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 };Every branch below is driven by this union. When the law is amended, you add a variant and the
compiler tells you every place that needs a decision — which is the entire reason to model it this
way rather than with booleans named isUnfairDismissal.
Step 2: Two wage bases, one of them is not the salary
Article 2 of the Labour Law defines the wage (الأجر) as the basic wage plus all increases and allowances due to the worker for their work. The basic wage (الأجر الأساسي) is the contractual figure before those additions.
Article 77 compensation, Article 76 pay in lieu of notice and the Article 84 award are all computed on the actual wage, not the basic. Systems that price them off the basic wage understate every settlement by whatever housing and transport allowances the worker receives — which in Saudi practice is commonly 25% to 35% of the 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));
}Commission and piece rates. Article 86 excludes components that vary with output from the straightforward "last wage" rule, requiring an average instead. If your workforce is commissioned, the wage passed into this engine must already be that average — resolve it upstream, and keep the engine pure.
Step 3: Contract type decides the shape of the Article 77 award
Article 77 prices unlawful termination two different ways, and the contract type is the switch:
- Indefinite contract: 15 days' wage for each year of service
- Fixed-term contract: the wage for the remaining period of the contract
Both are then subject to a floor: the compensation may not be less than two months' wage. That floor is the part implementations forget, and it is the part that decides most short-service cases. A worker dismissed unlawfully after six months on an indefinite contract computes to 7.5 days — and receives two months.
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;
};There is a trap under the fixed-term branch. Article 55 converts a fixed-term contract with a Saudi
worker into an indefinite one once the parties keep performing after it has been renewed three
consecutive times, or once its total duration reaches four years — whichever comes first. A system
that stores kind: 'fixed' forever will price a ten-year "fixed-term" relationship as if a
non-renewal were lawful.
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;
}Step 4: Article 75 notice, and Article 76 when it was not given
Article 75 requires a party ending an indefinite contract to give written notice: 60 days where the wage is paid monthly, 30 days otherwise. Article 76 makes the party who skipped it pay the other party the wage for the notice period, or for the part of it not served.
Two details decide correctness here. First, pay in lieu is computed on the actual wage, at the 30-day daily rate from Step 2 — so 60 days is exactly two months' wage, not "two payroll runs". Second, it is owed independently of Article 77. A worker dismissed unlawfully and walked out the same day is owed both: two months minimum under Article 77 and 60 days under Article 76. Systems that treat them as alternatives halve every settlement of this shape.
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);
}Step 5: Article 77 compensation, and the floor that overrides the contract
Article 77 opens with a conditional: unless the contract specifies a compensation for termination without a lawful reason. Employment contracts in the Saudi market use that opening constantly, and a large share of them specify a figure below the statutory floor — one month is the classic.
That clause does not survive. Article 8 voids any waiver or settlement of rights the law confers on the worker, so a contractual compensation below the two-month floor is void to the extent of the shortfall. Model the contractual figure as an input, then apply the floor after it:
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);
}Note that serviceYears here is fractional and deliberately so — Article 77's fifteen days accrue
per year of service including the part year, which is the opposite of the anniversary-based
treatment the Article 84 award uses. If you share one serviceYears value between the two engines,
one of them will be wrong. Compute both from the same start and end dates, not from each other.
Step 6: Article 80 — nine cases, and the evidence gate that decides them
Article 80 lets the employer terminate with no award, no notice and no compensation. It is exhaustive: nine cases, listed, with no residual "other serious misconduct" clause. The nine, in substance:
- Assault on the employer, the manager or a superior during or because of work
- Failure to perform essential contractual obligations, or disobedience of lawful instructions, or failure to observe written safety instructions — after a written warning
- Proven bad conduct, or an act affecting honesty or integrity
- A deliberate act or omission causing material loss to the employer, provided the employer notifies the competent authorities within 24 hours of becoming aware of it
- Resorting to forgery to obtain the job
- Being within the probation period
- Absence without a valid reason for more than 30 days in one year, or more than 15 consecutive days — and only after a written warning issued once the absence has reached 20 days in the first case, 10 days in the second
- Unlawfully exploiting the position for personal gain
- Disclosing work-related industrial or commercial secrets
Across all nine, the article requires the employer to give the worker an opportunity to state their case. That procedural requirement is where employers actually lose. Encode it as a gate that can fail, and make failure reclassify the exit rather than merely log a warning:
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;
}
}Note the strict inequality on the absence thresholds. The article says more than 30 days, so a worker absent exactly 30 days cannot be dismissed under case 7 — the thirty-first day is the one that counts. Off-by-one here is not a rounding error; it is the difference between a lawful dismissal and a full settlement plus two months.
Step 7: Article 81 — the worker leaves and the employer pays anyway
Article 81 is Article 80's mirror. It lets the worker leave without notice while keeping full statutory rights, where the employer has, in substance: failed essential contractual or statutory obligations; committed fraud about the conditions of work at the time of contracting; assigned materially different work without consent; assaulted the worker; treated the worker with injustice or cruelty; exposed the worker to a serious danger the employer knew about and did not remedy; or acted so as to make the worker appear to be the party terminating the contract.
For pricing, an Article 81 exit is an employer termination. The worker keeps the full Article 84 award — no resignation scale — and the Article 77 exposure applies. Payroll systems that key the award scale off "who pressed the button" get this exactly backwards, because on paper the worker resigned.
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'],
};
}
}The rationale array is not decoration. When this settlement is disputed on the ودي amicable
settlement platform or before a labour court, the question asked is which article each number came
from. An engine that emits the reasoning alongside the amount answers that in seconds; one that
emits a single total sends someone back into a spreadsheet.
Step 8: Assemble the settlement, with the Article 88 clock
The heads of a final settlement, in the order they belong on the 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);
}The award callback is deliberate. This engine decides the scale; it does not reimplement
Articles 84 and 85. Pass in the function from your end-of-service engine and the two stay
consistent when either statute is amended.
Testing Your Implementation
Test against the statute, not against the current output. Every case below is a rule from the law rather than a regression snapshot:
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');
});Then run one real historical exit of each shape through the engine and reconcile against what was actually paid. In every payroll audit we have run in this cluster, the reconciliation finds at least one of the three classic gaps: allowances missing from the base, the two-month floor never applied, or notice pay netted off against compensation.
Troubleshooting
Every settlement is short by roughly a third. The wage base is the basic wage. Articles 76, 77 and 84 use the actual wage under Article 2 — basic plus allowances.
Short-service unlawful dismissals return almost nothing. The two-month floor is missing, or it is being applied before the contractual clause instead of after it.
Fixed-term non-renewals are generating Article 77 lines. Expiry is an Article 74 cause, not a
termination. Check that your ingestion maps "contract ended" to ground: 'article-74' and not to
no-lawful-cause.
A long-running "fixed-term" contract prices its non-renewal as lawful. Article 55 conversion is
not running. Verify normaliseContract is called before pricing, and that renewal counts are
actually being stored.
Article 80 dismissals never collapse. The evidence object is optional in your ingestion layer
and arrives empty, so investigationHeld is falsy and — depending on how you wrote the gate —
either everything fails or nothing does. Make the evidence fields required at the boundary and
reject the input if HR has not supplied them.
Day rates drift between engines. One module divides by 30, another by the actual days in the
month. Export a single dailyWage and import it everywhere; the overtime engine's 240 and 180-hour
divisors are a separate concern and should not leak into calendar-day heads.
Next Steps
- Feed the scale decision into the end-of-service gratuity engine, which implements Articles 84, 85 and 87 against the scale this engine selects
- Settle the leave balance with the annual leave accrual engine — an unused-leave figure computed on a different day rate will not reconcile with this settlement
- Pull unpaid overtime from the overtime pay engine before closing the file; it surfaces at exit more often than at any other time
- Push the final month through your WPS file generator, since the wage protection file is what the ministry compares your story against
- Understand the platform side in Qiwa integration for HR systems and Mudad payroll integration
- Let the affected employee check the same numbers with the free labour rights calculator, and read the entitlement-by-entitlement explainer in the Saudi labour rights calculator guide
Conclusion
The termination branch of a Saudi payroll system is not a formula, it is a classification followed by a formula. Get the classification right and the arithmetic is a few hundred lines: two wage bases, a 30-day day rate, one contract-type switch, an evidence gate with strict inequalities, and a floor applied last. Get it wrong and the system is confidently precise about a number that was never the right number — which is exactly the shape of case a labour court sees.
The three rules worth writing on the wall: the two-month floor beats any contract clause, notice pay and Article 77 compensation are cumulative, and an Article 80 dismissal without its paperwork is simply an unlawful one with extra steps.
If your exits are currently priced in a spreadsheet, or your HR system computes an award without ever asking why the relationship ended, tell us what your stack looks like — we will run your last twelve months of terminations through an engine like this one and show you which branch your current process is not modelling, before the 12-month window under Article 222 closes on someone else's terms.