writing/tutorial/2026/08
● TutorialAug 18, 2026·30 min read

Saudi End-of-Service Gratuity in TypeScript (Art. 84/85)

Build an end-of-service gratuity engine for Saudi payroll in TypeScript, implementing Articles 84, 85, 87 and 88 of the Labour Law — the two-tier award, the resignation scale, service in thirty-day months on the Hijri or Gregorian calendar the contract uses, the monthly accrual that makes it a provision rather than a surprise, and continuous service when the employer changes (Article 18, Cabinet Decision 318).

Every payroll system operating in Saudi Arabia has to answer one question correctly: when this employee walks out of the building, what do we owe them?

Article 84 of the Labour Law makes the end-of-service award — مكافأة نهاية الخدمة, often written EOSB — a statutory obligation on the employer, not a benefit the company grants. Article 88 gives you a week to pay it. And Article 85 quietly makes the same employee, with the same wage and the same length of service, worth three completely different numbers depending on who ended the contract.

Most in-house implementations get one of four things wrong: they count service in days over 365 instead of in months of thirty days on the contract's calendar, they apply the two-tier rate to the wrong bracket, they use basic salary where the law says wage, or they treat the award as a payment event instead of a liability that has been accruing every month since the employee's first day. Each of those produces a number that survives internal review and fails at the labour court.

This tutorial builds the calculation properly in TypeScript — the arithmetic, the statutory edge cases, the tests that pin each one, and the monthly accrual that turns the whole thing into a provision your finance team can see coming.

This is engineering guidance, not legal advice. The statutory text governs, and a contract may always be more generous than the minimum. Where a case is genuinely contested — an unusual termination, a disputed wage definition — get a Saudi labour lawyer to rule on the inputs before you encode them.

Corrected 18 September 2026. Earlier versions of this tutorial measured service in calendar anniversaries, treated the end date as the day after service, and assumed a Gregorian contract. Checked against the Ministry of Justice labour calculator, that disagreed on every one of 45 test cases, and it undercounted a Hijri contract by about three per cent of service. Step 3 now follows Articles 2 and 10 of the Labour Law and matches the Ministry's figure on all 45.

Updated 30 September 2026. Step 7 said notice under Article 75 is thirty days on a monthly-paid indefinite contract. Since the M/44 amendments took effect on 19 February 2025, that holds only when the worker ends the contract; an employer paying a monthly wage owes sixty. Step 9 is new: it keeps service continuous when the employer changes, under Article 18 of the Labour Law and Cabinet Decision 318.

Prerequisites

Before starting, ensure you have:

  • Node.js 20 or later, and TypeScript 5.x
  • Familiarity with integer arithmetic and why floating point is unsafe for money
  • A payroll or HR system with employment start dates and a wage definition you can query
  • The current Saudi Labour Law text open — the Ministry of Human Resources and Social Development (HRSD) publishes it, and Qiwa's knowledge centre carries a readable version of the same articles

What You'll Build

A pure function, endOfService, that takes a start date, an end date, the last wage and the reason the relationship ended, and returns a full breakdown: days of service, months of wage earned under Article 84, the full award, the Article 85 share, and the amount actually payable. Plus a monthly accrual function that answers the finance-side question — what has this liability grown to as of today, before anyone has resigned. And a split for the worker whose employer changed underneath them, which says who pays which part.

No dependencies, no dates library, no floats.

Step 1: Read the statute before writing the formula

Four articles carry the whole calculation. It is worth being precise about each, because the common implementation bugs are all misreadings rather than coding errors.

Article 84 — the award itself. Half a month's wage for each of the first five years of service, and one full month's wage for each year after that. Fractions of a year are paid in proportion.

The trap is that the tiers are cumulative, not selective. Seven years of service is not seven months, and it is not three and a half. It is five years at half a month, plus two years at a full month: 2.5 plus 2, which is 4.5 months.

Article 85 — the resignation reduction. When the worker ends the contract, the award is scaled by length of service:

Completed serviceShare of the Article 84 award
Less than 2 yearsnothing
2 years up to 5 yearsone third
5 years up to 10 yearstwo thirds
10 years or morethe whole award

When the employer ends the contract, the full award is payable regardless of length of service. This is the single largest source of disputes, and the reason the reason must be a function input rather than a default someone assumed.

Article 87 — the exceptions that override Article 85. A worker who leaves because of force majeure beyond their control receives the full award whatever their tenure. So does a female worker who ends the contract within six months of her marriage or within three months of giving birth. These are not edge cases you can defer — they are common enough in Saudi workforces to be worth an explicit flag rather than a manual override.

Article 88 — the deadline. Where the employer ends the relationship, all entitlements are settled within one week of the end. Where the worker resigns, within two weeks. A calculation that takes your finance team ten days to assemble is already non-compliant on the first path, which is the practical argument for automating it.

One more definition matters. Article 84 settles on the wage, and the Labour Law's definitions article distinguishes the basic wage from the actual wage — basic plus the allowances and raises paid regularly. Housing and transport allowances are the ones that move the number. Using basic-only understates the award, sometimes by thirty per cent or more, and it is the mistake that most often turns a routine departure into a claim.

Step 2: Hold money in halalas, never in floats

0.1 + 0.2 is not 0.3, and a gratuity is a multiplication chain over a wage. Work in whole minor units — halalas — and round exactly once, at the point of output.

/** 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}`;
}

The rule that follows from this: derive totals from unrounded rates, never from rounded intermediate values. If you round a half-month rate and then multiply by five years, you have banked the rounding error five times.

Step 3: Measure service the way the Ministry does

Two definitions govern service before Article 84 does. Article 2 defines the month as thirty days unless the employment contract or the work regulation says otherwise. Article 10 counts every period in the Labour Law on the Hijri calendar, with the same exception.

The Ministry of Justice labour calculator (الحاسبة العمالية) applies both. Before anything else it asks for the date type, Hijri or Gregorian, with a note: Gregorian dates are for Gregorian contracts and Hijri dates for Hijri contracts, because the entitlements differ. It then prices service in months of thirty days over a 360-day year, and it counts the last day worked.

We checked our engine against it on 18 September 2026 with 45 date pairs, covering termination and resignation, starts on the 29th to the 31st, and leap days. The rule below reproduces every one of those figures to the halala. Three consequences are worth knowing before you read the code:

  • The last day worked counts. 1 January 2019 to 31 December 2023 is exactly five years, and on a 10,000 wage that is 25,000.00. Ending on 1 January 2024 adds a day: 25,027.78.
  • February is thirty days. 1 February to 1 March is one month and one day, which pays 430.56. Leap days stop mattering, because days are never divided by a year's actual length.
  • The calendar changes the answer. On a Hijri contract the same dates are 5 Hijri years, 1 month and 23 days, because a Hijri year is about eleven days shorter. The Ministry prices that at 26,472.22, which is 1,472.22 more than the Gregorian contract.

The earlier version of this step counted anniversaries and divided the year in progress by its length in days. The differences were small, at most 49.89 riyals on a 10,000 wage, but they showed up in every case, and they are exactly what an auditor looks for first.

const MS_PER_DAY = 86_400_000;
 
/** Article 10: periods run on the Hijri calendar unless the contract or the work regulation says otherwise. */
export type CalendarBasis = 'gregorian' | 'hijri';
 
/** A date as the calendar in force writes it: [year, month, day]. */
type Ymd = readonly [number, number, number];
 
const HIJRI = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura-nu-latn', {
  timeZone: 'UTC',
  year: 'numeric',
  month: 'numeric',
  day: 'numeric',
});
 
function partsOf(date: Date, calendar: CalendarBasis): Ymd {
  if (calendar === 'gregorian') {
    return [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()];
  }
  const parts = HIJRI.formatToParts(date);
  const pick = (type: string) => Number(parts.find((p) => p.type === type)?.value);
  return [pick('year'), pick('month'), pick('day')];
}
 
const compare = (a: Ymd, b: Ymd) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
 
function addDays(date: Date, days: number): Date {
  return new Date(date.getTime() + days * MS_PER_DAY);
}
 
/**
 * The latest day whose date is on or before `target`. A date the month does
 * not have (31 April, or the 30th of a 29-day Hijri month) settles on the
 * month's last day. `guess` only needs to be close; the walk corrects it.
 */
function onOrBefore(target: Ymd, calendar: CalendarBasis, guess: Date): Date {
  let day = guess;
  while (compare(partsOf(addDays(day, 1), calendar), target) <= 0) day = addDays(day, 1);
  while (compare(partsOf(day, calendar), target) > 0) day = addDays(day, -1);
  return day;
}
 
const MEAN_MONTH_DAYS: Record<CalendarBasis, number> = { gregorian: 30.44, hijri: 29.53 };
 
/**
 * Service in days of a 360-day year: whole months of thirty days (Art. 2),
 * counted in the contract's calendar (Art. 10), plus the days left over.
 * `lastDay` is the last day worked, and it counts.
 */
export function serviceDays360(start: Date, lastDay: Date, calendar: CalendarBasis): number {
  const from = partsOf(start, calendar);
  const after = addDays(lastDay, 1);
  const until = partsOf(after, calendar);
 
  // The same day-of-month, `n` months after the start.
  const monthAt = (n: number): Ymd => {
    const k = from[1] - 1 + n;
    return [from[0] + Math.floor(k / 12), (k % 12) + 1, from[2]];
  };
 
  let months = 0;
  while (compare(monthAt(months + 1), until) <= 0) months++;
 
  const guess = addDays(start, Math.round(months * MEAN_MONTH_DAYS[calendar]));
  const anchor = onOrBefore(monthAt(months), calendar, guess);
  const leftover = Math.round((after.getTime() - anchor.getTime()) / MS_PER_DAY);
 
  return months * 30 + leftover;
}

Months are counted forward from the start date to the same day of the month. If that day does not exist in a month (the 31st in April, or the 30th of a 29-day Hijri month), the month completes only once the next month begins. That is how the Ministry treats a start on 31 January. Whatever is left after the last whole month is counted in actual days, each worth a thirtieth of a month.

Intl.DateTimeFormat with the islamic-umalqura calendar gives you Umm al-Qura, the official Saudi calendar, and Node 20 ships it with full ICU. No dates library is needed. Dates are parsed as UTC midnight, so a server in Riyadh and a server in Frankfurt agree on the day service ended, and on which Hijri day that was:

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;
}

Rejecting 2026-02-30 at the boundary is worth the eight lines. Date constructors that silently roll over turn a typo into a wrong payment.

Step 4: The two-tier Article 84 award

With service in days of a 360-day year, the two tiers are a split rather than a branch:

const YEAR_DAYS = 360;
const STEP_YEARS = 5;
 
const serviceDays = serviceDays360(from, lastDay, calendar);
 
const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
 
// Half a month per year for the first five, a whole month thereafter.
// Held as half-months times 360 so nothing is divided until the end.
const halfMonths = atHalf + 2 * atFull;
const fullMinor = divRound(monthly * halfMonths, 2 * YEAR_DAYS);

halfMonths counts half-months of wage multiplied by 360, which keeps the whole computation in integers until the final division. Check it against the cases everyone knows:

  • Exactly five years, or 1,800 days: halfMonths is 1,800, which is two and a half months. On 10,000 riyals: 25,000.00.
  • Exactly seven years, or 2,520 days: 1,800 plus twice 720 gives 3,240, which is four and a half months. On 10,000 riyals: 45,000.00.

A partial first year is where proportionality shows up. Six calendar months is 180 days, a quarter of a month's wage, so the award is 2,500.00 whether or not the year is a leap year.

Step 5: Article 85 — the same service, three different numbers

The reduction applies to the award, not to the wage, and only when the worker resigned:

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_DAYS);
  if (serviceDays < twoY) [numerator, denominator] = [0, 1];
  else if (serviceDays < fiveY) [numerator, denominator] = [1, 3];
  else if (serviceDays < tenY) [numerator, denominator] = [2, 3];
}
 
// From the unrounded award, so the one-third share is not rounded twice.
const payableMinor = divRound(monthly * halfMonths * numerator, 2 * YEAR_DAYS * denominator);

Note what the code deliberately does not do: it does not zero out fullMinor for a short resignation. The award still accrued — Article 85 governs what is payable, not what was earned. That distinction matters the moment the departure is re-characterised, which happens in about a third of contested cases: an employee recorded as having resigned is found to have been constructively dismissed, and the payable figure jumps to the full award without any recomputation of service.

Keep both numbers in the return type so the reclassification is a single field change:

export type EndOfServiceBreakdown = {
  /** Days of service in 360-day years: months of thirty, plus the rest. */
  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;
};

The concrete example worth putting in front of a stakeholder: three years of service on a wage of 10,000 riyals. Dismissed, the employee is owed 15,000.00. Having resigned, the same employee is owed 5,000.00 — one third. Same dates, same wage, same accrued award of 15,000.00. If your UI shows only the payable figure, nobody can see where the other 10,000 went, and that opacity is what generates the dispute.

Exposing share as a readable fraction rather than a decimal is a small thing that pays for itself in support tickets. "1/3" maps directly onto Article 85's language; "0.3333" does not.

Step 6: Article 87 — the overrides

Two conditions make the full award payable even on a resignation that Article 85 would have reduced. They need to be inputs, because no system can infer them:

export type EndReason = 'termination' | 'resignation';
 
export type EndOfServiceInput = {
  start: string;
  /** The last day worked. It counts: service runs up to and including it. */
  end: string;
  /** Last actual monthly wage — basic plus every regular allowance. */
  wage: string | number;
  /**
   * Art. 10 — Hijri unless the contract or the work regulation says
   * otherwise. Required: the answer is in a document this code cannot see.
   */
  calendar: CalendarBasis;
  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;
};

In the body of the function, the exception short-circuits the Article 85 scale entirely:

if (reason === 'resignation' && !fullAwardException) {
  // ... the Article 85 steps
}

A flag with a comment naming the article beats a clever inference. HR knows whether the departure was a marriage; your date arithmetic never will. The same goes for calendar: it is required rather than defaulted, because Article 10 makes Hijri the default and most contracts written today say Gregorian. Only the contract can settle it, so make the caller read it.

Put together, the whole function:

/** Days in a service year: twelve months of thirty. */
const YEAR_DAYS = 360;
const STEP_YEARS = 5;
/** Service thresholds, in years, at which a resignation's share steps up. */
export const RESIGNATION_STEPS = [2, 5, 10] as const;
 
export function endOfService({
  start,
  end,
  wage,
  calendar,
  reason = 'termination',
  fullAwardException = false,
}: EndOfServiceInput): EndOfServiceBreakdown {
  const from = parseDate(start, 'start');
  const lastDay = parseDate(end, 'end');
  if (lastDay < from) throw new RangeError('end is before start');
  const monthly = parseAmount(String(wage));
 
  const serviceDays = serviceDays360(from, lastDay, calendar);
 
  // Half a month per year for the first five, a whole month thereafter.
  // Held as half-months times 360 so nothing is divided until the end.
  const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
  const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
  const halfMonths = atHalf + 2 * atFull;
 
  // Article 85 — resignation only, unless Article 87 applies.
  let numerator = 1;
  let denominator = 1;
  if (reason === 'resignation' && !fullAwardException) {
    const [twoY, fiveY, tenY] = RESIGNATION_STEPS.map((y) => y * YEAR_DAYS);
    if (serviceDays < twoY) [numerator, denominator] = [0, 1];
    else if (serviceDays < fiveY) [numerator, denominator] = [1, 3];
    else if (serviceDays < tenY) [numerator, denominator] = [2, 3];
  }
 
  const fullMinor = divRound(monthly * halfMonths, 2 * YEAR_DAYS);
  // From the unrounded award, so the one-third share is not rounded twice.
  const payableMinor = divRound(monthly * halfMonths * numerator, 2 * YEAR_DAYS * denominator);
  const monthsCenti = divRound(halfMonths * MINOR_PER, 2 * YEAR_DAYS);
 
  return {
    serviceDays,
    serviceYears: Math.floor(serviceDays / YEAR_DAYS),
    monthsOfWage: formatMinor(monthsCenti),
    full: formatMinor(fullMinor),
    share: denominator === 1 ? String(numerator) : `${numerator}/${denominator}`,
    payable: formatMinor(payableMinor),
    fullMinor,
    payableMinor,
  };
}

Step 7: The award is only one line of the final settlement

Article 88 requires all entitlements settled within the deadline, not just the gratuity. A production engine that computes the award alone hands finance a number they still cannot pay against. The other statutory lines:

  • Unused annual leave, Articles 109 and 111. Twenty-one days a year, rising to thirty after five continuous years. Leave genuinely accrues per day, unlike the gratuity — so a period straddling the fifth anniversary has to be split and accrued at both rates. Computing the whole run at one rate is the standard error here, in the opposite direction from the gratuity's.
  • Notice pay, Articles 75 and 76. On an indefinite contract, the notice not served, paid at the actual wage. The period is sixty days when an employer paying a monthly wage ends the contract, and thirty in every other case: the worker ending it, or a wage not paid monthly. If the employer releases the worker from work during the notice, Article 78 counts service as continuous to the end of that period, so the end you pass to endOfService is the last day of the notice, not the last day in the office. The Article 75 guide works through the cases.
  • Compensation for unlawful termination, Article 77. Fifteen days' wage per year of service on an indefinite contract, or the wage for the unserved remainder on a fixed-term one — and in every case not less than two months' wage. That floor governs for anyone with under four years of service, which is most of the people who look it up.
  • Unpaid wages, which is the first line of the Ministry of Justice's own labour calculator.

Model these as separate pure functions returning the same money type, then sum at the settlement layer. A single monolithic calculateFinalSettlement becomes untestable within a sprint.

Our Saudi labour calculator gives quick estimates for these lines, and the leave balance calculator covers the Article 109 side alone. For the last halala, and for any Hijri contract, use the Ministry of Justice calculator as the reference.

Step 8: Accrue it monthly, or it is not a provision

This is the step that separates a payroll feature from a finance system, and it is the one most in-house builds skip.

End-of-service is a liability that grows every month an employee stays. If it is only computed when somebody leaves, the business discovers a six-figure obligation on the day it becomes payable within one week. Under IAS 19 it is an employee benefit obligation that should already be provisioned.

The engine you have built gives you this for free — call it with today's date instead of a termination date:

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

The comment carries the judgement call. Provisioning against the payable figure — assuming everyone resigns — systematically understates the liability, because dismissals and contract expiries pay in full. Provision against the full award and let the Article 85 reduction be a release when it happens.

Roll that across the headcount and you have a monthly series finance can actually plan against:

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

Two reporting facts fall out of this that nobody asks for until they see them. The month-on-month delta is the accrual charge for the period. And the step at each employee's fifth anniversary is visible in advance — the rate doubles from half a month to a full month per year, so a cohort hired together produces a jump in the provision on a date you can name today.

That series is the reporting layer, and it is the reason this calculation is worth doing properly rather than in a spreadsheet.

Step 9: When the employer changes, service does not restart

A start date and an end date assume one employer. Two rules say the clock keeps running when the employer changes, and a payroll system that opens a new employee record at the new entity, with the transfer as the start date, gets both wrong.

Article 18 of the Labour Law covers the private sector. When an establishment passes to a new owner, or its legal form changes by merger, split or otherwise, the employment contracts stay in force and service counts as continuous. For the rights that arose before the change, including wages and the end-of-service award "presumed due on the date ownership passed", the predecessor and the successor are jointly liable, so the worker can claim from either. The Law does not say how the two settle it between themselves; that usually belongs in the sale or merger agreement. For an individual establishment, the two may instead agree to move all of the workers' earlier rights to the new owner, with each worker's written consent. A worker who does not consent may ask to end the contract and collect what is due from the predecessor.

Cabinet Decision 318 of 4/4/1448H, published in Umm al-Qura on 25 September 2026, covers staff moved out of a government entity by privatisation. It amends the rules issued by Cabinet Decision 616 of 20/10/1442H. Service is continuous for the end-of-service award and for leave. The supervising entity, the predecessor, pays the award and leave for the period before the transfer. The receiving entity, the successor, pays for the period after. Both parts are calculated on the last wage. The Decision also deletes the three references that tied these rules to Article 18, and leaves the method to a mechanism the board of the National Center for Privatization will issue after coordinating with the Ministries of Finance and of Human Resources.

Three things follow for the engine, and none of them needs a new formula:

  1. The start date is the first day with the first employer. Four years with the predecessor and three with the successor is seven years of service. On a 10,000 wage that is 45,000.00. Restarting the clock at the transfer gives two awards, of 20,000 and 15,000, which is 10,000 short, because years six and seven earn a full month instead of half.
  2. Article 85 reads the continuous service too. A worker who resigns after those seven years takes two thirds of the award, not the one third that four years alone would give.
  3. What changes is who pays, and on which wage. Under Decision 318 both parts use the last wage. Under Article 18 the jointly owed figure is the award presumed due on the transfer date, which a system can only produce if it kept the wage in force on that day.

The split is where the Decision stops. Until the mechanism is published, two readings are defensible, and on the same worker they move 5,714.29 riyals between the two entities:

MethodPredecessorSuccessor
By time: in proportion to the days served with each25,714.2919,285.71
By band: the predecessor pays what its own four years earn20,000.0025,000.00

Under the time reading the predecessor also pays part of the full-month years that exist only because of continuity. Under the band reading the successor pays all of them. We do not know which one the mechanism will choose, so the code makes the caller say:

/**
 * Two ways a worker changes employer without a break in service.
 * - 'article-18': the establishment is sold, merged or split (Labour Law, Art. 18).
 * - 'decision-318': a government entity's staff move to the entity created by
 *   privatisation (Cabinet Decision 616/1442, as amended by Decision 318/1448).
 */
export type TransferRegime = 'article-18' | 'decision-318';
 
/** The two-tier weight from Step 4: half-months of wage, times 360. */
function halfMonthsFor(serviceDays: number): number {
  const atHalf = Math.min(serviceDays, STEP_YEARS * YEAR_DAYS);
  const atFull = Math.max(0, serviceDays - STEP_YEARS * YEAR_DAYS);
  return atHalf + 2 * atFull;
}
 
const dayBefore = (date: Date) => new Date(date.getTime() - MS_PER_DAY);
const isoDate = (date: Date) => date.toISOString().slice(0, 10);
 
/**
 * Article 18: the award "presumed due on the date ownership passed", for which
 * predecessor and successor are jointly liable. Our reading, not the Law's words:
 * a presumed award has no resignation behind it, so it is the full Art. 84 figure,
 * on the wage in force on the transfer date.
 */
export function presumedDueAtTransfer(input: {
  start: string;
  /** The first day under the new owner. */
  transferDate: string;
  wageAtTransfer: string | number;
  calendar: CalendarBasis;
}): number {
  const from = parseDate(input.start, 'start');
  const transfer = parseDate(input.transferDate, 'transferDate');
  if (transfer <= from) throw new RangeError('transferDate must fall after start');
  return endOfService({
    start: input.start,
    end: isoDate(dayBefore(transfer)),
    wage: input.wageAtTransfer,
    calendar: input.calendar,
    reason: 'termination',
  }).fullMinor;
}
 
/**
 * Decision 318 names who pays each period but leaves the method to a mechanism
 * the National Center for Privatization board has not issued. So there is no
 * default: the caller states the method, and the report shows which one it used.
 * - 'time': in proportion to the days served with each entity.
 * - 'band': the predecessor pays what its own years would earn at the last wage;
 *   the successor pays the rest, including the full-month years continuity added.
 */
export type SplitMethod = 'time' | 'band';
 
export type TransferSplit = {
  method: SplitMethod;
  /** Service with the predecessor, in days of a 360-day year. */
  predecessorDays: number;
  total: string;
  predecessor: string;
  successor: string;
  totalMinor: number;
  predecessorMinor: number;
  successorMinor: number;
};
 
export function splitAtTransfer(
  input: EndOfServiceInput & { transferDate: string },
  method: SplitMethod,
): TransferSplit {
  const from = parseDate(input.start, 'start');
  const transfer = parseDate(input.transferDate, 'transferDate');
  const lastDay = parseDate(input.end, 'end');
  if (transfer <= from || transfer > lastDay) {
    throw new RangeError('transferDate must fall after start and on or before end');
  }
 
  // Continuous service on the last wage: the worker's total never depends on the split.
  const whole = endOfService(input);
  const monthly = parseAmount(String(input.wage));
  const [num, den = 1] = whole.share.split('/').map(Number);
 
  const predecessorDays = serviceDays360(from, dayBefore(transfer), input.calendar);
  const [weight, scale] =
    method === 'time'
      ? [halfMonthsFor(whole.serviceDays) * predecessorDays, 2 * YEAR_DAYS * whole.serviceDays]
      : [halfMonthsFor(predecessorDays), 2 * YEAR_DAYS];
 
  // One division, from the unrounded award, as in Step 5.
  const product = monthly * weight * num;
  if (!Number.isSafeInteger(product)) throw new RangeError('amount too large for exact arithmetic');
  const predecessorMinor = divRound(product, scale * den);
  // The successor takes the remainder, so the two parts always sum to the award.
  const successorMinor = whole.payableMinor - predecessorMinor;
 
  return {
    method,
    predecessorDays,
    total: whole.payable,
    predecessor: formatMinor(predecessorMinor),
    successor: formatMinor(successorMinor),
    totalMinor: whole.payableMinor,
    predecessorMinor,
    successorMinor,
  };
}

Two decisions in that block are deliberate. SplitMethod has no default: a default would be a guess about a mechanism nobody has published, and it would sit inside every provision the receiving entity books. And the successor takes the remainder rather than its own rounded share, so the two parts always sum to exactly what the worker is owed. The worker's total is computed once, on continuous service, and never depends on the split.

presumedDueAtTransfer treats the presumed award as the full Article 84 award, with no resignation reduction. That is our reading: the Law presumes the award due on the transfer date and says nothing about a reason for leaving, because nobody left. Have a Saudi labour lawyer confirm it before you book a liability on it.

For the provision in Step 8 the change is one field: the receiving entity accrues from the original hire date. How the predecessor's share then reaches it, as a payment or an offset, is what the mechanism will settle. Leave follows the same rule. The thirty-day tier of Article 109 arrives after five continuous years, counted from the first day with the first employer.

Testing Your Implementation

Pin every statutory case as a named test. Each one of these has been an argument somewhere:

import test from 'node:test';
import assert from 'node:assert/strict';
import { endOfService } from './labour';
 
const gregorian = { wage: '10000', calendar: 'gregorian' } as const;
 
test('half a month for each of the first five years', () => {
  // The last day worked is 31 December 2023: exactly five years.
  const r = endOfService({ ...gregorian, start: '2019-01-01', end: '2023-12-31' });
  assert.equal(r.serviceDays, 1800);
  assert.equal(r.monthsOfWage, '2.50');
  assert.equal(r.full, '25000.00');
  assert.equal(r.payable, '25000.00'); // termination pays in full
});
 
test('the last day worked counts', () => {
  // One more day is five years and a day, which the Ministry of Justice
  // calculator prices at 25,027.78.
  const r = endOfService({ ...gregorian, start: '2019-01-01', end: '2024-01-01' });
  assert.equal(r.serviceDays, 1801);
  assert.equal(r.full, '25027.78');
});
 
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({ ...gregorian, start: '2018-01-01', end: '2024-12-31' });
  assert.equal(r.monthsOfWage, '4.50');
  assert.equal(r.full, '45000.00');
});
 
test('a month is thirty days, whatever February says', () => {
  // 1 February to 1 March is one month and one day: 31/360 of a year.
  const r = endOfService({ ...gregorian, start: '2019-02-01', end: '2019-03-01' });
  assert.equal(r.serviceDays, 31);
  assert.equal(r.full, '430.56');
});
 
test('fractions of a year are paid in proportion', () => {
  // Six calendar months is 180/360 of a year, in a leap year or not.
  const r = endOfService({ ...gregorian, start: '2024-01-01', end: '2024-06-30' });
  assert.equal(r.monthsOfWage, '0.25');
  assert.equal(r.full, '2500.00');
});
 
test('a Hijri contract is longer service on the same dates', () => {
  // The same five Gregorian years are 5 Hijri years, 1 month and 23 days.
  // The Ministry of Justice calculator, in Hijri mode, gives 26,472.22.
  const r = endOfService({ ...gregorian, calendar: 'hijri', start: '2019-01-01', end: '2023-12-31' });
  assert.equal(r.serviceDays, 1853);
  assert.equal(r.full, '26472.22');
});
 
test('resignation below two years pays nothing, but still accrues', () => {
  const r = endOfService({
    ...gregorian,
    start: '2024-01-01',
    end: '2025-05-31',
    reason: 'resignation',
  });
  assert.ok(r.fullMinor > 0, 'the award still accrues');
  assert.equal(r.payable, '0.00');
});
 
test('the same service settles at three different numbers', () => {
  const args = { ...gregorian, start: '2021-01-01', end: '2023-12-31' };
  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('the Art. 85 share is taken from the unrounded award', () => {
  // Two thirds of 26,472.22 rounds to 17,648.15 only if it is not rounded twice.
  const r = endOfService({
    ...gregorian,
    calendar: 'hijri',
    start: '2019-01-01',
    end: '2023-12-31',
    reason: 'resignation',
  });
  assert.equal(r.share, '2/3');
  assert.equal(r.payable, '17648.15');
});
 
test('bad input throws rather than paying a wrong number', () => {
  assert.throws(
    () => endOfService({ ...gregorian, start: '2025-01-01', end: '2024-01-01' }),
    RangeError,
  );
  assert.throws(() => endOfService({ ...gregorian, start: 'nope', end: '2025-01-01' }), TypeError);
});

The transfer rules from Step 9 get their own tests. Each figure is one of the worked examples above:

import test from 'node:test';
import assert from 'node:assert/strict';
import { endOfService, presumedDueAtTransfer, splitAtTransfer } from './labour';
 
const gregorian = { wage: '10000', calendar: 'gregorian' } as const;
// Four years with the predecessor, three with the successor.
const moved = { ...gregorian, start: '2018-01-01', transferDate: '2022-01-01', end: '2024-12-31' };
 
test('the clock does not restart at the transfer', () => {
  const whole = endOfService({ ...gregorian, start: '2018-01-01', end: '2024-12-31' });
  const before = endOfService({ ...gregorian, start: '2018-01-01', end: '2021-12-31' });
  const after = endOfService({ ...gregorian, start: '2022-01-01', end: '2024-12-31' });
  assert.equal(whole.full, '45000.00');
  // Restarted, the same seven years would earn 20,000 + 15,000.
  assert.equal(before.fullMinor + after.fullMinor, 3_500_000);
});
 
test('Decision 318 split by time', () => {
  const s = splitAtTransfer(moved, 'time');
  assert.equal(s.predecessorDays, 1440);
  assert.equal(s.predecessor, '25714.29');
  assert.equal(s.successor, '19285.71');
  assert.equal(s.predecessorMinor + s.successorMinor, s.totalMinor);
});
 
test('Decision 318 split by band', () => {
  const s = splitAtTransfer(moved, 'band');
  assert.equal(s.predecessor, '20000.00'); // four years at half a month
  assert.equal(s.successor, '25000.00'); // including both full-month years
  assert.equal(s.total, '45000.00');
});
 
test('a resignation takes its share from the continuous service', () => {
  // Seven years is two thirds under Art. 85; four years alone would be one third.
  const s = splitAtTransfer({ ...moved, reason: 'resignation' }, 'band');
  assert.equal(s.total, '30000.00');
  assert.equal(s.predecessor, '13333.33');
  assert.equal(s.successor, '16666.67');
});
 
test('Article 18 prices the presumed award on the wage at the transfer', () => {
  const presumed = presumedDueAtTransfer({
    start: '2018-01-01',
    transferDate: '2022-01-01',
    wageAtTransfer: '8000',
    calendar: 'gregorian',
  });
  assert.equal(presumed, 1_600_000); // 16,000: two months of the 8,000 wage
});
 
test('a transfer outside the service throws', () => {
  assert.throws(() => splitAtTransfer({ ...moved, transferDate: '2018-01-01' }, 'time'), RangeError);
  assert.throws(() => splitAtTransfer({ ...moved, transferDate: '2025-01-01' }, 'band'), RangeError);
});

Run with node --test. The last test in the first file matters more than it looks: a gratuity engine that returns NaN on a malformed date will happily write NaN into a payment file. Throwing is the correct behaviour for money.

Then cross-check a handful of real cases against the Ministry of Justice labour calculator, choosing the date type the contract uses. If your engine and the Ministry's disagree by even one halala, find out why before you ship. The cause is almost always the day-count convention, the calendar or the wage definition, and all three are better learned from your own tests than from an auditor.

Troubleshooting

The number is a few riyals off the Ministry's. Usually the last day worked is not counted, or the fraction of a year is days over 365 (or over that year's actual length) instead of months of thirty days. Search for / 365 and for any end date used as exclusive, and derive service from serviceDays360.

The number is far off the Ministry's for a long-serving employee. Check the date type. A Hijri year is about eleven days shorter than a Gregorian one, so a Hijri contract builds up roughly three per cent more service for every Gregorian year. Over ten years that is about four months.

The number is far lower than expected. Check the wage definition. If you are feeding basic salary where the contract's actual wage includes housing and transport allowances, you will be short by whatever proportion those allowances represent — commonly twenty-five to thirty-five per cent.

Year six pays less than year five. The tiers are being applied selectively rather than cumulatively — the code is charging one month per year for all years once service passes five, then somewhere subtracting. Check that atHalf and atFull sum to serviceDays.

A resignation at exactly two years pays nothing. An off-by-one at the threshold. Article 85 gives one third at two years of service, so the threshold comparison must be strictly-less-than, not less-than-or-equal.

A transferred worker's award is about a month short. The receiving entity opened a new record with the transfer date as the start. Under Article 18 and Decision 318 service is continuous: import the original hire date, and where Article 18 applies, keep the wage in force on the transfer date as well.

Totals drift by a few halalas across a large workforce. Rounding more than once. Round at output only, and derive every total from the unrounded rate.

Next Steps

Conclusion

The Saudi end-of-service award is a small piece of arithmetic wrapped in four articles of statute, and almost every implementation error is a misreading rather than a bug. Measure service in months of thirty days, on the contract's calendar, counting the last day. Apply the two tiers cumulatively. Keep the accrued award and the payable share as separate numbers so a reclassified departure is one field, not a recomputation. Settle on the actual wage. Keep the original start date when the employer changes. And accrue it monthly, so that Article 88's one-week deadline lands on a provision that already exists rather than on a discovery.

If you are carrying this calculation in a spreadsheet, or in an HR system whose figures do not agree with the Ministry's calculator, that gap is worth measuring before someone leaves. Tell us what your stack looks like and we will reconcile a sample of your real settlements against the statute — the disagreements are usually in the same three places, and they are cheaper to find now than in front of a labour committee.