Skip to content

Leases Developer Guide

Ed Mozley edited this page Oct 11, 2026 · 2 revisions

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 through AssetsService::updateFields(), so they are validated, audited in asset_history and 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() calls leaseAssetDatesEdited() whenever lease_start or lease_expiry is 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 (costCentreCheck with 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_events with source = 'asset_lease_notice': one entry per schedule with an end date, a lease notice_days, and at least one active asset, dated end minus notice.
  • Is gated on the same asset_lease_surface setting (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 lease entry in documentEntityRegistry(). leaseDelete() calls documentsDetachParent().
  • Settings β†’ Leasing. Gated by Cap::ASSETS_LEASING, plus analystHasAllTenantAccess() 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() in asset-management/index.php reads for_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 and updateAssetField() call, so nothing that saves them changed. switchDetailTab() is generic.
  • Due back CSV. due_back.php?format=csv, via spreadsheetWriteCsv() (formula-guarded).
  • D005. Each lease endpoint calls requireModuleAccessJson('assets') visibly as well as inside leaseEndpoint(), 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_expiry as before.
  • Leases are not in global search, the recent trail or workflow events.
  • Costs are recorded per schedule but not totalled or reported.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally