Search for a GOSI API and you will find no API. You will find the GOSI portal, a handful of HR SaaS vendors advertising "GOSI integration" on their marketplace pages, an accredited data broker or two, and a government page from 2005 describing a direct-link channel secured by digital certificates for large establishments. What you will not find is documentation you can build against.
This is the same shape as Muqeem: a platform every Saudi employer must interact with monthly, no open developer surface, and a layer of intermediaries in between. The instinct is to conclude there is nothing to build. That instinct is wrong, and expensively so.
Because the hard part of GOSI was never the transport. It is the arithmetic — and since 3 July 2024, that arithmetic has been running two different schemes at once, with one of them increasing every July until 2028. On 1 July 2026, five weeks before this was written, the rate moved again. Every payroll system in the Kingdom that stored a contribution rate as a constant is now producing wrong numbers, and most of them do not know it yet.
This tutorial builds the piece you actually own: an effective-dated contribution engine, and a reconciliation service that compares what you calculated against what GOSI billed you. The transport layer is an adapter at the edge, deliberately small, so it can be a broker API today and a CSV export tomorrow without touching the logic.
Prerequisites
Before starting, you should have:
- Node.js 20+ and TypeScript 5.5+
- Working knowledge of TypeScript generics and discriminated unions
- Access to a payroll dataset with, per employee: nationality, GOSI registration date, basic salary, housing allowance, and joining/leaving dates
- One month of GOSI statement data to reconcile against (a PDF or Excel export from the employer portal is fine to start)
- Familiarity with
zodor a similar runtime validation library
No GOSI credentials are required to follow along. The engine is testable entirely offline, which is the point.
What You'll Build
A library with four layers, each independently testable:
- A temporal rate table — GOSI rates as effective-dated data, not constants
- A contributory wage calculator — basic plus housing, capped, prorated
- A contribution engine — per-employee, per-month, scheme-aware, with halala-exact rounding
- A reconciliation service — your figures against the GOSI statement, with classified variances
And a thin access adapter interface at the boundary, so the brokered reality of GOSI data does not leak into your domain logic.
Step 1: Understand the Two Schemes
Everything downstream depends on getting this model right, so it is worth being precise before writing code.
Saudi Arabia operates two parallel social insurance schemes, and which one an employee falls under is determined by the date of their first GOSI registration — not their current employer, not their contract date, not a company-wide setting.
Existing scheme — first registered before 3 July 2024. Rates are frozen:
| Component | Employer | Employee |
|---|---|---|
| Annuities (pension) | 9% | 9% |
| Occupational hazards | 2% | — |
| SANED (unemployment) | 0.75% | 0.75% |
| Total | 11.75% | 9.75% |
Combined: 21.5%. This does not change.
New scheme — first registered on or after 3 July 2024. The annuities component rises 0.5 percentage points on each side every 1 July, through to 2028:
| Period | Employer | Employee | Combined |
|---|---|---|---|
| Jan–Jun 2026 | 12.25% | 10.25% | 22.5% |
| Jul 2026 onward | 12.75% | 10.75% | 23.5% |
The occupational hazards (2%) and SANED (0.75% / 0.75%) components are unchanged in both schemes — only annuities moves. That decomposition matters, because it tells you the step-up applies to one component rather than to the total, and your table should say so.
Non-Saudi employees are a third case entirely: 2% employer-side occupational hazards only, no employee deduction, no annuities, no SANED.
Three observations that shape the design:
- Scheme membership is a property of the employee, permanent from first registration. An employee who leaves and rejoins the workforce keeps their original scheme. Storing scheme as a company setting is the single most common modelling error here.
- The rate depends on the month being calculated, not today's date. Recalculating March 2026 in August 2026 must produce March's rate. Any code that reads a current rate is wrong the first time you run a correction.
- A single payroll run in July 2026 legitimately contains three different rate profiles: existing-scheme Saudis at 21.5%, new-scheme Saudis at 23.5%, and non-Saudis at 2%.
Verify the rates against the official GOSI schedule for your period before going to production. The figures above are correct for 2026 and the step-up pattern is defined through 2028, but rate tables are exactly the kind of data that should live in a reviewed configuration file with a named owner — not in a developer's memory.
Step 2: Model Rates as Effective-Dated Data
The whole design turns on this step. Rates are not constants; they are a time series that you query with a date.
// src/rates/types.ts
export type Scheme = "existing" | "new" | "non-saudi";
export interface RateComponents {
/** Pension / annuities component */
annuitiesEmployer: number;
annuitiesEmployee: number;
/** Occupational hazards — employer only */
hazardsEmployer: number;
/** SANED unemployment insurance */
sanedEmployer: number;
sanedEmployee: number;
}
export interface RateBand {
scheme: Scheme;
/** Inclusive start of the band, ISO date */
effectiveFrom: string;
/** Exclusive end. null = open-ended */
effectiveTo: string | null;
components: RateComponents;
/** Provenance — which circular or law defined this band */
source: string;
}Note source. When a finance director asks in eighteen months why September's numbers differ from August's, you want the answer to be a field in the data rather than an archaeology exercise through git history.
Now the table itself:
// src/rates/table.ts
import type { RateBand } from "./types";
const NONE = {
annuitiesEmployer: 0,
annuitiesEmployee: 0,
hazardsEmployer: 0,
sanedEmployer: 0,
sanedEmployee: 0,
};
export const RATE_TABLE: readonly RateBand[] = [
{
scheme: "existing",
effectiveFrom: "2000-01-01",
effectiveTo: null,
components: {
annuitiesEmployer: 0.09,
annuitiesEmployee: 0.09,
hazardsEmployer: 0.02,
sanedEmployer: 0.0075,
sanedEmployee: 0.0075,
},
source: "Existing scheme, unchanged for pre-2024-07-03 registrations",
},
{
scheme: "new",
effectiveFrom: "2024-07-03",
effectiveTo: "2025-07-01",
components: {
annuitiesEmployer: 0.09,
annuitiesEmployee: 0.09,
hazardsEmployer: 0.02,
sanedEmployer: 0.0075,
sanedEmployee: 0.0075,
},
source: "New Social Insurance Law, year 1 (21.5% combined)",
},
{
scheme: "new",
effectiveFrom: "2025-07-01",
effectiveTo: "2026-07-01",
components: {
annuitiesEmployer: 0.095,
annuitiesEmployee: 0.095,
hazardsEmployer: 0.02,
sanedEmployer: 0.0075,
sanedEmployee: 0.0075,
},
source: "New scheme, first 0.5pp step-up (22.5% combined)",
},
{
scheme: "new",
effectiveFrom: "2026-07-01",
effectiveTo: "2027-07-01",
components: {
annuitiesEmployer: 0.1,
annuitiesEmployee: 0.1,
hazardsEmployer: 0.02,
sanedEmployer: 0.0075,
sanedEmployee: 0.0075,
},
source: "New scheme, second step-up (23.5% combined)",
},
{
scheme: "non-saudi",
effectiveFrom: "2000-01-01",
effectiveTo: null,
components: { ...NONE, hazardsEmployer: 0.02 },
source: "Occupational hazards only for non-Saudi contributors",
},
];Encoding the annuities component explicitly rather than storing employerTotal: 0.1275 pays off immediately: adding the 2027 and 2028 bands is a one-line arithmetic change on a single field, and the SANED and hazards rows stay visibly untouched, which is itself a correctness signal during review.
The lookup is deliberately strict. A missing band is a thrown error, never a silent zero:
// src/rates/lookup.ts
import { RATE_TABLE } from "./table";
import type { RateBand, Scheme } from "./types";
export function resolveRateBand(scheme: Scheme, periodStart: string): RateBand {
const band = RATE_TABLE.find(
(b) =>
b.scheme === scheme &&
periodStart >= b.effectiveFrom &&
(b.effectiveTo === null || periodStart < b.effectiveTo),
);
if (!band) {
throw new Error(
`No GOSI rate band for scheme "${scheme}" at ${periodStart}. ` +
`The rate table likely needs extending — check the current GOSI schedule.`,
);
}
return band;
}That thrown error is a feature. In January 2029, with no band covering the period, this engine stops rather than quietly billing 2028 rates. A system that fails loudly at a known boundary is worth considerably more than one that keeps returning plausible numbers.
ISO date strings compare correctly with the standard relational operators lexicographically, so no date library is needed for band resolution. That is worth keeping — timezone handling is a rich source of off-by-one-month bugs in payroll, and the less of it you invite in, the better.
Step 3: Determine an Employee's Scheme
// src/domain/employee.ts
import { z } from "zod";
export const EmployeeSchema = z.object({
employeeId: z.string().min(1),
/** National ID (Saudi) or Iqama number (non-Saudi) */
identityNumber: z.string().regex(/^[12]\d{9}$/, "Must be 10 digits starting with 1 or 2"),
nationality: z.enum(["saudi", "non-saudi"]),
/** Date of FIRST ever GOSI registration — not the current contract date */
gosiRegistrationDate: z.string().date(),
basicSalary: z.number().nonnegative(),
housingAllowance: z.number().nonnegative(),
joinedOn: z.string().date(),
leftOn: z.string().date().nullable(),
});
export type Employee = z.infer<typeof EmployeeSchema>;The identityNumber pattern is worth a note: Saudi national IDs begin with 1, Iqama numbers with 2, both ten digits. That single regex catches a surprising share of real data problems — most often a national ID pasted into an Iqama column during a migration, which then produces a nationality/scheme mismatch that is very hard to spot in aggregate.
The scheme rule follows directly:
// src/domain/scheme.ts
import type { Employee } from "./employee";
import type { Scheme } from "../rates/types";
const NEW_SCHEME_START = "2024-07-03";
export function resolveScheme(employee: Employee): Scheme {
if (employee.nationality === "non-saudi") return "non-saudi";
return employee.gosiRegistrationDate >= NEW_SCHEME_START ? "new" : "existing";
}Ten lines, and it is the single most consequential function in the codebase. Get gosiRegistrationDate wrong for one employee and you under- or over-contribute for them every month until someone notices — which, in practice, is when GOSI notices.
Where does
gosiRegistrationDatecome from? Not from your HR system, which typically records the date the employee joined you. It comes from GOSI's own establishment records. Reconciling this field for your existing headcount is a one-time exercise you should do before trusting any of the numbers below.
Step 4: Calculate the Contributory Wage
The contributory wage is not gross pay. It is basic salary plus housing allowance, and nothing else — no transport, no phone, no bonus, no overtime. It is then subject to a ceiling of SAR 45,000 per month.
Work in halalas, never in floating-point riyals:
// src/domain/wage.ts
import type { Employee } from "./employee";
/** SAR 45,000 expressed in halalas */
export const CONTRIBUTORY_WAGE_CEILING = 4_500_000;
export function toHalalas(riyals: number): number {
return Math.round(riyals * 100);
}
export interface ContributoryWage {
/** In halalas, before the ceiling */
declared: number;
/** In halalas, after the ceiling */
capped: number;
ceilingApplied: boolean;
}
export function contributoryWage(employee: Employee): ContributoryWage {
const declared = toHalalas(employee.basicSalary) + toHalalas(employee.housingAllowance);
const capped = Math.min(declared, CONTRIBUTORY_WAGE_CEILING);
return { declared, capped, ceilingApplied: capped < declared };
}Converting each component separately before summing is intentional. toHalalas(a + b) and toHalalas(a) + toHalalas(b) diverge when both inputs carry sub-halala float error, and payroll differences of one halala across four thousand employees are exactly the kind of variance that costs an afternoon to explain.
Surfacing ceilingApplied rather than swallowing it gives you a useful reconciliation signal later: senior employees at the ceiling should show a flat contribution month over month, so any movement in their figure is a data problem by definition.
Step 5: Prorate Partial Months
An employee who joins on the 18th does not owe a full month. GOSI prorates by calendar days of coverage within the month.
// src/domain/proration.ts
import type { Employee } from "./employee";
export interface Period {
/** First day of the payroll month, ISO */
start: string;
/** Last day of the payroll month, ISO */
end: string;
}
export function daysInPeriod(period: Period): number {
const [y, m] = period.start.split("-").map(Number);
return new Date(Date.UTC(y, m, 0)).getUTCDate();
}
/** Days of GOSI coverage this employee has within the period. */
export function coveredDays(employee: Employee, period: Period): number {
const total = daysInPeriod(period);
const from = employee.joinedOn > period.start ? employee.joinedOn : period.start;
const to =
employee.leftOn !== null && employee.leftOn < period.end ? employee.leftOn : period.end;
if (from > to) return 0;
const fromDay = Number(from.slice(8, 10));
const toDay = Number(to.slice(8, 10));
return Math.min(toDay - fromDay + 1, total);
}
export function prorationFactor(employee: Employee, period: Period): number {
return coveredDays(employee, period) / daysInPeriod(period);
}Two behaviours here are deliberate and both are worth testing explicitly. Coverage is inclusive of both endpoints — join on the 1st and leave on the 30th of a 30-day month and you are covered for 30 days, not 29. And a start-after-end comparison returning zero handles the employee who left before the period started or joined after it ended, which occurs constantly in real data during back-dated corrections.
The new Date(Date.UTC(y, m, 0)) idiom gives the last day of month m because day zero of month m+1 rolls back one day, and m is already one-indexed from the ISO string. It reads as a bug and is not one, so it earns its comment.
Step 6: The Contribution Engine
Now the pieces compose:
// src/engine/calculate.ts
import { resolveRateBand } from "../rates/lookup";
import { resolveScheme } from "../domain/scheme";
import { contributoryWage } from "../domain/wage";
import { prorationFactor, type Period } from "../domain/proration";
import type { Employee } from "../domain/employee";
export interface ContributionBreakdown {
annuitiesEmployer: number;
annuitiesEmployee: number;
hazardsEmployer: number;
sanedEmployer: number;
sanedEmployee: number;
}
export interface ContributionResult {
employeeId: string;
period: string;
scheme: string;
contributoryWage: number;
ceilingApplied: boolean;
prorationFactor: number;
breakdown: ContributionBreakdown;
employerTotal: number;
employeeTotal: number;
grandTotal: number;
rateSource: string;
}
/** Bankers-free, deterministic: round half away from zero, in halalas. */
function applyRate(wageHalalas: number, rate: number, factor: number): number {
return Math.round(wageHalalas * rate * factor);
}
export function calculateContribution(
employee: Employee,
period: Period,
): ContributionResult {
const scheme = resolveScheme(employee);
const band = resolveRateBand(scheme, period.start);
const wage = contributoryWage(employee);
const factor = prorationFactor(employee, period);
const c = band.components;
const breakdown: ContributionBreakdown = {
annuitiesEmployer: applyRate(wage.capped, c.annuitiesEmployer, factor),
annuitiesEmployee: applyRate(wage.capped, c.annuitiesEmployee, factor),
hazardsEmployer: applyRate(wage.capped, c.hazardsEmployer, factor),
sanedEmployer: applyRate(wage.capped, c.sanedEmployer, factor),
sanedEmployee: applyRate(wage.capped, c.sanedEmployee, factor),
};
const employerTotal =
breakdown.annuitiesEmployer + breakdown.hazardsEmployer + breakdown.sanedEmployer;
const employeeTotal = breakdown.annuitiesEmployee + breakdown.sanedEmployee;
return {
employeeId: employee.employeeId,
period: period.start.slice(0, 7),
scheme,
contributoryWage: wage.capped,
ceilingApplied: wage.ceilingApplied,
prorationFactor: factor,
breakdown,
employerTotal,
employeeTotal,
grandTotal: employerTotal + employeeTotal,
rateSource: band.source,
};
}Rounding each component separately, then summing, is the choice that matters most in this function — and it is the one most likely to be "corrected" by a future reader. Round the total instead and your figures will drift from GOSI's by a halala or two on a meaningful share of employees, because GOSI itself bills by component. Component-level rounding costs nothing and removes an entire category of reconciliation noise. It deserves a comment in your codebase saying so.
Returning rateSource on every result is the other quiet win. Every calculated line carries the provenance of the rule that produced it, so a disputed figure is answerable from the output rather than from the code.
Step 7: The Access Adapter
Here is where the brokered reality of GOSI gets contained. You cannot call GOSI directly without accreditation, and the channel you end up with depends on commercial arrangements, not engineering ones. So define what you need and let the channel be swappable:
// src/access/port.ts
export interface GosiStatementLine {
identityNumber: string;
contributoryWage: number;
employerAmount: number;
employeeAmount: number;
}
export interface GosiStatement {
establishmentId: string;
period: string;
lines: GosiStatementLine[];
totalBilled: number;
}
/**
* The only surface the domain depends on. Implementations may be a broker API,
* an accredited HRMS vendor's export, or a parsed portal download.
*/
export interface GosiAccessPort {
fetchStatement(establishmentId: string, period: string): Promise<GosiStatement>;
}Start with the implementation that requires no accreditation at all:
// src/access/csv-adapter.ts
import { parse } from "csv-parse/sync";
import { toHalalas } from "../domain/wage";
import type { GosiAccessPort, GosiStatement } from "./port";
export class CsvStatementAdapter implements GosiAccessPort {
constructor(private readonly loadFile: (period: string) => Promise<string>) {}
async fetchStatement(establishmentId: string, period: string): Promise<GosiStatement> {
const raw = await this.loadFile(period);
const rows = parse(raw, { columns: true, skip_empty_lines: true, bom: true });
const lines = rows.map((r: Record<string, string>) => ({
identityNumber: r["identity_number"].trim(),
contributoryWage: toHalalas(Number(r["contributory_wage"])),
employerAmount: toHalalas(Number(r["employer_amount"])),
employeeAmount: toHalalas(Number(r["employee_amount"])),
}));
return {
establishmentId,
period,
lines,
totalBilled: lines.reduce(
(sum, l) => sum + l.employerAmount + l.employeeAmount,
0,
),
};
}
}bom: true is not decoration. Statement exports that pass through Excel routinely carry a UTF-8 byte-order mark, which silently corrupts the first column name and turns identity_number into a key you will never match. It is a fifteen-minute debugging session that you can simply decline to have.
This adapter is genuinely useful on day one — someone downloads the statement, the engine reconciles it, and you have value before any commercial conversation about API access has started. When accredited access does arrive, you write a second class against the same interface and change one line of wiring. Nothing in src/engine or src/domain knows the difference.
On credentials: as with Muqeem and Qiwa, GOSI access rights belong to the establishment, not to the vendor. Activation runs through an establishment administrator role, and the accreditation sits with the broker. Build assuming your customer holds the relationship and you hold the logic. Any design that assumes you can hold a single set of credentials on behalf of many establishments is going to meet a wall.
Step 8: The Reconciliation Service
This is the layer that earns its keep. You have your calculated figures and GOSI's billed figures. The value is not "do the totals match" — it is which employee, and why.
// src/recon/reconcile.ts
import type { ContributionResult } from "../engine/calculate";
import type { GosiStatement } from "../access/port";
import type { Employee } from "../domain/employee";
export type VarianceCode =
| "MISSING_FROM_STATEMENT"
| "MISSING_FROM_PAYROLL"
| "WAGE_MISMATCH"
| "SCHEME_MISMATCH"
| "AMOUNT_MISMATCH"
| "ROUNDING_ONLY";
export interface Variance {
code: VarianceCode;
identityNumber: string;
employeeId: string | null;
calculated: number | null;
billed: number | null;
deltaHalalas: number;
explanation: string;
}
/** Differences at or below this are noise, not findings. */
const ROUNDING_TOLERANCE = 2;
export function reconcile(
employees: Employee[],
calculated: ContributionResult[],
statement: GosiStatement,
): Variance[] {
const byIdentity = new Map(employees.map((e) => [e.identityNumber, e]));
const calcByIdentity = new Map<string, ContributionResult>();
for (const c of calculated) {
const emp = employees.find((e) => e.employeeId === c.employeeId);
if (emp) calcByIdentity.set(emp.identityNumber, c);
}
const variances: Variance[] = [];
const seen = new Set<string>();
for (const line of statement.lines) {
seen.add(line.identityNumber);
const calc = calcByIdentity.get(line.identityNumber);
if (!calc) {
variances.push({
code: "MISSING_FROM_PAYROLL",
identityNumber: line.identityNumber,
employeeId: null,
calculated: null,
billed: line.employerAmount + line.employeeAmount,
deltaHalalas: line.employerAmount + line.employeeAmount,
explanation:
"GOSI is billing for a contributor absent from this payroll run. " +
"Usually a leaver who was never deregistered, or a registration under the wrong establishment.",
});
continue;
}
if (calc.contributoryWage !== line.contributoryWage) {
variances.push({
code: "WAGE_MISMATCH",
identityNumber: line.identityNumber,
employeeId: calc.employeeId,
calculated: calc.contributoryWage,
billed: line.contributoryWage,
deltaHalalas: calc.contributoryWage - line.contributoryWage,
explanation:
"Contributory wage disagrees. GOSI holds the wage last reported to it; " +
"a raise applied in payroll but never pushed to GOSI shows up exactly like this.",
});
continue;
}
const billed = line.employerAmount + line.employeeAmount;
const delta = calc.grandTotal - billed;
if (delta === 0) continue;
if (Math.abs(delta) <= ROUNDING_TOLERANCE) {
variances.push({
code: "ROUNDING_ONLY",
identityNumber: line.identityNumber,
employeeId: calc.employeeId,
calculated: calc.grandTotal,
billed,
deltaHalalas: delta,
explanation: "Within rounding tolerance. No action required.",
});
continue;
}
// Same wage, materially different amount: the rate applied differs,
// which almost always means the two sides disagree about the scheme.
variances.push({
code: "SCHEME_MISMATCH",
identityNumber: line.identityNumber,
employeeId: calc.employeeId,
calculated: calc.grandTotal,
billed,
deltaHalalas: delta,
explanation:
`Identical contributory wage but a ${(delta / 100).toFixed(2)} SAR difference. ` +
`Calculated under the "${calc.scheme}" scheme — verify the first GOSI registration date.`,
});
}
for (const calc of calculated) {
const emp = employees.find((e) => e.employeeId === calc.employeeId);
if (!emp || seen.has(emp.identityNumber)) continue;
if (calc.grandTotal === 0) continue;
variances.push({
code: "MISSING_FROM_STATEMENT",
identityNumber: emp.identityNumber,
employeeId: calc.employeeId,
calculated: calc.grandTotal,
billed: null,
deltaHalalas: calc.grandTotal,
explanation:
"Calculated a contribution for someone GOSI is not billing. " +
"Typically an unregistered new hire — this is a late-registration exposure, not a saving.",
});
}
return variances;
}The second loop is the part people skip, and it is the one that catches money. Comparing only the rows on the statement means you can only ever find employees GOSI knows about. The unregistered new hire — the case that carries actual penalty exposure — appears nowhere on the statement, so a statement-driven reconciliation is structurally blind to it. Iterate your own payroll too, or you have built a report that can only ever tell you good news.
The ordering of the checks is load-bearing as well: wage is compared before amount, because a wage mismatch explains the amount mismatch and reporting both would double-count one root cause. Once wages agree and amounts still do not, the only remaining variable is the rate, which is why SCHEME_MISMATCH is the correct terminal diagnosis rather than a generic AMOUNT_MISMATCH.
Step 9: Test the Boundaries
The bugs in this domain cluster at date boundaries, so that is where the tests go.
// src/engine/calculate.test.ts
import { describe, it, expect } from "vitest";
import { calculateContribution } from "./calculate";
import type { Employee } from "../domain/employee";
const base: Employee = {
employeeId: "E-001",
identityNumber: "1012345678",
nationality: "saudi",
gosiRegistrationDate: "2020-01-15",
basicSalary: 10_000,
housingAllowance: 2_500,
joinedOn: "2020-01-15",
leftOn: null,
};
const june2026 = { start: "2026-06-01", end: "2026-06-30" };
const july2026 = { start: "2026-07-01", end: "2026-07-31" };
describe("scheme selection", () => {
it("keeps pre-2024-07-03 registrations on the existing scheme across the step-up", () => {
const june = calculateContribution(base, june2026);
const july = calculateContribution(base, july2026);
expect(june.grandTotal).toBe(july.grandTotal);
expect(july.scheme).toBe("existing");
// 12,500 SAR * 21.5% = 2,687.50 SAR
expect(july.grandTotal).toBe(268_750);
});
it("applies the July 2026 step-up to new-scheme registrations", () => {
const newJoiner = { ...base, gosiRegistrationDate: "2025-03-01" };
const june = calculateContribution(newJoiner, june2026);
const july = calculateContribution(newJoiner, july2026);
// 22.5% -> 23.5% on 12,500 SAR = 125 SAR more
expect(july.grandTotal - june.grandTotal).toBe(12_500);
expect(july.grandTotal).toBe(293_750);
});
it("treats 2024-07-03 itself as the new scheme", () => {
const boundary = { ...base, gosiRegistrationDate: "2024-07-03" };
expect(calculateContribution(boundary, july2026).scheme).toBe("new");
});
it("charges non-Saudis occupational hazards only", () => {
const expat = { ...base, nationality: "non-saudi" as const, identityNumber: "2012345678" };
const r = calculateContribution(expat, july2026);
expect(r.employeeTotal).toBe(0);
expect(r.employerTotal).toBe(25_000); // 12,500 * 2%
});
});
describe("ceiling and proration", () => {
it("caps the contributory wage at SAR 45,000", () => {
const exec = { ...base, basicSalary: 60_000, housingAllowance: 15_000 };
const r = calculateContribution(exec, july2026);
expect(r.ceilingApplied).toBe(true);
expect(r.contributoryWage).toBe(4_500_000);
});
it("prorates a mid-month joiner inclusively", () => {
const joiner = { ...base, joinedOn: "2026-07-17" };
const r = calculateContribution(joiner, july2026);
expect(r.prorationFactor).toBeCloseTo(15 / 31); // 17th to 31st inclusive
});
it("returns zero for someone who left before the period", () => {
const leaver = { ...base, leftOn: "2026-05-30" };
expect(calculateContribution(leaver, july2026).grandTotal).toBe(0);
});
});The first test is the important one and it looks almost too simple to bother writing. It asserts that an existing-scheme employee's contribution is identical in June and July 2026 — that the step-up did not leak across schemes. That is precisely the bug a naive implementation ships: a single rate constant updated in July, quietly raising contributions for the employees whose rate never moved. Nothing else in the suite catches it.
Pin the golden numbers as literals rather than recomputing them in the test. 268_750 recomputed from the same rate table as the implementation proves only that the code agrees with itself. Written by hand from the published rate, it proves the code agrees with the law.
Step 10: Wire It Together
// src/run.ts
import { readFile } from "node:fs/promises";
import { calculateContribution } from "./engine/calculate";
import { CsvStatementAdapter } from "./access/csv-adapter";
import { reconcile } from "./recon/reconcile";
import { EmployeeSchema, type Employee } from "./domain/employee";
export async function runMonthlyReconciliation(
establishmentId: string,
period: { start: string; end: string },
rawEmployees: unknown[],
) {
const employees: Employee[] = rawEmployees.map((r) => EmployeeSchema.parse(r));
const calculated = employees.map((e) => calculateContribution(e, period));
const adapter = new CsvStatementAdapter((p) =>
readFile(`./statements/${establishmentId}-${p}.csv`, "utf8"),
);
const statement = await adapter.fetchStatement(establishmentId, period.start.slice(0, 7));
const variances = reconcile(employees, calculated, statement);
const calculatedTotal = calculated.reduce((s, c) => s + c.grandTotal, 0);
const actionable = variances.filter((v) => v.code !== "ROUNDING_ONLY");
return {
period: period.start.slice(0, 7),
headcount: employees.length,
calculatedTotal,
billedTotal: statement.totalBilled,
difference: calculatedTotal - statement.totalBilled,
actionable,
summary: actionable.reduce<Record<string, number>>((acc, v) => {
acc[v.code] = (acc[v.code] ?? 0) + 1;
return acc;
}, {}),
};
}Validating every row through EmployeeSchema.parse at the entry point, and nowhere deeper, is the discipline that keeps the rest of the code honest. Past this line, every Employee is known-good and no function downstream needs a defensive check. A malformed registration date fails here with a clear message rather than resolving to the wrong scheme and producing wrong numbers for a year.
The summary count is what a payroll owner actually reads. Twelve MISSING_FROM_STATEMENT findings mean twelve unregistered employees and a real penalty exposure; forty ROUNDING_ONLY findings mean nothing at all, which is why they are filtered out before the report is built.
Troubleshooting
Every employee shows a small variance in the same direction. Your rounding strategy differs from GOSI's. Confirm you are rounding per component rather than on the total, and that you round half away from zero.
Variances appear only for high earners. The ceiling. Check that it is applied to basic plus housing before rates, not to the computed contribution afterwards.
A single employee is billed at a rate you never calculated. Their first GOSI registration date in your records disagrees with GOSI's. GOSI's is authoritative; correct yours.
Totals match but individual lines do not. Two employees swapped, usually via a duplicated or transposed identity number. The 10-digit [12] prefix check catches most of these at ingest.
July figures jumped for everyone. A single rate constant somewhere, applied without regard to scheme. This is the failure this entire design exists to prevent — grep for hardcoded rate literals outside RATE_TABLE.
The engine throws No GOSI rate band. Working as designed. Extend the table with the current published rates and record the source.
Next Steps
- Add the 2027 and 2028 bands now, while the step-up pattern is in front of you, rather than discovering the gap next July
- Persist every run so you can diff month over month — a contributory wage that moves without a corresponding raise is a data-integrity finding
- Emit the reconciliation as a report keyed by employee, not by total, so it can be handed to HR and acted on directly
- Extend the
GosiAccessPortwith a second implementation when accredited access lands, and keep the CSV adapter as your test double - Cross-check GOSI registration status against your WPS file — an employee in one and not the other is a finding in both systems
Related reading on this site:
- Qiwa Integration for Saudi HR Systems — the decision-stage case for integrating the Saudi government platform stack
- Mudad Payroll Integration — the payroll platform that consumes the figures this engine produces
- Building a WPS File Generator and Validator in TypeScript — the same reconciliation pattern applied to wage protection
- Muqeem Integration for Iqama and Exit-Reentry — the brokered-access pattern in detail, and why it shapes your architecture
Conclusion
The absence of a public GOSI API reads like a blocker and is actually a clarification. It tells you where the engineering value is not: in the transport, which is a commercial arrangement someone else will sell you. And it tells you where the value is: in the rate logic, the wage rules, and the reconciliation — all of which you own outright, can test offline, and can build before any accreditation conversation begins.
The design that follows from that is small. Rates are effective-dated data with recorded provenance, never constants. Scheme is a permanent property of the employee, resolved from their first registration date. Money is integer halalas, rounded per component. Access is a one-method interface at the edge with a CSV implementation that works today. Reconciliation walks both sides, because the employee missing from the statement is the one who costs you money.
Two schemes running in parallel, a rate that moves every July through 2028, and a wage definition that ignores most of what your payroll calls salary — this was always going to be a calculation problem. The July 2026 step-up has already happened. The question worth answering this month is whether your July numbers were right, and the reconciliation above answers it in an afternoon.
If your GOSI figures and your payroll figures have quietly stopped agreeing, the diagnosis is short: one month of payroll data, one GOSI statement, and a reconciliation run. Talk to us about an integration review — we will tell you whether the problem is your rate logic, your registration data, or the wage definition upstream of both.