-
-
Notifications
You must be signed in to change notification settings - Fork 27
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.
| 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 checkscontractPartyReady()/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.
| 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. |
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'.
-
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.
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 usecontractCanView()), and add a hidden + positive-control pair totests/contract-customer-party.php.
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.
-
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.
| 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.
-
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).
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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96