writing/tutorial/2026/08
● TutorialAug 8, 2026·37 min read

Building a WPS File Generator and Pre-Upload Validator in TypeScript

A hands-on guide to generating and validating Saudi Wage Protection System files in TypeScript. Build a schema-driven SIF writer, an IBAN and national ID validator, a reconciliation engine against contract data, a rejection report your finance team can actually act on, and an upload clock that tracks the ministry's late-file ladder.

Search for anything about the Saudi Wage Protection System and you get the same page twenty times: log into Mudad, pick your establishment, click upload. Every result is written for someone clicking through a portal. Not one is written for the person who has to produce the file.

That gap is where the violations live. Salaries get paid correctly, the transfer clears, and the compliance percentage still drops — because the file that described those salaries disagreed with the establishment record in a way nobody checked before upload. By the time the rejection comes back, the payroll cycle is closed and the correction window is shrinking.

This tutorial builds the thing that should sit between your HR system and that upload button: a TypeScript service that assembles the wage file from your own payroll data, validates every record against the rules that actually cause rejections, reconciles the numbers against your contract and registration data, and produces a report naming exactly which employee row will fail and why.

A note on the file specification. The exact field order, delimiter, and header layout of the wage file differ between banks and between the bank channel and Mudad. There is no single public byte-level spec that stays true everywhere. So we do not hardcode one. We build a schema-driven generator where the layout is configuration you fill in from your own bank's template, and the validation engine — which is the valuable part — stays the same regardless. Always verify the field layout against the template your bank gave you before going to production.

Prerequisites

  • Node.js 20 or newer
  • TypeScript fundamentals — generics and discriminated unions appear here
  • Familiarity with Zod or a similar schema library
  • Access to your bank's or Mudad's wage file template (for the layout config)
  • Payroll data you can export: employee identifiers, IBANs, salary components

What You'll Build

A package with four layers, each independently testable:

  1. A canonical payroll record — your domain model, deliberately independent of any file format
  2. A layout profile — declarative configuration describing one bank's file format
  3. A validation engine — the rules that predict rejection, including cross-source reconciliation
  4. A writer and a report — the text file itself, plus a human-readable failure list

The order matters. Most in-house implementations start at layer four, write a string template that emits a file, and discover the validation problem six violation notices later.

Step 1: Project Setup

mkdir wps-toolkit && cd wps-toolkit
npm init -y
npm install zod
npm install -D typescript tsx vitest @types/node
npx tsc --init

Set the compiler to something strict enough that the money handling cannot rot:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

noUncheckedIndexedAccess matters more than it looks. Most wage-file bugs are array indexing against a column that was not there.

Step 2: Model the Canonical Record

Do not model the file. Model the payroll. The file is a projection of the payroll, and if you invert that relationship you will end up with a bank-specific data structure leaking through your entire codebase.

// src/domain.ts
import { z } from "zod";
 
/** Money is stored in halalas (integer minor units) — never floats. */
export const Halalas = z.number().int().nonnegative();
 
export const PayrollRecord = z.object({
  /** Iqama number for non-Saudis, National ID for Saudis. 10 digits. */
  nationalId: z.string().regex(/^\d{10}$/),
  /** As registered with the establishment, not a nickname. */
  fullName: z.string().min(1).max(100),
  /** Saudi IBAN — 24 characters, SA prefix. */
  iban: z.string().regex(/^SA\d{22}$/),
  basicSalary: Halalas,
  housingAllowance: Halalas,
  otherAllowances: Halalas,
  deductions: Halalas,
  /** What actually left the account, in halalas. */
  netPaid: Halalas,
  /** ISO date of the transfer. */
  paymentDate: z.string().date(),
  /** Days actually worked in the period — drives partial-salary cases. */
  workedDays: z.number().int().min(0).max(31),
});
 
export type PayrollRecord = z.infer<typeof PayrollRecord>;
 
export const PayrollBatch = z.object({
  /** Ministry establishment identifier (labour office + sequence). */
  establishmentId: z.string().min(1),
  /** Commercial registration number the file is filed under. */
  crNumber: z.string().regex(/^\d{10}$/),
  /** Salary month being reported, as 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>;

Two decisions here are worth defending.

Money as integer halalas. Wage files are compared against bank transfers to the halala. A float representation of 4,733.15 will eventually serialise as 4733.1499999999996 and produce a mismatch nobody can explain. Store minor units as integers, format at the edge.

netPaid as its own field rather than a computed value. It is tempting to derive it. Do not. The whole point of the reconciliation layer is comparing what you say you paid against what you computed you should pay. If you derive one from the other, the two can never disagree, and you have destroyed the signal you were building the system to detect.

Step 3: Describe the File Layout as Configuration

This is where the bank-specific knowledge goes, and nowhere else.

// 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;
  /** Fixed-width layouts only; omit for delimited files. */
  width?: number;
  pad?: "left" | "right";
}
 
export interface LayoutProfile {
  id: string;
  delimiter: string;
  lineEnding: "\r\n" | "\n";
  encoding: "utf8" | "latin1";
  /** Some channels want a header row, some reject it outright. */
  headerFields?: FieldSpec[];
  detailFields: FieldSpec[];
  /** Trailer with record count and control totals, when required. */
  trailerFields?: FieldSpec[];
}

A concrete profile then reads as documentation of your bank's template:

// 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) } },
  ],
};

Adapt the field list and order to your bank's actual template before shipping. That is the one thing this file is for. When the bank changes its spec — and it will — you edit one array instead of hunting through string concatenation.

Step 4: The Validators That Predict Rejection

Schema validation from Step 2 catches shape errors. It does not catch the things that actually get files rejected. Those need real logic.

IBAN check digits

An IBAN with a transposed pair of digits passes a regex and fails at the bank. Mod-97 catches it deterministically.

// src/validators/iban.ts
 
/** ISO 13616 mod-97 check. Returns true when the IBAN's check digits are self-consistent. */
export function isValidIban(iban: string): boolean {
  const clean = iban.replace(/\s+/g, "").toUpperCase();
  if (clean.length < 15 || clean.length > 34) return false;
 
  // Move the first four characters to the end, then map letters to numbers.
  const rearranged = clean.slice(4) + clean.slice(0, 4);
  const numeric = rearranged.replace(/[A-Z]/g, (c) =>
    String(c.charCodeAt(0) - 55),
  );
 
  // The number is far beyond Number.MAX_SAFE_INTEGER, so reduce piecewise.
  let remainder = 0;
  for (const digit of numeric) {
    remainder = (remainder * 10 + Number(digit)) % 97;
  }
  return remainder === 1;
}
 
/** Saudi IBANs are exactly 24 characters and begin with SA. */
export function isValidSaudiIban(iban: string): boolean {
  const clean = iban.replace(/\s+/g, "").toUpperCase();
  return clean.length === 24 && clean.startsWith("SA") && isValidIban(clean);
}

The piecewise modulo matters. A naive implementation does BigInt(numeric) % 97n, which works but allocates a 30-digit BigInt per record. On a 4,000-employee file that is measurable; the loop above is not.

National ID and Iqama plausibility

Saudi identifiers carry a check digit computed with a Luhn-style algorithm, and the leading digit distinguishes a National ID from an 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";
}
 
/**
 * Luhn-style check digit used by Saudi identity numbers.
 * Treat this as a pre-filter that catches typos, not as an authority on
 * whether a person exists. Only the ministry's records can tell you that.
 */
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]);
}

The comment is not decoration. A check digit tells you the number was typed correctly. It says nothing about whether that person is registered under this establishment — and that mismatch is a leading cause of rejected records. We handle it in Step 5.

Internal arithmetic consistency

// 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: `Components total ${expectedNet / 100} SAR but netPaid is ${r.netPaid / 100} SAR`,
    });
  }
 
  if (r.deductions > gross) {
    issues.push({
      code: "DEDUCTION_EXCEEDS_GROSS",
      message: "Deductions exceed gross pay for the period",
    });
  }
 
  if (r.basicSalary === 0 && r.workedDays > 0) {
    issues.push({
      code: "ZERO_BASIC_WITH_WORKED_DAYS",
      message: "Basic salary is zero while worked days are greater than zero",
    });
  }
 
  if (r.netPaid === 0 && r.workedDays > 0) {
    issues.push({
      code: "ZERO_NET_WITH_WORKED_DAYS",
      message: "Net paid is zero for an employee credited with worked days",
    });
  }
 
  return issues;
}

A zero net for an employee with worked days is legitimate in unpaid-leave and mid-month-joiner scenarios. It is also what an accidental join failure looks like. Flag it as a warning that requires a stated reason rather than an error that blocks the file — the difference between the two is the subject of Step 6.

Step 5: Reconciliation — the Part Nobody Else Builds

Everything above validates the file against itself. The rejections that hurt come from the file disagreeing with a different system: the contract on record, the establishment registration, last month's submission.

Model that as a reference snapshot and diff against it.

// src/reconcile.ts
import type { PayrollBatch, PayrollRecord } from "./domain.js";
 
export interface ContractReference {
  nationalId: string;
  /** Contracted basic salary in halalas, as registered. */
  contractedBasic: number;
  contractedHousing: number;
  /** Establishment the employee is registered under. */
  establishmentId: string;
  status: "active" | "terminated" | "on_leave";
  /** IBAN on record with the establishment, if any. */
  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", "Employee appears more than once in this file");
      continue;
    }
    seen.add(r.nationalId);
 
    const ref = byId.get(r.nationalId);
    if (!ref) {
      push(r, "NOT_IN_REFERENCE", "error", "No contract record found for this identifier");
      continue;
    }
 
    if (ref.establishmentId !== batch.establishmentId) {
      push(
        r,
        "WRONG_ESTABLISHMENT",
        "error",
        `Registered under ${ref.establishmentId} but filed under ${batch.establishmentId}`,
      );
    }
 
    if (ref.status === "terminated") {
      push(r, "TERMINATED_EMPLOYEE", "error", "Employee is terminated but appears in this period");
    }
 
    if (ref.iban && ref.iban !== r.iban) {
      push(r, "IBAN_CHANGED", "warning", "IBAN differs from the one on record");
    }
 
    // A full month should match the contract; a partial month legitimately will not.
    const fullMonth = r.workedDays >= 28;
    if (fullMonth && r.basicSalary !== ref.contractedBasic) {
      push(
        r,
        "BASIC_BELOW_CONTRACT",
        r.basicSalary < ref.contractedBasic ? "error" : "warning",
        `Basic ${r.basicSalary / 100} SAR against contracted ${ref.contractedBasic / 100} SAR for a full month`,
      );
    }
 
    if (fullMonth && r.housingAllowance !== ref.contractedHousing) {
      push(r, "HOUSING_MISMATCH", "warning", "Housing allowance differs from the contracted amount");
    }
  }
 
  // Employees expected in the file but absent from it.
  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: "(missing from file)",
        code: "MISSING_ACTIVE_EMPLOYEE",
        severity: "error",
        message: "Active employee has no record in this period",
      });
    }
  }
 
  return findings;
}

Note the last loop. Every validator built in-house checks the rows that are present. The rows that are absent are what drives a compliance percentage down, because an active employee with no wage record reads as unpaid. Iterating the reference set rather than the file is the single highest-value check in this entire tutorial.

Step 6: Compose the Validation Pipeline

// 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", "IBAN fails the mod-97 check");
    if (!hasValidIdCheckDigit(r.nationalId)) at("INVALID_ID", "error", "Identifier fails the check digit");
    if (idKind(r.nationalId) === "unknown") at("UNKNOWN_ID_KIND", "warning", "Identifier is neither a National ID nor an 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"),
  };
}

Step 7: Write the File

Only now, and only for a batch that passed.

// 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(`Field ${spec.name} needs a record but none was supplied`);
      value = spec.source.render(record);
      break;
  }
 
  if (spec.width === undefined) return value;
  if (value.length > spec.width) {
    throw new Error(`Field ${spec.name} is ${value.length} chars, exceeding width ${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);
}

Three details that cause real-world failures:

Line endings. Several bank channels reject a file with Unix line endings and give no useful error. Make it configuration, not an accident of the machine that generated the file.

Encoding. If any name contains Arabic characters and the channel expects a legacy single-byte encoding, you get mojibake or a hard reject. Confirm what the template expects and set it explicitly rather than relying on the Node default.

Throwing on width overflow. Silently truncating a name to fit is how a record ends up describing someone who does not exist. Fail loudly at generation time, where somebody can fix it.

Step 8: A Report Finance Can Act On

A list of error codes is useless to the person who has to fix the data. Group by cause, not by row.

// 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 ? "PASSED — file is safe to generate" : "BLOCKED — errors must be resolved");
  lines.push(`${result.errors.length} errors, ${result.warnings.length} warnings`);
  lines.push("");
 
  for (const [code, findings] of sorted) {
    lines.push(`[${findings[0]!.severity.toUpperCase()}] ${code} — ${findings.length} affected`);
    for (const f of findings.slice(0, 5)) {
      lines.push(`   ${f.nationalId}  ${f.fullName}  —  ${f.message}`);
    }
    if (findings.length > 5) lines.push(`   ... and ${findings.length - 5} more`);
    lines.push("");
  }
 
  return lines.join("\n");
}

Sorting groups by frequency is deliberate. When 340 records fail with WRONG_ESTABLISHMENT, that is one branch registered under the wrong labour office, not 340 problems. The grouping turns a wall of noise into a single fix.

Step 9: Wire It Together

// 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("File not generated. Resolve the errors above and re-run.");
  process.exit(1);
}
 
const file = writeWpsFile(PayrollBatch.parse(batchInput), genericDelimited);
writeFileSync(outPath, file);
console.log(`Wrote ${outPath} (${file.byteLength} bytes)`);

Run it:

npx tsx src/cli.ts data/august.json data/contracts.json out/wps-2026-08.txt

The exit code is the point. Wired into a scheduled job, this refuses to hand a broken file to anyone and tells the payroll owner what to fix while the correction window is still open.

Step 10: The Upload Clock

Everything so far answers one question: is this file correct? The ministry asks a second one that no validator can answer: when did it arrive?

The Wage Protection Program page of the Ministry of Human Resources and Social Development says an employer's discipline is calculated "based on the number of months of delay in uploading the wage payment file", and that the delay is counted from the date the wages fall due. The same page publishes the ladder:

Delay in uploading the fileConsequence on the program page
One monthAn inspection visit to the establishment is scheduled
Two monthsAll services suspended except issuing and renewing work permits
Three monthsAll services suspended, and workers may transfer to another employer without the establishment's consent, even if the work permit has not expired

Three more statements from the ministry shape the data model:

  • An employer who paid on time but uploaded late is recognised as having paid, provided the file for each month is not more than three months late. Past that point a paid month reads as an unpaid one.
  • A file that does not follow the specification is rejected in full. A rejected upload is not an upload.
  • Since 1 March 2025 the permitted period for uploading the file on Mudad is 30 days instead of 60, according to the ministry's announcement.

The legal side of this, including Articles 90 and 94 of the Labour Law, is in Late salaries in Saudi Arabia: the clock is the wage file. What follows is the part an engineer owns: a register with three dates per salary month, where most payroll systems keep one.

// src/clock.ts
 
/** The ladder on the ministry's Wage Protection Program page, worst last. */
export const STAGES = [
  "within_window",
  "inspection",
  "services_suspended",
  "transfer_without_consent",
] as const;
 
export type Stage = (typeof STAGES)[number];
 
export interface PeriodEntry {
  /** Salary month, as YYYY-MM. Same value as PayrollBatch.period. */
  period: string;
  /** ISO date the wages for this period fall due under the contracts. */
  dueDate: string;
  /** ISO date the transfer left the bank, or null if it has not. */
  paidOn: string | null;
  /** ISO date the wage file was ACCEPTED, or null. A rejected upload is null. */
  acceptedOn: string | null;
}
 
export interface ClockOptions {
  /** Length of one "month of delay" in days. See the note on this reading. */
  monthDays: number;
  /** Day after the due date on which an open period starts to alert. */
  alertDay: number;
}
 
export const DEFAULT_CLOCK: ClockOptions = { monthDays: 30, alertDay: 20 };
 
export interface PeriodStatus {
  period: string;
  /** True while no accepted file exists for the period. */
  open: boolean;
  /** Days from the due date to the accepted file, or to `asOf` if open. */
  daysSinceDue: number;
  stage: Stage;
  /** First day of each later stage, as ISO dates. */
  inspectionFrom: string;
  suspensionFrom: string;
  transferFrom: string;
  /** True when the transfer itself was on or before the due date. */
  paidOnTime: boolean;
  /** Paid on time AND the file landed before the three-month mark. */
  paymentRecognised: boolean;
  /** Open, and at or past the alert day. */
  alert: boolean;
}
 
const DAY_MS = 86_400_000;
 
function toUtc(iso: string): number {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
  if (!m) throw new Error(`Not an ISO date: ${iso}`);
  const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
  if (new Date(ms).toISOString().slice(0, 10) !== iso) {
    throw new Error(`Not a calendar date: ${iso}`);
  }
  return ms;
}
 
function addDays(iso: string, days: number): string {
  return new Date(toUtc(iso) + days * DAY_MS).toISOString().slice(0, 10);
}
 
function daysBetween(from: string, to: string): number {
  return Math.round((toUtc(to) - toUtc(from)) / DAY_MS);
}
 
export function stageFor(daysSinceDue: number, monthDays: number): Stage {
  const monthsLate = Math.floor(Math.max(0, daysSinceDue) / monthDays);
  return STAGES[Math.min(monthsLate, STAGES.length - 1)]!;
}
 
export function assessPeriod(
  entry: PeriodEntry,
  asOf: string,
  options: ClockOptions = DEFAULT_CLOCK,
): PeriodStatus {
  const { monthDays, alertDay } = options;
  if (!Number.isInteger(monthDays) || monthDays < 1) {
    throw new Error("monthDays must be a positive integer");
  }
  if (entry.acceptedOn !== null && daysBetween(entry.acceptedOn, asOf) < 0) {
    throw new Error(`${entry.period}: file accepted after the as-of date`);
  }
 
  const open = entry.acceptedOn === null;
  const daysSinceDue = daysBetween(entry.dueDate, entry.acceptedOn ?? asOf);
  const paidOnTime =
    entry.paidOn !== null && daysBetween(entry.paidOn, entry.dueDate) >= 0;
 
  return {
    period: entry.period,
    open,
    daysSinceDue,
    stage: stageFor(daysSinceDue, monthDays),
    inspectionFrom: addDays(entry.dueDate, monthDays),
    suspensionFrom: addDays(entry.dueDate, 2 * monthDays),
    transferFrom: addDays(entry.dueDate, 3 * monthDays),
    paidOnTime,
    paymentRecognised: paidOnTime && !open && daysSinceDue < 3 * monthDays,
    alert: open && daysSinceDue >= alertDay,
  };
}
 
export interface ClockReport {
  asOf: string;
  /** Worst stage among periods that still have no accepted file. */
  liveStage: Stage;
  /** Worst first, then oldest first. */
  periods: PeriodStatus[];
}
 
export function runClock(
  register: PeriodEntry[],
  asOf: string,
  options: ClockOptions = DEFAULT_CLOCK,
): ClockReport {
  const seen = new Set<string>();
  for (const e of register) {
    if (seen.has(e.period)) throw new Error(`Duplicate period ${e.period}`);
    seen.add(e.period);
  }
 
  const rank = (s: Stage) => STAGES.indexOf(s);
  const periods = register
    .map((e) => assessPeriod(e, asOf, options))
    .sort((a, b) => rank(b.stage) - rank(a.stage) || a.period.localeCompare(b.period));
 
  const live = periods.filter((p) => p.open);
  const liveStage = live.reduce<Stage>(
    (worst, p) => (rank(p.stage) > rank(worst) ? p.stage : worst),
    "within_window",
  );
 
  return { asOf, liveStage, periods };
}

Five decisions in that file are worth defending.

dueDate is an input, never derived. Article 90 fixes the frequency, once a month for monthly-paid workers, and says nothing about the day. The day comes from your contracts. An engine that assumes the last day of the month is wrong for every establishment that pays on the 25th, and wrong in the direction that hides lateness.

acceptedOn, not uploadedOn. Because a non-conforming file is rejected in full, the attempt on day 28 that bounced does not stop the clock. Write the date into the register only when the channel confirms acceptance. This is also why the validator from Steps 4 to 6 belongs in front of the upload: it is what makes the first attempt count.

monthDays is our reading, and it is an option for that reason. The program page counts in months without defining one. The ministry's own announcement equates a month with thirty days, so the default is 30, counted from the due date. That leaves two boundary days no published text assigns: day 30 itself (the announcement permits thirty days, the page schedules an inspection at one month) and day 90 for recognising an on-time payment. The engine treats both as late. If Mudad shows your establishment something different, Mudad is right: change the option, not the code.

No riyal amounts. Three different fine figures circulate for late wages, each with its own source, and the violations schedule was reclassified in 2026. The engine returns dates and stages. Leave the amount to whoever reads the schedule in force.

liveStage looks at open periods only. A month whose file landed late stays in the report as history, because it is what an inspector will see. Only the months with no accepted file are still moving. The page does not say how quickly a suspension lifts once the missing file is accepted, so the engine does not pretend to know.

Note also what paidOnTime does not do: it does not forgive a late transfer because the file was prompt. A file accepted on day 5 that reports a transfer made on day 4 is a documented late payment.

The register itself is a small JSON document your payroll close writes to:

[
  { "period": "2026-05", "dueDate": "2026-05-31", "paidOn": "2026-05-28", "acceptedOn": "2026-09-10" },
  { "period": "2026-06", "dueDate": "2026-06-30", "paidOn": "2026-06-29", "acceptedOn": "2026-07-06" },
  { "period": "2026-07", "dueDate": "2026-07-31", "paidOn": "2026-07-30", "acceptedOn": null },
  { "period": "2026-08", "dueDate": "2026-08-31", "paidOn": "2026-08-28", "acceptedOn": null },
  { "period": "2026-09", "dueDate": "2026-09-30", "paidOn": "2026-09-29", "acceptedOn": null }
]

And the entry point takes the as-of date as an argument, so that a re-run for last Tuesday gives last Tuesday's answer:

// src/clock-cli.ts
import { readFileSync } from "node:fs";
import { runClock, type PeriodEntry } from "./clock.js";
 
const [, , registerPath, asOf] = process.argv;
if (!registerPath || !asOf) {
  console.error("usage: tsx src/clock-cli.ts <register.json> <YYYY-MM-DD>");
  process.exit(2);
}
 
const register = JSON.parse(readFileSync(registerPath, "utf8")) as PeriodEntry[];
const report = runClock(register, asOf);
 
for (const p of report.periods) {
  if (!p.open && p.stage === "within_window") continue;
  const state = p.open ? "OPEN  " : "closed";
  console.log(
    `${p.period}  ${state}  day ${String(p.daysSinceDue).padStart(3)}  ${p.stage}` +
      (p.open ? `  (suspension from ${p.suspensionFrom})` : ""),
  );
}
console.log(`Live stage: ${report.liveStage}`);
 
const quiet = report.liveStage === "within_window" && !report.periods.some((p) => p.alert);
process.exit(quiet ? 0 : 1);

Run it:

npx tsx src/clock-cli.ts data/register.json 2026-10-09
2026-05  closed  day 102  transfer_without_consent
2026-07  OPEN    day  70  services_suspended  (suspension from 2026-09-29)
2026-08  OPEN    day  39  inspection  (suspension from 2026-10-30)
2026-09  OPEN    day   9  within_window  (suspension from 2026-11-29)
Live stage: services_suspended

May is closed and still listed: paid on time, file accepted on day 102, so the payment was never recognised. July was paid seventy days ago and has been on the suspension rung for ten days. August will join it on 30 October. None of the three is a payroll failure. Every salary in this register left the bank before its due date.

The tests pin the boundaries, because the boundaries are where the readings live:

// tests/clock.test.ts
import { describe, it, expect } from "vitest";
import { assessPeriod, runClock, stageFor } from "../src/clock.js";
 
const august = {
  period: "2026-08",
  dueDate: "2026-08-31",
  paidOn: "2026-08-28",
  acceptedOn: null,
};
 
describe("upload clock", () => {
  it("walks the ladder on the published boundaries", () => {
    expect(stageFor(29, 30)).toBe("within_window");
    expect(stageFor(30, 30)).toBe("inspection");
    expect(stageFor(59, 30)).toBe("inspection");
    expect(stageFor(60, 30)).toBe("services_suspended");
    expect(stageFor(90, 30)).toBe("transfer_without_consent");
    expect(stageFor(400, 30)).toBe("transfer_without_consent");
  });
 
  it("treats a paid month with no file as an open, alerting period", () => {
    const s = assessPeriod(august, "2026-09-20");
    expect(s.daysSinceDue).toBe(20);
    expect(s.stage).toBe("within_window");
    expect(s.alert).toBe(true);
    expect(s.paidOnTime).toBe(true);
    expect(s.paymentRecognised).toBe(false);
    expect(s.inspectionFrom).toBe("2026-09-30");
    expect(s.suspensionFrom).toBe("2026-10-30");
    expect(s.transferFrom).toBe("2026-11-29");
  });
 
  it("recognises an on-time payment only if the file lands before day 90", () => {
    const at89 = assessPeriod({ ...august, acceptedOn: "2026-11-28" }, "2026-12-15");
    const at90 = assessPeriod({ ...august, acceptedOn: "2026-11-29" }, "2026-12-15");
    expect(at89.paymentRecognised).toBe(true);
    expect(at90.paymentRecognised).toBe(false);
    expect(at90.stage).toBe("transfer_without_consent");
  });
 
  it("does not let an early file hide a late transfer", () => {
    const s = assessPeriod(
      { ...august, paidOn: "2026-09-04", acceptedOn: "2026-09-05" },
      "2026-09-20",
    );
    expect(s.stage).toBe("within_window");
    expect(s.paidOnTime).toBe(false);
    expect(s.paymentRecognised).toBe(false);
  });
 
  it("reports the live stage from open periods only", () => {
    const report = runClock(
      [
        { period: "2026-05", dueDate: "2026-05-31", paidOn: "2026-05-28", acceptedOn: "2026-09-10" },
        { period: "2026-07", dueDate: "2026-07-31", paidOn: "2026-07-30", acceptedOn: null },
        august,
      ],
      "2026-10-09",
    );
    expect(report.periods.map((p) => p.period)).toEqual(["2026-05", "2026-07", "2026-08"]);
    expect(report.periods[0]?.stage).toBe("transfer_without_consent");
    expect(report.liveStage).toBe("services_suspended");
  });
 
  it("refuses bad input instead of guessing", () => {
    expect(() => assessPeriod({ ...august, dueDate: "2026-02-30" }, "2026-09-20")).toThrow();
    expect(() => assessPeriod({ ...august, acceptedOn: "2026-10-01" }, "2026-09-20")).toThrow();
    expect(() => runClock([august, august], "2026-09-20")).toThrow();
  });
});

Schedule the clock daily, separately from the monthly file run. With alertDay at 20 it starts failing the job ten days before the inspection rung, which is the time you need to repair a rejected file and get a second attempt accepted.

Testing Your Implementation

// 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("rejects a length that is not 24", () => {
    expect(isValidSaudiIban("SA038000000060801016751")).toBe(false);
  });
 
  it("rejects a non-Saudi prefix", () => {
    expect(isValidSaudiIban("GB82WEST12345698765432")).toBe(false);
  });
 
  it("catches a transposition that a regex would pass", () => {
    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("passes a consistent record", () => {
    expect(checkAmounts(base)).toHaveLength(0);
  });
 
  it("flags a net that does not match its components", () => {
    const codes = checkAmounts({ ...base, netPaid: 600_000 }).map((i) => i.code);
    expect(codes).toContain("NET_MISMATCH");
  });
});

Use synthetic identifiers in tests. Never commit a fixture built from real employee data — the file you are generating is exactly the kind of payload that should not end up in a git history.

Then test the reconciliation layer against the scenario that matters most:

it("catches an active employee missing from the file", () => {
  const result = validateBatch(batchWithoutFatima, contractsIncludingFatima);
  expect(result.errors.map((e) => e.code)).toContain("MISSING_ACTIVE_EMPLOYEE");
});

Once the suite is green, run a real generated file through an independent implementation before it goes to the bank. Our free WPS file validator checks the IBAN check digit, the identity numbers and the net-versus-components match in the browser — the file is never uploaded to a server — and the WPS file template gives you a known-good CSV to diff your own output against when you suspect the layout profile rather than the data.

Troubleshooting

The bank rejects the file with no explanation. Almost always the layout profile, not the data. Diff your generated file against the bank's own sample template byte by byte — check the delimiter, the trailing newline, and the presence or absence of a header row.

Arabic names come back garbled. Encoding mismatch. Confirm what the channel expects and set encoding in the profile explicitly.

Amounts are off by tiny fractions. Something upstream is producing floats. Convert to integer halalas at the boundary where payroll data enters your system, not later.

Everything validates but the compliance percentage still drops. The file was accepted and the establishment record disagrees — employees registered under a different labour office, or an establishment split that HR knows about and the ministry record does not. That is WRONG_ESTABLISHMENT and NOT_IN_REFERENCE territory, which is why those checks are errors rather than warnings.

Services are suspended and every salary was paid. A month has no accepted file. Run the Step 10 clock over the last six periods and look for an open one, or one whose first upload was rejected and never retried.

Next Steps

  • Add a previous-period snapshot so the engine flags salary drops between months, which read as underpayment
  • Persist every run with its findings — a trend of the same code recurring monthly is a broken upstream process, not a data-entry problem
  • Expose it as an internal API endpoint so your HR system can call it before closing the payroll cycle
  • Extend the reference set to include registration data, so establishment mismatches surface before payroll rather than after upload

Related reading on this site:

Conclusion

The wage file is not a payroll problem. It is an integration problem wearing a payroll costume: two systems holding overlapping records, one of them authoritative, and a monthly deadline that punishes any disagreement between them.

Everything in this tutorial follows from that framing. Model the payroll rather than the file. Push the bank's format into a single configuration object. Spend your engineering effort on the reconciliation layer, because that is where the rejections come from. And check the employees who are missing from the file, not only the ones present in it.

The result is a build that takes a few days and removes a recurring monthly fire — plus a report that tells the payroll owner what to fix while there is still time to fix it.


If you are carrying WPS violations you cannot explain, the diagnosis is usually a short one: export a month of payroll data, export the contract records, and see which rows disagree. Talk to us about a reconciliation review — we will tell you whether your problem is the file, the data, or the establishment record.