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 cached SELECT col … LIMIT 0) first. Not ready β†’ reads select 'supplier' AS party_type, NULL … and skip the joins, saves force party_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_id is 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's companyScope (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_type must be supplier, customer or 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 use contractCanView()), and add a hidden + positive-control pair to tests/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_id in the service, company_id in 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 have C-001.
  • Moving (a different tenant_id on 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 checks contracts.tenant_id at 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 / .deleted payloads gain party_type, customer_tenant_id, customer_user_id.
  • contract.expiring gains those plus party_name (supplier or customer, whichever it is with); the Contract renewal reminder recipe now says With: {{contract.party_name}}.
  • domain.* payloads gain customer_user_id.
  • REST: Contract gains party_type and customer {company, person}; GET /contracts takes party_type, customer_company_id, customer_user_id; POST/PATCH accept party_type, customer_tenant_id, customer_user_id. Domain gains customer, and accepts customer_user_id. The workflow engine's .full loaders 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