On 2 November 2026 the Ministry of Human Resources and Social Development (HRSD) reminded private establishments with 50 or more workers that their training data for the year must be disclosed on Qiwa before 31 January 2027. Firms that trained nobody must still file, declaring that they did not train. Only a little over half of covered firms filed for 2025. We covered the notice and the fine in our news article on the 31 January deadline.
This tutorial is for the person who has to produce the numbers: an HR systems owner, an in-house developer, or the consultant asked to "sort out the Qiwa training thing" in January. The Qiwa form is filled in by hand through the Training Management service, so the hard part is not the upload. It is getting a defensible count out of a payroll export, a learning platform and three spreadsheets kept by different managers.
We will build a tool that does exactly that, and nothing more:
- Decides whether the duty applies, from the head count on 31 December.
- Rejects bad training rows before they reach a total.
- Produces the per-group figures the decision asks for: completed trainees, hours and cost.
- Flags trainees below the eight-unit minimum.
- Checks the 2% graduate and job-seeker target.
- Prints the fine at stake if nothing is filed.
What the Rules Actually Say
Every rule in the code below comes from one of four Ministry texts. Where the texts are silent, the code takes a setting instead of guessing, and we say so.
| Rule | Source | In the code |
|---|---|---|
| Establishments with 50 or more workers "at the end of the year" must disclose annually on Qiwa | Ministerial decision announced 19 August 2023 | isCovered, head count on 31 December |
| Report training hours and the number of trainees who completed training, in four groups: employees, students, graduates, job seekers | Same announcement | buildDisclosure counts completed courses only |
| Disclosed training must be at least eight units per trainee per year | Same announcement | minUnitsPerTrainee: 8, hoursPerUnit is a setting |
| Firms that trained report trainees, programmes, hours and costs; firms that did not must declare that they did not | HRSD notice, 2 November 2026 | nil: true |
| Graduates and job seekers are to be trained at 2% of total workers | Same notice | graduateCheck |
| Failure to disclose: SAR 5,000, Category A (50 or more workers) only | Ministerial Decision 112377, item 50 | nonFilingExposure |
| Older figures: SAR 5,000 / 10,000 / 15,000 by size, doubled on repeat | HRSD notice, 10 November 2025 | returned as a second reading |
Two things the texts do not tell us, and that you must decide before you file:
- What a "unit" is. The 2023 decision says eight units, not eight hours. The Ministry's procedural guide defines the term, but we could not open it from any source. The tool treats one unit as one hour by default and makes that a single setting you change once you have the guide.
- How to round 2%. For 120 workers, 2% is 2.4 people. The notice gives no rounding rule. The tool rounds up, which is the safe side.
Not covered here: item 51 of Decision 112377 (SAR 500 per Saudi worker not trained according to the ratios the Minister sets). Those ratios are not in the texts we read, so the tool does not pretend to compute them.
Prerequisites
- Node.js 20 or later
- Basic TypeScript
- An export of your worker register (worker ID, unified number, start and end dates)
- Your training records for 2026, from wherever they live: a learning platform, the finance ledger for external courses, attendance sheets for on-the-job training
Step 1: Set Up the Project
mkdir qiwa-training && cd qiwa-training
npm init -y
npm pkg set type=module
npm install -D typescript tsx @types/node
mkdir src testAdd a tsconfig.json with strict on. Every file below imports with a .ts extension, which tsx runs directly.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src", "test"]
}Step 2: Model the Data
Start with the types. The four groups are a closed union because the decision names exactly four. The Policy type holds every value the Ministry has not given us in a readable form, so nobody has to hunt through the code for an assumption.
// The four trainee groups named in the 2023 ministerial decision.
export type TraineeCategory = "employee" | "student" | "graduate" | "jobSeeker";
export interface Worker {
workerId: string;
unifiedNumber: string; // the establishment's unified number (700...)
startDate: string; // ISO date, first day on the establishment's register
endDate?: string; // ISO date, last day; absent while still employed
}
export interface TrainingRecord {
traineeId: string; // iqama / national ID, or an internal ID for non-employees
category: TraineeCategory;
programmeId: string;
startDate: string; // ISO date
endDate: string; // ISO date
hours: number; // contact hours actually delivered
costSar: number; // what the establishment paid, in riyals
completed: boolean;
}
export interface Programme {
programmeId: string;
title: string;
provider: string;
internal: boolean; // on-the-job or in-house, versus an external provider
}
// Settings the Ministry has not published in a form we could read.
// Keep them explicit so a reviewer can see every assumption.
export interface Policy {
year: number; // the training year being disclosed
hoursPerUnit: number; // how many hours count as one "training unit"
minUnitsPerTrainee: number; // 8 in the 2023 decision
graduateRate: number; // 0.02 per the 2 November 2026 notice
}A few modelling choices worth stating:
hoursis delivered hours, not scheduled hours. If someone left a five-day course on day two, record two days.costSaris what the establishment paid. In-house training with no invoice is0, not missing. A missing cost is a data problem; a zero cost is a fact.- Students, graduates and job seekers are not on your payroll, so
traineeIdis free text. Use the national ID or iqama number where you have it, so one person is never counted twice.
Step 3: Decide Whether the Duty Applies
The 2023 decision applies to establishments with 50 or more workers "at the end of the current year". We read that as the register on 31 December of the training year. Decision 112377 counts workers under the establishment's unified number, so the count runs per unified number, not per commercial registration.
import type { Worker } from "./types.ts";
export const COVERAGE_THRESHOLD = 50;
// A worker counts if they were on the register on the given day.
function onRegister(worker: Worker, day: string): boolean {
if (worker.startDate > day) return false;
if (worker.endDate !== undefined && worker.endDate < day) return false;
return true;
}
// Head count for one unified number on one day, each worker counted once
// even if they appear twice (for example after a transfer between branches).
export function headcountOn(
workers: Worker[],
unifiedNumber: string,
day: string,
): number {
const ids = new Set<string>();
for (const w of workers) {
if (w.unifiedNumber === unifiedNumber && onRegister(w, day)) {
ids.add(w.workerId);
}
}
return ids.size;
}
// The 2023 decision tests the head count "at the end of the year".
// We read that as 31 December of the training year.
export function isCovered(
workers: Worker[],
unifiedNumber: string,
year: number,
): { covered: boolean; headcount: number; asOf: string } {
const asOf = `${year}-12-31`;
const headcount = headcountOn(workers, unifiedNumber, asOf);
return { covered: headcount >= COVERAGE_THRESHOLD, headcount, asOf };
}Two traps this handles. A worker who appears twice in the export, which happens after a transfer between branches, counts once. And a worker whose contract ended on 30 December does not count on the 31st.
If you sit close to 50, run the count on the Qiwa side as well before you rely on it. The Ministry's register is what will be checked, not yours.
Step 4: Validate Before You Total
Most wrong filings are not wrong arithmetic. They are a course from January 2027 counted in 2026, a row entered twice by two managers, or a programme code nobody can explain. Check rows first, and report every problem instead of dropping the bad row quietly.
import type { Programme, TrainingRecord } from "./types.ts";
export interface Problem {
index: number; // row in the input, starting at 0
code:
| "OUTSIDE_YEAR"
| "BAD_DATES"
| "BAD_HOURS"
| "BAD_COST"
| "UNKNOWN_PROGRAMME"
| "DUPLICATE";
message: string;
}
// Row-level checks to run before anything is typed into Qiwa.
// A row with a problem is reported, never silently dropped.
export function validateRecords(
records: TrainingRecord[],
programmes: Programme[],
year: number,
): Problem[] {
const problems: Problem[] = [];
const known = new Set(programmes.map((p) => p.programmeId));
const seen = new Map<string, number>();
const first = `${year}-01-01`;
const last = `${year}-12-31`;
records.forEach((r, index) => {
if (r.endDate < r.startDate) {
problems.push({ index, code: "BAD_DATES", message: "ends before it starts" });
}
if (r.endDate < first || r.endDate > last) {
problems.push({
index,
code: "OUTSIDE_YEAR",
message: `ended ${r.endDate}, outside ${year}`,
});
}
if (!Number.isFinite(r.hours) || r.hours <= 0) {
problems.push({ index, code: "BAD_HOURS", message: "hours must be above zero" });
}
if (!Number.isFinite(r.costSar) || r.costSar < 0) {
problems.push({ index, code: "BAD_COST", message: "cost cannot be negative" });
}
if (!known.has(r.programmeId)) {
problems.push({
index,
code: "UNKNOWN_PROGRAMME",
message: `programme ${r.programmeId} is not in the catalogue`,
});
}
const key = `${r.traineeId}|${r.programmeId}|${r.startDate}`;
const prior = seen.get(key);
if (prior !== undefined) {
problems.push({
index,
code: "DUPLICATE",
message: `same trainee, programme and start date as row ${prior}`,
});
} else {
seen.set(key, index);
}
});
return problems;
}A course belongs to the year it ended in. That is our convention, chosen because the decision counts trainees who completed training. A course that runs from 20 December 2026 to 2 January 2027 belongs to the 2027 filing, and the validator flags it if it appears in 2026.
Step 5: Build the Disclosure Figures
Now the totals. Only completed courses count. Each person counts once per group however many courses they finished, while hours and cost add up across all of them.
import type { Policy, TraineeCategory, TrainingRecord } from "./types.ts";
const CATEGORIES: TraineeCategory[] = ["employee", "student", "graduate", "jobSeeker"];
export interface CategoryTotals {
completedTrainees: number; // distinct people with at least one completed course
hours: number;
costSar: number;
}
export interface Disclosure {
year: number;
nil: boolean; // true when there is nothing to report: file the nil declaration
programmes: number;
byCategory: Record<TraineeCategory, CategoryTotals>;
totalHours: number;
totalCostSar: number;
belowMinimum: { traineeId: string; units: number }[];
}
function emptyTotals(): CategoryTotals {
return { completedTrainees: 0, hours: 0, costSar: 0 };
}
// Rounds to halalas so floating point never shows up in a filing.
function sar(n: number): number {
return Math.round(n * 100) / 100;
}
// Builds the year's figures from records that already passed validation.
// Only completed courses count: the decision asks for trainees
// "who completed training".
export function buildDisclosure(records: TrainingRecord[], policy: Policy): Disclosure {
const done = records.filter((r) => r.completed);
const byCategory = {} as Record<TraineeCategory, CategoryTotals>;
for (const c of CATEGORIES) byCategory[c] = emptyTotals();
const people = new Map<TraineeCategory, Set<string>>();
const hoursPerTrainee = new Map<string, number>();
const programmes = new Set<string>();
for (const r of done) {
const t = byCategory[r.category];
t.hours += r.hours;
t.costSar = sar(t.costSar + r.costSar);
if (!people.has(r.category)) people.set(r.category, new Set());
people.get(r.category)!.add(r.traineeId);
hoursPerTrainee.set(r.traineeId, (hoursPerTrainee.get(r.traineeId) ?? 0) + r.hours);
programmes.add(r.programmeId);
}
for (const c of CATEGORIES) byCategory[c].completedTrainees = people.get(c)?.size ?? 0;
const belowMinimum: { traineeId: string; units: number }[] = [];
for (const [traineeId, hours] of hoursPerTrainee) {
const units = hours / policy.hoursPerUnit;
if (units < policy.minUnitsPerTrainee) {
belowMinimum.push({ traineeId, units: Math.round(units * 100) / 100 });
}
}
belowMinimum.sort((a, b) => a.traineeId.localeCompare(b.traineeId));
const totalHours = CATEGORIES.reduce((s, c) => s + byCategory[c].hours, 0);
const totalCostSar = sar(CATEGORIES.reduce((s, c) => s + byCategory[c].costSar, 0));
return {
year: policy.year,
nil: done.length === 0,
programmes: programmes.size,
byCategory,
totalHours,
totalCostSar,
belowMinimum,
};
}The belowMinimum list is the eight-unit check. It does not remove anyone from the totals: a trainee with six hours still completed training. It tells you who needs another course before you can say the establishment meets the minimum, and it is the list your training manager will want to see in December rather than in February.
Change hoursPerUnit to see why this setting matters. With one hour per unit, a trainee with nine hours passes. With four hours per unit (a half day), the same trainee has 2.25 units and fails. Until you have read the procedural guide, show both results to whoever signs the filing.
When nothing was completed, nil is true. That is the result the Ministry most wants to see filed and the one firms most often skip: the November notice is explicit that an establishment that ran no training must still disclose that fact.
Step 6: Graduates and the Fine at Stake
Two last figures. The first is the 2% graduate and job-seeker target the November notice refers to. The second is what non-filing costs.
import type { Disclosure } from "./aggregate.ts";
import type { Policy } from "./types.ts";
export interface GraduateCheck {
required: number; // people, rounded up: our conservative reading
trained: number; // graduates plus job seekers who completed training
gap: number;
}
// The 2 November 2026 notice cites a decision to train graduates and
// job seekers at 2% of total workers. It does not say how to round, so
// we round up: a firm of 120 needs 3, not 2.4.
export function graduateCheck(
d: Disclosure,
headcount: number,
policy: Policy,
): GraduateCheck {
const required = Math.ceil(headcount * policy.graduateRate);
const trained =
d.byCategory.graduate.completedTrainees + d.byCategory.jobSeeker.completedTrainees;
return { required, trained, gap: Math.max(0, required - trained) };
}
export interface FineReading {
source: string;
amountSar: number;
}
// What a covered firm risks if it files nothing. Two Ministry figures exist,
// so we return both and let a person choose which one to budget on.
export function nonFilingExposure(headcount: number): FineReading[] {
if (headcount < 50) return [];
const tiered = headcount < 500 ? 5_000 : headcount <= 3_000 ? 10_000 : 15_000;
return [
{ source: "Decision 112377, item 50 (schedule in force)", amountSar: 5_000 },
{ source: "HRSD notice of 10 Nov 2025, first offence", amountSar: tiered },
];
}On the fine, the published numbers disagree. The 10 November 2025 notice set SAR 5,000, 10,000 or 15,000 by size, doubled on a repeat. Decision 112377 of 21/8/1447H (9 February 2026) replaced the whole violations schedule. Its item 50 sets SAR 5,000 for Category A and nothing above it, and clause 9 repeals what conflicts with it. Our reading is that the 2026 schedule governs. We have not found a Ministry statement that says so directly, so the tool prints both figures and leaves the budget decision to a person. Note too that the 2025 tiers leave a firm of exactly 3,000 workers unassigned; the code puts it in the middle tier.
A first violation may also qualify for the 80% reduction under Decision 93442. Our guide to the Decision 112377 schedule covers its conditions.
Step 7: Run It on Your Data
Put the four inputs into one JSON file. The worker list is left empty here to keep the page short; paste in your full register export, one object per worker as in the Worker type.
{
"unifiedNumber": "7001234567",
"workers": [],
"programmes": [
{ "programmeId": "P1", "title": "Excel for finance", "provider": "in-house", "internal": true },
{ "programmeId": "P2", "title": "Fire safety", "provider": "Riyadh Safety Institute", "internal": false }
],
"records": [
{ "traineeId": "1012345678", "category": "employee", "programmeId": "P1", "startDate": "2026-03-01", "endDate": "2026-03-05", "hours": 10, "costSar": 0, "completed": true },
{ "traineeId": "1012345678", "category": "employee", "programmeId": "P2", "startDate": "2026-09-14", "endDate": "2026-09-14", "hours": 6, "costSar": 450, "completed": true },
{ "traineeId": "2098765432", "category": "employee", "programmeId": "P2", "startDate": "2026-09-14", "endDate": "2026-09-14", "hours": 6, "costSar": 450, "completed": true },
{ "traineeId": "G-0001", "category": "graduate", "programmeId": "P1", "startDate": "2026-07-01", "endDate": "2026-08-31", "hours": 120, "costSar": 3000, "completed": true }
]
}The runner ties the pieces together. It stops with exit code 2 on any data problem, so you can call it from a scheduled job and get alerted instead of filing half a year.
import { readFileSync } from "node:fs";
import { buildDisclosure } from "./aggregate.ts";
import { isCovered } from "./coverage.ts";
import { graduateCheck, nonFilingExposure } from "./exposure.ts";
import type { Policy, Programme, TrainingRecord, Worker } from "./types.ts";
import { validateRecords } from "./validate.ts";
interface Input {
unifiedNumber: string;
workers: Worker[];
programmes: Programme[];
records: TrainingRecord[];
}
const policy: Policy = {
year: 2026,
hoursPerUnit: 1, // ASSUMPTION: replace with the Ministry's definition
minUnitsPerTrainee: 8,
graduateRate: 0.02,
};
const path = process.argv[2];
if (!path) {
console.error("usage: npx tsx src/report.ts training-2026.json");
process.exit(1);
}
const input = JSON.parse(readFileSync(path, "utf8")) as Input;
const cover = isCovered(input.workers, input.unifiedNumber, policy.year);
console.log(`Head count on ${cover.asOf}: ${cover.headcount}`);
if (!cover.covered) {
console.log("Below 50 workers: the disclosure duty does not apply.");
process.exit(0);
}
const problems = validateRecords(input.records, input.programmes, policy.year);
if (problems.length > 0) {
for (const p of problems) console.log(`row ${p.index}: ${p.code} ${p.message}`);
console.log(`${problems.length} problem(s). Fix the source data, then run again.`);
process.exit(2);
}
const d = buildDisclosure(input.records, policy);
if (d.nil) {
console.log("No completed training: file the nil declaration in Qiwa.");
} else {
console.log(`Programmes: ${d.programmes}`);
for (const [category, t] of Object.entries(d.byCategory)) {
console.log(
`${category.padEnd(10)} trainees ${t.completedTrainees} hours ${t.hours} cost SAR ${t.costSar}`,
);
}
console.log(`Total hours ${d.totalHours}, total cost SAR ${d.totalCostSar}`);
for (const b of d.belowMinimum) {
console.log(`below minimum: ${b.traineeId} has ${b.units} unit(s)`);
}
}
const g = graduateCheck(d, cover.headcount, policy);
console.log(`Graduates and job seekers: ${g.trained} of ${g.required} (gap ${g.gap})`);
for (const f of nonFilingExposure(cover.headcount)) {
console.log(`If not filed: SAR ${f.amountSar} per ${f.source}`);
}With 120 workers on the register and the four sample rows, the output is:
Head count on 2026-12-31: 120
Programmes: 2
employee trainees 2 hours 22 cost SAR 900
student trainees 0 hours 0 cost SAR 0
graduate trainees 1 hours 120 cost SAR 3000
jobSeeker trainees 0 hours 0 cost SAR 0
Total hours 142, total cost SAR 3900
below minimum: 2098765432 has 6 unit(s)
Graduates and job seekers: 1 of 3 (gap 2)
If not filed: SAR 5000 per Decision 112377, item 50 (schedule in force)
If not filed: SAR 5000 per HRSD notice of 10 Nov 2025, first offenceRead it as a to-do list. One employee needs two more hours of documented training, the establishment is two graduates short of its 2% target, and the per-group figures are what you type into Training Management.
Step 8: Test the Rules
Every rule from the table at the top gets a test, including the boundaries: 49 versus 50 workers, a contract ending the day before year end, the unit-size setting, and the rounding of 2%.
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildDisclosure } from "../src/aggregate.ts";
import { headcountOn, isCovered } from "../src/coverage.ts";
import { graduateCheck, nonFilingExposure } from "../src/exposure.ts";
import type { Policy, Programme, TrainingRecord, Worker } from "../src/types.ts";
import { validateRecords } from "../src/validate.ts";
const policy: Policy = { year: 2026, hoursPerUnit: 1, minUnitsPerTrainee: 8, graduateRate: 0.02 };
function staff(n: number, unifiedNumber = "7001"): Worker[] {
return Array.from({ length: n }, (_, i) => ({
workerId: `W${i}`,
unifiedNumber,
startDate: "2025-01-01",
}));
}
const catalogue: Programme[] = [
{ programmeId: "P1", title: "Excel for finance", provider: "in-house", internal: true },
{ programmeId: "P2", title: "Safety", provider: "External Co", internal: false },
];
function rec(over: Partial<TrainingRecord>): TrainingRecord {
return {
traineeId: "T1",
category: "employee",
programmeId: "P1",
startDate: "2026-03-01",
endDate: "2026-03-05",
hours: 10,
costSar: 1000,
completed: true,
...over,
};
}
test("coverage starts at 50 workers on 31 December", () => {
assert.equal(isCovered(staff(49), "7001", 2026).covered, false);
assert.equal(isCovered(staff(50), "7001", 2026).covered, true);
});
test("a worker who left before year end is not counted", () => {
const w = staff(50);
w[0] = { ...w[0], endDate: "2026-12-30" };
assert.equal(headcountOn(w, "7001", "2026-12-31"), 49);
});
test("the same worker listed twice counts once", () => {
const w = [...staff(50), { workerId: "W0", unifiedNumber: "7001", startDate: "2026-06-01" }];
assert.equal(headcountOn(w, "7001", "2026-12-31"), 50);
});
test("other unified numbers are ignored", () => {
const w = [...staff(30), ...staff(30, "7002")];
assert.equal(headcountOn(w, "7001", "2026-12-31"), 30);
});
test("validation reports every bad row", () => {
const problems = validateRecords(
[
rec({}),
rec({ endDate: "2027-01-02", startDate: "2026-12-20" }),
rec({ hours: 0, startDate: "2026-04-01", endDate: "2026-04-01" }),
rec({ programmeId: "P9", startDate: "2026-05-01", endDate: "2026-05-01" }),
rec({}),
],
catalogue,
2026,
);
assert.deepEqual(
problems.map((p) => [p.index, p.code]),
[
[1, "OUTSIDE_YEAR"],
[2, "BAD_HOURS"],
[3, "UNKNOWN_PROGRAMME"],
[4, "DUPLICATE"],
],
);
});
test("no completed training means a nil declaration", () => {
const d = buildDisclosure([rec({ completed: false })], policy);
assert.equal(d.nil, true);
assert.equal(d.totalHours, 0);
});
test("trainees are counted once per category, hours and cost summed", () => {
const d = buildDisclosure(
[
rec({}),
rec({ programmeId: "P2", hours: 6, costSar: 450.5 }),
rec({ traineeId: "G1", category: "graduate", hours: 40, costSar: 0 }),
],
policy,
);
assert.equal(d.byCategory.employee.completedTrainees, 1);
assert.equal(d.byCategory.employee.hours, 16);
assert.equal(d.byCategory.employee.costSar, 1450.5);
assert.equal(d.byCategory.graduate.completedTrainees, 1);
assert.equal(d.programmes, 2);
assert.equal(d.totalCostSar, 1450.5);
});
test("trainees under eight units are flagged, and the unit size matters", () => {
const records = [rec({ hours: 6 }), rec({ traineeId: "T2", hours: 9 })];
assert.deepEqual(buildDisclosure(records, policy).belowMinimum, [{ traineeId: "T1", units: 6 }]);
const halfDays = buildDisclosure(records, { ...policy, hoursPerUnit: 4 });
assert.deepEqual(
halfDays.belowMinimum.map((b) => b.traineeId),
["T1", "T2"],
);
});
test("graduate target rounds up", () => {
const d = buildDisclosure([rec({ traineeId: "G1", category: "graduate" })], policy);
assert.deepEqual(graduateCheck(d, 120, policy), { required: 3, trained: 1, gap: 2 });
assert.equal(graduateCheck(d, 50, policy).required, 1);
});
test("both fine readings are returned for covered firms only", () => {
assert.deepEqual(nonFilingExposure(49), []);
assert.deepEqual(
nonFilingExposure(600).map((f) => f.amountSar),
[5_000, 10_000],
);
assert.equal(nonFilingExposure(3_001)[1].amountSar, 15_000);
assert.equal(nonFilingExposure(499)[1].amountSar, 5_000);
});Run them with:
npx tsx --test test/disclosure.test.tsAll ten should pass. If you change a reading, for example the unit size or the rounding, change the test first so the decision is written down somewhere a reviewer will see it.
Troubleshooting
The head count disagrees with Qiwa. Your export probably includes workers registered under another unified number, or misses end dates for people who left. Compare the IDs, not just the totals.
Hours look too high. Check for courses entered once per session instead of once per course. The duplicate check only catches identical trainee, programme and start date.
Graduates show zero. Cooperative-training students and graduate trainees are often tracked by a different team, outside the HR system. They are separate groups in the decision: ask for that list explicitly.
Costs are missing for in-house courses. Enter 0 and keep a note of the trainer's time if you want to report it later. Do not leave the field empty.
Next Steps
- Feed the same worker register into the Nitaqat calculator to check your Saudization band while the data is clean.
- If your training records are spread across systems that cannot export hours and costs per person, that is an integration problem. Our guide to Qiwa and HR systems integration covers it.
- For another rule turned into tested code, see the Saudization engine for gyms and sports centres.
Conclusion
The training disclosure is a small filing with a fixed deadline and a known fine. What makes it hard is the data behind it: a year of training spread across systems that were never meant to agree. A tool of a few hundred lines, with the Ministry's rules as tests and its gaps as named settings, gives you figures you can defend and a list of what to fix before 31 January.
If your records are in that state and you want a second pair of eyes before the deadline, send us a short description of your systems. We will tell you where the numbers will come from and what is missing.