الكتابات/tutorial/2026/08
● Tutorial18 أغسطس 2026·30 دقيقة

مكافأة نهاية الخدمة السعودية بـ TypeScript (المادتان 84 و85)

ابنِ محرك احتساب مكافأة نهاية الخدمة للرواتب السعودية بلغة TypeScript، بتطبيق المواد 84 و85 و87 و88 من نظام العمل — المكافأة ذات الشريحتين، وسلّم الاستقالة، واحتساب المدة بأشهر من ثلاثين يومًا على تقويم العقد هجريًا كان أو ميلاديًا، والاستحقاق الشهري الذي يحوّلها من مفاجأة إلى مخصّص محاسبي، واتصال الخدمة عند تغيّر صاحب العمل (المادة 18 وقرار مجلس الوزراء 318).

كل نظام رواتب يعمل في المملكة العربية السعودية مُطالَب بالإجابة الصحيحة عن سؤال واحد: حين يغادر هذا الموظف المبنى، ما الذي ندين له به؟

المادة 84 من نظام العمل تجعل مكافأة نهاية الخدمة — وتُختصر أحيانًا بـ EOSB — التزامًا نظاميًا على صاحب العمل، لا ميزة تمنحها الشركة. والمادة 88 تمنحك أسبوعًا واحدًا للسداد. أما المادة 85 فتجعل الموظف نفسه، بالأجر نفسه ومدة الخدمة نفسها، يساوي ثلاثة أرقام مختلفة تمامًا بحسب من أنهى العقد.

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

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

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

تصحيح بتاريخ 18 سبتمبر 2026. كانت النسخ السابقة من هذا الدرس تقيس الخدمة بالذكرى السنوية الميلادية، وتعامل تاريخ النهاية على أنه اليوم التالي لانتهاء الخدمة، وتفترض أن العقد ميلادي. وعند مقابلتها بالحاسبة العمالية لوزارة العدل اختلفت معها في كل حالة من 45 حالة اختبار، وأنقصت مدة الخدمة في العقد الهجري بنحو ثلاثة بالمئة. الخطوة 3 الآن تتبع المادتين الثانية والعاشرة من نظام العمل، وتطابق رقم الوزارة في الحالات الـ45 كلها.

تحديث بتاريخ 30 سبتمبر 2026. كانت الخطوة 7 تقول إن مهلة الإشعار في المادة 75 ثلاثون يومًا في العقد غير محدد المدة المدفوع أجره شهريًا. ومنذ نفاذ تعديلات المرسوم الملكي (م/44) في 19 فبراير 2025 لا يصح ذلك إلا حين ينهي العامل العقد؛ أما صاحب العمل الذي يدفع الأجر شهريًا فعليه ستون يومًا. والخطوة 9 جديدة: تُبقي الخدمة متصلة عند تغيّر صاحب العمل، وفق المادة 18 من نظام العمل وقرار مجلس الوزراء رقم 318.

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js إصدار 20 أو أحدث، و TypeScript إصدار 5.x
  • إلمام بالحساب بالأعداد الصحيحة، وسبب كون الفاصلة العائمة غير آمنة مع المبالغ المالية
  • نظام رواتب أو موارد بشرية فيه تواريخ بدء التوظيف وتعريف للأجر يمكنك الاستعلام عنه
  • نص نظام العمل السعودي الحالي مفتوحًا أمامك — وزارة الموارد البشرية والتنمية الاجتماعية تنشره، ومركز المعرفة في منصة قوى يحمل نسخة مقروءة من المواد نفسها

ما الذي ستبنيه

دالة نقية باسم endOfService تأخذ تاريخ البدء وتاريخ الانتهاء وآخر أجر وسبب انتهاء العلاقة، وتُعيد تفصيلًا كاملًا: أيام الخدمة، وعدد أشهر الأجر المستحقة بموجب المادة 84، والمكافأة الكاملة، وحصة المادة 85، والمبلغ واجب السداد فعليًا. إضافة إلى دالة استحقاق شهري تجيب عن السؤال المالي — إلى أين وصل هذا الالتزام حتى اليوم، قبل أن يستقيل أحد. ودالة تقسيم للعامل الذي تغيّر صاحب عمله من تحته، تحدد من يدفع كل جزء.

بلا اعتماديات، وبلا مكتبة تواريخ، وبلا أعداد عشرية عائمة.

الخطوة 1: اقرأ النص النظامي قبل أن تكتب المعادلة

أربع مواد تحمل الحساب كله. ويستحق الأمر الدقة في كل واحدة منها، لأن أخطاء التطبيق الشائعة كلها أخطاء قراءة لا أخطاء برمجة.

المادة 84 — المكافأة نفسها. نصف شهر عن كل سنة من السنوات الخمس الأولى من الخدمة، وأجر شهر كامل عن كل سنة بعد ذلك. وتُدفع أجزاء السنة بنسبة ما قضاه منها في العمل.

المزلق هنا أن الشريحتين تراكميتان لا انتقائيتان. سبع سنوات من الخدمة ليست سبعة أشهر، وليست ثلاثة أشهر ونصف. هي خمس سنوات بنصف شهر، زائد سنتين بشهر كامل: 2.5 زائد 2، أي 4.5 شهر.

المادة 85 — تخفيض الاستقالة. حين يكون العامل هو من أنهى العقد، تُخفَّض المكافأة بحسب مدة الخدمة:

مدة الخدمة المكتملةالحصة من مكافأة المادة 84
أقل من سنتينلا شيء
من سنتين إلى أقل من خمس سنواتالثلث
من خمس سنوات إلى أقل من عشرالثلثان
عشر سنوات فأكثرالمكافأة كاملة

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

المادة 87 — الاستثناءات التي تتجاوز المادة 85. العامل الذي يترك العمل لقوة قاهرة خارجة عن إرادته يستحق المكافأة كاملة مهما كانت مدة خدمته. وكذلك العاملة التي تنهي العقد خلال ستة أشهر من تاريخ زواجها أو ثلاثة أشهر من وضعها. وهذه ليست حالات حدّية يمكن تأجيلها — فهي شائعة بما يكفي في القوى العاملة السعودية لتستحق مؤشرًا صريحًا بدل تجاوز يدوي.

المادة 88 — المهلة. حين يُنهي صاحب العمل العلاقة، تُسوّى جميع المستحقات خلال أسبوع واحد من الانتهاء. وحين يستقيل العامل، خلال أسبوعين. والحساب الذي يستغرق من فريقك المالي عشرة أيام لتجميعه يكون قد خالف النظام أصلًا على المسار الأول، وهذه هي الحجة العملية لأتمتته.

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

الخطوة 2: احتفظ بالمال بالهللات، لا بالأعداد العائمة

0.1 + 0.2 لا يساوي 0.3، والمكافأة سلسلة عمليات ضرب فوق أجر. اعمل بالوحدات الصغرى الصحيحة — الهللات — وقرِّب مرة واحدة فقط، عند نقطة الإخراج.

/** 100 halalas to the riyal. */
export const MINOR_PER = 100;
 
/** Divide and round half-away-from-zero, staying in integers. */
export function divRound(numerator: number, denominator: number): number {
  const sign = numerator < 0 ? -1 : 1;
  return sign * Math.round(Math.abs(numerator) / denominator);
}
 
/** '10000' or '10,000.50' to whole halalas. */
export function parseAmount(input: string): number {
  const cleaned = String(input).replace(/[,\s]/g, '');
  if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) throw new TypeError('Not an amount');
  const [whole, frac = ''] = cleaned.split('.');
  return Number(whole) * MINOR_PER + Number(frac.padEnd(2, '0'));
}
 
export function formatMinor(minor: number): string {
  const whole = Math.trunc(minor / MINOR_PER);
  const frac = String(Math.abs(minor % MINOR_PER)).padStart(2, '0');
  return `${whole}.${frac}`;
}

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

الخطوة 3: قِس الخدمة كما تقيسها الوزارة

قبل أن تصل إلى المادة 84 يحكم مدةَ الخدمة تعريفان. المادة الثانية تعرّف الشهر بأنه «ثلاثون يومًا ما لم ينص على خلاف ذلك في عقد العمل أو في لائحة تنظيم العمل». والمادة العاشرة تنص على أن «تحسب جميع المدد والمواعيد المنصوص عليها في هذا النظام بالتقويم الهجري، ما لم ينص في عقد العمل أو لائحة تنظيم العمل على خلاف ذلك».

والحاسبة العمالية لوزارة العدل تطبّق التعريفين معًا. أول ما تطلبه هو نوع التاريخ، هجري أو ميلادي، مع ملاحظة صريحة: «التاريخ الميلادي يستخدم للعقود الميلادية والتاريخ الهجري يستخدم للعقود الهجرية لأن الاستحقاقات تختلف». ثم تحتسب الخدمة بأشهر من ثلاثين يومًا على سنة من 360 يومًا، وتعدّ آخر يوم عمل.

قابلنا محركنا بها في 18 سبتمبر 2026 على 45 زوجًا من التواريخ، شملت الإنهاء والاستقالة، والبدايات في أيام 29 إلى 31 من الشهر، والسنوات الكبيسة. والقاعدة أدناه تعيد إنتاج كل رقم من تلك الأرقام بالهللة. وهناك ثلاث نتائج تستحق أن تعرفها قبل قراءة الشيفرة:

  • آخر يوم عمل يُحتسب. من 1 يناير 2019 إلى 31 ديسمبر 2023 خمس سنوات بالضبط، وتساوي 25,000.00 على أجر 10,000. وإن كان آخر يوم عمل هو 1 يناير 2024 أضيف يوم واحد: 25,027.78.
  • فبراير ثلاثون يومًا. من 1 فبراير إلى 1 مارس شهر ويوم، ومكافأته 430.56. ولا يعود لليوم الكبيس أثر، لأن الأيام لا تُقسم أبدًا على طول السنة الفعلي.
  • التقويم يغيّر النتيجة. في العقد الهجري تساوي التواريخ نفسها 5 سنوات هجرية وشهرًا و23 يومًا، لأن السنة الهجرية أقصر بنحو أحد عشر يومًا. وتحتسبها الوزارة بمبلغ 26,472.22، أي بزيادة 1,472.22 على العقد الميلادي.

كانت النسخة السابقة من هذه الخطوة تعدّ الذكريات السنوية، وتقسم السنة الجارية على عدد أيامها. كانت الفروق صغيرة، لا تتجاوز 49.89 ريالًا على أجر 10,000، لكنها ظهرت في كل الحالات، وهي أول ما يبحث عنه المراجع.

const MS_PER_DAY = 86_400_000;
 
/** Article 10: periods run on the Hijri calendar unless the contract or the work regulation says otherwise. */
export type CalendarBasis = 'gregorian' | 'hijri';
 
/** A date as the calendar in force writes it: [year, month, day]. */
type Ymd = readonly [number, number, number];
 
const HIJRI = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura-nu-latn', {
  timeZone: 'UTC',
  year: 'numeric',
  month: 'numeric',
  day: 'numeric',
});
 
function partsOf(date: Date, calendar: CalendarBasis): Ymd {
  if (calendar === 'gregorian') {
    return [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()];
  }
  const parts = HIJRI.formatToParts(date);
  const pick = (type: string) => Number(parts.find((p) => p.type === type)?.value);
  return [pick('year'), pick('month'), pick('day')];
}
 
const compare = (a: Ymd, b: Ymd) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
 
function addDays(date: Date, days: number): Date {
  return new Date(date.getTime() + days * MS_PER_DAY);
}
 
/**
 * The latest day whose date is on or before `target`. A date the month does
 * not have (31 April, or the 30th of a 29-day Hijri month) settles on the
 * month's last day. `guess` only needs to be close; the walk corrects it.
 */
function onOrBefore(target: Ymd, calendar: CalendarBasis, guess: Date): Date {
  let day = guess;
  while (compare(partsOf(addDays(day, 1), calendar), target) <= 0) day = addDays(day, 1);
  while (compare(partsOf(day, calendar), target) > 0) day = addDays(day, -1);
  return day;
}
 
const MEAN_MONTH_DAYS: Record<CalendarBasis, number> = { gregorian: 30.44, hijri: 29.53 };
 
/**
 * Service in days of a 360-day year: whole months of thirty days (Art. 2),
 * counted in the contract's calendar (Art. 10), plus the days left over.
 * `lastDay` is the last day worked, and it counts.
 */
export function serviceDays360(start: Date, lastDay: Date, calendar: CalendarBasis): number {
  const from = partsOf(start, calendar);
  const after = addDays(lastDay, 1);
  const until = partsOf(after, calendar);
 
  // The same day-of-month, `n` months after the start.
  const monthAt = (n: number): Ymd => {
    const k = from[1] - 1 + n;
    return [from[0] + Math.floor(k / 12), (k % 12) + 1, from[2]];
  };
 
  let months = 0;
  while (compare(monthAt(months + 1), until) <= 0) months++;
 
  const guess = addDays(start, Math.round(months * MEAN_MONTH_DAYS[calendar]));
  const anchor = onOrBefore(monthAt(months), calendar, guess);
  const leftover = Math.round((after.getTime() - anchor.getTime()) / MS_PER_DAY);
 
  return months * 30 + leftover;
}

تُعدّ الأشهر من تاريخ البداية إلى اليوم نفسه من الشهر. وإن لم يكن ذلك اليوم موجودًا في شهر ما (اليوم 31 في أبريل، أو اليوم 30 من شهر هجري من 29 يومًا)، فلا يكتمل ذلك الشهر إلا حين يبدأ الشهر التالي، وهكذا تتعامل الوزارة مع بداية في 31 يناير. وما يتبقى بعد آخر شهر كامل يُعدّ بالأيام الفعلية، وكل يوم منها يساوي جزءًا من ثلاثين من الشهر.

يعطيك Intl.DateTimeFormat مع تقويم islamic-umalqura تقويم أم القرى، وهو التقويم الرسمي في المملكة، ويأتي مع Node 20 ضمن ICU الكامل، فلا حاجة إلى مكتبة تواريخ. والتواريخ تُحلَّل عند منتصف ليل UTC، حتى يتفق خادم في الرياض وخادم في فرانكفورت على اليوم الذي انتهت فيه الخدمة، وعلى اليوم الهجري الموافق له:

function parseDate(iso: string, label: string): Date {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso).trim());
  if (!m) throw new TypeError(`${label} must be yyyy-mm-dd`);
  const [, y, mo, d] = m;
  const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
  if (
    date.getUTCFullYear() !== Number(y) ||
    date.getUTCMonth() !== Number(mo) - 1 ||
    date.getUTCDate() !== Number(d)
  ) {
    throw new TypeError(`${label} is not a real date`);
  }
  return date;
}

رفض 2026-02-30 عند الحدّ يستحق الأسطر الثمانية. فمُنشئات التواريخ التي تتجاوز الخطأ بصمت تحوّل خطأ طباعة إلى دفعة خاطئة.

الخطوة 4: مكافأة المادة 84 ذات الشريحتين

مع تمثيل الخدمة بأيام من سنة طولها 360 يومًا، تصبح الشريحتان تقسيمًا لا تفرّعًا شرطيًا:

const YEAR_DAYS = 360;
const STEP_YEARS = 5;
 
const serviceDays = serviceDays360(from, lastDay, calendar);
 
const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
 
// Half a month per year for the first five, a whole month thereafter.
// Held as half-months times 360 so nothing is divided until the end.
const halfMonths = atHalf + 2 * atFull;
const fullMinor = divRound(monthly * halfMonths, 2 * YEAR_DAYS);

المتغير halfMonths يعدّ أنصاف أشهر الأجر مضروبة في 360، وهو ما يُبقي الحساب كله بالأعداد الصحيحة حتى القسمة الأخيرة. تحقّق من صحته أمام الحالتين اللتين يعرفهما الجميع:

  • خمس سنوات بالضبط، أي 1,800 يوم: halfMonths تساوي 1,800، أي شهران ونصف. على أجر 10,000 ريال: 25,000.00.
  • سبع سنوات بالضبط، أي 2,520 يومًا: 1,800 زائد ضعف 720 تعطي 3,240، أي أربعة أشهر ونصف. على أجر 10,000 ريال: 45,000.00.

وفي السنة الأولى الجزئية تظهر النسبية: ستة أشهر تقويمية تساوي 180 يومًا، أي ربع أجر شهر، فتكون المكافأة 2,500.00 سواء كانت السنة كبيسة أم لا.

الخطوة 5: المادة 85 — مدة الخدمة نفسها، وثلاثة أرقام مختلفة

التخفيض يسري على المكافأة لا على الأجر، وفقط حين يستقيل العامل:

export type EndReason = 'termination' | 'resignation';
 
/** Service thresholds, in years, at which a resignation's share steps up. */
export const RESIGNATION_STEPS = [2, 5, 10] as const;
 
let numerator = 1;
let denominator = 1;
 
if (reason === 'resignation') {
  const [twoY, fiveY, tenY] = RESIGNATION_STEPS.map((y) => y * YEAR_DAYS);
  if (serviceDays < twoY) [numerator, denominator] = [0, 1];
  else if (serviceDays < fiveY) [numerator, denominator] = [1, 3];
  else if (serviceDays < tenY) [numerator, denominator] = [2, 3];
}
 
// From the unrounded award, so the one-third share is not rounded twice.
const payableMinor = divRound(monthly * halfMonths * numerator, 2 * YEAR_DAYS * denominator);

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

أبقِ الرقمين معًا في نوع الإرجاع حتى تكون إعادة التصنيف تغييرًا في حقل واحد:

export type EndOfServiceBreakdown = {
  /** Days of service in 360-day years: months of thirty, plus the rest. */
  serviceDays: number;
  serviceYears: number;
  /** Months of wage earned under Art. 84, before any Art. 85 reduction. */
  monthsOfWage: string;
  /** The Art. 84 award in full. */
  full: string;
  /** The Art. 85 share actually payable, as a readable fraction. */
  share: string;
  /** What is owed after the share is applied. */
  payable: string;
  fullMinor: number;
  payableMinor: number;
};

والمثال العملي الجدير بالعرض على صاحب القرار: ثلاث سنوات خدمة على أجر 10,000 ريال. إن فُصل، فله 15,000.00. وإن استقال، فللموظف نفسه 5,000.00 — أي الثلث. التواريخ نفسها، والأجر نفسه، والمكافأة المتراكمة نفسها 15,000.00. وإذا كانت واجهتك تعرض المبلغ واجب السداد وحده، فلن يستطيع أحد أن يرى أين ذهبت الـ 10,000 الباقية، وهذا الغموض هو ما يولّد النزاع.

وإظهار share ككسر مقروء بدل رقم عشري تفصيل صغير يسدّد ثمنه في تذاكر الدعم. فـ "1/3" ينطبق مباشرة على لغة المادة 85، بينما "0.3333" لا ينطبق.

الخطوة 6: المادة 87 — التجاوزات

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

export type EndReason = 'termination' | 'resignation';
 
export type EndOfServiceInput = {
  start: string;
  /** The last day worked. It counts: service runs up to and including it. */
  end: string;
  /** Last actual monthly wage — basic plus every regular allowance. */
  wage: string | number;
  /**
   * Art. 10 — Hijri unless the contract or the work regulation says
   * otherwise. Required: the answer is in a document this code cannot see.
   */
  calendar: CalendarBasis;
  reason?: EndReason;
  /**
   * Art. 87 — force majeure beyond the worker's control, or a female worker
   * ending the contract within six months of marriage or three months of
   * giving birth. Either pays the full award regardless of service.
   */
  fullAwardException?: boolean;
};

وداخل جسم الدالة، يقطع الاستثناء سلّم المادة 85 كليًا:

if (reason === 'resignation' && !fullAwardException) {
  // ... the Article 85 steps
}

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

والدالة كاملة بعد تجميعها:

/** Days in a service year: twelve months of thirty. */
const YEAR_DAYS = 360;
const STEP_YEARS = 5;
/** Service thresholds, in years, at which a resignation's share steps up. */
export const RESIGNATION_STEPS = [2, 5, 10] as const;
 
export function endOfService({
  start,
  end,
  wage,
  calendar,
  reason = 'termination',
  fullAwardException = false,
}: EndOfServiceInput): EndOfServiceBreakdown {
  const from = parseDate(start, 'start');
  const lastDay = parseDate(end, 'end');
  if (lastDay < from) throw new RangeError('end is before start');
  const monthly = parseAmount(String(wage));
 
  const serviceDays = serviceDays360(from, lastDay, calendar);
 
  // Half a month per year for the first five, a whole month thereafter.
  // Held as half-months times 360 so nothing is divided until the end.
  const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
  const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
  const halfMonths = atHalf + 2 * atFull;
 
  // Article 85 — resignation only, unless Article 87 applies.
  let numerator = 1;
  let denominator = 1;
  if (reason === 'resignation' && !fullAwardException) {
    const [twoY, fiveY, tenY] = RESIGNATION_STEPS.map((y) => y * YEAR_DAYS);
    if (serviceDays < twoY) [numerator, denominator] = [0, 1];
    else if (serviceDays < fiveY) [numerator, denominator] = [1, 3];
    else if (serviceDays < tenY) [numerator, denominator] = [2, 3];
  }
 
  const fullMinor = divRound(monthly * halfMonths, 2 * YEAR_DAYS);
  // From the unrounded award, so the one-third share is not rounded twice.
  const payableMinor = divRound(monthly * halfMonths * numerator, 2 * YEAR_DAYS * denominator);
  const monthsCenti = divRound(halfMonths * MINOR_PER, 2 * YEAR_DAYS);
 
  return {
    serviceDays,
    serviceYears: Math.floor(serviceDays / YEAR_DAYS),
    monthsOfWage: formatMinor(monthsCenti),
    full: formatMinor(fullMinor),
    share: denominator === 1 ? String(numerator) : `${numerator}/${denominator}`,
    payable: formatMinor(payableMinor),
    fullMinor,
    payableMinor,
  };
}

الخطوة 7: المكافأة سطر واحد فقط من التسوية النهائية

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

  • رصيد الإجازة السنوية غير المستخدم، المادتان 109 و111. واحد وعشرون يومًا في السنة، ترتفع إلى ثلاثين بعد خمس سنوات متصلة. والإجازة تتراكم يومًا بيوم فعلًا، بخلاف المكافأة — لذا فالمدة التي تمتد عبر الذكرى السنوية الخامسة يجب تقسيمها واحتسابها بالنسبتين. واحتساب المدة كلها بنسبة واحدة هو الخطأ المعتاد هنا، وهو في الاتجاه المعاكس لخطأ المكافأة.
  • بدل الإشعار، المادتان 75 و76. في العقد غير محدد المدة، ما لم يُراعَ من مهلة الإشعار، مدفوعًا بالأجر الفعلي. والمهلة ستون يومًا حين ينهي العقدَ صاحبُ عمل يدفع الأجر شهريًا، وثلاثون في كل حالة أخرى: حين ينهيه العامل، أو حين لا يُدفع الأجر شهريًا. وإذا أعفى صاحب العمل العامل من العمل أثناء المهلة، فالمادة 78 تحتسب خدمته مستمرة إلى حين انتهائها، فيكون end الذي تمرره إلى endOfService آخر يوم في المهلة، لا آخر يوم حضر فيه. ويعرض دليل المادة 75 الحالات كلها.
  • التعويض عن الإنهاء غير المشروع، المادة 77. أجر خمسة عشر يومًا عن كل سنة خدمة في العقد غير محدد المدة، أو أجر المدة المتبقية في العقد محدد المدة — وفي كل الأحوال بما لا يقل عن أجر شهرين. وهذا الحد الأدنى هو الحاكم لمن تقل خدمته عن أربع سنوات، وهم أغلب من يبحثون عن الأمر.
  • الأجور غير المدفوعة، وهي السطر الأول في الحاسبة العمالية لوزارة العدل نفسها.

اصنع لكل بند من هذه دالة نقية مستقلة تُعيد النوع المالي نفسه، ثم اجمعها في طبقة التسوية. فدالة واحدة ضخمة باسم calculateFinalSettlement تصبح غير قابلة للاختبار خلال دورة تطوير واحدة.

تعطيك الحاسبة العمالية السعودية تقديرًا سريعًا لهذه البنود، و حاسبة رصيد الإجازات لجانب المادة 109 وحده. أما للهللة الأخيرة، ولأي عقد هجري، فاعتمد الحاسبة العمالية لوزارة العدل مرجعًا.

الخطوة 8: راكِمها شهريًا، وإلا فهي ليست مخصّصًا

هذه هي الخطوة التي تفصل بين ميزة في الرواتب ونظام مالي، وهي الخطوة التي تتخطاها أغلب التطبيقات الداخلية.

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

والمحرك الذي بنيته يمنحك هذا بلا تكلفة إضافية — استدعِه بتاريخ اليوم بدل تاريخ الإنهاء:

/** The award accrued as of a date, for provisioning. */
export function accruedLiability(
  employee: { start: string; wage: string; calendar: CalendarBasis },
  asOf: string,
) {
  // Provision against the full Art. 84 award, not the Art. 85 share:
  // the reduction is contingent on how the relationship ends, which is
  // not known on the reporting date.
  return endOfService({
    start: employee.start,
    end: asOf,
    wage: employee.wage,
    calendar: employee.calendar,
    reason: 'termination',
  }).fullMinor;
}

التعليق يحمل الحكم المهني. فالتخصيص مقابل الرقم واجب السداد — أي بافتراض أن الجميع سيستقيلون — يبخس الالتزام بصورة منهجية، لأن الفصل وانتهاء العقد يدفعان المكافأة كاملة. خصِّص مقابل المكافأة الكاملة، ودع تخفيض المادة 85 يكون إفراجًا عن المخصص حين يقع.

طبّق ذلك على كامل القوى العاملة، فتحصل على سلسلة شهرية تستطيع الإدارة المالية التخطيط عليها فعلًا:

export function provisionByMonth(
  employees: Array<{ id: string; start: string; wage: string; calendar: CalendarBasis }>,
  months: string[], // ['2026-01-31', '2026-02-28', ...]
) {
  return months.map((asOf) => ({
    asOf,
    totalMinor: employees
      .filter((e) => e.start <= asOf)
      .reduce((sum, e) => sum + accruedLiability(e, asOf), 0),
  }));
}

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

تلك السلسلة هي طبقة التقارير، وهي سبب استحقاق هذا الحساب أن يُنجز بإتقان بدل أن يُترك في جدول بيانات.

الخطوة 9: حين يتغيّر صاحب العمل لا تبدأ الخدمة من جديد

تاريخ البدء وتاريخ الانتهاء يفترضان صاحب عمل واحدًا. وقاعدتان تقولان إن العدّاد يستمر حين يتغيّر صاحب العمل، ونظام الرواتب الذي يفتح سجل موظف جديدًا لدى الجهة الجديدة، بتاريخ الانتقال بدايةً للخدمة، يخطئ فيهما معًا.

المادة 18 من نظام العمل تحكم القطاع الخاص. إذا انتقلت ملكية المنشأة لمالك جديد، أو طرأ تغيير على شكلها النظامي بالدمج أو التجزئة أو غير ذلك، تبقى عقود العمل نافذة وتُعد الخدمة مستمرة. أما حقوق العمال الناشئة عن المدة السابقة للتغيير، ومنها الأجور و«مكافأة نهاية خدمة مفترضة الاستحقاق بتاريخ انتقال الملكية»، فيكون الخلف والسلف مسؤولين عنها بالتضامن، فللعامل أن يطالب أيًّا منهما. ولا يقول النظام كيف يسوّي الطرفان ذلك فيما بينهما؛ فمكان ذلك عادةً اتفاقية البيع أو الدمج. وفي المنشآت الفردية يجوز للسلف والخلف أن يتفقا بدلًا من ذلك على انتقال جميع حقوق العمال السابقة إلى المالك الجديد، بموافقة العامل الخطية. وللعامل الذي لا يوافق أن يطلب إنهاء عقده ويتسلم مستحقاته من السلف.

قرار مجلس الوزراء رقم 318 وتاريخ 4/4/1448هـ، المنشور في أم القرى في 25 سبتمبر 2026، يحكم الموظفين والعمال المنقولين من جهة حكومية بالتخصيص. وهو يعدّل القواعد الصادرة بقرار مجلس الوزراء رقم 616 وتاريخ 20/10/1442هـ. تُعد الخدمة متصلة فيما يتعلق بمكافأة نهاية الخدمة والإجازات. وتتحمل الجهة المشرفة (السلف) المكافأة والإجازات عن الفترة التي سبقت الانتقال، وتتحمل الجهة المحوَّل إليها (الخلف) ما بعدها، ويُحتسب الجزءان كلاهما على أساس الأجر الأخير. ويحذف القرار كذلك العبارات الثلاث التي ربطت هذه القواعد بالمادة 18، ويترك الطريقة لآلية يصدرها مجلس إدارة المركز الوطني للتخصيص بعد التنسيق مع وزارتي المالية والموارد البشرية والتنمية الاجتماعية.

تترتب على ذلك ثلاثة أمور في المحرك، ولا يحتاج أيّ منها إلى معادلة جديدة:

  1. تاريخ البدء هو أول يوم لدى صاحب العمل الأول. أربع سنوات لدى السلف وثلاث لدى الخلف تساوي سبع سنوات خدمة. وعلى أجر 10,000 يكون ذلك 45,000.00. أما إعادة تشغيل العدّاد عند الانتقال فتعطي مكافأتين، 20,000 و15,000، أي أقل بـ10,000، لأن السنتين السادسة والسابعة تستحقان شهرًا كاملًا لا نصف شهر.
  2. المادة 85 تقرأ الخدمة المتصلة أيضًا. العامل الذي يستقيل بعد تلك السنوات السبع يأخذ ثلثي المكافأة، لا الثلث الذي تعطيه السنوات الأربع وحدها.
  3. ما يتغيّر هو من يدفع، وعلى أي أجر. في القرار 318 يُحتسب الجزءان على الأجر الأخير. وفي المادة 18 يكون المبلغ المضمون بالتضامن هو المكافأة المفترضة الاستحقاق بتاريخ الانتقال، ولا يستطيع النظام إخراجه إلا إذا احتفظ بالأجر الساري في ذلك اليوم.

التقسيم هو حيث يتوقف القرار. وإلى أن تصدر الآلية تبقى قراءتان مقبولتان، وعلى العامل نفسه تنقلان 5,714.29 ريال بين الجهتين:

الطريقةالسلفالخلف
بحسب المدة: بنسبة الأيام التي قضاها لدى كل جهة25,714.2919,285.71
بحسب الشريحة: يدفع السلف ما تستحقه سنواته الأربع وحدها20,000.0025,000.00

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

/**
 * Two ways a worker changes employer without a break in service.
 * - 'article-18': the establishment is sold, merged or split (Labour Law, Art. 18).
 * - 'decision-318': a government entity's staff move to the entity created by
 *   privatisation (Cabinet Decision 616/1442, as amended by Decision 318/1448).
 */
export type TransferRegime = 'article-18' | 'decision-318';
 
/** The two-tier weight from Step 4: half-months of wage, times 360. */
function halfMonthsFor(serviceDays: number): number {
  const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
  const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
  return atHalf + 2 * atFull;
}
 
const dayBefore = (date: Date) => new Date(date.getTime() - MS_PER_DAY);
const isoDate = (date: Date) => date.toISOString().slice(0, 10);
 
/**
 * Article 18: the award "presumed due on the date ownership passed", for which
 * predecessor and successor are jointly liable. Our reading, not the Law's words:
 * a presumed award has no resignation behind it, so it is the full Art. 84 figure,
 * on the wage in force on the transfer date.
 */
export function presumedDueAtTransfer(input: {
  start: string;
  /** The first day under the new owner. */
  transferDate: string;
  wageAtTransfer: string | number;
  calendar: CalendarBasis;
}): number {
  const from = parseDate(input.start, 'start');
  const transfer = parseDate(input.transferDate, 'transferDate');
  if (transfer <= from) throw new RangeError('transferDate must fall after start');
  return endOfService({
    start: input.start,
    end: isoDate(dayBefore(transfer)),
    wage: input.wageAtTransfer,
    calendar: input.calendar,
    reason: 'termination',
  }).fullMinor;
}
 
/**
 * Decision 318 names who pays each period but leaves the method to a mechanism
 * the National Center for Privatization board has not issued. So there is no
 * default: the caller states the method, and the report shows which one it used.
 * - 'time': in proportion to the days served with each entity.
 * - 'band': the predecessor pays what its own years would earn at the last wage;
 *   the successor pays the rest, including the full-month years continuity added.
 */
export type SplitMethod = 'time' | 'band';
 
export type TransferSplit = {
  method: SplitMethod;
  /** Service with the predecessor, in days of a 360-day year. */
  predecessorDays: number;
  total: string;
  predecessor: string;
  successor: string;
  totalMinor: number;
  predecessorMinor: number;
  successorMinor: number;
};
 
export function splitAtTransfer(
  input: EndOfServiceInput & { transferDate: string },
  method: SplitMethod,
): TransferSplit {
  const from = parseDate(input.start, 'start');
  const transfer = parseDate(input.transferDate, 'transferDate');
  const lastDay = parseDate(input.end, 'end');
  if (transfer <= from || transfer > lastDay) {
    throw new RangeError('transferDate must fall after start and on or before end');
  }
 
  // Continuous service on the last wage: the worker's total never depends on the split.
  const whole = endOfService(input);
  const monthly = parseAmount(String(input.wage));
  const [num, den = 1] = whole.share.split('/').map(Number);
 
  const predecessorDays = serviceDays360(from, dayBefore(transfer), input.calendar);
  const [weight, scale] =
    method === 'time'
      ? [halfMonthsFor(whole.serviceDays) * predecessorDays, 2 * YEAR_DAYS * whole.serviceDays]
      : [halfMonthsFor(predecessorDays), 2 * YEAR_DAYS];
 
  // One division, from the unrounded award, as in Step 5.
  const product = monthly * weight * num;
  if (!Number.isSafeInteger(product)) throw new RangeError('amount too large for exact arithmetic');
  const predecessorMinor = divRound(product, scale * den);
  // The successor takes the remainder, so the two parts always sum to the award.
  const successorMinor = whole.payableMinor - predecessorMinor;
 
  return {
    method,
    predecessorDays,
    total: whole.payable,
    predecessor: formatMinor(predecessorMinor),
    successor: formatMinor(successorMinor),
    totalMinor: whole.payableMinor,
    predecessorMinor,
    successorMinor,
  };
}

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

تعامل presumedDueAtTransfer المكافأة المفترضة على أنها مكافأة المادة 84 كاملة، بلا تخفيض للاستقالة. وهذه قراءتنا: فالنظام يفترض استحقاق المكافأة بتاريخ الانتقال ولا يذكر سببًا لترك العمل، لأن أحدًا لم يترك العمل. اطلب تأكيدها من محامٍ عمالي سعودي قبل أن تقيّد عليها التزامًا.

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

اختبار التنفيذ

ثبّت كل حالة نظامية في اختبار مُسمّى. كل واحدة من هذه كانت محل خلاف في مكان ما:

import test from 'node:test';
import assert from 'node:assert/strict';
import { endOfService } from './labour';
 
const gregorian = { wage: '10000', calendar: 'gregorian' } as const;
 
test('half a month for each of the first five years', () => {
  // The last day worked is 31 December 2023: exactly five years.
  const r = endOfService({ ...gregorian, start: '2019-01-01', end: '2023-12-31' });
  assert.equal(r.serviceDays, 1800);
  assert.equal(r.monthsOfWage, '2.50');
  assert.equal(r.full, '25000.00');
  assert.equal(r.payable, '25000.00'); // termination pays in full
});
 
test('the last day worked counts', () => {
  // One more day is five years and a day, which the Ministry of Justice
  // calculator prices at 25,027.78.
  const r = endOfService({ ...gregorian, start: '2019-01-01', end: '2024-01-01' });
  assert.equal(r.serviceDays, 1801);
  assert.equal(r.full, '25027.78');
});
 
test('and a full month for each year after the fifth', () => {
  // Seven years: 2.5 months for the first five, 2 for the rest.
  const r = endOfService({ ...gregorian, start: '2018-01-01', end: '2024-12-31' });
  assert.equal(r.monthsOfWage, '4.50');
  assert.equal(r.full, '45000.00');
});
 
test('a month is thirty days, whatever February says', () => {
  // 1 February to 1 March is one month and one day: 31/360 of a year.
  const r = endOfService({ ...gregorian, start: '2019-02-01', end: '2019-03-01' });
  assert.equal(r.serviceDays, 31);
  assert.equal(r.full, '430.56');
});
 
test('fractions of a year are paid in proportion', () => {
  // Six calendar months is 180/360 of a year, in a leap year or not.
  const r = endOfService({ ...gregorian, start: '2024-01-01', end: '2024-06-30' });
  assert.equal(r.monthsOfWage, '0.25');
  assert.equal(r.full, '2500.00');
});
 
test('a Hijri contract is longer service on the same dates', () => {
  // The same five Gregorian years are 5 Hijri years, 1 month and 23 days.
  // The Ministry of Justice calculator, in Hijri mode, gives 26,472.22.
  const r = endOfService({ ...gregorian, calendar: 'hijri', start: '2019-01-01', end: '2023-12-31' });
  assert.equal(r.serviceDays, 1853);
  assert.equal(r.full, '26472.22');
});
 
test('resignation below two years pays nothing, but still accrues', () => {
  const r = endOfService({
    ...gregorian,
    start: '2024-01-01',
    end: '2025-05-31',
    reason: 'resignation',
  });
  assert.ok(r.fullMinor > 0, 'the award still accrues');
  assert.equal(r.payable, '0.00');
});
 
test('the same service settles at three different numbers', () => {
  const args = { ...gregorian, start: '2021-01-01', end: '2023-12-31' };
  const sacked = endOfService({ ...args, reason: 'termination' });
  const quit = endOfService({ ...args, reason: 'resignation' });
  assert.equal(sacked.payable, '15000.00');
  assert.equal(quit.payable, '5000.00'); // one third, Art. 85
  assert.equal(sacked.fullMinor, quit.fullMinor, 'the award accrued is the same');
});
 
test('the Art. 85 share is taken from the unrounded award', () => {
  // Two thirds of 26,472.22 rounds to 17,648.15 only if it is not rounded twice.
  const r = endOfService({
    ...gregorian,
    calendar: 'hijri',
    start: '2019-01-01',
    end: '2023-12-31',
    reason: 'resignation',
  });
  assert.equal(r.share, '2/3');
  assert.equal(r.payable, '17648.15');
});
 
test('bad input throws rather than paying a wrong number', () => {
  assert.throws(
    () => endOfService({ ...gregorian, start: '2025-01-01', end: '2024-01-01' }),
    RangeError,
  );
  assert.throws(() => endOfService({ ...gregorian, start: 'nope', end: '2025-01-01' }), TypeError);
});

وقواعد الانتقال في الخطوة 9 لها اختباراتها الخاصة. كل رقم فيها أحد الأمثلة المحسوبة أعلاه:

import test from 'node:test';
import assert from 'node:assert/strict';
import { endOfService, presumedDueAtTransfer, splitAtTransfer } from './labour';
 
const gregorian = { wage: '10000', calendar: 'gregorian' } as const;
// Four years with the predecessor, three with the successor.
const moved = { ...gregorian, start: '2018-01-01', transferDate: '2022-01-01', end: '2024-12-31' };
 
test('the clock does not restart at the transfer', () => {
  const whole = endOfService({ ...gregorian, start: '2018-01-01', end: '2024-12-31' });
  const before = endOfService({ ...gregorian, start: '2018-01-01', end: '2021-12-31' });
  const after = endOfService({ ...gregorian, start: '2022-01-01', end: '2024-12-31' });
  assert.equal(whole.full, '45000.00');
  // Restarted, the same seven years would earn 20,000 + 15,000.
  assert.equal(before.fullMinor + after.fullMinor, 3_500_000);
});
 
test('Decision 318 split by time', () => {
  const s = splitAtTransfer(moved, 'time');
  assert.equal(s.predecessorDays, 1440);
  assert.equal(s.predecessor, '25714.29');
  assert.equal(s.successor, '19285.71');
  assert.equal(s.predecessorMinor + s.successorMinor, s.totalMinor);
});
 
test('Decision 318 split by band', () => {
  const s = splitAtTransfer(moved, 'band');
  assert.equal(s.predecessor, '20000.00'); // four years at half a month
  assert.equal(s.successor, '25000.00'); // including both full-month years
  assert.equal(s.total, '45000.00');
});
 
test('a resignation takes its share from the continuous service', () => {
  // Seven years is two thirds under Art. 85; four years alone would be one third.
  const s = splitAtTransfer({ ...moved, reason: 'resignation' }, 'band');
  assert.equal(s.total, '30000.00');
  assert.equal(s.predecessor, '13333.33');
  assert.equal(s.successor, '16666.67');
});
 
test('Article 18 prices the presumed award on the wage at the transfer', () => {
  const presumed = presumedDueAtTransfer({
    start: '2018-01-01',
    transferDate: '2022-01-01',
    wageAtTransfer: '8000',
    calendar: 'gregorian',
  });
  assert.equal(presumed, 1_600_000); // 16,000: two months of the 8,000 wage
});
 
test('a transfer outside the service throws', () => {
  assert.throws(() => splitAtTransfer({ ...moved, transferDate: '2018-01-01' }, 'time'), RangeError);
  assert.throws(() => splitAtTransfer({ ...moved, transferDate: '2025-01-01' }, 'band'), RangeError);
});

شغّلها بالأمر node --test. والاختبار الأخير في الملف الأول أهم مما يبدو: فمحرك مكافأة يُعيد NaN عند تاريخ مشوَّه سيكتب NaN في ملف السداد بكل رضا. ورمي الخطأ هو السلوك الصحيح مع المال.

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

استكشاف الأخطاء وإصلاحها

الرقم يختلف ببضعة ريالات عن رقم الوزارة. السبب في الغالب أن آخر يوم عمل لا يُحتسب، أو أن جزء السنة محسوب بقسمة الأيام على 365 (أو على طول تلك السنة الفعلي) بدل عدّه بأشهر من ثلاثين يومًا. ابحث عن / 365 وعن أي تاريخ نهاية يُعامل على أنه غير داخل في المدة، واشتقّ مدة الخدمة من serviceDays360.

الرقم بعيد عن رقم الوزارة لموظف طويل الخدمة. راجع نوع التاريخ. فالسنة الهجرية أقصر من الميلادية بنحو أحد عشر يومًا، لذا يراكم العقد الهجري خدمة أكثر بنحو ثلاثة بالمئة عن كل سنة ميلادية، أي قرابة أربعة أشهر في عشر سنوات.

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

السنة السادسة تدفع أقل من الخامسة. الشريحتان تُطبَّقان انتقائيًا لا تراكميًا — أي أن الشيفرة تحتسب شهرًا عن كل السنوات بمجرد تجاوز الخدمة خمس سنوات، ثم تطرح في مكان ما. تأكد أن مجموع atHalf و atFull يساوي serviceDays.

استقالة عند سنتين بالضبط لا تدفع شيئًا. خطأ بمقدار واحد عند العتبة. فالمادة 85 تمنح الثلث عند بلوغ سنتين من الخدمة، لذا يجب أن تكون المقارنة عند العتبة "أقل من" تمامًا، لا "أقل من أو يساوي".

مكافأة العامل المنقول تنقص نحو شهر. فتحت الجهة المحوَّل إليها سجلًا جديدًا بتاريخ الانتقال بدايةً. والخدمة متصلة وفق المادة 18 والقرار 318: استورد تاريخ التعيين الأصلي، واحتفظ كذلك بالأجر الساري يوم الانتقال حين تنطبق المادة 18.

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

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

الخلاصة

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

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