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

Saudi Termination Settlement Engine in TypeScript (Art. 77)

Build the termination branch of a Saudi payroll system in TypeScript: the asymmetric Article 75 notice periods, Article 76 pay in lieu, Article 77 compensation with its two-month floor, service measured in thirty-day months on the contract's calendar, the Article 55 conversion the contract triggers rather than the worker, the nine Article 80 cases that kill the award, Article 81 constructive dismissal, and the Article 88 settlement deadline.

Most Saudi payroll systems can compute an end-of-service award. Very few can decide whether the award is owed at all.

That decision is the expensive one. The same worker, the same salary, the same seven years of service produce four completely different final settlements depending on how the employment relationship ended — and the difference between the cheapest and the most expensive branch is routinely six figures in riyals. An employer who dismissed someone under Article 80 without the written warning the article requires does not simply lose the Article 80 defence. The dismissal becomes unlawful, the full award under Article 84 comes back, Article 77 compensation is added on top, and pay in lieu of the Article 75 notice period is added on top of that.

This tutorial builds that decision as code: a termination settlement engine that classifies the exit first and prices it second. It is the branch that sits above the end-of-service gratuity engine and decides which scale that engine should be called with.

Prerequisites

  • Node.js 20 or later and TypeScript 5.x
  • Comfort with discriminated unions and exhaustive switch statements
  • The published text of the Saudi Labour Law open in another tab (laws.boe.gov.sa). Article numbers in this tutorial are given so you can check every rule against the source — do that, because the law has been amended repeatedly and your compliance date matters more than this article's
  • Familiarity with integer money arithmetic. If you have not read it, the overtime pay engine covers the halala representation this tutorial reuses

What You'll Build

A pure, dependency-free module exposing one entry point:

settleTermination(input: TerminationInput): Settlement

It returns an itemised settlement — one line per statutory head, each carrying the article it comes from — plus a classification explaining why the exit was priced that way, and the date by which Article 88 requires the money to be paid.

Step 1: Classify the exit before you price anything

The single most common architectural mistake in this domain is a function called calculateEndOfService(salary, years). It bakes in the assumption that the exit was ordinary, and every unlawful-termination case then has to be handled by a human overriding the system.

Model the exit as data instead. There are five ways an employment relationship ends that matter to money:

/** Article 74 lists the lawful causes of termination that carry no fault. */
type Article74Cause =
  | 'mutual-written-agreement'
  | 'fixed-term-expiry'
  | 'retirement-age'
  | 'force-majeure'
  | 'establishment-closure'
  | 'activity-cessation';
 
type Exit =
  /** Employer ends an indefinite contract for a valid reason, with notice (Art. 75). */
  | { by: 'employer'; ground: 'article-75-valid-reason'; reasonInWriting: boolean }
  /** Employer ends without a lawful cause — the Article 77 exposure. */
  | { by: 'employer'; ground: 'no-lawful-cause' }
  /** Employer dismisses for one of the nine Article 80 cases. */
  | { by: 'employer'; ground: 'article-80'; case: Article80Case; evidence: Article80Evidence }
  /** A neutral Article 74 ending: expiry, retirement, closure, force majeure. */
  | { by: 'neither'; ground: 'article-74'; cause: Article74Cause }
  /** The worker resigns in the ordinary way. */
  | { by: 'worker'; ground: 'resignation' }
  /** The worker leaves under Article 81 — priced as an employer termination. */
  | { by: 'worker'; ground: 'article-81'; case: Article81Case; noticeGiven: boolean };

Every branch below is driven by this union. When the law is amended, you add a variant and the compiler tells you every place that needs a decision — which is the entire reason to model it this way rather than with booleans named isUnfairDismissal.

Step 2: Two wage bases and one calendar, none of them the obvious one

Article 2 of the Labour Law defines the wage (الأجر) as the basic wage plus all increases and allowances due to the worker for their work. The basic wage (الأجر الأساسي) is the contractual figure before those additions.

Article 77 compensation, Article 76 pay in lieu of notice and the Article 84 award are all computed on the actual wage, not the basic. Systems that price them off the basic wage understate every settlement by whatever housing and transport allowances the worker receives — which in Saudi practice is commonly 25% to 35% of the total.

Article 2 also defines the month as thirty days unless the contract or the work regulation says otherwise, which makes the statutory year 360 days rather than 365 or 365.25. And Article 10 settles the calendar: «تحسب جميع المدد والمواعيد المنصوص عليها في هذا النظام بالتقويم الهجري، ما لم ينص في عقد العمل أو لائحة تنظيم العمل على خلاف ذلك» — every period in the law runs on the Hijri calendar unless the contract says otherwise. A Hijri year is about 354 days, so a contract that is silent on the point accrues service roughly 3% faster than a Gregorian reading of it.

Do not rebuild that arithmetic here. The end-of-service gratuity engine exports a serviceDays360 that reproduces the Ministry of Justice labour calculator to the halala across 45 fixtures. Import it, so the two engines can never disagree about how long someone worked:

import { serviceDays360, parseDate, type CalendarBasis } from './end-of-service';
 
/** All money is integer halalas. 1 SAR = 100 halalas. Floats do not belong here. */
type Halalas = number;
 
const MS_PER_DAY = 86_400_000;
 
type Wage = {
  /** الأجر الأساسي — the contractual basic. */
  basic: Halalas;
  /** Housing, transport and any other allowance due for the work itself. */
  allowances: Halalas;
};
 
/** الأجر — Article 2. This is the base for Articles 76, 77 and 84. */
const actualMonthlyWage = (w: Wage): Halalas => w.basic + w.allowances;
 
/**
 * Labour entitlements are priced per calendar day on a 30-day month, not on the
 * actual length of the month. A 31-day month does not make the day rate cheaper.
 */
const dailyWage = (w: Wage): Halalas => divideRoundHalfUp(actualMonthlyWage(w), 30);
 
function divideRoundHalfUp(numerator: number, denominator: number): number {
  return Math.floor((numerator * 2 + denominator) / (denominator * 2));
}

Commission and piece rates. Article 86 excludes components that vary with output from the straightforward "last wage" rule, requiring an average instead. If your workforce is commissioned, the wage passed into this engine must already be that average — resolve it upstream, and keep the engine pure.

Step 3: Contract type decides the shape of the Article 77 award

Article 77 prices unlawful termination two different ways, and the contract type is the switch:

  • Indefinite contract: 15 days' wage for each year of service
  • Fixed-term contract: the wage for the remaining period of the contract

Both are then subject to a floor: the compensation may not be less than two months' wage. That floor is the part implementations forget, and it is the part that decides most short-service cases. A worker dismissed unlawfully after six months on an indefinite contract computes to 7.5 days — and receives two months.

type Contract =
  | { kind: 'indefinite' }
  | {
      kind: 'fixed';
      /** ISO date this term began. Article 55 measures the contract, not the person. */
      startedOn: string;
      /** ISO date the term was due to end. */
      endsOn: string;
      /** How many times this fixed term has already been renewed. */
      renewals: number;
      /** Article 55/2 only runs on a term that carries a renewal clause. */
      hasRenewalClause: boolean;
      /** Both limbs of Article 55 require that the parties kept performing. */
      stillPerforming: boolean;
    };

There is a trap under the fixed-term branch, and it is not the one most implementations guard against. Article 55 has two separate limbs, and the difference between them is the renewal clause:

  • 55/1 — a term with no renewal clause expires, «فإذا استمر طرفاه في تنفيذه عُد العقد مجدداً لمدة غير محددة». The parties carried on, so the contract is now indefinite. Nothing else is required: not four years, not three renewals.
  • 55/2 — a term that does carry a renewal clause renews for the agreed period instead, until it has been renewed three consecutive times or the original term plus the renewals reaches four years, «أيهما أقل» — whichever comes first — and the parties kept performing.

Both limbs measure the contract, not the worker. The four years in 55/2 are «مدة العقد الأصلي مع مدة التجديد»: the term's own length. A twenty-year veteran who signs a fresh two-year term is on a fixed-term contract, and an engine that feeds it the worker's service length will convert that contract on the day it is signed. That error is expensive in both directions — it prices a lawful non-renewal as an Article 77 exposure.

Article 37 puts non-Saudis outside all of this. Article 12 of the Implementing Regulation is blunt about it: a non-Saudi's contract is fixed-term «مهما طالت مدته، أو مدد تجديده، وفي جميع الأحوال لا يتحول إلى عقد غير محدد المدة حتى لو اتفق الطرفان على ذلك» — it does not convert even if both parties agree that it should.

const FIXED_TERM_RENEWAL_LIMIT = 3;
const FIXED_TERM_YEARS_LIMIT = 4;
 
/** Article 2 defines the month as thirty days, so the statutory year is 360. */
const DAYS_PER_YEAR = 360;
 
/**
 * Article 55 — normalise before pricing. Two separate limbs, and both measure the
 * contract rather than the worker:
 *
 * 55/1 — a term with no renewal clause runs out, the parties keep performing, and the
 *        contract is deemed renewed for an indefinite period.
 * 55/2 — a term that does carry a renewal clause renews for the agreed period, until it
 *        has been renewed three consecutive times or the original term plus the renewals
 *        reaches four years, whichever comes first, and the parties keep performing.
 *
 * Service with the same employer under earlier contracts does not convert the current
 * one. Article 37, and Article 12 of the Implementing Regulation, put a non-Saudi
 * outside this altogether.
 */
function normaliseContract(
  contract: Contract,
  asOf: string,
  calendar: CalendarBasis,
  isSaudi: boolean,
): Contract {
  if (contract.kind !== 'fixed' || !isSaudi || !contract.stillPerforming) return contract;
 
  const expired = parseDate(asOf, 'terminatedOn') > parseDate(contract.endsOn, 'contract.endsOn');
  if (!contract.hasRenewalClause) {
    return expired ? { kind: 'indefinite' } : contract;
  }
 
  const termYears =
    serviceDays360(
      parseDate(contract.startedOn, 'contract.startedOn'),
      parseDate(contract.endsOn, 'contract.endsOn'),
      calendar,
    ) / DAYS_PER_YEAR;
 
  const converted =
    contract.renewals >= FIXED_TERM_RENEWAL_LIMIT || termYears >= FIXED_TERM_YEARS_LIMIT;
  return converted ? { kind: 'indefinite' } : contract;
}

Step 4: Article 75 notice, and Article 76 when it was not given

Article 75 requires a party ending an indefinite contract to give written notice — and the two parties do not owe the same notice. Where the wage is paid monthly, 75/1/أ gives the worker «ثلاثين يوماً» and 75/1/ب gives the employer «ستين يوماً». Where the wage is not paid monthly, 75/2 puts both parties on thirty. A single noticeDaysRequired(frequency) that returns 60 for every monthly-wage exit bills a resigning worker for two months of notice they never owed.

Article 76 makes the party who skipped the notice pay the other the wage for the same period, «ما لم يتفق الطرفان على أكثر من ذلك» — unless they agreed on more. So a contractual figure above the statutory one binds; one below it does not, because Article 8 voids any reduction of a statutory right.

Two further details decide correctness. First, pay in lieu is computed on the actual wage, at the 30-day daily rate from Step 2 — so 60 days is two months' wage, not "two payroll runs". Second, it is owed independently of Article 77. A worker dismissed unlawfully and walked out the same day is owed both: two months minimum under Article 77 and 60 days under Article 76. Systems that treat them as alternatives halve every settlement of this shape.

type PayFrequency = 'monthly' | 'other';
type TerminatingParty = 'employer' | 'worker';
 
/**
 * Article 75 — the notice period is not symmetric. Where the wage is paid monthly the
 * employer owes sixty days (75/1/b) and the worker owes thirty (75/1/a). Where it is not
 * paid monthly, 75/2 puts both parties on thirty.
 */
const noticeDaysRequired = (f: PayFrequency, by: TerminatingParty): number =>
  f === 'monthly' && by === 'employer' ? 60 : 30;
 
/**
 * Article 76 — the shortfall between the notice the law requires of the party who ended
 * the contract and the notice actually served. Never negative: serving longer than
 * required earns nothing back. An agreed figure above the statutory one stands; one
 * below it does not.
 */
function noticeInLieu(
  wage: Wage,
  frequency: PayFrequency,
  by: TerminatingParty,
  daysServed: number,
  contract: Contract,
  agreedNoticeCompensation?: Halalas,
): Halalas {
  // Notice is an indefinite-contract obligation. A fixed term ending on its own
  // date needs no notice — the end date was the notice.
  if (contract.kind === 'fixed') return 0;
  const required = noticeDaysRequired(frequency, by);
  const shortfall = Math.max(0, required - daysServed);
  if (shortfall === 0) return 0;
 
  const agreedDaily =
    agreedNoticeCompensation === undefined
      ? 0
      : divideRoundHalfUp(agreedNoticeCompensation, required);
  return shortfall * Math.max(dailyWage(wage), agreedDaily);
}

Step 5: Article 77 compensation, and the floor that overrides the contract

Article 77 opens with a conditional: unless the contract specifies a compensation for termination without a lawful reason. Employment contracts in the Saudi market use that opening constantly, and a large share of them specify a figure below the statutory floor — one month is the classic.

That clause does not survive. Article 8 voids any waiver or settlement of rights the law confers on the worker, so a contractual compensation below the two-month floor is void to the extent of the shortfall. Model the contractual figure as an input, then apply the floor after it:

const MIN_COMPENSATION_MONTHS = 2;
const INDEFINITE_DAYS_PER_YEAR = 15;
 
type Article77Input = {
  wage: Wage;
  contract: Contract;
  /** Service on the Article 2 / Article 10 basis — see `settleTermination`. */
  serviceYears: number;
  /** ISO date the relationship actually ended. */
  terminatedOn: string;
  /** The calendar the contract runs on (Article 10). */
  calendar: CalendarBasis;
  /** A figure written into the contract under the Article 77 opening clause. */
  contractualCompensation?: Halalas;
};
 
function article77Compensation(input: Article77Input): {
  amount: Halalas;
  basis: 'contractual' | 'per-year' | 'remaining-term';
  flooredUp: boolean;
} {
  const { wage, contract, serviceYears, terminatedOn, calendar, contractualCompensation } = input;
  const monthly = actualMonthlyWage(wage);
  const floor = MIN_COMPENSATION_MONTHS * monthly;
 
  let amount: Halalas;
  let basis: 'contractual' | 'per-year' | 'remaining-term';
 
  if (contractualCompensation !== undefined) {
    amount = contractualCompensation;
    basis = 'contractual';
  } else if (contract.kind === 'indefinite') {
    amount = Math.round(serviceYears * INDEFINITE_DAYS_PER_YEAR * dailyWage(wage));
    basis = 'per-year';
  } else {
    amount = remainingTermWage(wage, terminatedOn, contract.endsOn, calendar);
    basis = 'remaining-term';
  }
 
  // Article 8 — a contract may improve on the statute, never reduce it.
  return amount < floor
    ? { amount: floor, basis, flooredUp: true }
    : { amount, basis, flooredUp: false };
}
 
/**
 * Wage for the unexpired part of a fixed term, on the Article 2 thirty-day month and the
 * Article 10 calendar. The first unexpired day is the day *after* termination; the
 * contract's own end date counts.
 */
function remainingTermWage(
  wage: Wage,
  from: string,
  to: string,
  calendar: CalendarBasis,
): Halalas {
  const firstUnexpired = new Date(parseDate(from, 'terminatedOn').getTime() + MS_PER_DAY);
  const end = parseDate(to, 'contract.endsOn');
  if (end < firstUnexpired) return 0;
 
  return serviceDays360(firstUnexpired, end, calendar) * dailyWage(wage);
}

serviceYears is fractional here, and it comes from the Article 2 / Article 10 measurement in Step 2 — not from a Gregorian year divided by 365.25.

Correction, 2026-09-21. An earlier version of this tutorial said Article 77's fifteen days accrue on the part year "which is the opposite of the anniversary-based treatment the Article 84 award uses". That had the two articles the wrong way round. Article 84 is the one that states the pro-rata rule explicitly — «ويستحق العامل مكافأة عن أجزاء السنة بنسبة ما قضاه منها في العمل» — and Article 77 carries no part-year clause at all. Treating the part year pro rata under Article 77 is the prevailing practice, but it is a reading of «عن كل سنة من سنوات خدمة العامل», not a quotation. Say so in your code comment rather than citing a sentence that is in the other article.

That distinction matters less than it looks, and the arithmetic says why. Fifteen days a year only reaches sixty days — the two-month floor — at four years of service, so below four years the floor decides the number and the part-year reading cannot change it. Above four years the part year is worth 15/360 of a monthly wage per extra day, about 41 SAR on a 10,000 wage.

One rounding trap sits exactly on that boundary. The day rate is a rounded thirtieth of the monthly wage, so sixty days at the day rate is twenty halalas short of two months. Compare the computed amount against the floor in halalas, as the code above does — a check written as "is this more than 60 days" reports flooredUp: false on a number that is actually below the floor.

Step 6: Article 80 — nine cases, and the evidence gate that decides them

Article 80 lets the employer terminate with no award, no notice and no compensation. It is exhaustive: nine cases, listed, with no residual "other serious misconduct" clause. The nine, in substance:

  1. Assault on the employer, the manager or a superior during or because of work
  2. Failure to perform essential contractual obligations, or disobedience of lawful instructions, or failure to observe written safety instructions — after a written warning
  3. Proven bad conduct, or an act affecting honesty or integrity
  4. A deliberate act or omission causing material loss to the employer, provided the employer notifies the competent authorities within 24 hours of becoming aware of it
  5. Resorting to forgery to obtain the job
  6. Being within the probation period
  7. Absence without a valid reason for more than 30 days in one year, or more than 15 consecutive days — and only after a written warning issued once the absence has reached 20 days in the first case, 10 days in the second
  8. Unlawfully exploiting the position for personal gain
  9. Disclosing work-related industrial or commercial secrets

Across all nine, the article requires the employer to give the worker an opportunity to state their case. That procedural requirement is where employers actually lose. Encode it as a gate that can fail, and make failure reclassify the exit rather than merely log a warning:

type Article80Case = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
 
type Article80Evidence = {
  /** Required for all nine cases: the worker was allowed to state their case. */
  investigationHeld: boolean;
  /** Case 2: the prior written warning. */
  writtenWarningIssued?: boolean;
  /** Case 4: hours between the employer becoming aware and notifying the authorities. */
  hoursToAuthorityNotice?: number;
  /** Case 7. */
  absence?: { daysInYear: number; consecutiveDays: number; warningIssued: boolean };
};
 
const AUTHORITY_NOTICE_HOURS = 24;
const ABSENCE_DAYS_IN_YEAR = 30;
const ABSENCE_CONSECUTIVE_DAYS = 15;
 
/** Returns null when the dismissal stands, or the reason it collapses. */
function article80Failure(c: Article80Case, e: Article80Evidence): string | null {
  if (!e.investigationHeld) return 'no opportunity to state the case (Art. 80, final paragraph)';
 
  switch (c) {
    case 2:
      return e.writtenWarningIssued ? null : 'no prior written warning (Art. 80/2)';
    case 4:
      return (e.hoursToAuthorityNotice ?? Infinity) <= AUTHORITY_NOTICE_HOURS
        ? null
        : 'authorities not notified within 24 hours (Art. 80/4)';
    case 7: {
      const a = e.absence;
      if (!a) return 'absence record missing (Art. 80/7)';
      const threshold =
        a.daysInYear > ABSENCE_DAYS_IN_YEAR || a.consecutiveDays > ABSENCE_CONSECUTIVE_DAYS;
      if (!threshold) return 'absence below the 30-day or 15-consecutive-day threshold (Art. 80/7)';
      return a.warningIssued ? null : 'no written warning before dismissal (Art. 80/7)';
    }
    default:
      return null;
  }
}

Note the strict inequality on the absence thresholds. The article says more than 30 days, so a worker absent exactly 30 days cannot be dismissed under case 7 — the thirty-first day is the one that counts. Off-by-one here is not a rounding error; it is the difference between a lawful dismissal and a full settlement plus two months.

Step 7: Article 81 — the worker leaves and the employer pays anyway

Article 81 is Article 80's mirror. It lets the worker leave without notice while keeping full statutory rights, where the employer has, in substance: failed essential contractual or statutory obligations; committed fraud about the conditions of work at the time of contracting; assigned materially different work without consent; assaulted the worker; treated the worker with injustice or cruelty; exposed the worker to a serious danger the employer knew about and did not remedy; or acted so as to make the worker appear to be the party terminating the contract.

For pricing, an Article 81 exit is an employer termination. The worker keeps the full Article 84 award — no resignation scale — and the Article 77 exposure applies. Payroll systems that key the award scale off "who pressed the button" get this exactly backwards, because on paper the worker resigned.

type Article81Case = 1 | 2 | 3 | 4 | 5 | 6 | 7;
 
type Classification = {
  /** Which end-of-service scale the award engine should be called with. */
  awardScale: 'full' | 'resignation-scale' | 'none';
  /** Whether Article 77 compensation is owed. */
  article77Owed: boolean;
  /** Whether notice was owed by the employer at all. */
  noticeOwedByEmployer: boolean;
  /** Human-readable trail: what the engine decided and under which article. */
  rationale: string[];
};
 
function classify(exit: Exit): Classification {
  switch (exit.ground) {
    case 'article-80': {
      const failure = article80Failure(exit.case, exit.evidence);
      if (failure) {
        return {
          awardScale: 'full',
          article77Owed: true,
          noticeOwedByEmployer: true,
          rationale: [`Article 80 dismissal collapses: ${failure}`, 'priced as unlawful termination'],
        };
      }
      return {
        awardScale: 'none',
        article77Owed: false,
        noticeOwedByEmployer: false,
        rationale: [`Article 80 case ${exit.case} established`],
      };
    }
    case 'no-lawful-cause':
      return {
        awardScale: 'full',
        article77Owed: true,
        noticeOwedByEmployer: true,
        rationale: ['termination without a lawful cause (Art. 77)'],
      };
    case 'article-81':
      return {
        awardScale: 'full',
        article77Owed: true,
        noticeOwedByEmployer: true,
        rationale: [`Article 81 case ${exit.case} — worker exit priced as employer termination`],
      };
    case 'article-75-valid-reason':
      return {
        awardScale: 'full',
        article77Owed: false,
        noticeOwedByEmployer: true,
        rationale: [
          exit.reasonInWriting
            ? 'lawful termination with written reason (Art. 75)'
            : 'reason not stated in writing — Article 75 defect, review before paying',
        ],
      };
    case 'article-74':
      return {
        awardScale: 'full',
        article77Owed: false,
        noticeOwedByEmployer: false,
        rationale: [`Article 74 — ${exit.cause}`],
      };
    case 'resignation':
      return {
        awardScale: 'resignation-scale',
        article77Owed: false,
        noticeOwedByEmployer: false,
        rationale: ['ordinary resignation — Article 85 scale applies'],
      };
  }
}

The rationale array is not decoration. When this settlement is disputed on the ودي amicable settlement platform or before a labour court, the question asked is which article each number came from. An engine that emits the reasoning alongside the amount answers that in seconds; one that emits a single total sends someone back into a spreadsheet.

Step 8: Assemble the settlement, with the Article 88 clock

The heads of a final settlement, in the order they belong on the document:

type SettlementLine = { head: string; article: string; amount: Halalas };
 
type TerminationInput = {
  exit: Exit;
  wage: Wage;
  contract: Contract;
  frequency: PayFrequency;
  isSaudi: boolean;
  /**
   * Article 10 — every period in the Labour Law is Hijri unless the contract or the
   * work regulation says otherwise. Required, so that nobody defaults it by accident.
   */
  calendar: CalendarBasis;
  hiredOn: string;
  terminatedOn: string;
  noticeDaysServed: number;
  contractualCompensation?: Halalas;
  /** Article 76 — a notice compensation agreed above the statutory one. */
  agreedNoticeCompensation?: Halalas;
  /** Already-computed balances from the other engines. */
  unpaidWages: Halalas;
  unusedLeavePay: Halalas;
  unpaidOvertime: Halalas;
  /** Article 84/85 award, computed by the end-of-service engine for the scale we pass it. */
  award: (scale: 'full' | 'resignation-scale' | 'none') => Halalas;
};
 
type Settlement = {
  classification: Classification;
  lines: SettlementLine[];
  total: Halalas;
  /** Article 88 — the date the money is legally due. */
  dueBy: string;
};
 
function settleTermination(input: TerminationInput): Settlement {
  // Articles 2 and 10: whole thirty-day months on the contract's calendar, then the
  // days left over. The same function the end-of-service engine uses, so the two
  // engines cannot disagree about how long this person worked.
  const serviceYears =
    serviceDays360(
      parseDate(input.hiredOn, 'hiredOn'),
      parseDate(input.terminatedOn, 'terminatedOn'),
      input.calendar,
    ) / DAYS_PER_YEAR;
 
  const contract = normaliseContract(
    input.contract,
    input.terminatedOn,
    input.calendar,
    input.isSaudi,
  );
  const classification = classify(input.exit);
  const lines: SettlementLine[] = [];
 
  if (input.unpaidWages > 0) {
    lines.push({ head: 'Unpaid wages', article: 'Art. 90', amount: input.unpaidWages });
  }
  if (input.unpaidOvertime > 0) {
    lines.push({ head: 'Unpaid overtime', article: 'Art. 107', amount: input.unpaidOvertime });
  }
  if (input.unusedLeavePay > 0) {
    lines.push({ head: 'Unused annual leave', article: 'Art. 111', amount: input.unusedLeavePay });
  }
 
  const award = input.award(classification.awardScale);
  if (award > 0) {
    lines.push({ head: 'End-of-service award', article: 'Art. 84/85', amount: award });
  }
 
  if (classification.noticeOwedByEmployer) {
    const lieu = noticeInLieu(
      input.wage,
      input.frequency,
      'employer',
      input.noticeDaysServed,
      contract,
      input.agreedNoticeCompensation,
    );
    if (lieu > 0) {
      lines.push({ head: 'Pay in lieu of notice', article: 'Art. 76', amount: lieu });
    }
  }
 
  if (classification.article77Owed) {
    const c = article77Compensation({
      wage: input.wage,
      contract,
      serviceYears,
      terminatedOn: input.terminatedOn,
      calendar: input.calendar,
      contractualCompensation: input.contractualCompensation,
    });
    lines.push({ head: 'Unlawful-termination compensation', article: 'Art. 77', amount: c.amount });
    classification.rationale.push(
      c.flooredUp
        ? `Article 77 basis "${c.basis}" raised to the two-month statutory floor`
        : `Article 77 basis "${c.basis}"`,
    );
  }
 
  return {
    classification,
    lines,
    total: lines.reduce((sum, l) => sum + l.amount, 0),
    dueBy: settlementDeadline(input.terminatedOn, input.exit.by),
  };
}
 
/**
 * Article 88 — one week where the employer ended the relationship, two weeks
 * where the worker did. The clock does not wait for the next payroll run.
 */
function settlementDeadline(terminatedOn: string, by: Exit['by']): string {
  const days = by === 'worker' ? 14 : 7;
  const due = new Date(parseDate(terminatedOn, 'terminatedOn').getTime() + days * MS_PER_DAY);
  return due.toISOString().slice(0, 10);
}

The award callback is deliberate. This engine decides the scale; it does not reimplement Articles 84 and 85. Pass in the function from your end-of-service engine and the two stay consistent when either statute is amended.

Testing Your Implementation

Test against the statute, not against the current output. Every case below is a rule from the law rather than a regression snapshot:

import test from 'node:test';
import assert from 'node:assert/strict';
 
const wage: Wage = { basic: 800_000, allowances: 200_000 }; // 8,000 + 2,000 SAR
const monthly = actualMonthlyWage(wage);
const daily = dailyWage(wage);
const indefinite: Contract = { kind: 'indefinite' };
 
const fixedTerm = (o: Partial<Extract<Contract, { kind: 'fixed' }>>): Contract => ({
  kind: 'fixed',
  startedOn: '2025-01-01',
  endsOn: '2026-12-31',
  renewals: 0,
  hasRenewalClause: false,
  stillPerforming: true,
  ...o,
});
 
test('Article 77 floor lifts a short-service indefinite case to two months', () => {
  const c = article77Compensation({
    wage,
    contract: indefinite,
    serviceYears: 0.5, // 7.5 days computed
    terminatedOn: '2026-08-24',
    calendar: 'hijri',
  });
  assert.equal(c.amount, 2 * monthly); // 20,000 SAR
  assert.equal(c.flooredUp, true);
});
 
test('a contractual one-month clause is raised to the statutory floor', () => {
  const c = article77Compensation({
    wage,
    contract: indefinite,
    serviceYears: 6,
    terminatedOn: '2026-08-24',
    calendar: 'hijri',
    contractualCompensation: monthly, // one month, as written
  });
  assert.equal(c.amount, 2 * monthly);
});
 
test('fifteen days a year only clears the floor past four years of service', () => {
  const at = (serviceYears: number) =>
    article77Compensation({ wage, contract: indefinite, serviceYears, terminatedOn: '2026-08-24', calendar: 'hijri' });
 
  for (const years of [0.25, 1, 2, 3, 3.9, 4]) {
    assert.equal(at(years).amount, 2 * monthly, `${years} years should be floored`);
  }
  // The day rate is a rounded thirtieth, so sixty days is twenty halalas under two months.
  assert.equal(60 * daily, 2 * monthly - 20);
  assert.equal(at(4 + 1 / 360).flooredUp, false);
});
 
test('the unexpired term starts the day after termination, and the end date counts', () => {
  const c = article77Compensation({
    wage,
    contract: fixedTerm({ startedOn: '2026-01-01', endsOn: '2026-12-31' }),
    serviceYears: 0.5,
    terminatedOn: '2026-06-30',
    calendar: 'gregorian',
  });
  assert.equal(c.basis, 'remaining-term');
  assert.equal(c.amount, 180 * daily); // six thirty-day months, not 181 actual days
});
 
test('Article 2 and Article 10 — the contract decides the calendar, and the calendar decides the money', () => {
  const settle = (calendar: CalendarBasis) =>
    settleTermination({
      exit: { by: 'employer', ground: 'no-lawful-cause' },
      wage,
      contract: indefinite,
      frequency: 'monthly',
      isSaudi: true,
      calendar,
      hiredOn: '2019-01-01',
      terminatedOn: '2023-12-31',
      noticeDaysServed: 60,
      unpaidWages: 0,
      unusedLeavePay: 0,
      unpaidOvertime: 0,
      award: () => 0,
    }).lines.find((l) => l.article === 'Art. 77')!.amount;
 
  // 1,800 days of thirty-day months = exactly five years.
  assert.equal(settle('gregorian'), Math.round(5 * 15 * daily));
  // The same dates in Umm al-Qura are 1,853 days: a silent contract is Hijri, and pays more.
  assert.equal(settle('hijri'), Math.round((1853 / 360) * 15 * daily));
  assert.ok(settle('hijri') > settle('gregorian'));
});
 
test('Article 55 measures the contract, not the worker', () => {
  const kind = (c: Contract, asOf: string, isSaudi = true) =>
    normaliseContract(c, asOf, 'gregorian', isSaudi).kind;
 
  // A two-year term signed by a long-serving worker is still a two-year term.
  assert.equal(kind(fixedTerm({ hasRenewalClause: true }), '2026-06-30'), 'fixed');
  // 55/2 — the term's own length reaching four years converts it.
  assert.equal(
    kind(fixedTerm({ hasRenewalClause: true, startedOn: '2023-01-01' }), '2026-06-30'),
    'indefinite',
  );
  // 55/2 — so do three consecutive renewals, whatever the length.
  assert.equal(kind(fixedTerm({ hasRenewalClause: true, renewals: 3 }), '2026-06-30'), 'indefinite');
  // 55/1 — no renewal clause, term expired, both parties carried on.
  assert.equal(kind(fixedTerm({ endsOn: '2026-01-31' }), '2026-06-30'), 'indefinite');
  // 55/1 — but only if they carried on.
  assert.equal(
    kind(fixedTerm({ endsOn: '2026-01-31', stillPerforming: false }), '2026-06-30'),
    'fixed',
  );
  // Article 37 and Regulation 12 — a non-Saudi contract never converts.
  assert.equal(
    kind(fixedTerm({ hasRenewalClause: true, renewals: 9, endsOn: '2026-01-31' }), '2026-06-30', false),
    'fixed',
  );
});
 
test('Article 75 notice is asymmetric', () => {
  assert.equal(noticeDaysRequired('monthly', 'employer'), 60);
  assert.equal(noticeDaysRequired('monthly', 'worker'), 30);
  assert.equal(noticeDaysRequired('other', 'employer'), 30);
  assert.equal(noticeDaysRequired('other', 'worker'), 30);
});
 
test('Article 76 honours a higher agreed figure and ignores a lower one', () => {
  const agreed = 4 * monthly; // 40,000 SAR for the sixty-day period
  assert.equal(
    noticeInLieu(wage, 'monthly', 'employer', 0, indefinite, agreed),
    60 * divideRoundHalfUp(agreed, 60),
  );
  assert.equal(noticeInLieu(wage, 'monthly', 'employer', 0, indefinite, 50_000), 60 * daily);
});
 
test('absence of exactly 30 days does not establish Article 80 case 7', () => {
  const failure = article80Failure(7, {
    investigationHeld: true,
    absence: { daysInYear: 30, consecutiveDays: 4, warningIssued: true },
  });
  assert.match(failure ?? '', /threshold/);
});
 
test('a case 7 dismissal without the written warning collapses into Article 77', () => {
  const c = classify({
    by: 'employer',
    ground: 'article-80',
    case: 7,
    evidence: {
      investigationHeld: true,
      absence: { daysInYear: 41, consecutiveDays: 41, warningIssued: false },
    },
  });
  assert.equal(c.awardScale, 'full');
  assert.equal(c.article77Owed, true);
});
 
test('Article 81 keeps the full award even though the worker left', () => {
  const c = classify({ by: 'worker', ground: 'article-81', case: 4, noticeGiven: false });
  assert.equal(c.awardScale, 'full');
  assert.equal(c.article77Owed, true);
});
 
test('notice in lieu and Article 77 are cumulative, not alternatives', () => {
  const lieu = noticeInLieu(wage, 'monthly', 'employer', 0, indefinite);
  assert.equal(lieu, 60 * daily); // two months on top of Article 77
});
 
test('Article 88 gives one week when the employer terminated', () => {
  assert.equal(settlementDeadline('2026-08-24', 'employer'), '2026-08-31');
  assert.equal(settlementDeadline('2026-08-24', 'worker'), '2026-09-07');
});

Then run one real historical exit of each shape through the engine and reconcile against what was actually paid. In every payroll audit we have run in this cluster, the reconciliation finds at least one of the three classic gaps: allowances missing from the base, the two-month floor never applied, or notice pay netted off against compensation.

Troubleshooting

Every settlement is short by roughly a third. The wage base is the basic wage. Articles 76, 77 and 84 use the actual wage under Article 2 — basic plus allowances.

Short-service unlawful dismissals return almost nothing. The two-month floor is missing, or it is being applied before the contractual clause instead of after it.

Fixed-term non-renewals are generating Article 77 lines. Expiry is an Article 74 cause, not a termination. Check that your ingestion maps "contract ended" to ground: 'article-74' and not to no-lawful-cause.

A long-running "fixed-term" contract prices its non-renewal as lawful. Article 55 conversion is not running. Verify normaliseContract is called before pricing, that renewal counts are actually being stored, and that stillPerforming is not hard-coded to false by an ingestion layer that never captured it.

A freshly signed fixed term is being priced as indefinite. The conversion is being fed the worker's service length instead of the contract's term. Article 55 measures the contract: a twenty-year veteran who signs a new two-year term is on a two-year term.

Every settlement is short by two or three percent, and nobody can find where. The service length is being counted in Gregorian years. Article 10 makes the period Hijri unless the contract or the work regulation says otherwise, and Article 2 makes the month thirty days. Pass the contract's calendar in explicitly and count with serviceDays360; do not divide elapsed milliseconds by 365 or 365.25.

A resigning worker is billed for sixty days of notice. noticeDaysRequired is keyed on the pay frequency alone. Article 75/1 gives the employer sixty days and the worker thirty; only Article 75/2, for non-monthly wages, is symmetric.

The fixed-term remaining period is one day long. The unexpired term is being measured from the termination date rather than from the day after it, so the last day worked is paid twice — once in the final wage run and once in the Article 77 line.

Article 80 dismissals never collapse. The evidence object is optional in your ingestion layer and arrives empty, so investigationHeld is falsy and — depending on how you wrote the gate — either everything fails or nothing does. Make the evidence fields required at the boundary and reject the input if HR has not supplied them.

Day rates drift between engines. One module divides by 30, another by the actual days in the month. Export a single dailyWage and import it everywhere; the overtime engine's 240 and 180-hour divisors are a separate concern and should not leak into calendar-day heads.

Next Steps

Conclusion

The termination branch of a Saudi payroll system is not a formula, it is a classification followed by a formula. Get the classification right and the arithmetic is a few hundred lines: two wage bases, a 30-day day rate, one contract-type switch, an evidence gate with strict inequalities, and a floor applied last. Get it wrong and the system is confidently precise about a number that was never the right number — which is exactly the shape of case a labour court sees.

The three rules worth writing on the wall: the two-month floor beats any contract clause, notice pay and Article 77 compensation are cumulative, and an Article 80 dismissal without its paperwork is simply an unlawful one with extra steps.

If your exits are currently priced in a spreadsheet, or your HR system computes an award without ever asking why the relationship ended, tell us what your stack looks like — we will run your last twelve months of terminations through an engine like this one and show you which branch your current process is not modelling, before the 12-month window under Article 222 closes on someone else's terms.