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

Saudi Annual Leave Accrual in TypeScript (Art. 109/111)

Build an annual leave accrual engine for Saudi payroll in TypeScript, implementing Articles 109, 110 and 111 of the Labour Law — the 21-to-30-day entitlement switch, day-by-day accrual, a leave ledger that survives audits, deferral tracking, and the exit cash-out that turns an unused balance into money.

Every HR system operating in Saudi Arabia carries a liability that grows every single day and appears on no invoice: the annual leave balance. Article 109 of the Labour Law grants every employee at least 21 days of paid leave per year — 30 once they pass five consecutive years of service — and Article 111 converts whatever they have not used into cash the day they leave.

Most systems get this wrong in the same three places. They grant the entitlement as a lump on January 1st instead of accruing it day by day. They apply the 30-day rate to the whole year in which the fifth anniversary falls, instead of blending the two rates around the anniversary date. And they store the balance as a floating-point number of days multiplied by a floating-point daily wage, which is how a settlement ends up 3 halalas off and a labour-court filing ends up on someone's desk.

This tutorial builds the engine properly: an accrual function that follows the statute, a ledger that explains every balance it reports, and a settlement calculation that produces the same number a labour-court expert would. It is a companion to our end-of-service gratuity engine — the two run side by side in any final settlement — and everything we build here is the logic behind our free leave balance 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 date arithmetic in JavaScript
  • A copy of the Saudi Labour Law articles 109–111 open in a tab — 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 Lambda.

What You'll Build

A small library, saudi-leave-engine, exposing four functions:

  • accruedDays(hireDate, asOf, policy) — statutory accrual from hire date to any date, with the 21-to-30 switch handled at the anniversary, not the calendar year
  • leaveBalance(employee, ledger, asOf) — accrued minus taken, from an event ledger
  • exitCashOut(employee, ledger, exitDate) — the Article 111 settlement figure, in halalas
  • leaveLiability(employees, asOf) — the provision your finance team should be booking monthly

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 do all the work. Get their shape right and the code is almost mechanical.

Article 109 sets the entitlement: no less than 21 days of annual leave per year, rising to no less than 30 days once the worker completes five consecutive years with the same employer. Two details matter for code. First, these are floors — a contract can grant more, never less, so the numbers must be policy inputs, not constants. Second, the leave wage is paid in advance when the leave is taken, at the wage current at that time.

Article 110 governs scheduling. The worker may, with the employer's consent, defer leave into the following year. The employer may postpone leave after the end of its due year for up to 90 days where work conditions require; pushing further needs the worker's written consent, and even then the leave cannot slip past the end of the year following its due year. Note what Article 110 does not say: it never says an unused balance is forfeited. It disciplines scheduling, not entitlement.

Article 111 is the money article. A worker who leaves before using accrued leave receives a wage for the untaken days, including fractions of a year pro-rated to time served, based on the wage at the date the leave fell due. In practice — and in the Ministry's own calculators — the daily rate is the monthly wage divided by 30.

One more definition matters: since the 2019 amendment by Royal Decree M/46, the Labour Law defines the year as 365 days unless the contract says otherwise. Older contracts written against the Hijri calendar exist; treat the day basis as configuration, and default to 365.

Warning: the single most common implementation bug is granting leave annually instead of accruing it daily. Article 111's pro-rata rule makes the daily view the legally meaningful one: an employee who resigns on day 100 of their leave year is owed 100/365 of that year's entitlement, whether or not your system had "granted" it yet.

Step 2: Hold money in halalas, never in floats

Same rule as every payroll engine we build: money is an integer count of the smallest unit. One riyal is 100 halalas. Days accrued can be fractional — that is unavoidable and harmless — but the moment days meet money, we round once, at the end, and never accumulate float error.

// src/money.ts
 
/** Money is always an integer number of halalas (1 SAR = 100 halalas). */
export type Halalas = number;
 
export function sar(amount: number): Halalas {
  return Math.round(amount * 100);
}
 
export function formatSar(h: Halalas): string {
  return (h / 100).toFixed(2) + " SAR";
}
 
/**
 * The daily wage under the Ministry's convention: monthly wage / 30.
 * Kept as an exact rational (numerator over 30) until the final rounding.
 */
export function leaveValue(days: number, monthlyWage: Halalas): Halalas {
  return Math.round((days * monthlyWage) / 30);
}

leaveValue multiplies before dividing and rounds exactly once. 21.5 days at SAR 8,000 is Math.round(21.5 * 800000 / 30) = 573,333 halalas = 5,733.33 SAR — reproducible to the halala on every machine.

Step 3: Model the policy, not just the statute

Article 109 gives floors. Real contracts give 22, 25, or 30 days from day one; some CBAs and internal regulations upgrade seniority terms. Make the statute the default, and every number overridable:

// src/policy.ts
 
export interface LeavePolicy {
  /** Days per year before the seniority threshold. Statutory floor: 21. */
  baseDays: number;
  /** Days per year after the threshold. Statutory floor: 30. */
  seniorDays: number;
  /** Consecutive years of service that trigger the senior rate. Statute: 5. */
  seniorAfterYears: number;
  /** Days in a leave year. 365 since the M/46 amendment, unless the contract says otherwise. */
  yearBasis: number;
}
 
export const STATUTORY_POLICY: LeavePolicy = {
  baseDays: 21,
  seniorDays: 30,
  seniorAfterYears: 5,
  yearBasis: 365,
};
 
export function resolvePolicy(overrides?: Partial<LeavePolicy>): LeavePolicy {
  const p = { ...STATUTORY_POLICY, ...overrides };
  if (p.baseDays < STATUTORY_POLICY.baseDays || p.seniorDays < STATUTORY_POLICY.seniorDays) {
    throw new Error(
      "Policy below the Article 109 floor: contracts may improve on 21/30 days, never reduce them."
    );
  }
  return p;
}

The guard is not decoration. Article 8 of the Labour Law voids any term that gives the worker less than the statute — a config file must not be able to encode an illegal contract.

Step 4: Accrue day by day, and blend the anniversary

Dates are the part worth slowing down for. We work in UTC date-only terms to dodge timezone drift, and we compute service anniversaries by calendar date — the fifth anniversary of a 2021-03-15 hire is 2026-03-15, leap years included, not "hire plus 1,825 days".

// src/accrual.ts
import { LeavePolicy, resolvePolicy } from "./policy";
 
const DAY_MS = 86_400_000;
 
function utc(date: string): number {
  const [y, m, d] = date.split("-").map(Number);
  return Date.UTC(y, m - 1, d);
}
 
/** Whole days between two ISO dates (end exclusive). */
export function daysBetween(from: string, to: string): number {
  return Math.round((utc(to) - utc(from)) / DAY_MS);
}
 
/** The nth service anniversary of a hire date, as an ISO date. */
export function anniversary(hireDate: string, n: number): string {
  const [y, m, d] = hireDate.split("-").map(Number);
  const t = new Date(Date.UTC(y + n, m - 1, d));
  return t.toISOString().slice(0, 10);
}
 
/**
 * Statutory accrued leave days from hire to `asOf` (exclusive).
 * Days before the seniority anniversary accrue at baseDays/yearBasis,
 * days after it at seniorDays/yearBasis — blended, not retroactive.
 */
export function accruedDays(
  hireDate: string,
  asOf: string,
  overrides?: Partial<LeavePolicy>
): number {
  const policy = resolvePolicy(overrides);
  const switchDate = anniversary(hireDate, policy.seniorAfterYears);
 
  const totalDays = Math.max(0, daysBetween(hireDate, asOf));
  const baseServiceDays = Math.max(0, Math.min(totalDays, daysBetween(hireDate, switchDate)));
  const seniorServiceDays = totalDays - baseServiceDays;
 
  return (
    (baseServiceDays * policy.baseDays) / policy.yearBasis +
    (seniorServiceDays * policy.seniorDays) / policy.yearBasis
  );
}

Run the number everyone gets wrong. An employee hired 2021-03-15, checked on 2026-09-15 — six months past their fifth anniversary:

accruedDays("2021-03-15", "2026-09-15");
// base period : 2021-03-15 → 2026-03-15 = 1,826 days at 21/365  = 105.06 days
// senior      : 2026-03-15 → 2026-09-15 =   184 days at 30/365  =  15.12 days
// total ≈ 120.18 days

The naive version applies the 30-day rate to the whole year in which the anniversary falls — back to 2025-03-15 — overstating the balance by 9 days, an error of SAR 2,400 at an SAR 8,000 wage, sitting on the books until an exit settlement exposes it. The blend is what the pro-rata logic of Article 111 demands.

Step 5: The ledger — a balance you can defend

A balance that is just a number in a column cannot survive a dispute. Store events, derive the balance. Every leave taken, every manual adjustment, every opening balance from a system migration is a row with a date and a reason:

// src/ledger.ts
import { Halalas } from "./money";
import { LeavePolicy } from "./policy";
import { accruedDays } from "./accrual";
 
export type LeaveEvent =
  | { type: "taken"; from: string; to: string; note?: string }
  | { type: "adjustment"; date: string; days: number; note: string };
 
export interface Employee {
  id: string;
  hireDate: string;
  /** Current monthly wage in halalas — the Article 111 basis at exit. */
  monthlyWage: Halalas;
  policy?: Partial<LeavePolicy>;
}
 
function takenDays(e: LeaveEvent): number {
  if (e.type === "adjustment") return -e.days; // positive adjustment credits the balance
  // Leave spans are inclusive on both ends: 2026-08-02 → 2026-08-06 is 5 days.
  const ms = Date.parse(e.to) - Date.parse(e.from);
  return Math.round(ms / 86_400_000) + 1;
}
 
export function leaveBalance(
  employee: Employee,
  ledger: LeaveEvent[],
  asOf: string
): number {
  const accrued = accruedDays(employee.hireDate, asOf, employee.policy);
  const consumed = ledger
    .filter((e) => (e.type === "taken" ? e.from : e.date) <= asOf)
    .reduce((sum, e) => sum + takenDays(e), 0);
  return accrued - consumed;
}

Two design decisions deserve a note.

Inclusive spans. In Saudi HR practice a leave "from the 2nd to the 6th" is five days, both ends counted. Encoding the span rather than a day count means the ledger can later answer questions the balance cannot — how much of this leave fell inside a deferral window, whether it overlapped an Eid holiday your internal regulation excludes.

Negative balances are legal states, not errors. Article 109's advance-payment rule means employers routinely grant leave ahead of accrual — a new hire taking ten days in month four is simply at minus a few days, which future accrual repays. Your UI may warn; your engine must not throw. At exit, a negative balance becomes a deduction from the final settlement, which is exactly what the cash-out function will produce, with the sign doing the work.

Step 6: Track the Article 110 clock

Article 110 does not delete balances, but it does put dates on them, and an engine that cannot answer "which of these 34 days is inside its lawful deferral window?" leaves HR exposed in both directions — pressuring employees over leave that is lawfully deferred, or sleepwalking into a pile of aged liability with written-consent requirements nobody collected.

The rule reduces to a horizon: leave due in year N should be taken in year N; the employer alone can push it up to 90 days into year N+1; with the worker's written consent it can go to the end of year N+1, and no further. We report aging, and leave the policy decision to humans:

// src/deferral.ts
import { anniversary, daysBetween } from "./accrual";
 
export interface LeaveYearAging {
  /** Leave year index (1 = first year of service). */
  year: number;
  dueYearEnd: string;
  employerDeferralEnd: string;  // dueYearEnd + 90 days
  consentDeferralEnd: string;   // end of the following leave year
  status: "current" | "employer-window" | "consent-required" | "beyond-limit";
}
 
export function agingFor(hireDate: string, year: number, asOf: string): LeaveYearAging {
  const dueYearEnd = anniversary(hireDate, year);
  const employerDeferralEnd = addDays(dueYearEnd, 90);
  const consentDeferralEnd = anniversary(hireDate, year + 1);
 
  const status =
    asOf <= dueYearEnd ? "current"
    : asOf <= employerDeferralEnd ? "employer-window"
    : asOf <= consentDeferralEnd ? "consent-required"
    : "beyond-limit";
 
  return { year, dueYearEnd, employerDeferralEnd, consentDeferralEnd, status };
}
 
function addDays(date: string, n: number): string {
  const t = new Date(Date.parse(date) + n * 86_400_000);
  return t.toISOString().slice(0, 10);
}

A beyond-limit flag is a compliance finding, not a write-off: the employee's money is safe under Article 111 either way. What the flag tells the employer is that the scheduling obligation was missed — the kind of pattern a labour inspector reads as systemic.

Step 7: The exit cash-out — Article 111 in one function

At the end of the relationship, everything converges: accrual runs to the exit date, the ledger nets off what was taken, and the remainder is valued at the daily wage. Fractions of a year count — that is the article's explicit instruction — and a negative balance flips into a recoverable advance:

// src/settlement.ts
import { Employee, LeaveEvent, leaveBalance } from "./ledger";
import { Halalas, leaveValue } from "./money";
 
export interface LeaveCashOut {
  balanceDays: number;
  dailyWage: Halalas;
  /** Positive: owed to the employee. Negative: advance leave recoverable from the settlement. */
  amount: Halalas;
}
 
export function exitCashOut(
  employee: Employee,
  ledger: LeaveEvent[],
  exitDate: string
): LeaveCashOut {
  const balanceDays = leaveBalance(employee, ledger, exitDate);
  return {
    balanceDays,
    dailyWage: Math.round(employee.monthlyWage / 30),
    amount: leaveValue(balanceDays, employee.monthlyWage),
  };
}

This line item sits next to the end-of-service award in the final settlement — Article 111 for the leave, Articles 84 and 85 for the gratuity. If you followed the gratuity engine tutorial, the two libraries share the halala convention and compose into one settlement object without adapters. They differ in one important way: the gratuity scales with the last wage by statute, while the leave wage follows the wage at the leave's due date — for the current year's fraction those coincide, but if you have granted mid-year raises and hold old balances, the difference is real money and your ledger's dates are what let you compute it honestly.

Step 8: Book it monthly, or the balance is a surprise

The same discipline as the gratuity provision: a liability that only materialises at exit has a habit of materialising all at once. Finance should see leave liability move every month:

// src/liability.ts
import { Employee, LeaveEvent, leaveBalance } from "./ledger";
import { Halalas, leaveValue } from "./money";
 
export function leaveLiability(
  staff: Array<{ employee: Employee; ledger: LeaveEvent[] }>,
  asOf: string
): Halalas {
  return staff.reduce((total, { employee, ledger }) => {
    const days = leaveBalance(employee, ledger, asOf);
    return total + Math.max(0, leaveValue(days, employee.monthlyWage));
  }, 0);
}

For a 40-person company on an average SAR 7,000 wage, every untaken statutory year is roughly SAR 196,000 of quiet liability. Watching it monthly is also what makes the Article 110 aging report actionable — the two numbers move together.

Testing Your Implementation

Pin each statutory rule to a scenario. These are the tests that catch the classic bugs:

// test/engine.test.ts
import { describe, expect, it } from "vitest";
import { accruedDays, anniversary } from "../src/accrual";
import { leaveBalance } from "../src/ledger";
import { exitCashOut } from "../src/settlement";
import { sar } from "../src/money";
 
describe("Article 109 accrual", () => {
  it("accrues 21 days over a full early year", () => {
    expect(accruedDays("2025-01-01", "2026-01-01")).toBeCloseTo(21, 5);
  });
 
  it("blends the rate at the fifth anniversary, not the calendar year", () => {
    const total = accruedDays("2021-03-15", "2026-09-15");
    expect(total).toBeCloseTo((1826 * 21) / 365 + (184 * 30) / 365, 5);
  });
 
  it("rejects sub-statutory policies", () => {
    expect(() => accruedDays("2025-01-01", "2026-01-01", { baseDays: 15 })).toThrow();
  });
});
 
describe("Article 111 cash-out", () => {
  const employee = {
    id: "E-1001",
    hireDate: "2023-06-01",
    monthlyWage: sar(8000),
  };
 
  it("pays the pro-rated fraction on early exit", () => {
    const { balanceDays, amount } = exitCashOut(employee, [], "2023-09-09");
    expect(balanceDays).toBeCloseTo((100 * 21) / 365, 5); // 100 days of service
    expect(amount).toBe(153_425); // 1,534.25 SAR, rounded once
  });
 
  it("turns advance leave into a negative settlement line", () => {
    const ledger = [{ type: "taken", from: "2023-07-02", to: "2023-07-11" } as const];
    const { amount } = exitCashOut(employee, ledger, "2023-09-09");
    expect(amount).toBeLessThan(0); // 10 days taken, ~5.75 accrued
  });
});
 
describe("service anniversaries", () => {
  it("handles leap-day hires without drifting", () => {
    expect(anniversary("2024-02-29", 5)).toBe("2029-03-01");
  });
});

Then cross-check a handful of real employees against our leave balance calculator — it runs this exact accrual logic, so any disagreement means an input mismatch worth investigating, usually the hire date or a policy override you forgot you had.

Troubleshooting

Balances a few halalas off vendor payroll reports. Almost always premature rounding on their side — a rounded daily wage multiplied by fractional days. Your leaveValue rounds once; when the numbers differ, yours is the defensible one. Show the arithmetic.

Fifth-anniversary jumps in historical reports. If a report shows a step change of several days at the anniversary, someone applied the 30-day rate retroactively to the whole year. Re-run with the blended accrual and restate.

Hijri-basis contracts. Contracts predating the M/46 amendment, or explicitly Hijri, need yearBasis: 354 and Hijri anniversaries. JavaScript's Intl API with the islamic-umalqura calendar can derive them; keep those employees on an explicit policy override rather than a global flag.

Migrated opening balances. Never backfill synthetic history. One adjustment event dated at migration, with a note pointing at the legacy report, keeps the ledger honest and the audit trail short.

Next Steps

Conclusion

Annual leave in Saudi Arabia is not a perk to track loosely — it is a statutory liability with a daily accrual rule, a seniority switch, a scheduling clock, and a guaranteed cash conversion at exit. The engine that handles it correctly is small: integer money, blended day-by-day accrual, an event ledger, and one rounding at the end. What it buys is large — settlements that match the court's arithmetic, provisions that match reality, and an audit trail that answers questions instead of raising them.

If your leave balances live in a spreadsheet, or your HR system's numbers and your payroll's numbers disagree and nobody can say why, tell us what your stack looks like — we will run a sample of your real employee data through an engine like this one and show you exactly where the two diverge, before an exit settlement does it for you.