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:
- A canonical payroll record — your domain model, deliberately independent of any file format
- A layout profile — declarative configuration describing one bank's file format
- A validation engine — the rules that predict rejection, including cross-source reconciliation
- 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 --initSet 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.txtThe 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.
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");
});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.
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:
- Saudi WPS Violations Are a Data Problem, Not Payroll — the decision-stage version of this argument, for whoever signs off on the budget
- Building a NPHIES FHIR Integration in TypeScript — the same pattern applied to Saudi healthcare claims
- Zod v4 for Schema Validation in Next.js — deeper on the validation library used here
- The ERP Trap: Integration, Not Replacement — why the answer to a compliance failure is rarely a new system
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.