22 · Leave and the employee agreement
How employees take leave, how the holiday calendar is kept, and how the employee agreement is issued, signed and kept. The source is the Alhuda Travels Employee Agreement, v3 — Clause 6 (leave policy), Clause 13 (peak season), Clause 20 (notice) and Annexure B (salary, benefits and leave) — and the owner's request of 29/09/2026: "create a leave portal for our employees and holiday calendar, based on these rules, also put an employee agreement digital copy of it where they will sign it and will be permanently saved", followed by "No, it can be a simple web page, why pdf?".
Two prefixes: LV for leave and holidays, EA for the employee agreement.
Migrations: 20261003094100_leave_and_agreement_permissions.sql (permissions),
20261003094200_leave_and_holidays.sql (leave), 20261003094300_an_agreement_is_signed_and_kept.sql
(the agreement), 20261005183000_leave_is_emailed_to_the_reporting_officer.sql (leave e-mails).
Tests: supabase/tests/leave_follows_the_agreement.sql, supabase/tests/leave_emails.sql,
supabase/tests/an_agreement_is_signed_and_kept.sql. Screens: Leave,
Employee agreement. Routes:
Leave and agreements API. Running it:
Leave year end and holidays.
Payroll is not built. Leave without pay and encashment are recorded as days; no salary is calculated and nothing is posted to the ledger.
Who and when
LV-001 · Who the policy covers
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Employee Agreement v3, Clause 6
The leave policy covers every staff login that holds leave.apply (every staff role — see
PERMISSIONS.md §5.7) and whose employee record has a joining date. The
office records the joining date on the employee record. Without it nothing is credited, and an
application is refused with "HR has not recorded your date of joining yet".
Tour leaders, business partners and travellers are not on it (TOUR_LEADER, AGENT and
CUSTOMER hold no leave permission; see LV-003).
Enforced by: leave_employees() (active, not deleted, joining date set, holds leave.apply);
leave_actor() in every write; leave_evaluate(). Test: leave_follows_the_agreement.sql
(LV-001 section).
LV-002 · The leave year runs 1 April to 31 March
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.1
A leave year starts on 1 April and ends on 31 March. The year is named by the year it starts
in ("2026–2027" is year 2026). One application stays inside one leave year; days after 31 March
are applied for separately.
Enforced by: LeaveSettings.leaveYearStartMonth (4); leave_year_of(), leave_year_start(),
leave_year_end(); leave_evaluate() refuses an application that crosses the year end.
LV-003 · Are tour leaders and field staff on this policy?
Status: OPEN · Owner: Management
A tour leader's login holds only field.* (FLD-001) and
has no leave.apply today, so tour leaders cannot apply. Options: (a) tour leaders are not
employees on this policy (today's behaviour); (b) grant leave.apply to TOUR_LEADER, which
also needs a joining date on each leader's record; (c) a separate policy for field staff.
LV-004 · The weekly off is Sunday
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 4.1 (working days
Monday to Saturday); Annexure B ("weekly off as notified")
Monday to Saturday are working days and Sunday is the weekly off. HR can set a different weekly
off for one person on their leave record (up to three days of the week).
Whether the Company uses per-employee weekly offs at all is OPEN; the field is there and
empty for everyone, so everyone has Sunday.
Enforced by: LeaveSettings.weeklyOffDays (default Sunday), EmployeeProfile.weeklyOffDays
(set by hr_set_employee_leave_profile); leave_weekly_offs().
LV-005 · How days of leave are counted
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 4.1, Clause 6.2
Days of leave are working days. Weekly offs and paid holidays (LV-020) inside the dates are not
counted, and the application shows each skipped date and why. Casual / sick leave, compensatory
off and leave without pay may start in the second half of the first day or end after the first
half of the last day; each half is 0.5. Earned leave is taken in whole days. Maternity leave and
Hajj / Umrah leave are counted in calendar days, every day included, with no half days.
The count is fixed when the application is made; a holiday added or moved later does not
recount it.
Enforced by: leave_count_days() (called by leave_evaluate()); LeaveType.halfDayAllowed,
LeaveType.calendarDays. Test: leave_follows_the_agreement.sql (LV-005 / LV-006).
LV-006 · The sandwich rule
Status: OPEN · Owner: Management
Should a weekly off or holiday that falls between two days of leave count as leave (the
"sandwich rule")? The agreement does not say. It is built as a setting in Leave admin, off by
default, so today such days are not counted. Switching it on changes the count of new
applications only.
Enforced by: LeaveSettings.sandwichRule; leave_count_days().
Entitlements
LV-010 · Casual / sick leave: 12 paid days a leave year, pro rata
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.1, Clause 6.2, Annexure B
Casual and sick leave (CL / SL) are one combined entitlement of 12 paid days a leave year. The
days are credited at the start of the leave year in proportion to the months of service in it:
someone who joined before 1 April gets 12; someone who joins during the year gets 12 × months ÷
12, rounded to the half day, where the month of joining counts if they joined on or before the
15th. Unused CL / SL lapses at year end (LV-052).
Enforced by: LeaveType row CL_SL (accrual yearly_prorata, 12 days);
leave_months_in_year(), leave_round_half(); the credit is written by
leave_run_accruals_internal() (LV-062) with period key credit:CL_SL:<year>, so it is written
once. Test: leave_follows_the_agreement.sql (LV-010 / LV-012).
LV-011 · Leave during probation
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 3.4, Clause 6.5 Until HR records the date of the written confirmation letter, the employee is on probation:
- CL / SL is usable only in proportion to the completed months worked in the leave year (12 × months ÷ 12, rounded to the half day, less what is used or pending);
- earned leave accrues from the joining date but cannot be taken. It becomes usable for leave that starts on or after the confirmation date.
HR records the confirmation date in Leave admin.
Enforced by: EmployeeProfile.probationConfirmedOn (set by hr_set_employee_leave_profile);
leave_balances_of() (the usableNow figure and the note on the dashboard);
leave_evaluate() refuses earned leave before confirmation. Test:
leave_follows_the_agreement.sql (LV-011).
LV-012 · Earned leave: 15 a year, 1.25 a completed month
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.2, Annexure B
Earned leave (EL) accrues at 1.25 days for each completed calendar month of service (15 a year).
A month counts when the employee joined on or before its 15th. The 1.25 is credited after the
month ends, dated the last day of the month, by the nightly job (LV-062). The job fills in
months back to the start of the previous leave year; service before that is entered by HR as an
opening balance (LV-051).
Enforced by: LeaveType row EL (accrual monthly, 15 days, requiresConfirmation);
leave_run_accruals_internal(), one ledger row per month with period key
accrual:EL:<YYYY-MM>. Test: leave_follows_the_agreement.sql (LV-010 / LV-012).
LV-013 · Earned leave is applied for 7 to 15 days ahead
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.2, Clause 6.3
Earned leave is normally applied for 7 to 15 days in advance. An application with less notice
is warned, not refused: HR may refuse it for short notice. An application marked as an
emergency is not warned. The 7 is a setting.
Enforced by: LeaveSettings.plannedAdvanceDays (7); a warning from leave_evaluate().
LV-014 · A medical certificate for sick leave over two days
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.2, Annexure B
For CL / SL of more than two consecutive days HR may ask for a medical certificate. The
application is flagged and the applicant is told; it is not refused. The applicant uploads the
certificate from the application, then or later. The file goes to the company Google Shared
Drive under Employees / "<name> (<id>)", never to Supabase Storage
(ACC-074), and is attached to the application. The approvers can open
it; nobody else beyond the employee and the office. A certificate does not count towards the
twenty-document limit of the employee's profile. The two days are a setting.
Enforced by: LeaveSettings.medicalCertificateAfterDays (2); LeaveApplication.certificateNeeded;
the edge function upload-employee-doc (document type medical_certificate) and
employee_add_document(); leave_attach_certificate() (own certificate, own application); the
row policy EmployeeDocument select leave certificate.
LV-015 · Maternity leave
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.2; LAW — Maternity
Benefit Act, 1961
Maternity leave is as the Maternity Benefit Act, 1961 provides: 26 weeks for the first two
children, less after that. HR records the entitlement for the employee when it applies, as a
grant in Leave admin, and the leave is counted in calendar days (LV-005). The system does not
work out the entitlement from the Act.
Enforced by: LeaveType row MAT (accrual manual, calendar days); leave_adjust() with kind
grant.
LV-016 · Paternity, bereavement, marriage and Hajj / Umrah leave
Status: OPEN · Owner: Management · Source: Clause 6.2, Annexure B ("if provided under Company
policy")
The agreement lists paternity, bereavement and marriage leave and a once-in-service leave for
the employee's own Hajj or Umrah, "if provided under Company policy". The number of days is not
set. Each is built as a leave type that is off until HR sets its days and switches it on in
Leave admin. Paternity and bereavement would be credited each leave year; marriage and Hajj /
Umrah once in service; Hajj / Umrah is counted in calendar days. Decision needed: which of the
four the Company gives, and how many days each.
Enforced by: LeaveType rows PAT, BRV, MAR, HAJ (enabled = false); leave_type_save()
refuses switching one on without a number of days, and refuses switching off CL / SL, EL,
comp-off or LWP.
Holidays
LV-020 · Twelve paid holidays a calendar year, in the holiday calendar
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.2, Annexure B
The Company gives 12 paid public and festival holidays a calendar year: Republic Day, Eid-ul-
Adha (3 days), Eid-ul-Fitr (2), Muharram 9 and 10 (2), Shab-e-Qadr (1), Nowruz (1), Independence
Day and Gandhi Jayanti. HR keeps them in the holiday calendar, which every member of staff can
read. A public or festival holiday is not leave: it is skipped when leave is counted (LV-005).
A restricted holiday is listed for information only and is counted as a working day. The
calendar shows how many paid holiday days a year has; it does not stop HR entering more than 12.
Enforced by: Holiday (kind public, festival or restricted; one entry spans at most seven
days); holiday_save(), holiday_delete() (hr.holidays.manage, audited); holiday_list()
(any staff login); leave_holiday_on() (public and festival only). Test:
leave_follows_the_agreement.sql (LV-020).
LV-021 · Dates that depend on the moon are tentative until HR confirms them
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
Eid-ul-Fitr, Eid-ul-Adha, Muharram and Shab-e-Qadr follow the moon. Their dates are entered as
tentative and shown so, and HR confirms or moves them when the moon is sighted. A tentative
holiday is already skipped when leave is counted.
Seeded by the migration: the fixed dates for 2026 and 2027 — 26/01 (Republic Day), 21/03
(Nowruz), 15/08 (Independence Day), 02/10 (Gandhi Jayanti) — and the 2027 lunar dates as
tentative: Shab-e-Qadr about 05/03/2027, Eid-ul-Fitr about 10/03/2027–11/03/2027, Eid-ul-Adha
about 17/05/2027–19/05/2027, Muharram 9 and 10 about 14/06/2027–15/06/2027 — and the 2026
lunar dates, also tentative: Shab-e-Qadr 16/03/2026, Eid-ul-Fitr 20/03/2026–21/03/2026,
Eid-ul-Adha 27/05/2026–29/05/2026, Muharram 9 and 10 25/06/2026–26/06/2026. HR confirms the
2026 ones against the dates the office observed and clears Tentative.
Enforced by: Holiday.tentative; holiday_save().
Applying and approving
LV-030 · Leave is sanctioned in advance by HR, through the portal
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.3
Leave is applied for and approved in advance by HR through the leave portal. A verbal "yes"
from a line manager is not an approval. HR approves or refuses; a refusal says why, and the
applicant sees the reason. Every application needs a reason. The days are taken from the
balance when the application is fully approved; while it is pending they are shown as
"pending" and are not usable twice.
Enforced by: leave_apply() (refuses anything leave_evaluate() lists as an error; a repeated
clientKey returns the first application); leave_decide() (a rejection needs remarks);
leave_post_approval() writes the debit. Test: leave_follows_the_agreement.sql (LV-030 /
LV-033 / LV-034).
LV-031 · HR's own leave is approved by their reporting manager
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.3, Clause 6.6, Clause 13.2
An applicant who holds the HR approval (hr.leave.approve) is approved by their reporting
manager — the person recorded as "reports to" on their employee record — instead of HR. Nobody
approves or decides their own leave. If an HR applicant has no reporting manager recorded, the
application is refused with a message asking the office to set "reports to".
Enforced by: leave_evaluate() sets routeHrUserId; leave_can_decide_hr(); leave_decide()
refuses the applicant. Test: leave_follows_the_agreement.sql (LV-031).
LV-032 · Peak season needs management approval too
Status: DECIDED 2026-09-29 (owner) · Owner: Management · Source: Clause 6.6, Clause 13.1, Clause 13.2 HR declares the peak-season date ranges in Leave admin. An application with any day inside a peak season:
- is approved by management (
hr.leave.approve_peak) after HR — two approvals; - is applied for at least 30 days ahead, unless it is marked as an emergency (refused otherwise; the 30 is a setting);
- gives a reason of at least a sentence (ten characters);
- cannot be compensatory off (LV-060).
The applicant sees a warning that peak-season leave is ordinarily not permitted.
Which months are peak season is OPEN: Clause 13.1 says "normally [the months of …]" and the
blank is not filled. Until it is, peak season is only what HR declares.
Enforced by: PeakSeason; peak_season_save(), peak_season_delete() (hr.leave.admin);
leave_evaluate(); LeaveSettings.peakAdvanceDays (30); leave_can_decide_mgmt(). Test:
leave_follows_the_agreement.sql (LV-011 / LV-013 / LV-032 / LV-040 / LV-042).
LV-033 · Withdrawing and cancelling
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.3
- The applicant withdraws an application that is still pending.
- The applicant cancels approved leave that has not started; the days go back to the
balance.
- For approved leave that has started, the applicant asks to cancel with a reason; the HR
approver (or, for an HR applicant, their reporting manager) agrees — the days go back — or
refuses with a reason.
- HR (hr.leave.admin) may cancel anyone else's application that is pending, approved or has
a cancellation asked — before or after it starts — with a reason; any days it took go back,
and the applicant is told. The screen offers Cancel this leave on the application in Leave
approvals and on the register in Leave admin.
An application is never deleted.
Enforced by: leave_cancel(), leave_decide_cancellation(), leave_reverse() (a reversal
ledger row); leave_application_detail() answers canHrCancel; the trigger
LeaveApplication_no_delete. Test: leave_follows_the_agreement.sql (LV-030 / LV-033 /
LV-034), leave_hr_cancels_and_records.sql (LV-033).
LV-034 · Every application keeps a timeline, and the people concerned are told
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
Every step of an application is kept in its timeline with who and when: applied or reported,
approved by HR / by the manager / by management, approved, rejected, withdrawn, cancelled,
cancellation asked for, agreed or refused, certificate attached. The applicant, the approvers
whose turn it is and the colleague taking over the work are told in the app's notification
inbox (TRV-010). The same steps are also e-mailed (LV-036 … LV-039,
amended 01/10/2026). No push notification is sent for leave: the in-app message waits in the
inbox until the person opens it.
Enforced by: LeaveEvent (append-only by trigger); leave_notify() writes AppNotification
rows and never fails the action that sends them.
LV-035 · A planned application names who takes over the work
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.3
A planned application names the colleague who looks after the applicant's pending work and
deadlines, with an optional handover note. It must be an active login and not the applicant.
The colleague is told when the leave is approved. An unplanned absence (LV-042) needs no
handover.
Enforced by: leave_evaluate(); LeaveApplication.handoverUserId; leave_decide() notifies
the colleague.
LV-036 · A leave request is e-mailed to the reporting officer and the approvers
Status: DECIDED 2026-10-01 (owner) · Owner: Admin/HR · Source: the owner, 01/10/2026: "leaves should also sent emails to reporting officer and upon accept, it should trigger another email" When an application is made — planned leave, an absence the employee reports (LV-042) — the system e-mails:
- the applicant's reporting officer (the "reports to" on their employee record): Leave request: Name — type DD/MM/YYYY–DD/MM/YYYY (n days) (Absence reported: … for an absence). It says the type, the dates with any half day, the days counted, the reason, the colleague taking over, the flags (peak season, notice period, leave without pay, emergency, unplanned) and who approves. When the reporting officer is also an approver of it — an HR applicant's leave goes to them (LV-031), or they hold the approval themselves — it says Your approval is needed; otherwise For your information — HR approves;
- everyone the application is routed to: the HR stage (holders of
hr.leave.approve, or the HR applicant's reporting officer) and, for peak season, the notice period or leave without pay, management (hr.leave.approve_peak): Leave waiting for your approval: Name — ….
A person who is both gets one e-mail. With no reporting officer recorded, only the approvers
are e-mailed, and their e-mail says none is recorded. When one stage is approved and another
is left, the next stage's approvers get Leave waiting for your approval again, saying the
first approval is done. A request to cancel leave that has started (LV-033) is e-mailed to
the HR stage (Cancellation waiting for your decision). A pending request the applicant
withdraws is e-mailed to the reporting officer (Leave request withdrawn).
Every e-mail links to Leave approvals (/leave/approvals) or the applicant's Leave page.
Enforced by: the trigger LeaveEvent_email → leave_email_on_event() (migration
20261005183000), which queues CommunicationQueue rows (leave_notice, category hr_leave)
for the communications-dispatcher through the one notification queue
(COMM-039, amended 02/10/2026:
leave_email_queue() → notify_enqueue()); the mailer template leave_notice. Test:
leave_emails.sql, notifications_share_one_queue.sql, src/services/leaveEmails.test.ts.
LV-037 · A decision is e-mailed to the applicant and the reporting officer
Status: DECIDED 2026-10-01 (owner) · Owner: Admin/HR · Source: as LV-036 ("upon accept, it should trigger another email") Only the final approval — after every stage the application needs — e-mails the applicant Your leave is approved: type DD/MM/YYYY–DD/MM/YYYY (n days), with what is left of that kind of leave for the leave year (none for leave without pay), and the reporting officer Leave approved for Name. A stage on its way is not told to the applicant (LV-036 tells the next approver). Also:
- a refusal — the applicant, with the remarks (Your leave is not approved), and the reporting officer for information;
- a cancellation of leave — by the applicant (a confirmation), by HR (with the reason), or an agreed request to cancel leave that has started — the applicant (Your leave is cancelled) and the reporting officer for information; a refused request to cancel — the applicant, with the remarks;
- an absence HR records (LV-042) — the employee (HR recorded an absence, with the balance) and the reporting officer.
The reporting officer is not e-mailed about a decision they took themselves.
Enforced by: leave_email_on_event(). Test: leave_emails.sql.
LV-038 · Leave e-mails go once, to working company addresses, and say only what is needed
Status: DECIDED 2026-10-01 (owner) · Owner: Admin/HR, IT
- Once. One e-mail per application, step and recipient: a double click, a repeated step or
a retry queues nothing more. A request to cancel can be made again after a refusal, and is
e-mailed again.
- To whom. The company e-mail on the login (User.email). A deactivated, deleted or
paused (ACC-070) login gets
none; nor does a missing address, something that is not an address, or a placeholder
(.invalid, .example, .local, .localhost, example.com/.org/.net).
- What it never carries. No medical certificate or its name, no contact number while away,
no handover note, no Aadhaar or PAN, no salary.
- From. The mailer's no-reply sender. The footer says replies are not read and to speak to
HR or call the office.
- Never in the way. An e-mail that cannot be queued never stops the leave step, and the
in-app notification goes as before (LV-034). Only the database queues a leave e-mail; the
mailer refuses one from a browser.
Enforced by: leave_email_address(), leave_email_data(), leave_email_queue() (the key
application : step : recipient — payload.leaveEmailKey, and since 02/10/2026 also the
queue's general key notifyKey in category hr_leave, unique by the index
CommunicationQueue_notify_once that replaced CommunicationQueue_leave_email_once; every
leave e-mail queued before then kept its key, so none is sent again —
COMM-039), the exception handler in
leave_email_on_event(); mailer (service role only for leave_notice). Test:
leave_emails.sql, notifications_share_one_queue.sql, leaveEmails.test.ts.
LV-039 · HR switches the leave e-mails on or off
Status: DECIDED 2026-10-01 (owner) · Owner: Admin/HR
The leave e-mails are on — the owner asked for them. HR (hr.leave.admin) switches them
off and on in Leave admin → Settings (Send leave e-mails). The change is audited
(leave_settings_updated). While off, nothing is queued; the in-app notifications still go.
This switch is the switch of the hr_leave notification category
(COMM-039, 02/10/2026): Admin →
Reminders lists it and says it is changed here; there is no second leave switch.
Enforced by: LeaveSettings.emailNotifications; leave_settings_save();
notify_category_enabled('hr_leave') reads it. Test: leave_emails.sql,
notifications_share_one_queue.sql, LeaveAdmin.test.tsx.
LV-040 · Leave during the notice period needs management approval
Status: DECIDED 2026-09-29 (owner) · Owner: Management · Source: Clause 6.8, Clause 20.3
HR records when an employee's notice period starts and ends. Leave with any day inside it is
generally not permitted: it is approved by management after HR, and the applicant is warned.
Enforced by: EmployeeProfile.noticeStartedOn / noticeEndsOn (set by
hr_set_employee_leave_profile); leave_evaluate() sets inNoticePeriod and needsManagement.
LV-041 · Leave without pay
Status: DECIDED 2026-09-29 (owner) · Owner: Management · Source: Clause 6.7
When the balance does not cover the dates, the applicant may choose to take the shortfall as
leave without pay (LWP); otherwise the application is refused and says how many days are
usable. An application for LWP itself, or with an LWP part, is approved by management after
HR. LWP is recorded as days in the ledger. Payroll is not built: no salary deduction is
calculated.
Enforced by: leave_evaluate() (allowLwp, the paid / LWP split);
LeaveApplication.paidDays + lwpDays = days (a constraint); LeaveType row LWP (unpaid,
no balance). Test: leave_follows_the_agreement.sql (LV-041).
LV-042 · Reporting an unplanned absence
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.4 An employee who could not come in reports the absence on the day or up to 3 days after (a setting), as CL / SL, compensatory off or LWP. HR regularises it by approving it like any application; the employee's reporting manager is told as well. A day that has passed cannot be applied for as planned leave; an absence older than the window is recorded by HR.
HR (hr.leave.admin) records it in Leave admin → Record an absence: the employee, CL / SL,
comp-off or LWP, the dates and a reason. It is filed already approved, counted and checked the
same way as the employee's own report (weekly offs and holidays skipped, no overlap, comp-off
still valid), and any days beyond the balance are leave without pay. Nobody records their own
absence, and a closed leave year takes no new record (correct it with an adjustment, LV-051).
The employee and their reporting manager are told.
Enforced by: LeaveApplication.isUnplanned; LeaveSettings.backdateDays (3);
leave_evaluate(); leave_apply() notifies the reporting manager;
leave_hr_record_absence() (migration 20261003170000, audited as
leave_absence_recorded_by_hr). Test: leave_hr_cancels_and_records.sql (LV-042).
Balances and the year
LV-050 · The leave ledger
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.10
Every credit and every debit of leave is a row in the leave ledger, with its date, its reason
and who made it (or "System"). A balance is the sum of the rows. The ledger is append-only: a
mistake is corrected with a new adjustment row, never by changing or removing one.
Enforced by: LeaveLedger; the trigger LeaveLedger_append_only; leave_balances_of(). Test:
leave_follows_the_agreement.sql (LV-050).
LV-051 · Manual adjustments
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
HR enters an opening balance, a grant (such as maternity leave) or a correction in Leave admin.
Each needs a reason and is in half days. Nobody adjusts, credits comp-off to, or changes the
leave record of their own login; another member of HR or management does it.
Enforced by: leave_adjust() (kinds opening, grant, adjustment; hr.leave.admin);
leave_compoff_credit(), hr_set_employee_leave_profile() refuse the caller's own record.
Test: leave_follows_the_agreement.sql (LV-051).
LV-052 · Closing the leave year
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.2, Annexure B The leave year is closed by HR, by hand, on or after 1 April — once per year, after a preview that shows each person's balance and what will happen to it:
- earned leave is carried forward up to 30 days (a setting); the rest lapses;
- CL / SL and any other leave credited per year lapse.
Closing first runs the accruals up to the year end, so every month is credited. A second close
of the same year changes nothing. To confirm, HR types the leave year; "2026–27", "2026-27",
"2026/27" and "2026 27" are all taken (the database is sent the year as a number).
Enforced by: leave_year_end_preview(), leave_year_end_run() (hr.leave.admin);
LeaveYearClose (one row per year, append-only); LeaveSettings.elCarryForwardCap (30). Test:
leave_follows_the_agreement.sql (LV-052 / LV-053).
LV-053 · Encashment at year end
Status: OPEN · Owner: Management · Source: Clause 6.2 ("where the Company permits, at year-end")
May earned leave above the carry-forward limit be encashed at year end instead of lapsing? It is
built as a setting, off by default. When on, those days are recorded as encashed days in the
ledger; no amount is calculated or paid. Encashment on separation is settled in the full and
final settlement outside the system.
Enforced by: LeaveSettings.yearEndEncashment; leave_year_end_rows().
LV-054 · The leave register
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.10
HR can open one employee's register for a leave year — their record, balances, every ledger row,
every application and every comp-off — and export it as a CSV file.
Enforced by: leave_register() (hr.leave.admin or hr.leave.approve).
LV-055 · Who is away
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
The team view lists approved, pending and cancellation-asked leave in a date range of up to
three months. HR and management see everyone; anyone else sees the people who report to them.
Enforced by: leave_team(); leave_may_read() in the row policies. Test:
leave_follows_the_agreement.sql (ACC-053 section).
Compensatory off and the nightly job
LV-060 · Compensatory off
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Clause 6.9, Clause 13.2, Annexure B
When the Company instructs an employee to work on their weekly off or a paid holiday, HR records
it: the day, who instructed it, what the work was, and a whole or half day. A day that was a
working day for that person is refused. The employee takes the comp-off as leave on a working
day outside peak season (LV-032); the oldest credit is used first.
Enforced by: CompOffCredit (one per person per day); leave_compoff_credit()
(hr.leave.admin); leave_evaluate(); leave_post_approval(). Test:
leave_follows_the_agreement.sql (LV-060 / LV-061).
LV-061 · A comp-off expires after 90 days
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
A comp-off credit must be used within 90 days of the day worked (a setting). An application is
checked against the credits still valid on its last day. What is unused on the expiry date
lapses and is written to the ledger by the nightly job.
Enforced by: LeaveSettings.compOffExpiryDays (90); CompOffCredit.expiresOn;
leave_run_accruals_internal().
LV-062 · The nightly job
Status: DECIDED 2026-09-29 (owner) · Owner: IT/Admin
Every night at 00:30 India time a scheduled job credits the yearly leave (LV-010, LV-016),
accrues earned leave for the months that have ended (LV-012) and expires comp-offs (LV-061).
It can run any number of times: a credit already written is not written again. HR can run the
same catch-up by hand from Leave admin, up to today. If the database has no pg_cron, the job
is not created and HR runs it by hand.
Enforced by: leave_nightly(), scheduled by pg_cron as alhuda-leave-nightly (0 19 * * *
UTC); leave_run_accruals() (hr.leave.admin); the unique index LeaveLedger_period_key.
Runbook: Leave year end and holidays.
The employee agreement
EA-001 · The agreement text is a fixed, versioned template
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: Employee Agreement v3
The text of the employee agreement (Clauses 1–25, Annexures A–E) is kept in the database as
version v3: the owner's v3 text word for word, with every bracketed blank turned into a named
value that changes per employee. The only wording the system adds is in Clause 25: a sentence
saying the copy is signed electronically with the record shown after it, and Witnesses as a
value that starts as "none — signed electronically with the record shown below". These replace
the paper signature and witness tables, because the signatures are the recorded signature
blocks (EA-004, EA-005). The owner should confirm them (EA-011).
A template never changes; a new text is a new version. Agreement numbers read
EA-<employee code>-V<template>-<n>. Since 02/10/2026 new agreements use v4: v3 with the
salary clause 5.1, the salary review and increments clause 5.6 (EA-018)
and the changes clause 24.3 (EA-019); nothing else differs.
Enforced by: AgreementTemplate (rows employee-agreement-v3, employee-agreement-v4 —
20261008125500); the trigger
AgreementTemplate_guard refuses any change or delete.
EA-002 · HR prepares the agreement
Status: DECIDED 2026-09-29 (owner), amended 2026-10-02 (EA-017) · Owner: Admin/HR
HR (hr.agreements.issue) prepares an agreement for one employee; it goes to the employee only
after the CEO approves it (EA-017). The values are filled from
the employee's record (name, address, employee code, designation, department, reporting
manager, joining date) and the company settings (legal name, address, signatory). Every bracket
of the paper version is an editable value that starts as its bracketed text; the salary lines
start as "as set out in the offer letter". HR sees the full text before issuing. A required
value left empty is refused. Preparing again for the same employee withdraws an earlier version
still waiting for the CEO; the CEO's approval of the new one withdraws an earlier issue that is not
yet signed. HR can withdraw an unsigned issue with a reason; a signed agreement cannot be
withdrawn. Where the appointment values come from when the profile is empty, and how they go
back to the profile, is EA-012 and EA-013.
Enforced by: agreement_prepare(), agreement_issue(), agreement_withdraw();
agreement_values(), agreement_render(). Test: an_agreement_is_signed_and_kept.sql (EA-002).
EA-003 · The text is fixed by its hash
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
When an agreement is issued, the exact text is stored with its SHA-256 hash. On every view the
hash is checked again, in the database and in the browser, and the page shows whether it
matches. A signature is accepted only for the text whose hash the signer's screen sent.
Enforced by: EmployeeAgreement.renderedText, contentHash; agreement_sha256();
agreement_json() (hashVerified); agreement_sign() and agreement_countersign() compare the
hash.
EA-004 · The employee signs
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
Only the employee named in the agreement signs it. To sign they read to the end, tick "I have
read and understood", type their full name exactly as it is on their record, draw their
signature in the box, and confirm with their password. The password is checked in the database
against the login; five wrong passwords lock signing for fifteen minutes. The record keeps the
time, the typed name, the IP address, the browser, the hash and the drawn signature (its
strokes). Management (the countersignatories) is told.
Enforced by: agreement_sign(); agreement_check_password() (each failure is an audit row);
agreement_name_matches(); agreement_signature_ok() (only move, line and curve commands and
numbers, so a signature can never carry script). Test: an_agreement_is_signed_and_kept.sql
(EA-004).
EA-005 · The Company countersigns
Status: DECIDED 2026-09-29 (owner) · Owner: Management
After the employee, the authorised signatory (hr.agreements.countersign) countersigns the
same way — typed name, drawn signature, password. Their designation is optional; left empty,
the designation on their employee record is used, else the signatory designation in the leave
settings. Nobody countersigns their own agreement. The employee is told when it is complete.
Enforced by: agreement_countersign(); the constraint EmployeeAgreement_not_self_check. Test:
an_agreement_is_signed_and_kept.sql (EA-005).
EA-006 · A change is a new version, signed again
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
A signed agreement is never edited. A change is a new version issued to the employee and signed
by both sides again. When the new version is countersigned, the older signed version is marked
superseded and kept.
Enforced by: agreement_issue() (the next version number); agreement_countersign() marks the
older ones superseded. Test: an_agreement_is_signed_and_kept.sql (EA-006).
EA-007 · An agreement is permanent
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR · Source: the owner, "will be permanently
saved"
An issued agreement is never deleted. Its text, hash, values and issuer never change, and a
signature once given never changes. Its status only moves forward: issued → signed by the
employee → countersigned → superseded, or issued → withdrawn before anyone signs.
Enforced by: the triggers EmployeeAgreement_guard (every update and delete) and
EmployeeAgreement_no_truncate; the constraints on EmployeeAgreement. Test:
an_agreement_is_signed_and_kept.sql (EA-007).
EA-008 · Who reads an agreement
Status: DECIDED 2026-09-29 (owner) · Owner: Admin/HR
An employee reads their own agreements at My agreement. HR and management
(hr.agreements.view) read every agreement. A countersignatory reads the ones signed by the
employee. Nobody else reads them.
Enforced by: the row policy EmployeeAgreement select own or hr; agreement_my(),
agreement_get(), agreement_hr_list(). Test: an_agreement_is_signed_and_kept.sql (EA-008).
EA-009 · What kind of signature this is
Status: DECIDED 2026-09-29 (owner) · Owner: Management · Source: the owner, "it can be a simple web page, why pdf?" The signature is a simple electronic signature with an audit trail: the person is signed in, confirms with their password, and the record keeps who, when, from where and the hash of the text (Information Technology Act, 2000). It does not claim to be an electronic signature under section 3A and the Second Schedule of the Act; that needs Aadhaar eSign, which is not built. There is no PDF: the web page is the record. The browser's Print, or Save as PDF, makes a paper copy.
EA-010 · Who is the Company's authorised signatory?
Status: OPEN · Owner: Management
The agreement names the Company's authorised signatory and their designation. Today CEO and GM
hold hr.agreements.countersign, and the name and designation printed in the agreement are a
setting HR fills in Leave admin; an agreement cannot be issued while they are empty. Decision
needed: who signs for the Company (one person, or either of CEO and GM), and whether anyone else
may.
EA-011 · Confirm the Clause 25 wording and the starting values
Status: OPEN · Owner: Management Two things need the owner's confirmation (EA-001): the Clause 25 wording the system adds (the sentence about the electronic signature and the Witnesses value), and the values the bracketed blanks start as — for example the peak months ("the months the Company notifies in writing each year") and the salary lines ("as set out in the offer letter"). HR can change any value before issuing; a change to the text itself is a new template version.
EA-012 · The appointment comes from the staff record
Status: DECIDED 2026-09-30 (owner: "These should be fetched and mapped from the staff, right? At least designation and Reporting to") · Owner: Admin/HR The appointment in the agreement — designation, department, reporting to, date of joining — is filled from the employee's profile. When the profile is empty:
- Designation is the plain title of the login's highest staff role, and department the role's family. Highest first: CEO (Chief Executive Officer, Management), GM (General Manager, Management), FINANCE_MANAGER (Finance Manager, Finance), OPS_MANAGER (Operations Manager, Operations), SALES_MANAGER (Sales Manager, Sales), B2B_MANAGER (B2B Manager, B2B / Partners), TICKET_MANAGER (Ticketing Manager, Ticketing), ADMIN_HR (HR Manager, Human Resources), IT_ADMIN (IT Administrator, IT), CHARTERED_ACCOUNTANT (Chartered Accountant, Accounts), ACCOUNTANT (Accountant, Accounts), AUDITOR (Auditor, Accounts), VISA_OFFICER (Visa Officer, Visa), OPS_EXEC (Operations Executive, Operations), SALES_EXEC (Sales Executive, Sales), B2B_EXEC (B2B Executive, B2B / Partners), TICKET_EXEC (Ticketing Executive, Ticketing), CASHIER (Cashier, Accounts). SUPER_ADMIN, TOUR_LEADER, AGENT and CUSTOMER are not job titles; a login with only those has none.
- Reporting to is never guessed. HR picks it from the staff list (ACC-081); it reads "Designation (Name)", the officer's designation being their profile's, else their role's title.
- Date of joining is never guessed. It comes from the profile only.
The father's / guardian's name is kept on the profile (EmployeeProfile.parentName, edited by
the office) and filled from there the same way; it is never guessed.
The dialog marks each of the four, and the father's / guardian's name, with where it came from: from the profile, from the role (to
be checked), missing, or typed by HR. The designation is typed freely, with the designations
already on profiles and the role titles suggested. The sidebar's short job title (for example
"HR Admin") is a display chip and is not used in the agreement.
Enforced by: staff_job_title(), staff_designation(), agreement_values_traced() (origins,
reportsToUserId), agreement_prepare() — migration
20261003195000_the_appointment_comes_from_the_staff.sql. Test:
the_appointment_comes_from_the_staff.sql (EA-012).
EA-013 · Issuing saves the appointment to the profile
Status: PROPOSED 2026-09-30 · Owner: Admin/HR · Source: EA-012
When HR issues an agreement with Also save these to the employee's profile ticked (the
default), the appointment values used — designation, department, reporting officer, joining
date — and the father's / guardian's name are written to the employee's profile in the same transaction, but only those that were
empty on the profile or that HR changed. The write is the office's own profile edit
(admin_update_employee_profile()), with its audit row naming the fields; the issue's audit row
records what was saved or why it was not.
Issuing never widens who may edit a profile: the write needs the same right as Edit profile
(amended 2026-09-30, ACC-084).
HR (hr.profiles.edit) may write the appointment of any staff login that is not an admin account
(IT Admin or Super Admin today) — a salesperson, operations, finance, a manager, the GM, a CEO.
The office's full edit (admin.users.edit over someone whose every right it holds,
can_manage_user, ACC-071) also may. Otherwise the agreement is still issued, the profile is not
written, and HR is told why — for an admin, "Not saved to the profile — HR can't edit an IT Admin
or Super Admin profile."; without either right, "Not saved to the profile — needs HR profile
rights or Admin → Users edit rights". A joining date that is not a date, or a reporting officer
typed rather than picked (possible only through the API), is not saved.
Enforced by: agreement_issue(p_user_id, p_values, p_save_to_profile),
agreement_profile_block() (asks profile_edit_rights() since 20261003235500),
agreement_parse_date() — migrations
20261003195000_the_appointment_comes_from_the_staff.sql, 20261003235500_hr_edits_profile_details.sql. Tests:
the_appointment_comes_from_the_staff.sql, hr_edits_profile_details.sql (EA-013).
EA-014 · Every step of the agreement is e-mailed
Status: DECIDED 2026-10-02 (owner: "Balaghat HR issued one agreement to Showkat, but showkat didn't get it anywhere, everything should also trigger emails") · Owner: Admin/HR · Source: EA-002, EA-004, EA-005, LV-038 Each step of an agreement e-mails the person who has to act or should know, at the company address of an active login, besides the ERP inbox notice:
| Step | E-mailed to |
|---|---|
| Prepared by HR | each CEO who may approve it — Review and approve (EA-017) |
| Approved by the CEO | the employee — "ready to sign", with Read and sign; a new version says which version it replaces; the HR who prepared it, for information |
| Not approved | the HR who prepared it, with the CEO's reason; the employee never hears of it |
| Signed by the employee | each countersigner (hr.agreements.countersign) — Open and countersign; the HR who issued it, for information |
| Countersigned | the employee — "complete", with Open my copy; the HR who issued it, for information |
| Withdrawn by HR | the employee, with HR's reason (also an inbox notice) |
A version replaced by a newer one before it was signed is not mailed as withdrawn; the new version's e-mail says it replaces it. The e-mail carries a link, never the agreement's text or pay. A deactivated or paused login gets no e-mail. Once per agreement, step and recipient; a failed e-mail never stops the step.
Enforced by: the trigger EmployeeAgreement_email → agreement_announce() → agreement_email() → notify_enqueue() (category hr_agreement, template agreement_notice in the mailer, sent by the database only) — migration 20261008125000_every_agreement_step_is_emailed.sql. Agreements already waiting when it was applied were e-mailed then. Test: supabase/tests/every_agreement_step_is_emailed.sql; the wording src/services/agreementEmails.test.ts.
EA-015 · An agreement left waiting is reminded
Status: DECIDED 2026-10-02 · Owner: Admin/HR · Source: EA-014
An agreement waiting for the CEO's approval is e-mailed to each CEO who may approve it every 2 days, from day 2 to day 30. One waiting for the employee is e-mailed again (and an inbox notice) every 3 days from the day it was approved, from day 3 to day 30. One waiting for the Company's countersignature is e-mailed to each countersigner every 2 days, from day 2 to day 30. Daily at 10:00 India time.
Enforced by: agreement_reminders_run(), pg_cron job alhuda-agreement-reminders (30 4 * * * UTC) — 20261008125000. Test: every_agreement_step_is_emailed.sql.
EA-016 · Until it is signed, every screen says so
Status: DECIDED 2026-10-02 (owner: "they get to see the agreement atleast once or whenever they want to … whatever is professionally followed around the world") · Owner: Admin/HR · Source: EA-004, EA-008
While a staff member has an agreement to sign, every page of the ERP shows a bar — "Your employee agreement is waiting for your signature" — with Read and sign, and the app's home screen says the same. A countersigner sees how many agreements wait for the Company, and a CEO how many wait for approval. The bar asks for no text and no pay. Once signed, the agreement — and every earlier version — stays under My agreement, to open, print or save as PDF at any time (EA-008, EA-009).
Enforced by: agreement_waiting_for_me(), carried in badge_counts() (agreementToSign, agreementsToCountersign) so the web bar costs no extra request (PRF-002) — 20261008125000; src/components/layout/AgreementWaitingBanner.tsx; the app's home (apps/mobile/src/app/(staff)/index.tsx, agreementWaitingLine). Tests: every_agreement_step_is_emailed.sql, AgreementWaitingBanner.test.tsx, apps/mobile/src/lib/agreements.test.ts.
EA-017 · The CEO approves before the employee sees it
Status: DECIDED 2026-10-02 (owner: "it gives HR rights to re-issue agreements without approval from higher authorities, what if HR puts different salary?"; asked who approves: "CEO only") · Owner: CEO · Source: EA-002, ACC-030 (maker-checker)
An agreement HR prepares — a first issue, a re-issue or a new version — waits for the CEO. The
employee neither sees it nor is told of it. A CEO (hr.agreements.approve: CEO; SUPER_ADMIN as
the system's backstop) reads the exact text, salary included, and approves it (an optional
note) or rejects it with a reason. Nobody approves an agreement they prepared, or their own; a
CEO's own agreement is approved by the other CEO. Only on approval is the agreement issued to the
employee, stamped with who approved it and when, and only then is an earlier unsigned version
withdrawn. A rejected one is kept, marked withdrawn with "Not approved by the CEO: pending_approval; agreement_issue() (prepares), agreement_approve(),
agreement_reject(), agreement_approvers(), the trigger EmployeeAgreement_guard
(pending_approval → issued or withdrawn only); agreement_my() and agreement_get() hide a
pending version from the employee — 20261008125500_the_ceo_approves_an_agreement.sql. Routes
POST /agreements/:id/approve, POST /agreements/:id/reject. Tests:
an_agreement_is_signed_and_kept.sql, every_agreement_step_is_emailed.sql (EA-017),
src/pages/agreements/AgreementPage.test.tsx.
EA-018 · Salary and increments
Status: DECIDED 2026-10-02 (owner: "add salary and increment clause"; asked when: "Yearly in April") · Owner: CEO · Source: EA-001 (template v4)
Clause 5.1 of v4: the salary is the one in Annexure B as approved by the Chief Executive Officer
before the Agreement was issued, paid monthly by bank transfer by the set day with a salary slip
showing each component and deduction. Clause 5.6: salary is reviewed once a year after the
performance review; an increment, if any, takes effect from 1 April; it depends on the rating,
conduct and attendance and the Company's results, is discretionary and never automatic; an
employee on probation on 1 April or with a written warning in the year may be reviewed later or
not given one; every increment or salary change is a written increment letter approved by the
CEO, which becomes part of the Agreement; no one else may promise or change a salary; fixed
salary is never reduced without written consent except as the law allows.
Enforced by: the text of employee-agreement-v4 (20261008125500).
Not built: increment letters in the system. Until they are, an increment letter is a paper letter
signed by the CEO; a change of salary in the agreement itself is a new version (EA-006) approved
by the CEO (EA-017).
EA-019 · The Company may change the Agreement
Status: DECIDED 2026-10-02 (owner: "add points that we may change any clause any time according to laws"; asked how: "Law: at once; others: 30 days") · Owner: CEO · Source: EA-001 (template v4), Clause 12
Clause 24.3 of v4: a change the law, a court or government order or a regulator requires applies
from the date it takes effect, and the employee is told in writing as soon as practicable. Any
other change of a clause, annexure or policy needs 30 days' written notice (letter, e-mail or
the HR system) or longer if the law requires; an employee who does not accept it may resign under
Clause 20 before it applies. No change reduces fixed salary or a legal right without the
employee's written consent. Anything else needs both signatures.
Enforced by: the text of employee-agreement-v4 (20261008125500).
Not built: sending a change notice from the system. A legal review of v4's new clauses by the
Company's lawyer is advised before wide use.