معظم أنظمة الرواتب في السعودية تعرف كيف تحسب مكافأة نهاية الخدمة. القليل منها يعرف كيف يقرّر ما إذا كانت المكافأة مستحقة أصلًا.
هذا القرار هو الأغلى. العامل نفسه، والراتب نفسه، وسبع سنوات الخدمة نفسها تُنتج أربع تسويات نهائية مختلفة تمامًا بحسب الطريقة التي انتهت بها علاقة العمل — والفارق بين أرخص فرع وأغلاه يُقاس عادةً بمئات الآلاف من الريالات. صاحب العمل الذي فصل موظفًا استنادًا إلى المادة 80 دون الإنذار الكتابي الذي تشترطه المادة لا يخسر دفع المادة 80 وحده. يصبح الفصل غير مشروع، وتعود مكافأة المادة 84 كاملة، ويُضاف تعويض المادة 77 فوقها، ويُضاف بدل مهلة الإشعار في المادة 75 فوق ذلك كله.
هذا الدرس يبني ذلك القرار برمجيًا: محرّك تسوية يصنّف نوع الإنهاء أولًا ثم يسعّره ثانيًا. وهو الفرع الذي يعلو محرّك مكافأة نهاية الخدمة ويقرّر بأي مقياس يجب استدعاؤه.
المتطلبات الأساسية
- Node.js 20 أو أحدث مع TypeScript 5.x
- إلمام بالاتحادات المميّزة (discriminated unions) وجُمَل
switchالشاملة - نص نظام العمل السعودي المنشور مفتوحًا في تبويب آخر (laws.boe.gov.sa). أرقام المواد مذكورة هنا لتتحقّق من كل قاعدة من مصدرها — افعل ذلك، فالنظام عُدّل مرارًا، وتاريخ امتثالك أهم من تاريخ هذا المقال
- معرفة بحساب المبالغ بالأعداد الصحيحة. إن لم تقرأه بعد، فإن محرّك احتساب الأوفر تايم يشرح تمثيل الهللات الذي يعيد هذا الدرس استخدامه
ما الذي ستبنيه
وحدة نقيّة بلا اعتماديات، تُصدّر مدخلًا واحدًا:
settleTermination(input: TerminationInput): Settlementتُعيد تسوية مفصّلة — سطر لكل بند نظامي، يحمل كل سطر المادة التي جاء منها — إضافةً إلى تصنيفٍ يشرح لماذا سُعّر الإنهاء بهذه الطريقة، والتاريخ الذي توجب المادة 88 دفع المبلغ قبله.
الخطوة 1: صنّف الإنهاء قبل أن تسعّر أي شيء
أكثر الأخطاء المعمارية شيوعًا في هذا المجال دالّة اسمها calculateEndOfService(salary, years). إنها
تفترض ضمنًا أن الإنهاء كان عاديًا، فيصبح كل فصل غير مشروع حالةً يعالجها موظف بشري بتجاوز النظام.
اجعل الإنهاء بيانات بدل ذلك. هناك خمس طرق تنتهي بها علاقة العمل ولها أثر مالي:
/** 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 };كل فرع أدناه مبنيّ على هذا الاتحاد. وحين يُعدَّل النظام، تُضيف حالة جديدة فيخبرك المترجم بكل موضع يحتاج
قرارًا — وهذا هو السبب الكامل لنمذجة الأمر هكذا بدل قيم منطقية باسم isUnfairDismissal.
الخطوة 2: قاعدتان للأجر وتقويم واحد، وليس أيٌّ منها بديهيًّا
تعرّف المادة الثانية من نظام العمل الأجر بأنه الأجر الأساسي مضافًا إليه سائر الزيادات والبدلات المستحقة للعامل مقابل عمله. أما الأجر الأساسي فهو الرقم التعاقدي قبل تلك الإضافات.
تعويض المادة 77، وبدل الإشعار في المادة 76، ومكافأة المادة 84 — جميعها تُحسب على الأجر الفعلي لا الأساسي. والأنظمة التي تسعّرها على الأجر الأساسي تُنقص كل تسوية بمقدار بدلي السكن والنقل، وهما في العُرف السعودي ما بين 25% و35% من الإجمالي.
وتعرّف المادة الثانية أيضًا الشهر بأنه «ثلاثون يومًا ما لم ينص على خلاف ذلك في عقد العمل أو في لائحة تنظيم العمل»، فتصير السنة النظامية 360 يومًا لا 365 ولا 365.25. أما المادة العاشرة فتحسم التقويم: «تحسب جميع المدد والمواعيد المنصوص عليها في هذا النظام بالتقويم الهجري، ما لم ينص في عقد العمل أو لائحة تنظيم العمل على خلاف ذلك». والسنة الهجرية نحو 354 يومًا، فالعقد الساكت عن هذه المسألة تتراكم فيه الخدمة أسرع بنحو 3% من قراءته قراءةً ميلادية.
لا تُعِد بناء هذا الحساب هنا. فمحرّك
مكافأة نهاية الخدمة
يصدّر دالّة serviceDays360 تطابق حاسبة وزارة العدل العمالية إلى الهللة في 45 حالة مرجعية.
استوردها، كي يستحيل على المحرّكين أن يختلفا في مدّة خدمة الشخص نفسه:
import { serviceDays360, parseDate, type CalendarBasis } from './end-of-service';
/** All money is integer halalas. 1 SAR = 100 halalas. Floats do not belong here. */
type Halalas = number;
const MS_PER_DAY = 86_400_000;
type Wage = {
/** الأجر الأساسي — the contractual basic. */
basic: Halalas;
/** Housing, transport and any other allowance due for the work itself. */
allowances: Halalas;
};
/** الأجر — Article 2. This is the base for Articles 76, 77 and 84. */
const actualMonthlyWage = (w: Wage): Halalas => w.basic + w.allowances;
/**
* Labour entitlements are priced per calendar day on a 30-day month, not on the
* actual length of the month. A 31-day month does not make the day rate cheaper.
*/
const dailyWage = (w: Wage): Halalas => divideRoundHalfUp(actualMonthlyWage(w), 30);
function divideRoundHalfUp(numerator: number, denominator: number): number {
return Math.floor((numerator * 2 + denominator) / (denominator * 2));
}العمولة والأجر بالإنتاج. تستثني المادة 86 المكوّنات المتغيّرة بحسب الإنتاج من قاعدة «الأجر الأخير» المباشرة، وتوجب حساب متوسّط بدلًا منها. فإن كانت قوّتك العاملة على عمولة، فليكن الأجر الذي يدخل هذا المحرّك هو ذلك المتوسّط أصلًا — احسبه في طبقة أعلى، وأبقِ المحرّك نقيًّا.
الخطوة 3: نوع العقد يحدّد شكل تعويض المادة 77
تسعّر المادة 77 الإنهاء غير المشروع بطريقتين، والمفتاح بينهما هو نوع العقد:
- العقد غير محدد المدة: أجر خمسة عشر يومًا عن كل سنة من سنوات الخدمة
- العقد محدد المدة: أجر المدة الباقية من العقد
ثم يخضع الاثنان لحدّ أدنى: ألا يقلّ التعويض عن أجر شهرين. هذا الحدّ هو ما تنساه أغلب التطبيقات، وهو ما يحسم معظم حالات الخدمة القصيرة. عاملٌ فُصل فصلًا غير مشروع بعد ستة أشهر على عقد غير محدد المدة يُحسب له 7.5 أيام — ويستحق شهرين.
type Contract =
| { kind: 'indefinite' }
| {
kind: 'fixed';
/** ISO date this term began. Article 55 measures the contract, not the person. */
startedOn: string;
/** ISO date the term was due to end. */
endsOn: string;
/** How many times this fixed term has already been renewed. */
renewals: number;
/** Article 55/2 only runs on a term that carries a renewal clause. */
hasRenewalClause: boolean;
/** Both limbs of Article 55 require that the parties kept performing. */
stillPerforming: boolean;
};وتحت فرع العقد محدد المدة فخٌّ ليس هو الفخّ الذي تحترز منه أغلب التطبيقات. فللمادة 55 شقّان منفصلان، والفارق بينهما هو شرط التجديد:
- 55/1 — عقدٌ لا يتضمّن شرط تجديد تنقضي مدّته، «فإذا استمر طرفاه في تنفيذه عُد العقد مجدّدًا لمدة غير محددة». استمرّ الطرفان، فصار العقد غير محدد المدة. ولا يُشترط شيء آخر: لا أربع سنوات ولا ثلاثة تجديدات.
- 55/2 — عقدٌ يتضمّن شرط تجديد يتجدّد للمدة المتفق عليها بدلًا من ذلك، حتى يتعدّد التجديد ثلاث مرات متتالية أو تبلغ مدة العقد الأصلي مع مدة التجديد أربع سنوات «أيهما أقل» — ويستمر الطرفان في تنفيذه.
والشقّان كلاهما يقيسان العقد لا العامل. فالأربع سنوات في 55/2 هي «مدة العقد الأصلي مع مدة التجديد»: طول المدة نفسها. ومن أمضى عشرين سنة ثم وقّع عقدًا جديدًا لسنتين فهو على عقد محدد المدة، والمحرّك الذي يمرّر إليه مدّة خدمة العامل سيحوّل ذلك العقد يوم توقيعه. وهذا خطأ مكلف في الاتجاهين — إذ يسعّر عدم تجديدٍ مشروع باعتباره انكشافًا على المادة 77.
وتُخرج المادة 37 غير السعوديين من هذا كلّه. والمادة الثانية عشرة من اللائحة التنفيذية صريحة في ذلك: عقد غير السعودي محدد المدة «مهما طالت مدته، أو مدد تجديده، وفي جميع الأحوال لا يتحول إلى عقد غير محدد المدة حتى لو اتفق الطرفان على ذلك» — فلا يتحوّل ولو اتفق الطرفان على تحويله.
const FIXED_TERM_RENEWAL_LIMIT = 3;
const FIXED_TERM_YEARS_LIMIT = 4;
/** Article 2 defines the month as thirty days, so the statutory year is 360. */
const DAYS_PER_YEAR = 360;
/**
* Article 55 — normalise before pricing. Two separate limbs, and both measure the
* contract rather than the worker:
*
* 55/1 — a term with no renewal clause runs out, the parties keep performing, and the
* contract is deemed renewed for an indefinite period.
* 55/2 — a term that does carry a renewal clause renews for the agreed period, until it
* has been renewed three consecutive times or the original term plus the renewals
* reaches four years, whichever comes first, and the parties keep performing.
*
* Service with the same employer under earlier contracts does not convert the current
* one. Article 37, and Article 12 of the Implementing Regulation, put a non-Saudi
* outside this altogether.
*/
function normaliseContract(
contract: Contract,
asOf: string,
calendar: CalendarBasis,
isSaudi: boolean,
): Contract {
if (contract.kind !== 'fixed' || !isSaudi || !contract.stillPerforming) return contract;
const expired = parseDate(asOf, 'terminatedOn') > parseDate(contract.endsOn, 'contract.endsOn');
if (!contract.hasRenewalClause) {
return expired ? { kind: 'indefinite' } : contract;
}
const termYears =
serviceDays360(
parseDate(contract.startedOn, 'contract.startedOn'),
parseDate(contract.endsOn, 'contract.endsOn'),
calendar,
) / DAYS_PER_YEAR;
const converted =
contract.renewals >= FIXED_TERM_RENEWAL_LIMIT || termYears >= FIXED_TERM_YEARS_LIMIT;
return converted ? { kind: 'indefinite' } : contract;
}الخطوة 4: مهلة الإشعار في المادة 75، والمادة 76 حين لا تُمنح
توجب المادة 75 على من ينهي عقدًا غير محدد المدة أن يُشعر الطرف الآخر كتابةً — والطرفان لا يتساويان
في المهلة. فإذا كان الأجر شهريًّا، أعطت الفقرة 75/1/أ العامل «ثلاثين يومًا»، وأعطت 75/1/ب صاحب العمل
«ستين يومًا». وإذا كان الأجر لا يُدفع شهريًّا، وضعت 75/2 الطرفين على ثلاثين. والدالّة الواحدة
noticeDaysRequired(frequency) التي تُرجع 60 لكل إنهاء بأجر شهري تُحمّل العامل المستقيل شهرين من
مهلة لم يكن يدين بها أصلًا.
وتُلزم المادة 76 من لم يراعِ المهلة بأن يدفع للطرف الآخر أجر المهلة نفسها، «ما لم يتفق الطرفان على أكثر من ذلك». فالرقم التعاقدي الأعلى من النظامي مُلزِم، والأدنى منه غير مُلزِم، لأن المادة الثامنة تُبطل كل إنقاص لحقٍّ قرّره النظام.
ثم تفصيلان آخران يحسمان صحّة الحساب. الأول أن بدل الإشعار يُحسب على الأجر الفعلي، بمعدّل اليوم على أساس ثلاثين يومًا من الخطوة 2 — فستون يومًا أجر شهرين، لا «دورتَي رواتب». والثاني أنه مستحقّ استقلالًا عن المادة 77. فالعامل الذي فُصل فصلًا غير مشروع وأُخرج في اليوم نفسه يستحق الاثنين معًا: شهرين كحدّ أدنى بموجب المادة 77، وستين يومًا بموجب المادة 76. والأنظمة التي تعاملهما كبديلين تُنصّف كل تسوية من هذا الشكل.
type PayFrequency = 'monthly' | 'other';
type TerminatingParty = 'employer' | 'worker';
/**
* Article 75 — the notice period is not symmetric. Where the wage is paid monthly the
* employer owes sixty days (75/1/b) and the worker owes thirty (75/1/a). Where it is not
* paid monthly, 75/2 puts both parties on thirty.
*/
const noticeDaysRequired = (f: PayFrequency, by: TerminatingParty): number =>
f === 'monthly' && by === 'employer' ? 60 : 30;
/**
* Article 76 — the shortfall between the notice the law requires of the party who ended
* the contract and the notice actually served. Never negative: serving longer than
* required earns nothing back. An agreed figure above the statutory one stands; one
* below it does not.
*/
function noticeInLieu(
wage: Wage,
frequency: PayFrequency,
by: TerminatingParty,
daysServed: number,
contract: Contract,
agreedNoticeCompensation?: Halalas,
): Halalas {
// Notice is an indefinite-contract obligation. A fixed term ending on its own
// date needs no notice — the end date was the notice.
if (contract.kind === 'fixed') return 0;
const required = noticeDaysRequired(frequency, by);
const shortfall = Math.max(0, required - daysServed);
if (shortfall === 0) return 0;
const agreedDaily =
agreedNoticeCompensation === undefined
? 0
: divideRoundHalfUp(agreedNoticeCompensation, required);
return shortfall * Math.max(dailyWage(wage), agreedDaily);
}الخطوة 5: تعويض المادة 77، والحدّ الأدنى الذي يعلو على العقد
تبدأ المادة 77 بشرط: ما لم يتضمّن العقد تعويضًا محدّدًا مقابل إنهائه لسبب غير مشروع. وعقود العمل في السوق السعودي تستعمل هذه الفتحة باستمرار، وكثير منها يحدّد مبلغًا دون الحدّ الأدنى النظامي — وأشهرها راتب شهر واحد.
هذا الشرط لا يصمد. فالمادة الثامنة تُبطل كل تنازل أو مصالحة عن الحقوق التي يقرّرها النظام للعامل، ومن ثمّ فالتعويض التعاقدي الذي يقلّ عن حدّ الشهرين باطل بمقدار النقص. فلتنمذج الرقم التعاقدي كمُدخل، ثم طبّق الحدّ الأدنى بعده:
const MIN_COMPENSATION_MONTHS = 2;
const INDEFINITE_DAYS_PER_YEAR = 15;
type Article77Input = {
wage: Wage;
contract: Contract;
/** Service on the Article 2 / Article 10 basis — see `settleTermination`. */
serviceYears: number;
/** ISO date the relationship actually ended. */
terminatedOn: string;
/** The calendar the contract runs on (Article 10). */
calendar: CalendarBasis;
/** A figure written into the contract under the Article 77 opening clause. */
contractualCompensation?: Halalas;
};
function article77Compensation(input: Article77Input): {
amount: Halalas;
basis: 'contractual' | 'per-year' | 'remaining-term';
flooredUp: boolean;
} {
const { wage, contract, serviceYears, terminatedOn, calendar, contractualCompensation } = input;
const monthly = actualMonthlyWage(wage);
const floor = MIN_COMPENSATION_MONTHS * monthly;
let amount: Halalas;
let basis: 'contractual' | 'per-year' | 'remaining-term';
if (contractualCompensation !== undefined) {
amount = contractualCompensation;
basis = 'contractual';
} else if (contract.kind === 'indefinite') {
amount = Math.round(serviceYears * INDEFINITE_DAYS_PER_YEAR * dailyWage(wage));
basis = 'per-year';
} else {
amount = remainingTermWage(wage, terminatedOn, contract.endsOn, calendar);
basis = 'remaining-term';
}
// Article 8 — a contract may improve on the statute, never reduce it.
return amount < floor
? { amount: floor, basis, flooredUp: true }
: { amount, basis, flooredUp: false };
}
/**
* Wage for the unexpired part of a fixed term, on the Article 2 thirty-day month and the
* Article 10 calendar. The first unexpired day is the day *after* termination; the
* contract's own end date counts.
*/
function remainingTermWage(
wage: Wage,
from: string,
to: string,
calendar: CalendarBasis,
): Halalas {
const firstUnexpired = new Date(parseDate(from, 'terminatedOn').getTime() + MS_PER_DAY);
const end = parseDate(to, 'contract.endsOn');
if (end < firstUnexpired) return 0;
return serviceDays360(firstUnexpired, end, calendar) * dailyWage(wage);
}serviceYears هنا كسريّ، ومصدره قياس المادتين الثانية والعاشرة في الخطوة 2 — لا سنةٌ ميلادية مقسومة
على 365.25.
تصحيح بتاريخ 2026-09-21. كانت نسخة سابقة من هذا الدرس تقول إن خمسة عشر يومًا في المادة 77 تستحق عن جزء السنة «وهو عكس المعالجة القائمة على تواريخ الذكرى السنوية التي تستعملها مكافأة المادة 84». وهذا قلبٌ للمادتين. فالمادة 84 هي التي تنصّ على قاعدة التناسب صراحةً — «ويستحق العامل مكافأة عن أجزاء السنة بنسبة ما قضاه منها في العمل» — بينما لا تتضمّن المادة 77 نصًّا على جزء السنة أصلًا. ومعاملة جزء السنة بالتناسب في المادة 77 هي العُرف السائد، غير أنها قراءة لعبارة «عن كل سنة من سنوات خدمة العامل» لا اقتباس منها. فاذكر ذلك في تعليق الشفرة بدل الإحالة إلى جملة موضعها المادة الأخرى.
وهذا الفارق أقلّ أثرًا مما يبدو، والحساب يبيّن السبب. فخمسة عشر يومًا في السنة لا تبلغ ستين يومًا — أي حدّ الشهرين — إلا عند أربع سنوات من الخدمة، ومن ثمّ فدون الأربع سنوات يحسم الحدُّ الأدنى الرقمَ ولا تستطيع قراءةُ جزء السنة تغييره. وفوق الأربع سنوات يساوي جزء السنة 15/360 من الأجر الشهري عن كل يوم إضافي، أي نحو 41 ريالًا على أجر 10,000.
وعلى هذه الحدود بالذات يقبع فخُّ تقريب. فمعدّل اليوم جزءٌ من ثلاثين من الأجر الشهري بعد التقريب،
فتكون ستون يومًا بمعدّل اليوم أقلّ من أجر شهرين بعشرين هللة. قارن المبلغ المحسوب بالحدّ الأدنى
بالهللات كما تفعل الشفرة أعلاه — فالفحص المكتوب على صورة «هل هذا أكثر من 60 يومًا» يُرجع
flooredUp: false على رقم هو في الحقيقة دون الحدّ.
الخطوة 6: المادة 80 — تسع حالات، وبوّابة إثبات تحسمها
تتيح المادة 80 لصاحب العمل الفسخ دون مكافأة ولا إشعار ولا تعويض. وهي حصرية: تسع حالات معدودة، بلا بند مطّاط لـ«أي إخلال جسيم آخر». وهذه الحالات، في مضمونها:
- الاعتداء على صاحب العمل أو المدير المسؤول أو أحد الرؤساء أثناء العمل أو بسببه
- عدم أداء الالتزامات الجوهرية المترتبة على العقد، أو عدم إطاعة الأوامر المشروعة، أو عدم مراعاة تعليمات السلامة المكتوبة — بعد إنذار كتابي
- ثبوت سوء السلوك، أو ارتكاب عمل مخلّ بالشرف أو الأمانة
- ارتكاب العامل عمدًا فعلًا أو تقصيرًا يقصد به إلحاق خسارة مادية بصاحب العمل، بشرط أن يُبلّغ صاحب العمل الجهات المختصة خلال أربع وعشرين ساعة من علمه بالواقعة
- اللجوء إلى التزوير للحصول على العمل
- كون العامل معيّنًا تحت الاختبار
- الغياب دون سبب مشروع أكثر من ثلاثين يومًا خلال السنة الواحدة، أو أكثر من خمسة عشر يومًا متتالية — على أن يسبق الفصل إنذار كتابي بعد بلوغ الغياب عشرين يومًا في الحالة الأولى، وعشرة أيام في الثانية
- استغلال المركز الوظيفي بطريقة غير مشروعة لتحقيق مكاسب شخصية
- إفشاء الأسرار الصناعية أو التجارية الخاصة بالعمل
وفي الحالات التسع جميعًا توجب المادة على صاحب العمل أن يتيح للعامل فرصة إبداء أسبابه. هذا الشرط الإجرائي هو ما يخسر عنده أصحاب العمل فعليًّا. فلتُرمِّزه كبوّابة قابلة للسقوط، واجعل سقوطها يعيد تصنيف الإنهاء لا أن يسجّل تحذيرًا فحسب:
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;
}
}انتبه إلى المقارنة الصارمة في عتبات الغياب. النصّ يقول أكثر من ثلاثين يومًا، فالعامل الغائب ثلاثين يومًا بالضبط لا يجوز فصله بالحالة السابعة — اليوم الحادي والثلاثون هو الذي يُعتدّ به. والخطأ بمقدار واحد هنا ليس خطأ تقريب، بل هو الفرق بين فصل مشروع وتسوية كاملة زائد شهرين.
الخطوة 7: المادة 81 — العامل يترك العمل وصاحب العمل يدفع
المادة 81 مرآة المادة 80. فهي تتيح للعامل ترك العمل دون إشعار مع احتفاظه بحقوقه النظامية كاملة، حين يكون صاحب العمل قد: أخلّ بالتزاماته الجوهرية العقدية أو النظامية؛ أو غشّه وقت التعاقد في شروط العمل؛ أو كلّفه بعمل يختلف جوهريًّا عن المتفق عليه دون موافقته؛ أو اعتدى عليه؛ أو عامله معاملة تتّسم بالقسوة أو الجور؛ أو عرّضه لخطر جسيم يهدّد سلامته وهو عالم به ولم يتّخذ إجراءً؛ أو تصرّف بما يجعل العامل في ظاهر الأمر هو من أنهى العقد.
ومن حيث التسعير، فإن إنهاء المادة 81 هو إنهاء من صاحب العمل. يحتفظ العامل بمكافأة المادة 84 كاملة — بلا مقياس الاستقالة — وتنطبق تبعة المادة 77. وأنظمة الرواتب التي تربط مقياس المكافأة بـ«من ضغط الزرّ» تعكس هذه الحالة تمامًا، لأن العامل على الورق مُستقيل.
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'],
};
}
}مصفوفة rationale ليست زينة. فحين تُنازَع هذه التسوية على منصّة ودّي للتسوية الودّية أو أمام
المحكمة العمالية، يكون السؤال المطروح: من أي مادة جاء كل رقم؟ المحرّك الذي يُخرج التسبيب إلى جانب
المبلغ يجيب عن ذلك في ثوانٍ، والذي يُخرج مجموعًا واحدًا يعيد شخصًا ما إلى جدول بيانات.
الخطوة 8: تركيب التسوية، مع ساعة المادة 88
بنود التسوية النهائية، بالترتيب الذي تستحقّه في المستند:
type SettlementLine = { head: string; article: string; amount: Halalas };
type TerminationInput = {
exit: Exit;
wage: Wage;
contract: Contract;
frequency: PayFrequency;
isSaudi: boolean;
/**
* Article 10 — every period in the Labour Law is Hijri unless the contract or the
* work regulation says otherwise. Required, so that nobody defaults it by accident.
*/
calendar: CalendarBasis;
hiredOn: string;
terminatedOn: string;
noticeDaysServed: number;
contractualCompensation?: Halalas;
/** Article 76 — a notice compensation agreed above the statutory one. */
agreedNoticeCompensation?: Halalas;
/** Already-computed balances from the other engines. */
unpaidWages: Halalas;
unusedLeavePay: Halalas;
unpaidOvertime: Halalas;
/** Article 84/85 award, computed by the end-of-service engine for the scale we pass it. */
award: (scale: 'full' | 'resignation-scale' | 'none') => Halalas;
};
type Settlement = {
classification: Classification;
lines: SettlementLine[];
total: Halalas;
/** Article 88 — the date the money is legally due. */
dueBy: string;
};
function settleTermination(input: TerminationInput): Settlement {
// Articles 2 and 10: whole thirty-day months on the contract's calendar, then the
// days left over. The same function the end-of-service engine uses, so the two
// engines cannot disagree about how long this person worked.
const serviceYears =
serviceDays360(
parseDate(input.hiredOn, 'hiredOn'),
parseDate(input.terminatedOn, 'terminatedOn'),
input.calendar,
) / DAYS_PER_YEAR;
const contract = normaliseContract(
input.contract,
input.terminatedOn,
input.calendar,
input.isSaudi,
);
const classification = classify(input.exit);
const lines: SettlementLine[] = [];
if (input.unpaidWages > 0) {
lines.push({ head: 'Unpaid wages', article: 'Art. 90', amount: input.unpaidWages });
}
if (input.unpaidOvertime > 0) {
lines.push({ head: 'Unpaid overtime', article: 'Art. 107', amount: input.unpaidOvertime });
}
if (input.unusedLeavePay > 0) {
lines.push({ head: 'Unused annual leave', article: 'Art. 111', amount: input.unusedLeavePay });
}
const award = input.award(classification.awardScale);
if (award > 0) {
lines.push({ head: 'End-of-service award', article: 'Art. 84/85', amount: award });
}
if (classification.noticeOwedByEmployer) {
const lieu = noticeInLieu(
input.wage,
input.frequency,
'employer',
input.noticeDaysServed,
contract,
input.agreedNoticeCompensation,
);
if (lieu > 0) {
lines.push({ head: 'Pay in lieu of notice', article: 'Art. 76', amount: lieu });
}
}
if (classification.article77Owed) {
const c = article77Compensation({
wage: input.wage,
contract,
serviceYears,
terminatedOn: input.terminatedOn,
calendar: input.calendar,
contractualCompensation: input.contractualCompensation,
});
lines.push({ head: 'Unlawful-termination compensation', article: 'Art. 77', amount: c.amount });
classification.rationale.push(
c.flooredUp
? `Article 77 basis "${c.basis}" raised to the two-month statutory floor`
: `Article 77 basis "${c.basis}"`,
);
}
return {
classification,
lines,
total: lines.reduce((sum, l) => sum + l.amount, 0),
dueBy: settlementDeadline(input.terminatedOn, input.exit.by),
};
}
/**
* Article 88 — one week where the employer ended the relationship, two weeks
* where the worker did. The clock does not wait for the next payroll run.
*/
function settlementDeadline(terminatedOn: string, by: Exit['by']): string {
const days = by === 'worker' ? 14 : 7;
const due = new Date(parseDate(terminatedOn, 'terminatedOn').getTime() + days * MS_PER_DAY);
return due.toISOString().slice(0, 10);
}تمرير award كدالّة أمر مقصود. فهذا المحرّك يقرّر المقياس، ولا يعيد تنفيذ المادتين 84 و85. مرّر إليه
دالّة محرّك نهاية الخدمة لديك، فيبقى الاثنان متّسقين حين تُعدَّل أيّ من المادتين.
اختبار التنفيذ
اختبر مقابل النصّ النظامي، لا مقابل المخرجات الحالية. كل حالة أدناه قاعدة من النظام لا لقطة انحدار:
import test from 'node:test';
import assert from 'node:assert/strict';
const wage: Wage = { basic: 800_000, allowances: 200_000 }; // 8,000 + 2,000 SAR
const monthly = actualMonthlyWage(wage);
const daily = dailyWage(wage);
const indefinite: Contract = { kind: 'indefinite' };
const fixedTerm = (o: Partial<Extract<Contract, { kind: 'fixed' }>>): Contract => ({
kind: 'fixed',
startedOn: '2025-01-01',
endsOn: '2026-12-31',
renewals: 0,
hasRenewalClause: false,
stillPerforming: true,
...o,
});
test('Article 77 floor lifts a short-service indefinite case to two months', () => {
const c = article77Compensation({
wage,
contract: indefinite,
serviceYears: 0.5, // 7.5 days computed
terminatedOn: '2026-08-24',
calendar: 'hijri',
});
assert.equal(c.amount, 2 * monthly); // 20,000 SAR
assert.equal(c.flooredUp, true);
});
test('a contractual one-month clause is raised to the statutory floor', () => {
const c = article77Compensation({
wage,
contract: indefinite,
serviceYears: 6,
terminatedOn: '2026-08-24',
calendar: 'hijri',
contractualCompensation: monthly, // one month, as written
});
assert.equal(c.amount, 2 * monthly);
});
test('fifteen days a year only clears the floor past four years of service', () => {
const at = (serviceYears: number) =>
article77Compensation({ wage, contract: indefinite, serviceYears, terminatedOn: '2026-08-24', calendar: 'hijri' });
for (const years of [0.25, 1, 2, 3, 3.9, 4]) {
assert.equal(at(years).amount, 2 * monthly, `${years} years should be floored`);
}
// The day rate is a rounded thirtieth, so sixty days is twenty halalas under two months.
assert.equal(60 * daily, 2 * monthly - 20);
assert.equal(at(4 + 1 / 360).flooredUp, false);
});
test('the unexpired term starts the day after termination, and the end date counts', () => {
const c = article77Compensation({
wage,
contract: fixedTerm({ startedOn: '2026-01-01', endsOn: '2026-12-31' }),
serviceYears: 0.5,
terminatedOn: '2026-06-30',
calendar: 'gregorian',
});
assert.equal(c.basis, 'remaining-term');
assert.equal(c.amount, 180 * daily); // six thirty-day months, not 181 actual days
});
test('Article 2 and Article 10 — the contract decides the calendar, and the calendar decides the money', () => {
const settle = (calendar: CalendarBasis) =>
settleTermination({
exit: { by: 'employer', ground: 'no-lawful-cause' },
wage,
contract: indefinite,
frequency: 'monthly',
isSaudi: true,
calendar,
hiredOn: '2019-01-01',
terminatedOn: '2023-12-31',
noticeDaysServed: 60,
unpaidWages: 0,
unusedLeavePay: 0,
unpaidOvertime: 0,
award: () => 0,
}).lines.find((l) => l.article === 'Art. 77')!.amount;
// 1,800 days of thirty-day months = exactly five years.
assert.equal(settle('gregorian'), Math.round(5 * 15 * daily));
// The same dates in Umm al-Qura are 1,853 days: a silent contract is Hijri, and pays more.
assert.equal(settle('hijri'), Math.round((1853 / 360) * 15 * daily));
assert.ok(settle('hijri') > settle('gregorian'));
});
test('Article 55 measures the contract, not the worker', () => {
const kind = (c: Contract, asOf: string, isSaudi = true) =>
normaliseContract(c, asOf, 'gregorian', isSaudi).kind;
// A two-year term signed by a long-serving worker is still a two-year term.
assert.equal(kind(fixedTerm({ hasRenewalClause: true }), '2026-06-30'), 'fixed');
// 55/2 — the term's own length reaching four years converts it.
assert.equal(
kind(fixedTerm({ hasRenewalClause: true, startedOn: '2023-01-01' }), '2026-06-30'),
'indefinite',
);
// 55/2 — so do three consecutive renewals, whatever the length.
assert.equal(kind(fixedTerm({ hasRenewalClause: true, renewals: 3 }), '2026-06-30'), 'indefinite');
// 55/1 — no renewal clause, term expired, both parties carried on.
assert.equal(kind(fixedTerm({ endsOn: '2026-01-31' }), '2026-06-30'), 'indefinite');
// 55/1 — but only if they carried on.
assert.equal(
kind(fixedTerm({ endsOn: '2026-01-31', stillPerforming: false }), '2026-06-30'),
'fixed',
);
// Article 37 and Regulation 12 — a non-Saudi contract never converts.
assert.equal(
kind(fixedTerm({ hasRenewalClause: true, renewals: 9, endsOn: '2026-01-31' }), '2026-06-30', false),
'fixed',
);
});
test('Article 75 notice is asymmetric', () => {
assert.equal(noticeDaysRequired('monthly', 'employer'), 60);
assert.equal(noticeDaysRequired('monthly', 'worker'), 30);
assert.equal(noticeDaysRequired('other', 'employer'), 30);
assert.equal(noticeDaysRequired('other', 'worker'), 30);
});
test('Article 76 honours a higher agreed figure and ignores a lower one', () => {
const agreed = 4 * monthly; // 40,000 SAR for the sixty-day period
assert.equal(
noticeInLieu(wage, 'monthly', 'employer', 0, indefinite, agreed),
60 * divideRoundHalfUp(agreed, 60),
);
assert.equal(noticeInLieu(wage, 'monthly', 'employer', 0, indefinite, 50_000), 60 * daily);
});
test('absence of exactly 30 days does not establish Article 80 case 7', () => {
const failure = article80Failure(7, {
investigationHeld: true,
absence: { daysInYear: 30, consecutiveDays: 4, warningIssued: true },
});
assert.match(failure ?? '', /threshold/);
});
test('a case 7 dismissal without the written warning collapses into Article 77', () => {
const c = classify({
by: 'employer',
ground: 'article-80',
case: 7,
evidence: {
investigationHeld: true,
absence: { daysInYear: 41, consecutiveDays: 41, warningIssued: false },
},
});
assert.equal(c.awardScale, 'full');
assert.equal(c.article77Owed, true);
});
test('Article 81 keeps the full award even though the worker left', () => {
const c = classify({ by: 'worker', ground: 'article-81', case: 4, noticeGiven: false });
assert.equal(c.awardScale, 'full');
assert.equal(c.article77Owed, true);
});
test('notice in lieu and Article 77 are cumulative, not alternatives', () => {
const lieu = noticeInLieu(wage, 'monthly', 'employer', 0, indefinite);
assert.equal(lieu, 60 * daily); // two months on top of Article 77
});
test('Article 88 gives one week when the employer terminated', () => {
assert.equal(settlementDeadline('2026-08-24', 'employer'), '2026-08-31');
assert.equal(settlementDeadline('2026-08-24', 'worker'), '2026-09-07');
});ثم مرّر حالة حقيقية واحدة من كل شكل عبر المحرّك وطابقها بما دُفع فعلًا. في كل مراجعة رواتب أجريناها في هذا المجال، كشفت المطابقة عن واحدة من الفجوات الثلاث الكلاسيكية على الأقل: بدلات غائبة عن قاعدة الأجر، أو حدّ الشهرين لم يُطبَّق قط، أو بدل الإشعار خُصم من التعويض.
حلّ المشكلات الشائعة
كل تسوية ناقصة بنحو الثلث. قاعدة الأجر هي الأجر الأساسي. المواد 76 و77 و84 تستعمل الأجر الفعلي وفق المادة الثانية — الأساسي زائد البدلات.
حالات الفصل غير المشروع قصيرة الخدمة تُخرج مبالغ شبه معدومة. حدّ الشهرين مفقود، أو أنه يُطبَّق قبل الشرط التعاقدي بدل أن يُطبَّق بعده.
انتهاء العقود محددة المدة يولّد بنود مادة 77. انتهاء المدة سبب من أسباب المادة 74 لا إنهاء. تحقّق
من أن طبقة الإدخال لديك تحوّل «انتهى العقد» إلى ground: 'article-74' لا إلى no-lawful-cause.
عقد «محدد المدة» طويل الأمد يُسعَّر عدم تجديده كأنه مشروع. تحويل المادة 55 لا يعمل. تأكّد من استدعاء
normaliseContract قبل التسعير، ومن أن عدد التجديدات مخزَّن فعلًا، ومن أن stillPerforming ليست
مثبَّتة على false في طبقة إدخال لم تلتقطها أصلًا.
عقد محدد المدة وُقّع للتوّ يُسعَّر كأنه غير محدد المدة. التحويل يُغذّى بمدّة خدمة العامل بدل مدّة العقد. المادة 55 تقيس العقد: من أمضى عشرين سنة ثم وقّع عقدًا جديدًا لسنتين فهو على عقد لسنتين.
كل تسوية ناقصة بنسبة اثنين أو ثلاثة بالمئة ولا أحد يعرف أين. مدّة الخدمة تُحسب بسنوات ميلادية.
المادة العاشرة تجعل المدّة هجرية ما لم ينص عقد العمل أو لائحة تنظيم العمل على خلاف ذلك، والمادة
الثانية تجعل الشهر ثلاثين يومًا. مرّر تقويم العقد صراحةً واحسب بـ serviceDays360، ولا تقسم الفارق
بالمللي ثانية على 365 أو 365.25.
العامل المستقيل يُحمَّل ستين يومًا من مهلة الإشعار. noticeDaysRequired مبنيّة على دورية الأجر
وحدها. الفقرة 75/1 تعطي صاحب العمل ستين يومًا والعامل ثلاثين؛ ولا تتماثل المهلة إلا في 75/2، للأجر
غير الشهري.
المدة الباقية من العقد محدد المدة أطول بيوم. المدة غير المنقضية تُقاس من تاريخ الإنهاء بدل اليوم التالي له، فيُدفع آخر يوم عمل مرتين — مرة في تصفية الأجر ومرة في بند المادة 77.
فصول المادة 80 لا تسقط أبدًا. كائن الإثبات اختياري في طبقة الإدخال ويصل فارغًا، فتكون
investigationHeld قيمة كاذبة — وبحسب كيفية كتابتك للبوّابة يسقط كل شيء أو لا يسقط شيء. اجعل حقول
الإثبات إلزامية عند الحدود، وارفض المدخل إن لم تزوّدها الموارد البشرية.
معدّلات اليوم تتباعد بين المحرّكات. وحدة تقسم على 30 وأخرى على عدد أيام الشهر الفعلي. صدّر دالّة
dailyWage واحدة واستوردها في كل مكان؛ أما مقسوما 240 و180 ساعة في محرّك الأوفر تايم فشأن منفصل لا
ينبغي أن يتسرّب إلى البنود المحسوبة بالأيام.
الخطوات التالية
- مرّر قرار المقياس إلى محرّك مكافأة نهاية الخدمة الذي ينفّذ المواد 84 و85 و87 وفق المقياس الذي يختاره هذا المحرّك، ويشرحه بندًا بندًا دليل الحاسبة العمالية السعودية
- سوِّ رصيد الإجازات عبر محرّك احتساب رصيد الإجازة السنوية — فرقم الإجازة غير المستعملة المحسوب بمعدّل يومي مختلف لن يطابق هذه التسوية
- استخرج الأوفر تايم غير المدفوع من محرّك احتساب الأوفر تايم قبل إغلاق الملف؛ فهو يظهر عند الإنهاء أكثر من أي وقت آخر
- ادفع الشهر الأخير عبر مولّد ملف حماية الأجور، فملف حماية الأجور هو ما تقارن به الوزارة روايتك
- افهم الجانب المنصّي في ربط قوى بأنظمة الموارد البشرية و التكامل مع مدد للرواتب
- ودَع الموظف المعنيّ يتحقّق من الأرقام نفسها عبر الحاسبة العمالية المجانية
الخلاصة
فرع إنهاء الخدمة في نظام رواتب سعودي ليس معادلة، بل تصنيفٌ تليه معادلة. اضبط التصنيف يصبح الحساب بضع مئات من الأسطر: قاعدتا أجر، ومعدّل يومي على أساس ثلاثين يومًا، ومفتاح واحد لنوع العقد، وبوّابة إثبات بمقارنات صارمة، وحدّ أدنى يُطبَّق أخيرًا. اضبطه خطأً يصبح النظام دقيقًا بثقة في رقمٍ لم يكن الرقم الصحيح أصلًا — وهذا بالضبط شكل القضايا التي تراها المحاكم العمالية.
والقواعد الثلاث الجديرة بأن تُكتب على الحائط: حدّ الشهرين يعلو على أي شرط تعاقدي، وبدل الإشعار وتعويض المادة 77 يجتمعان ولا يتبادلان، وفصلٌ بالمادة 80 بلا أوراقه ليس إلا فصلًا غير مشروع بخطوات إضافية.
إن كانت حالات الإنهاء لديك تُسعَّر اليوم في جدول بيانات، أو كان نظام الموارد البشرية يحسب المكافأة دون أن يسأل أصلًا لماذا انتهت العلاقة، أخبِرنا بشكل منظومتك — سنمرّر حالات الإنهاء في آخر اثني عشر شهرًا عبر محرّك كهذا ونُريك أي فرع لا تنمذجه عمليتك الحالية، قبل أن تُغلق مهلة الاثني عشر شهرًا في المادة 222 بشروط غيرك.