writing/tutorial/2026/08
TutorialAug 29, 2026·28 min read

Saudi Pension Entitlement Engine in TypeScript: The 2024 Amendments, Two Calendars and Two Divisors

Build a GOSI pension engine that classifies a subscriber into one of three regimes, applies the retirement-age ladder introduced on 3 July 2024, and prices the pension with the 600/480 accrual split. Includes the calendar bug that overstates retirement age by nearly two years.

Search for how a Saudi pension is calculated and the most-shared answer you will find is this:

Last basic salary × months of service ÷ 480

It has been retweeted thousands of times. It appears, in slightly different words, in the free retirement calculator currently ranking third in Saudi search results. And it is wrong in three separate ways at once.

It is wrong about the wage: the General Organization for Social Insurance does not use your last basic salary, it uses the mean of your last twenty-four contributory wages — and it caps that mean. It is wrong about the divisor: 480 applies only to service after 1/1/1422H, and service before that date accrues at 600. And it is silent about the thing that changed most recently — the amendments to the Social Insurance Law that took effect on 27/12/1445H, Wednesday 3 July 2024, which moved the statutory retirement age off a fixed number and onto a ladder.

Each of those is a bug you can ship. Together they are the difference between telling someone they retire at sixty on 5,000 riyals a month and telling them they retire at sixty-one and eight months on 4,750.

This tutorial builds the engine properly.

What You'll Build

A TypeScript module that takes a subscriber's dates and wage history and returns a defensible entitlement assessment:

  • Regime classification — three populations, not two, decided by two conditions that must both hold
  • Statutory retirement age — a Hijri constant for one regime, a Gregorian ladder for another, a flat 65 for the third
  • Early-retirement qualification — a second ladder, from 300 to 360 months
  • The wage base — a capped 24-month mean, or the mean of the highest 180 months, depending on regime
  • The pension — split across the 600 and 480 accrual rates at the 1/1/1422H boundary

Every rule is covered by a test, and one of those tests reproduces GOSI's own published worked example to the riyal.

Prerequisites

  • Node.js 20+ and TypeScript 5+
  • Familiarity with Intl.DateTimeFormat and non-Gregorian calendars
  • No GOSI API credentials — this is a pure calculation engine you can run offline

You should also read our GOSI contribution engine tutorial if you have not. That one handles contributions going in. This one handles benefits coming out, and the two share a subscriber model but almost nothing else.

Step 1: Three Regimes, Not Two

The single most common architectural mistake here is a boolean. Engineers read that the law changed in July 2024 and write isNewLaw. There are three populations, and the middle one is the one that breaks a boolean.

The Council of Ministers restricted the new Social Insurance Law to new entrants to the labour market with no prior contribution periods. Existing subscribers continue under the old rules — except for the provisions concerning the statutory retirement age and the periods qualifying for a pension before that age. That exception is the whole problem. It creates a middle group that is governed by the old benefit formula and the new age rules simultaneously.

And membership in that middle group is not automatic. It requires both of two conditions, measured on 3 July 2024:

  • the subscriber is under 50 Hijri years old, and
  • the subscriber has fewer than 240 months of prior contributions

Fail either one and nothing changes for you at all.

export const REFORM_DATE = new Date('2024-07-03T00:00:00Z');       // 27/12/1445H
export const ACCRUAL_SPLIT_DATE = new Date('2001-03-26T00:00:00Z'); // 1/1/1422H
 
export type Regime = 'legacy' | 'amended' | 'new';
 
export function classifyRegime(s: Subscriber): Regime {
  const joinedAfterReform =
    s.firstRegisteredAt.getTime() >= REFORM_DATE.getTime();
 
  // The new law is for genuine new entrants. A rejoiner with prior service
  // is not one, however recent their latest registration is.
  if (joinedAfterReform && s.monthsAtReform === 0) return 'new';
 
  const ageHijriAtReform = hijriYearsBetween(s.dateOfBirth, REFORM_DATE);
  const covered = ageHijriAtReform < 50 && s.monthsAtReform < 240;
  return covered ? 'amended' : 'legacy';
}

Note the second half of the new test. Someone who worked for six years, left, and registered again in September 2024 has a post-reform registration date and is emphatically not a new entrant. Keying only on the date silently moves them onto a scheme with a different wage base and a 65-year retirement age.

Both conditions, or neither. A 47-year-old with 21 years of contributions stays on the legacy rules. A 51-year-old with 8 years stays on the legacy rules. Only the subscriber who is young and short of 240 months moves onto the ladder.

Step 2: The Calendar Bug That Costs Two Years

Under the legacy rules the statutory retirement age is sixty years. Almost every implementation writes some version of this:

// WRONG — compares a Gregorian age against a Hijri threshold
const age = (Date.now() - dateOfBirth.getTime()) / (365.2425 * 86400000);
const eligible = age >= 60;

The legacy age of sixty is sixty Hijri years. A Hijri year is about 354.37 days. Sixty of them is roughly 21,262 days, which is about 58.2 Gregorian years. Comparing a Gregorian age against the number 60 therefore holds the subscriber back by nearly two years past the date they actually qualified.

This is not a rounding quibble — it is why the amended ladder starts where it does. When GOSI published the new table it expressed the ages in Gregorian years, and the "no change" bracket at the top comes out at 58, not 60. The official summary describes the resulting band as running between 58 and 65 Gregorian years. Those two facts only reconcile once you know that the old sixty was Hijri.

So do not convert. Evaluate each regime on the calendar its rule is written in:

const HIJRI = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', {
  year: 'numeric', month: 'numeric', day: 'numeric', timeZone: 'UTC',
});
 
function hijriParts(date: Date) {
  const p = Object.fromEntries(
    HIJRI.formatToParts(date).map((x) => [x.type, x.value]),
  );
  return { y: Number(p.year), m: Number(p.month), d: Number(p.day) };
}
 
/** Completed Hijri years between two dates. */
export function hijriYearsBetween(from: Date, to: Date): number {
  const a = hijriParts(from), b = hijriParts(to);
  let years = b.y - a.y;
  if (b.m < a.m || (b.m === a.m && b.d < a.d)) years -= 1;
  return years;
}

Use islamic-umalqura specifically. It is the Umm al-Qura calendar, the one the Saudi state actually runs on; the generic islamic calendar is a tabular approximation and drifts by a day, which is enough to move a birthday across a month boundary.

Step 3: The Retirement-Age Ladder

For the amended regime, GOSI publishes retirement age as a function of the subscriber's Gregorian age on 3 July 2024. It rises by four months for every year of youth:

Age on 3 July 2024Retirement age
48.5 and aboveunchanged
48 to under 48.558 years 4 months
47 to under 4858 years 8 months
46 to under 4759 years
43 to under 4460 years
40 to under 4161 years
29 to under 3064 years 8 months
under 2965 years

Rather than hard-code twenty brackets, derive them. The whole table collapses into one expression:

/** Returns the statutory retirement age in whole months. */
export function amendedRetirementAgeMonths(gregorianAgeAtReform: number): number {
  const a = gregorianAgeAtReform;
  if (a >= 48.5) return 58 * 12;           // unchanged
  if (a >= 48) return 58 * 12 + 4;         // 58y 4m
  if (a < 29) return 65 * 12;              // floor of the ladder
  return 704 + (47 - Math.floor(a)) * 4;   // +4 months per year of youth
}

Then assert the derivation against the published table rather than trusting it. The constant 704 is 58 years 8 months, the value for the 47-year bracket, and every other row falls out of the arithmetic.

Step 4: The Second Ladder — Early Retirement

Early retirement has its own transition, and it moves in twelve-month steps rather than four. It is keyed to contribution length on 3 July 2024, not to age:

Contributions on 3 July 2024Months required
240 months and above300
19 to under 20 years300
18 to under 19 years312
15 to under 16 years348
under 15 years360
export function earlyRetirementMonthsRequired(
  regime: Regime,
  monthsAtReform: number,
): number {
  if (regime === 'legacy') return 300;
  if (regime === 'new') return 360;
 
  const yearsAtReform = Math.floor(monthsAtReform / 12);
  if (yearsAtReform >= 19) return 300;
  return Math.min(360, 300 + (19 - yearsAtReform) * 12);
}

The widely-shared summary of this change on social media states the 19-years-and-above case and the 15-to-19 case, and stops. It omits the floor: a subscriber with fewer than fifteen years on the reform date needs a full 360 months. That is the population most likely to be planning around this rule and least likely to have been told the truth about it.

Watch the cliff. A subscriber with 18 years and 11 months on 3 July 2024 needs 312 months. One month more of service before that date would have put them in the 19-year bracket and cost them 300. A single month of backdated registration moves the finish line by a year.

Step 5: The Wage Base

Two regimes, two entirely different aggregations. This is why a single averageWage() helper cannot serve the engine.

The legacy and amended regimes use the mean of the last twenty-four contributory wages. But that mean is capped: the regulation limits it to 150% of the contribution wage at the start of the last five years. It exists to stop a wage being inflated shortly before retirement to lift a lifetime pension.

export function averageWageLegacy(
  last24Wages: number[],
  wageAtStartOfLast5Years: number,
): number {
  if (last24Wages.length !== 24) {
    throw new Error(`expected 24 monthly wages, received ${last24Wages.length}`);
  }
  const mean = last24Wages.reduce((a, b) => a + b, 0) / 24;
  const cap = wageAtStartOfLast5Years * 1.5;
  return Math.min(mean, cap);
}

The new regime does something structurally different: it takes the mean of the highest 180 months of insurable wage across the entire career.

export function averageWageNew(allMonthlyWages: number[]): number {
  if (allMonthlyWages.length < 180) {
    throw new Error(
      `need at least 180 insurable months, received ${allMonthlyWages.length}`,
    );
  }
  const top = [...allMonthlyWages].sort((a, b) => b - a).slice(0, 180);
  return top.reduce((a, b) => a + b, 0) / 180;
}

The consequence is worth stating plainly, because it inverts a piece of retirement folk wisdom. Under the legacy base, ending your career on a reduced wage — dropping to part-time, stepping down a grade — cuts your pension directly, because the last twenty-four months are the base. Under the new base it does almost nothing, because those low months simply fail to make the top 180. Advice built on the old regime actively misleads anyone on the new one.

Throwing on a wrong-length array matters more than it looks. Both functions would happily average whatever they were handed, and a payroll export that quietly returns 23 months produces a plausible number that is wrong by a few percent — the hardest class of bug to find later.

Step 6: Two Divisors, Split at 1/1/1422H

The Social Insurance Law issued under Royal Decree M/33 came into force on 1/1/1422H, which is 26 March 2001. Service is therefore split into a prior period and a subsequent period, and the two accrue at different rates: the prior period at one-fiftieth of the average wage per year, the subsequent period at one-fortieth. Per month, that is 600 and 480.

export function splitServiceMonths(serviceStart: Date, serviceEnd: Date) {
  const total = gregorianMonthsBetween(serviceStart, serviceEnd);
  if (serviceStart >= ACCRUAL_SPLIT_DATE) {
    return { priorMonths: 0, subsequentMonths: total };
  }
  if (serviceEnd <= ACCRUAL_SPLIT_DATE) {
    return { priorMonths: total, subsequentMonths: 0 };
  }
  const priorMonths = gregorianMonthsBetween(serviceStart, ACCRUAL_SPLIT_DATE);
  return { priorMonths, subsequentMonths: total - priorMonths };
}
 
export function pensionLegacy(
  averageWage: number,
  priorMonths: number,
  subsequentMonths: number,
): number {
  return averageWage * (priorMonths / 600 + subsequentMonths / 480);
}
 
export function pensionNew(averageWage: number, contributionMonths: number): number {
  return averageWage * contributionMonths * (0.0225 / 12);  // 2.25% per year
}

GOSI publishes a worked example that pins this down exactly: 60 months of prior service producing 1,000 riyals, and 180 months of subsequent service producing 3,750. Both solve to the same average wage of 10,000, which is a useful thing to notice — it means the example is internally consistent and can be used as a fixture.

Now measure the cost of the mistake everyone makes. That same 240-month career, run entirely at 480 as the viral formula instructs:

correct:  10,000 × (60/600 + 180/480) = 4,750 SAR
naive:    10,000 × (240/480)          = 5,000 SAR

Two hundred and fifty riyals a month. Every month, for the rest of the subscriber's life. The error is invisible in the output because 5,000 is a perfectly reasonable-looking pension.

Step 7: Assembling the Assessment

export function assessPension(s: Subscriber) {
  const regime = classifyRegime(s);
  const totalMonths = gregorianMonthsBetween(s.serviceStart, s.assessmentDate);
 
  const statutoryAgeMonths =
    regime === 'new' ? 65 * 12
    : regime === 'amended'
      ? amendedRetirementAgeMonths(gregorianAgeAtReform(s.dateOfBirth))
      : null;  // legacy is 60 HIJRI years, not a Gregorian constant
 
  const reachedStatutoryAge =
    regime === 'legacy'
      ? hijriYearsBetween(s.dateOfBirth, s.assessmentDate) >= 60
      : gregorianMonthsBetween(s.dateOfBirth, s.assessmentDate)
          >= statutoryAgeMonths!;
 
  const earlyRequired = earlyRetirementMonthsRequired(regime, s.monthsAtReform);
  const minimumForAgePension = regime === 'new' ? 180 : 120;
 
  let entitlement: 'age_pension' | 'early_pension' | 'lump_sum_only';
  if (reachedStatutoryAge && totalMonths >= minimumForAgePension) {
    entitlement = 'age_pension';
  } else if (totalMonths >= earlyRequired) {
    entitlement = 'early_pension';
  } else {
    entitlement = 'lump_sum_only';
  }
 
  let monthlyPension = 0;
  if (entitlement !== 'lump_sum_only') {
    if (regime === 'new') {
      monthlyPension = pensionNew(averageWageNew(s.allMonthlyWages!), totalMonths);
    } else {
      const { priorMonths, subsequentMonths } =
        splitServiceMonths(s.serviceStart, s.assessmentDate);
      const wage = averageWageLegacy(s.last24Wages!, s.wageAtStartOfLast5Years!);
      monthlyPension = pensionLegacy(wage, priorMonths, subsequentMonths);
    }
  }
 
  return { regime, statutoryAgeMonths, totalMonths,
           earlyRetirementMonthsRequired: earlyRequired,
           entitlement, monthlyPension };
}

Returning statutoryAgeMonths: null for the legacy regime is deliberate. There is no correct Gregorian constant to put there, and inventing one — 58, 58.2, 60 — is how the calendar bug gets reintroduced by the next person to touch the file. A null forces the caller to ask which calendar they are in.

Note also that the classification decides the entitlement, and the entitlement decides whether there is a pension at all. A subscriber short of both bars receives a lump-sum compensation rather than a monthly pension, and an engine that returns a pension figure for them has answered a question nobody asked.

Testing Your Implementation

Test against published values, not against your own reimplementation of the same arithmetic. The three tests that earn their place:

test('the two anchor dates are the Hijri dates the law names', () => {
  const f = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura',
    { year: 'numeric', month: 'numeric', day: 'numeric', timeZone: 'UTC' });
  assert.equal(f.format(REFORM_DATE), '12/27/1445 AH');
  assert.equal(f.format(ACCRUAL_SPLIT_DATE), '1/1/1422 AH');
});
 
test('60 Hijri years is about 58.2 Gregorian years, not 60', () => {
  const born = new Date('1960-01-01T00:00:00Z');
  let d = new Date('2016-01-01T00:00:00Z');
  while (hijriYearsBetween(born, d) < 60) d = new Date(d.getTime() + 86400000);
  const gregorianYears = (d.getTime() - born.getTime()) / (365.2425 * 86400000);
  assert.ok(gregorianYears > 58.1 && gregorianYears < 58.3);
});
 
test("GOSI's published worked example reproduces exactly", () => {
  assert.equal(pensionLegacy(10000, 60, 0), 1000);
  assert.equal(pensionLegacy(10000, 0, 180), 3750);
  assert.equal(pensionLegacy(10000, 60, 180), 4750);
});

Add a property test over the ladder as well — for every age from 20 to 60 in quarter-year steps, the retirement age must stay inside the 58-to-65 band and must never decrease as the subscriber gets younger. That single test catches an off-by-one in the bracket arithmetic that no amount of spot-checking will.

The full suite backing this tutorial is seventeen tests, and all seventeen pass before any of this code goes near a payroll system.

Troubleshooting

Retirement dates land about 1.8 years late. You are comparing a Gregorian age against 60. See Step 2.

A rejoiner is on the wrong scheme. Your new test keys only on registration date. It must also require zero prior contribution months.

The pension is a few percent high for long-serving staff. You are dividing the whole career by 480. Split at 26 March 2001.

A late promotion produces an implausible pension. You have not applied the 150% cap against the wage at the start of the last five years.

Hijri dates are off by one day. You used the islamic calendar rather than islamic-umalqura.

What This Engine Deliberately Does Not Do

Being explicit about the boundary matters more in compliance code than in most software:

  • The early-retirement reduction under the new regime. The new law permits retirement up to ten years before statutory age with 360 months of contributions, with a reduction applied to the pension. The reduction factors are not something we were able to confirm from a primary source, so the engine qualifies the subscriber and stops short of discounting the figure. Do not guess these.
  • Dependants' supplements, non-occupational disability minimums and the minimum-pension floor.
  • Period consolidation between the Civil Service Pension scheme and Social Insurance, which has its own transfer rules.

Anything you cannot cite, leave out and surface as an explicit gap. A pension engine that returns a confident wrong number is considerably worse than one that returns a range and a note.

Next Steps

Conclusion

The reason this domain produces so much confidently wrong software is that every individual rule looks simple. Sixty years old. Divide by 480. Average your salary. Each is close enough to true that the output never looks alarming, and none of them survives contact with the actual regulation.

The three things worth carrying away: the statutory age under the legacy rules is measured in Hijri years, so sixty of them arrive at about 58.2 Gregorian; service is split at 1/1/1422H into two accrual rates, not one; and the July 2024 amendments created a middle population governed by old benefit rules and new age rules at the same time, which no boolean can represent.

Build the classification first and the arithmetic second. The arithmetic is the easy half.


Running payroll or HR systems in Saudi Arabia and unsure whether your end-of-service and pension figures reflect the 2024 amendments? Talk to us — we will review the calculation logic against the published rules and tell you where the gaps are.