Vesting Calculation Standards
Policy markers (
<!-- policy:... -->) are enforced by automated tests. When you edit or add a policy, update the accompanying marker and add/adjust a matchingpolicy:reference in the tests so behaviour stays in sync with this doc.
This document summarizes the business-standard helpers for ESOP calculations used across the codebase and when to apply each.
Core helpers (import from lib/vesting):
-
calculateUnvestedForfeitedOnTermination(total, vested):
- Purpose: Options that lapse immediately upon termination.
- Rule: total − vested (clamped to [0, total]).
-
calculateExercisableOptions(vested, exercised):
- Purpose: Options the holder can exercise at a point in time.
- Rule: vested − exercised (clamped to [0, vested]). Does not model early exercise repurchase.
-
calculateExpiredAfterExerciseWindow(vested, exercised):
- Purpose: Vested-but-unexercised options that expire after the post-termination exercise window.
- Rule: Equal to remaining exercisable options when the window ends.
Other functions:
- calculateVestingSchedule, calculateVestedOptions, getNextVestingDate, monthsUntilFullyVested, calculateVestedPercentage: scheduling/progress utilities.
Note: the old
calculateLapsedOptionshelper was removed; use the standards above to model forfeiture/expiry explicitly.
Service wrappers (policy-aware):
- exercisableAt(grant, scheme, now, { timeZone }):
- Purpose: Single-source exercisable/net figures that already account for exit-only gating, post-termination window expiry, grant expiry, acceleration, and restore-lapses policy.
- Returns:
{ grossVested, netVested, exercisable, windowExpired, exitOnly, exitAllowed }.
- getExerciseContext(grant, scheme, now, { timeZone }):
- Purpose: Rich context for UI/API flows; includes exercisable count plus reason codes for deadlines.
- Returns: the exercisable payload plus
deadlineTypewhere'EXIT_EVENT_EOD'indicates an exit-day override and'GRANT_EXPIRY_EOD'indicates the legal instrument expiry. - Consumers: batch expiry processors, notifications, and any flows that would otherwise redo math around lapsed/exercised.
- Implementation note: when supplying scheme data manually (e.g., in exit recompute jobs), always include
overrideExpiryOnExit; otherwise the helper will assume exit-day override behaviour. - Exit unlocks always set
exerciseDeadlineto the exit-day end-of-day moment and reportdeadlineType = 'EXIT_EVENT_EOD'. Exit-only schemes that keep the window closed returnwindowExpired = trueand zero balances for terminated holders even after an exit so compliance is preserved.
- Terminated holders only inherit the exit-day expiry override when “Restore lapsed options on exit” is enabled (and, for exit-only plans, “Reopen expired post-termination window on exit”) and the scheme keeps the “Exit overrides expiry (exit day)” toggle enabled. Active participants always receive the override regardless of toggles so legal expiries never block the exit window.
- This includes exit-only schemes where administrators keep the reopen toggle disabled; active members still inherit the exit-day override while terminated holders remain locked until both toggles are enabled.
- Grants marked as
EXPIREDsolely because the legal instrument lapsed (no termination timestamp) are treated as active for exit overrides and acceleration checks so members remain eligible during the exit window. - Exercise APIs honour this path even when the persisted status remains
EXPIRED, allowing expiry-only grants to submit exit-day exercises without enabling acceleration. - Guardrail: the
lib/exercise.test.tsexit-reopen matrix asserts the allowed/blocked combinations so future changes must update tests alongside this policy. - Recorded lapses that were captured when those grants expired are reinstated automatically during the exit override even if “Restore lapsed options on exit” remains off, so members regain the outstanding balance promised in policy without additional toggles (reflected as
effectiveLapsedOptions = 0ingetExerciseDataresponses). - Exit-day override matrix (all scenarios enforced in
lib/exercise.test.ts):Grant context Scheme type restoreLapsedOptionsOnExit reopenExerciseWindowOnExit overrideExpiryOnExit Expected exit-day result Current coverage Active (no termination), legal expiry passed Standard (not exit-only) N/A N/A trueExercisable, window open until exit EOD ✅ allows exit-day exercise for an EXPIRED grant when overrideExpiryOnExit is enabledActive (no termination), legal expiry passed Standard (not exit-only) N/A N/A falseExercisable, window open until exit EOD ✅ allows exit-day exercise for active expired grants when override is disabledExpiry-only (status EXPIRED, no termination)Standard N/A N/A trueExercisable, reinstated lapses, exit EOD deadline ✅ allows exit-day override for expiry-only active grants without accelerationTerminated GOOD_LEAVER Standard trueN/A trueExercisable, reinstated lapses ✅ allows exit-day exercise for reinstated terminated grants persisted as EXPIREDTerminated GOOD_LEAVER Standard trueN/A falseBlocked ( Grant must be ACTIVE or TERMINATED)✅ blocks exit-day override for terminated grants when the override toggle is disabledTerminated GOOD_LEAVER Standard falseN/A any Blocked ( Grant must be ACTIVE or TERMINATED)✅ blocks exit-day exercise for terminated grants when restore toggle is disabledTerminated GOOD_LEAVER Exit-only truetruetrueExercisable, reopened by exit ✅ allows exit-day exercise for exit-only terminated grants when restore and reopen toggles are enabledTerminated GOOD_LEAVER Exit-only truefalseany Blocked, window closed ✅ keeps exit-only terminated grants locked when reopen-on-exit remains disabledTerminated FOR_CAUSE/BAD_LEAVER Any any any any Blocked, forfeits remain ✅ termination guardrail cases - The guardrail suite in
lib/exercise.test.tsnow exercises every row above; keep comments referencing the specific test names when adding new scenarios so future contributors adjust both docs and tests together.
Implementation notes:
- Server routes should compute exercisable counts with
calculateExercisableOptionsrather than manual math. - Termination flows should compute forfeiture with
calculateUnvestedForfeitedOnTermination. - Email/notification logic should consume values computed by these helpers; avoid duplicating arithmetic in templates.
- Batch jobs that move vested‑but‑unexercised to lapsed (post‑termination or grant expiry) must also use
calculateExercisableOptions(vestedMinusLapsed, exercised)instead of inlinemax(0, gross - exercised - lapsed). This ensures consistent clamping and future rule changes propagate automatically.
- Batch jobs that move vested‑but‑unexercised to lapsed (post‑termination or grant expiry) must also use
Snapshot semantics:
- The vesting snapshot returned by
getGrantVestingSnapshotis policy‑effective by default. Net outstanding and net cumulative vested amounts honour exercise‑window gating (post-termination lapse days, grant expiry, exit-only reopen toggles) so UI/API consumers no longer surface balances that can no longer be exercised. When an exit event reinstates lapses via acceleration or a policy-allowed unlock, effective lapses become 0 until the exit-day deadline passes. Exit-only schemes require both the restore and reopen toggles to be enabled before lapses are cleared; non exit-only plans rely solely on therestoreLapsedOptionsOnExitflag. When restoration is disabled, effective lapses and recorded forfeits remain in the snapshot — but the exercise gating still zeros net outstanding once the window closes. Locked exit-only grants (either pre-exit or when the exit window stays closed) preserve their historical lapse/forfeit totals and their pre-gatingnetCumulativeVestedwhile reportingnetOutstanding = 0, so downstream tooling records the balance as “vested but blocked” rather than forfeited.- Manual termination flows (single grant, bulk, member deactivation) compare the current processing clock to the exit-day end-of-day in the resolved company timezone. Reinstatement only applies while processing occurs before that deadline; submissions captured after the exit-day EOD leave recorded lapses and forfeits intact even when
restoreLapsedOptionsOnExitandreopenExerciseWindowOnExitare enabled.
- Manual termination flows (single grant, bulk, member deactivation) compare the current processing clock to the exit-day end-of-day in the resolved company timezone. Reinstatement only applies while processing occurs before that deadline; submissions captured after the exit-day EOD leave recorded lapses and forfeits intact even when
- Terminations only lose reinstatement eligibility once the legal termination timestamp is after the exit-day end-of-day deadline (23:59:59.999 in the company timezone). Same-day terminations that land before that deadline continue to reinstate lapses and forfeits when policy toggles allow it. The helpers rely on the legal termination timestamp alongside the processing clock to keep post-exit clean-ups conservative while still honouring reinstatement for exit-day submissions.
- Context helpers (
getExerciseContext,exercisableAt) mirror this behaviour by keepingwindowExpired = truefor terminated exit-only grants until both toggles are enabled, preventing APIs from surfacing reopened windows that the docs forbid. - Batch expiry processors (
processPostTerminationExpiries,processGrantExpiries) pass{ enforceExerciseGating: false }so they can evaluate the full net outstanding balance at the deadline and move it to lapsed. Without that opt-out the snapshot would already be zero once the window closes. Locked exit-only grants with the reopen toggle disabled are exempt: the jobs merely flip their persisted status toEXPIREDonce the exit-day deadline passes and leave net/lapse totals untouched so the pool continues to reserve the blocked balance. resolveEffectiveGrantStatus(grant, getExerciseContext(...))exposes a derived status that flipsTERMINATEDgrants toEXPIREDonce their legal exercise window has lapsed. API routes and dashboards surface this alongside the stored status so members/auditors see the lifecycle state promised in policy docs even before nightly jobs persist the update.- When an exit acceleration temporarily reopens a terminated holder's window, the post-termination job waits until the exit-day end-of-day deadline before lapsing the remaining balance, then snapshots at that deadline to remove the accelerated (but unexercised) portion.
- Exit-day reopenings: Exit-only schemes persist reinstated grants as
TERMINATEDduring the exit window only when the reopening toggle is on for terminated holders. Active grants always treat the exit day as an effective expiry override, even when the toggle is off. For non exit-only plans, enable “Restore lapsed options on exit” so follow-on jobs (post-termination lapse, access review, notifications) revisit the grant. Once the exit-day deadline passes, the lapse job flips reinstated grants back toEXPIREDand removes any remaining exercisable balance. - Termination flows persist the same reinstated balances. When policy allows reinstatement (either via acceleration or by enabling both exit-only toggles), derived
netCumulativeVestedvalues ignore previously recorded lapses. Otherwise the persisted lapses/forfeits remain untouched so reports continue to match contractual outcomes.- The legal termination timestamp is always the reference point for reinstatement, even if administrators record the termination after the exit has already been captured. Helpers compare that timestamp to the live exit window using the processing clock so historical GOOD_LEAVER submissions regain lapses as soon as policy allows.
- When those grants are terminated during the reopened window, the system also clears the stored exit reinstatement stash so clearing or postponing the exit cannot replay the old lapses.
- After the exit-day deadline passes, pool usage retains only previously exercised amounts; reinstated balances are no longer considered outstanding regardless of acceleration.
accelerateOnExitfully vests active holders andGOOD_LEAVERterminations once the exit date arrives;BAD_LEAVERandFOR_CAUSEgrants never regain lapses during exit processing. TheaccelerateTerminatedGoodLeaversflag extends acceleration to schemes that opt out of full acceleration but still want terminated good leavers reinstated, but it only produces an exercisable balance whenrestoreLapsedOptionsOnExitis enabled (and exit-only plans also keepreopenExerciseWindowOnExiton) so the reinstated lapses/forfeits actually clear.- Exit reinstatements now retain the pre-exit lapse/forfeit balances alongside the temporary reinstatement, plus the exercised and outstanding baselines, so that pushing an exit out or clearing it restores the remaining
lapsedOptions/forfeitedOptionsfigures (after subtracting any options exercised during the reopening) as soon as acceleration no longer applies. - Termination routes persist
netCumulativeVestedusing the recorded lapses on the grant; even whenevaluateTerminationreports a gross vested amount, the stored net always equalsmax(gross - recordedLapses, exercised)to keep dashboards and exports aligned with member-facing balances. - Pool usage, company-access checks, and termination routes reuse
getExerciseContext/policy helpers and now pass each scheme'srestoreLapsedOptionsOnExit,reopenExerciseWindowOnExit, andoverrideExpiryOnExitflags so strict-expiry schemes remain closed after exit while restore-lapse policies behave consistently across UI, reporting, access control, and persisted grant snapshots. - Field rename (breaking, pre-live):
cumulativeVested→netCumulativeVestedto make it explicit that this is cumulative vested net of lapses. UsegrossVestedfor lifetime gross.
UI conventions:
- Vesting schedules may include zero-amount “anchor” events (e.g., a final event on the vesting end date, or a cliff that rounds to 0 for very small grants). These anchors improve timeline readability and do not affect totals.
Display conventions (gross vs net):
- "Vested Options" displayed in dashboards is cumulative vested net of lapses. This supports day‑to‑day operations and matches exercisable math.
- "% vested" shown in dashboards is derived from net cumulative vested:
netCumulativeVested / totalOptions * 100. - "Gross Vested" (lifetime vested, before lapses) is surfaced on grant detail views for audit/transparency. Use
snapshot.grossVestedfor this value.
Compliance document share counts:
- Exercised shares in compliance documents (CoR46.2, Annual Reports) represent NET shares issued, not gross options exercised.
- When share withholding is used, the net share count accounts for shares withheld for tax. Use
exercise.netSharesIssuedif available, falling back toexercise.numberOfOptionsfor legacy exercises. - This ensures compliance documents accurately reflect actual share issuances on the shareholder register, matching CIPC filing requirements.
- Example: If 1000 options are exercised with 180 shares withheld for PAYE, the compliance document reports 820 exercised shares (net issued), not 1000 (gross exercised).
Data invariants (enforced at app layer):
- exercised ≤ vested ≤ total
- Negative inputs are clamped to safe bounds by helpers; over-exercising triggers an error and should be investigated.
Evaluation clocks:
- Many helpers accept two dates:
asOf(snapshot reference) andnow(evaluation clock).- Termination flows pass the legal termination timestamp as the snapshot
asOfvalue while using the processing clock fornow. This keeps reinstatement aligned to the historical termination date but still lets exit acceleration detect that the exit event has already occurred (seepolicy:exit-acceleration-deadline). Other flows may still overridenowwhen a future evaluation is required, but default helpers remain anchored to the policy reference date for compliance.
- Termination flows pass the legal termination timestamp as the snapshot
- Always pass both values explicitly when you need behaviour that differs from the default evaluation clock.
Timezone resolution:
- Always resolve company-local time using
resolveTimezone({ companyId })fromlib/timezone.ts. The helper readsCompanyConfig.timezone, falls back to the platform timezone, then toUTC. - Do not access
PlatformSetting.timezonedirectly from feature code; the helper also guards against invalid zones and keeps behaviour consistent across services, jobs, and exports.
Vesting schedule calculations:
- The
calculateVestingSchedule()function inlib/vesting.tsaccepts an optionaltimeZoneparameter. When provided, all vesting dates are calculated at midnight (00:00:00) in that timezone. When omitted, the function falls back to UTC for backwards compatibility. - Always pass the resolved company timezone via
resolveTimezone({ companyId })to ensure vesting events align with local business days. This ensures that vesting dates like "January 1st" mean midnight on January 1st in the company's timezone, not UTC. - Example: A cliff vesting on "2024-01-01" in Johannesburg (Africa/Johannesburg, UTC+2) vests at 2024-01-01 00:00:00 SAST, not 2024-01-01 00:00:00 UTC.
- Notification jobs (
sendCliffVestingNotifications,sendAnnualVestingReminders) must resolve the company timezone before callingbuildSchedule()to ensure notifications are sent on the correct local day. - When
CONFIG_LOG_ERRORS=trueis set, the vesting calculator logs warnings when no timezone is provided, helping debug timezone-related issues during development.
Cliff vesting formula and edge cases:
- Formula: Cliff options are calculated as
Math.floor(totalOptions × cliffMonths ÷ vestingPeriodMonths) - Rounding: The formula uses
Math.floor()to round down to the nearest integer, ensuring whole options vest - Edge cases:
- Small grants: For small option counts, rounding may result in disproportionate cliff amounts (e.g., 10 options with 12-month cliff in 48-month period →
Math.floor(10 × 12 ÷ 48) = 2options at cliff) - Zero cliff: When
cliffMonths = 0, no cliff event is generated and vesting begins immediately per the vesting frequency - Remaining options: After cliff vesting, remaining options (
totalOptions - cliffOptions) are distributed across the remaining period (vestingPeriodMonths - cliffMonths) using the same floor-based proportional allocation
- Small grants: For small option counts, rounding may result in disproportionate cliff amounts (e.g., 10 options with 12-month cliff in 48-month period →
- Validation: The function enforces
cliffMonths < vestingPeriodMonths(cliff must be strictly less than vesting period) and rejects negative values. Full-period cliff vesting (wherecliffMonths = vestingPeriodMonths) is not supported to prevent 100% cliff vesting structures - Implementation: See
lib/vesting.tslines 108-119 for the authoritative calculation
Deadline labeling:
- When an exit event reopens an expired post‑termination window or overrides grant expiry for the exit day, client‑facing copy should label the deadline as "Exit‑day deadline" to avoid confusion. The helper
formatExerciseDeadline(deadline, { deadlineType })returns a descriptive label for use in UI/emails. UsedeadlineType === 'EXIT_EVENT_EOD'for exit reopenings anddeadlineType === 'GRANT_EXPIRY_EOD'when the grant has hit its own expiry.
End-of-day convention:
- All exercise deadlines are inclusive of the full calendar day in the company's local timezone. Technically, "end of day" is represented as 23:59:59.999 (last millisecond of the day) in the resolved timezone.
- The
calculateExerciseWindowDeadline()function inlib/exercise-window.tsimplements this convention by adding the specified number of days and setting the time to 23:59:59.999 in the company timezone. - This ensures that exercises submitted any time during the deadline day (00:00:00 through 23:59:59) are accepted, matching business expectations.
- Examples:
- Post-termination window of 90 days from 2024-01-01 → deadline is 2024-03-31 23:59:59.999 (company timezone)
- Grant expiry on 2024-12-31 → exercises accepted until 2024-12-31 23:59:59.999 (company timezone)
Exit-day gating:
- If a post‑termination window is reopened by an exit event (policy‑gated) or an expired grant is allowed to be
exercised on exit day, eligibility only extends through the local end‑of‑day of the exit date in the resolved
company timezone (via
resolveTimezone({ companyId }), falling back to platform timezone, then UTC). After that EOD moment, the window is considered expired again. Service logic usescalculateExerciseWindowDeadline(exitAt, 0, tz)with the resolved timezone to evaluate this bound and setwindowExpiredaccordingly.
Withholding policy:
-
If the settlement method is
SHARE_WITHHOLDINGbutPAYEis zero for the exercise, the server automatically downgrades the method toCASH, overrides any requested net-share count, and issues the entire exercised block. This avoids forcing a meaningless withheld-share and keeps reconciliation simple. Variance and hard-share checks only apply when the effective method isSHARE_WITHHOLDINGand at least one share is withheld. -
Share-withholding submissions must still issue at least one share. Requests that drop the
netSharesIssuedvalue to zero are rejected so the ledger and shareholder register stay accurate. -
PAYE and dividends tax withholdings are validated individually and in aggregate. If either amount – or their combined total – exceeds the market value of the exercised block (
options × FMV), the submission is blocked and the administrator must correct the inputs before continuing. The response includes the over-collected amount so finance can reconcile quickly. -
Authorised-share headroom is checked against the net share issuance. Share-withholding exercises still pass when the netshares issued fit within the remaining authorisation even if the gross option count would exceed it; capacity errors only surface when the actual issuance (and corresponding share class) lack headroom.
-
The withheld-share count must equal
ceil(PAYE ÷ FMV)so PAYE is fully covered by the shares you retain. When the calculated amount differs from the submitted count by more than one share the request is rejected. A ±1 rounding allowance applies automatically when the variance remains within tolerance; if it falls outside tolerance the administrator must acknowledge the PAYE reconciliation warning before proceeding. PDF generation: -
Offer/scheme PDFs consume vesting schedules from the core helper
calculateVestingSchedule(via a thin adapter inlib/pdf-service.ts). -
Dates are normalized to UTC start-of-day to prevent DST/timezone drift in generated documents, and event “shares” are the per‑event amounts from the core schedule. The percentage column reflects cumulative vested percentage for readability.