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

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

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

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

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

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

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

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

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

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

  • 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: قِس الخدمة بالسنوات الميلادية الكاملة، لا بقسمة الأيام على 365

هذا هو الخطأ الذي ينجو من مراجعة الشيفرة، لأن الشيفرة تبدو صحيحة بداهةً:

// WRONG — and it will pass every test that uses round numbers.
const years = daysBetween(start, end) / 365;

خمس سنوات ميلادية تتضمن يومًا كبيسًا هي 1,826 يومًا. وبقسمتها على 365 تُقرأ 5.0027 سنة. وعلى أجر قدره 10,000 ريال تكون مكافأة المادة 84 مبلغ 25,000.00 — بينما يدفع الرقم المنحرف 25,013.70. ثلاثة عشر ريالًا لا تعني شيئًا؛ لكن أن يختلف محركك مع حاسبة الوزارة نفسها بمقدار غير صفري ليس بالأمر الهيّن، لأنه أول ما يفحصه المراجع وأول ما يلتقط الموظف المغادر صورة له.

المكافأة معبَّر عنها بـ سنوات الخدمة، وسنة الخدمة هي ذكرى سنوية. فقِسها كذكرى سنوية مكتملة زائد نسبة من السنة الجارية:

const MS_PER_DAY = 86_400_000;
/** Years held in ten-thousandths, so fractions stay exact in integers. */
const YEAR_UNIT = 10_000;
 
/** The calendar anniversary `years` after `from`. */
function anniversary(from: Date, years: number): Date {
  return new Date(
    Date.UTC(from.getUTCFullYear() + years, from.getUTCMonth(), from.getUTCDate()),
  );
}
 
function serviceUnitsBetween(from: Date, to: Date): number {
  let completed = 0;
  while (anniversary(from, completed + 1) <= to) completed++;
 
  const last = anniversary(from, completed);
  const next = anniversary(from, completed + 1);
  const yearLength = Math.round((next.getTime() - last.getTime()) / MS_PER_DAY);
  const remainder = Math.round((to.getTime() - last.getTime()) / MS_PER_DAY);
 
  return completed * YEAR_UNIT + divRound(remainder * YEAR_UNIT, yearLength);
}

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

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 ذات الشريحتين

مع تمثيل الخدمة بأجزاء من عشرة آلاف من السنة، تصبح الشريحتان تقسيمًا لا تفرّعًا شرطيًا:

const STEP_YEARS = 5;
 
const serviceUnits = serviceUnitsBetween(from, to);
const stepUnits = STEP_YEARS * YEAR_UNIT;
 
const unitsAtHalf = Math.min(serviceUnits, stepUnits);
const unitsAtFull = Math.max(0, serviceUnits - stepUnits);
 
// Half a month per year for the first five, a whole month thereafter.
const monthUnits = divRound(unitsAtHalf, 2) + unitsAtFull;
const fullMinor = divRound(monthly * monthUnits, YEAR_UNIT);

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

  • خمس سنوات بالضبط: unitsAtHalf تساوي 50,000، و unitsAtFull تساوي 0، و monthUnits تساوي 25,000 — أي شهران ونصف. على أجر 10,000 ريال: 25,000.00.
  • سبع سنوات بالضبط: 50,000 بنصف الشهر زائد 20,000 بالشهر الكامل تعطي 45,000 وحدة — أي أربعة أشهر ونصف. على أجر 10,000 ريال: 45,000.00.

وسنة أولى جزئية، وهنا تظهر النسبية: 182 يومًا داخل سنة طولها 366 يومًا تساوي 0.4973 من السنة بنسبة نصف الشهر، فتعطي 0.25 شهر ومكافأة قدرها 2,487.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_UNIT);
  if (serviceUnits < twoY) [numerator, denominator] = [0, 1];
  else if (serviceUnits < fiveY) [numerator, denominator] = [1, 3];
  else if (serviceUnits < tenY) [numerator, denominator] = [2, 3];
}
 
const payableMinor = divRound(fullMinor * numerator, denominator);

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

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

export type EndOfServiceBreakdown = {
  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 EndOfServiceInput = {
  start: string;
  end: string;
  /** Last actual monthly wage — basic plus every regular allowance. */
  wage: string | number;
  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
}

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

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

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

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

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

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

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

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

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

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

/** The award accrued as of a date, for provisioning. */
export function accruedLiability(
  employee: { start: string; wage: string },
  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,
    reason: 'termination',
  }).fullMinor;
}

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

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

export function provisionByMonth(
  employees: Array<{ id: string; start: string; wage: string }>,
  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),
  }));
}

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

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

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

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

import test from 'node:test';
import assert from 'node:assert/strict';
import { endOfService } from './labour';
 
test('half a month for each of the first five years', () => {
  const r = endOfService({ start: '2019-01-01', end: '2024-01-01', wage: '10000' });
  assert.equal(r.monthsOfWage, '2.50');
  assert.equal(r.full, '25000.00');
  assert.equal(r.payable, '25000.00'); // termination pays in full
});
 
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({ start: '2018-01-01', end: '2025-01-01', wage: '10000' });
  assert.equal(r.monthsOfWage, '4.50');
  assert.equal(r.full, '45000.00');
});
 
test('fractions of a year are paid in proportion', () => {
  // 182 days into a 366-day year is 0.4973 of a year, at the half-month rate.
  const r = endOfService({ start: '2024-01-01', end: '2024-07-01', wage: '10000' });
  assert.equal(r.monthsOfWage, '0.25');
  assert.equal(r.full, '2487.00');
});
 
test('resignation below two years pays nothing, but still accrues', () => {
  const r = endOfService({
    start: '2024-01-01',
    end: '2025-06-01',
    wage: '10000',
    reason: 'resignation',
  });
  assert.ok(Number(r.full) > 0, 'the award still accrues');
  assert.equal(r.payable, '0.00');
});
 
test('the same service settles at three different numbers', () => {
  const args = { start: '2021-01-01', end: '2024-01-01', wage: '10000' } as const;
  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('a leap day does not inflate the gratuity', () => {
  // days/365 would read five calendar years spanning a leap day as 5.0027
  // years and pay 25,013.70. Years of service are anniversaries.
  const r = endOfService({ start: '2019-01-01', end: '2024-01-01', wage: '10000' });
  assert.equal(r.serviceDays, 1826);
  assert.equal(r.full, '25000.00');
});
 
test('the Art. 85 steps are calendar anniversaries, not two times 365', () => {
  // 731 days is two years across a leap year — the one-third step must fire.
  const r = endOfService({
    start: '2022-03-01',
    end: '2024-03-01',
    wage: '10000',
    reason: 'resignation',
  });
  assert.equal(r.serviceDays, 731);
  assert.equal(r.share, '1/3');
});
 
test('bad input throws rather than paying a wrong number', () => {
  assert.throws(
    () => endOfService({ start: '2025-01-01', end: '2024-01-01', wage: '10000' }),
    RangeError,
  );
  assert.throws(() => endOfService({ start: 'nope', end: '2025-01-01', wage: '1' }), TypeError);
});

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

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

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

الرقم أعلى قليلًا من رقم الوزارة. السبب في الغالب قسمة الأيام على 365 في مكان ما داخل حساب مدة الخدمة. ابحث عن / 365 واستبدلها بحساب الذكرى السنوية.

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

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

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

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

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

الخلاصة

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

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