Leave & Holidays (LVE)
Module code LVE · Release R1 (MVP) · Packaging Core · Requirements
FR-LVE-*,BR-LVE-*,RULE-LVE-*in the PRD and BRD · Related Attendance, Payroll, Exit, Organization (holiday calendars), Notifications & Workflow
1. Overview and scope
LVE manages what leave an employee is entitled to, what they have taken, and what it costs payroll. It is the most-used self-service feature and the source of LOP (loss of pay) for payroll. The design centres on three ideas:
- A leave-type builder with templates, instead of a fixed list.
- Effective-dated leave policies per type, assigned by applicability rules.
- An append-only leave ledger. A balance is always a sum of ledger entries, never a mutable number.
In scope (R1)
- Leave types (seeded templates: Casual, Sick, Earned/Privilege, Comp-off, Loss of Pay, Maternity, Paternity, Adoption, Marriage, Bereavement, Advance) plus custom types.
- Leave cycle (April–March or January–December, per tenant), accrual (upfront or periodic, pro-rata on joining), carry-forward, lapse, encashment at year end and at F&F.
- Requests with half days, sandwich rule, notice period, minimum service, document thresholds, per-instance caps, lifetime caps and gaps between terms.
- Comp-off earned from worked week-offs and holidays (from ATT), with expiry.
- Optional (restricted) holidays with a yearly quota.
- Cancellation of approved leave (future-dated, or past-dated with approval).
- Opening-balance import and manual adjustments with reasons.
- Team calendar, conflict warnings and the leave-to-payroll bridge.
Out of scope: holiday calendar maintenance (ORG owns it), calendar sync with Google/Microsoft (R3), leave forecasting analytics (R4).
2. Learned from the reference product
Source: Spryple analysis, §6, §16, §19 and §21.4.
- [Observed] There are ten fixed leave types that can only be activated or deactivated; adding a type means a vendor change (SP-LVE-02; A: Leave Mgmt).
- [Observed] Per-type rules are India-specific and rich: credit frequency (monthly, quarterly, half-yearly, yearly), sandwich flags, max per instance, minimum service months, advance notice, document required above N days, carry-forward cap, lapse to perks, comp-off minimum hours and expiry, maternity minimum gap between terms (SP-LVE-03).
- [Observed] Common rules include sandwich across weekends and holidays, a past-dated leave window, configurable emails and an April–March or January–December cycle (SP-LVE-01).
- [Observed] Eligibility for maternity, paternity and wedding leave depends on gender and marital status (SP-LVE-04). We drop this (see D-LVE-05).
- [Observed] Leave policy changes apply only to new employees (SP-LVE-07; SP-X-08).
- [Observed] Managers enter leave on behalf of employees, which is auto-approved with the balance shown; there is a cancellation approval queue and a leave "Payroll Report" with LOP and absent counts (SP-LVE-06).
- [Inferred] Balances appear to be stored values; no ledger-style history of credits and debits is documented (§6.3).
3. Our design decisions
| ID | Decision [Proposed] | Reason |
|---|---|---|
| D-LVE-01 | Leave types are tenant data created from templates. A type has a category (PAID, UNPAID, COMP_OFF, STATUTORY_PARENTAL, SPECIAL) that drives payroll and defaults. | Tenants need Restricted, Study, Sabbatical, Menstrual or state-specific types without a code change. |
| D-LVE-02 | Each type has one or more leave policies. A policy has effective-dated versions with a typed settings document, and is assigned by applicability (location, grade, employment type, department, designation). | Different rules for factory and office staff; mid-year changes apply from a chosen date to existing employees. |
| D-LVE-03 | Leave ledger (lve_ledger_entry) is the only source of balances. A cached lve_balance row is a projection, rebuilt from the ledger. | Every balance is explainable to the employee and auditor. Corrections are entries, not overwrites. |
| D-LVE-04 | Pending requests reserve balance. available = ledger_balance - pending_reserved. | Prevents two requests spending the same days. |
| D-LVE-05 | Eligibility is a rule over configurable attributes (service, employment type, gender identity where the law needs it, number of prior availments). Marital status is never used. Parental leave is inclusive: birth, adoption and commissioning parents. | Legal and ethical risk in the reference design; the Maternity Benefit Act does not require marriage (verify with counsel). |
| D-LVE-06 | The sandwich rule is computed by a pure function over the request, adjacent approved leave and the location's calendar. Results are stored per day in lve_request_day. | Explainable day counts; recompute when a holiday changes. |
| D-LVE-07 | Approvals go through the NTF engine. Default chain: line manager; conditional HR step when days exceed a threshold. | The reference product documents single-level only. |
| D-LVE-08 | Late-mark deductions from ATT post LATE_DEDUCTION entries here, in a configured type order, falling back to LOP. | One balance, one history. |
| D-LVE-09 | Encashment is a request type with its own policy (max days, eligible types, formula base) that produces a payroll input. | The reference product only "converts to perks". |
4. Domain model
Ledger entry_type values: OPENING, ACCRUAL, PRORATA_ACCRUAL, AVAIL (negative), AVAIL_REVERSAL, ADJUSTMENT, CARRY_FORWARD_OUT, CARRY_FORWARD_IN, LAPSE, ENCASHMENT, COMP_OFF_CREDIT, COMP_OFF_EXPIRY, LATE_DEDUCTION, ADVANCE, FNF_SETTLEMENT. Entries are never updated; a correction is a new entry that references reverses_entry_id.
lve_request_day.day_kind: WORKING, WEEK_OFF, HOLIDAY. charge is 0, 0.5 or 1.
Optional holidays: an optional holiday is a row in the ORG holiday calendar with type = OPTIONAL. Opting in is an lve_request against the system type OPTIONAL_HOLIDAY, limited by optional_holidays_per_year.
5. State machines
5.1 Leave request
Cancelling leave whose dates fall in a locked attendance period is allowed only with HR approval and produces an ATT adjustment for the next pay run.
5.2 Comp-off credit
When the attendance policy sets offday_compensation = COMP_OFF and comp_off.auto_credit = true, the request step is skipped.
5.3 Leave cycle
6. Key business logic
6.1 Policy resolution
function policyFor(employee, leaveType, date):
candidates = lve_policy_assignment for leaveType where applicability matches the
employee's job assignment on date (location, grade, employment type,
department, designation) and valid on date
pick the highest priority; ties fail validation at publish time
return the lve_policy_version valid on date
When a new version is published, transition_mode decides how existing employees move: FROM_DATE (new rules from valid_from, past entries untouched), RECOMPUTE_CYCLE (re-run accruals for the open cycle and post differences as ADJUSTMENT), or NEW_JOINERS_ONLY (reference-product parity, rarely used).
6.2 Accrual
nightly job, per tenant, per employee, per active leave type:
v = policyFor(employee, type, today)
if not v.accrual.enabled: continue
if employee.service_months < v.eligibility.min_service_months_for_accrual: continue
for each credit point P due since the last accrual entry:
# P is the cycle start (UPFRONT), or the start or end of each month, quarter or
# half-year (PERIODIC), per v.accrual.frequency and v.accrual.credit_on
period_quota = v.annual_quota / periods_per_year(v.accrual.frequency)
if P is the employee's first period: # pro-rata on joining
if v.accrual.prorata_rule == DAYS_IN_PERIOD:
amount = period_quota * days_employed_in_period / days_in_period
elif v.accrual.prorata_rule == JOIN_BEFORE_DAY:
amount = period_quota if joining_day <= v.accrual.join_before_day else 0
else:
amount = period_quota
if v.accrual.reduce_for_lop: # e.g. EL earned on days worked
amount = amount * (1 - lop_days_in_period / working_days_in_period)
amount = roundTo(amount, v.accrual.rounding) # 0.5, 0.25 or 0.01
amount = min(amount, v.max_balance - currentBalance) # balance cap
if amount > 0: post ledger ACCRUAL or PRORATA_ACCRUAL(amount, effective_date = P)
# Idempotency: unique key (employee, type, entry_type, effective_date, source_type = ACCRUAL_RUN)
UPFRONT credit for a mid-cycle joiner uses amount = annual_quota * remaining_months / 12, rounded per policy.
6.3 Day counting and the sandwich rule
function chargeDays(request, v, calendar):
days = []
for d in request.from_date .. request.to_date:
kind = HOLIDAY if calendar.isHoliday(employee.location, d)
else WEEK_OFF if ATT.isWeekOff(employee, d) else WORKING
half = halves for d (first/second/full) from from_half and to_half
charge = 0.5 if half != FULL else 1
if kind != WORKING: charge = 0 # provisional
days.append(d, kind, half, charge)
if v.sandwich.weekoffs or v.sandwich.holidays:
# Find runs of non-working days bounded by leave on both sides.
# Bounds may come from this request or, when v.sandwich.scope == ADJACENT,
# from an approved or pending leave on the immediately adjacent working day.
for each run R of consecutive non-working days:
left = leave on the working day just before R (this request or adjacent)
right = leave on the working day just after R (this request or adjacent)
if left and right
and (left is a full day or v.sandwich.half_day_counts)
and (right is a full day or v.sandwich.half_day_counts)
and (left.type in v.sandwich.bounding_types and right.type in v.sandwich.bounding_types):
for d in R:
if (d.kind == WEEK_OFF and v.sandwich.weekoffs) or (d.kind == HOLIDAY and v.sandwich.holidays):
d.charge = 1 ; d.sandwiched = true
return days, sum(charge)
Examples (Saturday and Sunday are week-offs, sandwich on for week-offs):
| Request | Charged days |
|---|---|
| Fri to Mon, full days | 4 (Sat, Sun sandwiched) |
Fri only, then a separate Mon request with scope = ADJACENT | Fri 1, Mon 3 (Sat and Sun charged on the second request) |
Fri second half to Mon, half_day_counts = false | 1.5 (no sandwich) |
| Thu to Fri, with Sat a holiday and Sun a week-off, holidays not sandwiched | 2 |
When a holiday is added or removed after approval, LVE recomputes lve_request_day for future approved requests at that location and posts the difference as ADJUSTMENT with reason HOLIDAY_CHANGE.
6.4 Request validation
function validate(request):
v = policyFor(employee, type, request.from_date)
check employee meets v.eligibility (service months, employment type, gender rule
when the type requires it, max_times_in_service, min_gap_between_terms_months)
check request.from_date >= today + v.notice_days unless emergency type
check request.from_date >= today - v.backdate_days past-dated window
days, charged = chargeDays(request, v, calendar)
check v.min_per_instance <= charged <= v.max_per_instance
check charged <= v.max_per_period for the month or quarter, if set
check document attached if charged > v.document_required_above_days
check no overlap with another non-cancelled request (any type)
check not combined with a type in v.cannot_combine_with on adjacent days
available = balance(employee, type) - reserved
if charged > available:
if v.allow_negative_balance up to v.negative_limit: allow and flag ADVANCE
elif v.overflow_to_lop: split the request, remainder as LOP days
else: reject "insufficient balance"
warn (not block) if team members on leave on the same dates >= v.team_conflict_threshold
6.5 Approval and ledger posting
on submit: reserved += charged (in one transaction, row lock on lve_balance)
on approve: post AVAIL(-charged, effective_date = from_date) ; reserved -= charged
emit lve.request.approved with per-day halves for ATT
on reject or withdraw: reserved -= charged
on cancel after approval: post AVAIL_REVERSAL(+charged) ; emit lve.request.cancelled
Concurrency: approvals and submissions take SELECT ... FOR UPDATE on the employee's lve_balance row for that type and cycle. The projection updates in the same transaction as the ledger insert.
6.6 Year-end close
for each employee, for each type at cycle_end:
bal = balance at cycle_end
carry = min(bal, v.carry_forward.max_days) # 0 when not allowed
encash = 0
if v.encashment.at_year_end:
encash = min(bal - carry, v.encashment.max_days)
lapse = bal - carry - encash
post CARRY_FORWARD_OUT(-carry) in old cycle ; CARRY_FORWARD_IN(+carry) in new cycle
if encash > 0: create lve_encashment(trigger = YEAR_END) and post ENCASHMENT(-encash)
if lapse > 0: post LAPSE(-lapse)
# Carry-forward older than v.carry_forward.expiry_months lapses on its own date.
6.7 Comp-off
- Eligibility comes from
att.day.computedwith statusWPorHPandworked_minutes >= comp_off.min_minutes. Half credit applies when worked minutes are between the half and full thresholds. expires_on = worked_date + comp_off.expiry_days. Availing uses credits in FIFO order of expiry.- A request for comp-off credit must be raised within
comp_off.request_window_days, matching the reference "backdated window" [Observed SP-LVE-03].
6.8 LOP feed to payroll
At attendance period lock, PAY asks LVE for each employee's unpaid days in the period: approved LOP leave days, plus unpaid portions of other types (e.g. negative balance beyond limit converted to LOP). These are combined with ATT absences in the payable-days calculation (see Payroll).
7. Configuration
Per leave policy version (settings):
| Setting | Type | Default | Description |
|---|---|---|---|
annual_quota | number | 12 | Days per cycle |
accrual.enabled | boolean | true | Whether the type accrues |
accrual.frequency | enum | MONTHLY | UPFRONT, MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY |
accrual.credit_on | enum | PERIOD_START | PERIOD_START or PERIOD_END |
accrual.prorata_rule | enum | DAYS_IN_PERIOD | DAYS_IN_PERIOD, JOIN_BEFORE_DAY or NONE |
accrual.join_before_day | integer | 15 | Used with JOIN_BEFORE_DAY |
accrual.reduce_for_lop | boolean | false | Accrual reduced by LOP days |
accrual.rounding | number | 0.5 | Rounding step for accrued days |
max_balance | number | null | Cap on balance; null means no cap |
eligibility.min_service_months | integer | 0 | Service needed to apply |
eligibility.employment_types | array of string | all | E.g. PERMANENT, PROBATION, CONTRACT |
eligibility.gender_rule | enum | ANY | ANY, or a statutory rule where the law requires one; never marital status |
eligibility.max_times_in_service | integer | null | Lifetime availments (e.g. parental) |
eligibility.min_gap_between_terms_months | integer | null | Gap between availments |
notice_days | integer | 0 | Advance notice required |
backdate_days | integer | 7 | How far back a request may start |
min_per_instance | number | 0.5 | Smallest request |
max_per_instance | number | null | Largest request |
max_per_period | number | null | Per month cap |
half_day_allowed | boolean | true | Allows half days |
document_required_above_days | number | null | E.g. 2 for sick leave |
sandwich.weekoffs | boolean | false | Count enclosed week-offs |
sandwich.holidays | boolean | false | Count enclosed holidays |
sandwich.scope | enum | WITHIN_REQUEST | WITHIN_REQUEST or ADJACENT |
sandwich.half_day_counts | boolean | false | Half-day bounds trigger sandwich |
sandwich.bounding_types | array of string | this type | Types whose leave on both sides triggers it |
allow_negative_balance | boolean | false | Advance leave |
negative_limit | number | 0 | Max negative days |
overflow_to_lop | boolean | true | Excess days become LOP |
cannot_combine_with | array of string | [] | Types that may not be adjacent |
carry_forward.max_days | number | 0 | Days carried to the next cycle |
carry_forward.expiry_months | integer | null | Carried days lapse after this |
encashment.at_year_end | boolean | false | Encash surplus at cycle end |
encashment.max_days | number | 0 | Encashable days per cycle |
encashment.at_exit | boolean | true | Encash balance in F&F |
encashment.base | enum | BASIC_DA | BASIC_DA, GROSS or CUSTOM_FORMULA |
encashment.divisor | integer | 26 | Per-day rate divisor (verify against company policy and law) |
comp_off.min_minutes | integer | 240 | Minutes on an off day to earn half a day |
comp_off.full_day_minutes | integer | 480 | Minutes to earn a full day |
comp_off.expiry_days | integer | 60 | Days until a credit expires |
comp_off.request_window_days | integer | 30 | Days to request a credit |
comp_off.auto_credit | boolean | false | Credit without a request |
team_conflict_threshold | integer | null | Warn when this many teammates overlap |
self_cancel_future | boolean | true | Employee may cancel approved future leave without approval |
Tenant-level settings: cycle_start_month (APRIL or JANUARY), optional_holidays_per_year (integer, default 2), notify_on (array of SUBMIT, APPROVE, REJECT, CANCEL), late_deduction_order (from ATT policy).
8. Screens by persona
| Persona | Web | Mobile |
|---|---|---|
| Employee | Balances with ledger drill-down ("why is my balance 7.5?"), apply leave with live day count and sandwich explanation, my requests, cancel, comp-off credits with expiry, optional holidays, holiday list (next three and all), policy view | Apply leave with balance chips, contact during leave, history, holiday list |
| Line manager | Team leave calendar, approval inbox with balance and conflicts shown, cancellation approvals, on-behalf leave and comp-off, team balances | Approvals, team calendar, on-behalf leave |
| HR manager | Adjustments with reason, opening-balance import (dry-run), cycle close console, encashment approvals, reports | — |
| HR admin | Leave-type builder from templates, policy editor with versions and applicability preview, simulation ("what would employee X accrue under this version?") | — |
| Payroll manager | Leave-to-payroll bridge: LOP days per employee for the period, encashment inputs | — |
9. Events
| Event | Emitted when | Main consumers |
|---|---|---|
lve.request.submitted | A request is submitted | NTF (start workflow) |
lve.request.approved | Final approval or on-behalf auto-approval | ATT (recompute days), NTF, calendar sync (R3) |
lve.request.rejected | Rejected | NTF |
lve.request.withdrawn | Withdrawn by the employee | NTF |
lve.request.cancellation_requested | Employee asks to cancel approved leave | NTF (start cancellation workflow) |
lve.request.cancelled | Cancellation completes | ATT (recompute), PAY (adjustment if period locked) |
lve.balance.accrued | An accrual run posts entries (batched per tenant) | RPT |
lve.ledger.adjusted | A manual or system adjustment is posted | NTF (inform employee), RPT |
lve.comp_off.credited | A comp-off credit is posted | NTF |
lve.comp_off.expiring | Seven days before expiry | NTF (reminder) |
lve.comp_off.expired | Credit expired | NTF |
lve.cycle.closed | Year-end close completes | PAY (encashment inputs), RPT |
lve.encashment.approved | Encashment approved | PAY (one-time input) |
Consumed: att.day.computed (comp-off eligibility), att.late.threshold_breached (post LATE_DEDUCTION), org.holiday.changed (recompute future requests), emp.employee.joined (pro-rata accrual), emp.job.changed (policy re-resolution), ext.separation.approved (block leave beyond LWD), ext.fnf.initiated (encashment at exit).
10. API surface
Detailed in API endpoints.
| Resource | Main operations |
|---|---|
/v1/lve/types | CRUD, POST /from-template |
/v1/lve/policies and /v1/lve/policies/:id/versions | CRUD, publish, GET /:id/applicability-preview, POST /:id/simulate |
/v1/lve/balances | GET by employee (SELF, TEAM, ORG) with as-of date |
/v1/lve/ledger-entries | GET by employee and type; POST adjustment (HR) |
/v1/lve/requests | POST, GET, PATCH, POST /:id/withdraw, POST /:id/cancel; POST /quote returns the day count and sandwich breakdown without saving |
/v1/lve/comp-off-credits | GET, POST request credit |
/v1/lve/optional-holidays | GET available, POST opt-in (as a request) |
/v1/lve/encashments | POST, GET |
/v1/lve/cycles | GET, POST /:id/close |
/v1/lve/opening-balances/import | POST with dry_run=true |
/v1/lve/payroll-bridge | GET LOP and encashment per employee for a pay period (consumed by PAY) |
11. Permissions
| Permission | Typical scopes | Default roles |
|---|---|---|
lve.balance.read | SELF, TEAM, ORG | employee, line_manager, hr_manager, auditor |
lve.request.create | SELF | employee |
lve.request.create_on_behalf | TEAM, LOCATION | line_manager, hr_manager |
lve.request.approve | TEAM, LOCATION, ORG | line_manager, hr_manager |
lve.request.cancel_approved | TEAM, ORG | line_manager, hr_manager |
lve.ledger.adjust | ORG, LOCATION | hr_manager |
lve.type.manage | ORG | hr_admin |
lve.policy.manage | ORG | hr_admin |
lve.cycle.close | ORG | hr_admin |
lve.encashment.approve | ORG | hr_manager, payroll_manager |
lve.import.run | ORG | hr_admin |
lve.report.export | TEAM, ORG | line_manager, hr_manager, org_owner |
12. Reports
| Report | Contents | Reference |
|---|---|---|
| Leave balance | Opening, accrued, availed, adjusted, lapsed, closing per type | [Observed] SP-LVE-05, extended with ledger columns [Proposed] |
| Leave ledger statement | Every entry with reason and source for one employee | [Proposed] |
| Leave register | Employee-wise, date-wise, type-wise and designation-wise availment | [Observed] SP-LVE-06 |
| Leave summary | Worked days vs leave days by employee | [Observed] SP-LVE-06 |
| Leave payroll report | Approved leave, LOP and absent counts per pay period | [Observed] SP-LVE-06 |
| Comp-off register | Earned, used, expired, expiring soon | [Observed] SP-LVE-06 |
| Encashment report | Days and amounts by trigger | [Proposed] |
| Absenteeism and leave trend | Leave rate by department and month, Bradford-style frequency | [Proposed] R4 |
| Statutory leave register | Format required by the state Shops & Establishments rules | [Proposed] R2, verify per state |
13. Release and priority
| Capability | Release | MoSCoW |
|---|---|---|
| Leave-type builder with templates; policies with versions and applicability | R1 | Must |
| Ledger, accrual (upfront and periodic, pro-rata), carry-forward, lapse | R1 | Must |
| Requests with half days, sandwich, notice, documents, caps; cancellation | R1 | Must |
| On-behalf leave, multi-level approval through NTF | R1 | Must |
| Comp-off from ATT with expiry; optional holidays | R1 | Must |
| Late deduction from ATT; LOP bridge to PAY | R1 | Must |
| Opening-balance import | R1 | Must |
| Year-end encashment; encashment at exit | R1 (year end), R2 (exit, with EXT) | Should |
| Policy simulation tool | R1 | Should |
| Team conflict warnings | R1 | Should |
| Calendar sync (Google/Microsoft) | R3 | Could |
| Statutory leave registers per state | R2 | Should |
| Leave analytics | R4 | Could |
14. Open questions
- Does sandwich across adjacent requests apply when the first request is already approved and the second is pending? The default is yes, charged on the second request.
- Should holiday changes after approval re-charge already-availed (past) leave? The default is future requests only.
- Which state-specific statutory leave minimums should we ship as templates in R1 (e.g. Karnataka, Maharashtra, Telangana, Tamil Nadu)? Verify each with counsel.
- Do customers want hourly leave units (as opposed to ATT permissions)?
unitexists on the type; R1 supportsDAYonly. - How should encashment be taxed and reported? It belongs in PAY as a component with its own tax treatment; confirm exemption rules at exit.