Customer Contracts Developer Guide - edmozley/freeitsm GitHub Wiki
Customer contracts β Developer Guide
User guide: Contracts with customers Β· Issue: #153 Β· Plan: People, customers and suppliers roadmap
A contract is with a supplier (you buy) or a customer (you sell). Contracts have no tenant_id β supplier contracts are install-wide β but a customer contract belongs to its customer's company, and that one fact is what most of this page is about: every place that reads a contract had to learn it.
Schema
| Table | Column | Notes |
|---|---|---|
contracts |
party_type VARCHAR(10) NOT NULL DEFAULT 'supplier' |
'supplier' or 'customer'. Existing rows default to supplier β nothing changes meaning. |
contracts |
customer_tenant_id INT NULL |
FK tenants, ON DELETE SET NULL |
contracts |
customer_user_id INT NULL |
FK users, ON DELETE SET NULL |
domains |
customer_user_id INT NULL |
FK users, ON DELETE SET NULL. No company column β the domain's own tenant_id is the customer's company. |
A customer contract has supplier_id = NULL; a supplier contract has both customer columns NULL. contractNormaliseParty() enforces that on every save, so a contract never points both ways.
β οΈ Before Database Verification. Every read and write checks
contractPartyReady()/domainCustomerReady()(a cachedSELECT col β¦ LIMIT 0) first. Not ready β reads select'supplier' AS party_type, NULL β¦and skip the joins, saves forceparty_type = 'supplier'and skip the customer columns. An upgraded install that has not run DB Verify keeps working rather than failing every contract query.
The one rule, in one file β includes/contract_party.php
| Function | Use |
|---|---|
contractVisibilitySql($conn, $analystId, $alias) |
[" AND (β¦)", $params] to append to any query over contracts: every company the analyst can reach (allAccessibleTenantsFilter on tenant_id). ['', []] before Verification or on a single-company install. |
contractListSql($conn, $analystId, $alias) |
The contracts list and its dashboard: the active company, or all under All companies (activeTenantReadFilter). |
contractTenantOf($conn, $id) / contractStoreTenant($conn, $tid) / contractOwnerSql($conn, $alias) |
A contract's company (NULL read as Default); how one is stored (Default β NULL); [cols, joins] adding tenant_id (effective) and company_name. |
contractVisibilitySqlForScope($conn, ?array $scope, $alias) |
The same from a company list in the API's null-means-all convention β for ActorContext::$companyScope (API keys, services). |
contractCanView($conn, $analystId, $id) |
One contract. Also the existence check: hidden reads as not found. |
contractPartySql($conn, $alias) |
[cols, joins]: party_type, customer_tenant_id, customer_user_id, customer_company_name, customer_person_name, customer_person_email via LEFT JOIN tenants cpt / users cpu. |
contractPartyLabel($row) |
"Who it's with" as one string β supplier trading/legal name, or Company Β· Person. |
contractNormaliseParty($conn, $analystId, $data) |
Validates a save. Throws InvalidArgumentException (translated) β the service turns it into a validation ServiceError. |
The visibility clause
Since 3.5.0 it is about the owner, not the customer:
AND (c.tenant_id IN (β¦accessible idsβ¦) OR c.tenant_id IS NULL) -- NULL only when Default is accessible
contracts.tenant_idis scoped data, like assets: NULL is the Default company, never "shared".- Fails closed: a scope with no companies gets
AND 1 = 0. assertVisible()in the service uses the actor'scompanyScope(contractVisibilitySqlForScope), not the analyst's access β an API key can be narrower than the analyst it acts as.- The party (
party_type,customer_*) no longer decides anything about visibility.contractCustomerTenantExpr()survives for the People company page, which lists contracts with a company.
Validation (contractNormaliseParty)
party_typemust besupplier,customeror empty (= supplier). Anything else is refused β a typo through the API must not quietly become a supplier contract.- A customer needs a company, a person, or both.
- The company must be one the analyst can access; the person must be active and in an accessible company; with both, the person must be in that company.
Where it is applied
Each of these was a contract read that assumed "install-wide". All now apply the rule:
| Surface | File |
|---|---|
List, filter ?party=, search by customer β contractListSql (active company / All) |
api/contracts/get_contracts.php |
| One contract (hidden = Contract not found) | api/contracts/get_contract.php |
Dashboard counts (+ customer_contracts) β contractListSql, matching the list |
api/contracts/get_dashboard_stats.php |
Save / delete / terms (assertVisible) |
includes/services/contracts.php |
| Preview | includes/record_preview.php |
| βK search | api/system/global_search.php |
| Recent trail gate | includes/recent_trail.php |
Documents (can + filter) |
includes/documents.php |
| Asset β contract: list, link, unlink, search | includes/contract_assets.php, api/assets/search_linkable_contracts.php |
| Equipment report + email | includes/contract_report.php (contractReportLoad(β¦, $analystId)) |
| Watchtower counts | includes/watchtower_queries.php |
| Domain's linked contract (label, picker, save) | includes/domains/read.php, includes/services/domains.php (assertContractVisible) |
| Calendar event / task link to a contract | includes/services/calendar.php, includes/services/tasks.php |
REST API (key's company_scope) |
api/v1/resources/contracts.php (apiContractVisibility) |
Also filtered since 3.5.0: the War Room bot's supplier-contact tool (includes/warbot/tools.php) and project links (includes/projects/links.php). Not filtered, on purpose: the contract.expiring scheduled trigger runs with no viewer.
π Adding a new contract read? Append
contractVisibilitySql()(or usecontractCanView()), and add a hidden + positive-control pair totests/contract-customer-party.php.
The setting (retired in 3.5.0)
Contracts β Settings β Customer contracts, its endpoint api/contracts/customer_visibility.php and Cap::CONTRACTS_CUSTOMER_VISIBILITY are gone. The first 3.5.0 Database Verification ($contractsTenantColWasMissing in api/system/db_verify.php) filed each customer contract under COALESCE(customer_tenant_id, the person's company) when the setting was at its default, and left them in Default when it was all β so nobody's view changed on upgrade. Only that once: afterwards a contract in Default with a customer elsewhere is a deliberate choice.
Owning company rules (3.5.0)
- Create stamps the company named (
tenant_idin the service,company_idin the API), else Default β or the actor's first company if they cannot reach Default. The screen always names the active company. A named company must exist and be in the actor's scope: refused, never silently replaced. - Contract numbers are unique per company (
assertNumberFree): two clients can both haveC-001. - Moving (a different
tenant_idon update) is refused while the contract has assets, projects or RFPs in its old company (assertMovable), with what is in the way. - Links stay in one company, checked even for an all-company analyst: a contract covers only its own company's assets (
contractAssetLink; both pickers offer only that company); a project links only its own company's contracts (projectLinkKinds()['contract']['scoped'] = 'contracts'); a domain links only its own company's contract (assertContractVisible(β¦, $domainTenant)). Tasks and calendar events keep the visibility rule only, as they do for tickets and changes β and moving a task already refuses a contract link in another company (it checkscontracts.tenant_idat runtime). - The War Room bot's supplier tool now scopes contracts too (
warbotContractScope, ids written in because its queries bind named parameters).
Choosing the customer β api/contracts/customer_lookup.php
GETβ{multi_company, companies}β active companies the analyst can access.GET ?q=&tenant_id=β up to 25 active users matching name, email or preferred name, only in accessible companies (β© the chosen one); no-company users only when Default is accessible.
Its own endpoint rather than api/tickets/get_users.php, which needs Tickets or Assets β an analyst who only does contracts must still be able to name a customer.
Domains β includes/domains/customer.php
| Function | Use |
|---|---|
domainCustomerReady($conn) |
Column guard. |
domainCustomerPersonOk($conn, $userId, $tenantId) |
Active user in the domain's company (no-company = Default). Used by the customer field type in DomainsService::validateField. |
domainCustomerSearch($conn, $tenantId, $q) |
The picker's query β behind api/domains/people.php?domain_id=&q=, which first checks analystCanAccessDomain. |
domainListSelect(?PDO $conn) and apiDomainSelect(?PDO $conn) take the connection so they can add the column and join only when it exists. The edit dialog keeps a linked contract the editor cannot see as an option labelled A contract you cannot see β otherwise saving the form would silently unlink it.
Workflows and the API
contract.created/.updated/.deletedpayloads gainparty_type,customer_tenant_id,customer_user_id.contract.expiringgains those plusparty_name(supplier or customer, whichever it is with); the Contract renewal reminder recipe now saysWith: {{contract.party_name}}.domain.*payloads gaincustomer_user_id.- REST:
Contractgainsparty_typeandcustomer {company, person};GET /contractstakesparty_type,customer_company_id,customer_user_id;POST/PATCHacceptparty_type,customer_tenant_id,customer_user_id.Domaingainscustomer, and acceptscustomer_user_id. The workflow engine's.fullloaders now call$selectFn($conn)(selects that take no argument ignore it).
Tests
php tests/contract-customer-party.php β 41 checks. It needs a restriction to test and most installs have none, so it clears a non-admin's all-companies flag, grants one company, creates people, contracts and a domain β all inside a transaction that is always rolled back, with a final check that nothing survived. Every "hidden" check has a positive control beside it, so a rule that hid everything could not pass.
See also: Contracts Β· Multi-tenancy β Developer Guide Β· Domains β Developer Guide Β· REST API: Contracts