A factory with a hundred employees, twenty-five of them Saudi. Saudization is 25%, and the band is Low Green in 2026. Nobody resigns, nobody is hired, nothing about the establishment changes — and in 2027 the band is Red.
That is not an arithmetic error. It is how the programme is built. Developer Nitaqat (نطاقات المطور) replaced the old fixed table with a curve, and the constants of that curve ratchet upward year by year. An establishment sitting on the line today falls below it next year by standing still.
Any HR system that shows "Saudization rate" as a single number for this month is hiding that fact from the person reading it. This tutorial builds the alternative: an engine that computes the band from the official formula, knows which heads actually count and which do not, and tells you which year you fall and how many people you need to hire before then.
We covered connecting HR systems to Qiwa — contract authentication, integration levels, the common API failures — in the Qiwa HR system integration guide. That article is about reaching the data. This one is about what to do with it once you have.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ and TypeScript 5+
vitestor an equivalent test runner- Basic familiarity with discriminated unions and JavaScript's math functions
- A copy of the Developer Nitaqat procedural guide published by the Ministry of Human Resources and Social Development — specifically Annex 1, which is the source of every constant in this tutorial
What this engine does and does not do. This code computes the published thresholds for an entity of a given activity and size, using the same formula the ministry applies. It does not read your establishment's file: Qiwa alone knows your registered activity code, your grace periods, your subsidiary structure, and the workforce counts it will actually use. Where the engine and Qiwa disagree, Qiwa is right. The engine's job is to compute the rule and surface the gap early, not to replace the platform.
What You'll Build
One module in six pieces:
- The constants table — Annex 1 as typed TypeScript
- The threshold calculator — the y = m × ln(x) + c curve, plus band classification
- The gap calculation — how many Saudis you actually need, not how many it looks like
- Countable headcount — from payroll roster to the numbers the programme recognises
- The year forecast — the same reading against the 2027 and 2028 constants
- The alerting engine — warning before the fall, not after it
Step 1: The Formula, and Why a Static Table Fails
The old version of Nitaqat asked for a percentage you looked up in a table. Developer Nitaqat computes it:
y = m × ln(x) + c
- y the minimum Saudization rate for that band
- m a curve constant, per activity and band
- c a levelling constant, per activity, band and year
- x the entity's total workforce
- ln the natural logarithm — the guide specifies "القيمة اللوغاريثمية الطبيعية", so
Math.log, notMath.log10
That last point is worth pausing on: using the base-10 logarithm instead of the natural one yields a plausible-looking number and a completely wrong result, and no test will warn you unless you write it yourself.
The practical consequence of a curve is that the required rate moves with headcount. In most activities it rises as an entity grows. In construction and cleaning the curve constant is negative, so the requirement eases as the establishment grows. No static table can answer this, which is why the engine asks for the activity and not just a count:
| Activity | Total workforce | Low Green floor (2026) |
|---|---|---|
| IT infrastructure | 30 | 30.05% |
| Construction and building contracting | 30 | 12.91% |
| Construction and building contracting | 1000 | 11.61% |
The same headcount, and two and a half times the obligation. Any interface that displays "required Saudization rate" without knowing the activity is displaying an invented number.
Step 2: Typing Annex 1
Start with the types. The bands are ordered, and that order is part of the logic rather than an incidental detail:
// nitaqat/types.ts
export const BAND_ORDER = ['lowGreen', 'midGreen', 'highGreen', 'platinum'] as const;
export type BandKey = (typeof BAND_ORDER)[number];
/** Red is not a threshold — it is where you are when you clear none of them. */
export type BandStatus = BandKey | 'red';
export const NITAQAT_YEARS = [2026, 2027, 2028] as const;
export type NitaqatYear = (typeof NITAQAT_YEARS)[number];
/**
* Annex 1 gives, per activity and band, one curve constant and one levelling
* constant per commitment year.
*/
export type BandConstants = {
m: number;
c: readonly [number, number, number];
};
export type Activity = {
id: string;
label: string;
bands: Record<BandKey, BandConstants>;
};Then the constants. Annex 1 carries 41 activities across 656 constants; three activities are enough for this tutorial, and the rest go in the same way:
// nitaqat/annex1.ts
import type { Activity } from './types';
export const ACTIVITIES: Record<string, Activity> = {
manufacturing: {
id: 'manufacturing',
label: 'الصناعات',
bands: {
lowGreen: { m: 1.68, c: [15.08, 18.08, 21.08] },
midGreen: { m: 1.87, c: [21.87, 24.87, 27.87] },
highGreen: { m: 2.08, c: [23.97, 26.97, 29.97] },
platinum: { m: 2.08, c: [29.87, 32.87, 35.87] },
},
},
construction: {
id: 'construction',
label: 'مقاولات التشييد والبناء',
bands: {
lowGreen: { m: -0.37, c: [14.17, 16.17, 18.17] },
midGreen: { m: -0.37, c: [16.17, 18.17, 20.17] },
highGreen: { m: 0, c: [17.5, 19.5, 21.5] },
platinum: { m: 0, c: [22.5, 24.5, 26.5] },
},
},
itInfrastructure: {
id: 'itInfrastructure',
label: 'البنية التحتية لتقنية المعلومات',
bands: {
lowGreen: { m: 3.61, c: [17.77, 19.77, 21.77] },
midGreen: { m: 3.61, c: [24.64, 26.64, 28.64] },
highGreen: { m: 3.61, c: [40, 42, 44] },
platinum: { m: 3.61, c: [50, 52, 54] },
},
},
};Note the negative constant on construction.
m: -0.37is not a typo. Construction and cleaning are the two activities whose obligation eases with size, and "fixing" that sign breaks the engine silently.
Step 3: Thresholds and Band Classification
// nitaqat/thresholds.ts
import { BAND_ORDER, NITAQAT_YEARS } from './types';
import type { Activity, BandKey, BandStatus, NitaqatYear } from './types';
/** The curve applies from six workers up; below that a flat rule governs. */
export const CURVE_MIN_HEADCOUNT = 6;
export function bandThresholds(
activity: Activity,
totalWorkforce: number,
year: NitaqatYear = 2026,
): Record<BandKey, number> {
const yearIndex = Math.max(0, NITAQAT_YEARS.indexOf(year));
// ln(0) is -Infinity and ln of a fraction is negative, so floor the size.
const ln = Math.log(Math.max(totalWorkforce, 1));
const out = {} as Record<BandKey, number>;
for (const band of BAND_ORDER) {
const { m, c } = activity.bands[band];
// Clamped: the curve is an empirical fit, not an identity. A negative
// constant at a small headcount can produce a faithful negative percentage.
out[band] = Math.min(100, Math.max(0, m * ln + c[yearIndex]));
}
return out;
}
/** The highest band a rate actually clears. */
export function classifyBand(
rate: number,
thresholds: Record<BandKey, number>,
): BandStatus {
let status: BandStatus = 'red';
for (const band of BAND_ORDER) {
if (rate >= thresholds[band]) status = band;
}
return status;
}The floor (Math.max(totalWorkforce, 1)) is not decoration: an establishment with zero employees produces Math.log(0) === -Infinity, every threshold becomes -Infinity, and the engine classifies the empty establishment as platinum. That is the kind of bug that passes review and shows up in a board report.
The clamp between zero and one hundred exists for a related reason: the curve is a statistical fit rather than a mathematical identity, and a negative constant at a small headcount can produce a sub-zero percentage — arithmetically faithful, practically meaningless.
Step 4: The Small-Establishment Rule
An entity with five workers or fewer is not governed by the logarithmic formula — but the obligation does not disappear. The ministry states it plainly: an establishment with five workers or fewer is required to add exactly one Saudi employee.
The difference between "Nitaqat does not apply" and "one Saudi is required" is the difference between a compliant establishment and one that discovers the problem at its first visa request:
// nitaqat/small-entity.ts
import { CURVE_MIN_HEADCOUNT } from './thresholds';
export const SMALL_ENTITY_SAUDI_REQUIREMENT = 1;
export type SmallEntityCheck = {
applies: true;
met: boolean;
required: number;
} | null;
/** Null once the curve takes over — the caller should read the band instead. */
export function smallEntityCheck(total: number, saudis: number): SmallEntityCheck {
if (total === 0 || total >= CURVE_MIN_HEADCOUNT) return null;
return {
applies: true,
met: saudis >= SMALL_ENTITY_SAUDI_REQUIREMENT,
required: SMALL_ENTITY_SAUDI_REQUIREMENT,
};
}When you are unsure which direction to be wrong in, choose the one that overstates the obligation. Telling a client they are compliant when they are not is far worse than the reverse.
Step 5: The Gap — The Denominator Grows With You
This is where almost everyone gets it wrong, including the spreadsheets running Saudization at large companies.
Someone with 20 Saudis out of 100 aiming for 30% calculates: 30 minus 20 is 10 hires. The answer is 15. Every Saudi hire lifts the numerator and the denominator together: after ten hires you have 30 Saudis out of 110, which is 27.27%, not 30%.
The correct relation:
(saudis + x) / (total + x) >= target
therefore: x >= (target × total − saudis) / (1 − target)
// nitaqat/gap.ts
export type Gap = {
hiresNeeded: number;
nonSaudiReduction: number;
};
export function gapTo(targetPercent: number, saudis: number, total: number): Gap {
const t = targetPercent / 100;
if (t >= 1) throw new RangeError('a 100% target has no finite hiring solution');
const rate = total === 0 ? 0 : (saudis / total) * 100;
if (rate >= targetPercent) return { hiresNeeded: 0, nonSaudiReduction: 0 };
// Hiring lifts both terms: (saudis + x) / (total + x) >= t
const hiresNeeded = Math.max(0, Math.ceil((t * total - saudis) / (1 - t)));
// The other lever — shrink the denominator: saudis / (saudis + y) >= t
const nonSaudis = total - saudis;
const allowedNonSaudis = t === 0 ? Infinity : Math.floor(saudis / t) - saudis;
const nonSaudiReduction = Number.isFinite(allowedNonSaudis)
? Math.max(0, nonSaudis - Math.max(0, allowedNonSaudis))
: 0;
return { hiresNeeded, nonSaudiReduction };
}Always show both numbers. Managers make a different decision when they can see that reaching the next band costs either eight Saudi hires or eighteen departures, and showing only one of the two turns a decision into a fait accompli.
Step 6: Which Heads Actually Count
This is the step that separates a calculator from a compliance system. Two filters sit between your payroll and the numerator, and most HR dashboards apply neither.
The first filter is the contract. Since 15 April 2026, a Saudi employee counts toward your Saudization rate only if their contract is electronically authenticated on Qiwa. A Saudi who genuinely works for you, is paid every month, and is registered with GOSI may still not be counted, because their contract was never authenticated.
The second filter is the wage, and it is the one that quietly costs the most. A ministerial decision of the Minister of Human Resources and Social Development raised the minimum monthly wage at which a Saudi counts in Nitaqat to SAR 4,000. Below that the employee does not disappear from your payroll — they arrive in the numerator as a fraction:
| Monthly contributory wage | Counts as |
|---|---|
| SAR 4,000 and above | one worker |
| Above SAR 3,000 and below SAR 4,000 | half a worker |
| Below SAR 3,000 | nothing |
Several categories carry their own floor and their own multiplier instead of the general rule: a student counts as half at a floor of SAR 1,500, a part-time employee as half at SAR 3,000, and a Saudi with a disability who is able to work counts as four workers. The caps on those categories — what share of a workforce may be students, or may be counted at the disability multiplier — are establishment-specific and enforced by Qiwa, so model the multiplier here and let the platform arbitrate the ceiling.
Your HR system counts heads. Nitaqat counts weights. The gap between those two numbers is what makes the dashboard lie:
// nitaqat/countable.ts
/**
* The minimum monthly wage at which a Saudi counts as one worker in Nitaqat,
* raised to SAR 4,000 by ministerial decision of the Minister of Human
* Resources and Social Development. Between SAR 3,000 and SAR 4,000 the
* employee counts as half a worker; below SAR 3,000 they do not count at all.
*/
export const FULL_COUNT_WAGE_SAR = 4000;
export const HALF_COUNT_WAGE_SAR = 3000;
/** Students carry their own floor, well under the general one. */
export const STUDENT_WAGE_FLOOR_SAR = 1500;
/** How the programme weights a head, before the wage is applied. */
export type CountingCategory = 'FULL_TIME' | 'PART_TIME' | 'STUDENT' | 'DISABILITY';
export type EmployeeRecord = {
id: string;
nationality: 'SA' | 'NON_SA';
/** Qiwa contract authentication state, mirrored from the platform. */
contractAuthenticated: boolean;
/** Whether GOSI shows an open contribution record for the period. */
gosiActive: boolean;
/**
* The GOSI contributory wage in SAR — not gross pay, and not basic salary.
* Required on purpose: an optional wage defaults to counting in full, which
* is the exact error this step exists to prevent.
*/
contributoryWageSar: number;
category: CountingCategory;
};
export type CountableWorkforce = {
/** Saudis that actually count toward the ratio, as a weight not a head count. */
saudis: number;
/** The denominator: one head each, plus the bonus weight above one head. */
total: number;
/** Saudis sitting in the denominator but contributing nothing to the numerator. */
uncountedSaudis: string[];
/** Saudis counting as a fraction of a head — usually a wage a little too low. */
partiallyCountedSaudis: string[];
};
/**
* The weight one employee contributes to the Saudization numerator.
* Returns 0 for anyone who does not count, whatever the payroll says.
*/
export function countableWeight(e: EmployeeRecord): number {
if (e.nationality !== 'SA') return 0;
// Since 15 April 2026 an unauthenticated Qiwa contract counts for nothing,
// however high the wage and however long the service.
if (!e.contractAuthenticated) return 0;
const wage = e.contributoryWageSar;
switch (e.category) {
case 'DISABILITY':
return wage >= FULL_COUNT_WAGE_SAR ? 4 : 0;
case 'STUDENT':
return wage >= STUDENT_WAGE_FLOOR_SAR ? 0.5 : 0;
case 'PART_TIME':
return wage >= HALF_COUNT_WAGE_SAR ? 0.5 : 0;
case 'FULL_TIME':
if (wage >= FULL_COUNT_WAGE_SAR) return 1;
if (wage >= HALF_COUNT_WAGE_SAR) return 0.5;
return 0;
}
}
export function countableWorkforce(roster: EmployeeRecord[]): CountableWorkforce {
let saudis = 0;
let total = 0;
const uncountedSaudis: string[] = [];
const partiallyCountedSaudis: string[] = [];
for (const e of roster) {
// No open GOSI contribution record, no place in either term.
if (!e.gosiActive) continue;
// Everyone on an open record occupies one head of the denominator,
// whatever the programme then weights them at in the numerator.
total += 1;
if (e.nationality !== 'SA') continue;
const weight = countableWeight(e);
if (weight === 0) {
uncountedSaudis.push(e.id);
continue;
}
if (weight < 1) partiallyCountedSaudis.push(e.id);
saudis += weight;
// Weight granted above the head itself — the disability multiplier — is
// an incentive, so it lifts both terms rather than only the numerator.
total += Math.max(0, weight - 1);
}
return { saudis, total, uncountedSaudis, partiallyCountedSaudis };
}Three decisions in that file are worth stating out loud.
The wage field is required, not optional. An optional wage defaults to counting in full, which is the precise error this step exists to prevent — and it fails silently, on the record of the employee who would have been cheapest to fix.
The wage is the GOSI contributory wage, not gross pay and not basic salary. Those are frequently different numbers and only one of them is the one the ministry reads. The GOSI contribution engine covers where the two diverge.
Weight above one head lifts both terms; weight below it lifts neither. A Saudi with a disability counting as four is an incentive, so the extra three ride in the denominator as well as the numerator: one such employee among nine non-Saudis reads as 30.77%, not 10%. A Saudi who counts for half, or for nothing, still occupies one whole head of the denominator. That is the conservative direction, it matches the treatment of an unauthenticated contract, and it is the right direction to be wrong in.
The boundary at exactly SAR 3,000 deserves one line of reconciliation. The rule reads "above 3,000 and below 4,000" for the half band and "below 3,000" for no count, which leaves the exact figure unclaimed. The code above counts it as half. If any of your employees sit on that riyal, compare against what Qiwa displays before you trust the number.
The corollary nobody expects: the hire that lowers your rate
Step 5 showed the denominator growing with every hire. Weights make that sharper, and it is worth alerting on. Adding one head of weight w raises your rate only when w is greater than your current rate expressed as a fraction:
- A Saudi hired below SAR 3,000 adds a head and no weight, so they always lower your Saudization rate.
- A Saudi hired in the half band lowers it whenever you are already above 50%.
- If the band threshold you are chasing is itself above 50%, half-weight hiring can never reach it, however many people you hire.
gapTo from Step 5 counts full-weight hires. If the roles you are about to open pay under SAR 4,000, the number it returns is not the number of people you need — and in the last case above, no such number exists.
Always reconcile total and saudis against the counts Qiwa itself displays, and treat any difference as an incident to investigate rather than a number to round. The Nitaqat calculator is the quickest way to see what a corrected weight does to your band before you write any of this.
Step 7: The Year You Fall
Now back to the factory we opened with. The levelling constant c rises three points a year across most manufacturing bands, and reading the same workforce against next year's constants exposes the cliff:
// nitaqat/forecast.ts
import { bandThresholds, classifyBand } from './thresholds';
import { NITAQAT_YEARS } from './types';
import type { Activity, BandStatus, NitaqatYear } from './types';
export type YearOutlook = {
year: NitaqatYear;
rate: number;
band: BandStatus;
lowGreenThreshold: number;
};
/** One unchanging workforce, read against each published year's constants. */
export function ratchetOutlook(
activity: Activity,
saudis: number,
total: number,
): YearOutlook[] {
const rate = total === 0 ? 0 : (saudis / total) * 100;
return NITAQAT_YEARS.map((year) => {
const thresholds = bandThresholds(activity, total, year);
return {
year,
rate: Number(rate.toFixed(2)),
band: classifyBand(rate, thresholds),
lowGreenThreshold: Number(thresholds.lowGreen.toFixed(2)),
};
});
}And its output for our factory — 25 Saudis out of 100:
| Year | Rate | Low Green floor | Band |
|---|---|---|---|
| 2026 | 25.00% | 22.82% | Low Green |
| 2027 | 25.00% | 25.82% | Red |
| 2028 | 25.00% | 28.82% | Red |
And the number that makes this actionable: reaching the 2027 floor from today's position takes two hires. Waiting for the fall into red means visa services and service transfers are suspended before you even begin recruiting. The difference between an early warning and a late crisis is, here, two people.
Step 8: Alerting Before the Edge, Not At It
A system that warns you when you fall is a late system. What you need is a margin:
// nitaqat/alerts.ts
import { bandThresholds, classifyBand } from './thresholds';
import { ratchetOutlook } from './forecast';
import { FULL_COUNT_WAGE_SAR } from './countable';
import { BAND_ORDER } from './types';
import type { Activity, BandKey, NitaqatYear } from './types';
export type Alert = {
level: 'info' | 'warn' | 'critical';
code: string;
message: string;
};
/** Percentage points between the current rate and the floor it sits on. */
export function bandBuffer(
rate: number,
thresholds: Record<BandKey, number>,
): number {
const current = classifyBand(rate, thresholds);
if (current === 'red') return 0;
return Number((rate - thresholds[current]).toFixed(2));
}
export function reviewCompliance(input: {
activity: Activity;
saudis: number;
total: number;
uncountedSaudis: string[];
partiallyCountedSaudis?: string[];
year?: NitaqatYear;
bufferPoints?: number;
}): Alert[] {
const {
activity, saudis, total, uncountedSaudis, partiallyCountedSaudis = [],
year = 2026, bufferPoints = 2,
} = input;
const alerts: Alert[] = [];
const rate = total === 0 ? 0 : (saudis / total) * 100;
const thresholds = bandThresholds(activity, total, year);
const band = classifyBand(rate, thresholds);
const buffer = bandBuffer(rate, thresholds);
if (band === 'red') {
alerts.push({
level: 'critical',
code: 'BAND_RED',
message: `Red band: ${rate.toFixed(2)}% against a ${thresholds.lowGreen.toFixed(2)}% floor.`,
});
} else if (buffer < bufferPoints) {
alerts.push({
level: 'warn',
code: 'BAND_MARGIN_THIN',
message: `Only ${buffer} points above the ${band} floor — one departure may cost the band.`,
});
}
if (uncountedSaudis.length > 0) {
alerts.push({
level: 'warn',
code: 'CONTRACTS_UNAUTHENTICATED',
message: `${uncountedSaudis.length} Saudi employees have no authenticated Qiwa contract and are not counting.`,
});
}
if (partiallyCountedSaudis.length > 0) {
alerts.push({
level: 'warn',
code: 'WAGE_BELOW_FULL_COUNT',
message: `${partiallyCountedSaudis.length} Saudi employees count as half a head because their contributory wage is under SAR ${FULL_COUNT_WAGE_SAR}.`,
});
}
const falls = ratchetOutlook(activity, saudis, total).find((o) => o.band === 'red');
if (falls && band !== 'red') {
alerts.push({
level: 'critical',
code: 'RATCHET_FALL',
message: `Unchanged, this workforce falls to red in ${falls.year}.`,
});
}
return alerts;
}The default margin of two percentage points is a choice, not a rule: in a hundred-person establishment, one Saudi resignation costs roughly a full point. Tune bufferPoints to the size of the entity, not to taste.
Testing the Engine
The tests here are not ceremonial. The first four numbers come from the manufacturing curve at a hundred employees, and any drift in them means someone swapped the logarithm or mistyped a constant:
// nitaqat/engine.test.ts
import { describe, expect, it } from 'vitest';
import { ACTIVITIES } from './annex1';
import { bandThresholds, classifyBand } from './thresholds';
import { gapTo } from './gap';
import { ratchetOutlook } from './forecast';
import { countableWorkforce } from './countable';
import type { EmployeeRecord } from './countable';
describe('thresholds', () => {
it('computes the 2026 manufacturing curve at 100 employees', () => {
const t = bandThresholds(ACTIVITIES.manufacturing, 100, 2026);
expect(t.lowGreen).toBeCloseTo(22.82, 2);
expect(t.midGreen).toBeCloseTo(30.48, 2);
expect(t.highGreen).toBeCloseTo(33.55, 2);
expect(t.platinum).toBeCloseTo(39.45, 2);
});
it('eases with size where the curve constant is negative', () => {
const small = bandThresholds(ACTIVITIES.construction, 10, 2026).lowGreen;
const large = bandThresholds(ACTIVITIES.construction, 1000, 2026).lowGreen;
expect(small).toBeCloseTo(13.32, 2);
expect(large).toBeCloseTo(11.61, 2);
expect(large).toBeLessThan(small);
});
it('separates two activities that share a headcount', () => {
const it = bandThresholds(ACTIVITIES.itInfrastructure, 30, 2026).lowGreen;
const con = bandThresholds(ACTIVITIES.construction, 30, 2026).lowGreen;
expect(it).toBeCloseTo(30.05, 2);
expect(con).toBeCloseTo(12.91, 2);
});
it('does not call an empty establishment platinum', () => {
const t = bandThresholds(ACTIVITIES.manufacturing, 0, 2026);
expect(Number.isFinite(t.lowGreen)).toBe(true);
expect(classifyBand(0, t)).toBe('red');
});
});
describe('the gap', () => {
it('accounts for the denominator growing with each hire', () => {
expect(gapTo(30.48, 25, 100).hiresNeeded).toBe(8);
});
it('offers the reduction path as well', () => {
expect(gapTo(30.48, 25, 100).nonSaudiReduction).toBe(18);
});
it('returns zero once the target is already met', () => {
expect(gapTo(20, 25, 100)).toEqual({ hiresNeeded: 0, nonSaudiReduction: 0 });
});
});
describe('the ratchet', () => {
it('drops a static workforce a band without anyone moving', () => {
const outlook = ratchetOutlook(ACTIVITIES.manufacturing, 25, 100);
expect(outlook[0].band).toBe('lowGreen');
expect(outlook[1].band).toBe('red');
expect(outlook[1].lowGreenThreshold).toBeCloseTo(25.82, 2);
});
});
describe('countable workforce', () => {
const head = (over: Partial<EmployeeRecord>): EmployeeRecord => ({
id: 'x',
nationality: 'SA',
contractAuthenticated: true,
gosiActive: true,
contributoryWageSar: 6000,
category: 'FULL_TIME',
...over,
});
it('keeps an unauthenticated Saudi in the denominator only', () => {
const w = countableWorkforce([
head({ id: 'a' }),
head({ id: 'b', contractAuthenticated: false }),
head({ id: 'c', nationality: 'NON_SA' }),
]);
expect(w.saudis).toBe(1);
expect(w.total).toBe(3);
expect(w.uncountedSaudis).toEqual(['b']);
});
it('halves a Saudi under the full-count wage and drops one under the lower band', () => {
const w = countableWorkforce([
head({ id: 'mid', contributoryWageSar: 3500 }),
head({ id: 'low', contributoryWageSar: 2800 }),
]);
expect(w.saudis).toBe(0.5);
expect(w.partiallyCountedSaudis).toEqual(['mid']);
expect(w.uncountedSaudis).toEqual(['low']);
});
});The second file is the one to run before you trust a single percentage. Each case is a wage or a category that a payroll report gets right and Nitaqat does not:
// nitaqat/counting.test.ts
import { describe, expect, it } from 'vitest';
import { countableWeight, countableWorkforce } from './countable';
import type { EmployeeRecord } from './countable';
import { reviewCompliance } from './alerts';
import { ACTIVITIES } from './annex1';
const saudi = (over: Partial<EmployeeRecord> = {}): EmployeeRecord => ({
id: 'x',
nationality: 'SA',
contractAuthenticated: true,
gosiActive: true,
contributoryWageSar: 6000,
category: 'FULL_TIME',
...over,
});
describe('the wage bands', () => {
it('counts a full head at the floor and half a head one riyal under it', () => {
expect(countableWeight(saudi({ contributoryWageSar: 4000 }))).toBe(1);
expect(countableWeight(saudi({ contributoryWageSar: 3999 }))).toBe(0.5);
});
it('counts nothing below the lower band', () => {
expect(countableWeight(saudi({ contributoryWageSar: 2999 }))).toBe(0);
});
it('ignores the wage entirely when the contract is unauthenticated', () => {
expect(
countableWeight(saudi({ contributoryWageSar: 40000, contractAuthenticated: false })),
).toBe(0);
});
});
describe('the category multipliers', () => {
it('counts a disabled Saudi as four, and as nothing under the general floor', () => {
expect(countableWeight(saudi({ category: 'DISABILITY' }))).toBe(4);
expect(
countableWeight(saudi({ category: 'DISABILITY', contributoryWageSar: 3900 })),
).toBe(0);
});
it('applies the student floor rather than the general one', () => {
expect(
countableWeight(saudi({ category: 'STUDENT', contributoryWageSar: 1500 })),
).toBe(0.5);
expect(
countableWeight(saudi({ category: 'STUDENT', contributoryWageSar: 1400 })),
).toBe(0);
});
it('counts a part-timer as half at the part-time floor', () => {
expect(
countableWeight(saudi({ category: 'PART_TIME', contributoryWageSar: 3000 })),
).toBe(0.5);
});
});
describe('the countable workforce', () => {
it('keeps an unauthenticated Saudi in the denominator only', () => {
const w = countableWorkforce([
saudi({ id: 'a' }),
saudi({ id: 'b', contractAuthenticated: false }),
{ ...saudi({ id: 'c' }), nationality: 'NON_SA' },
]);
expect(w.saudis).toBe(1);
expect(w.total).toBe(3);
expect(w.uncountedSaudis).toEqual(['b']);
});
it('separates a Saudi who counts for nothing from one who counts for half', () => {
const w = countableWorkforce([
saudi({ id: 'low', contributoryWageSar: 2800 }),
saudi({ id: 'mid', contributoryWageSar: 3500 }),
]);
expect(w.saudis).toBe(0.5);
expect(w.uncountedSaudis).toEqual(['low']);
expect(w.partiallyCountedSaudis).toEqual(['mid']);
});
it('lifts both terms for the disability multiplier', () => {
const roster = [
saudi({ id: 'd', category: 'DISABILITY' }),
...Array.from({ length: 9 }, (_, i) => ({
...saudi({ id: `n${i}` }),
nationality: 'NON_SA' as const,
})),
];
const w = countableWorkforce(roster);
expect(w.saudis).toBe(4);
expect(w.total).toBe(13);
expect((w.saudis / w.total) * 100).toBeCloseTo(30.77, 2);
});
it('shows a half-weight hire lowering a rate that is already above half', () => {
const before = countableWorkforce([
...Array.from({ length: 6 }, (_, i) => saudi({ id: `s${i}` })),
...Array.from({ length: 2 }, (_, i) => ({
...saudi({ id: `n${i}` }),
nationality: 'NON_SA' as const,
})),
]);
const after = countableWorkforce([
...Array.from({ length: 6 }, (_, i) => saudi({ id: `s${i}` })),
saudi({ id: 'new', contributoryWageSar: 3500 }),
...Array.from({ length: 2 }, (_, i) => ({
...saudi({ id: `n${i}` }),
nationality: 'NON_SA' as const,
})),
]);
expect((before.saudis / before.total) * 100).toBeCloseTo(75, 2);
expect((after.saudis / after.total) * 100).toBeCloseTo(72.22, 2);
expect(after.saudis / after.total).toBeLessThan(before.saudis / before.total);
});
});
describe('the alert', () => {
it('names the half-counted heads and what would fix them', () => {
const alerts = reviewCompliance({
activity: ACTIVITIES.manufacturing,
saudis: 24.5,
total: 100,
uncountedSaudis: [],
partiallyCountedSaudis: ['mid'],
});
const wage = alerts.find((a) => a.code === 'WAGE_BELOW_FULL_COUNT');
expect(wage?.level).toBe('warn');
expect(wage?.message).toContain('SAR 4000');
});
});You can try the same numbers by hand in the Nitaqat calculator before trusting the engine's output: if the two disagree, one of them has a mistyped constant.
Troubleshooting
Using Math.log10 instead of Math.log. The most common error and the hardest to spot, because the result remains a plausible-looking percentage. The guide specifies the natural logarithm.
Reading c from the wrong year column. The three constants per band are 2026, 2027 and 2028, in that order. Mixing them gives a correct band for the wrong year.
"Fixing" the negative sign on construction. -0.37 is intentional.
Counting payroll heads instead of countable heads. A contract that is not authenticated on Qiwa does not count, however faithfully the salary is paid.
Showing the rate without the band. 22% is a meaningless number: it is comfortably green for a contractor and red for an IT infrastructure firm.
Ignoring subsidiary structure. The calculation is at the entity level as Qiwa's establishment file defines it, not at the level of the branch that happens to be in your database.
Next Steps
The engine above computes the rule. What turns it into a real system is the source feeding it: a daily sync from Qiwa for authenticated contracts, from GOSI for open records, and from payroll for contract end dates. At that point ratchetOutlook becomes a monthly board report rather than a function in a file.
- Qiwa integration for HR systems — how to reach authenticated contract data
- GOSI contribution and reconciliation engine — the source of open records
- Mudad and WPS for developers — the other end of the payroll file
- Nitaqat calculator — for checking any case by hand
Conclusion
Saudization is not a single number. It is a position on a curve that moves underneath you. The engine we built computes thresholds from the Annex 1 constants instead of a frozen table, calculates the hiring gap with a growing denominator, separates countable heads from payroll heads, and reads future years against their own constants.
The real gain is not accuracy — it is time. Two hires today are cheaper than a red band four months from now.
Still tracking Saudization in a spreadsheet? If your rate is computed by hand once a month, you always learn your position late — and you discover unauthenticated contracts at the first rejected visa request. We connect HR systems to Qiwa, GOSI and payroll so the indicators compute themselves and the alerts arrive before the edge rather than after it. Talk to us for a review of your establishment's position.