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¶
-
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).
-
subcontractor.idis 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. -
Insurance policies always have an expiry date — this is enforced in the Pydantic schema (not nullable for
SubcontractorInsurance). -
Accreditations may not expire (
expiry_datenullable). Ifexpiry_date IS NULL, they are excluded from expiry scanning. -
Computed fields on the list response:
has_expiring_insurance— any insurance policy expiring within the configured alert thresholdhas_expired_insurance— any insurance policy withexpiry_date < CURRENT_DATEhas_expiring_accreditation— same for accreditations-
has_expired_accreditation— same for accreditations -
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 onnameandtrading_name - Transition status — valid transitions succeed
- Transition status — invalid → 409
- Soft delete — removed from list; linked personnel records unaffected
worker_countderived correctly from personnel FK
Insurance¶
- Add policy — valid → 201
- Add policy —
expiry_datein the past → accepted (expired) days_until_expiryand computedstatuscorrect for all states- Verify — sets
verified = true, recordsverified_byandverified_at - Soft delete — excluded from subsequent queries
Accreditations¶
- Add — valid, no expiry →
status = no_expiry - Add — with expiry →
statuscomputed correctly - Soft delete — excluded
Expiry flags on list response¶
has_expiring_insurancetrue when at least one policy within alert thresholdhas_expired_insurancetrue 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_idfrom 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 |