OPERATIONS
Capsense vesting standards and acceleration rules
Standard vesting schedules, cliff expectations, and exit acceleration policies used across Capsense schemes.

Last reviewed: 2025-02-18

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 matching policy: 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 calculateLapsedOptions helper 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 deadlineType where '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 exerciseDeadline to the exit-day end-of-day moment and report deadlineType = 'EXIT_EVENT_EOD'. Exit-only schemes that keep the window closed return windowExpired = true and 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 EXPIRED solely 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.ts exit-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 = 0 in getExerciseData responses).
    • 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 true Exercisable, window open until exit EOD allows exit-day exercise for an EXPIRED grant when overrideExpiryOnExit is enabled
      Active (no termination), legal expiry passed Standard (not exit-only) N/A N/A false Exercisable, window open until exit EOD allows exit-day exercise for active expired grants when override is disabled
      Expiry-only (status EXPIRED, no termination) Standard N/A N/A true Exercisable, reinstated lapses, exit EOD deadline allows exit-day override for expiry-only active grants without acceleration
      Terminated GOOD_LEAVER Standard true N/A true Exercisable, reinstated lapses allows exit-day exercise for reinstated terminated grants persisted as EXPIRED
      Terminated GOOD_LEAVER Standard true N/A false Blocked (Grant must be ACTIVE or TERMINATED) blocks exit-day override for terminated grants when the override toggle is disabled
      Terminated GOOD_LEAVER Standard false N/A any Blocked (Grant must be ACTIVE or TERMINATED) blocks exit-day exercise for terminated grants when restore toggle is disabled
      Terminated GOOD_LEAVER Exit-only true true true Exercisable, reopened by exit allows exit-day exercise for exit-only terminated grants when restore and reopen toggles are enabled
      Terminated GOOD_LEAVER Exit-only true false any Blocked, window closed keeps exit-only terminated grants locked when reopen-on-exit remains disabled
      Terminated FOR_CAUSE/BAD_LEAVER Any any any any Blocked, forfeits remain ✅ termination guardrail cases
    • The guardrail suite in lib/exercise.test.ts now 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 calculateExercisableOptions rather 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 inline max(0, gross - exercised - lapsed). This ensures consistent clamping and future rule changes propagate automatically.

Snapshot semantics:

  • The vesting snapshot returned by getGrantVestingSnapshot is 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 the restoreLapsedOptionsOnExit flag. 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-gating netCumulativeVested while reporting netOutstanding = 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 restoreLapsedOptionsOnExit and reopenExerciseWindowOnExit are enabled.
  • 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 keeping windowExpired = true for 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 to EXPIRED once 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 flips TERMINATED grants to EXPIRED once 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 TERMINATED during 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 to EXPIRED and 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 netCumulativeVested values 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.
  • accelerateOnExit fully vests active holders and GOOD_LEAVER terminations once the exit date arrives; BAD_LEAVER and FOR_CAUSE grants never regain lapses during exit processing. The accelerateTerminatedGoodLeavers flag extends acceleration to schemes that opt out of full acceleration but still want terminated good leavers reinstated, but it only produces an exercisable balance when restoreLapsedOptionsOnExit is enabled (and exit-only plans also keep reopenExerciseWindowOnExit on) 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/forfeitedOptions figures (after subtracting any options exercised during the reopening) as soon as acceleration no longer applies.
  • Termination routes persist netCumulativeVested using the recorded lapses on the grant; even when evaluateTermination reports a gross vested amount, the stored net always equals max(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's restoreLapsedOptionsOnExit, reopenExerciseWindowOnExit, and overrideExpiryOnExit flags 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): cumulativeVestednetCumulativeVested to make it explicit that this is cumulative vested net of lapses. Use grossVested for 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.grossVested for 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.netSharesIssued if available, falling back to exercise.numberOfOptions for 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) and now (evaluation clock).
    • Termination flows pass the legal termination timestamp as the snapshot asOf value while using the processing clock for now. This keeps reinstatement aligned to the historical termination date but still lets exit acceleration detect that the exit event has already occurred (see policy:exit-acceleration-deadline). Other flows may still override now when a future evaluation is required, but default helpers remain anchored to the policy reference date for compliance.
  • 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 }) from lib/timezone.ts. The helper reads CompanyConfig.timezone, falls back to the platform timezone, then to UTC.
  • Do not access PlatformSetting.timezone directly 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 in lib/vesting.ts accepts an optional timeZone parameter. 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 calling buildSchedule() to ensure notifications are sent on the correct local day.
  • When CONFIG_LOG_ERRORS=true is 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) = 2 options 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
  • Validation: The function enforces cliffMonths < vestingPeriodMonths (cliff must be strictly less than vesting period) and rejects negative values. Full-period cliff vesting (where cliffMonths = vestingPeriodMonths) is not supported to prevent 100% cliff vesting structures
  • Implementation: See lib/vesting.ts lines 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. Use deadlineType === 'EXIT_EVENT_EOD' for exit reopenings and deadlineType === '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 in lib/exercise-window.ts implements 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 uses calculateExerciseWindowDeadline(exitAt, 0, tz) with the resolved timezone to evaluate this bound and set windowExpired accordingly.

Withholding policy:

  • If the settlement method is SHARE_WITHHOLDING but PAYE is zero for the exercise, the server automatically downgrades the method to CASH, 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 is SHARE_WITHHOLDING and at least one share is withheld.

  • Share-withholding submissions must still issue at least one share. Requests that drop the netSharesIssued value 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 in lib/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.