Skip to content

Subcontractors

Status: Specified — ready to build Build order: 4 of 10 — depends on Projects & Sites


Purpose

Subcontractors is the company register for all firms working under the main contractor on a project. It tracks insurance policies and trade accreditations with expiry dates, triggers alerts before they lapse, and provides the subcontractor_id FK referenced by the Personnel module.


Entities

Subcontractor

A company working on a project. Registered at the project level.

Field Type Required Notes
id UUID System PK, generated
project_id UUID FK → projects.id
name VARCHAR(255) Company name
trading_name VARCHAR(255) If different from legal name
company_number VARCHAR(20) Companies House number
address_line_1 VARCHAR(255)
address_line_2 VARCHAR(255)
city VARCHAR(100)
postcode VARCHAR(20)
contact_name VARCHAR(255) Primary contact at the subcontractor
contact_email VARCHAR(255)
contact_phone VARCHAR(30)
scope_of_works TEXT Free text description of what they're doing on the project
status VARCHAR(20) Default: active
custom_fields JSONB Default {}
created_at TIMESTAMPTZ System
updated_at TIMESTAMPTZ System Trigger-maintained
created_by UUID System FK → users.id
updated_by UUID System
deleted_at TIMESTAMPTZ
CREATE INDEX idx_subcontractors_project_id
    ON subcontractors(project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_subcontractors_status
    ON subcontractors(project_id, status) WHERE deleted_at IS NULL;

SubcontractorInsurance

An insurance policy held by a subcontractor. One company typically has multiple policies (public liability, employers' liability, professional indemnity, etc.).

Field Type Required Notes
id UUID System PK, generated
subcontractor_id UUID FK → subcontractors.id
project_id UUID Denormalised
insurance_type VARCHAR(100) See suggested types below
insurer VARCHAR(255)
policy_number VARCHAR(100)
cover_amount NUMERIC(15,2) In GBP
expiry_date DATE Insurance policies always have an expiry
document_key VARCHAR(500) S3 key for the insurance certificate
verified BOOLEAN Default: false. True = certificate physically or digitally checked
verified_by UUID FK → users.id
verified_at TIMESTAMPTZ
notes TEXT
custom_fields JSONB Default {}
created_at TIMESTAMPTZ System
updated_at TIMESTAMPTZ System
created_by UUID System
updated_by UUID System
deleted_at TIMESTAMPTZ
CREATE INDEX idx_subco_insurance_subcontractor
    ON subcontractor_insurance(subcontractor_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_subco_insurance_project
    ON subcontractor_insurance(project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_subco_insurance_expiry
    ON subcontractor_insurance(expiry_date) WHERE deleted_at IS NULL;

Suggested insurance types:

Type Notes
Public Liability Most common — covers third-party injury and property damage
Employers' Liability Legally required if they employ anyone in the UK
Professional Indemnity Design/consultancy firms
Contractors' All Risks Plant, materials, and works in progress
Plant & Equipment Hired or owned plant
Other Free text

SubcontractorAccreditation

A trade certification, industry scheme membership, or safety scheme held by the subcontractor company. Separate from individual worker licences.

Field Type Required Notes
id UUID System PK, generated
subcontractor_id UUID FK → subcontractors.id
project_id UUID Denormalised
accreditation_type VARCHAR(100) See suggested types below
reference_number VARCHAR(100)
issuing_body VARCHAR(255)
issue_date DATE
expiry_date DATE NULL = does not expire
document_key VARCHAR(500) S3 key for the accreditation certificate
verified BOOLEAN Default: false
verified_by UUID FK → users.id
verified_at TIMESTAMPTZ
notes TEXT
custom_fields JSONB Default {}
created_at TIMESTAMPTZ System
updated_at TIMESTAMPTZ System
created_by UUID System
updated_by UUID System
deleted_at TIMESTAMPTZ
CREATE INDEX idx_subco_accreditation_subcontractor
    ON subcontractor_accreditations(subcontractor_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_subco_accreditation_project
    ON subcontractor_accreditations(project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_subco_accreditation_expiry
    ON subcontractor_accreditations(expiry_date)
    WHERE deleted_at IS NULL AND expiry_date IS NOT NULL;

Suggested accreditation types:

Type Notes
Constructionline Gold / Silver / Bronze UK supply chain management
CHAS (Contractors Health & Safety Assessment) Common health & safety pre-qual
SafeContractor Health & safety pre-qualification
SSIP Member (any scheme) Safety Schemes in Procurement umbrella
ISO 9001 Quality management
ISO 14001 Environmental management
ISO 45001 Occupational health & safety
RISQS Railway Industry Supplier Qualification Scheme
Achilles UVDB Utilities vendor database
Other Free text

Status Lifecycle — Subcontractor

Status Meaning
active Working on the project
suspended Temporarily suspended from the project
archived No longer on the project — historical record retained
From Permitted transitions
active suspended, archived
suspended active, archived
archived — terminal

Business Rules

  1. Subcontractors are registered at the project level. The same company can exist in multiple projects as separate records (different scope, different contact, different insurance requirements per project).

  2. subcontractor.id is the FK used in the Personnel module. Archiving or suspending a subcontractor does not automatically affect their workers' sign-in status. Personnel must be managed separately.

  3. Insurance policies always have an expiry date — this is enforced in the Pydantic schema (not nullable for SubcontractorInsurance).

  4. Accreditations may not expire (expiry_date nullable). If expiry_date IS NULL, they are excluded from expiry scanning.

  5. Computed fields on the list response:

  6. has_expiring_insurance — any insurance policy expiring within the configured alert threshold
  7. has_expired_insurance — any insurance policy with expiry_date < CURRENT_DATE
  8. has_expiring_accreditation — same for accreditations
  9. has_expired_accreditation — same for accreditations

  10. A subcontractor with expired insurance is visually flagged in the UI but is not blocked from having workers on site. Enforcement is the project manager's responsibility. The notification system handles the alerts.


API Endpoints

Subcontractors

Method Path Description
GET /projects/{projectId}/subcontractors List (paginated, filterable)
POST /projects/{projectId}/subcontractors Register subcontractor
GET /projects/{projectId}/subcontractors/{id} Full detail
PATCH /projects/{projectId}/subcontractors/{id} Partial update
DELETE /projects/{projectId}/subcontractors/{id} Soft delete
POST /projects/{projectId}/subcontractors/{id}/transition Status transition
GET /projects/{projectId}/subcontractors/{id}/personnel Workers linked to this subcontractor

Insurance

Method Path Description
GET /projects/{projectId}/subcontractors/{id}/insurance List policies
POST /projects/{projectId}/subcontractors/{id}/insurance Add policy
PATCH /projects/{projectId}/subcontractors/{id}/insurance/{insId} Update policy
DELETE /projects/{projectId}/subcontractors/{id}/insurance/{insId} Soft delete
POST /projects/{projectId}/subcontractors/{id}/insurance/{insId}/verify Mark as verified
POST /projects/{projectId}/subcontractors/{id}/insurance/{insId}/document Upload certificate

Accreditations

Method Path Description
GET /projects/{projectId}/subcontractors/{id}/accreditations List accreditations
POST /projects/{projectId}/subcontractors/{id}/accreditations Add accreditation
PATCH /projects/{projectId}/subcontractors/{id}/accreditations/{accId} Update
DELETE /projects/{projectId}/subcontractors/{id}/accreditations/{accId} Soft delete
POST /projects/{projectId}/subcontractors/{id}/accreditations/{accId}/verify Mark as verified
POST /projects/{projectId}/subcontractors/{id}/accreditations/{accId}/document Upload certificate

Request / Response Shapes

POST /projects/{projectId}/subcontractors

// Request
{
  "name": "Acme Steel Ltd",
  "company_number": "12345678",
  "contact_name": "Emma Walsh",
  "contact_email": "e.walsh@acmesteel.co.uk",
  "contact_phone": "0121 555 0100",
  "scope_of_works": "Structural steelwork — Blocks A and B",
  "custom_fields": {}
}

// Response 201
{
  "id": "uuid-subco",
  "project_id": "uuid-project",
  "name": "Acme Steel Ltd",
  "company_number": "12345678",
  "contact_name": "Emma Walsh",
  "contact_email": "e.walsh@acmesteel.co.uk",
  "contact_phone": "0121 555 0100",
  "scope_of_works": "Structural steelwork — Blocks A and B",
  "status": "active",
  "worker_count": 0,
  "has_expired_insurance": false,
  "has_expiring_insurance": false,
  "has_expired_accreditation": false,
  "has_expiring_accreditation": false,
  "insurance_count": 0,
  "accreditation_count": 0,
  "custom_fields": {},
  "created_at": "2026-05-28T09:00:00Z",
  "updated_at": "2026-05-28T09:00:00Z"
}

GET /projects/{projectId}/subcontractors

Query params: status, has_expired_insurance (bool), has_expiring_insurance (bool), q (name search), sort (default name), limit, cursor

// Response 200
{
  "data": [
    {
      "id": "uuid-subco",
      "name": "Acme Steel Ltd",
      "contact_name": "Emma Walsh",
      "status": "active",
      "worker_count": 12,
      "has_expired_insurance": false,
      "has_expiring_insurance": true,
      "has_expired_accreditation": false,
      "has_expiring_accreditation": false,
      "next_insurance_expiry": "2026-06-15",
      "next_accreditation_expiry": null
    }
  ],
  "pagination": { "total": 8, "cursor": null, "has_more": false }
}

POST /projects/{projectId}/subcontractors/{id}/insurance

// Request
{
  "insurance_type": "Public Liability",
  "insurer": "Aviva",
  "policy_number": "POL-2026-000123",
  "cover_amount": 5000000.00,
  "expiry_date": "2026-12-31"
}

// Response 201
{
  "id": "uuid-ins",
  "subcontractor_id": "uuid-subco",
  "insurance_type": "Public Liability",
  "insurer": "Aviva",
  "policy_number": "POL-2026-000123",
  "cover_amount": 5000000.00,
  "expiry_date": "2026-12-31",
  "verified": false,
  "verified_by": null,
  "verified_at": null,
  "document_url": null,
  "days_until_expiry": 217,
  "status": "valid",
  "created_at": "2026-05-28T09:00:00Z"
}

Computed status on insurance and accreditation responses:

Value Condition
no_expiry expiry_date IS NULL (accreditations only)
valid expiry_date >= CURRENT_DATE + threshold
expiring_soon expiry_date within configured alert threshold
expired expiry_date < CURRENT_DATE

Permission Matrix

Action Platform Admin Tenant Admin Project Manager Site Manager Viewer
List subcontractors ✓ assigned ✓ assigned ✓ assigned
Register subcontractor ✓ assigned
View detail ✓ assigned ✓ assigned ✓ assigned
Edit subcontractor ✓ assigned
Transition status ✓ assigned
Delete subcontractor ✓ assigned
Manage insurance ✓ assigned
Manage accreditations ✓ assigned
Verify insurance/accreditation ✓ assigned
View linked personnel ✓ assigned ✓ assigned

Site Managers can view subcontractor details and their linked workers (to know who they're managing on site) but cannot add or edit company-level records.


Notifications Triggered

Event Trigger point Notification type
Insurance approaching expiry Daily scanner (EventBridge 06:00 UTC) subcontractor.insurance_expiry
Accreditation approaching expiry Daily scanner (EventBridge 06:00 UTC) subcontractor.accreditation_expiry

Both scanners use the same pattern as the licence expiry scanner:

-- Insurance
SELECT si.*, s.project_id
FROM subcontractor_insurance si
JOIN subcontractors s ON s.id = si.subcontractor_id
WHERE si.deleted_at IS NULL
  AND si.expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + :advance_days
  AND s.status = 'active'
  AND s.deleted_at IS NULL

-- Accreditations
SELECT sa.*, s.project_id
FROM subcontractor_accreditations sa
JOIN subcontractors s ON s.id = sa.subcontractor_id
WHERE sa.deleted_at IS NULL
  AND sa.expiry_date IS NOT NULL
  AND sa.expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + :advance_days
  AND s.status = 'active'
  AND s.deleted_at IS NULL

Offline Sync Scope

Data Syncs to Notes
Subcontractor list Project Manager, Site Manager Basic details — no insurance/accreditation data
Insurance / accreditations Project Manager only Site Manager does not manage these

Site Managers see the company name on the personnel register (denormalised from the FK join) but don't need the full insurance/accreditation dataset on device.


Cross-Module Dependencies

Upstream

Module Dependency
Projects & Sites project_id

Downstream

Module Usage
Personnel & Attendance subcontractor_id — FK on personnel records for subcontract workers

The Personnel module uses subcontractor_id as a FK. This module must be built before that FK constraint is enforced. The migration to add the FK runs when Subcontractors is built.


Tests Required

Subcontractor CRUD

  • Register subcontractor — valid payload → 201
  • List subcontractors — filter by status, has_expired_insurance
  • Name search (q) matches on name and trading_name
  • Transition status — valid transitions succeed
  • Transition status — invalid → 409
  • Soft delete — removed from list; linked personnel records unaffected
  • worker_count derived correctly from personnel FK

Insurance

  • Add policy — valid → 201
  • Add policy — expiry_date in the past → accepted (expired)
  • days_until_expiry and computed status correct for all states
  • Verify — sets verified = true, records verified_by and verified_at
  • Soft delete — excluded from subsequent queries

Accreditations

  • Add — valid, no expiry → status = no_expiry
  • Add — with expiry → status computed correctly
  • Soft delete — excluded

Expiry flags on list response

  • has_expiring_insurance true when at least one policy within alert threshold
  • has_expired_insurance true when at least one policy past expiry
  • Flags false when all policies are valid

Cross-tenant isolation (mandatory)

  • GET subcontractor from another tenant → 404
  • POST insurance with subcontractor_id from another tenant → 404
  • Subcontractor from Project A cannot be FKed to workers in Project B

Permission boundaries

  • Site Manager cannot create/edit subcontractor → 403
  • Site Manager can list subcontractors and view detail → 200
  • Viewer can list and view → 200; cannot edit → 403

Default Tenant Labels

Key Default
module.subcontractors Subcontractors
field.subcontractor.company_number Company Number
field.subcontractor.scope_of_works Scope of Works
field.subcontractor.contact_name Contact
field.insurance.insurance_type Insurance Type
field.insurance.cover_amount Cover Amount
field.insurance.expiry_date Expiry Date
field.accreditation.accreditation_type Accreditation
field.accreditation.issuing_body Issuing Body
status.subcontractor.active Active
status.subcontractor.suspended Suspended
status.subcontractor.archived Archived

Foundation Dependencies

Component Location Used for
TenantMiddleware core/middleware.py Sets request.state.tenant
get_db core/db.py Schema-scoped AsyncSession
get_current_user core/security.py Auth
require_permission core/security.py RBAC
EntityMixin core/models.py Universal columns
PaginatedResponse core/pagination.py List endpoint wrapper
ConstruoError hierarchy core/exceptions.py Typed exceptions
presigned_put_url core/storage.py Certificate upload
SQSClient core/events.py Notification scanner output
audit_entity_change trigger DB foundation Applied to all three tables
factories.py tests/factories.py create_subcontractor, create_insurance, create_accreditation