Cherchez une API GOSI et vous ne trouverez aucune API. Vous trouverez le portail GOSI, une poignée d'éditeurs SIRH vantant leur « intégration GOSI » sur leurs pages marketplace, un ou deux courtiers de données accrédités, et une page gouvernementale de 2005 décrivant un canal de liaison directe sécurisé par certificats numériques pour les grands établissements. Ce que vous ne trouverez pas, c'est une documentation sur laquelle bâtir.
C'est exactement la forme rencontrée avec Muqeem : une plateforme avec laquelle tout employeur saoudien doit composer chaque mois, sans surface développeur ouverte, et une couche d'intermédiaires entre les deux. L'instinct pousse à conclure qu'il n'y a rien à construire. Cet instinct est faux, et son erreur coûte cher.
Car la difficulté de la GOSI n'a jamais été le transport. C'est l'arithmétique — et depuis le 3 juillet 2024, cette arithmétique applique deux régimes à la fois, dont l'un augmente chaque juillet jusqu'en 2028. Le 1er juillet 2026, soit cinq semaines avant la rédaction de ces lignes, le taux a de nouveau bougé. Tout système de paie du Royaume ayant stocké un taux de cotisation en constante produit désormais des chiffres faux, et la plupart l'ignorent encore.
Ce tutoriel construit la pièce que vous possédez réellement : un moteur de cotisations daté, et un service de rapprochement qui compare vos calculs à ce que la GOSI vous a facturé. La couche de transport devient un adaptateur en périphérie, délibérément minuscule, pour être une API de courtier aujourd'hui et un CSV demain sans toucher à la logique.
Prérequis
Avant de commencer, vous devriez disposer de :
- Node.js 20+ et TypeScript 5.5+
- Une maîtrise pratique des génériques et des unions discriminées en TypeScript
- Un jeu de données de paie comportant, par salarié : nationalité, date d'immatriculation GOSI, salaire de base, indemnité de logement, dates d'entrée et de sortie
- Un mois de relevé GOSI pour le rapprochement (un export PDF ou Excel du portail employeur suffit pour démarrer)
- Une familiarité avec
zodou une bibliothèque de validation à l'exécution équivalente
Aucun identifiant GOSI n'est nécessaire pour suivre ce guide. Le moteur est entièrement testable hors ligne, et c'est précisément l'intérêt.
Ce que vous allez construire
Une bibliothèque à quatre couches, chacune testable indépendamment :
- Une table de taux temporelle — les taux GOSI comme données datées, non comme constantes
- Un calculateur de salaire cotisable — base plus logement, plafonné, proratisé
- Un moteur de cotisations — par salarié, par mois, conscient du régime, avec un arrondi exact au halala
- Un service de rapprochement — vos chiffres contre le relevé GOSI, avec classification des écarts
Plus une interface d'adaptateur d'accès fine à la frontière, afin que la réalité intermédiée de l'accès GOSI ne contamine pas votre logique métier.
Étape 1 : comprendre les deux régimes
Tout ce qui suit dépend de la justesse de ce modèle : mieux vaut être précis avant d'écrire la moindre ligne.
L'Arabie saoudite fait fonctionner deux régimes d'assurance sociale en parallèle, et celui dont relève un salarié est déterminé par la date de sa toute première immatriculation à la GOSI — ni son employeur actuel, ni la date de son contrat, ni un paramètre défini au niveau de l'entreprise.
Régime existant — première immatriculation avant le 3 juillet 2024. Les taux sont gelés :
| Composante | Employeur | Salarié |
|---|---|---|
| Rentes (retraite) | 9 % | 9 % |
| Risques professionnels | 2 % | — |
| SANED (chômage) | 0,75 % | 0,75 % |
| Total | 11,75 % | 9,75 % |
Cumul : 21,5 %. Cela ne change pas.
Nouveau régime — première immatriculation à compter du 3 juillet 2024. La composante rentes augmente de 0,5 point de pourcentage de chaque côté chaque 1er juillet, jusqu'en 2028 :
| Période | Employeur | Salarié | Cumul |
|---|---|---|---|
| Janv.–juin 2026 | 12,25 % | 10,25 % | 22,5 % |
| À partir de juillet 2026 | 12,75 % | 10,75 % | 23,5 % |
Les composantes risques professionnels (2 %) et SANED (0,75 % / 0,75 %) restent inchangées dans les deux régimes — seules les rentes bougent. Cette décomposition compte, car elle indique que la hausse porte sur une composante et non sur le total : votre table doit le dire explicitement.
Les salariés non saoudiens constituent un troisième cas entièrement distinct : 2 % de risques professionnels côté employeur seulement, aucune retenue salariale, ni rentes ni SANED.
Trois observations qui façonnent la conception :
- L'appartenance au régime est une propriété du salarié, permanente depuis sa première immatriculation. Un salarié qui quitte le marché du travail puis y revient conserve son régime d'origine. Stocker le régime comme un paramètre d'entreprise est ici, de loin, l'erreur de modélisation la plus répandue.
- Le taux dépend du mois calculé, pas de la date du jour. Recalculer mars 2026 au mois d'août 2026 doit produire le taux de mars. Tout code qui lit « le taux actuel » est faux dès la première régularisation.
- Une même paie de juillet 2026 contient légitimement trois profils de taux : Saoudiens du régime existant à 21,5 %, Saoudiens du nouveau régime à 23,5 %, non-Saoudiens à 2 %.
Vérifiez les taux contre le barème officiel de la GOSI pour votre période avant toute mise en production. Les chiffres ci-dessus sont exacts pour 2026 et la trajectoire de hausse est définie jusqu'en 2028, mais une table de taux est exactement le type de donnée qui doit vivre dans un fichier de configuration revu et doté d'un propriétaire nommé — pas dans la mémoire d'un développeur.
Étape 2 : modéliser les taux comme des données datées
Toute la conception repose sur cette étape. Les taux ne sont pas des constantes : ce sont des séries temporelles que l'on interroge avec une date.
// src/rates/types.ts
export type Scheme = "existing" | "new" | "non-saudi";
export interface RateComponents {
/** Composante rentes / retraite */
annuitiesEmployer: number;
annuitiesEmployee: number;
/** Risques professionnels — employeur uniquement */
hazardsEmployer: number;
/** SANED, assurance chômage */
sanedEmployer: number;
sanedEmployee: number;
}
export interface RateBand {
scheme: Scheme;
/** Début inclusif de la plage, date ISO */
effectiveFrom: string;
/** Fin exclusive. null = ouverte */
effectiveTo: string | null;
components: RateComponents;
/** Provenance — quelle circulaire ou loi a défini cette plage */
source: string;
}Notez le champ source. Lorsqu'un directeur financier demandera dans dix-huit mois pourquoi les chiffres de septembre diffèrent de ceux d'août, vous voulez que la réponse soit un champ de la donnée, et non une fouille archéologique dans l'historique git.
La table elle-même :
// 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: "Régime existant, inchangé pour les immatriculations antérieures au 2024-07-03",
},
{
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: "Nouvelle loi d'assurance sociale, année 1 (21,5 % cumulés)",
},
{
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: "Nouveau régime, première hausse de 0,5 pt (22,5 % cumulés)",
},
{
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: "Nouveau régime, deuxième hausse (23,5 % cumulés)",
},
{
scheme: "non-saudi",
effectiveFrom: "2000-01-01",
effectiveTo: null,
components: { ...NONE, hazardsEmployer: 0.02 },
source: "Risques professionnels uniquement pour les cotisants non saoudiens",
},
];Encoder explicitement la composante rentes plutôt que de stocker employerTotal: 0.1275 paie immédiatement : ajouter les plages 2027 et 2028 devient une modification arithmétique d'une ligne sur un seul champ, et les lignes SANED et risques professionnels restent visiblement intactes — ce qui constitue en soi un signal de justesse lors de la relecture.
La recherche est volontairement stricte. Une plage manquante lève une erreur, jamais un zéro silencieux :
// 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(
`Aucune plage de taux GOSI pour le régime "${scheme}" au ${periodStart}. ` +
`La table doit vraisemblablement être étendue — consultez le barème GOSI en vigueur.`,
);
}
return band;
}Cette erreur levée est une fonctionnalité. En janvier 2029, sans plage couvrant la période, ce moteur s'arrête au lieu de facturer discrètement aux taux de 2028. Un système qui échoue bruyamment à une frontière connue vaut considérablement plus qu'un système qui continue de renvoyer des nombres plausibles.
Les chaînes de dates ISO se comparent correctement avec les opérateurs relationnels usuels, de façon lexicographique : aucune bibliothèque de dates n'est nécessaire pour résoudre une plage. Cela mérite d'être préservé — la gestion des fuseaux horaires est une source féconde de décalages d'un mois en paie, et moins vous l'invitez, mieux vous vous portez.
Étape 3 : déterminer le régime d'un salarié
// src/domain/employee.ts
import { z } from "zod";
export const EmployeeSchema = z.object({
employeeId: z.string().min(1),
/** Numéro national (Saoudien) ou numéro d'Iqama (non-Saoudien) */
identityNumber: z.string().regex(/^[12]\d{9}$/, "Doit comporter 10 chiffres commençant par 1 ou 2"),
nationality: z.enum(["saudi", "non-saudi"]),
/** Date de la PREMIÈRE immatriculation GOSI — pas la date du contrat actuel */
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>;Le motif d'identityNumber mérite une note : les numéros nationaux saoudiens commencent par 1, les numéros d'Iqama par 2, tous deux sur dix chiffres. Cette seule expression régulière intercepte une part surprenante des problèmes de données réels — le plus souvent un numéro national collé dans une colonne Iqama lors d'une migration, ce qui produit une incohérence nationalité/régime très difficile à repérer dans les agrégats.
La règle du régime en découle directement :
// 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";
}Dix lignes, et c'est la fonction la plus lourde de conséquences de tout le projet. Trompez-vous de gosiRegistrationDate pour un seul salarié et vous sous-cotiserez ou surcotiserez pour lui chaque mois jusqu'à ce que quelqu'un s'en aperçoive — en pratique, jusqu'à ce que la GOSI s'en aperçoive.
D'où vient
gosiRegistrationDate? Pas de votre SIRH, qui enregistre typiquement la date d'entrée du salarié chez vous. Elle provient des registres d'établissement de la GOSI elle-même. Rapprocher ce champ pour votre effectif existant est un exercice ponctuel, à mener avant d'accorder la moindre confiance aux chiffres ci-dessous.
Étape 4 : calculer le salaire cotisable
Le salaire cotisable n'est pas la rémunération brute. C'est le salaire de base plus l'indemnité de logement, et rien d'autre — ni transport, ni téléphone, ni prime, ni heures supplémentaires. Il est ensuite soumis à un plafond de 45 000 SAR par mois.
Travaillez en halalas, jamais en riyals à virgule flottante :
// src/domain/wage.ts
import type { Employee } from "./employee";
/** 45 000 SAR exprimés en halalas */
export const CONTRIBUTORY_WAGE_CEILING = 4_500_000;
export function toHalalas(riyals: number): number {
return Math.round(riyals * 100);
}
export interface ContributoryWage {
/** En halalas, avant plafond */
declared: number;
/** En halalas, après plafond */
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 };
}Convertir chaque composante séparément avant de sommer est intentionnel. toHalalas(a + b) et toHalalas(a) + toHalalas(b) divergent lorsque les deux entrées portent une erreur flottante inférieure au halala, et des écarts de paie d'un halala sur quatre mille salariés sont exactement le genre de variance qui coûte un après-midi à expliquer.
Exposer ceilingApplied plutôt que l'absorber vous donne un signal de rapprochement utile plus tard : les cadres supérieurs au plafond doivent afficher une cotisation constante d'un mois sur l'autre, si bien que tout mouvement dans leur chiffre est par définition un problème de données.
Étape 5 : proratiser les mois partiels
Un salarié entré le 18 ne doit pas un mois entier. La GOSI proratise selon les jours calendaires de couverture dans le mois.
// src/domain/proration.ts
import type { Employee } from "./employee";
export interface Period {
/** Premier jour du mois de paie, ISO */
start: string;
/** Dernier jour du mois de paie, 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();
}
/** Jours de couverture GOSI de ce salarié dans la période. */
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);
}Deux comportements sont ici délibérés et méritent tous deux un test explicite. La couverture est inclusive aux deux bornes — entrez le 1er et partez le 30 d'un mois de 30 jours et vous êtes couvert 30 jours, pas 29. Et la comparaison « début postérieur à la fin » qui renvoie zéro traite le salarié parti avant le début de la période ou entré après sa fin, cas qui survient constamment dans les données réelles lors des régularisations rétroactives.
Quant à l'idiome new Date(Date.UTC(y, m, 0)), il donne le dernier jour du mois m parce que le jour zéro du mois m+1 recule d'un jour, et que m est déjà indexé à partir de un dans la chaîne ISO. Cela se lit comme un bug sans en être un, d'où le commentaire qui l'accompagne.
Étape 6 : le moteur de cotisations
Les pièces se composent :
// 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;
}
/** Déterministe : arrondi au plus proche, à l'écart de zéro, en 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,
};
}Arrondir chaque composante séparément, puis sommer, est le choix le plus important de cette fonction — et celui que de futurs relecteurs risquent le plus de « corriger ». Arrondissez le total à la place et vos chiffres dériveront de ceux de la GOSI d'un ou deux halalas pour une part non négligeable des salariés, car la GOSI facture elle-même par composante. L'arrondi au niveau de la composante ne coûte rien et supprime toute une catégorie de bruit de rapprochement. Il mérite un commentaire dans votre code le disant.
Renvoyer rateSource sur chaque résultat est l'autre gain discret. Chaque ligne calculée porte la provenance de la règle qui l'a produite : un chiffre contesté devient répondable depuis la sortie, et non depuis le code.
Étape 7 : l'adaptateur d'accès
C'est ici que la réalité intermédiée de la GOSI se trouve confinée. Vous ne pouvez pas appeler la GOSI directement sans accréditation, et le canal dont vous disposerez dépend d'arrangements commerciaux, non techniques. Définissez donc ce dont vous avez besoin et laissez le canal interchangeable :
// 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;
}
/**
* La seule surface dont dépend le domaine. Les implémentations peuvent être une API
* de courtier, l'export d'un éditeur SIRH accrédité, ou un téléchargement du portail analysé.
*/
export interface GosiAccessPort {
fetchStatement(establishmentId: string, period: string): Promise<GosiStatement>;
}Commencez par l'implémentation qui ne requiert aucune accréditation :
// 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,
),
};
}
}L'option bom: true n'est pas décorative. Les exports de relevés qui transitent par Excel portent régulièrement une marque d'ordre des octets UTF-8, qui corrompt silencieusement le nom de la première colonne et transforme identity_number en une clé que vous ne trouverez jamais. C'est une séance de débogage de quinze minutes que vous pouvez simplement décliner.
Cet adaptateur est réellement utile dès le premier jour — quelqu'un télécharge le relevé, le moteur le rapproche, et vous produisez de la valeur avant même qu'une discussion commerciale sur l'accès API n'ait commencé. Quand l'accès accrédité arrivera, vous écrirez une seconde classe contre la même interface et changerez une ligne de câblage. Rien dans src/engine ou src/domain ne verra la différence.
À propos des identifiants : comme pour Muqeem et Qiwa, les droits d'accès GOSI appartiennent à l'établissement, non au prestataire. L'activation passe par un rôle d'administrateur d'établissement, et l'accréditation repose sur le courtier. Concevez en supposant que votre client détient la relation et que vous détenez la logique. Toute conception postulant que vous pouvez détenir un unique jeu d'identifiants pour le compte de multiples établissements ira au mur.
Étape 8 : le service de rapprochement
C'est la couche qui justifie son coût. Vous avez vos chiffres calculés et les chiffres facturés par la GOSI. La valeur n'est pas « les totaux correspondent-ils » — c'est quel salarié, et pourquoi.
// 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;
}
/** Les écarts inférieurs ou égaux à ce seuil sont du bruit, pas des constats. */
const ROUNDING_TOLERANCE = 2;
export function reconcile(
employees: Employee[],
calculated: ContributionResult[],
statement: GosiStatement,
): Variance[] {
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:
"La GOSI facture un cotisant absent de cette paie. " +
"Généralement un sortant jamais radié, ou une immatriculation sous le mauvais établissement.",
});
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:
"Le salaire cotisable diverge. La GOSI retient le dernier salaire qui lui a été déclaré ; " +
"une augmentation appliquée en paie mais jamais transmise se manifeste exactement ainsi.",
});
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: "Dans la tolérance d'arrondi. Aucune action requise.",
});
continue;
}
// Salaire identique, montant matériellement différent : le taux appliqué diffère,
// ce qui signifie presque toujours un désaccord sur le régime.
variances.push({
code: "SCHEME_MISMATCH",
identityNumber: line.identityNumber,
employeeId: calc.employeeId,
calculated: calc.grandTotal,
billed,
deltaHalalas: delta,
explanation:
`Salaire cotisable identique mais écart de ${(delta / 100).toFixed(2)} SAR. ` +
`Calculé sous le régime "${calc.scheme}" — vérifiez la date de première immatriculation GOSI.`,
});
}
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:
"Une cotisation a été calculée pour une personne que la GOSI ne facture pas. " +
"Typiquement une nouvelle recrue non immatriculée — c'est une exposition au retard d'immatriculation, pas une économie.",
});
}
return variances;
}La seconde boucle est la partie que l'on saute, et c'est celle qui rattrape de l'argent. Ne comparer que les lignes du relevé signifie que vous ne trouverez jamais que des salariés que la GOSI connaît. La nouvelle recrue non immatriculée — le cas qui porte l'exposition réelle aux pénalités — n'apparaît nulle part sur le relevé : un rapprochement piloté par le relevé y est donc structurellement aveugle. Parcourez aussi votre propre paie, faute de quoi vous avez construit un rapport incapable de vous annoncer autre chose que de bonnes nouvelles.
L'ordre des contrôles porte lui aussi sa charge : le salaire est comparé avant le montant, car un écart de salaire explique l'écart de montant, et signaler les deux compterait deux fois une cause racine unique. Une fois les salaires concordants et les montants toujours divergents, la seule variable restante est le taux : voilà pourquoi SCHEME_MISMATCH est le bon diagnostic terminal plutôt qu'un AMOUNT_MISMATCH générique.
Étape 9 : tester les frontières
Les bugs de ce domaine se concentrent aux frontières de dates : c'est là que vont les tests.
// 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("sélection du régime", () => {
it("maintient les immatriculations antérieures au 2024-07-03 sur le régime existant malgré la hausse", () => {
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("applique la hausse de juillet 2026 aux immatriculations du nouveau régime", () => {
const newJoiner = { ...base, gosiRegistrationDate: "2025-03-01" };
const june = calculateContribution(newJoiner, june2026);
const july = calculateContribution(newJoiner, july2026);
// 22,5 % -> 23,5 % sur 12 500 SAR = 125 SAR de plus
expect(july.grandTotal - june.grandTotal).toBe(12_500);
expect(july.grandTotal).toBe(293_750);
});
it("traite le 2024-07-03 lui-même comme relevant du nouveau régime", () => {
const boundary = { ...base, gosiRegistrationDate: "2024-07-03" };
expect(calculateContribution(boundary, july2026).scheme).toBe("new");
});
it("ne facture aux non-Saoudiens que les risques professionnels", () => {
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("plafond et proratisation", () => {
it("plafonne le salaire cotisable à 45 000 SAR", () => {
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("proratise une entrée en cours de mois de façon inclusive", () => {
const joiner = { ...base, joinedOn: "2026-07-17" };
const r = calculateContribution(joiner, july2026);
expect(r.prorationFactor).toBeCloseTo(15 / 31); // du 17 au 31 inclus
});
it("renvoie zéro pour une personne partie avant la période", () => {
const leaver = { ...base, leftOn: "2026-05-30" };
expect(calculateContribution(leaver, july2026).grandTotal).toBe(0);
});
});Le premier test est le plus important et paraît trop simple pour mériter d'être écrit. Il affirme que la cotisation d'un salarié du régime existant est identique en juin et en juillet 2026 — que la hausse n'a pas débordé d'un régime à l'autre. C'est précisément le bug qu'expédie une implémentation naïve : une constante de taux unique mise à jour en juillet, qui relève discrètement les cotisations des salariés dont le taux n'a jamais bougé. Rien d'autre dans la suite ne l'attrape.
Figez les nombres de référence en littéraux plutôt que de les recalculer dans le test. 268_750 recalculé à partir de la même table de taux que l'implémentation prouve seulement que le code est d'accord avec lui-même. Écrit à la main d'après le taux publié, il prouve que le code est d'accord avec la loi.
Étape 10 : assembler le tout
// 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;
}, {}),
};
}Valider chaque ligne via EmployeeSchema.parse au point d'entrée, et nulle part plus profondément, est la discipline qui maintient le reste du code honnête. Passé cette ligne, tout Employee est connu comme valide et aucune fonction en aval n'a besoin de contrôle défensif. Une date d'immatriculation malformée échoue ici avec un message clair, au lieu de se résoudre vers le mauvais régime et de produire des chiffres faux pendant un an.
Le décompte summary est ce que lit réellement un responsable paie. Douze constats MISSING_FROM_STATEMENT signifient douze salariés non immatriculés et une exposition réelle aux pénalités ; quarante constats ROUNDING_ONLY ne signifient rien du tout, d'où leur filtrage avant la construction du rapport.
Dépannage
Tous les salariés présentent un petit écart dans le même sens. Votre stratégie d'arrondi diffère de celle de la GOSI. Vérifiez que vous arrondissez par composante et non sur le total, et que l'arrondi se fait à l'écart de zéro.
Les écarts n'apparaissent que pour les hauts salaires. C'est le plafond. Vérifiez qu'il s'applique à base plus logement avant les taux, et non à la cotisation calculée ensuite.
Un seul salarié est facturé à un taux que vous n'avez jamais calculé. Sa date de première immatriculation GOSI dans vos données diverge de celle de la GOSI. Celle de la GOSI fait foi ; corrigez la vôtre.
Les totaux correspondent mais pas les lignes individuelles. Deux salariés intervertis, généralement via un numéro d'identité dupliqué ou transposé. Le contrôle des 10 chiffres avec préfixe [12] en attrape la plupart à l'ingestion.
Les chiffres de juillet ont bondi pour tout le monde. Une constante de taux unique quelque part, appliquée sans égard au régime. C'est l'échec que toute cette conception existe pour empêcher — cherchez toute valeur de taux littérale hors de RATE_TABLE.
Le moteur lève No GOSI rate band. Conforme à la conception. Étendez la table avec les taux publiés en vigueur et consignez leur source.
Prochaines étapes
- Ajoutez les plages 2027 et 2028 dès maintenant, tant que la trajectoire est sous vos yeux, plutôt que de découvrir le trou en juillet prochain
- Persistez chaque exécution afin de comparer d'un mois sur l'autre — un salaire cotisable qui bouge sans augmentation correspondante est un constat d'intégrité des données
- Produisez le rapprochement sous forme de rapport indexé par salarié, non par total, pour qu'il soit remis aux RH et traité directement
- Étendez
GosiAccessPortd'une seconde implémentation quand l'accès accrédité arrivera, en conservant l'adaptateur CSV comme doublure de test - Recoupez le statut d'immatriculation GOSI avec votre fichier WPS — un salarié présent dans l'un et absent de l'autre est un constat dans les deux systèmes
Lectures liées sur ce site :
- Intégration Qiwa pour les SIRH saoudiens — l'argumentaire décisionnel en faveur de l'intégration à la pile des plateformes gouvernementales saoudiennes
- Intégration de la paie Mudad — la plateforme de paie qui consomme les chiffres produits par ce moteur
- Construire un générateur et un validateur de fichier WPS en TypeScript — le même schéma de rapprochement appliqué à la protection des salaires
- Intégration Muqeem pour l'iqama et le visa de sortie-retour — le schéma d'accès intermédié en détail, et pourquoi il façonne votre architecture
Conclusion
L'absence d'API GOSI publique se lit comme un obstacle et constitue en réalité une clarification. Elle vous dit où la valeur d'ingénierie ne se trouve pas : dans le transport, un arrangement commercial que quelqu'un d'autre vous vendra. Et elle vous dit où elle se trouve : dans la logique des taux, les règles de salaire et le rapprochement — que vous possédez intégralement, pouvez tester hors ligne et construire avant toute discussion d'accréditation.
La conception qui en découle est modeste. Les taux sont des données datées à provenance consignée, jamais des constantes. Le régime est une propriété permanente du salarié, résolue depuis sa date de première immatriculation. L'argent est en halalas entiers, arrondis par composante. L'accès est une interface à une seule méthode en périphérie, avec une implémentation CSV qui fonctionne dès aujourd'hui. Le rapprochement parcourt les deux côtés, parce que le salarié absent du relevé est celui qui vous coûte de l'argent.
Deux régimes en parallèle, un taux qui bouge chaque juillet jusqu'en 2028, et une définition du salaire qui ignore l'essentiel de ce que votre paie appelle rémunération — cela allait toujours être un problème de calcul. La hausse de juillet 2026 a déjà eu lieu. La question qui mérite une réponse ce mois-ci est de savoir si vos chiffres de juillet étaient justes, et le rapprochement ci-dessus y répond en un après-midi.
Si vos chiffres GOSI et vos chiffres de paie ont discrètement cessé de concorder, le diagnostic est court : un mois de données de paie, un relevé GOSI, et une exécution de rapprochement. Parlons d'une revue d'intégration — nous vous dirons si le problème vient de votre logique de taux, de vos données d'immatriculation, ou de la définition du salaire en amont des deux.