feat(time-off): take approved leave from the balance and fix the request-form download
Merged
EV
14d ago+3547+3547+0123456789012345678901234567890123456789-107-107-012345678901234567890123456789
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_balancefor 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
remaininggoes below zero. A policy can opt in to refusing such approvals with the newrequireSufficientBalancesetting (off by default, a checkbox in the policy form).
- A balance that cannot cover the days is overdrawn, not refused. The approval goes through, the full deduction is recorded, and
- 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, sotakenonly 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 andofficial_holiday. - #5481:
getRequestFormopeneditems[0]for the paid form anditems[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:
- Negative balances: overdraw by default. An approval the balance cannot cover goes through.
takenrises by the full number of days,remaininggoes below zero, and the ledger records the full deduction, so a later denial gives back exactly that.time_off_balance.remainingis a signednumeric(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, defaultfalse). 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-balancekeeps its current behaviour: it still refuses a deduction the balance cannot cover.
- Strict refusal is opt-in per policy:
- Ledger table: kept (
time_off_balance_deduction). - Demo request forms (Cloudinary 404): out of scope. The dialog keeps the "could not be downloaded" hint, and no binaries are added.
- Documents hub: with
FEATURE_DOCUMENTSon, uploads go through the docs plugin into its owndocumenttable. That table is read through/plugins/docs/documentsunder theDOCS_READpermission, 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 readsorganization_document.
What changed
packages/contracts
ITimeOffBalanceDeduction: one row per (request, balance), holding the days taken.ITimeOffPolicyEntitlement.requireSufficientBalance.TimeOffRequestFormTypeEnumandTIME_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
timeZoneof 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 conditionalUPDATEthat movestaken/remaining, shared by the service and the approval path. It has three moves:spend(only whenremaining >= days, used by/time-off-balanceand by strict policies),overdraw(always), andgiveBack(clamped at 0). The service's behaviour is unchanged.TimeOffBalanceDeductionentity and tabletime_off_balance_deduction:- Migration
1791000003630-ConnectTimeOffBalancesToApprovalfor PostgreSQL, MySQL and SQLite. It creates the ledger table (every foreign key cascades on delete, with a unique index on (timeOffRequestId,balanceId)) and addstime_off_policy.requireSufficientBalance. Each step is skipped when already done, anddownundoes both.
- Migration
TimeOffBalanceDeductionService.settle(manager, leave, status), called byTimeOffStatusHandler.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
requireSufficientBalanceis 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.
- Into APPROVED:
TimeOffPolicyServicecreate/update saverequireSufficientBalance. An update body that leaves it out keeps the policy's current setting.- Demo seed: the two request forms use
httpsand the shared names. TimeOffRequestModuleimportsTimeOffBalanceModule.
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 answersblockedwhen 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 useHttpClient, 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 answersblockedwhen 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.
- If no form exists: warning
- The policy dialog has the
requireSufficientBalancecheckbox (TIME_OFF_PAGE.POLICY.REQUIRE_SUFFICIENT_BALANCE). - i18n: the new keys in all 14 languages (
achin 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_balancerows (today, every organization that never called/time-off-balanceto 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; requireSufficientBalanceon: 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. downon 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):deductunder two simultaneous callers spends the days once;carryForwardre-runs are idempotent and keep the cap.time-off.status.handler.spec.ts: checks thatsettleruns between the lock and the update, that a refusal rolls back, and thatsettleis 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 answersblocked, 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 --noEmitandnpx 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.tsundercommon/andshared/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
requireSufficientBalanceoff 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
takenstays. - 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
durationDayscolumn on the request: the ledger stores the days taken per balance, so the column can be added later from the same helper. - With
FEATURE_DOCUMENTSon, read the request forms from the Documents hub too (docs plugindocumenttable,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
downdropstime_off_balance_deductionandtime_off_policy.requireSufficientBalance, and balances keep whatevertakenthey reached. Reverting only the UI commits restores the old dialog and page behaviour without touching the backend.
š¤ Generated with Claude Code