feat(time-off): take approved leave from the balance and fix the request-form download

Merged
EV
14d ago
+3547-107

Summary

  • Leave balances follow the approval flow (first slice of #10183). When a time-off request is approved, its working days are taken from each employee's time_off_balance for that policy and year. When an approved request is denied or set back to requested, exactly those days are given back. If no balance row exists, nothing changes: approval works as it does today. Repeated or concurrent status changes never deduct twice.
    • A balance that cannot cover the days is overdrawn, not refused. The approval goes through, the full deduction is recorded, and remaining goes below zero. A policy can opt in to refusing such approvals with the new requireSufficientBalance setting (off by default, a checkbox in the policy form).
  • The request-form download picks the right file (#5481). "Download paid / unpaid request form" now finds the form by its document name instead of by its place in the list. It downloads the file and shows a hint when the organization has no such form. When the browser blocks the new tab, the dialog shows a link to the form.
  • The time-off page says why a status change was refused. A refused change no longer leaves the row showing the status that was asked for. Approve and Deny handle one change per request at a time.

Why

  • #10183 item 1: TimeOffBalanceService.deduct() / reverse() existed, but nothing on the approval path called them, so taken only moved through /time-off-balance. The owner's rule is to be a strict no-op when no balance row exists, and to count working days while respecting weekends and official_holiday.
  • #5481: getRequestForm opened items[0] for the paid form and items[1] for the unpaid one. Any other organization document (or the two forms in a different order) gave the wrong file. With a single document the unpaid button failed, and with no documents nothing happened.

Decisions

Answers to the questions from the first round:

  1. Negative balances: overdraw by default. An approval the balance cannot cover goes through. taken rises by the full number of days, remaining goes below zero, and the ledger records the full deduction, so a later denial gives back exactly that. time_off_balance.remaining is a signed numeric(10,2) with no check constraint, so negative values are stored as they are and nothing is lost or clamped.
    • Strict refusal is opt-in per policy: time_off_policy.requireSufficientBalance (boolean, default false). It is a column added by this PR's migration, a checkbox in the policy dialog ("Refuse approvals the leave balance cannot cover", 14 languages), the REST body, and the GraphQL types. With it on, the approval is refused with 400 "Insufficient leave balance for the requested number of days", and the whole decision rolls back.
    • TimeOffBalanceService.deduct() through /time-off-balance keeps its current behaviour: it still refuses a deduction the balance cannot cover.
  2. Ledger table: kept (time_off_balance_deduction).
  3. Demo request forms (Cloudinary 404): out of scope. The dialog keeps the "could not be downloaded" hint, and no binaries are added.
  4. Documents hub: with FEATURE_DOCUMENTS on, uploads go through the docs plugin into its own document table. That table is read through /plugins/docs/documents under the DOCS_READ permission, and files come back as signed download links. Reading the forms from there means crossing into an optional plugin, with its permission and visibility rules, so it is not a small read-only lookup. It is listed as a follow-up below. The dialog still reads organization_document.

What changed

packages/contracts

  • ITimeOffBalanceDeduction: one row per (request, balance), holding the days taken.
  • ITimeOffPolicyEntitlement.requireSufficientBalance.
  • TimeOffRequestFormTypeEnum and TIME_OFF_REQUEST_FORM_DOCUMENT_NAMES: the names of the two form documents, shared by the dialog and the seed.

packages/core

  • time-off-balance/working-days.ts (pure helper):
    • Counts the working days from a request's first to its last day, both included, split per calendar year.
    • Leaves out weekend days (default Saturday/Sunday, configurable in the helper), one-off holidays (including multi-day ranges) and recurring holidays (including 29 February in leap years for a recurring range saved in a non-leap year that runs across it).
    • Counts arithmetically: whole weeks times the working days per week, plus the remainder, minus only the holidays that fall inside the range. The cost no longer grows with the length of the range (a 1000-year span is counted in milliseconds).
    • Reads each date as the calendar day the user picked. The forms store the picker's local midnight as a UTC instant. The helper reads it as the nearest midnight in a reference zone, so a picker up to 12 hours away from that zone still gives the day that was picked. An end stored as end of day (23:59:59) is read as that day too.
  • The reference zone is the zone the dates were picked in, as far as the server knows it. It is the timeZone of the user who filed the request; else that of the employee's user, for a one-employee request; else the organization's; else UTC. Zones that moment does not know are skipped.
  • Long ranges: a request spanning more than 3 years (MAX_DEDUCTIBLE_SPAN_DAYS) is approved without taking days from balances, and a warning is logged. The approval itself is never refused because of its dates.
  • time-off-balance/time-off-balance-taken.ts: the conditional UPDATE that moves taken/remaining, shared by the service and the approval path. It has three moves: spend (only when remaining >= days, used by /time-off-balance and by strict policies), overdraw (always), and giveBack (clamped at 0). The service's behaviour is unchanged.
  • TimeOffBalanceDeduction entity and table time_off_balance_deduction:
    • Migration 1791000003630-ConnectTimeOffBalancesToApproval for PostgreSQL, MySQL and SQLite. It creates the ledger table (every foreign key cascades on delete, with a unique index on (timeOffRequestId, balanceId)) and adds time_off_policy.requireSufficientBalance. Each step is skipped when already done, and down undoes both.
  • TimeOffBalanceDeductionService.settle(manager, leave, status), called by TimeOffStatusHandler.decide() inside its existing transaction, after the leave row is locked and before any status is written:
    • Into APPROVED:
      • Skips holiday entries, requests without a policy, and requests that already have deductions.
      • Finds each employee's balance for the policy and each year of the request. With none, it does nothing.
      • Takes the working days per year in balance-id order (a consistent lock order), and records one ledger row per balance.
      • If a balance cannot cover the days, it is overdrawn. Only under a policy with requireSufficientBalance is the approval refused with a 400, with the whole decision rolled back.
    • Out of APPROVED (denied, or back to requested): gives back exactly the recorded days, including an overdraw, and removes the ledger rows.
      • A request approved before this change has no ledger rows, so nothing is given back.
      • A balance allocated after the approval is not touched.
      • A holiday added after the approval does not change what is given back.
    • Same status again (approve twice, deny twice): nothing.
  • TimeOffPolicyService create/update save requireSufficientBalance. An update body that leaves it out keeps the policy's current setting.
  • Demo seed: the two request forms use https and the shared names.
  • TimeOffRequestModule imports TimeOffBalanceModule.

packages/ui-core

  • common/utils/safe-url.ts, the shared safe opener for document links:
    • toHttpUrl(url): the link as a URL only when it is http(s).
    • openInNewTab(url): opens an http(s) link in a new tab without access to this page. It answers blocked when the browser blocks the tab.
    • escapeHtml(value).
  • common/utils/download-file.ts: downloadFile(url, name).
    • Accepts only http(s) links.
    • Fetches with plain fetch, without cookies or a referrer. It deliberately does not use HttpClient, whose interceptors would send the user's token to whatever host the link names.
    • Saves the file with file-saver (already a ui-core dependency) under the document's name plus the link's extension.
    • When the host refuses a cross-origin fetch, it opens the link through openInNewTab, and answers blocked when the browser blocks that tab. It reports a host error (such as a 404) so the caller can say so.
  • shared/time-off/.../time-off-request-form.ts: findTimeOffRequestForm(documents, type). It matches on the document name, ignoring case and extra spaces. If several documents match, one with an http(s) link comes first, then the most recently saved one. Documents without a link are skipped.
  • The time-off request dialog uses these:
    • If no form exists: warning TIME_OFF_PAGE.REQUEST_FORM_NOT_FOUND.
    • If the link gives no file: TIME_OFF_PAGE.REQUEST_FORM_UNAVAILABLE.
    • If the browser blocks the tab: warning TIME_OFF_PAGE.REQUEST_FORM_BLOCKED, and an "Open the "{{ name }}" form" link (TIME_OFF_PAGE.OPEN_REQUEST_FORM) under the buttons.
    • If the documents cannot be read: the error.
  • The policy dialog has the requireSufficientBalance checkbox (TIME_OFF_PAGE.POLICY.REQUIRE_SUFFICIENT_BALANCE).
  • i18n: the new keys in all 14 languages (ach in English, as recent commits do).

apps/gauzy

  • Time-off page approve/deny:
    • On a refusal, the row goes back to the status the server holds. Before, a second click reported "no changes" without asking the server.
    • A 400 shows the server's reason under the existing "Unable to set time off request status." title. Other errors keep the generic notice.
    • One status change per request is in flight at a time. Approve or Deny on a row whose change is still on its way is ignored, so a refusal always restores the confirmed status.
  • The request's document link in the Description column opens through the shared safe opener: it is http(s) only, the attribute is quoted, and the tab opens with rel="noopener noreferrer".

Flags / settings and defaults

  • New per-policy setting requireSufficientBalance, default off. With it off, approvals overdraw a balance that cannot cover them. With it on, they are refused with a 400.
  • Organizations without time_off_balance rows (today, every organization that never called /time-off-balance to allocate) see no change. The deduction is a strict no-op for them.

Tests

Environment: Node 26.10.0, local, no database server (core specs use better-sqlite3 in memory).

# core: new and touched suites, plus the neighbouring time-off, request-approval and official-holiday suites
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/core/jest.config.ts --rootDir packages/core \
  src/lib/time-off-balance src/lib/time-off-request src/lib/time-off-policy \
  src/lib/database/connect-time-off-balances-to-approval-migration.spec.ts \
  src/lib/request-approval src/lib/official-holiday
# -> Test Suites: 32 passed, 32 total; Tests: 572 passed, 572 total

# ui-core: common utils (safe opener, download helper, ...), time-off dialogs
npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core \
  packages/ui-core/common/src/lib/utils packages/ui-core/shared/src/lib/time-off
# -> Test Suites: 10 passed, 10 total; Tests: 82 passed, 82 total

# gauzy app: approve/deny, document link
npx jest -c apps/gauzy/jest.config.ts apps/gauzy/src/app/pages/time-off
# -> Test Suites: 1 passed, 1 total; Tests: 12 passed, 12 total

New or extended specs:

  • working-days.spec.ts (32 tests):
    • a Berlin employee in a UTC organization, a New York employee in a Berlin organization, and a Sydney picker read in UTC and in Los Angeles;
    • end values stored at midnight and at 23:59:59;
    • a 1000-year span counted in under a second;
    • one-off, multi-day and recurring holidays, a recurring holiday that spans New Year, a recurring range that includes 29 February (and separate recurring holidays on 28 February and 1 March that do not), a 31 December split, a Friday/Saturday weekend, and SQL timestamp strings.
  • time-off.status.handler.balance.spec.ts (30 tests): runs the real handler and service on better-sqlite3 with the core entities. Covers:
    • the no-balance no-op, and other policies or years left untouched;
    • 5 working days taken, holidays excluded, several employees, a 4/6 split across years, and a balance for only one of the years;
    • default: an approval the balance cannot cover overdraws it (remaining āˆ’1), and a denial gives the overdraw back;
    • requireSufficientBalance on: 400 with nothing written, rollback across employees, and approval when the balance covers it;
    • the reference zone: the filer's zone, the employee's zone, and an unknown zone falling back to the organization's;
    • a request spanning more than 3 years is approved, takes nothing, and logs a warning;
    • deny restores; a holiday added later and a balance allocated later change nothing; a request approved before the ledger existed gives nothing back;
    • re-approve after deny, approve twice, a stale second approval, deny twice, deny of a requested leave;
    • holiday entries, weekend-only requests, and both unique indexes.
      It also checks that the SQLite migration produces exactly the entities' schema for the new table and column (TypeORM finds nothing to change).
  • connect-time-off-balances-to-approval-migration.spec.ts:
    • PostgreSQL statements: advisory lock, the policy column, 9 indexes, the unique pair, 7 cascading foreign keys, the column alone when the table exists, and a re-run when both exist.
    • MySQL: the column and the table under GET_LOCK, refusal without the lock, release on failure.
    • down on all three databases.
  • time-off-policy.service.update.spec.ts: the setting is saved, and kept when an update leaves it out.
  • time-off-balance.service.concurrency.spec.ts (#10183 item 4): deduct under two simultaneous callers spends the days once; carryForward re-runs are idempotent and keep the cap.
  • time-off.status.handler.spec.ts: checks that settle runs between the lock and the update, that a refusal rolls back, and that settle is skipped for a removed leave.
  • safe-url.spec.ts: http(s) only (javascript:, data:, blob:, file:, empty and non-string refused), the opened tab loses its opener, a blocked tab answers blocked, HTML escaping.
  • download-file.spec.ts: download, the tab fallback, the blocked path, a host error, a refused link.
  • time-off-request-form.spec.ts; time-off-request-mutation.component.spec.ts (found / not found / order-independent selection, unusable link, blocked tab shows the link, read error); time-off-settings-mutation.component.spec.ts (the checkbox's default, edit value and payload); time-off.component.status.spec.ts (refusals, an overlapping click ignored, the document link).

Type-check:

  • npx tsc -p packages/{contracts,core}/tsconfig.lib.json --noEmit and npx tsc -p apps/gauzy/tsconfig.app.json --noEmit: clean.
  • npx tsc -p packages/ui-core/tsconfig.spec.json --noEmit: clean. npx tsc -p packages/ui-core/tsconfig.lib.json --noEmit: no errors outside spec files. That config also picks up *.spec.ts under common/ and shared/ without jest types, which is pre-existing.

ESLint on every touched file: no new errors. The remaining errors are on lines this PR does not change (no-inferrable-types, empty ngOnDestroy). Prettier: files added by this PR are formatted; existing files keep their develop formatting outside the changed lines.

Manual verification still needed

  • PostgreSQL and MySQL: run the migration, then approve, deny and re-approve a request for an employee with a balance, with requireSufficientBalance off and on. Only the SQLite path ran against a real database here.
  • Request form: in an organization with an unrelated document added first, both buttons should download the right forms. With the forms removed, the hint should appear. A form hosted without CORS should open in a new tab, and with popups blocked the link should appear under the buttons.
  • The demo forms' Cloudinary links return 404 today (out of scope, see Decisions). The seeded demo shows the "could not be downloaded" hint until real forms are uploaded.

Not in this PR (follow-ups for #10183 and #5481)

  • Approval through the request-approval answer route (moveTimeOff, both ORMs) does not deduct yet.
  • Deleting an approved request does not give its days back. The ledger rows cascade away and taken stays.
  • Editing the dates of an approved request (updateTimeOffByAdmin, TimeOffUpdateHandler) does not re-count.
  • Requests filed directly as APPROVED (policy without requiresApproval) are not deducted, because no status transition runs.
  • Items 2 and 3 of #10183: the accrual job and seeding official holidays.
  • Holidays are not yet filtered by country, and weekends are not yet configurable per organization (the helper already takes weekendDays).
  • A durationDays column on the request: the ledger stores the days taken per balance, so the column can be added later from the same helper.
  • With FEATURE_DOCUMENTS on, read the request forms from the Documents hub too (docs plugin document table, DOCS_READ, signed download links), as described under Decisions.

Risk and rollback

  • Risk is limited to organizations with balance rows. Their approvals now move taken, and overdraw the balance when it is too small (or are refused, for a policy that opts in). Everything happens in the decision's existing transaction.
  • Rollback: revert the PR. The migration's down drops time_off_balance_deduction and time_off_policy.requireSufficientBalance, and balances keep whatever taken they reached. Reverting only the UI commits restores the old dialog and page behaviour without touching the backend.

Closes #5481
Part of #10183

šŸ¤– Generated with Claude Code