Cherchez quoi que ce soit sur le système saoudien de protection des salaires et vous obtiendrez vingt fois la même page : connectez-vous à Mudad, choisissez votre établissement, cliquez sur téléverser. Chaque résultat s'adresse à quelqu'un qui clique dans un portail. Aucun ne s'adresse à la personne qui doit produire le fichier.
C'est dans cet écart que vivent les violations. Les salaires sont versés correctement, le virement passe, et le pourcentage de conformité chute quand même — parce que le fichier décrivant ces salaires contredisait le registre de l'établissement d'une manière que personne n'a vérifiée avant le téléversement. Quand le rejet revient, le cycle de paie est clos et la fenêtre de correction se referme.
Ce tutoriel construit ce qui devrait se trouver entre votre SIRH et ce bouton : un service TypeScript qui assemble le fichier des salaires à partir de vos propres données de paie, valide chaque enregistrement selon les règles qui provoquent réellement les rejets, rapproche les montants des données contractuelles et d'immatriculation, et produit un rapport nommant exactement quelle ligne échouera et pourquoi.
Une note sur la spécification du fichier. L'ordre des champs, le délimiteur et la structure de l'en-tête diffèrent selon les banques, et entre le canal bancaire et Mudad. Il n'existe pas de spécification publique unique au niveau de l'octet qui reste vraie partout. Nous n'en codons donc aucune en dur. Nous construisons un générateur piloté par schéma, où la mise en forme du fichier est une configuration que vous remplissez depuis le modèle de votre banque, tandis que le moteur de validation — la partie qui a de la valeur — reste identique. Vérifiez toujours l'agencement des champs contre le modèle fourni par votre banque avant la mise en production.
Prérequis
- Node.js 20 ou plus récent
- Les bases de TypeScript — génériques et unions discriminées apparaissent ici
- Une familiarité avec Zod ou une bibliothèque de schémas équivalente
- L'accès au modèle de fichier de votre banque ou de Mudad (pour la configuration)
- Des données de paie exportables : identifiants employés, IBAN, composantes salariales
Ce que vous allez construire
Un paquet à quatre couches, chacune testable indépendamment :
- Un enregistrement de paie canonique — votre modèle métier, délibérément indépendant de tout format de fichier
- Un profil de mise en forme — une configuration déclarative décrivant le format d'une banque
- Un moteur de validation — les règles qui prédisent le rejet, y compris le rapprochement inter-sources
- Un writer et un rapport — le fichier texte lui-même, plus une liste d'échecs lisible
L'ordre compte. La plupart des implémentations internes commencent par la quatrième couche, écrivent un gabarit de chaînes qui produit un fichier, et découvrent le problème de validation six avis de violation plus tard.
Étape 1 : Mise en place du projet
mkdir wps-toolkit && cd wps-toolkit
npm init -y
npm install zod
npm install -D typescript tsx vitest @types/node
npx tsc --initRéglez le compilateur assez strictement pour que la manipulation des montants ne pourrisse pas :
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"outDir": "dist"
},
"include": ["src"]
}noUncheckedIndexedAccess compte davantage qu'il n'y paraît. La plupart des bugs de fichiers de salaires sont une indexation de tableau sur une colonne absente.
Étape 2 : Modéliser l'enregistrement canonique
Ne modélisez pas le fichier. Modélisez la paie. Le fichier est une projection de la paie, et si vous inversez cette relation vous finirez avec une structure de données propre à une banque qui fuit dans tout votre code.
// src/domain.ts
import { z } from "zod";
/** Les montants sont stockés en halalas (unités mineures entières) — jamais en flottants. */
export const Halalas = z.number().int().nonnegative();
export const PayrollRecord = z.object({
/** Numéro d'Iqama pour les non-Saoudiens, identité nationale pour les Saoudiens. 10 chiffres. */
nationalId: z.string().regex(/^\d{10}$/),
/** Nom tel qu'enregistré auprès de l'établissement, pas un surnom. */
fullName: z.string().min(1).max(100),
/** IBAN saoudien — 24 caractères, préfixe SA. */
iban: z.string().regex(/^SA\d{22}$/),
basicSalary: Halalas,
housingAllowance: Halalas,
otherAllowances: Halalas,
deductions: Halalas,
/** Ce qui a réellement quitté le compte, en halalas. */
netPaid: Halalas,
/** Date du virement au format ISO. */
paymentDate: z.string().date(),
/** Jours réellement travaillés sur la période — pilote les cas de salaire partiel. */
workedDays: z.number().int().min(0).max(31),
});
export type PayrollRecord = z.infer<typeof PayrollRecord>;
export const PayrollBatch = z.object({
/** Identifiant d'établissement auprès du ministère (bureau du travail et séquence). */
establishmentId: z.string().min(1),
/** Numéro de registre de commerce sous lequel le fichier est déposé. */
crNumber: z.string().regex(/^\d{10}$/),
/** Mois de salaire déclaré, au format YYYY-MM. */
period: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/),
bankCode: z.string().min(1),
records: z.array(PayrollRecord).min(1),
});
export type PayrollBatch = z.infer<typeof PayrollBatch>;Deux décisions méritent d'être défendues.
Les montants en halalas entiers. Les fichiers de salaires sont comparés aux virements bancaires au halala près. Une représentation flottante de 4 733,15 finira par se sérialiser en 4733.1499999999996 et produira un écart que personne ne saura expliquer. Stockez les unités mineures en entiers, formatez uniquement en bordure.
netPaid comme champ à part entière et non comme valeur calculée. Il est tentant de le dériver. Ne le faites pas. Tout l'intérêt de la couche de rapprochement est de comparer ce que vous déclarez avoir payé à ce que vous calculez devoir payer. Si vous dérivez l'un de l'autre, les deux ne peuvent plus diverger, et vous avez détruit le signal même que le système devait détecter.
Étape 3 : Décrire la mise en forme comme une configuration
C'est ici que va le savoir propre à la banque, et nulle part ailleurs.
// src/layout.ts
import type { PayrollBatch, PayrollRecord } from "./domain.js";
export type FieldSource =
| { kind: "record"; render: (r: PayrollRecord) => string }
| { kind: "batch"; render: (b: PayrollBatch) => string }
| { kind: "literal"; value: string };
export interface FieldSpec {
name: string;
source: FieldSource;
/** Uniquement pour les formats à largeur fixe ; à omettre pour les fichiers délimités. */
width?: number;
pad?: "left" | "right";
}
export interface LayoutProfile {
id: string;
delimiter: string;
lineEnding: "\r\n" | "\n";
encoding: "utf8" | "latin1";
/** Certains canaux veulent une ligne d'en-tête, d'autres la rejettent. */
headerFields?: FieldSpec[];
detailFields: FieldSpec[];
/** Ligne de fin avec le nombre d'enregistrements et les totaux de contrôle, si requis. */
trailerFields?: FieldSpec[];
}Un profil concret se lit alors comme la documentation du modèle de votre banque :
// src/profiles/generic-delimited.ts
import type { LayoutProfile } from "../layout.js";
const halalasToRiyals = (h: number) => (h / 100).toFixed(2);
export const genericDelimited: LayoutProfile = {
id: "generic-delimited-v1",
delimiter: ",",
lineEnding: "\r\n",
encoding: "utf8",
headerFields: [
{ name: "recordType", source: { kind: "literal", value: "HDR" } },
{ name: "establishmentId", source: { kind: "batch", render: (b) => b.establishmentId } },
{ name: "crNumber", source: { kind: "batch", render: (b) => b.crNumber } },
{ name: "period", source: { kind: "batch", render: (b) => b.period.replace("-", "") } },
{ name: "bankCode", source: { kind: "batch", render: (b) => b.bankCode } },
{ name: "recordCount", source: { kind: "batch", render: (b) => String(b.records.length) } },
],
detailFields: [
{ name: "recordType", source: { kind: "literal", value: "DTL" } },
{ name: "nationalId", source: { kind: "record", render: (r) => r.nationalId } },
{ name: "fullName", source: { kind: "record", render: (r) => r.fullName } },
{ name: "iban", source: { kind: "record", render: (r) => r.iban } },
{ name: "basicSalary", source: { kind: "record", render: (r) => halalasToRiyals(r.basicSalary) } },
{ name: "housingAllowance", source: { kind: "record", render: (r) => halalasToRiyals(r.housingAllowance) } },
{ name: "otherAllowances", source: { kind: "record", render: (r) => halalasToRiyals(r.otherAllowances) } },
{ name: "deductions", source: { kind: "record", render: (r) => halalasToRiyals(r.deductions) } },
{ name: "netPaid", source: { kind: "record", render: (r) => halalasToRiyals(r.netPaid) } },
{ name: "paymentDate", source: { kind: "record", render: (r) => r.paymentDate.replaceAll("-", "") } },
{ name: "workedDays", source: { kind: "record", render: (r) => String(r.workedDays) } },
],
};Adaptez la liste et l'ordre des champs au modèle réel de votre banque avant de livrer. C'est la seule raison d'être de ce fichier. Et quand la banque changera sa spécification — elle le fera — vous modifierez un tableau au lieu de traquer des concaténations de chaînes.
Étape 4 : Les validateurs qui prédisent le rejet
La validation de schéma de l'étape 2 attrape les erreurs de forme. Elle n'attrape pas ce qui fait réellement rejeter les fichiers. Cela demande une vraie logique.
Les chiffres de contrôle de l'IBAN
Un IBAN dont deux chiffres ont été intervertis passe la regex et échoue à la banque. Le modulo 97 le détecte de façon déterministe.
// src/validators/iban.ts
/** Contrôle ISO 13616 mod-97. Renvoie true si les chiffres de contrôle sont cohérents. */
export function isValidIban(iban: string): boolean {
const clean = iban.replace(/\s+/g, "").toUpperCase();
if (clean.length < 15 || clean.length > 34) return false;
// Déplacer les quatre premiers caractères à la fin, puis mapper les lettres en chiffres.
const rearranged = clean.slice(4) + clean.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (c) =>
String(c.charCodeAt(0) - 55),
);
// Le nombre dépasse largement Number.MAX_SAFE_INTEGER : on réduit par morceaux.
let remainder = 0;
for (const digit of numeric) {
remainder = (remainder * 10 + Number(digit)) % 97;
}
return remainder === 1;
}
/** Les IBAN saoudiens font exactement 24 caractères et commencent par SA. */
export function isValidSaudiIban(iban: string): boolean {
const clean = iban.replace(/\s+/g, "").toUpperCase();
return clean.length === 24 && clean.startsWith("SA") && isValidIban(clean);
}La réduction par morceaux compte. Une implémentation naïve écrit BigInt(numeric) % 97n : cela fonctionne, mais alloue un BigInt de 30 chiffres par enregistrement. Sur un fichier de 4 000 employés c'est mesurable ; la boucle ci-dessus ne l'est pas.
Plausibilité de l'identité nationale et de l'Iqama
Les identifiants saoudiens portent un chiffre de contrôle calculé par un algorithme de type Luhn, et le premier chiffre distingue une identité nationale d'une Iqama.
// src/validators/national-id.ts
export type IdKind = "national" | "iqama" | "unknown";
export function idKind(id: string): IdKind {
if (!/^\d{10}$/.test(id)) return "unknown";
if (id.startsWith("1")) return "national";
if (id.startsWith("2")) return "iqama";
return "unknown";
}
/**
* Chiffre de contrôle de type Luhn utilisé par les numéros d'identité saoudiens.
* À considérer comme un pré-filtre qui attrape les fautes de frappe, pas comme
* une autorité sur l'existence d'une personne. Seuls les registres du ministère
* peuvent répondre à cela.
*/
export function hasValidIdCheckDigit(id: string): boolean {
if (!/^\d{10}$/.test(id)) return false;
let sum = 0;
for (let i = 0; i < 9; i++) {
const digit = Number(id[i]);
if (i % 2 === 0) {
const doubled = digit * 2;
sum += Math.floor(doubled / 10) + (doubled % 10);
} else {
sum += digit;
}
}
const expected = (10 - (sum % 10)) % 10;
return expected === Number(id[9]);
}Le commentaire n'est pas décoratif. Un chiffre de contrôle vous dit que le numéro a été saisi correctement. Il ne dit rien sur le fait que cette personne soit enregistrée sous cet établissement — et cette divergence est une cause majeure de rejet. Nous la traitons à l'étape 5.
Cohérence arithmétique interne
// src/validators/amounts.ts
import type { PayrollRecord } from "../domain.js";
export interface AmountIssue {
code: string;
message: string;
}
export function checkAmounts(r: PayrollRecord): AmountIssue[] {
const issues: AmountIssue[] = [];
const gross = r.basicSalary + r.housingAllowance + r.otherAllowances;
const expectedNet = gross - r.deductions;
if (expectedNet !== r.netPaid) {
issues.push({
code: "NET_MISMATCH",
message: `Total des composantes ${expectedNet / 100} SAR mais netPaid vaut ${r.netPaid / 100} SAR`,
});
}
if (r.deductions > gross) {
issues.push({
code: "DEDUCTION_EXCEEDS_GROSS",
message: "Les retenues dépassent le brut de la période",
});
}
if (r.basicSalary === 0 && r.workedDays > 0) {
issues.push({
code: "ZERO_BASIC_WITH_WORKED_DAYS",
message: "Salaire de base nul alors que des jours ont été travaillés",
});
}
if (r.netPaid === 0 && r.workedDays > 0) {
issues.push({
code: "ZERO_NET_WITH_WORKED_DAYS",
message: "Net nul pour un employé crédité de jours travaillés",
});
}
return issues;
}Un net nul pour un employé ayant des jours travaillés est légitime en congé sans solde ou en arrivée en cours de mois. C'est aussi à quoi ressemble une jointure ratée. Classez-le en avertissement exigeant un motif déclaré plutôt qu'en erreur bloquante — la différence entre les deux est le sujet de l'étape 5.
Étape 5 : Le rapprochement — la partie que personne d'autre ne construit
Tout ce qui précède valide le fichier contre lui-même. Les rejets qui font mal viennent d'un désaccord entre le fichier et un autre système : le contrat enregistré, l'immatriculation de l'établissement, la soumission du mois précédent.
Modélisez cela comme un instantané de référence et comparez.
// src/reconcile.ts
import type { PayrollBatch, PayrollRecord } from "./domain.js";
export interface ContractReference {
nationalId: string;
/** Salaire de base contractuel en halalas, tel qu'enregistré. */
contractedBasic: number;
contractedHousing: number;
/** Établissement sous lequel l'employé est enregistré. */
establishmentId: string;
status: "active" | "terminated" | "on_leave";
/** IBAN enregistré auprès de l'établissement, le cas échéant. */
iban?: string;
}
export type Severity = "error" | "warning";
export interface Finding {
nationalId: string;
fullName: string;
code: string;
severity: Severity;
message: string;
}
export function reconcile(
batch: PayrollBatch,
references: ContractReference[],
): Finding[] {
const byId = new Map(references.map((c) => [c.nationalId, c]));
const findings: Finding[] = [];
const seen = new Set<string>();
const push = (
r: PayrollRecord,
code: string,
severity: Severity,
message: string,
) => findings.push({ nationalId: r.nationalId, fullName: r.fullName, code, severity, message });
for (const r of batch.records) {
if (seen.has(r.nationalId)) {
push(r, "DUPLICATE_RECORD", "error", "L'employé apparaît plusieurs fois dans ce fichier");
continue;
}
seen.add(r.nationalId);
const ref = byId.get(r.nationalId);
if (!ref) {
push(r, "NOT_IN_REFERENCE", "error", "Aucun contrat trouvé pour cet identifiant");
continue;
}
if (ref.establishmentId !== batch.establishmentId) {
push(
r,
"WRONG_ESTABLISHMENT",
"error",
`Enregistré sous ${ref.establishmentId} mais déposé sous ${batch.establishmentId}`,
);
}
if (ref.status === "terminated") {
push(r, "TERMINATED_EMPLOYEE", "error", "Employé sorti des effectifs mais présent sur la période");
}
if (ref.iban && ref.iban !== r.iban) {
push(r, "IBAN_CHANGED", "warning", "L'IBAN diffère de celui enregistré");
}
// Un mois complet doit correspondre au contrat ; un mois partiel diverge légitimement.
const fullMonth = r.workedDays >= 28;
if (fullMonth && r.basicSalary !== ref.contractedBasic) {
push(
r,
"BASIC_BELOW_CONTRACT",
r.basicSalary < ref.contractedBasic ? "error" : "warning",
`Base ${r.basicSalary / 100} SAR contre ${ref.contractedBasic / 100} SAR au contrat pour un mois complet`,
);
}
if (fullMonth && r.housingAllowance !== ref.contractedHousing) {
push(r, "HOUSING_MISMATCH", "warning", "L'indemnité de logement diffère du montant contractuel");
}
}
// Employés attendus dans le fichier mais absents.
const filed = new Set(batch.records.map((r) => r.nationalId));
for (const ref of references) {
if (ref.status === "active" && ref.establishmentId === batch.establishmentId && !filed.has(ref.nationalId)) {
findings.push({
nationalId: ref.nationalId,
fullName: "(absent du fichier)",
code: "MISSING_ACTIVE_EMPLOYEE",
severity: "error",
message: "Employé actif sans enregistrement sur cette période",
});
}
}
return findings;
}Notez la dernière boucle. Tout validateur développé en interne vérifie les lignes présentes. Ce sont les lignes absentes qui font chuter le pourcentage de conformité, car un employé actif sans enregistrement de salaire se lit comme un employé non payé. Parcourir le référentiel plutôt que le fichier est le contrôle le plus rentable de tout ce tutoriel.
Étape 6 : Composer le pipeline de validation
// src/validate.ts
import { PayrollBatch } from "./domain.js";
import { isValidSaudiIban } from "./validators/iban.js";
import { hasValidIdCheckDigit, idKind } from "./validators/national-id.js";
import { checkAmounts } from "./validators/amounts.js";
import { reconcile, type ContractReference, type Finding } from "./reconcile.js";
export interface ValidationResult {
ok: boolean;
errors: Finding[];
warnings: Finding[];
}
export function validateBatch(
input: unknown,
references: ContractReference[],
): ValidationResult {
const parsed = PayrollBatch.safeParse(input);
if (!parsed.success) {
return {
ok: false,
warnings: [],
errors: parsed.error.issues.map((i) => ({
nationalId: "-",
fullName: "-",
code: "SCHEMA",
severity: "error" as const,
message: `${i.path.join(".")}: ${i.message}`,
})),
};
}
const batch = parsed.data;
const findings: Finding[] = [];
for (const r of batch.records) {
const at = (code: string, severity: "error" | "warning", message: string) =>
findings.push({ nationalId: r.nationalId, fullName: r.fullName, code, severity, message });
if (!isValidSaudiIban(r.iban)) at("INVALID_IBAN", "error", "L'IBAN échoue au contrôle mod-97");
if (!hasValidIdCheckDigit(r.nationalId)) at("INVALID_ID", "error", "L'identifiant échoue au chiffre de contrôle");
if (idKind(r.nationalId) === "unknown") at("UNKNOWN_ID_KIND", "warning", "Identifiant ni identité nationale ni Iqama");
for (const issue of checkAmounts(r)) at(issue.code, "error", issue.message);
}
findings.push(...reconcile(batch, references));
return {
ok: !findings.some((f) => f.severity === "error"),
errors: findings.filter((f) => f.severity === "error"),
warnings: findings.filter((f) => f.severity === "warning"),
};
}Étape 7 : Écrire le fichier
Maintenant seulement, et uniquement pour un lot qui a réussi la validation.
// src/write.ts
import type { LayoutProfile, FieldSpec } from "./layout.js";
import type { PayrollBatch, PayrollRecord } from "./domain.js";
function renderField(spec: FieldSpec, batch: PayrollBatch, record?: PayrollRecord): string {
let value: string;
switch (spec.source.kind) {
case "literal":
value = spec.source.value;
break;
case "batch":
value = spec.source.render(batch);
break;
case "record":
if (!record) throw new Error(`Le champ ${spec.name} exige un enregistrement, aucun fourni`);
value = spec.source.render(record);
break;
}
if (spec.width === undefined) return value;
if (value.length > spec.width) {
throw new Error(`Le champ ${spec.name} fait ${value.length} caractères et dépasse ${spec.width}`);
}
return spec.pad === "left"
? value.padStart(spec.width, "0")
: value.padEnd(spec.width, " ");
}
export function writeWpsFile(batch: PayrollBatch, profile: LayoutProfile): Buffer {
const rows: string[] = [];
const join = (specs: FieldSpec[], record?: PayrollRecord) =>
specs.map((s) => renderField(s, batch, record)).join(profile.delimiter);
if (profile.headerFields) rows.push(join(profile.headerFields));
for (const record of batch.records) rows.push(join(profile.detailFields, record));
if (profile.trailerFields) rows.push(join(profile.trailerFields));
const text = rows.join(profile.lineEnding) + profile.lineEnding;
return Buffer.from(text, profile.encoding);
}Trois détails qui provoquent de vrais échecs :
Les fins de ligne. Plusieurs canaux bancaires rejettent un fichier avec des fins de ligne Unix sans donner d'erreur utile. Faites-en une configuration, pas un accident de la machine qui a généré le fichier.
L'encodage. Si un nom contient des caractères arabes et que le canal attend un encodage mono-octet hérité, vous obtenez du charabia ou un rejet sec. Confirmez ce qu'attend le modèle et fixez-le explicitement plutôt que de vous fier au défaut de Node.
Lever une erreur au dépassement de largeur. Tronquer silencieusement un nom pour qu'il tienne, c'est ainsi qu'un enregistrement finit par décrire quelqu'un qui n'existe pas. Échouez bruyamment à la génération, là où quelqu'un peut corriger.
Étape 8 : Un rapport exploitable par la finance
Une liste de codes d'erreur est inutile à celui qui doit corriger les données. Groupez par cause, pas par ligne.
// src/report.ts
import type { ValidationResult } from "./validate.js";
export function formatReport(result: ValidationResult): string {
const lines: string[] = [];
const groups = new Map<string, typeof result.errors>();
for (const f of [...result.errors, ...result.warnings]) {
const existing = groups.get(f.code) ?? [];
existing.push(f);
groups.set(f.code, existing);
}
const sorted = [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
lines.push(result.ok ? "VALIDÉ — le fichier peut être généré" : "BLOQUÉ — les erreurs doivent être corrigées");
lines.push(`${result.errors.length} erreurs, ${result.warnings.length} avertissements`);
lines.push("");
for (const [code, findings] of sorted) {
lines.push(`[${findings[0]!.severity.toUpperCase()}] ${code} — ${findings.length} concernés`);
for (const f of findings.slice(0, 5)) {
lines.push(` ${f.nationalId} ${f.fullName} — ${f.message}`);
}
if (findings.length > 5) lines.push(` ... et ${findings.length - 5} autres`);
lines.push("");
}
return lines.join("\n");
}Trier les groupes par fréquence est délibéré. Quand 340 enregistrements échouent avec WRONG_ESTABLISHMENT, il s'agit d'une agence immatriculée sous le mauvais bureau du travail, pas de 340 problèmes. Le regroupement transforme un mur de bruit en une seule correction.
Étape 9 : Assembler le tout
// src/cli.ts
import { readFileSync, writeFileSync } from "node:fs";
import { validateBatch } from "./validate.js";
import { writeWpsFile } from "./write.js";
import { formatReport } from "./report.js";
import { genericDelimited } from "./profiles/generic-delimited.js";
import { PayrollBatch } from "./domain.js";
const [, , batchPath, referencesPath, outPath] = process.argv;
if (!batchPath || !referencesPath || !outPath) {
console.error("usage: tsx src/cli.ts <batch.json> <references.json> <out.txt>");
process.exit(2);
}
const batchInput = JSON.parse(readFileSync(batchPath, "utf8"));
const references = JSON.parse(readFileSync(referencesPath, "utf8"));
const result = validateBatch(batchInput, references);
console.log(formatReport(result));
if (!result.ok) {
console.error("Fichier non généré. Corrigez les erreurs ci-dessus et relancez.");
process.exit(1);
}
const file = writeWpsFile(PayrollBatch.parse(batchInput), genericDelimited);
writeFileSync(outPath, file);
console.log(`Écrit ${outPath} (${file.byteLength} octets)`);Lancez-le :
npx tsx src/cli.ts data/august.json data/contracts.json out/wps-2026-08.txtLe code de sortie est l'essentiel. Branché sur une tâche planifiée, cet outil refuse de remettre un fichier défectueux à qui que ce soit et indique au responsable paie quoi corriger pendant que la fenêtre est encore ouverte.
Tester votre implémentation
// tests/validators.test.ts
import { describe, it, expect } from "vitest";
import { isValidSaudiIban } from "../src/validators/iban.js";
import { checkAmounts } from "../src/validators/amounts.js";
describe("iban", () => {
it("rejette une longueur différente de 24", () => {
expect(isValidSaudiIban("SA038000000060801016751")).toBe(false);
});
it("rejette un préfixe non saoudien", () => {
expect(isValidSaudiIban("GB82WEST12345698765432")).toBe(false);
});
it("attrape une transposition que la regex laisserait passer", () => {
const good = "SA0380000000608010167519";
const transposed = good.slice(0, 10) + good[11] + good[10] + good.slice(12);
expect(isValidSaudiIban(good)).not.toBe(isValidSaudiIban(transposed));
});
});
describe("amounts", () => {
const base = {
nationalId: "1234567890",
fullName: "Test",
iban: "SA0380000000608010167519",
basicSalary: 500_000,
housingAllowance: 125_000,
otherAllowances: 0,
deductions: 0,
netPaid: 625_000,
paymentDate: "2026-08-28",
workedDays: 30,
};
it("laisse passer un enregistrement cohérent", () => {
expect(checkAmounts(base)).toHaveLength(0);
});
it("signale un net qui ne correspond pas à ses composantes", () => {
const codes = checkAmounts({ ...base, netPaid: 600_000 }).map((i) => i.code);
expect(codes).toContain("NET_MISMATCH");
});
});Utilisez des identifiants synthétiques dans les tests. Ne versionnez jamais une fixture construite à partir de données d'employés réelles — le fichier que vous générez est exactement le type de charge qui ne doit pas finir dans un historique git.
Testez ensuite la couche de rapprochement sur le scénario qui compte le plus :
it("détecte un employé actif absent du fichier", () => {
const result = validateBatch(batchWithoutFatima, contractsIncludingFatima);
expect(result.errors.map((e) => e.code)).toContain("MISSING_ACTIVE_EMPLOYEE");
});Dépannage
La banque rejette le fichier sans explication. C'est presque toujours le profil de mise en forme, pas les données. Comparez votre fichier généré au modèle fourni par la banque octet par octet — vérifiez le délimiteur, le saut de ligne final, et la présence ou l'absence d'une ligne d'en-tête.
Les noms arabes reviennent illisibles. Incompatibilité d'encodage. Confirmez ce qu'attend le canal et fixez encoding explicitement dans le profil.
Les montants divergent de fractions minuscules. Quelque chose en amont produit des flottants. Convertissez en halalas entiers à la frontière où les données de paie entrent dans votre système, pas plus tard.
Tout est valide et le pourcentage de conformité chute quand même. Le fichier a été accepté et c'est le registre de l'établissement qui diverge — employés enregistrés sous un autre bureau du travail, ou scission d'établissement connue des RH mais pas du ministère. C'est le domaine de WRONG_ESTABLISHMENT et NOT_IN_REFERENCE, et c'est pourquoi ces contrôles sont des erreurs et non des avertissements.
Étapes suivantes
- Ajoutez un instantané de la période précédente pour que le moteur signale les baisses de salaire d'un mois à l'autre, qui se lisent comme une sous-rémunération
- Persistez chaque exécution avec ses constats — un même code qui revient tous les mois est un processus amont cassé, pas une erreur de saisie
- Exposez le service comme un endpoint interne pour que votre SIRH l'appelle avant de clôturer le cycle de paie
- Étendez le référentiel aux données d'immatriculation, pour que les divergences d'établissement remontent avant la paie et non après le téléversement
Lectures liées sur ce site :
- Les violations WPS en Arabie Saoudite sont un problème de données, pas de paie — la version décisionnelle de cet argument, pour qui valide le budget
- Construire une intégration NPHIES FHIR en TypeScript — le même schéma appliqué aux demandes de remboursement de santé saoudiennes
- Zod v4 pour la validation de schémas dans Next.js — approfondissement sur la bibliothèque utilisée ici
- Le piège de l'ERP : intégrer plutôt que remplacer — pourquoi la réponse à un échec de conformité est rarement un nouveau système
Conclusion
Le fichier des salaires n'est pas un problème de paie. C'est un problème d'intégration déguisé en problème de paie : deux systèmes détenant des enregistrements qui se recouvrent, l'un faisant autorité, et une échéance mensuelle qui sanctionne le moindre désaccord entre eux.
Tout ce tutoriel découle de ce cadrage. Modélisez la paie et non le fichier. Poussez le format bancaire dans un unique objet de configuration. Dépensez votre effort d'ingénierie sur la couche de rapprochement, car c'est de là que viennent les rejets. Et vérifiez les employés absents du fichier, pas seulement ceux qui y figurent.
Le résultat tient en quelques jours de développement et supprime un incendie mensuel récurrent — plus un rapport qui dit au responsable paie quoi corriger pendant qu'il est encore temps.
Si vous accumulez des violations WPS que vous n'arrivez pas à expliquer, le diagnostic est généralement court : exportez un mois de données de paie, exportez les contrats, et regardez quelles lignes divergent. Parlons d'une revue de rapprochement — nous vous dirons si votre problème vient du fichier, des données, ou du registre de l'établissement.