writing/tutorial/2026/08
TutorialAug 26, 2026·24 min read

Saudi Sick Leave Pay in TypeScript (Art. 117)

Build a sick leave pay engine for Saudi payroll in TypeScript, implementing Article 117 of the Labour Law — the 30/60/30 ladder, the rolling sick year that does not follow the calendar, the wage base that decides whether you underpay, occupational injuries that must never touch the ladder, Sehhaty code verification, and the termination guard that protects the worker for 120 days.

Search Google in Saudi Arabia for how sick leave is calculated and you get two completely different answers on the same page of results. Most pages quote Article 117 of the Labour Law: thirty days at full pay, sixty days at three quarters, thirty days unpaid — one hundred and twenty days in a year. Other results, ranking just as high, describe a ladder of one hundred and eighty days at full pay, one hundred and eighty at half, and a further year at a quarter: seven hundred and twenty days.

Both are real. They govern different people. Article 117 governs private-sector employment contracts under the Labour Law. The longer ladder belongs to the public-sector human-resources regulations that cover government employees. A payroll engine that picks the wrong one is not slightly wrong — it is wrong by roughly a factor of six, in whichever direction hurts most.

That confusion is live in the search results, which means it is live in the spreadsheets and HR systems those results are feeding. This tutorial builds the engine that does not get it wrong: it refuses to compute until it knows which regime applies, prices each day against the correct wage base, tracks the tiers against a rolling year that ignores your fiscal calendar, routes occupational injuries away from the ladder entirely, and refuses to book a day as sick leave without a verifiable medical certificate behind it.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and TypeScript 5.5+ installed
  • Comfort with dates, integer arithmetic, and discriminated unions in TypeScript
  • A payroll or HR system with an employee master record that already knows contract type, monthly wage, and wage components
  • Access to your existing leave and attendance records — the engine is only as good as the absence data feeding it
  • No prior legal knowledge; the statutory rules that matter are stated as we implement them

If you have already worked through the annual leave accrual engine, the money and ledger conventions here will look familiar — deliberately so, because the two engines share an employee record and must agree at exit.

What You'll Build

A sickLeavePay module that takes an employee, a set of medically certified absence records, and a payroll period, and returns a payslip line plus a defensible ledger. Specifically:

  • A regime gate that classifies the employment relationship before anything else and throws rather than guesses
  • A wage-base resolver that answers the question Article 117 leaves open — which wage the word "pay" refers to
  • A rolling sick-year window opened by the first sick day, not by January
  • The three-tier ladder consumed day by day across continuous and intermittent absences
  • An injury router that sends occupational injuries to the social insurance branch that actually pays them, without consuming a single day of Article 117 balance
  • A certificate gate requiring a verifiable sick-leave code before any day is paid
  • A termination guard exposing the remaining protected days so your offboarding flow cannot quietly terminate someone the statute is still protecting

Step 1: Decide the regime before you decide anything else

The single most expensive bug in this domain is computing the right formula for the wrong person. So the first function in the module is not a calculation — it is a refusal.

/**
 * Which statutory instrument governs this employment relationship.
 *
 * `labour-law` — a private-sector contract under نظام العمل. Article 117
 *   applies: 30 days full, 60 at three quarters, 30 unpaid, per sick year.
 *
 * `civil-service` — a government post under the public-sector human-resources
 *   regulations. A materially longer and differently tiered ladder applies.
 *   This engine does NOT implement it.
 */
export type Regime = 'labour-law' | 'civil-service';
 
export class RegimeNotSupportedError extends Error {
  constructor(public readonly regime: Regime) {
    super(
      `Sick leave under the ${regime} regime is not implemented by this engine. ` +
        `Article 117 of the Labour Law governs private-sector contracts only.`,
    );
    this.name = 'RegimeNotSupportedError';
  }
}
 
export function assertLabourLaw(regime: Regime): asserts regime is 'labour-law' {
  if (regime !== 'labour-law') throw new RegimeNotSupportedError(regime);
}

Make regime a required field on the employee record with no default. A default is how the wrong ladder gets applied silently to a few hundred people. If your master data does not carry the distinction today, that is the finding — surface it as a data-quality error rather than letting the engine invent an answer.

Why a throw and not a fallback. A payroll engine that returns a number is trusted. One that refuses to return a number gets investigated. For a six-fold difference, being investigated is the cheaper outcome.

Step 2: Resolve the wage base — this is where money leaks

Article 117 says the worker is entitled to sick leave "with pay" for the first thirty days and "three quarters of the pay" for the next sixty. It does not spell out which wage.

The Labour Law defines two, and your other engines already use both differently: end-of-service gratuity is computed on the actual wage — basic plus the regular allowances the employee genuinely receives, such as housing and transport — while overtime is explicitly priced from the basic wage plus fifty per cent. Article 117 uses the unqualified word, which is read as the actual wage. Systems that reuse the overtime base here underpay every sick day, often by thirty to forty per cent, on a line nobody audits until an employee leaves.

/** All money is integer halalas. 1 SAR = 100 halalas. Never floats. */
export type Halalas = number;
 
export type WageComponents = {
  basic: Halalas;
  housing: Halalas;
  transport: Halalas;
  /** Other allowances paid regularly and unconditionally, per month. */
  otherRegular: Halalas;
  /** Variable pay: commission, bonuses, irregular incentives. Excluded. */
  variable: Halalas;
};
 
export type WageBase = 'actual' | 'basic';
 
export function monthlyWage(w: WageComponents, base: WageBase): Halalas {
  if (base === 'basic') return w.basic;
  // The actual wage: basic plus what is paid regularly and unconditionally.
  // Variable pay is deliberately excluded — it is not part of the fixed wage.
  return w.basic + w.housing + w.transport + w.otherRegular;
}
 
/**
 * The daily rate. The Labour Law prices a month at 30 days for wage
 * purposes, so this divisor is 30 regardless of how many days the
 * calendar month actually has. Kept fractional on purpose — see Step 4.
 */
export function dailyRate(w: WageComponents, base: WageBase): number {
  return monthlyWage(w, base) / 30;
}

Note the divisor. A February sick day and an August sick day cost the same, because the wage is priced per thirtieth of a month, not per calendar day. Systems that divide by the real day count produce a rate that changes month to month and a settlement nobody can reproduce.

Make the base an explicit, logged decision. If your employment contracts or internal regulations promise something more generous than the statute, that is permitted — the Labour Law sets a floor, not a ceiling — but the engine should record that it applied a policy above the floor, so an inspector can see the choice rather than infer a bug.

Step 3: The sick year is rolling, and it starts when the employee gets sick

This is the trap that survives code review, because the wrong version looks obviously right.

Article 117 measures the ladder across "one year", and the year in question begins on the date of the first sick leave. Not 1 January. Not your fiscal year. Not the employee's joining anniversary. The clock starts the first day the employee is certified sick, runs for one year, and the tiers reset only when a sick day falls outside that window — at which point that day opens a fresh window.

Bucketing by calendar year gets this wrong in both directions. An employee who is sick for twenty-five days in December and twenty-five in January has used fifty days of one sick year and should already be into the seventy-five-per-cent tier; calendar bucketing pays both spells at full rate. Conversely, an employee whose window opened in March 2025 and closed in March 2026 gets a fresh full-pay tier in March that calendar bucketing withholds.

export type SickYear = {
  /** Inclusive ISO date on which this window opened. */
  start: string;
  /** Inclusive ISO date on which it closes: start plus one year, minus one day. */
  end: string;
};
 
const DAY_MS = 86_400_000;
 
function toUTC(iso: string): number {
  const [y, m, d] = iso.split('-').map(Number);
  return Date.UTC(y, m - 1, d);
}
 
function toISO(ms: number): string {
  return new Date(ms).toISOString().slice(0, 10);
}
 
export function openSickYear(firstSickDay: string): SickYear {
  const [y, m, d] = firstSickDay.split('-').map(Number);
  // One year later, minus one day, so the window is inclusive on both ends.
  const endMs = Date.UTC(y + 1, m - 1, d) - DAY_MS;
  return { start: firstSickDay, end: toISO(endMs) };
}
 
export function isWithin(year: SickYear, day: string): boolean {
  const t = toUTC(day);
  return t >= toUTC(year.start) && t <= toUTC(year.end);
}

Two details worth pinning down. Work in UTC midnights throughout — a payroll engine that respects the local timezone will silently shift a boundary day when the server moves, and a sick day that lands one day earlier can push a whole spell into a different tier. And build the window from calendar arithmetic (Date.UTC(y + 1, ...)) rather than adding 365 days, so leap years do not shave a day off somebody's entitlement.

Step 4: Consume the ladder day by day

Article 117's ladder applies "whether the leave is continuous or intermittent". So the unit of account is the day, and the tier a given day falls into depends only on how many sick days have already been consumed in the current window.

export type Tier = {
  /** Cumulative day number, inclusive, at which this tier ends. */
  throughDay: number;
  numerator: number;
  denominator: number;
  label: string;
};
 
/** Article 117: 30 days full, the next 60 at three quarters, the next 30 unpaid. */
export const ARTICLE_117: readonly Tier[] = [
  { throughDay: 30, numerator: 1, denominator: 1, label: 'full pay' },
  { throughDay: 90, numerator: 3, denominator: 4, label: 'three quarters' },
  { throughDay: 120, numerator: 0, denominator: 1, label: 'unpaid' },
];
 
/**
 * Split `days` new sick days, starting after `alreadyUsed` days consumed in
 * this window, into per-tier slices. Days beyond 120 fall outside the
 * entitlement entirely and are returned separately.
 */
export function splitAcrossTiers(
  alreadyUsed: number,
  days: number,
): { slices: { tier: Tier; days: number }[]; beyondEntitlement: number } {
  const slices: { tier: Tier; days: number }[] = [];
  let cursor = alreadyUsed;
  let remaining = days;
 
  for (const tier of ARTICLE_117) {
    if (remaining <= 0) break;
    const roomInTier = tier.throughDay - cursor;
    if (roomInTier <= 0) continue;
    const take = Math.min(roomInTier, remaining);
    slices.push({ tier, days: take });
    cursor += take;
    remaining -= take;
  }
 
  return { slices, beyondEntitlement: remaining };
}

Now price the slices. The rounding decision matters more than it looks: three quarters of a daily rate is rarely a whole number of halalas, and rounding every single day accumulates a drift of several riyals across a sixty-day spell. Multiply first, round once per slice.

export type PaySlice = {
  tier: string;
  days: number;
  amount: Halalas;
};
 
export function priceSlices(
  slices: { tier: Tier; days: number }[],
  rate: number,
): PaySlice[] {
  return slices.map(({ tier, days }) => ({
    tier: tier.label,
    days,
    // Multiply across the whole slice, then round once. Rounding per day
    // drifts by several riyals over a 60-day spell.
    amount: Math.round((rate * days * tier.numerator) / tier.denominator),
  }));
}

Days past the one hundred and twentieth are not "unpaid sick leave" — they are outside the Article 117 entitlement altogether. Treat them as an unauthorised absence unless the parties agree to unpaid leave under Article 116, which requires the employer's agreement and is a different record with different consequences. Do not let the engine paper over the distinction: return beyondEntitlement and make the caller decide.

Step 5: An occupational injury is not sick leave

An employee hurt at work is not on Article 117 leave. Work injuries fall under the occupational hazards branch of the Social Insurance Law, where the compensation is a daily allowance funded through the insurance scheme rather than an employer-paid sick day, at a rate the insurance regulations set.

Two things follow, and payroll systems routinely get both wrong. The days must not consume the Article 117 ladder — an employee who spends forty days recovering from a work injury still has their full thirty days at full pay available if they later fall ill. And the days must not be paid twice, once as sick leave by payroll and once as an allowance through the insurance claim.

export type AbsenceCause = 'illness' | 'occupational-injury';
 
export type Routed =
  | { route: 'article-117'; days: string[] }
  | { route: 'occupational-hazards'; days: string[]; note: string };
 
export function routeByCause(cause: AbsenceCause, days: string[]): Routed {
  if (cause === 'occupational-injury') {
    return {
      route: 'occupational-hazards',
      days,
      note:
        'Compensated through the occupational hazards branch of social insurance. ' +
        'Does NOT consume Article 117 balance and must not be paid as sick leave.',
    };
  }
  return { route: 'article-117', days };
}

Classification is a data problem before it is a code problem. If your absence records carry a free-text reason, you cannot route reliably. Add the cause as a constrained field at the point of entry, require the injury report reference for anything classified as occupational, and reconcile the routed days against your insurance claims monthly. The GOSI contribution engine already handles the contribution side of that relationship; this is the benefit side of the same employee record.

Step 6: No verified certificate, no paid sick day

Article 117's entitlement belongs to the worker "who proves his illness". Proof is a medical report from a recognised entity, and in Saudi Arabia those reports are issued and verified digitally — an employee's certified sick leaves appear in the Sehhaty health platform and carry a service code an employer can verify against the national health services platform.

That gives payroll a control it should actually enforce: a day without a verifiable code is not a sick day.

export type Certificate = {
  /** The sick leave service code issued with the certificate. */
  code: string;
  from: string;
  to: string;
  cause: AbsenceCause;
  /** Set only after checking the code against the issuing platform. */
  verified: boolean;
  verifiedAt?: string;
};
 
export class UnverifiedCertificateError extends Error {
  constructor(code: string) {
    super(
      `Sick leave certificate ${code} has not been verified. ` +
        `Days covered by it cannot be paid under Article 117.`,
    );
    this.name = 'UnverifiedCertificateError';
  }
}
 
export function assertVerified(cert: Certificate): void {
  if (!cert.verified) throw new UnverifiedCertificateError(cert.code);
}

Store verifiedAt and who performed the verification. When a settlement is disputed two years later, "we checked the code on this date" is a defence; "the manager said it was fine" is not.

Resist the temptation to auto-verify by scraping a portal. Verification is a deliberate, logged human or service-account action against an official endpoint, and it belongs behind your integration layer rather than inside the pay calculation.

Step 7: Expose the termination guard

While the employee is inside the one-hundred-and-twenty-day entitlement, the illness is not available to the employer as a reason to end the contract. The protection is part of what Article 117 is for, and it is the rule most likely to turn a routine offboarding into a claim.

An engine that only returns money is not enough here. Return the remaining protected days as a first-class field, and have your offboarding flow read it.

export type Protection = {
  daysUsed: number;
  daysRemaining: number;
  /** True while the statutory sick-leave entitlement is not yet exhausted. */
  protected: boolean;
  windowEnds: string;
};
 
const ENTITLEMENT_DAYS = 120;
 
export function protectionStatus(year: SickYear, daysUsed: number): Protection {
  const daysRemaining = Math.max(0, ENTITLEMENT_DAYS - daysUsed);
  return {
    daysUsed,
    daysRemaining,
    protected: daysRemaining > 0,
    windowEnds: year.end,
  };
}

One related rule worth surfacing in the same object: an employee may ask to join their annual leave to their sick leave. When that request is granted, the joined days are annual leave days — paid at full rate, drawn from the annual balance, and not consuming Article 117 tiers. Model them as annual leave records so they flow through the accrual engine, and let the sick-leave engine see them only as a gap in the sick-day sequence. Two engines, one ledger, no double counting.

Step 8: Assemble the payslip line and the ledger

Everything above composes into one entry point.

export type Employee = {
  id: string;
  regime: Regime;
  wage: WageComponents;
  wageBase: WageBase;
};
 
export type SickLeaveResult = {
  employeeId: string;
  period: { from: string; to: string };
  year: SickYear;
  slices: PaySlice[];
  total: Halalas;
  beyondEntitlement: number;
  routedToInsurance: string[];
  protection: Protection;
};
 
export function computeSickLeavePay(
  employee: Employee,
  certificates: Certificate[],
  period: { from: string; to: string },
  priorDaysUsed: number,
  openWindow: SickYear | null,
): SickLeaveResult {
  assertLabourLaw(employee.regime);
  certificates.forEach(assertVerified);
 
  const illnessDays: string[] = [];
  const injuryDays: string[] = [];
 
  for (const cert of certificates) {
    const days = expandDays(cert.from, cert.to).filter(
      (d) => d >= period.from && d <= period.to,
    );
    const routed = routeByCause(cert.cause, days);
    if (routed.route === 'occupational-hazards') injuryDays.push(...routed.days);
    else illnessDays.push(...routed.days);
  }
 
  illnessDays.sort();
 
  // The window opens on the first sick day if none is open, and rolls
  // forward the moment a sick day falls outside the current one.
  let year = openWindow;
  let used = priorDaysUsed;
  if (year === null && illnessDays.length > 0) {
    year = openSickYear(illnessDays[0]);
    used = 0;
  }
  if (year !== null) {
    for (const day of illnessDays) {
      if (!isWithin(year, day)) {
        year = openSickYear(day);
        used = 0;
        break;
      }
    }
  }
  if (year === null) year = openSickYear(period.from);
 
  const inWindow = illnessDays.filter((d) => isWithin(year!, d));
  const { slices, beyondEntitlement } = splitAcrossTiers(used, inWindow.length);
  const priced = priceSlices(slices, dailyRate(employee.wage, employee.wageBase));
 
  return {
    employeeId: employee.id,
    period,
    year,
    slices: priced,
    total: priced.reduce((sum, s) => sum + s.amount, 0),
    beyondEntitlement,
    routedToInsurance: injuryDays,
    protection: protectionStatus(year, used + inWindow.length),
  };
}
 
function expandDays(from: string, to: string): string[] {
  const out: string[] = [];
  for (let t = toUTC(from); t <= toUTC(to); t += DAY_MS) out.push(toISO(t));
  return out;
}

Persist the result, not just the total. The window boundaries, the per-tier split, the certificate codes, and the days routed to insurance are what let you answer a question in eighteen months without recomputing from scratch against rules that may have changed.

Testing Your Implementation

The cases below are the ones that catch real bugs. Write them before you trust the module.

import { describe, expect, it } from 'vitest';
 
const wage: WageComponents = {
  basic: 800_000,      // 8,000 SAR
  housing: 200_000,    // 2,000 SAR
  transport: 50_000,   //   500 SAR
  otherRegular: 0,
  variable: 300_000,   // commission — must be excluded
};
 
describe('Article 117 sick leave', () => {
  it('excludes variable pay from the actual wage', () => {
    expect(monthlyWage(wage, 'actual')).toBe(1_050_000);
    expect(monthlyWage(wage, 'basic')).toBe(800_000);
  });
 
  it('prices the month at 30 days regardless of the calendar', () => {
    // 10,500 SAR over 30 days = 350 SAR per day.
    expect(dailyRate(wage, 'actual')).toBe(35_000);
  });
 
  it('splits a 100-day spell across all three tiers', () => {
    const { slices, beyondEntitlement } = splitAcrossTiers(0, 100);
    expect(slices.map((s) => s.days)).toEqual([30, 60, 10]);
    expect(beyondEntitlement).toBe(0);
  });
 
  it('carries the tier cursor across intermittent spells', () => {
    // 25 days used in December, 25 more in January of the same sick year.
    const { slices } = splitAcrossTiers(25, 25);
    expect(slices.map((s) => [s.tier.label, s.days])).toEqual([
      ['full pay', 5],
      ['three quarters', 20],
    ]);
  });
 
  it('reports days beyond the 120-day entitlement separately', () => {
    const { beyondEntitlement } = splitAcrossTiers(115, 20);
    expect(beyondEntitlement).toBe(15);
  });
 
  it('closes the window one year after the first sick day, inclusive', () => {
    expect(openSickYear('2026-03-10')).toEqual({
      start: '2026-03-10',
      end: '2027-03-09',
    });
  });
 
  it('handles a leap year without losing a day', () => {
    expect(openSickYear('2027-03-01').end).toBe('2028-02-29');
  });
 
  it('refuses to compute for a civil-service employee', () => {
    expect(() => assertLabourLaw('civil-service')).toThrow(RegimeNotSupportedError);
  });
 
  it('refuses to pay an unverified certificate', () => {
    const cert: Certificate = {
      code: 'GSL-000',
      from: '2026-05-01',
      to: '2026-05-05',
      cause: 'illness',
      verified: false,
    };
    expect(() => assertVerified(cert)).toThrow(UnverifiedCertificateError);
  });
 
  it('does not consume Article 117 balance for an occupational injury', () => {
    const routed = routeByCause('occupational-injury', ['2026-06-01', '2026-06-02']);
    expect(routed.route).toBe('occupational-hazards');
  });
 
  it('rounds once per slice, not once per day', () => {
    // A daily rate of 333.33 SAR at three quarters over 60 days.
    const odd: WageComponents = { ...wage, housing: 0, transport: 0, basic: 999_990 };
    const rate = dailyRate(odd, 'actual');            // 33_333 halalas exactly
    const [slice] = priceSlices([{ tier: ARTICLE_117[1], days: 60 }], rate);
    expect(slice.amount).toBe(Math.round(rate * 60 * 0.75));
  });
});

The intermittent-spell test is the one worth running against real data. Export a year of your own absence records, run them through splitAcrossTiers with a carried cursor, and compare against what your current system paid. The gap, if there is one, is almost always in December and January.

Troubleshooting

Every employee lands in the full-pay tier. Your cursor is resetting. Either you are passing priorDaysUsed as zero on every payroll run, or you are opening a new sick year per calendar year. The window persists across runs — store it on the employee, not in the request.

The settlement disagrees with the ministry's calculator by a few riyals. Almost always per-day rounding. Multiply across the slice and round once, and check that you are dividing the monthly wage by thirty rather than by the calendar month's length.

Sick pay looks about thirty per cent low across the board. You are on the basic wage instead of the actual wage. Check wageBase and confirm that housing and transport are flowing into monthlyWage.

An employee is showing more than 120 days consumed. Two likely causes: occupational injury days are being booked as illness, or joined annual leave days are being double-counted as sick days. Check the routing first — it is the more common of the two.

The tier boundary moves depending on when the job runs. Timezone. Every date in this module is a UTC midnight; a new Date(iso) parsed in local time somewhere in your pipeline will shift boundary days.

Verification passes for certificates that were never checked. Someone is defaulting verified to true. Make it required with no default, and store verifiedAt so an unverified record is visibly incomplete rather than quietly permissive.

Next Steps

Conclusion

Sick leave looks like the simplest line in Saudi payroll and is one of the most reliably wrong. The formula is a single sentence, but the sentence hides four decisions your code has to make explicitly: which regime governs the employee, which wage the word "pay" points at, when the year that carries the tiers actually began, and whether the absence belongs to Article 117 at all. Get those four right and the arithmetic is trivial. Get any one wrong and the error is systematic — the same wrong number, every month, for every affected employee, until somebody leaves and counts.

If your HR system's sick-leave figures and your payroll's sick-leave figures do not match, or nobody can tell you which wage base the current calculation uses, tell us what your stack looks like — we will run a year of your real absence records through an engine like this one and show you where the two diverge, before a labour claim does it for you.