Overtime is the payroll line Saudi labour courts see most often, and the reason is always the same: the formula in Article 107 of the Labour Law is short, but almost every system implements a different one. The statute says an overtime hour is worth the hourly wage plus 50% of the basic wage — two different wage bases in a single sentence. Systems that apply the 50% premium to the full salary overpay every month; systems that compute the whole hour from the basic wage underpay every month, and the difference surfaces years later as a labour claim with interest in the form of bad faith.
This tutorial builds the engine properly: integer money, the actual-versus-basic wage split the statute actually requires, hour classification that knows what a rest day and a Ramadan schedule are, the compensatory-leave option the recent amendments made explicit, and the 720-hour annual cap that most timesheet systems never check. It is the third engine in our Saudi payroll series, alongside the annual leave accrual engine and the end-of-service gratuity engine, and everything we build here is the logic behind our free overtime calculator, which you can use to cross-check your implementation at any point.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- TypeScript 5+ (
npm install -D typescript vitest) - Basic familiarity with TypeScript modules and unit testing
- The Saudi Labour Law open in a tab, Articles 98 to 108 — we will follow the text, not folklore
No framework is required. The engine is a plain TypeScript library you can drop into a Next.js API route, a payroll batch job, or a timesheet service.
What You'll Build
A small library, saudi-overtime-engine, exposing four functions:
hourlyRates(wage, ramadan, policy)— the actual and basic hourly rates, with the 240-hour and 180-hour divisors handled explicitlyovertimeHours(day, schedule)— how many of a day's hours are overtime, including rest days and official holidays where all hours are overtimeovertimePay(days, wage, schedule)— the Article 107 amount for a period, in halalascapStatus(hoursThisYear)— where the employee stands against the 720-hour annual ceiling
Plus a test suite that pins every rule to a scenario you can show an auditor.
Step 1: Read the statute before writing the formula
Three articles carry everything, and each one contributes a rule your engine must encode:
Article 98 sets the standard: no more than 8 working hours a day or 48 a week. During Ramadan, for Muslim employees, the standard drops to 6 hours a day and 36 a week. Everything beyond the applicable standard is overtime.
Article 106 lists the situations in which an employer may exceed those limits — annual inventory, seasonal peaks, preventing an accident. The executive framework caps the total at 720 overtime hours per employee per year, beyond which the employee's written consent is required. Most engines never track this number; yours will.
Article 107 prices it. An overtime hour is paid at the hourly wage plus 50% of the basic wage. Hours worked on the weekly rest day or on official holidays count as overtime in their entirety. And the recent amendments to the Labour Law made explicit what many contracts already did: with the employee's agreement, overtime can be settled as compensatory leave days instead of pay.
The trap sits in the two wage bases. The Saudi Labour Law distinguishes the basic wage (الأجر الأساسي) from the actual wage (الأجر الفعلي), which adds the fixed allowances — housing, transport, and anything else paid regularly. The first half of the overtime hour is priced from the actual wage; the 50% premium is priced from the basic wage alone. Any engine that carries a single "salary" field cannot implement Article 107 correctly.
Step 2: Hold money in halalas, never in floats
The same rule as every engine in this series: money is an integer number of halalas, fractions survive only inside a calculation, and rounding happens exactly once, at the end.
// 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;Step 3: Model the wage the way the statute splits it
Two fields, not one. If your HR master data has a single gross figure, fixing that is a data task that comes before this engine, not after it.
// 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;One decision to record in writing: which allowances are "fixed". A transport allowance paid
every month belongs in fixedAllowances; a one-off bonus does not. Auditors ask for this list —
keep it in your policy document, not in someone's memory.
Step 4: The hourly rate — and the divisor question
The statute prices overtime per hour but states wages per month, so every implementation needs a divisor. The dominant convention — and the one our overtime calculator applies — divides the monthly wage by 240 (8 hours × 30 days). During Ramadan the working month shrinks to 6 hours a day, so the divisor becomes 180, which makes each Ramadan hour, and therefore each Ramadan overtime hour, worth more. Some payrolls instead derive the rate from the weekly standard (48 × 52 / 12 = 208 hours a month). Both produce defensible numbers; what is not defensible is mixing them. Make the divisor a policy value, set it once, and let the tests pin it.
// 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,
};
}Note that the two rates stay as floating-point halalas here. That is deliberate: they are
intermediate values. The single Math.round waits until Step 6.
Step 5: Classify the hours — the part timesheets get wrong
Article 107 does not only price hours beyond the daily standard. It says hours worked on the weekly rest day and on official holidays are overtime from the first minute. A timesheet that only measures "hours above 8" silently drops both cases.
// 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(2)(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);
}Two things worth stating in your policy document. First, whether your establishment applies the
daily standard or the weekly one — Article 98 allows either, and the choice changes which hours
are overtime for irregular schedules. This engine applies the daily standard, which is the
common choice and the stricter one for the employer. Second, which days are official holidays —
Eid al-Fitr, Eid al-Adha, National Day, Founding Day — because someone has to feed
official-holiday into the day records, and "the timesheet didn't know it was Eid" is not a
defence a labour court accepts.
Step 6: The Article 107 formula in one function
With rates and classification in place, the pricing function is small enough to read against the statute line by line.
// 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);
}Walk through the worked example every Saudi HR forum eventually reaches. Basic wage 4,000 SAR, fixed allowances 800 SAR, so the actual wage is 4,800 SAR. Outside Ramadan the actual hourly rate is 4,800 / 240 = 20 SAR and the basic hourly rate is 4,000 / 240 = 16.67 SAR. One overtime hour is worth 20 + 8.33 = 28.33 SAR. Ten overtime hours pay 283.33 SAR — the engine returns 28,333 halalas. The wrong implementations produce 300 SAR (premium on the actual wage) or 250 SAR (whole hour from the basic wage). Fifty riyals a month, times a workforce, times years: that is the size of the liability this one function decides.
Run the same ten hours in Ramadan and the divisor does the work: 4,800 / 180 = 26.67 SAR actual hourly, 4,000 / 180 = 22.22 SAR basic hourly, 37.78 SAR per overtime hour — 377.78 SAR in total, without a single special case in the pricing code.
Step 7: Compensatory leave is a settlement mode, not a discount
The recent amendments allow overtime to be settled as compensatory leave instead of pay — with the employee's agreement. Two engine consequences. The consent is an event with a date and a reference, not a boolean on the employee record; store it the way the leave engine's ledger stores leave events, because the burden of proving agreement sits with the employer. And unsettled overtime is a liability either way: hours settled as leave flow into the leave balance, hours settled as pay flow into payroll, and hours settled as neither are a claim waiting for its hearing.
// settlement.ts
export type OvertimeSettlement =
| { mode: 'pay' }
| {
mode: 'comp-leave';
/** ISO date the employee agreed in writing. */
consentDate: string;
/** Reference to the signed consent document. */
consentRef: string;
};If the settlement record says comp-leave and there is no consentRef your system can produce
on request, treat it as pay. That default costs money; the other default costs a case.
Step 8: Track the 720-hour cap before the inspector does
The annual ceiling is the rule nobody codes because it lives in the executive framework rather than in the article everyone quotes. The engine's job is not to block the 721st hour — operations will always win that argument — but to see it coming and to demand the consent paperwork when it arrives.
// cap.ts
export const ANNUAL_OVERTIME_CAP_HOURS = 720;
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,
};
}Surface remaining on the HR dashboard at 600 hours, not at 719. The consent requirement above
the cap is per employee and in writing — the same evidence discipline as Step 7.
Testing Your Implementation
Every rule above becomes a scenario. These are the ones that catch real implementations:
// engine.test.ts
import { describe, expect, it } from 'vitest';
import { fromSAR } from './money';
import { overtimePay } from './pay';
import type { DayRecord } from './classify';
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);
});
});The last test is the one that matters most in production: rounding per day instead of per period drifts by a halala at a time until a reconciliation fails. Cross-check any scenario against our free overtime calculator — it runs this same logic.
Troubleshooting
Your numbers disagree with the employee's own calculation. Nine times out of ten they computed the whole overtime hour from the actual wage (giving 1.5 × actual hourly). Show the split: the statute prices the base hour from the actual wage and only the premium from the basic wage.
Your numbers disagree with the previous payroll system. Check the divisor first — 240 versus 208 versus "calendar days in that month" explains almost every legacy delta. Decide which policy you are adopting, record it, and migrate deliberately rather than matching the old system bug for bug.
Ramadan totals look too high. They are supposed to be higher per hour: the divisor drops to 180 and the daily standard to 6, so both the rate and the overtime hour count rise. The wrong result is Ramadan overtime priced at the normal rate.
Friday work shows zero overtime. Your timesheet is classifying the day as a workday with
hours under the standard. The kind field exists precisely so rest days and holidays never
pass through the daily-standard branch.
Next Steps
- Run exit scenarios end to end with the end-of-service gratuity engine — unpaid overtime surfaces at settlement time
- Route compensatory-leave settlements into the annual leave accrual engine so the two balances reconcile
- Feed the resulting payroll into your WPS file generator — the wage protection file has a dedicated overtime column, and mismatches there are visible to the ministry
- See how the figures flow onwards in Mudad payroll integration and the broader Qiwa platform stack
- Let employees sanity-check their own numbers with the free overtime calculator
Conclusion
Article 107 fits in a sentence, and that is exactly why it is implemented wrong so often: the sentence contains two wage bases, a divisor nobody states, three day types, a settlement option that requires evidence, and an annual cap living outside the article. The engine that handles all of it is a few hundred lines: integer money, a two-field wage model, explicit divisors, day classification, one pricing function, and one rounding.
If your overtime is computed in a spreadsheet, or your timesheet system and your payroll disagree and the difference is being paid out of goodwill, tell us what your stack looks like — we will run a month of your real timesheet data through an engine like this one and show you exactly which of the three classic mistakes your current formula is making, before an inspector or a labour court does.