Most Saudi HR systems model the work-permit levy — المقابل المالي — as a constant. A number in a config file, multiplied by headcount, shown in a budget line. It is the single most expensive simplification in the payroll layer, and it breaks in three separate places at once.
It breaks because the rate is not one number but two, and the rule that picks between them reads a headcount you do not currently hold. It breaks because the ministry bills on a 30-day month, so the year it charges for has 360 days in it and your date library does not. And from 21 January 2027 it breaks for every micro-establishment in the country, because a permit renewed before that date and expiring after it is not exempt and not charged — it is both, on either side of one line.
This tutorial builds the engine that gets all three right, in TypeScript, with the tests that prove it. The rules come from HRSD's own working-rules document for the levy, issued under Ministerial Decision 197 of 1438-03-23H, from Council of Ministers Decision 325 of 1442-06-13H for instalments, and from Qiwa's notice on the end of the micro-establishment exemption.
Prerequisites
- Node.js 22+ (the examples use the built-in test runner and native TypeScript stripping)
- TypeScript 5.6+
- Familiarity with date arithmetic and integer money handling
- No Qiwa API credentials — everything here is the published rule, computed locally
What You'll Build
A module that takes a work permit's period, an establishment's workforce data and a GOSI history, and returns a priced invoice: segment by segment, with the tier and the rate applied to each, the chargeable day count, and a total that reconciles to the ministry's own annual figure to the halala.
Along the way it will refuse to answer two questions, on purpose, and the refusals matter as much as the arithmetic.
Step 1: Two tiers, and the number that decides them is six months old
The levy has two columns. Per expat worker, per month, since 1 January 2020:
| Condition | Monthly | Annual |
|---|---|---|
| Expat workers do not exceed the Saudi count | SAR 700 | SAR 8,400 |
| Expat workers exceed the Saudi count | SAR 800 | SAR 9,600 |
Separately, and regardless of tier, the permit itself costs SAR 100 per expat worker per year.
Now the part almost every implementation gets wrong. "The Saudi count" is not the number of Saudis on your payroll today. The working rules define it as:
«يتم احتساب عدد الوحدات المستحقة بناء على بيانات عدد العمالة الوافدة ومتوسط عدد السعوديين المدفوع عنهم التأمينات خلال 26 أسبوع على مستوى الرقم الموحد»
The comparison is made against the average number of Saudis with GOSI contributions paid over 26 weeks, at the level of the establishment's unified number — not per branch, not per commercial registration — fed weekly from GOSI.
Two consequences fall straight out of that, and both are commercial, not technical:
- Hiring a Saudi today does not lower your rate today. It enters a 26-week average, so it moves the basis by roughly one twenty-sixth per week. A tier change bought by hiring lands about six months later. Anyone selling you an immediate reduction is selling you the wrong thing.
- Losing one is equally slow, which is the only good news here. A resignation does not spike your rate the following week either.
If that averaging window sounds familiar, it is the same one the Saudization band runs on — we went through its consequences in why your Nitaqat band turned red.
A handful of workers count as one Saudi each for this comparison. The list is short and specific:
export type SpecialCategory =
| 'disabledFullTime' // عامل معاق بدوام كامل
| 'prisonerFullTime' // مسجون بدوام كامل
| 'remoteFullTime' // عامل عن بعد بدوام كامل
| 'student' // الطالب
| 'saudiOwnerFullTime'; // المالك السعودي المتفرغ
export const AVERAGE_WEEKS = 26;
export function saudiEquivalents(
weeklyGosiSaudis: number[],
special: SpecialCategory[] = [],
) {
const window = weeklyGosiSaudis.slice(-AVERAGE_WEEKS);
if (window.length === 0) throw new Error('no GOSI weeks supplied');
const average = window.reduce((a, b) => a + b, 0) / window.length;
return {
weeksUsed: window.length,
// Surface an incomplete window rather than quietly averaging over 9 weeks.
complete: window.length === AVERAGE_WEEKS,
average,
basis: average + special.length,
};
}
export type Tier = 'notExceeding' | 'exceeding';
export function tierFor(expats: number, saudiBasis: number): Tier {
return expats > saudiBasis ? 'exceeding' : 'notExceeding';
}Do not carry a weighting across from Nitaqat. A disabled worker counts as one Saudi here. The Saudization programme weights the same worker differently, because it is a different rule serving a different purpose. Sharing a constant between the two modules is a bug waiting for an audit.
Note also that complete is returned rather than thrown on. A new establishment genuinely has fewer
than 26 weeks of history; the engine should say so and let the caller decide, not invent a basis.
Step 2: The month is 30 days, and the published daily rate does not add up
The working rules are explicit about the billing basis:
«سيتم حساب المقابل المالي على أساس يومي على أن يكون عدد أيام الشهر 30 يوم»
The levy is computed daily, on a 30-day month. So a billed year is 360 days. February is not
short. A 31st does not exist. If you reach for date-fns differenceInDays here, every invoice you
produce will be wrong by a few days a year in a direction that compounds.
That is the well-known part. Here is the part that is not: the decision's table publishes three figures per tier — annual, monthly and daily — and they do not agree with each other. The published daily figure for the lower tier is 23.3. Multiply it by the ministry's own 360-day year:
23.3 × 360 = 8,388 but the annual column says 8,400
26.6 × 360 = 9,576 but the annual column says 9,600
The published dailies are truncated to one decimal, not rounded — 800 ÷ 30 is 26.67, and the table
prints 26.6. They are display values. The rate that actually closes is monthly / 30, and the
engine has to say which one it uses:
export const DAYS_PER_MONTH = 30;
/**
* 30/360 day count. A year is 360 days and February is not short — the
* decision fixes the month at 30 days, so the calendar is not consulted.
*/
export function days360(from: string, to: string): number {
const a = parse(from);
const b = parse(to);
const d1 = Math.min(a.d, 30);
const d2 = a.d >= 30 && b.d === 31 ? 30 : Math.min(b.d, 31);
return Math.max(0, (b.y - a.y) * 360 + (b.m - a.m) * 30 + (Math.min(d2, 30) - d1));
}
/**
* The rate the invoice reconciles to. The published daily figure is a
* truncated display value: 23.3 × 360 is 8,388, not the 8,400 printed one
* column to its left. Bill on monthly / 30.
*/
export function dailyRateHalalas(rates: Rates): number {
return (rates.monthly * 100) / DAYS_PER_MONTH;
}Work in halalas — integer hundredths — and round exactly once, at the end. 700 / 30 is not
representable in binary floating point; accumulate it across 360 days and per-worker drift becomes
visible on an establishment with two hundred permits.
The full schedule is worth encoding rather than hardcoding today's rate, because arrears are real and a permit period can straddle a step:
export type Rates = { annual: number; monthly: number; publishedDaily: number };
export type ScheduleStep = { from: string; notExceeding: Rates; exceeding: Rates };
export const LEVY_SCHEDULE: ScheduleStep[] = [
{ from: '2018-01-01',
notExceeding: { annual: 3600, monthly: 300, publishedDaily: 10 },
exceeding: { annual: 4800, monthly: 400, publishedDaily: 13.3 } },
{ from: '2019-01-01',
notExceeding: { annual: 6000, monthly: 500, publishedDaily: 16.6 },
exceeding: { annual: 7200, monthly: 600, publishedDaily: 20 } },
{ from: '2020-01-01',
notExceeding: { annual: 8400, monthly: 700, publishedDaily: 23.3 },
exceeding: { annual: 9600, monthly: 800, publishedDaily: 26.6 } },
];
export function ratesOn(date: string, tier: Tier): Rates {
const step = [...LEVY_SCHEDULE].reverse().find((s) => s.from <= date);
if (!step) throw new Error(`no levy step in force on ${date}`);
return step[tier];
}Keeping publishedDaily in the type, unused for billing, is deliberate. It is what the reader will
find in the ministry's PDF, and a test that asserts it does not reconcile is the cheapest way to
stop a future maintainer from "fixing" the engine to use it.
Step 3: The exemption is a date, not a status
Micro-establishments have paid SAR 100 per permit and no levy at all for years. That arrangement, extended repeatedly since April 2020, has an end: 21 January 2027, corresponding to 14 Sha'ban 1448. Qiwa has published the notice. There is no fourth extension announced as of this writing.
The conditions to be inside it are narrow, and every one of them is a boolean your HR system probably does not store:
- Nine workers or fewer in total under the unified number — Saudi and non-Saudi, owner included
- A single owner — one natural person
- The owner working full time in the establishment and registered with GOSI as the owner
Meeting that covers two expat work permits. Add at least one full-time Saudi employee and it covers four. Permits beyond the covered slots pay the levy normally.
export type ExemptionInput = {
totalWorkers: number; // everyone under the unified number, owner included
singleOwner: boolean;
ownerFullTimeInsured: boolean; // full time AND registered with GOSI as owner
saudiFullTimeEmployees: number;
};
export function exemptSlots(input: ExemptionInput): number {
if (input.totalWorkers > 9) return 0;
if (!input.singleOwner || !input.ownerFullTimeInsured) return 0;
return input.saudiFullTimeEmployees >= 1 ? 4 : 2;
}Model this as slots, not as a flag on the establishment. An establishment with six expats and a
full-time owner is not "exempt" — it has two exempt permits and four chargeable ones, and which
worker occupies which slot changes the invoice. A boolean isExempt on the company record cannot
represent that, and it is the shape most systems reach for first.
Step 4: Pricing a permit that crosses the cliff
Here is where the date matters. A permit renewed in November 2026 for one year does not fall entirely inside the exemption and does not fall entirely outside it. The fee applies only to the days falling after the exemption ends — the permit is priced in two segments.
So the pricing function cannot take a rate. It has to take a period, cut it at every boundary that changes the price, and price each piece:
export const EXEMPTION_LAST_DAY = '2027-01-21';
export const WORK_PERMIT_FEE = 100;
export function priceWorkPermit(input: PriceInput) {
const cliff = input.exemptionLastDay ?? EXEMPTION_LAST_DAY;
const firstChargeable = dayAfter(cliff);
// Every date at which the price can change, including the period's own ends.
const boundaries = new Set<string>([input.start, input.end]);
for (const step of LEVY_SCHEDULE) {
if (step.from > input.start && step.from < input.end) boundaries.add(step.from);
}
if (input.exempt && firstChargeable > input.start && firstChargeable < input.end) {
boundaries.add(firstChargeable);
}
const cuts = [...boundaries].sort();
const segments: PricedSegment[] = [];
for (let i = 0; i < cuts.length - 1; i++) {
const from = cuts[i];
const to = cuts[i + 1];
const days = days360(from, to);
if (days === 0) continue;
const exemptHere = input.exempt && to <= firstChargeable;
const rates = ratesOn(from, input.tier);
segments.push({
from, to, days, tier: input.tier, monthly: rates.monthly, exempt: exemptHere,
levyHalalas: exemptHere ? 0 : Math.round(dailyRateHalalas(rates) * days),
});
}
const levyHalalas = segments.reduce((a, s) => a + s.levyHalalas, 0);
return {
segments,
chargeableDays: segments.filter((s) => !s.exempt).reduce((a, s) => a + s.days, 0),
levy: levyHalalas / 100,
permitFee: WORK_PERMIT_FEE,
total: (levyHalalas + WORK_PERMIT_FEE * 100) / 100,
};
}Two design notes worth defending in review.
exemptionLastDay is an input with a default. This date has moved several times since 2020. If
it moves again, that is a configuration change, not a deployment. Burying a policy date in a
constant is how compliance code rots.
The cliff is the last covered day, so the first chargeable day is the day after. The sources say
fees apply to the period falling after 21 January 2027. Making firstChargeable an explicit named
value rather than an inline comparison means the assumption is visible, testable, and easy to flip
if Qiwa's invoice says otherwise on the day.
Run it on a permit renewed on 1 November 2026 for a year, at the higher tier:
segment 1 2026-11-01 → 2027-01-22 81 days exempt SAR 0
segment 2 2027-01-22 → 2027-11-01 279 days SAR 800/mo SAR 7,440
permit fee SAR 100
total SAR 7,540
The establishment that budgeted SAR 100 for that renewal is short by SAR 7,440, on one worker.
Step 5: Instalments, arrears, and the two questions the engine must refuse
Instalments. Council of Ministers Decision 325 of 1442-06-13H permits the levy to be split into tranches of at least three months. Domestic labour and those in its ruling are excluded and pay in full:
export function fractionQuarterly(totalSar: number, months: number, domesticLabour = false) {
if (domesticLabour || months < 3) return [{ months, amount: totalSar }];
const tranches = Math.floor(months / 3);
const perHalalas = Math.floor((totalSar * 100) / tranches);
const out = Array.from({ length: tranches }, () => perHalalas);
// The remainder lands on the last tranche; nothing is lost to rounding.
out[out.length - 1] += totalSar * 100 - perHalalas * tranches;
return out.map((h) => ({ months: months / tranches, amount: h / 100 }));
}Arrears are not priced historically. The working rules state that late fees for previous years are computed at the levy rate due currently, not the rate that applied in the year the liability arose. An engine that reconstructs the 2019 rate for a 2019 arrear will under-bill and the shortfall will surface at the worst possible moment. Price arrears at today's rate; the schedule array is for periods that legitimately straddle a step, not for back-dating.
And the refusals:
The engine must not tell anyone their invoice. It computes the published rule. Qiwa knows the establishment's registered activity, its actual GOSI feed, its grace periods, its subsidiary structure and its exemption flags. Where the two disagree, Qiwa is right. Say so in the module docblock and again in any UI that renders the number.
The engine must not recommend hiring a Saudi to cut the levy. Run the arithmetic honestly. Twenty expats against a 26-week Saudi average of twelve sit on the higher tier: 20 × 9,600 = SAR 192,000 a year. Lifting the average to twenty drops it to 20 × 8,400 = SAR 168,000. The saving is SAR 24,000 — SAR 1,200 per expat per year — and it requires eight sustained Saudi hires that will not affect the average for about six months. Eight salaries cost multiples of the saving.
The levy alone never justifies the hire. The Saudization band, the visa quota and the services that a red band suspends are what justify it — a different calculation, on the Nitaqat engine. Any tool that presents levy savings as the reason to hire is doing the reader a disservice, and the market is full of them.
Step 6: Testing Your Implementation
The tests are the deliverable here more than the code is. These all pass:
import test from 'node:test';
import assert from 'node:assert/strict';
test('a 30/360 year is 360 days', () => {
assert.equal(days360('2026-01-01', '2027-01-01'), 360);
assert.equal(days360('2026-02-01', '2026-03-01'), 30);
});
test('the published daily rate does not reconcile to the published annual', () => {
const r = ratesOn('2026-06-01', 'notExceeding');
assert.equal(r.annual, 8400);
assert.equal(r.publishedDaily * 360, 8388); // the display value
assert.equal((dailyRateHalalas(r) * 360) / 100, 8400); // the billing value
});
test('both tiers close on monthly / 30, in every step', () => {
for (const step of LEVY_SCHEDULE) {
for (const tier of ['notExceeding', 'exceeding'] as const) {
const r = step[tier];
assert.equal((dailyRateHalalas(r) * 360) / 100, r.annual);
}
}
});
test('the tier is decided by a 26-week average, not by today', () => {
const weeks = [...Array(25).fill(4), 12]; // hired eight this week
const basis = saudiEquivalents(weeks);
assert.equal(Number(basis.average.toFixed(4)), 4.3077);
assert.equal(tierFor(6, basis.basis), 'exceeding'); // still the higher tier
assert.equal(tierFor(6, 12), 'notExceeding'); // what today's headcount would say
});
test('exemption slots follow the owner and the Saudi employee', () => {
const base = { totalWorkers: 6, singleOwner: true,
ownerFullTimeInsured: true, saudiFullTimeEmployees: 0 };
assert.equal(exemptSlots(base), 2);
assert.equal(exemptSlots({ ...base, saudiFullTimeEmployees: 1 }), 4);
assert.equal(exemptSlots({ ...base, totalWorkers: 10 }), 0);
assert.equal(exemptSlots({ ...base, singleOwner: false }), 0);
});
test('a non-exempt full year is the published annual plus the permit fee', () => {
const r = priceWorkPermit({ start: '2026-03-01', end: '2027-03-01',
tier: 'exceeding', exempt: false });
assert.equal(r.chargeableDays, 360);
assert.equal(r.levy, 9600);
assert.equal(r.total, 9700);
});
test('an exempt permit renewed across the cliff pays only the days after it', () => {
const r = priceWorkPermit({ start: '2026-11-01', end: '2027-11-01',
tier: 'exceeding', exempt: true });
assert.equal(r.segments.length, 2);
assert.equal(r.segments.filter((s) => !s.exempt)[0].from, '2027-01-22');
assert.equal(r.chargeableDays, 279);
assert.equal(r.total, 7540);
});
test('a period spanning a schedule step is priced on both rates', () => {
const r = priceWorkPermit({ start: '2019-07-01', end: '2020-07-01',
tier: 'notExceeding', exempt: false });
assert.deepEqual(r.segments.map((s) => s.monthly), [500, 700]);
assert.equal(r.levy, 3000 + 4200);
});Run them with node --experimental-strip-types --test levy.test.ts.
The second test is the one to keep forever. It asserts a contradiction in the source table, which means it documents why the engine ignores a number the ministry published — the kind of thing that gets silently "corrected" by someone reading the PDF a year from now.
Troubleshooting
The total is a few riyals off Qiwa's invoice. Check the day count first. If you used a real
calendar anywhere, a 365-day year against a 360-day rate gives a predictable overcharge. If it is
off by exactly 12 or 24 riyals per worker per year, you are billing on the published daily figure
instead of monthly / 30.
The tier flips a week after a hire. You are comparing against a live headcount, not the 26-week GOSI average. The average is the rule; the live number is a preview at best.
The exempt establishment is being charged from day one. Check the boundary direction. If
firstChargeable is the cliff date itself rather than the day after, every exempt permit loses a
day and any permit expiring exactly on 21 January 2027 is charged in full.
Branches disagree with head office. The comparison is made at the unified number (الرقم الموحد), not per commercial registration. Aggregating per branch produces a different tier than the ministry will apply. Where the workforce numbers come from is an integration question before it is an arithmetic one — see Qiwa integration for HR systems.
Floating-point cents in the invoice. Accumulate halalas as integers, round once at the end.
Next Steps
- Check where your establishment actually sits with the Nitaqat calculator — the Saudi-to-expat ratio is what puts you on the SAR 700 tier or the SAR 800 one, and it is the same 26-week average this engine reads.
- Build the band itself with the Nitaqat engine in TypeScript.
- Understand the averaging window's consequences in why your Nitaqat band turned red.
- Extend the engine with a forecast: given today's permits and expiry dates, what does the 2027 budget line look like once the exemption lapses? That report is the one finance actually asks for.
Conclusion
The work-permit levy looks like a constant and behaves like a time series. The rate depends on an average you cannot change quickly, the billing calendar has 360 days in it, the ministry's own table disagrees with itself by 12 riyals a year, and on 21 January 2027 a single renewal starts carrying two prices.
None of that is difficult to implement. All of it is easy to get wrong silently, which is worse — the invoice arrives, it is larger than the budget line, and nobody can say which assumption produced the gap. An engine that segments the period, names its boundary dates, works in integers and refuses to impersonate Qiwa will not surprise anyone in January.
If your payroll still carries the levy as one number in a config file, that is worth a look before the exemption lapses rather than after. Talk to us — we will read the current setup and tell you what the 2027 line actually becomes.