Leases Developer Guide - edmozley/freeitsm GitHub Wiki
Leases — Developer Guide
User guide: Leases. Built in 3.5.0.
All the rules are in includes/leases.php. The endpoints in api/assets/leases/ are thin adapters (_boot.php → leaseEndpoint()), and the pages (asset-management/leases/index.php, view.php) are rendered by assets/js/leases.js.
Tables
| Table | Notes |
|---|---|
leases |
tenant_id is scoped data like assets: NULL = the Default company. supplier_id, cost_centre_id, contract_id are all FK ON DELETE SET NULL. notice_days NULL = none. |
lease_schedules |
lease_id FK ON DELETE CASCADE. cost_period ∈ month quarter year total (LEASE_PERIODS). A lease always has at least one; leaseSave() creates the first. |
lease_assets |
schedule_id CASCADE, asset_id CASCADE, outcome_id SET NULL. UNIQUE (schedule_id, asset_id). start_date / end_date are the asset's own dates: NULL = the schedule's. |
lease_outcomes |
One global list. kind active (still on lease) or closed. is_default = what a new row starts with. Seeded by Database Verification when empty. |
includes/db_verify_modules.php maps the lease prefix to assets.
"Active"
A lease_assets row is still on lease when its outcome is NULL or of kind active. Always use leaseActiveSql('la', 'lo') with LEFT JOIN lease_outcomes lo rather than writing it out.
One active row per asset is enforced in leaseAssetsAdd() and in leaseAssetUpdate() (going from a closed outcome back to an active one). The database cannot express "unique among active rows".
Dates: one source of truth, kept in step both ways
The asset's own lease_start / lease_expiry stay the dates everything else reads (asset panel, table, import, REST, Watchtower, calendar). For an asset on an active row they are kept equal to the effective dates, COALESCE(la.start_date, s.start_date) and the same for the end.
- Lease side → asset.
leaseSyncAsset()writes the effective dates throughAssetsService::updateFields(), so they are validated, audited inasset_historyand re-sync the calendar. It is called after adding an asset, after a schedule's dates change (leaseSyncSchedule()), and after an asset's own dates or outcome change. - Asset side → lease.
AssetsService::updateFields()callsleaseAssetDatesEdited()wheneverlease_startorlease_expiryis in the input. On an active row it stores the asset's dates as the row's own dates, or NULL where they equal the schedule's. - No loop.
leaseAssetDatesEdited()never writes the asset, so a write from the lease side triggers it once and it just re-derives the same own dates.
A closed row is never synced: a returned asset keeps the dates it had. Deleting a lease or schedule also leaves the asset's dates alone.
Companies
| Function | Use |
|---|---|
leaseListSql() |
The list and Due back: activeTenantReadFilter, so it follows the switcher, and "All companies" means every reachable one. |
leaseVisibilitySql() / leaseCanView() / leaseLoad() |
Any single read or write: any company the analyst can reach. leaseLoad() throws not_found, never revealing another company's lease. Schedules and asset rows are loaded through their lease (leaseLoadSchedule(), leaseLoadAssetRow()). |
Same-company rules:
- Assets must be in the lease's company (
leaseAssetsAdd()). - Contracts must be visible (
contractCanView) and in the lease's company (contractTenantOf). - Cost centres use the GH #160 rules (
costCentreCheckwith the lease's company). - Moving a lease to another company is refused while it has any asset rows. Moving its cost centre needs a new one in the same request.
- Moving an asset (
AssetsService::moveToCompany()) is refused while it is on an active lease.
Calendar
leaseSyncCalendar():
- Wipes and rebuilds
calendar_eventswithsource = 'asset_lease_notice': one entry per schedule with an end date, a leasenotice_days, and at least one active asset, dated end minus notice. - Is gated on the same
asset_lease_surfacesetting (calendar/both) as the lease-end entries. - Uses their category (
awcEnsureCategory(..., 'asset_lease', ...)). - Is called from
syncAssetWarrantyCalendar(), so saving the Expiry alerts tab rebuilds it, and after every lease change that could move it. - Swallows its own errors: a calendar must never fail a lease save.
Elsewhere
- Documents. The
leaseentry indocumentEntityRegistry().leaseDelete()callsdocumentsDetachParent(). - Settings → Leasing. Gated by
Cap::ASSETS_LEASING, plusanalystHasAllTenantAccess()for writes because the list is global. It reuses the shared settings modal, with a "Counts as" row (leaseKindSetup()). Endpoints:api/assets/{get,save,delete}_lease_outcome(s).php. Deleting an outcome is refused while in use. - Asset panel.
loadAssetLease()inasset-management/index.phpreadsfor_asset.php. Since #2300 the money fields (purchase date and cost, supplier, cost centre, order number, warranty, both lease dates and the lease line) sit on a Financial tab,#financialPanel/data-dtab="financial", beside Key info. They were moved, not rebuilt: every input keeps its id andupdateAssetField()call, so nothing that saves them changed.switchDetailTab()is generic. - Due back CSV.
due_back.php?format=csv, viaspreadsheetWriteCsv()(formula-guarded). - D005. Each lease endpoint calls
requireModuleAccessJson('assets')visibly as well as insideleaseEndpoint(), so the endpoint-permissions audit can see the guard.
Tests
php tests/leases.php runs 47 checks in a rolled-back transaction, each refusal with a positive control. They cover:
- dates following the schedule, both ways;
- own dates;
- outcomes;
- one active lease per asset;
- Due back windows and overdue;
- the calendar notice entries on and off;
- history;
- every company rule, as an analyst who can reach one company;
- delete.
Not built (yet)
- The REST API has no lease endpoints. Assets carry
lifecycle.lease_start/lease_expiryas before. - Leases are not in global search, the recent trail or workflow events.
- Costs are recorded per schedule but not totalled or reported.