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

محرك حساب الأوفر تايم بلغة TypeScript — المادة 107

ابنِ محرك حساب أجر العمل الإضافي لمسير الرواتب السعودي بلغة TypeScript، تنفيذًا للمواد 98 و106 و107 من نظام العمل — الفرق بين الأجر الفعلي والأساسي، قواسم 240 و180 ساعة، ساعات يوم الراحة والعطلات، دوام رمضان، الإجازة التعويضية وفق المادة 22 مكرر من اللائحة التنفيذية، وسقف 720 ساعة سنويًا.

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

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

تصحيح، 28 سبتمبر 2026. كانت نسخة سابقة من هذا الدرس تعامل الإجازة التعويضية كعلامة موافقة لا أكثر، واقترحت إدخالها في رصيد الإجازة السنوية. والمادة 22 مكرر من اللائحة التنفيذية تضع أربعة شروط تغيّر الأرقام: ساعة ونصف إجازة على الأقل عن كل ساعة عمل إضافي، وجدولة الإجازة خلال 60 يومًا، وألا تزيد على 30 يومًا في السنة، وأن يُدفع أجر ما بقي منها إذا ترك العامل العمل. أما الإجازة التعويضية يومًا بيوم، التي تذكرها أدلة عربية كثيرة، فهي قاعدة الخدمة المدنية، وفي القطاع الخاص تسوّي الدين ناقصًا ثلثه. الخطوة 7 تنفّذ الآن الشروط الأربعة، والخطوة 8 تضيف حد المادة 106 البالغ 10 ساعات فعلية في اليوم و60 في الأسبوع، وسقف الـ720 ساعة منسوب الآن إلى المادة 22 من اللائحة التنفيذية.

المتطلبات المسبقة

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

  • Node.js 20 أو أحدث
  • TypeScript 5 أو أحدث (npm install -D typescript vitest)
  • إلمام أساسي بوحدات TypeScript واختبارات الوحدات
  • نسخة من نظام العمل السعودي مفتوحة أمامك، المواد من 98 إلى 108، ومن اللائحة التنفيذية المادتان 22 و22 مكرر — سنتبع النص، لا الشائع

لا حاجة إلى أي إطار عمل. المحرك مكتبة TypeScript بسيطة يمكنك وضعها داخل مسار API في Next.js أو مهمة رواتب دورية أو خدمة حضور وانصراف.

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

مكتبة صغيرة باسم saudi-overtime-engine تكشف الدوال التالية:

  • hourlyRates(wage, ramadan, policy) — أجر الساعة الفعلي والأساسي، مع التعامل الصريح مع قاسمَي 240 و180 ساعة
  • overtimeHours(day, schedule) — كم ساعة من ساعات اليوم إضافية، بما في ذلك أيام الراحة والعطلات الرسمية حيث تكون كل الساعات إضافية
  • overtimePay(days, wage, schedule) — مبلغ المادة 107 عن فترة كاملة، بالهللات
  • settle(...) — كم من الساعات الإضافية يجوز أن تغطيه الإجازة التعويضية نظامًا، وكم يذهب إلى الرواتب، والتاريخ الذي يجب أن تُستعمل الإجازة قبله
  • unusedCompLeavePayout(...) — قيمة الإجازة التعويضية المستحقة حين يترك الموظف العمل
  • capStatus(hoursThisYear) وفحوص حد المادة 106 — موقع الموظف من السقف السنوي البالغ 720 ساعة ومن حد العشر ساعات اليومي

إضافة إلى حزمة اختبارات تثبّت كل قاعدة في سيناريو يمكنك عرضه على أي مدقق.

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

ثلاث مواد تحمل كل شيء، وكل واحدة تضيف قاعدة يجب أن يشفّرها محركك:

المادة 98 تضع المعيار: لا يجوز تشغيل العامل أكثر من 8 ساعات في اليوم أو 48 في الأسبوع. وفي رمضان، للموظفين المسلمين، ينخفض المعيار إلى 6 ساعات في اليوم و36 في الأسبوع. كل ما تجاوز المعيار المنطبق فهو عمل إضافي.

المادة 106 تعدّد الحالات التي يجوز فيها لصاحب العمل تجاوز تلك الحدود — الجرد السنوي، ومواسم الذروة، ومنع وقوع حادث — وتضع حدًا يبقى قائمًا حتى فيها: لا تزيد ساعات العمل الفعلية على عشر ساعات في اليوم أو ستين ساعة في الأسبوع. وتضع المادة 22 من اللائحة التنفيذية سقفًا قدره 720 ساعة إضافية للموظف في السنة، ولا تجوز الزيادة عليه إلا بموافقة العامل. أغلب المحركات لا تتتبّع أيًّا من الرقمين؛ محركك سيفعل.

المادة 107 تسعّرها. الساعة الإضافية تُدفع بـأجر الساعة مضافًا إليه 50% من الأجر الأساسي. وإذا اعتمدت المنشأة المعيار الأسبوعي فما زاد على ساعاته إضافي (107/2)، وجميع ساعات العمل في أيام العطل والأعياد إضافية بكاملها (107/3). كما جعلت تعديلات 2025 صريحًا ما كانت عقود كثيرة تفعله: بموافقة العامل، يمكن تسوية العمل الإضافي بـإجازة تعويضية مدفوعة الأجر بدل الأجر، بشروط تفصّلها المادة 22 مكرر من اللائحة التنفيذية.

الفخ يسكن في الأساسين الأجريّين. يفرّق نظام العمل السعودي بين الأجر الأساسي والأجر الفعلي الذي يضيف البدلات الثابتة — السكن والنقل وكل ما يُدفع بانتظام. النصف الأول من الساعة الإضافية يُسعَّر من الأجر الفعلي؛ أما علاوة الـ50% فتُسعَّر من الأجر الأساسي وحده. وأي محرك يحمل حقل "راتب" واحدًا لا يستطيع تنفيذ المادة 107 تنفيذًا صحيحًا.

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

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

// money.ts
export type Halalas = number; // always an integer
 
export const fromSAR = (sar: number): Halalas => Math.round(sar * 100);
export const toSAR = (halalas: Halalas): number => halalas / 100;

الخطوة 3: نمذج الأجر كما يقسمه النظام

حقلان، لا حقل واحد. إن كانت بياناتك الرئيسية في نظام الموارد البشرية تحمل رقمًا إجماليًا واحدًا، فإصلاح ذلك مهمة بيانات تسبق هذا المحرك، لا تليه.

// wage.ts
import type { Halalas } from './money';
 
export interface MonthlyWage {
  /** Basic wage — the contractual base, before any allowance. */
  basic: Halalas;
  /** Fixed, regularly paid allowances: housing, transport, and similar. */
  fixedAllowances: Halalas;
}
 
/** Actual wage: the base plus every fixed allowance (Labour Law, Art. 2). */
export const actualWage = (w: MonthlyWage): Halalas => w.basic + w.fixedAllowances;

قرار واحد ينبغي توثيقه كتابة: أي البدلات "ثابتة". بدل النقل المدفوع كل شهر مكانه fixedAllowances؛ أما المكافأة الاستثنائية فلا. المدققون يطلبون هذه القائمة — احفظها في وثيقة سياساتك، لا في ذاكرة أحدهم.

الخطوة 4: أجر الساعة — وسؤال القاسم

يسعّر النظام العمل الإضافي بالساعة لكنه يذكر الأجور بالشهر، لذا يحتاج كل تنفيذ إلى قاسم. العرف الغالب — وهو ما تطبّقه حاسبة الأوفر تايم عندنا — يقسم الأجر الشهري على 240 (8 ساعات × 30 يومًا). وفي رمضان يتقلص شهر العمل إلى 6 ساعات يوميًا، فيصبح القاسم 180، ما يجعل كل ساعة رمضانية، ومن ثم كل ساعة إضافية رمضانية، أعلى قيمة. وتشتق بعض مسيرات الرواتب الأجر من المعيار الأسبوعي بدلًا من ذلك (48 × 52 / 12 = 208 ساعات شهريًا). كلا الطريقتين ينتج أرقامًا يمكن الدفاع عنها؛ ما لا يمكن الدفاع عنه هو الخلط بينهما. اجعل القاسم قيمة سياسة، وثبّته مرة واحدة، ودع الاختبارات تحرسه.

// rates.ts
import type { MonthlyWage } from './wage';
import { actualWage } from './wage';
 
export interface RatePolicy {
  /** Hours dividing the monthly wage in a normal month. 240 = 8h x 30d. */
  monthlyDivisorHours: number;
  /** Hours dividing the monthly wage in Ramadan. 180 = 6h x 30d. */
  ramadanDivisorHours: number;
}
 
export const defaultRatePolicy: RatePolicy = {
  monthlyDivisorHours: 240,
  ramadanDivisorHours: 180,
};
 
export interface HourlyRates {
  /** Hourly rate from the actual wage, in halalas (may carry fractions). */
  actualHourly: number;
  /** Hourly rate from the basic wage, in halalas (may carry fractions). */
  basicHourly: number;
}
 
export function hourlyRates(
  wage: MonthlyWage,
  ramadan: boolean,
  policy: RatePolicy = defaultRatePolicy,
): HourlyRates {
  const divisor = ramadan ? policy.ramadanDivisorHours : policy.monthlyDivisorHours;
  return {
    actualHourly: actualWage(wage) / divisor,
    basicHourly: wage.basic / divisor,
  };
}

لاحظ أن الأجرين يبقيان هنا هللات بفواصل عائمة. هذا مقصود: هما قيمتان وسيطتان، وMath.round الوحيد ينتظر حتى الخطوة 6.

الخطوة 5: صنّف الساعات — الجزء الذي تخطئه أنظمة الحضور

المادة 107 لا تسعّر فقط الساعات التي تجاوزت المعيار اليومي، بل تنص على أن ساعات العمل في يوم الراحة الأسبوعية وفي العطلات الرسمية إضافيةٌ من الدقيقة الأولى. نظام الحضور الذي يقيس فقط "ما فوق 8 ساعات" يُسقط الحالتين بصمت.

// classify.ts
export type DayKind = 'workday' | 'rest-day' | 'official-holiday';
 
export interface DayRecord {
  /** ISO date, e.g. "2026-08-21". */
  date: string;
  kind: DayKind;
  hoursWorked: number;
  /** True when the employee is Muslim and the date falls in Ramadan. */
  ramadan: boolean;
}
 
export interface SchedulePolicy {
  /** Daily standard outside Ramadan (Art. 98): 8. */
  dailyStandardHours: number;
  /** Daily standard during Ramadan for Muslim employees (Art. 98): 6. */
  ramadanDailyStandardHours: number;
}
 
export const defaultSchedule: SchedulePolicy = {
  dailyStandardHours: 8,
  ramadanDailyStandardHours: 6,
};
 
/** Overtime hours in one day, per Art. 98 and Art. 107(3). */
export function overtimeHours(
  day: DayRecord,
  schedule: SchedulePolicy = defaultSchedule,
): number {
  if (day.kind !== 'workday') {
    // Rest day or official holiday: every hour is overtime.
    return day.hoursWorked;
  }
  const standard = day.ramadan
    ? schedule.ramadanDailyStandardHours
    : schedule.dailyStandardHours;
  return Math.max(0, day.hoursWorked - standard);
}

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

الخطوة 6: معادلة المادة 107 في دالة واحدة

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

// pay.ts
import type { Halalas } from './money';
import type { MonthlyWage } from './wage';
import { hourlyRates, type RatePolicy } from './rates';
import { overtimeHours, type DayRecord, type SchedulePolicy } from './classify';
 
/** Price of one overtime hour: hourly wage + 50% of basic hourly (Art. 107(1)). */
export function overtimeHourRate(actualHourly: number, basicHourly: number): number {
  return actualHourly + 0.5 * basicHourly;
}
 
/** Article 107 overtime pay for a period, in halalas. One rounding, at the end. */
export function overtimePay(
  days: DayRecord[],
  wage: MonthlyWage,
  schedule?: SchedulePolicy,
  ratePolicy?: RatePolicy,
): Halalas {
  let total = 0;
  for (const day of days) {
    const hours = overtimeHours(day, schedule);
    if (hours === 0) continue;
    const rates = hourlyRates(wage, day.ramadan, ratePolicy);
    total += hours * overtimeHourRate(rates.actualHourly, rates.basicHourly);
  }
  return Math.round(total);
}

لنمرّ على المثال الذي تصل إليه كل منتديات الموارد البشرية السعودية في النهاية. أجر أساسي 4,000 ريال، وبدلات ثابتة 800 ريال، فالأجر الفعلي 4,800 ريال. خارج رمضان يكون أجر الساعة الفعلي 4,800 / 240 = 20 ريالًا، وأجر الساعة الأساسي 4,000 / 240 = 16.67 ريالًا. الساعة الإضافية الواحدة تساوي 20 + 8.33 = 28.33 ريالًا. عشر ساعات إضافية تدفع 283.33 ريالًا — والمحرك يعيد 28,333 هللة. التنفيذات الخاطئة تنتج 300 ريال (علاوة على الأجر الفعلي) أو 250 ريالًا (الساعة كلها من الأساسي). خمسون ريالًا في الشهر، مضروبة في عدد الموظفين، مضروبة في السنوات: هذا حجم الالتزام الذي تحسمه هذه الدالة الواحدة.

شغّل الساعات العشر نفسها في رمضان ودع القاسم يقوم بالعمل: 4,800 / 180 = 26.67 ريالًا للساعة الفعلية، و4,000 / 180 = 22.22 ريالًا للأساسية، و37.78 ريالًا للساعة الإضافية — 377.78 ريالًا في المجموع، من دون حالة خاصة واحدة في شيفرة التسعير.

الخطوة 7: الإجازة التعويضية وضعُ تسوية، لا خصم

أجازت تعديلات 2025 تسوية العمل الإضافي بإجازة تعويضية مدفوعة الأجر بدل الأجر، بموافقة الموظف، وأحالت التفاصيل إلى اللائحة التنفيذية. والمادة 22 مكرر من اللائحة تضع أربعة شروط، وكل شرط منها سطر في الشيفرة:

  1. يحدد الاتفاق مدة التكليف ومقدار الإجازة، على ألا تقل عن «ساعة ونصف إجازة عن كل ساعة عمل». عشر ساعات إضافية تساوي خمس عشرة ساعة إجازة على الأقل، لا عشرًا. أما يوم بيوم فهي قاعدة الخدمة المدنية، وتنقلها أدلة عربية كثيرة إلى القطاع الخاص خطأً. والاتفاق الذي ينزل عن هذا الحد لا يُعتمد عليه، فيدفع المحرك تلك الساعات نقدًا.
  2. لصاحب العمل تحديد وقت الإجازة خلال مدة لا تتجاوز 60 يومًا من تاريخ مباشرة الساعات الإضافية، ما لم يتفق الطرفان على خلاف ذلك. يحسب المحرك هذا التاريخ كي لا تنجرف الإجازة إلى رصيد لا يجدوله أحد، ويعدّ يوم العمل الإضافي الأول يومًا أول، وهي القراءة الأحوط.
  3. لا تزيد الإجازة التعويضية خلال السنة على 30 يومًا. وما لا يستوعبه السقف من العمل الإضافي يبقى مستحق التعويض، والأجر هو الأصل في المادة 107(1). ولا تحدد اللائحة أي سنة تقصد؛ والمادة 10 تحسب مدد نظام العمل بالتقويم الهجري ما لم ينص العقد على خلافه، فقرر كتابةً أي سنة تتتبّع.
  4. للعامل أجر الإجازات التعويضية المستحقة إذا ترك العمل قبل استعمالها. وهي إجازة مدفوعة، فتُدفع بالأجر، والمادة 2 تجعل الأجر هو الأجر الفعلي.

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

// settlement.ts
import type { Halalas } from './money';
import type { MonthlyWage } from './wage';
import { hourlyRates, type RatePolicy } from './rates';
 
/** Implementing Regulation Art. 22 bis(1): at least 1.5 leave hours per overtime hour. */
export const MIN_LEAVE_HOURS_PER_OVERTIME_HOUR = 1.5;
/** Art. 22 bis(2): the employer schedules the leave within 60 days, unless agreed otherwise. */
export const SCHEDULING_WINDOW_DAYS = 60;
/** Art. 22 bis(3): no more than 30 days of compensatory leave in a year. */
export const ANNUAL_COMP_LEAVE_CAP_DAYS = 30;
 
export interface CompLeaveAgreement {
  /** ISO date the employee agreed in writing. */
  consentDate: string;
  /** Reference to the signed agreement. */
  consentRef: string;
  /** Leave hours granted per overtime hour, as written in the agreement. */
  leaveHoursPerOvertimeHour: number;
  /** A different scheduling deadline the parties agreed in writing, if any. */
  agreedScheduleBy?: string;
}
 
export type OvertimeSettlement =
  | { mode: 'pay' }
  | { mode: 'comp-leave'; agreement: CompLeaveAgreement };
 
export interface SettlementSplit {
  /** Overtime hours that go to payroll at the Art. 107 rate. */
  paidHours: number;
  /** Hours credited to the compensatory-leave balance, never to annual leave. */
  leaveHours: number;
  /** ISO date by which the leave must be taken, when any hours went to leave. */
  scheduleBy?: string;
}
 
const addDays = (iso: string, days: number): string => {
  const d = new Date(`${iso}T00:00:00Z`);
  d.setUTCDate(d.getUTCDate() + days);
  return d.toISOString().slice(0, 10);
};
 
/**
 * Split one overtime assignment between pay and compensatory leave (Reg. Art. 22 bis).
 * Whatever the agreement cannot lawfully cover falls back to pay, the Art. 107(1) default.
 */
export function settle(
  overtimeHours: number,
  overtimeStart: string,
  settlement: OvertimeSettlement,
  compLeaveDaysUsedThisYear: number,
  hoursPerLeaveDay: number,
): SettlementSplit {
  if (settlement.mode === 'pay') return { paidHours: overtimeHours, leaveHours: 0 };
  const a = settlement.agreement;
  if (!a.consentRef || a.leaveHoursPerOvertimeHour < MIN_LEAVE_HOURS_PER_OVERTIME_HOUR) {
    // No provable consent, or a ratio under the floor: the agreement cannot be relied on.
    return { paidHours: overtimeHours, leaveHours: 0 };
  }
  const capLeftDays = Math.max(0, ANNUAL_COMP_LEAVE_CAP_DAYS - compLeaveDaysUsedThisYear);
  const leaveHours = Math.min(
    overtimeHours * a.leaveHoursPerOvertimeHour,
    capLeftDays * hoursPerLeaveDay,
  );
  if (leaveHours === 0) return { paidHours: overtimeHours, leaveHours: 0 };
  return {
    paidHours: overtimeHours - leaveHours / a.leaveHoursPerOvertimeHour,
    leaveHours,
    // The first overtime day counts as day one: the cautious reading of "within 60 days".
    scheduleBy: a.agreedScheduleBy ?? addDays(overtimeStart, SCHEDULING_WINDOW_DAYS - 1),
  };
}
 
/** Reg. Art. 22 bis(4): compensatory leave still owed is paid when the employee leaves. */
export function unusedCompLeavePayout(
  unusedLeaveHours: number,
  wage: MonthlyWage,
  ratePolicy?: RatePolicy,
): Halalas {
  // Paid leave is paid at the wage, and Art. 2 makes the wage the actual wage.
  return Math.round(unusedLeaveHours * hourlyRates(wage, false, ratePolicy).actualHourly);
}

رقمان يستحقان العرض على كل من يقترح الإجازة التعويضية بابًا للتوفير. عشر ساعات إضافية على أجر الخطوة 6 تكلّف 283.33 ريالًا نقدًا. وإن سُوّيت إجازةً صارت خمس عشرة ساعة، فإن ترك الموظف العمل قبل أن يستعملها كان المستحق 15 × 20 = 300 ريال. ومع أي بدل في الأجر تكون ساعة ونصف من الأجر الفعلي أكثر من ساعة فعلية مضافًا إليها نصف ساعة أساسية، فلا تكلّف الإجازة التعويضية أقل من الأجر أبدًا. هي وسيلة لتسوية العمل الإضافي دون نقد، لا وسيلة لإنفاق أقل عليه.

يحوّل hoursPerLeaveDay أيام اللائحة إلى الساعات التي كُتبت بها النسبة. استخدم يوم العمل التعاقدي، ووثّق هذا الاختيار بجانب القاسم الذي اعتمدته في الخطوة 4.

الخطوة 8: تتبّع سقف الـ720 ساعة قبل أن يتتبّعه المفتش

السقف السنوي هو القاعدة التي لا يشفّرها أحد لأنها تسكن المادة 22 من اللائحة التنفيذية لا المادة التي يقتبسها الجميع. مهمة المحرك ليست منع الساعة 721 — فالتشغيل سيكسب ذلك الجدال دائمًا — بل رؤيتها قادمةً والمطالبة بأوراق الموافقة عند وصولها.

// cap.ts
import type { DayRecord } from './classify';
 
/** Implementing Regulation Art. 22: 720 overtime hours a year, more only with consent. */
export const ANNUAL_OVERTIME_CAP_HOURS = 720;
/** Art. 106: even where overtime is allowed, 10 actual hours a day and 60 a week at most. */
export const MAX_ACTUAL_HOURS_PER_DAY = 10;
export const MAX_ACTUAL_HOURS_PER_WEEK = 60;
 
export interface CapStatus {
  used: number;
  remaining: number;
  exceeded: boolean;
}
 
export function capStatus(hoursThisYear: number): CapStatus {
  return {
    used: hoursThisYear,
    remaining: Math.max(0, ANNUAL_OVERTIME_CAP_HOURS - hoursThisYear),
    exceeded: hoursThisYear > ANNUAL_OVERTIME_CAP_HOURS,
  };
}
 
/** Dates on which actual hours broke the Art. 106 daily ceiling. */
export function dailyCeilingBreaches(days: DayRecord[]): string[] {
  return days.filter((d) => d.hoursWorked > MAX_ACTUAL_HOURS_PER_DAY).map((d) => d.date);
}
 
/** True when one week's actual hours broke the Art. 106 weekly ceiling. */
export function weekExceedsCeiling(week: DayRecord[]): boolean {
  return week.reduce((sum, d) => sum + d.hoursWorked, 0) > MAX_ACTUAL_HOURS_PER_WEEK;
}

أظهر remaining في لوحة الموارد البشرية عند 600 ساعة، لا عند 719. واشتراط الموافقة فوق السقف فردي وكتابي — انضباط الإثبات نفسه الذي رأيناه في الخطوة 7.

وحد المادة 106 من نوع آخر. سقف الـ720 ساعة يجوز تجاوزه بموافقة العامل؛ أما حد العشر ساعات الفعلية في اليوم والستين في الأسبوع فلا يملك أحد التنازل عنه. لذلك تقرأ الفحوص hoursWorked، أي اليوم كله، لا الساعات الإضافية وحدها. ومناوبة من 12 ساعة مخالفة أيًّا كان اسمها في نظام الحضور.

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

كل قاعدة أعلاه تتحول إلى سيناريو. هذه هي السيناريوهات التي تُمسك بالتنفيذات الحقيقية:

// engine.test.ts
import { describe, expect, it } from 'vitest';
import { fromSAR } from './money';
import { overtimePay } from './pay';
import type { DayRecord } from './classify';
import { settle, unusedCompLeavePayout, type OvertimeSettlement } from './settlement';
import { dailyCeilingBreaches, weekExceedsCeiling } from './cap';
 
const wage = { basic: fromSAR(4000), fixedAllowances: fromSAR(800) };
 
const workday = (hoursWorked: number, ramadan = false): DayRecord => ({
  date: '2026-03-02',
  kind: 'workday',
  hoursWorked,
  ramadan,
});
 
describe('Article 107 pricing', () => {
  it('prices the premium from the basic wage, not the actual wage', () => {
    // 2h overtime: 2 x (4800/240 + 0.5 x 4000/240) = 2 x 28.333 SAR
    expect(overtimePay([workday(10)], wage)).toBe(5667);
  });
 
  it('pays nothing at or under the daily standard', () => {
    expect(overtimePay([workday(8)], wage)).toBe(0);
  });
 
  it('treats every rest-day hour as overtime', () => {
    const friday: DayRecord = {
      date: '2026-03-06',
      kind: 'rest-day',
      hoursWorked: 5,
      ramadan: false,
    };
    // 5 x 28.333 = 141.67 SAR
    expect(overtimePay([friday], wage)).toBe(14167);
  });
 
  it('applies the 180-hour divisor and 6-hour standard in Ramadan', () => {
    // 8h worked in Ramadan = 2h overtime at (4800/180 + 0.5 x 4000/180)
    expect(overtimePay([workday(8, true)], wage)).toBe(7556);
  });
 
  it('rounds once at the end, not per day', () => {
    const days = Array.from({ length: 3 }, () => workday(9));
    // 3 x 28.333... rounds to 8500, not 3 x 2833 = 8499
    expect(overtimePay(days, wage)).toBe(8500);
  });
});
 
const compLeave = (ratio: number, consentRef = 'OT-2026-014'): OvertimeSettlement => ({
  mode: 'comp-leave',
  agreement: { consentDate: '2026-02-20', consentRef, leaveHoursPerOvertimeHour: ratio },
});
 
describe('Compensatory leave (Implementing Regulation Art. 22 bis)', () => {
  it('grants at least an hour and a half of leave per overtime hour', () => {
    const split = settle(10, '2026-03-02', compLeave(1.5), 0, 8);
    expect(split).toEqual({ paidHours: 0, leaveHours: 15, scheduleBy: '2026-04-30' });
  });
 
  it('pays in cash when the agreed ratio is under 1.5', () => {
    // Day for day is the civil-service rule, not the private-sector one.
    expect(settle(10, '2026-03-02', compLeave(1), 0, 8)).toEqual({ paidHours: 10, leaveHours: 0 });
  });
 
  it('pays in cash when there is no consent reference', () => {
    expect(settle(10, '2026-03-02', compLeave(1.5, ''), 0, 8)).toEqual({ paidHours: 10, leaveHours: 0 });
  });
 
  it('sends overtime past the 30-day annual cap to payroll', () => {
    // 29 days already taken: one 8-hour day left covers 8 / 1.5 overtime hours.
    const split = settle(10, '2026-03-02', compLeave(1.5), 29, 8);
    expect(split.leaveHours).toBe(8);
    expect(split.paidHours).toBeCloseTo(4.667, 3);
  });
 
  it('pays unused leave at the actual wage when the employee leaves', () => {
    // 15 leave hours x 4800/240 = 300 SAR, more than the 283.33 SAR cash settlement.
    expect(unusedCompLeavePayout(15, wage)).toBe(30000);
    expect(overtimePay(Array.from({ length: 5 }, () => workday(10)), wage)).toBe(28333);
  });
});
 
describe('Article 106 ceilings', () => {
  it('flags any day over 10 actual hours', () => {
    expect(dailyCeilingBreaches([workday(10), { ...workday(11), date: '2026-03-03' }])).toEqual(['2026-03-03']);
  });
 
  it('flags a week over 60 actual hours', () => {
    expect(weekExceedsCeiling(Array.from({ length: 6 }, () => workday(10)))).toBe(false);
    expect(weekExceedsCeiling([...Array.from({ length: 6 }, () => workday(10)), workday(1)])).toBe(true);
  });
});

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

استكشاف الأخطاء

أرقامك تخالف حساب الموظف نفسه. في تسع حالات من عشر حسبَ الساعة الإضافية كلها من الأجر الفعلي (أي 1.5 × أجر الساعة الفعلي). أظهر له التقسيم: النظام يسعّر أصل الساعة من الأجر الفعلي والعلاوةَ وحدها من الأساسي.

أرقامك تخالف نظام الرواتب السابق. افحص القاسم أولًا — 240 مقابل 208 مقابل "أيام الشهر الفعلية" يفسّر كل فرق موروث تقريبًا. قرر أي سياسة تعتمد، ووثّقها، وانتقل عمدًا بدل مطابقة النظام القديم خطأً بخطأ.

مجاميع رمضان تبدو مرتفعة. يفترض أن تكون أعلى لكل ساعة: القاسم ينخفض إلى 180 والمعيار اليومي إلى 6، فيرتفع الأجر وعدد الساعات الإضافية معًا. النتيجة الخاطئة هي تسعير إضافي رمضان بالأجر العادي.

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

عمل الجمعة يُظهر صفر ساعات إضافية. نظام حضورك يصنّف اليوم يومَ عمل بساعات دون المعيار. حقل kind موجود تحديدًا كي لا يمرّ يوم الراحة أو العطلة عبر فرع المعيار اليومي أبدًا.

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

الخلاصة

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

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