Skip to content

Customer Contracts Developer Guide

Ed Mozley edited this page Oct 2, 2026 · 1 revision

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. Returns ['', []] when nothing needs hiding: not ready, single-company, or the setting is all.
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.
contractCustomerVisibility($conn, $reload = false) 'company' (default) or 'all', from system_settings.contracts_customer_visibility.

The visibility clause

AND (c.party_type <> 'customer'
     OR COALESCE(c.customer_tenant_id,
                 (SELECT tenant_id FROM users WHERE id = c.customer_user_id)) IN (…accessible ids…)
     OR <that expression> IS NULL)          -- only when Default is accessible
  • A contract naming only a person takes the person's company; a person with no company is the Default company β€” the convention used everywhere else.
  • Fails closed: an analyst with no accessible companies gets AND c.party_type <> 'customer'.

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 api/contracts/get_contracts.php
One contract (hidden = Contract not found) api/contracts/get_contract.php
Dashboard counts (+ customer_contracts) 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)

Not filtered, on purpose: the War Room bot's supplier-contact tool lists contracts by supplier (supplier_id = s.id), and customer contracts have no supplier. 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

Contracts β†’ Settings β†’ Customer contracts β€” api/contracts/customer_visibility.php, gated by module access and Cap::CONTRACTS_CUSTOMER_VISIBILITY (contracts.customer_visibility, marked sensitive in contracts/settings/manifest.php). Stored with INSERT … ON DUPLICATE KEY UPDATE in system_settings.

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

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally