writing/tutorial/2026/08
TutorialAug 18, 2026·24 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, anniversary-based service, and the monthly accrual that makes it a provision rather than a surprise.

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 compute service as days divided by 365, 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.

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.

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 in anniversaries, not in days over 365

Here is the bug that survives code review, because the code looks obviously right:

// WRONG — and it will pass every test that uses round numbers.
const years = daysBetween(start, end) / 365;

Five calendar years that contain a leap day is 1,826 days. Divided by 365 that reads as 5.0027 years. On a wage of 10,000 riyals the Article 84 award is 25,000.00 — but the drifted figure pays 25,013.70. Thirteen riyals is nothing; the fact that your engine disagrees with the Ministry's own calculator by a non-zero amount is not nothing, because it is the first thing an auditor checks and the first thing a departing employee screenshots.

The award is expressed in years of service, and a year of service is an anniversary. Measure it as completed anniversaries plus a proportion of the year in progress:

const MS_PER_DAY = 86_400_000;
/** Years held in ten-thousandths, so fractions stay exact in integers. */
const YEAR_UNIT = 10_000;
 
/** The calendar anniversary `years` after `from`. */
function anniversary(from: Date, years: number): Date {
  return new Date(
    Date.UTC(from.getUTCFullYear() + years, from.getUTCMonth(), from.getUTCDate()),
  );
}
 
function serviceUnitsBetween(from: Date, to: Date): number {
  let completed = 0;
  while (anniversary(from, completed + 1) <= to) completed++;
 
  const last = anniversary(from, completed);
  const next = anniversary(from, completed + 1);
  const yearLength = Math.round((next.getTime() - last.getTime()) / MS_PER_DAY);
  const remainder = Math.round((to.getTime() - last.getTime()) / MS_PER_DAY);
 
  return completed * YEAR_UNIT + divRound(remainder * YEAR_UNIT, yearLength);
}

Two details are load-bearing. Dates are parsed as UTC midnight, so a server in Riyadh and a server in Frankfurt agree on which day someone's service ended. And the fraction of the year in progress is measured against that specific year's length, so a partial year straddling February 2028 is divided by 366 rather than 365.

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 ten-thousandths of a year, the two tiers are a split rather than a branch:

const STEP_YEARS = 5;
 
const serviceUnits = serviceUnitsBetween(from, to);
const stepUnits = STEP_YEARS * YEAR_UNIT;
 
const unitsAtHalf = Math.min(serviceUnits, stepUnits);
const unitsAtFull = Math.max(0, serviceUnits - stepUnits);
 
// Half a month per year for the first five, a whole month thereafter.
const monthUnits = divRound(unitsAtHalf, 2) + unitsAtFull;
const fullMinor = divRound(monthly * monthUnits, YEAR_UNIT);

monthUnits is months-of-wage in the same ten-thousandths scale, which keeps the whole computation in integers until the final division. Sanity-check it against the two cases everyone knows:

  • Exactly five years: unitsAtHalf is 50,000, unitsAtFull is 0, monthUnits is 25,000 — two and a half months. On 10,000 riyals: 25,000.00.
  • Exactly seven years: 50,000 at half plus 20,000 at full gives 45,000 units — four and a half months. On 10,000 riyals: 45,000.00.

And a partial first year, which is where proportionality shows up: 182 days into a 366-day year is 0.4973 of a year at the half-month rate, giving 0.25 months and an award of 2,487.00.

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_UNIT);
  if (serviceUnits < twoY) [numerator, denominator] = [0, 1];
  else if (serviceUnits < fiveY) [numerator, denominator] = [1, 3];
  else if (serviceUnits < tenY) [numerator, denominator] = [2, 3];
}
 
const payableMinor = divRound(fullMinor * numerator, 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 = {
  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 EndOfServiceInput = {
  start: string;
  end: string;
  /** Last actual monthly wage — basic plus every regular allowance. */
  wage: string | number;
  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.

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, Article 75. Thirty days on a monthly-paid indefinite contract where notice was not served.
  • 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.

You can check any of these against our Saudi labour calculator, which runs this exact library, or against the leave balance calculator for the Article 109 side alone.

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 },
  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,
    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 }>,
  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.

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';
 
test('half a month for each of the first five years', () => {
  const r = endOfService({ start: '2019-01-01', end: '2024-01-01', wage: '10000' });
  assert.equal(r.monthsOfWage, '2.50');
  assert.equal(r.full, '25000.00');
  assert.equal(r.payable, '25000.00'); // termination pays in full
});
 
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({ start: '2018-01-01', end: '2025-01-01', wage: '10000' });
  assert.equal(r.monthsOfWage, '4.50');
  assert.equal(r.full, '45000.00');
});
 
test('fractions of a year are paid in proportion', () => {
  // 182 days into a 366-day year is 0.4973 of a year, at the half-month rate.
  const r = endOfService({ start: '2024-01-01', end: '2024-07-01', wage: '10000' });
  assert.equal(r.monthsOfWage, '0.25');
  assert.equal(r.full, '2487.00');
});
 
test('resignation below two years pays nothing, but still accrues', () => {
  const r = endOfService({
    start: '2024-01-01',
    end: '2025-06-01',
    wage: '10000',
    reason: 'resignation',
  });
  assert.ok(Number(r.full) > 0, 'the award still accrues');
  assert.equal(r.payable, '0.00');
});
 
test('the same service settles at three different numbers', () => {
  const args = { start: '2021-01-01', end: '2024-01-01', wage: '10000' } as const;
  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('a leap day does not inflate the gratuity', () => {
  // days/365 would read five calendar years spanning a leap day as 5.0027
  // years and pay 25,013.70. Years of service are anniversaries.
  const r = endOfService({ start: '2019-01-01', end: '2024-01-01', wage: '10000' });
  assert.equal(r.serviceDays, 1826);
  assert.equal(r.full, '25000.00');
});
 
test('the Art. 85 steps are calendar anniversaries, not two times 365', () => {
  // 731 days is two years across a leap year — the one-third step must fire.
  const r = endOfService({
    start: '2022-03-01',
    end: '2024-03-01',
    wage: '10000',
    reason: 'resignation',
  });
  assert.equal(r.serviceDays, 731);
  assert.equal(r.share, '1/3');
});
 
test('bad input throws rather than paying a wrong number', () => {
  assert.throws(
    () => endOfService({ start: '2025-01-01', end: '2024-01-01', wage: '10000' }),
    RangeError,
  );
  assert.throws(() => endOfService({ start: 'nope', end: '2025-01-01', wage: '1' }), TypeError);
});

Run with node --test. The last test 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 HRSD end-of-service calculator and the Ministry of Justice labour calculator. If your engine and the Ministry's disagree by even one halala, find out why before you ship — the discrepancy is almost always the day-count convention or the wage definition, and both are worth knowing about before an auditor tells you.

Troubleshooting

The number is slightly higher than the Ministry's. Almost always days divided by 365 somewhere in the service calculation. Search for / 365 and replace with anniversary arithmetic.

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 unitsAtHalf and unitsAtFull sum to serviceUnits.

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.

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 anniversaries. 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. 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.