الكتابات/tutorial/2026/08
Tutorial24 أغسطس 2026·24 دقيقة

محرّك تسوية إنهاء الخدمة بـ TypeScript (المادة 77)

بناء فرع إنهاء الخدمة في نظام رواتب سعودي بـ TypeScript: مهلة الإشعار في المادة 75، وبدل الإشعار في المادة 76، وتعويض المادة 77 وحدّه الأدنى بأجر شهرين، وحالات المادة 80 التسع التي تُسقط المكافأة، والمادة 81، ومهلة التصفية في المادة 88.

معظم أنظمة الرواتب في السعودية تعرف كيف تحسب مكافأة نهاية الخدمة. القليل منها يعرف كيف يقرّر ما إذا كانت المكافأة مستحقة أصلًا.

هذا القرار هو الأغلى. العامل نفسه، والراتب نفسه، وسبع سنوات الخدمة نفسها تُنتج أربع تسويات نهائية مختلفة تمامًا بحسب الطريقة التي انتهت بها علاقة العمل — والفارق بين أرخص فرع وأغلاه يُقاس عادةً بمئات الآلاف من الريالات. صاحب العمل الذي فصل موظفًا استنادًا إلى المادة 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% من الإجمالي.

/** 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));
}

العمولة والأجر بالإنتاج. تستثني المادة 86 المكوّنات المتغيّرة بحسب الإنتاج من قاعدة «الأجر الأخير» المباشرة، وتوجب حساب متوسّط بدلًا منها. فإن كانت قوّتك العاملة على عمولة، فليكن الأجر الذي يدخل هذا المحرّك هو ذلك المتوسّط أصلًا — احسبه في طبقة أعلى، وأبقِ المحرّك نقيًّا.

الخطوة 3: نوع العقد يحدّد شكل تعويض المادة 77

تسعّر المادة 77 الإنهاء غير المشروع بطريقتين، والمفتاح بينهما هو نوع العقد:

  • العقد غير محدد المدة: أجر خمسة عشر يومًا عن كل سنة من سنوات الخدمة
  • العقد محدد المدة: أجر المدة الباقية من العقد

ثم يخضع الاثنان لحدّ أدنى: ألا يقلّ التعويض عن أجر شهرين. هذا الحدّ هو ما تنساه أغلب التطبيقات، وهو ما يحسم معظم حالات الخدمة القصيرة. عاملٌ فُصل فصلًا غير مشروع بعد ستة أشهر على عقد غير محدد المدة يُحسب له 7.5 أيام — ويستحق شهرين.

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;
    };

وفي فرع العقد محدد المدة فخّ. فالمادة 55 تحوّل عقد العامل السعودي محدد المدة إلى غير محدد المدة متى استمر الطرفان في تنفيذه بعد تجديده ثلاث مرات متتالية، أو متى بلغت مدته أربع سنوات، أيّهما أقل. والنظام الذي يخزّن kind: 'fixed' إلى الأبد سيسعّر علاقة عمل عمرها عشر سنوات وكأن عدم التجديد فيها مشروع.

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;
}

الخطوة 4: مهلة الإشعار في المادة 75، والمادة 76 حين لا تُمنح

توجب المادة 75 على من ينهي عقدًا غير محدد المدة أن يُشعر الطرف الآخر كتابةً: ستون يومًا إذا كان الأجر شهريًّا، وثلاثون يومًا فيما عدا ذلك. وتُلزم المادة 76 من لم يراعِ المهلة بأن يدفع للطرف الآخر أجر المهلة، أو أجر ما تبقّى منها.

هنا تفصيلان يحسمان صحّة الحساب. الأول أن بدل الإشعار يُحسب على الأجر الفعلي، بمعدّل اليوم على أساس ثلاثين يومًا من الخطوة 2 — فستون يومًا تساوي بالضبط أجر شهرين، لا «دورتَي رواتب». والثاني أنه مستحقّ استقلالًا عن المادة 77. فالعامل الذي فُصل فصلًا غير مشروع وأُخرج في اليوم نفسه يستحق الاثنين معًا: شهرين كحدّ أدنى بموجب المادة 77، وستين يومًا بموجب المادة 76. والأنظمة التي تعاملهما كبديلين تُنصّف كل تسوية من هذا الشكل.

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);
}

الخطوة 5: تعويض المادة 77، والحدّ الأدنى الذي يعلو على العقد

تبدأ المادة 77 بشرط: ما لم يتضمّن العقد تعويضًا محدّدًا مقابل إنهائه لسبب غير مشروع. وعقود العمل في السوق السعودي تستعمل هذه الفتحة باستمرار، وكثير منها يحدّد مبلغًا دون الحدّ الأدنى النظامي — وأشهرها راتب شهر واحد.

هذا الشرط لا يصمد. فالمادة الثامنة تُبطل كل تنازل أو مصالحة عن الحقوق التي يقرّرها النظام للعامل، ومن ثمّ فالتعويض التعاقدي الذي يقلّ عن حدّ الشهرين باطل بمقدار النقص. فلتنمذج الرقم التعاقدي كمُدخل، ثم طبّق الحدّ الأدنى بعده:

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);
}

لاحظ أن serviceYears هنا كسريّ، وذلك مقصود: خمسة عشر يومًا في المادة 77 تستحق عن كل سنة خدمة بما فيها جزء السنة، وهو عكس المعالجة القائمة على تواريخ الذكرى السنوية التي تستعملها مكافأة المادة 84. فإن تشاركت قيمة serviceYears واحدة بين المحرّكين، فسيكون أحدهما خاطئًا. احسبهما من تاريخي المباشرة والانتهاء نفسيهما، لا أحدهما من الآخر.

الخطوة 6: المادة 80 — تسع حالات، وبوّابة إثبات تحسمها

تتيح المادة 80 لصاحب العمل الفسخ دون مكافأة ولا إشعار ولا تعويض. وهي حصرية: تسع حالات معدودة، بلا بند مطّاط لـ«أي إخلال جسيم آخر». وهذه الحالات، في مضمونها:

  1. الاعتداء على صاحب العمل أو المدير المسؤول أو أحد الرؤساء أثناء العمل أو بسببه
  2. عدم أداء الالتزامات الجوهرية المترتبة على العقد، أو عدم إطاعة الأوامر المشروعة، أو عدم مراعاة تعليمات السلامة المكتوبة — بعد إنذار كتابي
  3. ثبوت سوء السلوك، أو ارتكاب عمل مخلّ بالشرف أو الأمانة
  4. ارتكاب العامل عمدًا فعلًا أو تقصيرًا يقصد به إلحاق خسارة مادية بصاحب العمل، بشرط أن يُبلّغ صاحب العمل الجهات المختصة خلال أربع وعشرين ساعة من علمه بالواقعة
  5. اللجوء إلى التزوير للحصول على العمل
  6. كون العامل معيّنًا تحت الاختبار
  7. الغياب دون سبب مشروع أكثر من ثلاثين يومًا خلال السنة الواحدة، أو أكثر من خمسة عشر يومًا متتالية — على أن يسبق الفصل إنذار كتابي بعد بلوغ الغياب عشرين يومًا في الحالة الأولى، وعشرة أيام في الثانية
  8. استغلال المركز الوظيفي بطريقة غير مشروعة لتحقيق مكاسب شخصية
  9. إفشاء الأسرار الصناعية أو التجارية الخاصة بالعمل

وفي الحالات التسع جميعًا توجب المادة على صاحب العمل أن يتيح للعامل فرصة إبداء أسبابه. هذا الشرط الإجرائي هو ما يخسر عنده أصحاب العمل فعليًّا. فلتُرمِّزه كبوّابة قابلة للسقوط، واجعل سقوطها يعيد تصنيف الإنهاء لا أن يسجّل تحذيرًا فحسب:

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;
  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);
}

تمرير 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
 
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');
});

ثم مرّر حالة حقيقية واحدة من كل شكل عبر المحرّك وطابقها بما دُفع فعلًا. في كل مراجعة رواتب أجريناها في هذا المجال، كشفت المطابقة عن واحدة من الفجوات الثلاث الكلاسيكية على الأقل: بدلات غائبة عن قاعدة الأجر، أو حدّ الشهرين لم يُطبَّق قط، أو بدل الإشعار خُصم من التعويض.

حلّ المشكلات الشائعة

كل تسوية ناقصة بنحو الثلث. قاعدة الأجر هي الأجر الأساسي. المواد 76 و77 و84 تستعمل الأجر الفعلي وفق المادة الثانية — الأساسي زائد البدلات.

حالات الفصل غير المشروع قصيرة الخدمة تُخرج مبالغ شبه معدومة. حدّ الشهرين مفقود، أو أنه يُطبَّق قبل الشرط التعاقدي بدل أن يُطبَّق بعده.

انتهاء العقود محددة المدة يولّد بنود مادة 77. انتهاء المدة سبب من أسباب المادة 74 لا إنهاء. تحقّق من أن طبقة الإدخال لديك تحوّل «انتهى العقد» إلى ground: 'article-74' لا إلى no-lawful-cause.

عقد «محدد المدة» طويل الأمد يُسعَّر عدم تجديده كأنه مشروع. تحويل المادة 55 لا يعمل. تأكّد من استدعاء normaliseContract قبل التسعير، ومن أن عدد التجديدات مخزَّن فعلًا.

فصول المادة 80 لا تسقط أبدًا. كائن الإثبات اختياري في طبقة الإدخال ويصل فارغًا، فتكون investigationHeld قيمة كاذبة — وبحسب كيفية كتابتك للبوّابة يسقط كل شيء أو لا يسقط شيء. اجعل حقول الإثبات إلزامية عند الحدود، وارفض المدخل إن لم تزوّدها الموارد البشرية.

معدّلات اليوم تتباعد بين المحرّكات. وحدة تقسم على 30 وأخرى على عدد أيام الشهر الفعلي. صدّر دالّة dailyWage واحدة واستوردها في كل مكان؛ أما مقسوما 240 و180 ساعة في محرّك الأوفر تايم فشأن منفصل لا ينبغي أن يتسرّب إلى البنود المحسوبة بالأيام.

الخطوات التالية

الخلاصة

فرع إنهاء الخدمة في نظام رواتب سعودي ليس معادلة، بل تصنيفٌ تليه معادلة. اضبط التصنيف يصبح الحساب بضع مئات من الأسطر: قاعدتا أجر، ومعدّل يومي على أساس ثلاثين يومًا، ومفتاح واحد لنوع العقد، وبوّابة إثبات بمقارنات صارمة، وحدّ أدنى يُطبَّق أخيرًا. اضبطه خطأً يصبح النظام دقيقًا بثقة في رقمٍ لم يكن الرقم الصحيح أصلًا — وهذا بالضبط شكل القضايا التي تراها المحاكم العمالية.

والقواعد الثلاث الجديرة بأن تُكتب على الحائط: حدّ الشهرين يعلو على أي شرط تعاقدي، وبدل الإشعار وتعويض المادة 77 يجتمعان ولا يتبادلان، وفصلٌ بالمادة 80 بلا أوراقه ليس إلا فصلًا غير مشروع بخطوات إضافية.

إن كانت حالات الإنهاء لديك تُسعَّر اليوم في جدول بيانات، أو كان نظام الموارد البشرية يحسب المكافأة دون أن يسأل أصلًا لماذا انتهت العلاقة، أخبِرنا بشكل منظومتك — سنمرّر حالات الإنهاء في آخر اثني عشر شهرًا عبر محرّك كهذا ونُريك أي فرع لا تنمذجه عمليتك الحالية، قبل أن تُغلق مهلة الاثني عشر شهرًا في المادة 222 بشروط غيرك.