Leave, Holidays and Accruals: The Calendar Logic Nobody Warns You About
Leave, Holidays and Accruals: The Calendar Logic Nobody Warns You About

Leave is the feature that everyone underestimates. It presents as a form, an approval, and a number that goes down. Two days of work, surely.
Then somebody joins in June and asks how much of the annual allowance they get. Then somebody wants to carry unused days into next year, but only some of them, and only for one leave type. Then an administrator deletes a leave type that has a year of approved requests attached to it. That last one is not hypothetical and we will come to it, because it is the most useful part of this article.
A balance is computed, never stored
The foundational decision, and the one that makes everything else possible: nobody’s leave balance is stored anywhere. It is computed, every time it is asked for, from the policy and the requests that exist.

The alternative — a balance column decremented on approval — is simpler for about a month. Then a request is withdrawn and somebody forgets to increment it back. Then a policy changes mid-year and every stored balance is wrong with no way to tell which. Then an administrator makes a manual adjustment and now there are two sources of truth for the same number.
Computing it means the balance is always consistent with the requests, because it is derived from them. It costs a little on read and it removes an entire class of support ticket that begins “my leave balance is wrong and I do not know why”.
Proration, or what somebody who joined in June gets
An annual allowance of twelve days is unambiguous for somebody who was there on the first of January. For somebody who joined in June it is a question, and the answer has to be a stated rule rather than whatever the code happens to do.
Ours prorates by month, with the joining month counting in full, rounded to the nearest half day. Twelve days, joining in June, gives seven. That rule is arbitrary in the sense that other companies choose differently — some count from the following month, some prorate by day — and it is not arbitrary in the sense that it is written down, applied consistently, and visible.
The half-day rounding matters more than it looks. Prorating twelve days across seven months gives exactly seven, but eighteen days across five months gives seven and a half, and a system that silently floors that has quietly taken half a day from somebody. Choose the rounding deliberately and state it.
Every leave rule will eventually be questioned by the person it applies to. Rules that are written down survive that conversation. Rules that live only in code do not.
Monthly accrual is a different calculation, not a variation
Some policies grant the whole allowance at the start of the year. Others accrue it — one and a half days a month, say, available as they are earned.

These feel like the same feature with a switch and they are not. A yearly allowance is a fixed number prorated by joining date. A monthly accrual is a running total that depends on how many months have elapsed since the later of the policy’s creation and the person’s joining date, which means it changes on the first of every month without anybody doing anything.
That “later of” is the detail that gets missed. If a company introduces a monthly policy in September, nobody should be credited for the eight months before it existed — and equally, somebody who joined in July should not be credited for the months before they arrived. Getting either wrong produces balances that are wrong for exactly the people most likely to notice.
Carryover has two meanings and they behave differently
Carryover sounds like a single behaviour. In practice there are two, and a policy has to say which it means.
- Expire. Unused days do not survive the year boundary. The balance resets each January. Simple, and what most companies actually want.
- Accumulate. Unused days carry forward. Now the balance is a running figure across years, and the question of how far back it runs becomes real.
For yearly allowances we carry one year back and no further, because an unbounded carryover produces balances that grow indefinitely and a liability nobody has budgeted for. For monthly accrual with accumulation, the total accrues continuously and usage is counted all-time, which is a genuinely different calculation from the yearly case rather than the same one with a longer window.
We shipped the field before we shipped the behaviour — the policy could be set to carry over and nothing acted on it. That is worth avoiding. A setting that does nothing is worse than a missing setting, because somebody configures it, believes it is working, and finds out a year later that it never was.
Manual adjustments, because reality does not fit the policy
Whatever the policy computes, somebody will need a different number. Compensation for a weekend deployment. A goodwill day. A correction for leave taken before the system existed.
Rather than allowing the computed balance to be overwritten — which would reintroduce the stored-balance problem — adjustments are their own records: a signed number of days, a reason, an author, a timestamp. The balance is the computed figure plus the sum of adjustments, so both parts remain visible and either can be explained.
Every adjustment is audited and the member is notified. A balance that changes without explanation is indistinguishable from a bug, and support cannot tell them apart either.
The bug that cost somebody their leave
This is the part worth reading if you skip the rest.
Leave types were deletable. An administrator tidying up a list of policies deleted one, and the delete cascaded to the requests attached to it. A real employee’s approved annual leave — recorded, approved, taken — vanished from the system. Not archived. Gone.

The cascade was not malicious or even careless in the usual sense. It was the default behaviour of a foreign key that nobody had thought about, on a table that in development had never contained anything that mattered.
Two changes came out of it. Leave types are now archived rather than deleted: they disappear from the list of things you can apply for, and every historical request keeps pointing at them. And the foreign keys were changed to restrict, so the database itself refuses to destroy a type that has history behind it. The application-level fix would have been enough on its own; the database-level one is there because the application will be changed by somebody who does not know this story.
The general lesson is one we now apply everywhere: in a business application, deletion is almost always the wrong verb. Things are archived, deactivated, ended. The only rows that should be genuinely destroyed are ones that carry no history — and if you are unsure whether a row carries history, it does.
Holidays are simpler and still catch people out
A company holiday calendar is a list of dates that are non-working for everybody. It is the simplest thing in this article and it has two traps.

The first is whether a holiday inside a leave request consumes a day of allowance. It should not — a week of leave containing a public holiday should cost four days, not five — and that has to be computed rather than left to whoever fills in the form.
The second is regional variation, which in a country like India is the normal case rather than an edge case. A company with offices in two states does not have one holiday list. We have not solved this properly yet and we would rather say so than pretend the single-list model covers it. It is one org-wide calendar today, and the honest description of that is a limitation, not a design.
Notice periods, and where they are enforced
Some leave types need warning. Casual leave can be same-day; annual leave might need a fortnight.
That is a minimum-notice value on the type, computed against the start of the person’s shift on the first day of the leave, using the company’s work schedule so that “two days before” means two working days rather than forty-eight hours.
It is enforced on the server when the request is created, and the message the server returns is shown to the person verbatim rather than being replaced by a generic validation error. “Sick leave must be applied for at least two hours before your shift starts” is a rule somebody can follow. “Invalid request” is a support ticket.
Approval is a workflow, and the edges are where it breaks
The straightforward path — an employee applies, a manager approves or rejects, the balance updates — is about an afternoon of work. The value is entirely in the edges.

Nobody may review their own request. This sounds obvious and needs to be enforced at the server, because a manager is both an approver and a person who takes holidays, and the natural implementation checks whether the user has the approver role without checking whether they are also the applicant.
A pending request can be withdrawn by the person who made it. An approved one generally cannot, because somebody has already planned around it. The exception is worth stating: an owner may withdraw their own approved leave, because their approval was automatic and there is no one else to ask.
That owner case is a good illustration of a general trap. We first applied the same workflow to everybody, including the owner of the company — who has no reporting line, so their requests sat permanently pending and quietly broke every report that filtered on approval status. The fix was not to invent a fake approver. It was to make the system say the true thing: an owner’s request is created approved, other approvers are notified, the action is audited as automatic, and the interface says there is no approver needed rather than showing a control that does nothing.
Leave has to reach the timesheet, or you have two calendars
Approved leave that only exists in the leave module is half a feature. The timesheet still shows an absence, the attendance report still shows a missing day, and somebody has to explain the same fact twice.
So a day covered by approved leave reads as leave in the timesheet grid rather than as absent, and the attendance report distinguishes leave from absence from a non-working day. Three different states that look identical if you only count hours, and mean completely different things to a payroll clerk.
The one that catches people is the interaction with automatic clock-out and with scheduling. Somebody on approved leave who clocks in anyway — because they came in to finish something — should not have that time discarded, and should not silently consume a day of allowance either. We record the time and leave the request alone, because the two are different facts, and a system that tries to reconcile them automatically will get it wrong for the case where a person genuinely worked part of a leave day.
Notifications people actually read
Leave generates more notifications than any other part of a work application, and it is the easiest place to train people to ignore you.
The rule we settled on is that a notification exists when somebody has to do something or when a decision affects them directly. A request needs approving: the approvers hear about it. A request was approved or rejected: the applicant hears. A policy or a holiday changed: everybody hears, once. That is the whole list.
What we removed were the notifications that felt informative and were not — reminders that a request was still pending, notices to a whole team that a colleague was away, confirmations that an action the person had just performed had indeed been performed. Every one of them was defensible in isolation and collectively they taught people that the bell was noise.
The other rule: an actor never notifies themselves. Approving a request should not produce a notification for the approver about the approval they just made. It is a two-line condition and its absence is the fastest way to make an inbox feel broken.
What we would build differently
Two things we would change if we were starting again.
The first is regional holiday calendars, which we should have modelled from the start rather than assuming one list per company. Retrofitting a second dimension onto a table that is referenced from leave calculations, timesheet status and attendance reporting is considerably more work than allowing for it at the beginning, and in a country with as much regional variation as India it was always going to be needed.
The second is a persisted ledger. Balances are computed live, which is right and which we would keep. But there is a case for also writing an immutable record at each year boundary — this is what the balance was on the thirty-first of December, this is what carried forward — because at some point somebody will ask what a balance was eighteen months ago, and reconstructing it from a policy that has since changed is genuinely hard.
Neither of those is urgent. Both are the kind of thing that is cheap to add early and expensive to add once there is history, which is exactly the category of decision worth making before you need it.
Half days, and the request that does not fit the model
A leave model built on whole days will meet a half day within a week of launch, and how you handle it decides how much of the rest of the system you have to touch.
Storing days as a decimal rather than an integer from the beginning is nearly free and saves a migration through every balance calculation, every report and every approval screen. Half a day is 0.5; the calculations already work.
What does not come free is the interaction with the working day. Half a day of leave against a nine-hour shift is not the same number of hours as half a day against a six-hour one, and if leave is ever converted to hours — for payroll, or to fill a timesheet — that conversion has to use the person’s actual schedule rather than a constant.
Leave that spans a weekend
A request from Friday to Monday is two working days in most companies and four in a naive implementation. The count has to walk the range against the company’s working days and the holiday calendar rather than subtracting two dates.
It is a small function and it belongs in exactly one place, because the same count is needed when the request is created, when the balance is computed, when the timesheet is rendered and when a report is exported. Four implementations of a working-day count is four chances to disagree, and they will.
Who sees whose leave
A question that arrives in the first week of any deployment: can employees see when their colleagues are off? Both answers are defensible and the wrong move is to decide silently.
Some teams need it — you cannot plan a fortnight without knowing who is away. Others treat it as personal, particularly where sick leave is involved and the type of leave would be visible along with the dates. Our default is that a member sees their own detail and approvers see the team’s, with upcoming absences visible more broadly without the type attached. Dates without reasons answers the planning question without turning a leave calendar into a health record.
What we would tell anyone building this
- Compute balances, never store them. A stored balance drifts and cannot be explained.
- Write the proration rule down, including the rounding, where employees can read it.
- Treat monthly accrual as a separate calculation, starting from the later of policy creation and joining date.
- Make carryover explicit — expire or accumulate — and bound the yearly case.
- Never ship a setting that does nothing. Someone will configure it and trust it.
- Model adjustments as records, not as overwrites.
- Archive, do not delete. Then set the foreign keys to restrict, for the developer who has not read this.
- Exclude holidays from leave day counts in code, not in the form.
- Return rule messages verbatim. People can comply with a rule they can read.
Leave is a small feature that touches payroll, employment law and how fairly people believe they are treated. The calculations are not hard. What makes it difficult is that every one of them will eventually be questioned by the person it applies to, and the only defensible answer is a rule that was written down before it was applied.
Happy Tracker handles leave types, accrual, carryover, holidays and approvals alongside time tracking, free for up to five users with no time limit at happytracker.happycoders.in.

