writing/tutorial/2026/08
● TutorialAug 28, 2026·26 min read

Saudi Special Leave Entitlements in TypeScript (Art. 113, 114, 115, 160)

Build the special leave engine Saudi payroll systems keep getting wrong — marriage, newborn, bereavement, Hajj, exam and iddah leave under Articles 113, 114, 115 and 160. Covers the claim windows that expire, the five-versus-three day split by degree of relation, why marriage and bereavement leave run from the event and not from the request, what Article 2's thirty-day month does to the iddah period, and why Hajj leave must never pay Eid twice.

Most Saudi payroll systems model leave as one number. An employee has a balance, they take days, the balance goes down. That model is correct for annual leave and it is wrong for everything else.

Articles 113, 114, 115 and 160 of the Saudi Labour Law create a second category of leave that behaves nothing like a balance. It is not accrued. It is not carried over. It cannot be refused on the grounds that the worker has annual days available. It arrives attached to an event — a marriage, a birth, a death, a Hajj season, an exam — and in several cases it expires if the worker does not claim it inside a window measured in days.

Get this wrong and the failure is silent. Nobody notices a leave type that was never granted. The employee notices roughly a year later, in front of a labour committee, with a claim.

This tutorial builds the engine that gets it right. Every rule below is traced to an article number, every branch is covered by a test, and the test suite runs green at the end.

Prerequisites

  • Node.js 22+ (the examples use node --experimental-strip-types, so no build step)
  • TypeScript familiarity — discriminated unions and exhaustive switch handling
  • A working understanding of Saudi payroll concepts (wage base, payslip lines)
  • No database required; the engine is pure functions over dates

What You'll Build

A single awardSpecialLeave function that takes an employment record and a leave event, and returns an award: how many days are paid, how many are unpaid, when the leave starts and ends, whether it touches the annual balance, and — critically — a refusal reason when the entitlement does not arise.

The six events it handles:

EventArticleEntitlement
Marriage1135 days, full pay
Birth of a child1133 days, full pay, claimed within 7 days of the birth
Bereavement1135 days for spouse, ascendant or descendant; 3 days for a sibling
Hajj11410 to 15 days, once in the whole service, after 2 continuous years
Exam115Actual exam days, paid for a first attempt, unpaid for a repeated year
Iddah (widow)1604 months and 10 days for a Muslim worker; 15 days otherwise

Step 1: Model the event, not the balance

The first design decision is the one that saves you later. Do not add these to the annual leave table with a type column. They are a different shape: they have no opening balance, no accrual rate, and no carry-over. Model them as events that produce awards.

// src/special-leave/types.ts
export type Religion = 'muslim' | 'non-muslim';
export type Relation = 'spouse' | 'ascendant' | 'descendant' | 'sibling';
export type MonthBasis = 'statutory' | 'calendar';
 
export type Employment = {
  hiredOn: string;
  religion: Religion;
  performedHajjBefore: boolean;
  hajjLeaveUsedInService: boolean;
  enrolmentApproved: boolean;
  // Art. 2: a month is 30 days unless the contract or the work
  // regulations say otherwise. Use 'calendar' only if yours do.
  monthBasis: MonthBasis;
};
 
export type Event =
  | { kind: 'marriage'; on: string; requestedStart?: string }
  | { kind: 'newborn'; on: string; requestedStart?: string }
  | { kind: 'bereavement'; on: string; relation: Relation; requestedStart?: string }
  | { kind: 'iddah'; on: string; pregnant?: boolean; deliveryOn?: string }
  | { kind: 'hajj'; on: string; requestedDays?: number }
  | { kind: 'exam'; on: string; examDays: number; repeatYear: boolean };
 
export type Award = {
  article: string;
  paidDays: number;
  unpaidDays: number;
  startsOn: string | null;
  endsOn: string | null;
  deductsFromAnnual: boolean;
  notes: string[];
  refused?: string;
};

Note deductsFromAnnual on the award rather than as a constant. It is always false under these articles, and stating it explicitly on every award is what stops a later refactor from quietly netting these days off the annual balance — the single most common defect in this area.

monthBasis is required on purpose. Article 2 defines a month as thirty days "unless the employment contract or the work regulations provide otherwise", so the length of any leave written in months depends on a document the engine cannot see. Leaving the field optional would let a missing value fall back silently; making it required forces someone to open the contract once.

The rule people get wrong: an employer cannot refuse Article 113 leave because the worker still has annual days in hand. The entitlement is independent of the annual balance. This comes up constantly in Saudi HR discussions, and it is settled — the article grants the leave outright.

Step 2: Date arithmetic that survives month ends

Every rule in this engine is a date rule, so the date helpers are the engine. Use UTC throughout; a local-timezone Date will shift a leave boundary by a day for anyone running the payroll from a machine set to a different offset.

// src/special-leave/dates.ts
const MS_DAY = 86_400_000;
 
export const parseDate = (iso: string) => {
  const [y, m, d] = iso.split('-').map(Number);
  return new Date(Date.UTC(y, m - 1, d));
};
 
export const toISO = (d: Date) => d.toISOString().slice(0, 10);
 
export const addDays = (iso: string, n: number) =>
  toISO(new Date(parseDate(iso).getTime() + n * MS_DAY));
 
export const daysBetween = (a: string, b: string) =>
  Math.round((parseDate(b).getTime() - parseDate(a).getTime()) / MS_DAY);
 
export function addMonths(iso: string, n: number): string {
  const d = parseDate(iso);
  const day = d.getUTCDate();
  const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
  const last = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 0)).getUTCDate();
  t.setUTCDate(Math.min(day, last));
  return toISO(t);
}
 
export const overlapDays = (aS: string, aE: string, bS: string, bE: string) => {
  const s = Math.max(parseDate(aS).getTime(), parseDate(bS).getTime());
  const e = Math.min(parseDate(aE).getTime(), parseDate(bE).getTime());
  return e < s ? 0 : Math.round((e - s) / MS_DAY) + 1;
};

addMonths clamps: adding one month to 31 January yields 28 February, not 3 March. That clamp is not decoration. It is load-bearing in Step 5.

Step 3: The constants, each tied to its article

// src/special-leave/constants.ts
import type { Relation } from './types';
 
export const BEREAVEMENT_DAYS: Record<Relation, number> = {
  spouse: 5,      // Art. 113
  ascendant: 5,   // parents, grandparents
  descendant: 5,  // children, grandchildren
  sibling: 3,     // brother or sister
};
 
export const MARRIAGE_DAYS = 5;                 // Art. 113
export const NEWBORN_DAYS = 3;                  // Art. 113
export const NEWBORN_CLAIM_WINDOW_DAYS = 7;     // Art. 113 — from the date of birth
 
export const STATUTORY_MONTH_DAYS = 30;         // Art. 2 — unless the contract says otherwise
 
export const IDDAH_MONTHS = 4;                  // Art. 160
export const IDDAH_EXTRA_DAYS = 10;             // Art. 160
export const IDDAH_NON_MUSLIM_DAYS = 15;        // Art. 160
 
export const HAJJ_MIN_DAYS = 10;                // Art. 114
export const HAJJ_MAX_DAYS = 15;                // Art. 114
export const HAJJ_MIN_TENURE_YEARS = 2;         // Art. 114

The five-versus-three split is worth pausing on, because the SERP disagrees with itself about it. Article 113 grants five days for the death of a spouse, an ascendant or a descendant. A sibling carries three. Several widely-read Saudi HR pages quote only the five-day figure and let readers assume it covers every relative — which overpays for a sibling and, worse, teaches HR a rule that breaks the moment someone checks it.

Step 4: Article 113 — the three event leaves and the day the count starts

// src/special-leave/engine.ts
const refuse = (article: string, why: string): Award => ({
  article, paidDays: 0, unpaidDays: 0, startsOn: null, endsOn: null,
  deductsFromAnnual: false, notes: [], refused: why,
});
 
const span = (start: string, days: number) => ({ start, end: addDays(start, days - 1) });
 
// Art. 113: marriage and bereavement leave "are all counted from the date of
// the event". The period is fixed on the calendar; a late start shortens it.
const fromEvent = (on: string, days: number, requestedStart?: string): Award => {
  const lastDay = addDays(on, days - 1);
  const start = requestedStart ?? on;
  if (daysBetween(on, start) < 0) return refuse('113', 'Leave cannot start before the event.');
  if (daysBetween(start, lastDay) < 0)
    return refuse('113', `All ${days} days ran from ${on} and ended on ${lastDay}.`);
  const paid = daysBetween(start, lastDay) + 1;
  const notes = [`Counted from the date of the event (${on}); ends ${lastDay}.`];
  if (paid < days) notes.push(`${days - paid} of the ${days} days had already elapsed.`);
  return {
    article: '113', paidDays: paid, unpaidDays: 0,
    startsOn: start, endsOn: lastDay, deductsFromAnnual: false, notes,
  };
};
 
// --- marriage ---
case 'marriage': {
  const award = fromEvent(ev.on, MARRIAGE_DAYS, ev.requestedStart);
  award.notes.unshift('The event is the marriage date, not the date of the contract (aqd qiran).');
  return award;
}
 
// --- newborn ---
case 'newborn': {
  const start = ev.requestedStart ?? ev.on;
  const elapsed = daysBetween(ev.on, start);
  if (elapsed < 0) return refuse('113', 'Leave cannot start before the birth.');
  if (elapsed >= NEWBORN_CLAIM_WINDOW_DAYS)
    return refuse('113', `Claim window of ${NEWBORN_CLAIM_WINDOW_DAYS} days from birth has expired.`);
  const { end } = span(start, NEWBORN_DAYS);
  return {
    article: '113', paidDays: NEWBORN_DAYS, unpaidDays: 0,
    startsOn: start, endsOn: end, deductsFromAnnual: false,
    notes: [`Started on day ${elapsed} of the 7-day window.`],
  };
}
 
// --- bereavement ---
case 'bereavement': {
  const days = BEREAVEMENT_DAYS[ev.relation];
  const award = fromEvent(ev.on, days, ev.requestedStart);
  award.notes.unshift(`Relation '${ev.relation}' carries ${days} days.`);
  return award;
}

Three traps live in this block.

Marriage and bereavement are counted from the event, not from the request. Article 113, as amended by Royal Decree M/44, says these leaves "are all counted from the date of the event" (تحتسب جميعها من تاريخ الواقعة). The period sits on the calendar from the day of the death or the marriage, and a worker who starts late does not push the end date back. A worker who hears of a parent's death two days after it happened has three of the five days left, not five. That is why fromEvent fixes lastDay first and derives the paid days from it, and why a start after the last day is a refusal with its reason. The earlier version of this engine let requestedStart move the whole marriage leave, which quietly granted days the article does not.

The newborn window closes. The three days attach to the birth and must be taken inside seven days of it. A father who requests the leave two weeks later has no entitlement left to claim. A system that grants it anyway is not being generous — it is producing a payslip that does not reconcile to the statute, and the reverse case (a system that silently drops the request without telling anyone why) is how the entitlement gets lost in the first place. That is why refuse carries a reason string instead of returning zero days.

Marriage leave runs from the marriage, not the engagement. The distinction between the contract date and the marriage date is a live argument in Saudi HR practice, and the engine should record which date it used rather than leave it implicit.

Step 5: Article 160 — the iddah period, and the month Article 2 defines

Article 160 gives a Muslim worker whose husband dies leave at full pay of not less than four months and ten days from the date of death. A non-Muslim worker in the same circumstance receives fifteen days. If she is pregnant she may extend the leave without pay until she delivers, and she may not use what remains of the iddah leave after the delivery.

The question is what "a month" means, and the Labour Law answers it before Article 160 ever does. Article 2 defines the month as thirty days unless the employment contract or the work regulations provide otherwise. So the statutory period is 4 × 30 + 10 = 130 days, whatever the date of death. Calendar arithmetic applies only when the contract or the regulations define months as calendar months.

case 'iddah': {
  if (emp.religion === 'non-muslim') {
    const { start, end } = span(ev.on, IDDAH_NON_MUSLIM_DAYS);
    return {
      article: '160', paidDays: IDDAH_NON_MUSLIM_DAYS, unpaidDays: 0,
      startsOn: start, endsOn: end, deductsFromAnnual: false,
      notes: ['Non-Muslim widow: 15 days at full pay.'],
    };
  }
 
  // Art. 2 fixes the month at 30 days unless the contract says otherwise.
  const full = emp.monthBasis === 'calendar'
    ? daysBetween(ev.on, addDays(addMonths(ev.on, IDDAH_MONTHS), IDDAH_EXTRA_DAYS))
    : IDDAH_MONTHS * STATUTORY_MONTH_DAYS + IDDAH_EXTRA_DAYS;
  const notes = [`Four months and ten days on a ${emp.monthBasis} month basis = ${full} days.`];
 
  if (ev.pregnant && ev.deliveryOn && daysBetween(ev.on, ev.deliveryOn) >= 0) {
    const toDelivery = daysBetween(ev.on, ev.deliveryOn) + 1; // delivery day included
    if (toDelivery < full) {
      notes.push('Delivered inside the iddah: the remainder lapses at delivery.');
      return {
        article: '160', paidDays: toDelivery, unpaidDays: 0,
        startsOn: ev.on, endsOn: ev.deliveryOn, deductsFromAnnual: false, notes,
      };
    }
    if (toDelivery > full) {
      const unpaid = toDelivery - full;
      notes.push(`Pregnant: unpaid extension of ${unpaid} days to delivery.`);
      return {
        article: '160', paidDays: full, unpaidDays: unpaid,
        startsOn: ev.on, endsOn: ev.deliveryOn, deductsFromAnnual: false, notes,
      };
    }
  }
 
  return {
    article: '160', paidDays: full, unpaidDays: 0,
    startsOn: ev.on, endsOn: addDays(ev.on, full - 1), deductsFromAnnual: false, notes,
  };
}

Run it from two dates on each basis:

statutory month (Art. 2), any date   = 130 days
calendar months, from 2026-02-01     = 130 days
calendar months, from 2026-10-01     = 133 days

On the statutory basis the answer never moves. On a calendar basis it moves with the months the period spans: October to February is three days longer than February to June. Neither is wrong on its own. What is wrong is not knowing which one your contracts put you on. A system that computes calendar months for an employer whose contracts are silent overpays; one that stores 130 for an employer whose regulations define calendar months underpays an October widow by three days.

Correction (18 September 2026). An earlier version of this step said the iddah period is "not 130 days" and that storing 130 underpays. That ignored Article 2. Under the Labour Law as written, 130 days is the default, and the calendar computation applies only by contract.

The delivery ends the iddah. If a pregnant widow gives birth before the 130 days run out, the remaining days lapse; the engine pays up to and including the day of delivery and says so in the notes. Maternity leave under Article 151 then takes over. If she delivers after the period ends, the gap is the unpaid extension.

Article 10 is the other half. The periods in the Labour Law are counted in the Hijri calendar unless the contract or the regulations say otherwise. That does not change a count in days, but it matters the moment you add months to a date. The engine's calendar option adds Gregorian months, which is what a Gregorian-dated contract produces.

Note the floor. The article says not less than four months and ten days. The computed value is a minimum, not a cap. An employer policy that grants more is lawful.

Step 6: Article 114 — Hajj leave must not pay Eid twice

Hajj leave carries the most preconditions of any leave in the statute, and one arithmetic trap that costs real money.

The entitlement is ten to fifteen days at full pay, including the Eid al-Adha holiday, once during the entire period of service, and only if the worker has not performed Hajj before. It requires at least two continuous years with the same employer, and the employer may cap how many workers take it in a given year according to operational need.

case 'hajj': {
  const tenureDays = daysBetween(emp.hiredOn, ev.on);
  if (tenureDays < HAJJ_MIN_TENURE_YEARS * 365)
    return refuse('114', 'Requires two continuous years with the same employer.');
  if (emp.performedHajjBefore)
    return refuse('114', 'Worker has already performed Hajj.');
  if (emp.hajjLeaveUsedInService)
    return refuse('114', 'Hajj leave is once for the whole service.');
 
  const granted = Math.min(
    HAJJ_MAX_DAYS,
    Math.max(HAJJ_MIN_DAYS, ev.requestedDays ?? HAJJ_MIN_DAYS),
  );
  const { start, end } = span(ev.on, granted);
 
  const eid = holidays
    .filter((h) => h.name === 'eid-al-adha')
    .reduce((n, h) => n + overlapDays(start, end, h.start, addDays(h.start, h.days - 1)), 0);
 
  const notes = [`Granted ${granted} days, of which ${eid} fall inside the Eid al-Adha holiday.`];
  if (eid) notes.push('Eid days are already paid as a public holiday and are not paid twice.');
 
  return {
    article: '114', paidDays: granted - eid, unpaidDays: 0,
    startsOn: start, endsOn: end, deductsFromAnnual: false, notes,
  };
}

The phrase "including the Eid al-Adha holiday" means the Eid days sit inside the grant. They are already paid to every employee as a public holiday. A grant of fifteen days that overlaps a four-day Eid produces eleven days of incremental cost, not fifteen. Systems that treat the two as separate lines overstate the leave provision by up to four days per pilgrim.

Eid dates are data, not a formula. Eid al-Adha is fixed by moon sighting and announced each year. Do not compute it. Inject it:

export type Holiday = { name: string; start: string; days: number };
 
// Replace with the officially announced dates each year — these are astronomical
// estimates and the announced dates routinely differ by a day.
export const HOLIDAYS_2026: Holiday[] = [
  { name: 'eid-al-adha', start: '2026-05-27', days: 4 },
];

This is the same lesson the probation-period engine learned: any date the state announces rather than derives belongs in a table that a human updates once a year, with the estimate labelled as an estimate. A formula that is right four years out of five is worse than a table, because nobody checks a formula.

Step 7: Article 115 — the exam leave that flips to unpaid

Article 115 is conditional in a way the others are not. If the employer approved the worker's enrolment in an educational institution, the worker gets full pay for the actual exam days of a first attempt. For a repeated year, the leave is still owed — but without pay.

case 'exam': {
  if (!emp.enrolmentApproved)
    return refuse('115', 'Employer never approved the enrolment, so no entitlement arises.');
 
  const { start, end } = span(ev.on, ev.examDays);
 
  return ev.repeatYear
    ? {
        article: '115', paidDays: 0, unpaidDays: ev.examDays,
        startsOn: start, endsOn: end, deductsFromAnnual: false,
        notes: ['Repeated year: leave is owed, but unpaid.'],
      }
    : {
        article: '115', paidDays: ev.examDays, unpaidDays: 0,
        startsOn: start, endsOn: end, deductsFromAnnual: false,
        notes: ['Non-repeated year: actual exam days at full pay.'],
      };
}

"Actual exam days" is literal. It is not the length of the exam period; it is the days on which the worker sits an exam. A three-week exam session with four papers is four days, not twenty-one.

Step 8: Assemble and expose the refusal

Wire the branches into one exhaustive function:

export function awardSpecialLeave(
  emp: Employment,
  ev: Event,
  holidays: Holiday[] = [],
): Award {
  switch (ev.kind) {
    // ... the six cases from Steps 4 to 7
  }
}

Because Event is a discriminated union and every branch returns, TypeScript will fail the build if a seventh leave type is added and left unhandled. That is the property you want: the compiler, not a code reviewer, catches the statute changing.

The refused field matters as much as the paid days. An HR screen that shows "0 days" teaches nobody anything. One that shows "Claim window of 7 days from birth has expired" tells the manager what happened, tells the employee why, and gives you an audit trail when the claim arrives.

Testing Your Implementation

Twenty-one assertions cover every branch and every trap. This suite runs green:

import assert from 'node:assert/strict';
import { awardSpecialLeave, addMonths, type Employment } from './engine.ts';
 
const emp: Employment = {
  hiredOn: '2022-01-10',
  religion: 'muslim',
  performedHajjBefore: false,
  hajjLeaveUsedInService: false,
  enrolmentApproved: true,
  monthBasis: 'statutory',
};
 
// Art. 113 — marriage never touches the annual balance
const marriage = awardSpecialLeave(emp, { kind: 'marriage', on: '2026-03-01' });
assert.equal(marriage.paidDays, 5);
assert.equal(marriage.endsOn, '2026-03-05');
assert.equal(marriage.deductsFromAnnual, false);
 
// Art. 113 — counted from the event: a late start does not move the end
const lateMarriage = awardSpecialLeave(emp,
  { kind: 'marriage', on: '2026-03-01', requestedStart: '2026-03-04' });
assert.equal(lateMarriage.paidDays, 2);
assert.equal(lateMarriage.endsOn, '2026-03-05');
 
// Art. 113 — news of a death that arrives two days late leaves three of five days
const lateNews = awardSpecialLeave(emp,
  { kind: 'bereavement', on: '2026-03-01', relation: 'ascendant', requestedStart: '2026-03-03' });
assert.equal(lateNews.paidDays, 3);
assert.equal(lateNews.endsOn, '2026-03-05');
 
// Art. 113 — the newborn window closes on day 7
const late = awardSpecialLeave(emp, {
  kind: 'newborn', on: '2026-03-01', requestedStart: '2026-03-08',
});
assert.equal(late.paidDays, 0);
assert.match(late.refused!, /window/);
 
// Art. 113 — five days for an ascendant, three for a sibling
assert.equal(awardSpecialLeave(emp,
  { kind: 'bereavement', on: '2026-03-01', relation: 'ascendant' }).paidDays, 5);
assert.equal(awardSpecialLeave(emp,
  { kind: 'bereavement', on: '2026-03-01', relation: 'sibling' }).paidDays, 3);
 
// Art. 2 + 160 — a statutory month is 30 days, so 130 whatever the date
const feb = awardSpecialLeave(emp, { kind: 'iddah', on: '2026-02-01' });
const oct = awardSpecialLeave(emp, { kind: 'iddah', on: '2026-10-01' });
assert.equal(feb.paidDays, 130);
assert.equal(oct.paidDays, 130);
assert.equal(feb.endsOn, '2026-06-10');
 
// ...unless the contract defines the month as a calendar month
const cal: Employment = { ...emp, monthBasis: 'calendar' };
assert.equal(awardSpecialLeave(cal, { kind: 'iddah', on: '2026-10-01' }).paidDays, 133);
 
// Art. 160 — a delivery inside the iddah ends it
const delivered = awardSpecialLeave(emp,
  { kind: 'iddah', on: '2026-02-01', pregnant: true, deliveryOn: '2026-03-02' });
assert.equal(delivered.paidDays, 30);
assert.equal(delivered.unpaidDays, 0);
 
// Art. 114 — fifteen granted days over a four-day Eid cost eleven
const hajj = awardSpecialLeave(emp,
  { kind: 'hajj', on: '2026-05-25', requestedDays: 15 },
  [{ name: 'eid-al-adha', start: '2026-05-27', days: 4 }]);
assert.equal(hajj.paidDays, 11);
 
// Art. 115 — a repeated year is owed, but unpaid
const repeat = awardSpecialLeave(emp,
  { kind: 'exam', on: '2026-06-01', examDays: 4, repeatYear: true });
assert.equal(repeat.paidDays, 0);
assert.equal(repeat.unpaidDays, 4);
 
// month-end clamp
assert.equal(addMonths('2026-01-31', 1), '2026-02-28');

Run with node --experimental-strip-types test.ts. The assertions worth keeping forever are the late-start pair under Article 113, the iddah month basis and the Hajj overlap. Those are the ones a well-meaning refactor breaks first.

Troubleshooting

The iddah end date comes back one day early. You built the end date with local time instead of UTC, and a machine west of Riyadh rolled the boundary back. Every date in this engine must go through parseDate.

Hajj leave is refused for a long-serving employee. The tenure check uses 2 * 365 days, which ignores leap years. That is deliberately conservative — it refuses a borderline case rather than granting one that fails audit. If your policy is to count calendar years, swap the check for daysBetween(emp.hiredOn, addMonths(ev.on, -24)) >= 0.

Special leave is showing up as a deduction from annual balance. Something downstream is reading paidDays and netting it off. The deductsFromAnnual flag exists precisely so the consumer can assert on it — check it in your payroll posting step, not just in the engine.

A leave type returns undefined. You added a variant to Event and did not add a case. Turn on noImplicitReturns and the compiler will catch it before the payroll run does.

Next Steps

Conclusion

Special leave is a small part of a payroll system that generates a disproportionate share of its labour claims, because every rule in it is a rule about dates and every defect in it is invisible on the payslip. The engine above is roughly two hundred and fifty lines. It encodes Articles 2, 113, 114, 115 and 160, four preconditions, a count that starts on the day of the event whatever day the worker comes forward, and a month whose length is set by Article 2 unless your contract says otherwise.

The pattern that makes it maintainable is not clever. It is a discriminated union that the compiler can check, constants annotated with the article they come from, a refusal reason on every rejection, and a table for the dates the state announces rather than derives.

If your HR system already computes annual leave correctly and still handles marriage, bereavement and Hajj as manual approvals in a spreadsheet, that gap is where the claims come from. We connect HR systems to payroll so entitlements are computed from the statute rather than remembered by a manager — tell us what you are running and we will tell you what it is missing.