On 27 October 2026 the Saudization rate for accounting professions rises from 40% to 50%. That is phase two of Ministerial Decision 103108, and it is the phase that catches people, because nothing has to change for an establishment to fall out of compliance. The establishment that cleared 40% by one head last October is in breach this October having hired nobody, fired nobody, and changed nothing.
This tutorial builds the engine that tells you that in advance. It is not the Nitaqat engine. If you already built the Annex 1 Saudization engine, reuse none of its maths here: this decision has a flat rate rather than a logarithmic curve, its own denominator that excludes most of your workforce, and three countability conditions that Nitaqat does not apply. A green Nitaqat band is no protection at all.
Prerequisites
- Node.js 20 or later and TypeScript 5.x
- Familiarity with discriminated unions and literal types
- Read access to two data sources you probably do not currently join: the job titles registered with GOSI, and SOCPA accreditation status
- A reading of the decision itself, summarised in accounting Saudization hits 50% on 27 October 2026
What You'll Build
A pure, dependency-free module that answers four questions for any date:
- Is this establishment even in scope?
- What rate applies on that date?
- Which of my Saudi accountants actually count, and which are excluded and why?
- How many people must change, and does hiring close the gap or widen it?
Every constant below comes from the ministry's procedural guide. Every number in the worked examples was executed before it was written down.
Step 1: Why the Nitaqat Model Is the Wrong Shape
Nitaqat asks one question about the whole establishment: what fraction of everybody is Saudi, measured against a curve that moves with your size and your activity. Decision 103108 asks a narrower and stricter question.
Three structural differences drive the whole design:
- The denominator is the accounting professions alone. A company of 400 with 23 accountants divides by 23, not 400.
- The rate is flat and stepped by date, not derived from a curve. It is a whole number of percentage points, which matters more than it sounds.
- The obligation is parallel. It applies at entity level regardless of Nitaqat band, and it stacks with the 70% localisation of project-management professions effective 14 February 2027. An establishment can sit in green and breach both.
So model it as its own engine with its own inputs, and let the dashboard show the two side by side rather than folding one into the other.
Step 2: The Phase Schedule, Including the Phase Five Inversion
Most published summaries render the schedule as a rising ladder — 40, 50, 60, 70. Phase five is not a rung on that ladder. It does two things at once: it holds 70% for establishments with five accountants or more, and it pulls establishments with three or four accountants into scope for the first time at 30%.
If you model a phase as a single rate, phase five is unrepresentable. Model it as a set of brackets:
// tawteen/phases.ts
/** A size bracket within a phase. `max: null` means "and above". */
export type Bracket = {
readonly min: number;
readonly max: number | null;
/** Whole percentage points. Never store this as a fraction. */
readonly ratePercent: number;
};
export type Phase = {
readonly ordinal: 1 | 2 | 3 | 4 | 5;
/** ISO date the phase takes effect, inclusive. */
readonly effectiveFrom: string;
readonly brackets: readonly Bracket[];
};
export const ACCOUNTING_PHASES: readonly Phase[] = [
{ ordinal: 1, effectiveFrom: '2025-10-27', brackets: [{ min: 5, max: null, ratePercent: 40 }] },
{ ordinal: 2, effectiveFrom: '2026-10-27', brackets: [{ min: 5, max: null, ratePercent: 50 }] },
{ ordinal: 3, effectiveFrom: '2027-10-27', brackets: [{ min: 5, max: null, ratePercent: 60 }] },
{ ordinal: 4, effectiveFrom: '2028-10-27', brackets: [{ min: 5, max: null, ratePercent: 70 }] },
{
ordinal: 5,
effectiveFrom: '2029-10-27',
brackets: [
// New in phase five: small establishments enter scope.
{ min: 3, max: 4, ratePercent: 30 },
{ min: 5, max: null, ratePercent: 70 },
],
},
];Resolution is a lookup, not a calculation. Return null for out of scope rather than zero — zero is a rate, and an establishment below the threshold has no rate at all:
// tawteen/rate.ts
import { ACCOUNTING_PHASES, type Phase } from './phases';
export type ApplicableRate = {
readonly phase: Phase['ordinal'];
readonly ratePercent: number;
};
function phaseOn(onDate: string): Phase | null {
let found: Phase | null = null;
// ISO strings compare lexicographically; the table is in ascending order.
for (const p of ACCOUNTING_PHASES) if (p.effectiveFrom <= onDate) found = p;
return found;
}
/** `null` means the establishment is outside the decision's scope on that date. */
export function rateFor(totalAccountants: number, onDate: string): ApplicableRate | null {
const phase = phaseOn(onDate);
if (!phase) return null;
for (const b of phase.brackets) {
if (totalAccountants < b.min) continue;
if (b.max !== null && totalAccountants > b.max) continue;
return { phase: phase.ordinal, ratePercent: b.ratePercent };
}
return null;
}An establishment with four accountants gets null on 27 October 2026 and phase 5, 30% on 27 October 2029. That single transition is worth an alert of its own, because it arrives without any change on the establishment's side.
The higher rate prevails. Where an accounting profession is also targeted by a different decision at a different rate, the higher of the two applies. If you localise more than one profession family, resolve all applicable rates for an employee's job code and take the maximum — do not assume this table is the only one that touches a given code.
Step 3: The Job Codes Are the Denominator
The decision names job codes from the Saudi Unified Classification of Occupations, and the list is wider than the word "accountant" suggests. Inventory controller and accounts assistant are on it. Every one of those heads sits in your denominator whether or not the HR system thinks of them as finance staff.
// tawteen/job-codes.ts
/**
* Job codes targeted by Ministerial Decision 103108, as transcribed from the
* ministry's procedural guide. This table IS the denominator: a worker whose
* GOSI job code is absent from it is outside the calculation entirely.
*/
export const TARGETED_JOB_CODES = {
'121101': 'Financial manager',
'121102': 'Accounts manager',
'121103': 'Tariff accounts manager',
'121104': 'Treasury manager',
'121105': 'Budget manager',
'121106': 'Audit manager',
'121107': 'Internal audit manager',
'121113': 'Collections manager',
'121116': 'Treasury director',
'241101': 'Accountant',
'241102': 'Cost accountant',
'241103': 'Internal auditor',
'241105': 'Chartered accountant',
'241106': 'Financial controller',
'241107': 'Financial budgeting specialist',
'241108': 'Tax accounts specialist',
'241109': 'Inventory control specialist',
'331301': 'Accounts assistant',
'331302': 'Inventory controller',
'335202': 'Tax officer',
'431101': 'Accounts clerk',
'431201': 'Finance clerk',
} as const;
export type TargetedJobCode = keyof typeof TARGETED_JOB_CODES;
export function isTargeted(code: string): code is TargetedJobCode {
return code in TARGETED_JOB_CODES;
}Do not hardcode the number of codes anywhere. Published summaries of this decision disagree on whether the table holds 21 or 22 entries. Derive the count from the table, and pin it with a test against the official guide, so that a transcription slip fails a build instead of silently shrinking your denominator.
There is one thing the table cannot express, and you should not pretend otherwise in code. The guide states that the decision follows the work actually performed, not only the registered title: assigning the duties of a localised profession to a non-Saudi under some other job title is an explicit breach. A code-based engine cannot detect that. Treat the engine's output as the floor of your exposure, never the ceiling.
Step 4: Three Systems That Must Agree
Here is where this decision differs from every headcount ratio you have written before. Being Saudi and working in accounts is not enough. Three conditions must hold at the same time, and they live in three different systems:
- The job code registered with GOSI is in the table.
- The contributory wage is at least SAR 6,000 for a bachelor's degree or equivalent, or SAR 4,500 for a diploma or equivalent.
- The SOCPA professional accreditation is active.
Fail any one, and the employee leaves the numerator. Critically, they do not leave the denominator — they are still a worker in an accounting profession. That asymmetry is the whole reason spreadsheets are wrong.
// tawteen/countable.ts
import { isTargeted } from './job-codes';
export type Qualification = 'BACHELOR' | 'DIPLOMA';
/** Minimum GOSI contributory wage in SAR for a Saudi accountant to be counted. */
export const WAGE_FLOOR_SAR: Record<Qualification, number> = {
BACHELOR: 6000,
DIPLOMA: 4500,
};
export type ExclusionReason =
| 'JOB_CODE_NOT_TARGETED'
| 'WAGE_BELOW_FLOOR'
| 'SOCPA_ACCREDITATION_INACTIVE'
| 'QUALIFICATION_UNMAPPED';
export type Employee = {
readonly id: string;
readonly nationality: 'SA' | 'NON_SA';
/** The code as registered with GOSI — not the internal HR title. */
readonly gosiJobCode: string;
/** Undefined when HR holds no usable qualification record. */
readonly qualification?: Qualification;
/** The GOSI contributory wage, not gross pay and not basic salary. */
readonly contributoryWageSar: number;
readonly socpaAccreditationActive: boolean;
};
export type EmployeeAssessment = {
readonly inDenominator: boolean;
readonly inNumerator: boolean;
readonly reasons: readonly ExclusionReason[];
};
export function assessEmployee(e: Employee): EmployeeAssessment {
if (!isTargeted(e.gosiJobCode)) {
// Outside the profession family: neither term. Not an exclusion, a filter.
return { inDenominator: false, inNumerator: false, reasons: ['JOB_CODE_NOT_TARGETED'] };
}
// A non-Saudi accountant is denominator-only by definition.
if (e.nationality !== 'SA') {
return { inDenominator: true, inNumerator: false, reasons: [] };
}
const reasons: ExclusionReason[] = [];
if (e.qualification === undefined) {
// Never assume the lower floor to be generous. An unknown qualification is
// a data incident to resolve, not a number to guess.
reasons.push('QUALIFICATION_UNMAPPED');
} else if (e.contributoryWageSar < WAGE_FLOOR_SAR[e.qualification]) {
reasons.push('WAGE_BELOW_FLOOR');
}
if (!e.socpaAccreditationActive) {
reasons.push('SOCPA_ACCREDITATION_INACTIVE');
}
return { inDenominator: true, inNumerator: reasons.length === 0, reasons };
}Collect all failing reasons rather than returning on the first. An accountant who is both under the wage floor and unaccredited needs two fixes, and a remediation list that reveals the second problem only after you have solved the first will cost you the deadline.
Wage means the contributory wage registered with GOSI, which is frequently not the number your payroll report calls salary. If you have not already reconciled those two figures, the GOSI contribution engine covers where they diverge. An accountant at SAR 5,800 registered and SAR 6,400 paid is excluded, and no payroll report will tell you.
Step 5: Rounding — Where a Float Costs You a Head
The rate is required to the nearest whole number, and a half rounds up. The ministry's guide works an establishment with 23 accountants: 23 × 50% = 11.5, required = 12. Its phase-one example rounds 9.2 down to 9.
The obvious implementation is wrong:
// Wrong. Do not ship this.
const required = Math.round(total * (ratePercent / 100));ratePercent / 100 is not exactly representable in binary floating point for 30 or 70. With 45 accountants at 70%, 45 * 0.7 evaluates to 31.499999999999996, and Math.round returns 31 where the correct answer is 32. With 85 accountants it returns 59 instead of 60. You are one Saudi accountant short of the rate and your dashboard is green.
Keep the arithmetic in integers all the way through. Percentage points are integers; multiply first, add half the divisor, then divide:
// tawteen/required.ts
/**
* Required Saudi accountants, rounded to nearest with halves rounding up.
* Integer-only: `total * ratePercent` is exact, so no float ever enters.
*/
export function requiredSaudis(totalAccountants: number, ratePercent: number): number {
return Math.floor((totalAccountants * ratePercent + 50) / 100);
}Verified against the guide: requiredSaudis(23, 50) returns 12, and requiredSaudis(23, 40) returns 9. Against the float trap: requiredSaudis(45, 70) returns 32 and requiredSaudis(85, 70) returns 60, both correct where Math.round is not.
Report the achieved rate in integers too. Truncating basis points errs downward, which is the safe direction for a compliance figure:
/** Achieved rate in basis points, truncated. 4000 = 40.00%. */
export function achievedBasisPoints(countableSaudis: number, totalAccountants: number): number {
if (totalAccountants === 0) return 0;
return Math.floor((countableSaudis * 10000) / totalAccountants);
}Step 6: Assembling the Assessment
Now join the parts. The output of a compliance engine should never be a single number — it should be a number plus the evidence that produced it, because the number alone cannot be audited or acted on.
// tawteen/assess.ts
import { assessEmployee, type Employee, type ExclusionReason } from './countable';
import { rateFor } from './rate';
import { achievedBasisPoints, requiredSaudis } from './required';
export type ExcludedSaudi = {
readonly employeeId: string;
readonly reasons: readonly ExclusionReason[];
};
export type Assessment =
| { readonly inScope: false; readonly totalAccountants: number }
| {
readonly inScope: true;
readonly phase: number;
readonly ratePercent: number;
readonly totalAccountants: number;
readonly countableSaudis: number;
readonly requiredSaudis: number;
/** Heads short, by replacement. Zero when compliant. */
readonly replacementGap: number;
readonly achievedBasisPoints: number;
readonly excludedSaudis: readonly ExcludedSaudi[];
};
export function assess(roster: readonly Employee[], onDate: string): Assessment {
let total = 0;
let countable = 0;
const excludedSaudis: ExcludedSaudi[] = [];
for (const e of roster) {
const a = assessEmployee(e);
if (!a.inDenominator) continue;
total += 1;
if (a.inNumerator) {
countable += 1;
} else if (e.nationality === 'SA') {
// A Saudi in the denominator but not the numerator is the remediation list.
excludedSaudis.push({ employeeId: e.id, reasons: a.reasons });
}
}
const rate = rateFor(total, onDate);
if (!rate) return { inScope: false, totalAccountants: total };
const required = requiredSaudis(total, rate.ratePercent);
return {
inScope: true,
phase: rate.phase,
ratePercent: rate.ratePercent,
totalAccountants: total,
countableSaudis: countable,
requiredSaudis: required,
replacementGap: Math.max(0, required - countable),
achievedBasisPoints: achievedBasisPoints(countable, total),
excludedSaudis,
};
}Run it against a small roster where two Saudi accountants fail different conditions — one registered at SAR 5,800 against a bachelor's floor of 6,000, one whose accreditation lapsed — and a five-head denominator that the HR system reads as 80% Saudi comes out at 40%. The two numbers are not close, and only one of them is the one the ministry computes.
Step 7: Hiring Three Does Not Close a Gap of Three
The guide's worked example: 23 accountants, phase two, required 12, nine countable Saudis. It says replace three non-Saudi accountants. That is right — and it is right precisely because it says replace.
Replacement holds the denominator at 23. Hiring grows both terms at once, so a gap of three does not close after three hires. Run the arithmetic and watch the target run away:
| Saudis hired | Total accountants | Required at 50% | Countable Saudis | Still short |
|---|---|---|---|---|
| 3 | 26 | 13 | 12 | 1 |
| 4 | 27 | 14 | 13 | 1 |
| 5 | 28 | 14 | 14 | 0 |
Five net new hires to close a gap of three. Every dashboard that shows a bare "gap: 3" and lets a recruiter read it as a hiring target is lying by about 67%. Compute both numbers and label them:
// tawteen/gap.ts
import { requiredSaudis } from './required';
export type Remediation = {
/** Non-Saudi accountants to replace with Saudis. Denominator unchanged. */
readonly byReplacement: number;
/** Net new countable Saudi hires, if you replace nobody. Denominator grows. */
readonly byHiringOnly: number;
};
export function remediation(
totalAccountants: number,
countableSaudis: number,
ratePercent: number,
): Remediation {
const byReplacement = Math.max(0, requiredSaudis(totalAccountants, ratePercent) - countableSaudis);
let hires = 0;
// Each hire adds one to both terms; the target moves, so solve rather than divide.
while (countableSaudis + hires < requiredSaudis(totalAccountants + hires, ratePercent)) {
hires += 1;
if (hires > 10_000) throw new Error('remediation did not converge');
}
return { byReplacement, byHiringOnly: hires };
}The loop terminates for any rate under 100% and is bounded in practice by a handful of iterations, but keep the guard: a future rate of 100 would spin forever, and a compliance job that hangs silently at 03:00 is worse than one that throws.
Step 8: The Date You Fall Without Anyone Leaving
This is the alert worth building the engine for. An establishment sitting at exactly the required rate today is in breach on the next phase date having done nothing at all.
// tawteen/forecast.ts
import { ACCOUNTING_PHASES } from './phases';
import { rateFor } from './rate';
import { requiredSaudis } from './required';
export type FutureBreach = {
readonly effectiveFrom: string;
readonly phase: number;
readonly ratePercent: number;
readonly requiredSaudis: number;
readonly shortfall: number;
};
/** Phase dates on which the current roster would breach, if nothing changes. */
export function forecast(
totalAccountants: number,
countableSaudis: number,
fromDate: string,
): FutureBreach[] {
const out: FutureBreach[] = [];
for (const phase of ACCOUNTING_PHASES) {
if (phase.effectiveFrom <= fromDate) continue;
const rate = rateFor(totalAccountants, phase.effectiveFrom);
if (!rate) continue; // Still out of scope at that size.
const required = requiredSaudis(totalAccountants, rate.ratePercent);
if (countableSaudis >= required) continue;
out.push({
effectiveFrom: phase.effectiveFrom,
phase: rate.phase,
ratePercent: rate.ratePercent,
requiredSaudis: required,
shortfall: required - countableSaudis,
});
}
return out;
}Twenty accountants with ten countable Saudis is compliant on 27 October 2026 — required 10, held 10. On 27 October 2027 the requirement becomes 12 and the same roster is two short. The engine should have said so a year earlier.
The same function catches the phase five entry. An establishment with four accountants and one countable Saudi is out of scope until 27 October 2029, then needs requiredSaudis(4, 30), which is 1 — compliant. Move it to four accountants and zero countable Saudis and the forecast fires on a date nobody had in a calendar.
Testing Your Implementation
Three families of test, and the first is not optional.
Pin the official worked examples. They are the only external check you have on the rounding rule:
import { strict as assert } from 'node:assert';
import { requiredSaudis } from './required';
import { remediation } from './gap';
// Ministry procedural guide, phase two: 23 x 50% = 11.5 -> 12.
assert.equal(requiredSaudis(23, 50), 12);
// Phase one from the same guide: 9.2 -> 9.
assert.equal(requiredSaudis(23, 40), 9);
// The float trap: Math.round(45 * 0.7) is 31 and wrong.
assert.equal(requiredSaudis(45, 70), 32);
assert.equal(requiredSaudis(85, 70), 60);
// Replacement and hiring are different numbers, and the gap is the smaller one.
assert.deepEqual(remediation(23, 9, 50), { byReplacement: 3, byHiringOnly: 5 });Assert the shape of the reference tables, not just the behaviour. A silently shortened job-code list is the failure mode you will not notice:
import { TARGETED_JOB_CODES } from './job-codes';
import { ACCOUNTING_PHASES } from './phases';
const codes = Object.keys(TARGETED_JOB_CODES);
assert.equal(new Set(codes).size, codes.length, 'duplicate job code');
assert.ok(codes.every((c) => /^\d{6}$/.test(c)), 'job codes are six digits');
assert.equal(ACCOUNTING_PHASES.length, 5);Property-test the invariants. Over random totals and the four live rates: the required count never exceeds the total, byHiringOnly is never smaller than byReplacement, and applying byReplacement replacements always produces a compliant assessment. Those three hold for every rate in the table and will catch an off-by-one in the rounding long before an auditor does.
For a spot check by hand, put the same totals into the Nitaqat calculator with 50 entered as the required rate — remembering that here the denominator is the accountants alone, not the establishment. If the two disagree, one of them has a mistyped constant.
Troubleshooting
The engine says 43% and the HR dashboard says 52%. Expected, and the reason the engine exists. Print excludedSaudis and you will usually find the difference in two or three lapsed SOCPA accreditations and one contributory wage a couple of hundred riyals under the floor.
Every employee comes back JOB_CODE_NOT_TARGETED. You are reading internal HR titles instead of the codes registered with GOSI. They are different fields and they drift apart constantly — that drift is itself the finding, not a bug in the join.
The rate flips between two values on consecutive runs. Something in the pipeline is recomputing total from a live query while countableSaudis comes from a cached snapshot. Assess one immutable roster read at one instant, or the two terms will belong to different moments.
An establishment near a bracket edge oscillates in and out of scope. Real, not a bug — five accountants is the threshold and the fifth may be a leaver. Alert on approaching the edge rather than only on crossing it.
Next Steps
- Wire the assessment to run nightly and diff against yesterday, alerting on any newly excluded Saudi rather than on the headline rate. Accreditations lapse quietly.
- Add the parallel obligation. Project-management professions reach 70% on 14 February 2027 with its own job codes and its own denominator, and the same engine shape covers it.
- Pull the countable roster from the platform rather than a spreadsheet — Qiwa integration for HR systems covers where authenticated contract data comes from.
- Keep the Nitaqat view alongside this one. The Annex 1 engine answers a different question about the same company, and being green there says nothing about this decision.
Conclusion
The hard part of this decision is not the percentage. It is that the number the ministry computes is assembled from three systems that your organisation reads separately, and the arithmetic that joins them punishes both a float and a spreadsheet in the same direction — optimistically.
An engine that stores rates as integers, keeps every failing reason instead of the first, distinguishes replacement from hiring, and forecasts the next phase date will tell you in August what you would otherwise discover in November. That is the whole value, and there is under two months of it left before 27 October.
If you would rather have this reconciliation running automatically than refreshed by hand once a quarter, book a free diagnostic session — we will look at where your job codes, wages and accreditation data actually live, and where the three stop agreeing.