openapi: 3.1.0
info:
  title: WorkSkedge API
  version: 2.34.0
  description: |
    REST API for WorkSkedge Apps & Integrations Platform. **Last Updated: 2026-05-15**

    ### What's New in 2.34.0
    - **Employee references accept `emp_number`**: Any endpoint accepting an employee UUID field (`employee_id`, `site_lead_id`, `estimator_employee_id`, `account_manager_id`, `reimbursement_employee_id`, `temp_employee_id`) now also accepts a sibling `*_emp_number` field (e.g. `employee_emp_number`, `site_lead_emp_number`, `estimator_emp_number`, `account_manager_emp_number`, `reimbursement_emp_number`, `temp_employee_emp_number`). Provide **either** the UUID **or** the emp_number — not both. Emp_number lookups are scoped to the caller's company. Backward compatible: existing UUID-based integrations continue to work unchanged. Applies to: Timesheets, Assignments, Custom Bookings, Schedules (filter), Work Orders (`site_lead`), Purchase Orders (`reimbursement`), Customers (`account_manager`), Projects (`estimator`).
    - **Customer & Vendor Document Upload (Restricted)**: Added `GET/POST /v1/customers/{id}/documents`, `DELETE /v1/customers/{id}/documents/{document_id}` and the matching `/v1/vendors/{id}/documents[...] ` endpoints. Reads require `customers:read` / `vendors:read`. Uploads and deletes require `customers:write` / `vendors:write` **and** the new restricted flags `allow_customer_document_upload` / `allow_vendor_document_upload` toggled per-app by a WorkSkedge superuser. Supports compliance document types (insurance, COI, W9, licenses, vendor `subcontract_declaration`, etc.) with `document_type`, `document_name`, `issuer`, `reference_number`, `issued_date`, `expires_at`, and `description` fields.

    ### What's New in 2.33.1
    - **PO bill line items now mirror raw DB columns**: `bills[].line_items[]` returned by `GET /purchase-orders` and `GET /purchase-orders/{id}` (and the `po.*` webhooks) now include `po_bill_id`, `po_line_item_id`, `master_description`, `sku`, `display_order`, `notes`, and `created_at`. Existing fields are unchanged. `line_total` returns the persisted DB value, with a computed fallback only when the column is null (legacy rows).




    ### 2.33.0
    - **Purchase Order line items on list endpoint**: `GET /purchase-orders` now returns `line_items` on every record, matching the documented `PurchaseOrder` schema and the single-PO `GET /purchase-orders/{id}` payload.
    - **`PurchaseOrderLineItem` schema expanded** to mirror the underlying database row: added `purchase_order_id`, `master_description`, `cost_code`, `product_id`, `qty_ordered`, `qty_received`, `qty_invoiced`, `is_sample`, `received_at`, `received_by`, `created_at`, `updated_at`. Removed `is_taxable` and `tax_code_id` (these were never persisted on PO line items — tax handling is at the document level via `tax_inclusive` and `tax_lines`).

    ### 2.32.0
    - **Trainer Chat Takeover (Restricted)**: Apps may now register as trainer chat providers and replace the built-in WorkSkedge Trainer with their own iframe-embedded chat surface. Requires a WorkSkedge administrator to enable `supports_trainer_provider = true` and set `trainer_provider_url` on the app's catalog entry (Restricted API Capabilities). No new HTTP endpoints — integration is via the App Bridge iframe surface; the loaded URL receives `?token=<jwt>&install_id=<uuid>&surface=trainer`. See `docs/trainer-provider-developer-guide.md`. No webhook events are emitted by this surface.

    ### 2.31.0
    - **Report Photo Upload (Restricted)**: Added `POST /reports/{id}/photos`, `GET /reports/{id}/photos`, and `DELETE /reports/{id}/photos/{photoId}`. Lets approved apps attach images to daily and completion reports. Requires `reports:write` scope **and** the `allow_file_upload` feature flag (the same flag that gates project document uploads).
    - **Report `photos` field**: `POST /reports` and `PATCH /reports/{id}` now formally accept either an array of URL strings (legacy) or an array of photo metadata objects (`{ id, url, name, content_type, size_bytes, ... }`).

    ### 2.30.0
    - **Project Overhead Work Orders**: Added `is_overhead` field to WorkOrder schema. Overhead WOs are excluded from list endpoints by default; use `include_overhead=true` query param to include them.
    - Added `PUT /v1/vendor-credits/:id/sync-status` endpoint for accounting software sync
    - Added `sync_status` query parameter to `GET /v1/vendor-credits`
    - Added `sync_status`, `sync_message`, `sync_url` fields to VendorCredit schema

    
    ### 2.29.0
    - Added Vendor Credits CRUD endpoints (`/v1/vendor-credits`)
    
    ### 2.28.0
    - Added `GET /projects/{id}/documents` endpoint — returns project documents with signed download URLs
    
    ### 2.27.0
    
    ### 2.26.0
    - Combined tax code auto-resolution for invoice and PO line items when 2+ document-level taxes are present
    
    ### 2.26.0 — Cost link reverse-echo (additive)
    - `GET /v1/invoices/:id` returns new `cost_links[]` — a unified projection of every consumed cost source (timesheet entries, PO lines, header-level POs, usage records, vendor credits) regardless of `billing_mode` or hidden state. See `InvoiceCostLink`.
    - `GET /v1/purchase-orders/:id`, `GET /v1/timesheets/:id` (per entry), `GET /v1/usage-records/:id`, `GET /v1/vendor-credits/:id` each return optional `linked_invoice` (see `LinkedInvoiceRef`) — most recent invoice that billed the source, or `null`.
    - List endpoints are unchanged. Existing fields, types, and ordering are unchanged.

    ### 2.25.0
    - Added Notes CRUD endpoints for Projects and Work Orders (`/projects/{id}/notes`, `/work-orders/{id}/notes`)
    
    ### 2.24.0
    - Added `is_taxable` and `tax_code_id` fields to PO line items and POBill line items
    
    ### 2.23.0
    - Added `tax_inclusive`, `finalized_at` fields to POBill schema
    - Added `cost_code`, `product_id` fields to POBill line_items
    
    ### 2.22.0
    - **BREAKING**: `tax_inclusion` removed from individual tax line objects. Tax inclusion is now controlled at the document level via `tax_inclusive` (boolean, default `false`) on PO and Invoice root objects.
    
    ## Authentication
    
    ### API Key Authentication
    All REST API endpoints require an API key passed via the `X-API-Key` header.
    API keys are obtained either through the `app.installed` webhook or via OAuth 2.0 token exchange.
    
    ### OAuth 2.0 Authorization Code Flow
    WorkSkedge supports OAuth 2.0 for app installations. Use this flow when users connect from your app.
    
    **Endpoints:**
    - Authorization: `https://app.workskedge.com/v1/oauth-authorize`
    - Token Exchange: `https://app.workskedge.com/v1/oauth-token`
    
    See the OAuth 2.0 section below for detailed flow documentation.
    
    ## Rate Limiting
    Default rate limit: 60 requests per minute per app installation.
    Rate limit headers are included in all responses:
    - `X-RateLimit-Limit`: Maximum requests per window
    - `X-RateLimit-Remaining`: Requests remaining in current window
    - `X-RateLimit-Reset`: Unix timestamp when the window resets
    
    ## Pagination
    List endpoints support offset-based pagination:
    - `limit`: Number of items per page (default: 20, max: 100)
    - `offset`: Number of items to skip (default: 0)
    
    List responses return an array directly in `data` (not wrapped by entity name):
    ```json
    { "data": [...], "meta": { "total": 100, "limit": 20, "offset": 0, "has_more": true } }
    ```
    
    > **Note:** This differs from webhook payloads, which use entity wrappers (e.g., `data.employee`).
    > See the API Integrations Guide for details on response structure differences.
    
    ## Data Isolation
    All data is automatically filtered by the authenticated company context.
    You can only access data for the company that installed your app.

  contact:
    name: WorkSkedge API Support
    email: support@workskedge.com

servers:
  - url: https://app.workskedge.com/api/v1
    description: Production API

security:
  - ApiKeyAuth: []

tags:
  - name: OAuth 2.0
    description: |
      OAuth 2.0 Authorization Code flow for app installations.
      
      ## Endpoints
      - **Authorization**: `https://app.workskedge.com/v1/oauth-authorize`
      - **Token Exchange**: `https://app.workskedge.com/v1/oauth-token`
      
      ## Flow Overview
      1. Redirect user to authorization URL with your `client_id`
      2. User logs in and approves permissions in WorkSkedge
      3. User is redirected to your `redirect_uri` with an authorization `code`
      4. Exchange the `code` for an API key using the token endpoint
      5. Use the returned `access_token` (API key) for all subsequent API calls
      
      ## Authorization Request Parameters
      | Parameter | Required | Description |
      |-----------|----------|-------------|
      | `client_id` | Yes | Your app's Client ID |
      | `redirect_uri` | Yes | Must match a registered redirect URI |
      | `response_type` | Yes | Must be `code` |
      | `scope` | No | Space or comma-separated list of scopes |
      | `state` | Recommended | Random string for CSRF protection |
      
      ## Token Exchange Request
      ```json
      {
        "grant_type": "authorization_code",
        "code": "auth_code_from_callback",
        "redirect_uri": "https://yourapp.com/callback",
        "client_id": "your_client_id",
        "client_secret": "your_client_secret"
      }
      ```
      
      ## Token Response
      ```json
      {
        "access_token": "wsk_...",
        "token_type": "Bearer",
        "scope": "employees:read work_orders:read",
        "company_id": "uuid",
        "app_install_id": "uuid",
        "session_signing_secret": "ws_sess_..."
      }
      ```
      
      The `session_signing_secret` is used to verify JWT session tokens for embedded apps.

  - name: Permission Scopes
    description: |
      ## Overview
      
      API scopes control what data and actions your app can access. When creating an app, you must 
      specify which scopes your app requires. Users will see these permissions when installing your app.
      
      **Request only the scopes your app actually needs** - this builds trust with users and follows 
      the principle of least privilege.
      
      ## Scope Format
      
      Scopes follow the pattern: `{resource}:{action}`
      
      | Action | Description |
      |--------|-------------|
      | `read` | View and list resources |
      | `write` | Create and update resources (implies read) |
      | `delete` | Remove resources (implies write and read) |
      
      ## Scope Inheritance
      
      Higher-level actions automatically include lower-level permissions:
      - `write` implies `read` - if you can update a resource, you can view it
      - `delete` implies `write` and `read` - if you can delete, you can also create/update/view
      
      ## Wildcard Scopes
      
      Use wildcards for broad access (use sparingly):
      - `*:read` - Read access to all resources
      - `*:write` - Write access to all resources
      - `*:*` - Full access to all resources (admin-level)
      
      ## Available Resources
      
      | Resource | Scopes | Description |
      |----------|--------|-------------|
      | `employees` | `employees:read`, `employees:write`, `employees:delete` | Employee records and profiles. **Also covers employee skills/certifications and certificate document upload (`/employees/:id/skills*`).** |
      | `projects` | `projects:read`, `projects:write`, `projects:delete` | Project management |
      | `work_orders` | `work_orders:read`, `work_orders:write`, `work_orders:delete` | Work orders (phases within projects) |
      | `timesheets` | `timesheets:read`, `timesheets:write`, `timesheets:delete` | Daily timesheet records |
      | `timesheet_entries` | `timesheet_entries:read`, `timesheet_entries:write`, `timesheet_entries:delete` | Individual line items on timesheets |
      | `time_off` | `time_off:read`, `time_off:write`, `time_off:delete` | Employee time-off requests |
      | `customers` | `customers:read`, `customers:write`, `customers:delete` | Customer/client records |
      | `locations` | `locations:read`, `locations:write`, `locations:delete` | Customer location/site records |
      | `job_addresses` | `job_addresses:read` | Saved job-site addresses repo (read-only) |
      | `contacts` | `contacts:read`, `contacts:write`, `contacts:delete` | Customer contact records |
      | `vendors` | `vendors:read`, `vendors:write`, `vendors:delete` | Vendor records |
      | `purchase_orders` | `purchase_orders:read`, `purchase_orders:write`, `purchase_orders:delete` | Purchase orders |
      | `invoices` | `invoices:read`, `invoices:write`, `invoices:delete` | Invoice records |
      | `reports` | `reports:read`, `reports:write`, `reports:delete` | Daily and completion reports |
      | `schedules` | `schedules:read` | Employee daily schedule blocks (read-only) |
      | `assignments` | `assignments:read` | Employee work order assignments (read-only) |
      | `custom_bookings` | `custom_bookings:read`, `custom_bookings:write`, `custom_bookings:delete` | Non-work-order time bookings |
      | `settings` | `settings:read`, `settings:write`, `settings:delete` | Company settings, work types, custom fields. **Also grants access to skills.** |
      | `skills` | `skills:read`, `skills:write`, `skills:delete` | Skills & certifications library (also accessible via `settings` scopes) |
      | `products` | `products:read`, `products:write`, `products:delete` | Product price book. **Also grants access to usage_records.** |
      | `usage_records` | `usage_records:read`, `usage_records:write`, `usage_records:delete` | Usage ledger entries (also accessible via `products` scopes) |
      | `equipment` | `equipment:read`, `equipment:write`, `equipment:delete` | Equipment assets, checkouts, service records |
      | `webhooks` | `webhooks:read`, `webhooks:write`, `webhooks:delete` | Webhook subscription management |
      | `app` | `app:read`, `app:write`, `app:delete` | App extensions (metadata) |
      | `credit_cards` | `credit_cards:read`, `credit_cards:write`, `credit_cards:delete` | Company credit/payment cards |
      ## Example Scope Requests
      
      **Timesheet Integration:**
      ```text
      employees:read timesheets:read timesheet_entries:read
      ```
      
      **Full Project Management:**
      ```text
      projects:read projects:write work_orders:read work_orders:write employees:read
      ```
      
      **Accounting Integration:**
      ```text
      customers:read invoices:read invoices:write purchase_orders:read
      ```

  - name: App Authentication
    description: |
      Endpoints for authenticating users in embedded WorkSkedge apps.
      
      ## Session Token Flow (Iframe Mode)
      1. User opens embedded app in WorkSkedge
      2. WorkSkedge calls `apps-generate-session-token` with user's auth
      3. Token is passed to embedded app via URL parameter
      4. App verifies token using Session Signing Secret (self-verification) or API
      5. Token is refreshed every ~50 minutes via postMessage
      
      ## Redirect Mode (Standalone / PWA Apps)
      For apps that run outside an iframe (e.g., standalone PWAs), WorkSkedge supports
      a redirect-based authentication flow. Instead of embedding the app, WorkSkedge
      generates the session JWT and redirects the user's browser to the app with the
      token as a query parameter.
      
      ### Flow
      1. App redirects user to:
         `https://app.workskedge.com/a/apps/catalog/{appId}/embed?redirect_mode=true&redirect_uri=https://yourapp.com/auth/callback&state={csrf_token}`
      2. User authenticates in WorkSkedge (if not already logged in)
      3. WorkSkedge validates `redirect_uri` against the app's registered redirect URIs
      4. WorkSkedge generates the same session JWT used in iframe mode
      5. Browser redirects to: `{redirect_uri}?token={jwt}&state={state}`
      
      ### Query Parameters
      | Parameter | Required | Description |
      |-----------|----------|-------------|
      | `redirect_mode` | Yes | Must be `true` |
      | `redirect_uri` | Yes | Must match a registered redirect URI in `apps_catalog` |
      | `state` | Recommended | Opaque value passed through for CSRF protection |
      
      ### Security
      - `redirect_uri` must exactly match one of the app's registered redirect URIs
      - The JWT payload and signing are identical to the iframe flow (HS256, 1-hour expiry)
      - The `state` parameter is passed through unchanged
      
      ### Setup
      Register your callback URL as an allowed redirect URI for your app. Contact your
      WorkSkedge admin or update the `redirect_uris` array in the app catalog entry.

  - name: Employees
    description: |
      Manage employee records.
      
      ## Example Employee Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "name": "John Smith",
        "email": "john.smith@company.com",
        "phone": "555-123-4567",
        "emp_number": "EMP-001",
        "is_active": true,
        "start_date": "2023-01-15",
        "birthday": "1985-06-20",
        "scheduling_color": "#4A90D9",
        "address": {
          "street": "123 Main St",
          "city": "Dallas",
          "state": "TX",
          "postal_code": "75201"
        },
        "emergency_contact_name": "Jane Smith",
        "emergency_phone_1": "555-987-6543",
        "emergency_contact_relationship": "Spouse",
        "role": {
          "id": "550e8400-e29b-41d4-a716-446655440010",
          "name": "Project Manager"
        },
        "department": {
          "id": "550e8400-e29b-41d4-a716-446655440011",
          "name": "Operations"
        },
        "skills": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440012",
            "skill": {
              "id": "550e8400-e29b-41d4-a716-446655440013",
              "skill_name": "Electrical",
              "skill_category": "trade"
            },
            "is_verified": true,
            "expiry_date": "2026-01-15"
          }
        ],
        "app_metadata": {
          "my-app": {
            "display": { "type": "badge", "label": "Synced", "intent": "success" }
          }
        },
        "created_at": "2023-01-15T08:00:00Z",
        "updated_at": "2025-01-20T14:30:00Z"
      }
      ```

  - name: Time Off
    description: |
      Manage employee time-off records (vacation, sick leave, etc.).
      
      ## Example Time Off Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "employee_id": "550e8400-e29b-41d4-a716-446655440002",
        "time_off_type": "vacation",
        "status": "approved",
        "start_date": "2025-02-01",
        "end_date": "2025-02-05",
        "start_time": null,
        "end_time": null,
        "notes": "Family vacation",
        "employee": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "John Smith",
          "email": "john@company.com"
        },
        "created_at": "2025-01-10T08:00:00Z",
        "updated_at": "2025-01-10T08:00:00Z"
      }
      ```
      
      **Time Off Types:** `vacation`, `sick`, `personal`, `bereavement`, `jury_duty`, `military`, `unpaid`, `other`
      
      **Statuses:** `pending`, `approved`, `denied`, `cancelled`

  - name: Projects
    description: |
      Manage projects.
      
      ## Example Project Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "project_name": "Downtown Office Renovation",
        "project_code": "PRJ-2025-001",
        "customer_id": "550e8400-e29b-41d4-a716-446655440002",
        "location_id": "550e8400-e29b-41d4-a716-446655440003",
        "status": "active",
        "priority": "normal",
        "description": "Complete renovation of floors 3-5",
        "color_index": 2,
        "custom_fields": {
          "po_number": "PO-12345",
          "contract_value": 150000
        },
        "job_lost_reason": null,
        "estimator_employee_id": "550e8400-e29b-41d4-a716-446655440009",
        "estimation_due_date": "2025-02-15",
        "closed_at": null,
        "customer": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "ACME Corporation",
          "email": "info@acme.com",
          "phone": "555-100-2000"
        },
        "location": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "name": "Main Office",
          "address_line_1": "123 Main St",
          "address_line_2": "Suite 500",
          "city": "Dallas",
          "province": "TX",
          "postal_code": "75201",
          "country": "USA"
        },
        "estimator": {
          "id": "550e8400-e29b-41d4-a716-446655440009",
          "name": "Mike Johnson",
          "email": "mike@company.com"
        },
        "project_owners": [
          {
            "display_order": 1,
            "employee": {
              "id": "550e8400-e29b-41d4-a716-446655440004",
              "name": "John Smith",
              "email": "john@company.com"
            }
          }
        ],
        "project_contacts": [
          {
            "is_primary": true,
            "contact": {
              "id": "550e8400-e29b-41d4-a716-446655440005",
              "first_name": "Jane",
              "last_name": "Doe",
              "email": "jane@acme.com",
              "phone": "555-1234",
              "mobile": "555-5678",
              "title": "Facilities Manager"
            }
          }
        ],
        "app_metadata": {
          "my-app": {
            "display": { "type": "badge", "label": "Synced", "intent": "success" }
          }
        },
        "created_at": "2025-01-15T08:00:00Z",
        "updated_at": "2025-01-20T14:30:00Z"
      }
      ```
      
      **Statuses:** Depend on company's `project_status_mode`:
      - **Simple mode:** `active`, `closed`
      - **Advanced mode:** `lead`, `estimating`, `pending_approval`, `approved`, `active`, `complete`, `closed`
      
      **Priorities:** `low`, `normal`, `high`, `urgent`

  - name: Work Orders
    description: |
      Manage work orders (project phases).
      
      ## Example Work Order Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "project_id": "550e8400-e29b-41d4-a716-446655440002",
        "phase_name": "Electrical Installation",
        "work_order_number": "WO-2025-001",
        "status": "in_progress",
        "priority": "high",
        "display_order": 1,
        "start_date": "2025-02-01",
        "end_date": "2025-02-15",
        "start_time": "07:00",
        "end_time": "16:00",
        "expected_start_date": "2025-02-01",
        "expected_end_date": "2025-02-15",
        "notes": "Phase 1 electrical work",
        "description": "Install electrical wiring for floors 3-5",
        "important_note": "High voltage - safety gear required",
        "important_note_images": [],
        "people_required": 4,
        "days_hours_required": "10 days",
        "require_daily_report": true,
        "require_completion_report": true,
        "scope_of_work": {
          "checklists": [
            {
              "id": "c1a2b3c4-d5e6-7890-abcd-ef1234567890",
              "name": "Electrical Tasks",
              "is_table_view": false,
              "tasks": [
                { "id": "t1a2b3c4-0001-0000-0000-000000000001", "text": "Install main panel", "completed": true, "completed_at": "2025-02-10T14:30:00Z" },
                { "id": "t1a2b3c4-0001-0000-0000-000000000002", "text": "Run conduit", "completed": false }
              ]
            }
          ]
        },
        "progress_cache": {
          "percent_complete": 45,
          "last_updated": "2025-02-10T14:30:00Z"
        },
        "custom_fields": {
          "permit_number": "ELEC-2025-123"
        },
        "site_lead": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "name": "Mike Johnson",
          "email": "mike@company.com"
        },
        "project": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "project_name": "Downtown Office Renovation",
          "project_code": "PRJ-2025-001"
        },
        "customer": {
          "id": "550e8400-e29b-41d4-a716-446655440004",
          "name": "ACME Corporation"
        },
        "location_id": "550e8400-e29b-41d4-a716-446655440005",
        "site_address": {
          "address_line_1": "123 Main St",
          "city": "Dallas",
          "province": "TX",
          "gps_coordinates": "32.776700, -96.797000"
        },
        "location": {
          "id": "550e8400-e29b-41d4-a716-446655440005",
          "name": "Main Office",
          "address_line_1": "123 Main St",
          "city": "Dallas",
          "province": "TX"
        },
        "assignments": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440006",
            "assigned_start_date": "2025-02-01",
            "assigned_end_date": "2025-02-15",
            "employee": {
              "id": "550e8400-e29b-41d4-a716-446655440007",
              "name": "John Smith"
            }
          }
        ],
        "app_metadata": {
          "my-app": {
            "display": { "type": "text", "label": "Job #12345" }
          }
        },
        "created_at": "2025-01-20T08:00:00Z",
        "updated_at": "2025-01-25T14:30:00Z"
      }
      ```
      
      **Statuses:** `created`, `draft`, `ready`, `assigned`, `active`, `on-hold`, `complete-pending`, `complete`, `closed`, `cancelled`
      
      **Priorities:** `low`, `normal`, `high`, `urgent`

  - name: Assignments
    description: |
      Manage employee assignments to work orders.
      
      ## Example Assignment Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "employee_id": "550e8400-e29b-41d4-a716-446655440002",
        "phase_id": "550e8400-e29b-41d4-a716-446655440003",
        "project_id": "550e8400-e29b-41d4-a716-446655440004",
        "assigned_start_date": "2025-02-01",
        "assigned_end_date": "2025-02-15",
        "employee": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "John Smith",
          "email": "john@company.com",
          "phone": "555-123-4567"
        },
        "work_order": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "phase_name": "Electrical Installation",
          "work_order_number": "WO-2025-001"
        },
        "project": {
          "id": "550e8400-e29b-41d4-a716-446655440004",
          "project_name": "Downtown Office Renovation",
          "project_code": "PRJ-2025-001"
        },
        "created_at": "2025-01-25T08:00:00Z",
        "updated_at": "2025-01-25T08:00:00Z"
      }
      ```

  - name: Timesheets
    description: |
      Manage timesheet entries.
      
      ## Example Timesheet Entry Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "timesheet_id": "550e8400-e29b-41d4-a716-446655440002",
        "work_order_id": "550e8400-e29b-41d4-a716-446655440003",
        "work_type_id": "550e8400-e29b-41d4-a716-446655440004",
        "hours": 8.0,
        "start_time": "07:00",
        "end_time": "15:30",
        "notes": "Completed wiring on floor 3",
        "is_break": false,
        "timesheet": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "timesheet_date": "2025-01-20",
          "status": "submitted",
          "total_hours": 8.0,
          "employee": {
            "id": "550e8400-e29b-41d4-a716-446655440005",
            "name": "John Smith",
            "email": "john@company.com",
            "emp_number": "EMP-001"
          }
        },
        "work_type": {
          "id": "550e8400-e29b-41d4-a716-446655440004",
          "name": "Regular Hours",
          "short_code": "REG",
          "multiplier": 1.0,
          "is_billable": true
        },
        "work_order": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "phase_name": "Electrical Installation",
          "work_order_number": "WO-2025-001",
          "project": {
            "id": "550e8400-e29b-41d4-a716-446655440006",
            "project_name": "Downtown Office Renovation",
            "project_code": "PRJ-2025-001"
          }
        },
        "created_at": "2025-01-20T17:00:00Z",
        "updated_at": "2025-01-20T17:00:00Z"
      }
      ```
      
      **Timesheet Statuses:** `draft`, `submitted`, `locked`, `processed`

  - name: Customers
    description: |
      Manage customer records.
      
      ## Example Customer Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "name": "ACME Corporation",
        "email": "info@acme.com",
        "phone": "555-100-2000",
        "website": "https://acme.com",
        "status": "active",
        "type": "commercial",
        "notes": "Preferred customer since 2020",
        "custom_fields": {
          "industry": "Manufacturing",
          "account_tier": "Premium"
        },
        "account_manager": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "Sarah Johnson",
          "email": "sarah@company.com"
        },
        "locations": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440003",
            "name": "Headquarters",
            "address_line_1": "123 Main St",
            "city": "Dallas",
            "province": "TX",
            "postal_code": "75201"
          },
          {
            "id": "550e8400-e29b-41d4-a716-446655440004",
            "name": "Warehouse",
            "address_line_1": "456 Industrial Blvd",
            "city": "Dallas",
            "province": "TX",
            "postal_code": "75202"
          }
        ],
        "contacts": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440005",
            "first_name": "Jane",
            "last_name": "Doe",
            "email": "jane@acme.com",
            "phone": "555-1234",
            "mobile": "555-5678",
            "title": "Facilities Manager",
            "is_primary": true
          }
        ],
        "tax_exemptions": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440010",
            "exemption_type": "resale",
            "jurisdiction_level": "regional",
            "jurisdiction_name": "British Columbia",
            "id_number": "PST-1234-5678",
            "certificate_name": "cert_bc.pdf",
            "expires_at": "2026-12-31",
            "status": "active",
            "notes": null
          }
        ],
        "app_metadata": {
          "my-app": {
            "display": { "type": "badge", "label": "VIP", "intent": "success" }
          }
        },
        "created_at": "2020-05-10T08:00:00Z",
        "updated_at": "2025-01-15T14:30:00Z"
      }
      ```
      
      **Statuses:** `active`, `inactive`

  - name: Locations
    description: |
      Manage customer locations.
      
      ## Example Location Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "customer_id": "550e8400-e29b-41d4-a716-446655440002",
        "name": "Headquarters",
        "address_line_1": "123 Main St",
        "address_line_2": "Suite 500",
        "city": "Dallas",
        "province": "TX",
        "postal_code": "75201",
        "country": "USA",
        "notes": "Main entrance on Oak Street",
        "customer": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "ACME Corporation"
        },
        "default_contact": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "first_name": "Jane",
          "last_name": "Doe",
          "email": "jane@acme.com"
        },
        "created_at": "2020-05-10T08:00:00Z",
        "updated_at": "2025-01-15T14:30:00Z"
      }
      ```

  - name: Contacts
    description: |
      Manage customer contacts.
      
      ## Example Contact Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "customer_id": "550e8400-e29b-41d4-a716-446655440002",
        "location_id": "550e8400-e29b-41d4-a716-446655440003",
        "first_name": "Jane",
        "last_name": "Doe",
        "email": "jane@acme.com",
        "phone": "555-1234",
        "phone_ext": "101",
        "mobile": "555-5678",
        "title": "Facilities Manager",
        "is_primary": true,
        "contact_tags": ["decision-maker", "billing"],
        "notes": "Prefers email communication",
        "customer": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "ACME Corporation"
        },
        "location": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "name": "Headquarters"
        },
        "created_at": "2020-05-10T08:00:00Z",
        "updated_at": "2025-01-15T14:30:00Z"
      }
      ```

  - name: Vendors
    description: |
      Manage vendors and subcontractors.
      
      ## Example Vendor Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "name": "ABC Electrical Supply",
        "email": "orders@abcelectrical.com",
        "phone": "555-200-3000",
        "website": "https://abcelectrical.com",
        "status": "active",
        "notes": "Preferred electrical supplier",
        "address": {
          "street": "789 Supplier Rd",
          "city": "Houston",
          "state": "TX",
          "postal_code": "77001"
        },
        "contact_name": "Bob Wilson",
        "contact_email": "bob@abcelectrical.com",
        "contact_phone": "555-200-3001",
        "payment_terms": "Net 30",
        "tax_id": "12-3456789",
        "app_metadata": {
          "my-app": {
            "display": { "type": "badge", "label": "Preferred", "intent": "success" }
          }
        },
        "created_at": "2021-03-15T08:00:00Z",
        "updated_at": "2025-01-10T14:30:00Z"
      }
      ```
      
      **Statuses:** `active`, `inactive`

  - name: Webhooks
    description: |
      Manage webhook subscriptions for real-time event notifications.
      
      ## Example Webhook Subscription Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "target_url": "https://yourapp.com/webhooks/workskedge",
        "event_types": [
          "work_order.created",
          "work_order.updated",
          "assignment.created"
        ],
        "secret": "whsec_abc123...",
        "is_active": true,
        "created_at": "2025-01-01T08:00:00Z",
        "updated_at": "2025-01-01T08:00:00Z"
      }
      ```
      
      **Available Event Types:**
      - `project.created`, `project.updated`, `project.deleted`
      - `work_order.created`, `work_order.updated`, `work_order.deleted`
      - `assignment.created`, `assignment.updated`, `assignment.deleted`
      - `employee.created`, `employee.updated`, `employee.deleted`
      - `customer.created`, `customer.updated`, `customer.deleted`
      - `timesheet.submitted`, `timesheet.updated`
      - `daily_report.created`, `completion_report.created`
      - `settings.updated`

  - name: Reports
    description: |
      Manage daily and completion reports for work orders.
      
      ## Example Daily Report Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "work_order_id": "550e8400-e29b-41d4-a716-446655440002",
        "report_date": "2025-01-20",
        "weather": "Sunny, 75°F",
        "crew_size": 4,
        "work_performed": "Completed electrical rough-in for floors 3-4",
        "materials_used": "500ft 12/2 Romex, 20 junction boxes",
        "delays": null,
        "safety_incidents": null,
        "notes": "On schedule for completion",
        "photos": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440003",
            "url": "https://storage.workskedge.com/photos/abc123.jpg",
            "caption": "Floor 3 panel installation"
          }
        ],
        "submitted_by": {
          "id": "550e8400-e29b-41d4-a716-446655440004",
          "name": "John Smith"
        },
        "work_order": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "phase_name": "Electrical Installation",
          "work_order_number": "WO-2025-001"
        },
        "created_at": "2025-01-20T17:00:00Z",
        "updated_at": "2025-01-20T17:00:00Z"
      }
      ```
      
      ## Example Completion Report Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "work_order_id": "550e8400-e29b-41d4-a716-446655440002",
        "completion_date": "2025-02-15",
        "summary": "All electrical work completed and inspected",
        "punch_list_items": [],
        "customer_signature": true,
        "customer_signed_at": "2025-02-15T14:30:00Z",
        "customer_signed_by": "Jane Doe",
        "photos": [...],
        "checklist": [
          { "item": "All circuits tested", "completed": true },
          { "item": "Panel labeled", "completed": true },
          { "item": "Cleanup complete", "completed": true }
        ],
        "submitted_by": {
          "id": "550e8400-e29b-41d4-a716-446655440004",
          "name": "John Smith"
        },
        "approved_by": {
          "id": "550e8400-e29b-41d4-a716-446655440005",
          "name": "Mike Johnson"
        },
        "approved_at": "2025-02-15T16:00:00Z",
        "work_order": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "phase_name": "Electrical Installation"
        },
        "created_at": "2025-02-15T14:00:00Z",
        "updated_at": "2025-02-15T16:00:00Z"
      }
      ```

  - name: Schedules
    description: |
      **Read-only API** for daily work schedule blocks.
      
      Schedules represent specific daily work blocks within an assignment (e.g., Jan 15, 8am-5pm).
      They are managed through the WorkSkedge UI due to complex business logic around assignments,
      time-off, and calendar synchronization.
      
      ## Example Schedule Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "assignment_id": "550e8400-e29b-41d4-a716-446655440002",
        "work_order_id": "550e8400-e29b-41d4-a716-446655440003",
        "work_date": "2025-01-20",
        "start_time": "07:00",
        "end_time": "16:00",
        "is_override": false,
        "is_active": true,
        "notes": "Report to main entrance",
        "app_metadata": {
          "my-app": {
            "display": { "type": "badge", "label": "Safety Required", "intent": "warning" }
          }
        },
        "employee": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "John Smith",
          "email": "john@company.com"
        },
        "work_order": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "phase_name": "Electrical Installation",
          "work_order_number": "WO-2025-001",
          "project": {
            "id": "550e8400-e29b-41d4-a716-446655440004",
            "project_name": "Downtown Office Renovation"
          }
        },
        "created_at": "2025-01-15T08:00:00Z",
        "updated_at": "2025-01-15T08:00:00Z"
      }
      ```

  - name: Assignments
    description: |
      **Read-only API** for employee work order assignments.
      
      Assignments represent the high-level record of an employee's duration on a work order 
      (e.g., Jan 15-20). They are managed through the WorkSkedge UI due to complex business 
      logic around scheduling, notifications, and calendar sync.
      
      ## Example Assignment Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "employee_id": "550e8400-e29b-41d4-a716-446655440002",
        "work_order_id": "550e8400-e29b-41d4-a716-446655440003",
        "assigned_start_date": "2025-02-01",
        "assigned_end_date": "2025-02-15",
        "employee": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "John Smith",
          "email": "john@company.com",
          "emp_number": "EMP-001",
          "phone": "555-123-4567"
        },
        "work_order": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "phase_name": "Electrical Installation",
          "work_order_number": "WO-2025-001",
          "status": "active",
          "start_date": "2025-02-01",
          "end_date": "2025-02-15",
          "project": {
            "id": "550e8400-e29b-41d4-a716-446655440004",
            "project_name": "Downtown Office Renovation",
            "project_code": "PRJ-2025-001"
          }
        },
        "created_at": "2025-01-25T08:00:00Z",
        "updated_at": "2025-01-25T08:00:00Z"
      }
      ```

  - name: Custom Bookings
    description: |
      Manage non-work-order time bookings for employees (training, meetings, etc.).
      
      Custom bookings support full CRUD operations and batch creation for group events.
      Unlike schedules and assignments, apps can create, update, and delete custom bookings.
      
      ## Example Custom Booking Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "employee_id": "550e8400-e29b-41d4-a716-446655440002",
        "booking_date": "2025-01-20",
        "name": "Safety Training",
        "description": "Mandatory quarterly safety refresher",
        "start_time": "09:00",
        "end_time": "12:00",
        "work_type_id": "550e8400-e29b-41d4-a716-446655440003",
        "is_active": true,
        "external_id": "training-2025-q1",
        "employee": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "John Smith",
          "email": "john@company.com",
          "emp_number": "EMP-001"
        },
        "work_type": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "name": "Training",
          "short_code": "TRN"
        },
        "created_at": "2025-01-15T08:00:00Z",
        "updated_at": "2025-01-15T08:00:00Z"
      }
      ```

  - name: Purchase Orders
    description: |
      Manage purchase orders and procurement workflows.
      
      ## Example Purchase Order Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "po_number": "PO-2025-001",
        "vendor_id": "550e8400-e29b-41d4-a716-446655440002",
        "project_id": "550e8400-e29b-41d4-a716-446655440003",
        "work_order_id": "550e8400-e29b-41d4-a716-446655440004",
        "status": "approved",
        "order_date": "2025-01-15",
        "expected_delivery_date": "2025-01-25",
        "subtotal": 5000.00,
        "tax_amount": 412.50,
        "total_amount": 5412.50,
        "budget_amount": 6000.00,
        "not_to_exceed_amount": 6500.00,
        "notes": "Rush delivery requested",
        "internal_notes": "Approved by PM",
        "vendor": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "ABC Electrical Supply"
        },
        "project": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "project_name": "Downtown Office Renovation",
          "project_code": "PRJ-2025-001"
        },
        "work_order": {
          "id": "550e8400-e29b-41d4-a716-446655440004",
          "phase_name": "Electrical Installation"
        },
        "line_items": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440005",
            "description": "12/2 Romex Wire (500ft)",
            "quantity": 10,
            "unit": "roll",
            "unit_price": 250.00,
            "line_total": 2500.00,
            "max_amount": 300.00
          },
          {
            "id": "550e8400-e29b-41d4-a716-446655440006",
            "description": "Junction Box",
            "quantity": 50,
            "unit": "each",
            "unit_price": 50.00,
            "line_total": 2500.00
          }
        ],
        "created_by": {
          "id": "550e8400-e29b-41d4-a716-446655440007",
          "name": "John Smith"
        },
        "approved_by": {
          "id": "550e8400-e29b-41d4-a716-446655440008",
          "name": "Mike Johnson"
        },
        "approved_at": "2025-01-15T10:00:00Z",
        "created_at": "2025-01-15T08:00:00Z",
        "updated_at": "2025-01-15T10:00:00Z"
      }
      ```
      
      **Statuses:** `draft`, `pending_approval`, `approved`, `sent`, `received`, `cancelled`

  - name: Invoices
    description: |
      Manage invoices for customers. Supports standard, progress, final, credit, and retainage invoice types.
      Track payments, send invoices, and manage invoice status lifecycle.
      
      ## Example Invoice Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "invoice_number": "INV-2025-001",
        "invoice_type": "standard",
        "customer_id": "550e8400-e29b-41d4-a716-446655440002",
        "project_id": "550e8400-e29b-41d4-a716-446655440003",
        "work_order_id": "550e8400-e29b-41d4-a716-446655440004",
        "status": "sent",
        "payment_status": "partially_paid",
        "invoice_date": "2025-01-15",
        "due_date": "2025-02-14",
        "subtotal": 10000.00,
        "tax_rate": 8.25,
        "tax_amount": 825.00,
        "total_amount": 10825.00,
        "amount_paid": 5000.00,
        "balance_due": 5825.00,
        "payment_terms": "Net 30",
        "notes": "Thank you for your business",
        "internal_notes": "Customer requested extended terms",
        "terms_and_conditions": "Payment due within 30 days...",
        "revision_count": 0,
        "sent_at": "2025-01-15T10:00:00Z",
        "viewed_at": "2025-01-15T14:30:00Z",
        "customer": {
          "id": "550e8400-e29b-41d4-a716-446655440002",
          "name": "ACME Corporation"
        },
        "project": {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "project_name": "Downtown Office Renovation",
          "project_code": "PRJ-2025-001"
        },
        "work_order": {
          "id": "550e8400-e29b-41d4-a716-446655440004",
          "phase_name": "Electrical Installation"
        },
        "line_items": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440005",
            "description": "Electrical Installation - Labor",
            "quantity": 80,
            "unit": "hours",
            "unit_price": 75.00,
            "line_total": 6000.00,
            "category": "Labor"
          },
          {
            "id": "550e8400-e29b-41d4-a716-446655440006",
            "description": "Electrical Materials",
            "quantity": 1,
            "unit": "lot",
            "unit_price": 4000.00,
            "line_total": 4000.00,
            "category": "Materials"
          }
        ],
        "payments": [
          {
            "id": "550e8400-e29b-41d4-a716-446655440007",
            "amount": 5000.00,
            "paid_at": "2025-01-20T14:00:00Z",
            "payment_method": "check",
            "reference_number": "CHK-12345"
          }
        ],
        "created_at": "2025-01-15T08:00:00Z",
        "updated_at": "2025-01-20T14:00:00Z"
      }
      ```
      
      **Invoice Statuses:** `draft`, `sent`, `viewed`, `partial`, `paid`, `void`
      
      **Invoice Types:** `standard`, `progress`, `final`, `credit`, `retainage`
      
      **Payment Statuses:** `unpaid`, `partially_paid`, `paid`, `overpaid`

  - name: Credit Cards
    description: |
      Manage company credit/payment cards for purchase order tracking.
      
      ## Example Credit Card Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "card_name": "Company Visa",
        "card_type": "visa",
        "external_id": "card_abc123",
        "is_active": true,
        "is_default": true,
        "created_at": "2025-01-15T08:00:00Z",
        "updated_at": "2025-01-20T14:30:00Z"
      }
      ```
      
      **Card Types:** `visa`, `mastercard`, `amex`, `discover`, `other`

  - name: Settings
    description: |
      Endpoints for managing company settings, configuration, work types, finance settings, custom fields, and skills.
      
       **Company Settings** (`/company-settings`) - Business profile, operational settings,
      timezone, work hours, timesheet configuration, feature modules, roles, and departments.
      
      **Work Types** (`/work-types`) - Categories of labor (e.g., Standard Labor, Overtime, Vacation)
      used in timesheets. Each work type has properties like billability, multiplier, and
      whether it requires a work order reference.
      
      **Finance Settings** (`/finance-settings`) - Tax rates, purchase order 
      defaults, contract terms, invoice settings, cost codes, branding, and
      product pricing configuration. Supports read (GET) and update (PATCH) operations.
      
      **Custom Fields** (`/custom-fields`) - Manage custom field definitions for
      projects and work orders. Apps can create and delete their own custom fields.
      
      **Skills** (`/skills`) - Manage company-level skill and certification definitions.
      Skills can be assigned to employees with optional certification details (license numbers,
      expiry dates, issuing authorities). Apps can only update/delete skills they created.
      
      ## Example Company Settings Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "name": "Smith Construction LLC",
        "timezone": "America/Chicago",
        "industry": "construction",
        "employee_count": 25,
        "address": {
          "street": "100 Company Way",
          "city": "Dallas",
          "state": "TX",
          "postal_code": "75201"
        },
        "contact_info": {
          "phone": "555-100-1000",
          "email": "info@smithconstruction.com"
        },
        "modules_enabled": {
          "core": true,
          "workTracking": true,
          "timesheets": true,
          "finances": true
        },
        "default_work_hours": {
          "start": "07:00",
          "end": "16:00"
        },
        "expected_daily_hours": 8,
        "expected_weekly_hours": 40,
        "project_status_mode": "standard"
      }
      ```
      
      ## Example Work Type Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "name": "Regular Hours",
        "short_code": "REG",
        "multiplier": 1.0,
        "is_billable": true,
        "is_active": true,
        "requires_work_order": true,
        "created_at": "2023-01-01T00:00:00Z",
        "updated_at": "2023-01-01T00:00:00Z"
      }
      ```
      
      ## Example Finance Settings Object
      ```json
      {
        "tax_rates": [
          { "name": "State Tax", "rate": 0.0625 },
          { "name": "City Tax", "rate": 0.02 }
        ],
        "purchase_orders": {
          "po_number_prefix": "PO-",
          "po_number_padding": 5,
          "po_number_next_sequence": 42,
          "require_approval_threshold": 1000.00,
          "allow_field_employees_create": false,
          "require_budget_on_quick_po": true
        },
        "terms": {
          "payment_due": "net_30",
          "payment_terms": "Net 30 days from invoice date",
          "terms_conditions": "Standard terms and conditions apply."
        },
        "invoices": {
          "default_payment_due": "net_30",
          "default_notes": "Thank you for your business",
          "header_text": "INVOICE",
          "footer_text": "Payment details and instructions",
          "cc_reply_to_email": "billing@example.com"
        },
        "cost_codes": [
          { "id": "550e8400-e29b-41d4-a716-446655440001", "code": "MATERIALS", "description": "Material and supply costs", "is_active": true, "display_order": 1 },
          { "id": "550e8400-e29b-41d4-a716-446655440002", "code": "LABOR", "description": "Labor and personnel costs", "is_active": true, "display_order": 2 }
        ],
        "branding": {
          "show_logo_on_invoices": true,
          "logo_url": "https://storage.example.com/logos/company-logo.png"
        },
        "pricing": {
          "default_product_margin_pct": 25.0
        }
      }
      ```
      
      ## Example Custom Field Object
      ```json
      {
        "id": "550e8400-e29b-41d4-a716-446655440001",
        "company_id": "550e8400-e29b-41d4-a716-446655440000",
        "field_name": "External Reference",
        "field_key": "external_reference",
        "field_type": "text",
        "applies_to": "project",
        "dropdown_options": [],
        "is_required": false,
        "display_order": 1,
        "app_install_id": "550e8400-e29b-41d4-a716-446655440002",
        "created_at": "2025-01-05T00:00:00Z",
        "updated_at": "2025-01-05T00:00:00Z"
      }
      ```

  - name: App Metadata
    description: |
      Push contextual data to WorkSkedge entities that can be displayed in the UI.
      
      App Metadata allows your app to attach structured data to projects, work orders,
      employees, customers, vendors, invoices, and purchase orders. This data can include 
      display information (badges, text, icons) and interactive triggers.
      
      **Required Scopes:**
      - `app:read` - Read metadata on entities (included when fetching entities)
      - `app:write` - Create or update metadata
      - `app:delete` - Remove metadata from entities
      
      ## Namespace Protection
      - Each app can only write to its own namespace (based on app name slug)
      - Apps with `app:read` scope can read ALL app metadata on entities
      - This enables cross-app data sharing while preventing overwrites
      
      ## Display Types
      - `badge`: Colored badge with label (e.g., status indicators)
      - `text`: Plain text with optional icon
      - `icon`: Icon-only display
      
      ## Trigger Types
      - `modal`: Opens your app in a modal dialog
      - `navigate`: Opens your app in a new tab
      - `external`: Opens an external URL
      
      ## Supported Entity Types
      `project`, `work_order`, `employee`, `customer`, `vendor`

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: API key generated for your app installation

  schemas:
    # Note schemas
    Note:
      type: object
      properties:
        id:
          type: string
          format: uuid
        note:
          type: string
          description: The note text content
        created_at:
          type: string
          format: date-time
        created_by:
          type: object
          properties:
            id:
              type: string
              format: uuid
              nullable: true
            name:
              type: string
              description: Display name of the author, or "API" if created via API

    NoteCreate:
      type: object
      required: [note]
      properties:
        note:
          type: string
          minLength: 1
          maxLength: 5000
          description: The note text (1-5000 characters)

    NoteUpdate:
      type: object
      properties:
        note:
          type: string
          minLength: 1
          maxLength: 5000
          description: Updated note text

    # Common schemas
    PaginationMeta:
      type: object
      properties:
        total:
          type: integer
          description: Total number of items
        limit:
          type: integer
          description: Items per page
        offset:
          type: integer
          description: Items skipped
        has_more:
          type: boolean
          description: True if more pages exist beyond current offset
      required: [total, limit, offset, has_more]

    AccountDocument:
      type: object
      description: |
        A compliance document attached to a customer or vendor (insurance, COI, W9, license,
        subcontract declaration, etc.). Returned by `GET /customers/{id}/documents` and
        `GET /vendors/{id}/documents`. When backed by a file, `url` is a signed download URL
        valid for `expires_in` seconds (default 3600).
      properties:
        id: { type: string, format: uuid }
        document_type:
          type: string
          description: |
            Customer: insurance, w9, license, contract, subcontract_declaration, tax_exempt, compliance, other.
            Vendor: insurance, w9, license, contract, subcontract_declaration, wsib, prequalification, compliance, other.
        document_name: { type: string, nullable: true }
        issuer: { type: string, nullable: true }
        reference_number: { type: string, nullable: true }
        issued_date: { type: string, format: date, nullable: true }
        expiry_date: { type: string, format: date, nullable: true }
        notes: { type: string, nullable: true }
        file_name: { type: string, nullable: true }
        file_path: { type: string, nullable: true, description: Storage object path }
        file_size: { type: integer, nullable: true }
        mime_type: { type: string, nullable: true }
        url:
          type: string
          format: uri
          nullable: true
          description: Signed download URL (when a file is attached)
        expires_in:
          type: integer
          nullable: true
          description: URL validity in seconds (default 3600)
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # App Metadata schemas
    MetadataDisplay:
      type: object
      properties:
        type:
          type: string
          enum: [badge, text, icon]
        label:
          type: string
          maxLength: 100
        intent:
          type: string
          enum: [none, success, warning, danger]
        icon:
          type: string
      required: [type]

    MetadataTrigger:
      type: object
      properties:
        type:
          type: string
          enum: [modal, navigate, external]
        target:
          type: string
          description: Path or URL to open when triggered
      required: [type, target]

    MetadataPayload:
      type: object
      properties:
        display:
          $ref: '#/components/schemas/MetadataDisplay'
        trigger:
          $ref: '#/components/schemas/MetadataTrigger'
        context:
          type: object
          description: Arbitrary context data passed to triggers (max ~5KB)
        creates_task:
          type: boolean
          default: false
          description: |
            If true, creates a task in the site lead's To-Do widget.
            Only supported for the 'schedules' entity type.
            Tasks only appear for today's date or past dates - future schedules will not show tasks.
        task_label:
          type: string
          maxLength: 50
          description: |
            Custom label for the task (max 50 characters).
            If not provided, defaults to display.label.
            Only used when creates_task is true.

    MetadataBatchItem:
      type: object
      properties:
        entity_type:
          type: string
          enum: [projects, work_orders, employees, customers, vendors, invoices, purchase_orders, schedules]
        entity_id:
          type: string
          format: uuid
        metadata:
          $ref: '#/components/schemas/MetadataPayload'
      required: [entity_type, entity_id, metadata]

    MetadataBatch:
      type: object
      properties:
        items:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/MetadataBatchItem'
      required: [items]

    AppMetadata:
      type: object
      description: App-injected metadata keyed by app slug
      additionalProperties:
        $ref: '#/components/schemas/MetadataPayload'

    # Product schemas
    Product:
      type: object
      properties:
        id: { type: string, format: uuid }
        company_id: { type: string, format: uuid }
        name: { type: string }
        type: { type: string, enum: [material, equipment, labor, service], description: "Product kind. `service` = resold offerings (packages, $/sq ft builds, rental services). Services behave like materials for billing and linked-cost rollup, and render in the **Services** built-in invoice section." }
        sku: { type: string, nullable: true }
        base_cost: { type: number }
        unit_of_measure: { type: string }
        flat_resale_override: { type: number, nullable: true }
        item_margin_pct: { type: number, nullable: true }
        category_id: { type: string, format: uuid, nullable: true }
        category:
          type: object
          nullable: true
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            margin_pct: { type: number, nullable: true }
        description: { type: string, nullable: true }
        tax_category: { type: string, nullable: true }
        min_billing_increment: { type: number, nullable: true }
        internal_overhead_rate: { type: number, nullable: true }
        is_active: { type: boolean }
        is_billable: { type: boolean, description: "When false, usage records from this product are auto-excluded from invoice totals" }
        preferred_vendor_id: { type: string, format: uuid, nullable: true, description: "UUID of the preferred vendor for this product" }
        preferred_vendor:
          type: object
          nullable: true
          properties:
            id: { type: string, format: uuid }
            vendor_name: { type: string }
        burden_pct: { type: number, nullable: true, description: "Burden percentage added to base_cost for true employer cost (labor only)" }
        is_role_default: { type: boolean, description: "When true, this product serves as the default rate for all employees in the linked role" }
        role_id: { type: string, format: uuid, nullable: true, description: "Role this product is linked to (labor only)" }
        employee_id: { type: string, format: uuid, nullable: true, description: "Employee this labor rate is assigned to" }
        metadata: { type: object }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProductCreate:
      type: object
      required: [name, type, base_cost, unit_of_measure]
      properties:
        name: { type: string, maxLength: 255 }
        type: { type: string, enum: [material, equipment, labor, service], description: "Product kind. Use `service` for resold offerings (packages, $/sq ft builds, rental services)." }
        sku: { type: string, maxLength: 100, nullable: true }
        base_cost: { type: number, minimum: 0 }
        unit_of_measure: { type: string, maxLength: 50, description: "Sanitized automatically (e.g., 'Each' → 'ea')" }
        flat_resale_override: { type: number, nullable: true }
        item_margin_pct: { type: number, minimum: 0, maximum: 99.99, nullable: true }
        category_name: { type: string, maxLength: 100, nullable: true, description: "Category name (auto-created if new)" }
        description: { type: string, maxLength: 2000, nullable: true }
        tax_category: { type: string, maxLength: 100, nullable: true }
        min_billing_increment: { type: number, nullable: true }
        internal_overhead_rate: { type: number, nullable: true }
        is_active: { type: boolean, default: true }
        is_billable: { type: boolean, default: true, description: "When false, usage records are auto-excluded from invoice totals" }
        preferred_vendor_id: { type: string, format: uuid, nullable: true, description: "UUID of the preferred vendor" }
        burden_pct: { type: number, minimum: 0, maximum: 999, nullable: true, description: "Burden percentage on top of base_cost (labor only)" }
        is_role_default: { type: boolean, default: false, description: "Marks product as the default rate template for a role" }
        role_id: { type: string, format: uuid, nullable: true, description: "Links to a role for role-default labor products" }
        employee_id: { type: string, format: uuid, nullable: true, description: "Assign this labor rate to a specific employee" }
        employee_emp_number: { type: string, maxLength: 100, nullable: true, description: "Employee number lookup (alternative to employee_id)" }
        metadata: { type: object, nullable: true }

    ProductUpdate:
      type: object
      properties:
        name: { type: string, maxLength: 255 }
        type: { type: string, enum: [material, equipment, labor, service] }
        sku: { type: string, maxLength: 100, nullable: true }
        base_cost: { type: number, minimum: 0 }
        unit_of_measure: { type: string, maxLength: 50 }
        flat_resale_override: { type: number, nullable: true }
        item_margin_pct: { type: number, minimum: 0, maximum: 99.99, nullable: true }
        category_name: { type: string, maxLength: 100, nullable: true }
        description: { type: string, maxLength: 2000, nullable: true }
        tax_category: { type: string, maxLength: 100, nullable: true }
        is_active: { type: boolean }
        is_billable: { type: boolean, description: "When false, usage records are auto-excluded from invoice totals" }
        metadata: { type: object, nullable: true }
        min_billing_increment: { type: number, nullable: true, description: "Minimum billing increment in hours (equipment only)" }
        internal_overhead_rate: { type: number, nullable: true, description: "Internal overhead rate percentage (equipment only)" }
        preferred_vendor_id: { type: string, format: uuid, nullable: true, description: "UUID of the preferred vendor" }
        burden_pct: { type: number, minimum: 0, maximum: 999, nullable: true, description: "Burden percentage on top of base_cost (labor only)" }
        is_role_default: { type: boolean, description: "Marks product as the default rate template for a role" }
        role_id: { type: string, format: uuid, nullable: true, description: "Links to a role for role-default labor products" }
        employee_id: { type: string, format: uuid, nullable: true, description: "Assign this labor rate to a specific employee" }
        employee_emp_number: { type: string, maxLength: 100, nullable: true, description: "Employee number lookup (alternative to employee_id)" }

    # Usage Record schemas
    UsageRecord:
      type: object
      description: |
        Canonical usage record shape returned by every GET endpoint and emitted on every
        `usage_record.created` / `usage_record.updated` webhook. Centralized through
        `_shared/usageRecordService.ts` so API and webhook channels are byte-identical.

        **ID stability:** A usage_record `id` is stable for the lifetime of the row.
        Daily-report edits in WorkSkedge delete-and-reinsert the materials block, which
        fires fresh `usage_record.created` events with **new IDs**. Downstream consumers
        must key off the webhook `id` per event and not assume IDs persist across report
        edits or invoice-time recreation.
      properties:
        id: { type: string, format: uuid }
        company_id: { type: string, format: uuid }
        work_order_id: { type: string, format: uuid }
        product_id: { type: string, format: uuid, nullable: true }
        product:
          type: object
          nullable: true
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            sku: { type: string, nullable: true }
            type: { type: string, enum: [material, equipment, labor, service] }
            unit_of_measure: { type: string, nullable: true }
        quantity: { type: number }
        unit_cost_snapshot: { type: number, nullable: true, description: "Frozen base cost at creation (per base unit, post UoM pre-divide)" }
        resale_price_snapshot: { type: number, nullable: true, description: "Frozen resale price at creation (per base unit, post UoM pre-divide)" }
        display_unit: { type: string, nullable: true, description: "Original quoted UoM before pre-divide (e.g. 'box of 12')" }
        display_unit_price: { type: number, nullable: true, description: "Original quoted resale price per display_unit" }
        divisor_applied: { type: number, nullable: true, description: "UoM divisor applied during pre-divide (1 if none)" }
        product_name_snapshot: { type: string, nullable: true, description: "Snapshot of product name at creation time" }
        po_line_item_id: { type: string, format: uuid, nullable: true }
        invoice_id: { type: string, format: uuid, nullable: true, description: "Direct FK to the invoice this record was billed onto. `null` while unbilled." }
        status: { type: string, enum: [pending, invoiced, written-off] }
        source: { type: string, enum: [field_worker, api, office] }
        notes: { type: string, nullable: true }
        metadata: { type: object }
        recorded_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        linked_invoice:
          allOf: [{ $ref: '#/components/schemas/LinkedInvoiceRef' }]
          nullable: true
          description: Most recent invoice that billed this usage record (v2.26.0). `null` when not billed. Resolved at request/dispatch time, not stored.

    UsageRecordCreate:
      type: object
      required: [work_order_id, product_id, quantity]
      properties:
        work_order_id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        quantity: { type: number, minimum: 0.001 }
        po_line_item_id: { type: string, format: uuid, nullable: true }
        notes: { type: string, maxLength: 2000, nullable: true }
        metadata: { type: object, nullable: true }

    UsageRecordUpdate:
      type: object
      description: Only records with `status='pending'` can be updated. Allowed fields are listed below.
      properties:
        quantity: { type: number, minimum: 0.001 }
        status: { type: string, enum: [pending, invoiced, written-off] }
        notes: { type: string, maxLength: 2000, nullable: true }
        metadata: { type: object, nullable: true }

    Error:
      type: object
      properties:
        error:
          type: string
          description: Error code
        message:
          type: string
          description: Human-readable error message
        details:
          type: object
          description: Additional error details (validation errors, etc.)
      required: [error, message]

    RateLimitError:
      type: object
      description: Rate limit exceeded error response
      properties:
        error:
          type: string
          example: "Rate limit exceeded"
        message:
          type: string
          example: "Too many requests. Please wait before retrying."
        retry_after:
          type: integer
          description: Seconds until you can retry
          example: 35
      required: [error, message]

    ScopeOfWork:
      type: object
      description: |
        Scope of work containing checklists and tasks for a work order.
        This field is read-write and can be set via API or updated through progress reports.
      properties:
        checklists:
          type: array
          items:
            $ref: '#/components/schemas/ScopeChecklist'
      example:
        checklists:
          - id: "550e8400-e29b-41d4-a716-446655440000"
            name: "Installation Tasks"
            is_table_view: false
            tasks:
              - id: "550e8400-e29b-41d4-a716-446655440001"
                text: "Install equipment"
                completed: true
                completed_at: "2024-01-15T14:30:00Z"

    ScopeChecklist:
      type: object
      description: A checklist containing a group of related tasks
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: Name of the checklist
        is_table_view:
          type: boolean
          description: |
            Display mode for the checklist.
            - `false` or undefined: Card/checkbox list view - tasks displayed as simple checkboxes
            - `true`: Table view - tasks displayed in a spreadsheet-like grid with columns defined by task `fields`
        tasks:
          type: array
          items:
            $ref: '#/components/schemas/ScopeTask'
      required: [id, name, tasks]

    ScopeTask:
      type: object
      description: |
        A single task within a checklist.
        In card view mode, tasks are simple checkboxes.
        In table view mode, tasks are rows with columns defined by `fields` and values in `fieldValues`.
      properties:
        id:
          type: string
          format: uuid
        text:
          type: string
          description: Task description/name (displayed as row header in table view)
        completed:
          type: boolean
          description: Whether the task is completed
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: ISO timestamp when task was completed
        fields:
          type: array
          description: |
            Column definitions for table view mode.
            Only present when the parent checklist has `is_table_view: true`.
            Defines the columns that appear in the table.
          items:
            $ref: '#/components/schemas/TaskFieldDefinition'
        fieldValues:
          type: array
          description: |
            User-entered values for each field/column.
            Each entry maps to a field definition by `field_id`.
          items:
            $ref: '#/components/schemas/TaskFieldValue'
      required: [id, text, completed]

    TaskFieldDefinition:
      type: object
      description: Defines a column in table view mode
      properties:
        id:
          type: string
          description: Unique identifier for this field
        label:
          type: string
          description: Column header text displayed in the table
        field_type:
          type: string
          enum: [text, number, dropdown, note]
          description: |
            Type of input for this field:
            - `text`: Free text input
            - `number`: Numeric input
            - `dropdown`: Selection from predefined options
            - `note`: Multi-line text input
        is_required:
          type: boolean
          description: Whether this field must have a value
        dropdown_options:
          type: array
          items:
            type: string
          description: Available options when field_type is 'dropdown'
        display_order:
          type: integer
          description: Order in which this column appears (left to right)
        is_input:
          type: boolean
          description: |
            If true, field is editable by users.
            If false, field is display-only (calculated or system-managed).
      required: [id, label, field_type, is_required, display_order]

    TaskFieldValue:
      type: object
      description: User-entered value for a task field/column
      properties:
        field_id:
          type: string
          description: References the TaskFieldDefinition.id this value belongs to
        value:
          oneOf:
            - type: string
            - type: number
          description: The actual value entered by the user
      required: [field_id, value]

    ProgressCache:
      type: object
      description: |
        Cached progress data for a work order, computed from scope_of_work.
        This field is READ-ONLY and automatically updated when progress/completion reports are submitted.
        Do not attempt to set this field via API - it will be ignored.
      properties:
        total_tasks:
          type: integer
          description: Total number of tasks across all checklists in the scope of work
        completed_tasks:
          type: integer
          description: Number of tasks marked as completed
        progress_percentage:
          type: integer
          minimum: 0
          maximum: 100
          description: Completion percentage (0-100), calculated as (completed_tasks / total_tasks) * 100
        last_report_id:
          type: string
          format: uuid
          nullable: true
          description: ID of the most recent report that updated the progress
        last_report_date:
          type: string
          format: date
          nullable: true
          description: Date of the last report (YYYY-MM-DD)
        last_report_type:
          type: string
          enum: [daily, completion]
          nullable: true
          description: Type of the last report that updated progress
        last_reported_by_name:
          type: string
          nullable: true
          description: Display name of the person who submitted the last report
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: ISO timestamp of when progress_cache was last updated
      required: [total_tasks, completed_tasks, progress_percentage]
      example:
        total_tasks: 10
        completed_tasks: 7
        progress_percentage: 70
        last_report_id: "550e8400-e29b-41d4-a716-446655440099"
        last_report_date: "2024-01-15"
        last_report_type: "daily"
        last_reported_by_name: "John Smith"
        updated_at: "2024-01-15T16:45:00Z"

    # Employee schemas
    Employee:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
          format: email
        emp_number:
          type: string
        phone:
          type: string
          nullable: true
        role_id:
          type: string
          format: uuid
          nullable: true
        department_id:
          type: string
          format: uuid
          nullable: true
        app_roles:
          type: array
          items:
            type: string
            enum: [site_worker, site_lead, project_owner, scheduler, manager, administrator]
          description: Array of role designations (e.g., ["site_worker", "site_lead"])
        is_active:
          type: boolean
        start_date:
          type: string
          format: date
          nullable: true
          description: Date when the employee started working for the company (YYYY-MM-DD)
        birthday:
          type: string
          format: date
          nullable: true
          description: Employee's date of birth (YYYY-MM-DD)
        address:
          type: object
          nullable: true
          description: Employee's address
          properties:
            street:
              type: string
            city:
              type: string
            state:
              type: string
            zip:
              type: string
            country:
              type: string
        emergency_contact_name:
          type: string
          nullable: true
          description: Name of emergency contact
        emergency_contact_relationship:
          type: string
          nullable: true
          description: Relationship to emergency contact (e.g., Spouse, Parent, Sibling)
        emergency_phone_1:
          type: string
          nullable: true
          description: Primary emergency contact phone number
        emergency_phone_2:
          type: string
          nullable: true
          description: Secondary emergency contact phone number
        emergency_email:
          type: string
          format: email
          nullable: true
          description: Emergency contact email address
        scheduling_color:
          type: string
          nullable: true
        scheduling_sort_order:
          type: integer
          nullable: true
        deleted:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        skills:
          type: array
          description: Employee's assigned skills with certification details
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
                description: Skill assignment ID
              skill:
                type: object
                description: The company skill definition
                properties:
                  id:
                    type: string
                    format: uuid
                  skill_name:
                    type: string
                  skill_category:
                    type: string
                    enum: [license, certification, training, equipment_operation, skill]
              certification_number:
                type: string
                nullable: true
                description: Certificate or license number
              issue_date:
                type: string
                format: date
                nullable: true
                description: Date the certification was issued
              expiry_date:
                type: string
                format: date
                nullable: true
                description: Date the certification expires
              issuing_authority:
                type: string
                nullable: true
                description: Organization that issued the certification
              is_verified:
                type: boolean
                description: Whether the skill has been verified by management
              notes:
                type: string
                nullable: true
                description: Additional notes about this certification
              document_url:
                type: string
                nullable: true
                description: URL to uploaded certification document
              document_name:
                type: string
                nullable: true
                description: Name of the uploaded document
      required: [id, name, email, emp_number]

    EmployeeCreate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        email:
          type: string
          format: email
        emp_number:
          type: string
          minLength: 1
        phone:
          type: string
        role_id:
          type: string
          format: uuid
        department_id:
          type: string
          format: uuid
        roles:
          type: array
          items:
            type: string
            enum: [site_worker, site_lead, project_owner, scheduler, manager, administrator]
          description: Array of role designations (e.g., ["site_worker", "site_lead"])
          default: []
        is_active:
          type: boolean
          default: true
        start_date:
          type: string
          format: date
          description: Date when the employee started working for the company (YYYY-MM-DD)
        birthday:
          type: string
          format: date
          description: Employee's date of birth (YYYY-MM-DD)
        address:
          type: object
          description: Employee's address
          properties:
            street:
              type: string
            city:
              type: string
            state:
              type: string
            zip:
              type: string
            country:
              type: string
        emergency_contact_name:
          type: string
          description: Name of emergency contact
        emergency_contact_relationship:
          type: string
          description: Relationship to emergency contact
        emergency_phone_1:
          type: string
          description: Primary emergency contact phone number
        emergency_phone_2:
          type: string
          description: Secondary emergency contact phone number
        emergency_email:
          type: string
          format: email
          description: Emergency contact email address
        scheduling_color:
          type: string
        skills:
          type: array
          items:
            type: string
          description: |
            Array of skill names to assign to the employee.
            Skills must exist in the company's skill library.
            Note: This replaces all skills (sync behavior).
            To add certification details, use the UI or a future dedicated endpoint.
          example: ["Electrical", "First Aid", "Forklift Operation"]
      required: [name]

    EmployeeUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        email:
          type: string
          format: email
        phone:
          type: string
        role_id:
          type: string
          format: uuid
        department_id:
          type: string
          format: uuid
        roles:
          type: array
          items:
            type: string
            enum: [site_worker, site_lead, project_owner, scheduler, manager, administrator]
          description: Array of role designations (e.g., ["site_worker", "site_lead"])
        is_active:
          type: boolean
        start_date:
          type: string
          format: date
          description: Date when the employee started working for the company (YYYY-MM-DD)
        birthday:
          type: string
          format: date
          description: Employee's date of birth (YYYY-MM-DD)
        address:
          type: object
          description: Employee's address
          properties:
            street:
              type: string
            city:
              type: string
            state:
              type: string
            zip:
              type: string
            country:
              type: string
        emergency_contact_name:
          type: string
          description: Name of emergency contact
        emergency_contact_relationship:
          type: string
          description: Relationship to emergency contact
        emergency_phone_1:
          type: string
          description: Primary emergency contact phone number
        emergency_phone_2:
          type: string
          description: Secondary emergency contact phone number
        emergency_email:
          type: string
          format: email
          description: Emergency contact email address
        scheduling_color:
          type: string
        scheduling_sort_order:
          type: integer
        skills:
          type: array
          items:
            type: string
          description: |
            Array of skill names to assign to the employee.
            Skills must exist in the company's skill library.
            Note: This replaces all skills (sync behavior).
            To add certification details, use the UI or a future dedicated endpoint.
          example: ["Electrical", "First Aid", "Forklift Operation"]


    # Project schemas
    Project:
      type: object
      properties:
        id:
          type: string
          format: uuid
        project_name:
          type: string
        project_code:
          type: string
        customer_id:
          type: string
          format: uuid
          nullable: true
        location_id:
          type: string
          format: uuid
          nullable: true
        status:
          type: string
          description: |
            Project status. Values depend on company's project_status_mode:
            - **Simple mode**: "active", "closed"
            - **Advanced mode**: "lead", "estimating", "pending_approval", "job_lost", "approved", "active", "complete", "closed"
            
            Note: "job_lost" is a terminal status representing a lead that did not convert.
        priority:
          type: string
          enum: [normal, high]
        description:
          type: string
          nullable: true
        color_index:
          type: integer
          nullable: true
          description: Color index for UI display (0-8)
        custom_fields:
          type: object
          nullable: true
          description: Company-specific custom fields
        job_lost_reason:
          type: string
          nullable: true
          description: Reason the job was lost. Only populated when status is "job_lost".
        estimator_employee_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of the employee assigned as estimator. Required when status is "estimating".
        estimator_emp_number:
          type: string
          nullable: true
          description: Alternative to `estimator_employee_id` — resolve estimator by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        estimation_due_date:
          type: string
          format: date
          nullable: true
          description: Due date for the estimation. Populated when status is "estimating".
        closed_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp when project was closed
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        customer:
          type: object
          nullable: true
          description: Embedded customer data
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            email:
              type: string
              nullable: true
              description: Only included in single project GET response
            phone:
              type: string
              nullable: true
              description: Only included in single project GET response
        location:
          type: object
          nullable: true
          description: Embedded location data
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            address_line_1:
              type: string
              nullable: true
            address_line_2:
              type: string
              nullable: true
              description: Only included in single project GET response
            city:
              type: string
              nullable: true
            province:
              type: string
              nullable: true
            postal_code:
              type: string
              nullable: true
              description: Only included in single project GET response
            country:
              type: string
              nullable: true
              description: Only included in single project GET response
        estimator:
          type: object
          nullable: true
          description: Embedded estimator employee data. Populated when estimator_employee_id is set.
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            email:
              type: string
        project_owners:
          type: array
          description: Employees assigned as project owners (max 2)
          items:
            type: object
            properties:
              employee:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  name:
                    type: string
                  email:
                    type: string
        project_contacts:
          type: array
          description: Contacts associated with the project
          items:
            type: object
            properties:
              is_primary:
                type: boolean
                description: Whether this is the primary contact for the project
              contact:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  first_name:
                    type: string
                  last_name:
                    type: string
                  email:
                    type: string
                    format: email
                    nullable: true
                  phone:
                    type: string
                    nullable: true
                  mobile:
                    type: string
                    nullable: true
                  title:
                    type: string
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                    description: Only included in single project GET response
        customer_po_numbers:
          type: array
          nullable: true
          items:
            type: string
          description: Customer purchase order numbers associated with this project. Inherited by invoices created for this project.
      required: [id, project_name, project_code, status, priority]

    ProjectStatusDefinition:
      type: object
      description: Definition of a project status in the company's status workflow
      properties:
        id:
          type: string
          format: uuid
        status_key:
          type: string
          description: Internal status key (e.g., "open", "lead", "active")
        label:
          type: string
          description: Human-readable label (e.g., "Open", "Lead", "Active")
        display_order:
          type: integer
          description: Order in which status appears in UI
        color:
          type: string
          nullable: true
          description: Color identifier for UI display
        is_terminal:
          type: boolean
          description: Whether this is a terminal status (e.g., "closed")
        mode:
          type: string
          enum: [simple, advanced]
          description: Which status mode this definition belongs to
      required: [id, status_key, label, display_order, is_terminal, mode]

    ProjectCreate:
      type: object
      description: |
        Create a new project. You must provide either `customer_id` (UUID) or `customer_name` (text),
        **except when `status` is `"lead"`** — lead-status projects can be created without a customer
        or location (both fields remain `null` until the lead is qualified and later updated).

        **Text-based entity resolution:** When `customer_name` is provided instead of `customer_id`,
        WorkSkedge searches for an existing customer by name (case-insensitive). If no match is found,
        a new customer is created automatically. The same resolve-or-create logic applies to `location_name`.

        When a customer is matched or created, their primary and location-specific contacts are
        automatically linked to the project — UNLESS you supply `contact_ids` and/or `contacts`,
        in which case those replace the auto-linked set and you control linking explicitly.

        **Inline contacts:** Pass `contacts: [...]` to create brand-new contacts under the
        resolved customer in the same request. New contacts are linked to the project
        automatically. You can combine `contact_ids` (existing) with `contacts` (new); both
        sets are deduplicated and linked. If contact creation fails, the project is rolled back.



      properties:
        project_name:
          type: string
          minLength: 1
        project_code:
          type: string
        customer_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of an existing customer. Provide this OR `customer_name`. Optional when `status` is `"lead"`.
        customer_name:
          type: string
          maxLength: 255
          description: |
            Customer name for resolve-or-create. If a customer with this name exists (case-insensitive match),
            it will be linked. Otherwise, a new customer is created. Use instead of `customer_id`.
            Optional when `status` is `"lead"`.

        customer_type:
          type: string
          description: Type for a newly created customer (e.g., "residential", "commercial"). Ignored if customer already exists.
        customer_email:
          type: string
          format: email
          description: Email for a newly created customer. Ignored if customer already exists.
        customer_phone:
          type: string
          description: Phone for a newly created customer. Ignored if customer already exists.
        location_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of an existing location. Provide this OR `location_name`.
        location_name:
          type: string
          maxLength: 255
          description: |
            Location name for resolve-or-create. If a location with this name exists under the matched customer,
            it will be linked. Otherwise, a new location is created. Use instead of `location_id`.
        location_address_line_1:
          type: string
          description: Street address for a newly created location. Ignored if location already exists.
        location_city:
          type: string
          description: City for a newly created location. Ignored if location already exists.
        location_province:
          type: string
          description: Province/state for a newly created location. Ignored if location already exists.
        location_postal_code:
          type: string
          description: Postal/ZIP code for a newly created location. Ignored if location already exists.
        location_country:
          type: string
          description: Country for a newly created location. Ignored if location already exists.
        status:
          type: string
          description: |
            Initial project status. Defaults to first non-terminal status:
            - Simple mode: defaults to "active"
            - Advanced mode: defaults to "lead"
        priority:
          type: string
          enum: [normal, high]
          default: normal
        description:
          type: string
        project_owner_emp_numbers:
          type: array
          items:
            type: string
          maxItems: 2
          nullable: true
          description: |
            Employee numbers (e.g., `["EMP-2001", "EMP-2002"]`) of employees to assign as
            project owners. Maximum 2. Use employee numbers, not UUIDs. This is the canonical
            field name; the inbound webhook also accepts the deprecated aliases
            `internal_manager_emp_number` / `internal_manager_emp_numbers`.
          example: ["EMP-2001"]
        custom_fields:
          type: object
          description: Company-specific custom fields
        docs:
          type: array
          description: |
            Project documents - external links or file references.
            Use type "link" for external URLs, type "file" for storage file references.
          items:
            $ref: '#/components/schemas/ProjectDocument'
        estimator_employee_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of the employee to assign as estimator. Required when status is "estimating".
        estimator_emp_number:
          type: string
          nullable: true
          description: Alternative to `estimator_employee_id` — resolve estimator by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        estimation_due_date:
          type: string
          format: date
          nullable: true
          description: Due date for the estimation. Required when status is "estimating".
        site_address_id:
          type: string
          format: uuid
          nullable: true
          description: |
            UUID of a saved job-site address from the company repo (see `GET /v1/job-addresses`).
            When provided WITHOUT `site_address`, the snapshot is hydrated server-side from the
            repo row and the repo's `usage_count` / `last_used_at` are bumped.
            When both `site_address_id` and `site_address` are provided, the explicit `site_address`
            wins (snapshot rule: edits never propagate back to the saved repo entry).
        site_address:
          type: object
          nullable: true
          description: |
            Ad-hoc job site address, independent of the customer location (location_id).
            Use this when the physical work site differs from the customer's billing/office location.
            If set, work orders inherit this address instead of the customer location.
            Submitting a populated `site_address` also dedup-or-inserts a row in the company's
            saved job-site repo (`GET /v1/job-addresses`) so it can be reused later.
            The payload must include either `address_line_1` or `gps_coordinates`.
            See [Job Addresses](#tag/Job-Addresses) for details.
          properties:
            address_line_1:
              type: string
            address_line_2:
              type: string
            city:
              type: string
            province:
              type: string
            postal_code:
              type: string
            country:
              type: string
            gps_coordinates:
              type: string
              nullable: true
              example: "50.145580, -95.788568"
              description: 'Optional GPS coordinates as "latitude, longitude" in decimal degrees (lat -90..90, lng -180..180). Paste directly from a maps app. Replaces the previous split `latitude` / `longitude` fields.'
        contact_ids:
          type: array
          items:
            type: string
            format: uuid
          maxItems: 50
          description: |
            UUIDs of existing contacts to link to the project. Every contact must belong to
            the resolved customer or the request is rejected. When omitted (and no `contacts`
            inline array is provided), the customer's primary and location-specific contacts
            are auto-linked.
          example: ["c1a2b3d4-1111-2222-3333-444455556666"]
        contacts:
          type: array
          maxItems: 50
          description: |
            Inline contacts to create AND link to the project. New contacts are scoped to the
            resolved customer automatically. Combine with `contact_ids` to link existing
            contacts in the same request. If any inline contact fails to create, the project
            is rolled back.
          items:
            type: object
            required: [first_name, last_name]
            properties:
              first_name:
                type: string
                maxLength: 100
              last_name:
                type: string
                maxLength: 100
              title:
                type: string
                maxLength: 100
                nullable: true
              email:
                type: string
                format: email
                nullable: true
              phone:
                type: string
                maxLength: 50
                nullable: true
              phone_ext:
                type: string
                maxLength: 20
                nullable: true
              mobile:
                type: string
                maxLength: 50
                nullable: true
              is_primary:
                type: boolean
              location_id:
                type: string
                format: uuid
                nullable: true
                description: Optional location UUID to scope this contact to a specific location.
              contact_tags:
                type: array
                items:
                  type: string
                  maxLength: 50
                maxItems: 20
                nullable: true
              notes:
                type: string
                maxLength: 2000
                nullable: true
        primary_contact_id:
          type: string
          format: uuid
          nullable: true
          description: |
            UUID of the contact to mark as the project's primary contact. Must be present in
            either `contact_ids` or correspond to an inline contact created from `contacts`
            (the inline contact's UUID is not known in advance, so for new inline contacts
            use `is_primary: true` on the entry instead — the first match wins).
      required: [project_name, project_code]


    ProjectUpdate:
      type: object
      description: |
        Update an existing project. All fields are optional. Supports the same text-based
        entity resolution as ProjectCreate — provide `customer_name` or `location_name`
        to reassign by name with resolve-or-create logic.

        **Contacts (replace semantics):** If either `contact_ids` or `contacts` is present
        in the request body, the project's linked contacts are **replaced** with the union
        of the two arrays (after any inline contacts are created). Omitting both fields
        leaves linked contacts untouched. Pass `contact_ids: []` (without `contacts`) to
        clear all linked contacts. All contacts must belong to the project's customer;
        inline contacts are auto-scoped to that customer. See `ProjectCreate` for inline
        contact field details.
      properties:
        project_name:
          type: string
          minLength: 1
        project_code:
          type: string
        customer_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of an existing customer. Provide this OR `customer_name`.
        customer_name:
          type: string
          maxLength: 255
          description: Customer name for resolve-or-create (see ProjectCreate for details).
        customer_type:
          type: string
          description: Type for a newly created customer. Ignored if customer already exists.
        customer_email:
          type: string
          format: email
          description: Email for a newly created customer. Ignored if customer already exists.
        customer_phone:
          type: string
          description: Phone for a newly created customer. Ignored if customer already exists.
        location_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of an existing location. Provide this OR `location_name`.
        location_name:
          type: string
          maxLength: 255
          description: Location name for resolve-or-create (see ProjectCreate for details).
        location_address_line_1:
          type: string
          description: Street address for a newly created location.
        location_city:
          type: string
          description: City for a newly created location.
        location_province:
          type: string
          description: Province/state for a newly created location.
        location_postal_code:
          type: string
          description: Postal/ZIP code for a newly created location.
        location_country:
          type: string
          description: Country for a newly created location.
        status:
          type: string
          description: |
            Project status. Values depend on company's project_status_mode.
            Advanced mode values: "lead", "estimating", "pending_approval", "job_lost", "approved", "active", "complete", "closed".
            Use GET /projects/statuses to retrieve available statuses.
        priority:
          type: string
          enum: [normal, high]
        description:
          type: string
        project_owner_emp_numbers:
          type: array
          items:
            type: string
          maxItems: 2
          nullable: true
          description: |
            Employee numbers of employees to assign as project owners (max 2). Replaces any
            existing owners on the project. Pass `[]` to clear all owners. See `ProjectCreate`.
          example: ["EMP-2001", "EMP-2002"]
        custom_fields:
          type: object
          description: Company-specific custom fields
        docs:
          type: array
          description: |
            Project documents - external links or file references.
            Use type "link" for external URLs, type "file" for storage file references.
          items:
            $ref: '#/components/schemas/ProjectDocument'
        estimator_employee_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of the employee to assign as estimator. Required when status is "estimating".
        estimator_emp_number:
          type: string
          nullable: true
          description: Alternative to `estimator_employee_id` — resolve estimator by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        estimation_due_date:
          type: string
          format: date
          nullable: true
          description: Due date for the estimation. Required when status is "estimating".
        site_address_id:
          type: string
          format: uuid
          nullable: true
          description: |
            UUID of a saved job-site address (see `GET /v1/job-addresses`).
            Same hydration rules as `ProjectCreate.site_address_id` apply on update.
        site_address:
          type: object
          nullable: true
          description: |
            Ad-hoc job site address, independent of the customer location (location_id).
            Set to `null` to clear. If set, work orders inherit this address instead of the customer location.
            Submitting a populated `site_address` also dedup-or-inserts a row in the company's
            saved job-site repo. Must include either `address_line_1` or `gps_coordinates`.
          properties:
            address_line_1:
              type: string
            address_line_2:
              type: string
            city:
              type: string
            province:
              type: string
            postal_code:
              type: string
            country:
              type: string
            gps_coordinates:
              type: string
              nullable: true
              example: "50.145580, -95.788568"
              description: 'Optional GPS coordinates as "latitude, longitude" in decimal degrees. Replaces the previous split `latitude` / `longitude` fields.'
        contact_ids:
          type: array
          maxItems: 50
          items:
            type: string
            format: uuid
          description: |
            UUIDs of existing contacts to link to the project. REPLACE semantics:
            when present, the project's linked contacts are replaced with the union
            of `contact_ids` and any newly created `contacts`. Pass `[]` (with no
            `contacts`) to clear all linked contacts. All contacts must belong to
            the project's customer.
        contacts:
          type: array
          maxItems: 50
          description: |
            Inline contacts to create and link to the project. Each created contact
            is scoped to the project's current customer. Participates in REPLACE
            semantics together with `contact_ids` (see above).
          items:
            type: object
            required: [first_name, last_name]
            properties:
              first_name:
                type: string
                minLength: 1
                maxLength: 100
              last_name:
                type: string
                minLength: 1
                maxLength: 100
              title:
                type: string
                maxLength: 100
                nullable: true
              email:
                type: string
                format: email
                nullable: true
              phone:
                type: string
                maxLength: 50
                nullable: true
              phone_ext:
                type: string
                maxLength: 20
                nullable: true
              mobile:
                type: string
                maxLength: 50
                nullable: true
              is_primary:
                type: boolean
                description: If true and `primary_contact_id` is omitted, this inline contact becomes the project's primary.
              location_id:
                type: string
                format: uuid
                nullable: true
              contact_tags:
                type: array
                maxItems: 20
                items:
                  type: string
                  maxLength: 50
                nullable: true
              notes:
                type: string
                maxLength: 2000
                nullable: true
        primary_contact_id:
          type: string
          format: uuid
          nullable: true
          description: |
            Sets the primary contact among the final linked set. Must resolve to a
            contact in the final set (existing or newly created). If omitted but an
            inline `contacts` entry has `is_primary: true`, that one is used. If
            neither is supplied, no primary is set.



    ProjectDocument:
      type: object
      description: |
        A document attached to a project. Can be either an external link or a file reference.
        
        **Example - External Link:**
        ```json
        {
          "type": "link",
          "url": "https://example.com/cost-report.pdf",
          "label": "Q1 Cost Report"
        }
        ```
        
        **Example - File Reference:**
        ```json
        {
          "type": "file",
          "path": "company-id/project-id/invoice.pdf",
          "name": "Invoice.pdf"
        }
        ```
      properties:
        type:
          type: string
          enum: [file, link]
          description: Document type - "link" for external URLs, "file" for storage references
        url:
          type: string
          format: uri
          description: External URL (required when type is "link")
        label:
          type: string
          maxLength: 255
          description: Display label for the document link
        path:
          type: string
          description: Storage path (required when type is "file")
        name:
          type: string
          maxLength: 255
          description: Display name for the file
      required: [type]

    # Work Order schemas
    WorkOrder:
      type: object
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        phase_name:
          type: string
        work_order_number:
          type: string
        status:
          type: string
          enum: [created, draft, ready, assigned, active, on-hold, complete-pending, complete, closed, cancelled]
        priority:
          type: string
          enum: [low, normal, high, urgent]
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        start_time:
          type: string
          pattern: ^\d{2}:\d{2}$
          nullable: true
        end_time:
          type: string
          pattern: ^\d{2}:\d{2}$
          nullable: true
        expected_start_date:
          type: string
          format: date
          nullable: true
        expected_end_date:
          type: string
          format: date
          nullable: true
        people_required:
          type: integer
          nullable: true
        site_lead_id:
          type: string
          format: uuid
          nullable: true
        site_lead_emp_number:
          type: string
          nullable: true
          description: Alternative to `site_lead_id` — resolve site lead by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        vendor_id:
          type: string
          format: uuid
          nullable: true
        performed_by:
          type: string
          nullable: true
        scope_of_work:
          allOf:
            - $ref: '#/components/schemas/ScopeOfWork'
          nullable: true
          description: Scope of work with checklists and tasks. See ScopeOfWork schema for structure.
        progress_cache:
          allOf:
            - $ref: '#/components/schemas/ProgressCache'
          nullable: true
          readOnly: true
          description: |
            Cached progress data (READ-ONLY). Automatically computed from scope_of_work
            when reports are submitted. See ProgressCache schema for structure.
        equipment:
          type: array
          items:
            type: object
          nullable: true
        specialty_materials:
          type: array
          items:
            type: object
          nullable: true
        require_daily_report:
          type: boolean
        require_completion_report:
          type: boolean
        display_order:
          type: integer
          nullable: true
          description: Order for displaying work orders within a project
        description:
          type: string
          nullable: true
          description: Work order description
        notes:
          type: string
          nullable: true
          description: Internal notes for the work order
        important_note:
          type: string
          nullable: true
          description: Important note displayed prominently to field workers
        important_note_images:
          type: array
          nullable: true
          items:
            type: string
          description: Array of image URLs attached to the important note (max 4)
        location_id:
          type: string
          format: uuid
          nullable: true
          description: Customer location ID for the job site. If set, the resolved location is included in responses.
        site_address:
          type: object
          nullable: true
          description: Ad-hoc site address not linked to a customer location. Takes priority over location_id. Optional `gps_coordinates` string is included when set.
          properties:
            address_line_1:
              type: string
            address_line_2:
              type: string
            city:
              type: string
            province:
              type: string
            postal_code:
              type: string
            country:
              type: string
            gps_coordinates:
              type: string
              nullable: true
              example: "50.145580, -95.788568"
              description: 'GPS coordinates formatted as "latitude, longitude" in decimal degrees. Replaces the previous split `latitude` / `longitude` fields.'
        location:
          type: object
          nullable: true
          readOnly: true
          description: Resolved location from location_id join. Null if site_address is used or no location set.
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            address_line_1:
              type: string
            address_line_2:
              type: string
            city:
              type: string
            province:
              type: string
            postal_code:
              type: string
            country:
              type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        is_overhead:
          type: boolean
          default: false
          description: |
            `true` for auto-created Project Overhead work orders. These are created automatically
            for each new project to allow logging pre-setup/estimating costs. Excluded from list
            endpoints by default; use `include_overhead=true` to include them.
        is_migration:
          type: boolean
          default: false
          description: "`true` for records imported via the Data Migration wizard"
      required: [id, project_id, phase_name, status]

    WorkOrderCreate:
      type: object
      properties:
        project_id:
          type: string
          format: uuid
        phase_name:
          type: string
          minLength: 1
        work_order_number:
          type: string
        status:
          type: string
          enum: [created, draft, ready, assigned, active, on-hold, complete-pending, complete, closed, cancelled]
          default: created
        priority:
          type: string
          enum: [low, normal, high, urgent]
          default: normal
        description:
          type: string
          description: Work order description
        notes:
          type: string
          description: Internal notes for the work order
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        start_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        end_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        expected_start_date:
          type: string
          format: date
        expected_end_date:
          type: string
          format: date
        expected_start_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        expected_end_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        performed_by:
          type: string
          enum: [company, vendor]
          nullable: true
        vendor_id:
          type: string
          format: uuid
          nullable: true
        site_lead_id:
          type: string
          format: uuid
          nullable: true
        site_lead_emp_number:
          type: string
          nullable: true
          description: Alternative to `site_lead_id` — resolve site lead by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        people_required:
          type: integer
          nullable: true
        people_skill_requirements:
          type: array
          items:
            type: string
          description: Required skills for assignment
        days_hours_required:
          type: string
          description: Estimated duration (e.g., "8 hours", "2 days")
          nullable: true
        scope_of_work:
          allOf:
            - $ref: '#/components/schemas/ScopeOfWork'
          nullable: true
          description: |
            Scope of work with checklists and tasks.
            Can include both card-view checklists (simple checkbox tasks) and
            table-view checklists (spreadsheet-like with custom columns).
            See ScopeOfWork schema for detailed structure.
        equipment:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              notes:
                type: string
        specialty_materials:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              notes:
                type: string
        scheduler_notes:
          type: object
          properties:
            subtrade_required:
              type: string
            scheduling_insights:
              type: string
          nullable: true
        custom_fields:
          type: object
          additionalProperties: true
          nullable: true
        display_order:
          type: integer
          description: Order for displaying work orders within a project
        require_daily_report:
          type: boolean
          default: true
        require_completion_report:
          type: boolean
          default: true
        important_note:
          type: string
          description: Important note displayed prominently to field workers
          nullable: true
        important_note_images:
          type: array
          items:
            type: string
          description: Array of image URLs attached to the important note (max 4)
          nullable: true
        location_id:
          type: string
          format: uuid
          nullable: true
          description: Customer location ID for the job site. Defaults to project location if omitted on create.
        site_address:
          type: object
          nullable: true
          description: |
            Ad-hoc site address not linked to a customer location. Mutually exclusive with location_id.
            Must include either `address_line_1` or `gps_coordinates`.
          properties:
            address_line_1:
              type: string
            address_line_2:
              type: string
            city:
              type: string
            province:
              type: string
            postal_code:
              type: string
            country:
              type: string
            gps_coordinates:
              type: string
              nullable: true
              example: "50.145580, -95.788568"
              description: 'Optional GPS coordinates as "latitude, longitude" in decimal degrees.'
      required: [project_id, phase_name]

    WorkOrderUpdate:
      type: object
      properties:
        project_id:
          type: string
          format: uuid
        phase_name:
          type: string
          minLength: 1
        work_order_number:
          type: string
        status:
          type: string
          enum: [created, draft, ready, assigned, active, on-hold, complete-pending, complete, closed, cancelled]
        priority:
          type: string
          enum: [low, normal, high, urgent]
        description:
          type: string
        notes:
          type: string
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        start_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        end_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        expected_start_date:
          type: string
          format: date
        expected_end_date:
          type: string
          format: date
        expected_start_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        expected_end_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        performed_by:
          type: string
          enum: [company, vendor]
          nullable: true
        vendor_id:
          type: string
          format: uuid
          nullable: true
        site_lead_id:
          type: string
          format: uuid
          nullable: true
        site_lead_emp_number:
          type: string
          nullable: true
          description: Alternative to `site_lead_id` — resolve site lead by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        people_required:
          type: integer
          nullable: true
        people_skill_requirements:
          type: array
          items:
            type: string
        days_hours_required:
          type: string
          nullable: true
        scope_of_work:
          allOf:
            - $ref: '#/components/schemas/ScopeOfWork'
          nullable: true
          description: |
            Scope of work with checklists and tasks.
            Updates replace the entire scope_of_work - use PATCH carefully.
            See ScopeOfWork schema for detailed structure.
        equipment:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              notes:
                type: string
        specialty_materials:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              notes:
                type: string
        scheduler_notes:
          type: object
          properties:
            subtrade_required:
              type: string
            scheduling_insights:
              type: string
          nullable: true
        custom_fields:
          type: object
          additionalProperties: true
          nullable: true
        display_order:
          type: integer
        require_daily_report:
          type: boolean
        require_completion_report:
          type: boolean
        important_note:
          type: string
          nullable: true
        important_note_images:
          type: array
          items:
            type: string
          nullable: true
        location_id:
          type: string
          format: uuid
          nullable: true
          description: Customer location ID for the job site.
        site_address:
          type: object
          nullable: true
          description: |
            Ad-hoc site address not linked to a customer location. Mutually exclusive with location_id.
            Must include either `address_line_1` or `gps_coordinates`.
            Set the whole object to `null` to clear.
          properties:
            address_line_1:
              type: string
            address_line_2:
              type: string
            city:
              type: string
            province:
              type: string
            postal_code:
              type: string
            country:
              type: string
            gps_coordinates:
              type: string
              nullable: true
              example: "50.145580, -95.788568"
              description: 'Optional GPS coordinates as "latitude, longitude" in decimal degrees.'


    # Timesheet schemas
    Timesheet:
      type: object
      description: Daily timesheet record for an employee
      properties:
        id:
          type: string
          format: uuid
        employee_id:
          type: string
          format: uuid
        timesheet_date:
          type: string
          format: date
          description: Date of the daily timesheet (YYYY-MM-DD)
        total_hours:
          type: number
          description: Sum of hours from all entries
        status:
          type: string
          enum: [draft, submitted, locked, processed]
          description: |
            Timesheet status workflow:
            - **draft**: Initial state, can be edited
            - **submitted**: Submitted by employee for review
            - **locked**: Locked by manager, cannot be edited
            - **processed**: Processed/finalized for payroll
        notes:
          type: string
          nullable: true
          description: Employee notes for the timesheet
        submitted_at:
          type: string
          format: date-time
          nullable: true
        submitted_by:
          type: string
          format: uuid
          nullable: true
        locked_at:
          type: string
          format: date-time
          nullable: true
        locked_by:
          type: string
          format: uuid
          nullable: true
        processed_at:
          type: string
          format: date-time
          nullable: true
        processed_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        entries:
          type: array
          description: Timesheet entries (time allocations)
          items:
            $ref: '#/components/schemas/TimesheetEntry'
        employee:
          $ref: '#/components/schemas/Employee'
      required: [id, employee_id, timesheet_date, status]

    TimesheetEntry:
      type: object
      description: |
        Individual time entry within a timesheet.
        
        **Important:** Employee information is accessed via the nested `timesheet.employee` object,
        not at the root level. The `timesheet_id` field contains the parent timesheet UUID which 
        links to the employee record.
      properties:
        id:
          type: string
          format: uuid
        timesheet_id:
          type: string
          format: uuid
          description: ID of the parent timesheet (use `timesheet.employee` for employee data)
        work_order_id:
          type: string
          format: uuid
          nullable: true
          description: Work order reference (null for non-work-order time like travel, vacation)
        work_type_id:
          type: string
          format: uuid
          description: Work type ID (expanded in `work_type` object)
        hours:
          type: number
          description: Hours for this entry
        start_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
          description: Start time (HH:MM) if time-based entry
        end_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
          description: End time (HH:MM) if time-based entry
        notes:
          type: string
          nullable: true
        entry_date:
          type: string
          format: date
          description: Date of the entry
        role_id:
          type: string
          format: uuid
          nullable: true
          description: Snapshot of the employee's role at the time the entry was created
        role:
          type: object
          nullable: true
          description: Role details at time of entry creation
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
          required: [id, name]
        timesheet:
          type: object
          description: Parent timesheet with employee information
          properties:
            id:
              type: string
              format: uuid
            timesheet_date:
              type: string
              format: date
            status:
              type: string
              enum: [draft, submitted, locked, processed]
            employee:
              type: object
              description: Employee who submitted the timesheet
              properties:
                id:
                  type: string
                  format: uuid
                name:
                  type: string
                emp_number:
                  type: string
                email:
                  type: string
                  format: email
              required: [id, name, emp_number]
          required: [id, timesheet_date, status, employee]
        work_type:
          type: object
          description: Work type classification for this entry
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            short_code:
              type: string
              description: Short code (e.g., LAB, TRVL, VAC)
            multiplier:
              type: number
              description: Pay multiplier (e.g., 1.0 for regular, 1.5 for overtime)
          required: [id, name, short_code]
        work_order:
          type: object
          nullable: true
          description: Work order details (null for non-work-order entries)
          properties:
            id:
              type: string
              format: uuid
            phase_name:
              type: string
            work_order_number:
              type: string
            project:
              type: object
              description: Parent project information
              properties:
                id:
                  type: string
                  format: uuid
                project_name:
                  type: string
                project_code:
                  type: string
              required: [id, project_name]
          required: [id, phase_name, work_order_number]
        linked_invoice:
          allOf: [{ $ref: '#/components/schemas/LinkedInvoiceRef' }]
          nullable: true
          description: Most recent invoice that billed this entry (v2.26.0). `null` when not billed.
      required: [id, timesheet_id, work_type_id, hours, entry_date]

    TimesheetCreate:
      type: object
      description: Create a new daily timesheet
      properties:
        employee_id:
          type: string
          format: uuid
          description: Employee UUID to identify the employee
        timesheet_date:
          type: string
          format: date
          description: Date of the daily timesheet (YYYY-MM-DD)
        status:
          type: string
          enum: [draft, submitted]
          default: draft
        notes:
          type: string
          nullable: true
        entries:
          type: array
          description: Time entries to create with the timesheet
          items:
            type: object
            properties:
              work_order_id:
                type: string
                format: uuid
                nullable: true
                description: Work order UUID - null for non-work-order time
              work_type_id:
                type: string
                format: uuid
                description: Work type UUID
              hours:
                type: number
                description: Hours for this entry
              start_time:
                type: string
                pattern: '^\d{2}:\d{2}$'
                nullable: true
              end_time:
                type: string
                pattern: '^\d{2}:\d{2}$'
                nullable: true
              notes:
                type: string
                nullable: true
            required: [work_type_id, hours]
      required: [employee_id, timesheet_date]

    TimesheetUpdate:
      type: object
      description: Update an existing timesheet
      properties:
        status:
          type: string
          enum: [draft, submitted, locked, processed]
        notes:
          type: string
          nullable: true
        entries:
          type: array
          description: Replace all entries with this array (full replacement)
          items:
            type: object
            properties:
              work_order_id:
                type: string
                format: uuid
                nullable: true
              work_type_id:
                type: string
                format: uuid
              hours:
                type: number
              start_time:
                type: string
                nullable: true
              end_time:
                type: string
                nullable: true
              notes:
                type: string
                nullable: true

    TimesheetEntryCreate:
      type: object
      description: Create a new timesheet entry
      properties:
        timesheet_id:
          type: string
          format: uuid
          description: ID of the parent timesheet
        entry_date:
          type: string
          format: date
          description: Date of the entry (YYYY-MM-DD)
        work_order_id:
          type: string
          format: uuid
          nullable: true
          description: Work order reference (null for non-work-order time)
        work_type_id:
          type: string
          format: uuid
          description: Work type ID
        hours:
          type: number
          minimum: 0
          maximum: 24
          description: Hours for this entry
        start_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
          description: Start time (HH:MM)
        end_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
          description: End time (HH:MM)
        notes:
          type: string
          nullable: true
      required: [timesheet_id, entry_date, work_type_id, hours]

    TimesheetEntryUpdate:
      type: object
      description: Update an existing timesheet entry
      properties:
        work_order_id:
          type: string
          format: uuid
          nullable: true
        work_type_id:
          type: string
          format: uuid
        hours:
          type: number
          minimum: 0
          maximum: 24
        start_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
        end_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
        notes:
          type: string
          nullable: true

    # Customer schemas
    Customer:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
          format: email
          nullable: true
        phone:
          type: string
          nullable: true
        website:
          type: string
          nullable: true
        type:
          type: string
          enum: [Commercial, Residential, Government, Non-Profit]
          nullable: true
        status:
          type: string
          enum: [Active, Inactive]
        account_manager_id:
          type: string
          format: uuid
          nullable: true
        account_manager_emp_number:
          type: string
          nullable: true
          description: Alternative to `account_manager_id` — resolve account manager by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        default_contact_id:
          type: string
          format: uuid
          nullable: true
          description: Default contact for this customer
        notes:
          type: string
          nullable: true
        custom_fields:
          type: object
          additionalProperties: true
          nullable: true
          description: Company-specific custom fields
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        # Embedded relations
        account_manager:
          type: object
          nullable: true
          description: Embedded account manager employee data
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            email:
              type: string
              nullable: true
            phone:
              type: string
              nullable: true
              description: Only included in single customer GET response
        locations:
          type: array
          description: Locations associated with this customer
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
              address_line_1:
                type: string
                nullable: true
              address_line_2:
                type: string
                nullable: true
                description: Only included in single customer GET response
              city:
                type: string
                nullable: true
              province:
                type: string
                nullable: true
              postal_code:
                type: string
                nullable: true
                description: Only included in single customer GET response
              country:
                type: string
                nullable: true
                description: Only included in single customer GET response
        contacts:
          type: array
          description: Contacts associated with this customer
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              first_name:
                type: string
              last_name:
                type: string
              email:
                type: string
                format: email
                nullable: true
              phone:
                type: string
                nullable: true
              mobile:
                type: string
                nullable: true
              title:
                type: string
                nullable: true
              is_primary:
                type: boolean
                description: Whether this is the primary contact for the customer
              notes:
                type: string
                nullable: true
                description: Only included in single customer GET response
        tax_exemptions:
          type: array
          description: Tax exemption records for this customer
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              exemption_type:
                type: string
                enum: [resale, government, non_profit, tribal, export, other]
              jurisdiction_level:
                type: string
                enum: [global, federal, regional]
              jurisdiction_name:
                type: string
                nullable: true
                description: Province/state name when jurisdiction_level is regional
              id_number:
                type: string
                description: Tax exemption ID number (e.g. PST number)
              certificate_name:
                type: string
                nullable: true
                description: Original filename of uploaded certificate
              expires_at:
                type: string
                format: date
                nullable: true
              status:
                type: string
                enum: [active, expired]
                description: Computed from expires_at — active if null or in the future
              notes:
                type: string
                nullable: true
      required: [id, name]

    CustomerCreate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
        email:
          type: string
          format: email
        phone:
          type: string
        website:
          type: string
        type:
          type: string
          enum: [Commercial, Residential, Government, Non-Profit]
        status:
          type: string
          enum: [Active, Inactive]
          default: Active
        account_manager_id:
          type: string
          format: uuid
          description: Account manager UUID (use this OR account_manager_emp_number)
        account_manager_emp_number:
          type: string
          description: Account manager employee number (e.g., "EMP-2001")
        notes:
          type: string
        custom_fields:
          type: object
          additionalProperties: true
          description: Company-specific custom fields
      required: [name]

    CustomerUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
        email:
          type: string
          format: email
        phone:
          type: string
        website:
          type: string
        type:
          type: string
          enum: [Commercial, Residential, Government, Non-Profit]
        status:
          type: string
          enum: [Active, Inactive]
        account_manager_id:
          type: string
          format: uuid
        account_manager_emp_number:
          type: string
          nullable: true
          description: Alternative to `account_manager_id` — resolve account manager by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        notes:
          type: string
        custom_fields:
          type: object
          additionalProperties: true
          description: Company-specific custom fields

    # Job address schemas (read-only repo of reusable job sites)
    JobAddress:
      type: object
      description: |
        A saved job site address from the company-wide repo. Projects and work orders
        snapshot these into their own `site_address` JSONB at create time, so editing the
        repo never mutates existing records.
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        address_line_1: { type: string, nullable: true }
        address_line_2: { type: string, nullable: true }
        city: { type: string, nullable: true }
        province: { type: string, nullable: true }
        postal_code: { type: string, nullable: true }
        country: { type: string, nullable: true }
        gps_coordinates:
          type: string
          nullable: true
          example: "51.044730, -114.071900"
          description: 'Optional GPS coordinates as "latitude, longitude" in decimal degrees. Replaces the previous split `latitude` / `longitude` fields.'
        contact_first_name: { type: string, nullable: true }
        contact_last_name: { type: string, nullable: true }
        contact_phone: { type: string, nullable: true }
        contact_email: { type: string, nullable: true }
        usage_count: { type: integer }
        last_used_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # Location schemas
    Location:
      type: object
      properties:
        id:
          type: string
          format: uuid
        customer_id:
          type: string
          format: uuid
        name:
          type: string
        address_line_1:
          type: string
          nullable: true
        address_line_2:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        province:
          type: string
          nullable: true
        postal_code:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        default_contact_id:
          type: string
          format: uuid
          nullable: true
          description: Default contact for this location
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, customer_id, name]

    LocationCreate:
      type: object
      properties:
        customer_id:
          type: string
          format: uuid
        name:
          type: string
          minLength: 1
        address_line_1:
          type: string
        address_line_2:
          type: string
        city:
          type: string
        province:
          type: string
        postal_code:
          type: string
        country:
          type: string
          default: Canada
        notes:
          type: string
      required: [customer_id, name]

    LocationUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
        address_line_1:
          type: string
        address_line_2:
          type: string
        city:
          type: string
        province:
          type: string
        postal_code:
          type: string
        country:
          type: string
        notes:
          type: string

    # Contact schemas
    Contact:
      type: object
      properties:
        id:
          type: string
          format: uuid
        customer_id:
          type: string
          format: uuid
        location_id:
          type: string
          format: uuid
          nullable: true
        first_name:
          type: string
        last_name:
          type: string
        title:
          type: string
          nullable: true
        email:
          type: string
          format: email
          nullable: true
        phone:
          type: string
          nullable: true
        phone_ext:
          type: string
          nullable: true
        mobile:
          type: string
          nullable: true
        is_primary:
          type: boolean
        contact_tags:
          type: array
          items:
            type: string
          nullable: true
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        customer:
          $ref: '#/components/schemas/Customer'
        location:
          $ref: '#/components/schemas/Location'
      required: [id, customer_id, first_name]

    ContactCreate:
      type: object
      properties:
        customer_id:
          type: string
          format: uuid
        location_id:
          type: string
          format: uuid
        first_name:
          type: string
          minLength: 1
        last_name:
          type: string
        title:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        phone_ext:
          type: string
        mobile:
          type: string
        is_primary:
          type: boolean
          default: false
        contact_tags:
          type: array
          items:
            type: string
        notes:
          type: string
      required: [customer_id, first_name]

    ContactUpdate:
      type: object
      properties:
        location_id:
          type: string
          format: uuid
        first_name:
          type: string
          minLength: 1
        last_name:
          type: string
          minLength: 1
        title:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        phone_ext:
          type: string
        mobile:
          type: string
        is_primary:
          type: boolean
        contact_tags:
          type: array
          items:
            type: string
        notes:
          type: string

    # Webhook schemas
    WebhookSubscription:
      type: object
      properties:
        id:
          type: string
          format: uuid
        app_install_id:
          type: string
          format: uuid
        event_types:
          type: array
          items:
            type: string
          description: Array of subscribed event types
        target_url:
          type: string
          format: uri
        is_active:
          type: boolean
        secret:
          type: string
          description: Webhook signing secret (only returned on creation)
        created_at:
          type: string
          format: date-time
      required: [id, event_types, target_url, is_active]

    WebhookSubscriptionCreate:
      type: object
      properties:
        event_types:
          type: array
          items:
            type: string
            enum:
              # Employee events
              - employee.created
              - employee.updated
              - employee.deleted
              # Project events
              - project.created
              - project.updated
              - project.deleted
              - project.status_changed
              - project.closed
              # Work Order events
              - work_order.created
              - work_order.updated
              - work_order.status_changed
              - work_order.deleted
              - work_order.progress_updated
              # Timesheet events
              - timesheet.created
              - timesheet.updated
              - timesheet.submitted
              - timesheet.locked
              - timesheet.unlocked
              - timesheet.processed
              # Report events
              - report.daily_submitted
              - report.completion_submitted
              - report.completion_approved
              # Customer events
              - customer.created
              - customer.updated
              - customer.deleted
              # Location events
              - location.created
              - location.updated
              - location.deleted
              # Contact events
              - contact.created
              - contact.updated
              - contact.deleted
              # Vendor events
              - vendor.created
              - vendor.updated
              - vendor.deleted
              - vendor.contact.created
              - vendor.contact.updated
              - vendor.contact.deleted
              - vendor.location.created
              - vendor.location.updated
              - vendor.location.deleted
              # Purchase Order events
              - po.created
              - po.updated
              - po.submitted
              - po.approved
              - po.rejected
              - po.received
              - po.attachment.added
              - po.attachment.removed
              # Schedule batch events
              - schedule.batch_created
              - schedule.batch_updated
              - schedule.batch_deleted
              # Assignment events
              - assignment.created
              - assignment.updated
              - assignment.deleted
              # Custom Booking events
              - custom_booking.created
              - custom_booking.updated
              - custom_booking.deleted
              # Time Off events
              - time_off.created
              - time_off.updated
              - time_off.deleted
              - time_off.approved
              - time_off.rejected
              # App events (Apps Platform)
              - app.installed
              - app.uninstalled
              - app.updated
              # Settings events
              - settings.updated
          minItems: 1
          description: |
            Array of event types to subscribe to.
            
            Note: `app.uninstalled` payloads include `install_mode` ("production" or "training") and
            `reason` ("user_initiated" or "training_session_ended") fields to distinguish manual uninstalls
            from automatic training session cleanup.
        url:
          type: string
          format: uri
          minLength: 1
          description: Target URL for webhook delivery
        secret:
          type: string
          description: Optional webhook signing secret (auto-generated if not provided)
      required: [event_types, url]

    # Report schemas
    Report:
      type: object
      properties:
        id:
          type: string
          format: uuid
        work_order_id:
          type: string
          format: uuid
        report_type:
          type: string
          enum: [daily, completion]
        report_date:
          type: string
          format: date
        scope_checklist:
          type: object
          nullable: true
        notes:
          type: string
          nullable: true
        photos:
          type: array
          nullable: true
          description: |
            Photos attached to the report. Items may be either a legacy URL string
            or the current object shape produced by `POST /reports/{id}/photos`.
          items:
            oneOf:
              - type: string
                format: uri
                description: Legacy — full URL or storage path
              - $ref: '#/components/schemas/ReportPhotoEntry'
        signature:
          type: string
          nullable: true
        submitted_by_user_id:
          type: string
          format: uuid
        submitted_at:
          type: string
          format: date-time
        approved_by_user_id:
          type: string
          format: uuid
          nullable: true
        approved_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
      required: [id, work_order_id, report_type, report_date, submitted_by_user_id, submitted_at]

    ReportCreate:
      type: object
      properties:
        work_order_id:
          type: string
          format: uuid
        report_type:
          type: string
          enum: [daily, completion]
        report_date:
          type: string
          format: date
        scope_checklist:
          type: object
          description: Checklist with task completion status
        notes:
          type: string
        photos:
          type: array
          description: |
            Array of photos. Items may be either a legacy URL string or a
            `ReportPhotoEntry` object. To upload a new image file, use
            `POST /reports/{id}/photos` instead of inlining it here.
          items:
            oneOf:
              - type: string
                format: uri
              - $ref: '#/components/schemas/ReportPhotoEntry'
        signature:
          type: string
          description: Signature data (base64 or URL)
      required: [work_order_id, report_type, report_date]

    ReportUpdate:
      type: object
      description: Fields that can be updated on an existing report
      properties:
        scope_checklist:
          type: object
          description: Updated checklist with task completion status
        notes:
          type: string
          description: Updated notes
        photos:
          type: array
          description: |
            Updated array of photos. Items may be either a legacy URL string or a
            `ReportPhotoEntry` object. To attach a new file, prefer
            `POST /reports/{id}/photos`.
          items:
            oneOf:
              - type: string
                format: uri
              - $ref: '#/components/schemas/ReportPhotoEntry'

    ReportPhotoEntry:
      type: object
      description: |
        Photo metadata stored on a `Report.photos` entry. Produced by
        `POST /reports/{id}/photos`. Legacy reports may still contain plain URL
        strings instead of this object.
      properties:
        id:
          type: string
          format: uuid
          description: Stable photo identifier
        url:
          type: string
          description: Storage path inside the `report-photos` bucket (NOT a public URL)
        name:
          type: string
          description: Original filename
        content_type:
          type: string
          example: image/jpeg
        size_bytes:
          type: integer
        uploaded_at:
          type: string
          format: date-time
        uploaded_by_app_id:
          type: string
          format: uuid
          nullable: true
          description: App install that uploaded the photo (null for UI uploads)
        caption:
          type: string
          nullable: true

    # Schedule schemas
    Schedule:
      type: object
      properties:
        id:
          type: string
          format: uuid
        work_order_id:
          type: string
          format: uuid
        assignment_id:
          type: string
          format: uuid
        work_date:
          type: string
          format: date
        start_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        end_time:
          type: string
          pattern: ^\d{2}:\d{2}$
        is_override:
          type: boolean
        is_active:
          type: boolean
        notes:
          type: string
          nullable: true
        calendar_event_uid:
          type: string
          nullable: true
        app_metadata:
          type: object
          nullable: true
          description: App-specific metadata for this schedule
        employee:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            email:
              type: string
            is_site_lead:
              type: boolean
              description: True if this employee is the designated site lead for this work order
        work_order:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            phase_name:
              type: string
            work_order_number:
              type: string
            project:
              type: object
              nullable: true
              properties:
                id:
                  type: string
                  format: uuid
                project_name:
                  type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, work_order_id, assignment_id, work_date, start_time, end_time]

    # Assignment schemas (Read-Only API)
    Assignment:
      type: object
      description: Employee assignment to a work order (date range)
      properties:
        id:
          type: string
          format: uuid
        employee_id:
          type: string
          format: uuid
          nullable: true
        temp_employee_id:
          type: string
          format: uuid
          nullable: true
          description: ID if assigned to a temporary employee
        work_order_id:
          type: string
          format: uuid
          description: Work order ID (mapped from phase_id)
        assigned_start_date:
          type: string
          format: date
        assigned_end_date:
          type: string
          format: date
        employee:
          type: object
          nullable: true
          description: Regular or temporary employee details
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            email:
              type: string
            emp_number:
              type: string
              nullable: true
            phone:
              type: string
              nullable: true
            is_temporary:
              type: boolean
              description: True if this is a temporary employee
        work_order:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            phase_name:
              type: string
            work_order_number:
              type: string
            status:
              type: string
            start_date:
              type: string
              format: date
              nullable: true
            end_date:
              type: string
              format: date
              nullable: true
            start_time:
              type: string
              nullable: true
            end_time:
              type: string
              nullable: true
            project:
              type: object
              nullable: true
              properties:
                id:
                  type: string
                  format: uuid
                project_name:
                  type: string
                project_code:
                  type: string
            location:
              type: object
              nullable: true
              properties:
                id:
                  type: string
                  format: uuid
                name:
                  type: string
                address_line_1:
                  type: string
                  nullable: true
                city:
                  type: string
                  nullable: true
                province:
                  type: string
                  nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, work_order_id, assigned_start_date, assigned_end_date]

    # Scheduling write-request schemas (api-assignments / api-schedules)
    SchedulingWarning:
      type: object
      description: |
        Soft conflict surfaced from a scheduling write. Mirrors the banners a
        manager sees in the in-app Schedule Wizard — never blocks the write.
      properties:
        code:
          type: string
          enum: [assignment_overlap, time_off_overlap, custom_booking_overlap]
        message:
          type: string
        conflicts:
          type: array
          description: Detail rows for the conflicting records (shape depends on `code`).
          items:
            type: object
            additionalProperties: true
      required: [code, message]

    AssignmentCreateRequest:
      type: object
      properties:
        work_order_id:
          type: string
          format: uuid
        employee_id:
          type: string
          format: uuid
          description: Provide exactly one of `employee_id` or `temp_employee_id`.
        temp_employee_id:
          type: string
          format: uuid
        assigned_start_date:
          type: string
          format: date
        assigned_end_date:
          type: string
          format: date
        external_id:
          type: string
          maxLength: 255
          description: Caller-supplied tracking ID, unique per company.
        idempotency_key:
          type: string
          minLength: 1
          maxLength: 64
          description: "Safe retry key; duplicates return the original record with `idempotent_replay: true`."
        notify:
          type: boolean
          default: true
          description: When false, suppresses employee/manager notifications (matches "Save as Draft").
        schedule_days:
          type: array
          description: Optional day rows inserted in the same call.
          items:
            type: object
            properties:
              work_date: { type: string, format: date }
              start_time: { type: string, pattern: '^\d{2}:\d{2}$' }
              end_time: { type: string, pattern: '^\d{2}:\d{2}$' }
              notes: { type: string, nullable: true }
              external_id: { type: string }
            required: [work_date, start_time, end_time]
      required: [work_order_id, assigned_start_date, assigned_end_date]

    AssignmentUpdateRequest:
      type: object
      properties:
        assigned_start_date: { type: string, format: date }
        assigned_end_date: { type: string, format: date }
        employee_id:
          type: string
          format: uuid
          description: Swap the assignment to a different employee.
        external_id:
          type: string
          nullable: true

    AssignmentWriteResponse:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/Assignment'
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/SchedulingWarning'
        schedule_days_created:
          type: integer
          description: Present on create when `schedule_days[]` was supplied.
        idempotent_replay:
          type: boolean
          description: True when a duplicate `idempotency_key` returned the original record.

    ScheduleCreateRequest:
      type: object
      properties:
        assignment_id: { type: string, format: uuid }
        work_date: { type: string, format: date }
        start_time: { type: string, pattern: '^\d{2}:\d{2}$' }
        end_time: { type: string, pattern: '^\d{2}:\d{2}$' }
        notes: { type: string, nullable: true }
        external_id: { type: string }
      required: [assignment_id, work_date, start_time, end_time]

    ScheduleBatchCreateRequest:
      type: object
      properties:
        assignment_id: { type: string, format: uuid }
        notify: { type: boolean, default: true }
        days:
          type: array
          minItems: 1
          items:
            type: object
            properties:
              work_date: { type: string, format: date }
              start_time: { type: string, pattern: '^\d{2}:\d{2}$' }
              end_time: { type: string, pattern: '^\d{2}:\d{2}$' }
              notes: { type: string, nullable: true }
              external_id: { type: string }
            required: [work_date, start_time, end_time]
      required: [assignment_id, days]

    ScheduleUpdateRequest:
      type: object
      description: |
        Update times, notes, `work_date`, or `is_active`. Any time or
        `work_date` change automatically stamps `is_override = true`.
      properties:
        work_date: { type: string, format: date }
        start_time: { type: string, pattern: '^\d{2}:\d{2}$' }
        end_time: { type: string, pattern: '^\d{2}:\d{2}$' }
        notes: { type: string, nullable: true }
        is_active:
          type: boolean
          description: Set to false to soft-delete the day without emitting `schedule.batch_deleted`.
        external_id: { type: string, nullable: true }

    ScheduleWriteResponse:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/Schedule'
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/SchedulingWarning'

    # Custom Booking schemas
    CustomBooking:
      type: object
      description: Non-work-order time booking for employees (training, meetings, etc.)
      properties:
        id:
          type: string
          format: uuid
        employee_id:
          type: string
          format: uuid
        booking_date:
          type: string
          format: date
        name:
          type: string
          description: Name/title of the booking
        description:
          type: string
          nullable: true
        start_time:
          type: string
          pattern: ^\d{2}:\d{2}(:\d{2})?$
        end_time:
          type: string
          pattern: ^\d{2}:\d{2}(:\d{2})?$
        work_type_id:
          type: string
          format: uuid
          nullable: true
        is_active:
          type: boolean
        is_availability:
          type: boolean
          description: Whether this booking represents an availability note rather than a time-bound booking
        external_id:
          type: string
          nullable: true
          description: External system reference ID for integration tracking
        badge_text:
          type: string
          maxLength: 50
          nullable: true
          description: Optional badge label displayed on the schedule card (max 50 chars)
        badge_url:
          type: string
          maxLength: 2000
          nullable: true
          description: URL opened when badge is clicked - can be external URL or internal app embed path (e.g., /a/apps/{installId}/embed?page=/details)
        employee:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            email:
              type: string
            emp_number:
              type: string
        work_type:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            short_code:
              type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, employee_id, booking_date, name, start_time, end_time, is_active, is_availability]

    CustomBookingCreate:
      type: object
      properties:
        employee_id:
          type: string
          format: uuid
        booking_date:
          type: string
          format: date
          description: Single booking date. Provide either booking_date OR booking_dates (not both).
        booking_dates:
          type: array
          description: Bulk-create the same booking across multiple dates (1–90 dates). Provide either booking_date OR booking_dates (not both). Duplicates are de-duplicated server-side. One row is created per date; one webhook is fired per row.
          items:
            type: string
            format: date
          minItems: 1
          maxItems: 90
        name:
          type: string
          minLength: 1
          maxLength: 255
        description:
          type: string
          maxLength: 1000
          nullable: true
        start_time:
          type: string
          pattern: ^\d{2}:\d{2}(:\d{2})?$
        end_time:
          type: string
          pattern: ^\d{2}:\d{2}(:\d{2})?$
        work_type_id:
          type: string
          format: uuid
          nullable: true
        external_id:
          type: string
          maxLength: 255
          nullable: true
          description: External system reference ID
        is_availability:
          type: boolean
          description: Whether this booking represents an availability note rather than a time-bound booking
        badge_text:
          type: string
          maxLength: 50
          nullable: true
          description: Badge label displayed on the schedule card (visible to employee in My Schedule)
        badge_url:
          type: string
          maxLength: 2000
          nullable: true
          description: URL opened when badge is clicked (external URL or app embed path)
      required: [employee_id, name, start_time, end_time]

    CustomBookingBatchCreate:
      type: object
      description: Create the same booking for multiple employees at once
      properties:
        employee_ids:
          type: array
          items:
            type: string
            format: uuid
          minItems: 1
          maxItems: 100
        booking_date:
          type: string
          format: date
          description: Single booking date. Provide either booking_date OR booking_dates (not both).
        booking_dates:
          type: array
          description: Bulk-create across multiple dates (1–90 dates). Provide either booking_date OR booking_dates (not both). Total fan-out (employee_ids × booking_dates) is capped at 500 rows per request.
          items:
            type: string
            format: date
          minItems: 1
          maxItems: 90
        name:
          type: string
          minLength: 1
          maxLength: 255
        description:
          type: string
          maxLength: 1000
          nullable: true
        start_time:
          type: string
          pattern: ^\d{2}:\d{2}(:\d{2})?$
        end_time:
          type: string
          pattern: ^\d{2}:\d{2}(:\d{2})?$
        work_type_id:
          type: string
          format: uuid
          nullable: true
        external_id:
          type: string
          maxLength: 255
          nullable: true
          description: Shared external ID for all created bookings
        is_availability:
          type: boolean
          description: Whether this booking represents an availability note rather than a time-bound booking
        badge_text:
          type: string
          maxLength: 50
          nullable: true
          description: Badge label displayed on the schedule card (shared across all created bookings)
        badge_url:
          type: string
          maxLength: 2000
          nullable: true
          description: URL opened when badge is clicked (shared across all created bookings)
      required: [employee_ids, name, start_time, end_time]

    CustomBookingBatchResponse:
      type: object
      properties:
        created:
          type: integer
          description: Number of bookings created
        external_id:
          type: string
          nullable: true
          description: Shared external ID if provided
        bookings:
          type: array
          items:
            $ref: '#/components/schemas/CustomBooking'

    CustomBookingUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        description:
          type: string
          maxLength: 1000
          nullable: true
        booking_date:
          type: string
          format: date
        start_time:
          type: string
          pattern: ^\d{2}:\d{2}(:\d{2})?$
        end_time:
          type: string
          pattern: ^\d{2}:\d{2}(:\d{2})?$
        work_type_id:
          type: string
          format: uuid
          nullable: true
        is_active:
          type: boolean
        is_availability:
          type: boolean
          description: Whether this booking represents an availability note rather than a time-bound booking
        external_id:
          type: string
          maxLength: 255
          nullable: true
        badge_text:
          type: string
          maxLength: 50
          nullable: true
          description: Badge label displayed on the schedule card
        badge_url:
          type: string
          maxLength: 2000
          nullable: true
          description: URL opened when badge is clicked

    # ==========================================
    # VENDOR SCHEMAS
    # ==========================================
    Vendor:
      type: object
      properties:
        id:
          type: string
          format: uuid
        vendor_name:
          type: string
        type:
          type: string
          enum: [vendor, distributor, manufacturer, subcontractor, rental_provider, supplier, partner, operations_facility, professional_services, technology_saas, admin_office, fleet_equipment, marketing_growth, hr_culture]
          description: Vendor classification type
        tags:
          type: array
          items:
            type: string
          description: Freeform tags for categorization
        trade_specialty:
          type: string
          nullable: true
        contact_name:
          type: string
          nullable: true
        email:
          type: string
          format: email
          nullable: true
        phone:
          type: string
          nullable: true
        address:
          type: string
          nullable: true
        address_line2:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        state:
          type: string
          nullable: true
        zip_code:
          type: string
          nullable: true
        approval_status:
          type: string
          enum: [pending, approved, expired]
        insurance_expiry_date:
          type: string
          format: date
          nullable: true
        compliance_expiry_date:
          type: string
          format: date
          nullable: true
        rating:
          type: integer
          minimum: 1
          maximum: 5
          nullable: true
        regions:
          type: array
          items:
            type: string
          nullable: true
        notes:
          type: string
          nullable: true
        custom_fields:
          type: object
          additionalProperties: true
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, vendor_name]

    VendorCreate:
      type: object
      properties:
        vendor_name:
          type: string
          minLength: 1
        type:
          type: string
          enum: [vendor, distributor, manufacturer, subcontractor, rental_provider, supplier, partner, operations_facility, professional_services, technology_saas, admin_office, fleet_equipment, marketing_growth, hr_culture]
          default: vendor
        tags:
          type: array
          items:
            type: string
        trade_specialty:
          type: string
        contact_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        address:
          type: string
        address_line2:
          type: string
        city:
          type: string
        state:
          type: string
        zip_code:
          type: string
        approval_status:
          type: string
          enum: [pending, approved, expired]
          default: pending
        insurance_expiry_date:
          type: string
          format: date
        compliance_expiry_date:
          type: string
          format: date
        rating:
          type: integer
          minimum: 1
          maximum: 5
        regions:
          type: array
          items:
            type: string
        notes:
          type: string
        custom_fields:
          type: object
          additionalProperties: true
      required: [vendor_name]

    VendorUpdate:
      type: object
      properties:
        type:
          type: string
          enum: [vendor, distributor, manufacturer, subcontractor, rental_provider, supplier, partner, operations_facility, professional_services, technology_saas, admin_office, fleet_equipment, marketing_growth, hr_culture]
        tags:
          type: array
          items:
            type: string
        trade_specialty:
          type: string
        contact_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        address:
          type: string
        address_line2:
          type: string
        city:
          type: string
        state:
          type: string
        zip_code:
          type: string
        approval_status:
          type: string
          enum: [pending, approved, expired]
        insurance_expiry_date:
          type: string
          format: date
        compliance_expiry_date:
          type: string
          format: date
        rating:
          type: integer
          minimum: 1
          maximum: 5
        regions:
          type: array
          items:
            type: string
        notes:
          type: string
        custom_fields:
          type: object
          additionalProperties: true

    VendorContact:
      type: object
      properties:
        id:
          type: string
          format: uuid
        vendor_id:
          type: string
          format: uuid
        first_name:
          type: string
        last_name:
          type: string
        email:
          type: string
          format: email
          nullable: true
        phone:
          type: string
          nullable: true
        mobile:
          type: string
          nullable: true
        role:
          type: string
          nullable: true
        is_primary:
          type: boolean
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, vendor_id, first_name]

    VendorContactCreate:
      type: object
      properties:
        first_name:
          type: string
          minLength: 1
        last_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        mobile:
          type: string
        role:
          type: string
        is_primary:
          type: boolean
          default: false
        notes:
          type: string
      required: [first_name]

    VendorLocation:
      type: object
      properties:
        id:
          type: string
          format: uuid
        vendor_id:
          type: string
          format: uuid
        name:
          type: string
        address_line_1:
          type: string
          nullable: true
        address_line_2:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        state:
          type: string
          nullable: true
        zip_code:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        is_default:
          type: boolean
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, vendor_id, name]

    VendorLocationCreate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
        address_line_1:
          type: string
        address_line_2:
          type: string
        city:
          type: string
        state:
          type: string
        zip_code:
          type: string
        country:
          type: string
        phone:
          type: string
        is_default:
          type: boolean
          default: false
        notes:
          type: string
      required: [name]

    VendorContactUpdate:
      type: object
      description: All fields are optional for PATCH updates
      properties:
        first_name:
          type: string
        last_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        mobile:
          type: string
        role:
          type: string
        is_primary:
          type: boolean
        notes:
          type: string

    VendorLocationUpdate:
      type: object
      description: All fields are optional for PATCH updates
      properties:
        name:
          type: string
        address_line_1:
          type: string
        address_line_2:
          type: string
        city:
          type: string
        state:
          type: string
        zip_code:
          type: string
        country:
          type: string
        phone:
          type: string
        is_default:
          type: boolean
        notes:
          type: string

    # ==========================================
    # SHARED COST LINKAGE SCHEMAS (v2.26.0)
    # ==========================================
    InvoiceCostLink:
      type: object
      description: |
        One entry in `invoice.cost_links[]`. Unified projection of a consumed cost source.
        `invoice_line_item_id` is `null` for hidden / fixed-price / summary-collapsed lines.
        Invariant: sum of amounts ≤ invoice.subtotal (vendor credit amounts are negative).
      required: [type, ws_id, amount, invoice_line_item_id]
      properties:
        type: { type: string, enum: [timesheet_entry, purchase_order_line, purchase_order, usage_record, vendor_credit] }
        ws_id: { type: string, format: uuid }
        amount: { type: number }
        invoice_line_item_id: { type: string, format: uuid, nullable: true }
        employee_name: { type: string, nullable: true }
        entry_date: { type: string, format: date, nullable: true }
        hours: { type: number, nullable: true }
        role_name: { type: string, nullable: true }
        po_id: { type: string, format: uuid, nullable: true }
        po_number: { type: string, nullable: true }
        quantity: { type: number, nullable: true }
        category: { type: string, nullable: true }
        cost_code: { type: string, nullable: true }

    LinkedInvoiceRef:
      type: object
      description: |
        Reverse-echo from a cost source (PO / timesheet entry / usage record / vendor credit)
        to the most recent invoice that billed it. `null` when not billed.
      required: [id, invoice_number, invoice_line_item_id, billed_amount]
      properties:
        id: { type: string, format: uuid }
        invoice_number: { type: string, nullable: true }
        invoice_line_item_id: { type: string, format: uuid, nullable: true }
        billed_amount: { type: number }

    # ==========================================
    # PURCHASE ORDER SCHEMAS
    # ==========================================
    PurchaseOrder:
      type: object
      properties:
        id:
          type: string
          format: uuid
        po_number:
          type: string
        project_id:
          type: string
          format: uuid
          nullable: true
        project:
          type: object
          nullable: true
          properties:
            project_code:
              type: string
            project_name:
              type: string
        work_order_id:
          type: string
          format: uuid
          nullable: true
        work_order:
          type: object
          nullable: true
          properties:
            work_order_number:
              type: string
            phase_name:
              type: string
        vendor_id:
          type: string
          format: uuid
          nullable: true
        vendor:
          type: object
          nullable: true
          properties:
            vendor_name:
              type: string
        po_type:
          type: string
          enum: [standard, service, blanket]
          nullable: true
          description: |
            Type of purchase order:
            - standard: Regular PO for materials/goods with quantity tracking
            - service: Service/labor PO with simplified receipt workflow
            - blanket: Open-ended PO for ongoing purchases
        destination_type:
          type: string
          enum: [job, inventory, overhead]
          nullable: true
          description: |
            Where the PO costs are allocated:
            - job: Linked to a specific project/work order
            - inventory: For warehouse/stock replenishment
            - overhead: General business expenses
        paid_by:
          type: string
          enum: [vendor_invoice, cash_debit, credit_card, reimbursement]
          default: vendor_invoice
          description: |
            How this expense was or will be paid:
            - vendor_invoice: Vendor will send an invoice (traditional PO flow)
            - cash_debit: Expense paid by cash or debit card
            - credit_card: Expense paid by company credit card (requires credit_card_id)
            - reimbursement: Employee paid out-of-pocket and needs reimbursement (requires reimbursement_employee_id)
        status:
          type: string
          enum: [draft, submitted, approved, rejected, partially_received, received, partially_billed, billed, cancelled]
        budget_amount:
          type: number
          nullable: true
          description: Optional PO-level budget expectation (total expected spend)
        not_to_exceed_amount:
          type: number
          nullable: true
          description: Optional PO-level maximum spending cap (hard ceiling, cannot exceed)
        subtotal:
          type: number
          nullable: true
        tax_inclusive:
          type: boolean
          default: false
          description: >
            When false (default), tax is added on top of the subtotal.
            When true, tax is embedded in line item prices and does not increase the total.
        tax_amount:
          type: number
          nullable: true
        total_amount:
          type: number
          nullable: true
        notes:
          type: string
          nullable: true
        internal_notes:
          type: string
          nullable: true
        delivery_address:
          type: string
          nullable: true
        expected_delivery_date:
          type: string
          format: date
          nullable: true
        submitted_by:
          type: string
          format: uuid
          nullable: true
        submitted_at:
          type: string
          format: date-time
          nullable: true
        approved_by:
          type: string
          format: uuid
          nullable: true
        approved_at:
          type: string
          format: date-time
          nullable: true
        rejected_by:
          type: string
          format: uuid
          nullable: true
        rejected_at:
          type: string
          format: date-time
          nullable: true
        rejection_reason:
          type: string
          nullable: true
        received_by:
          type: string
          format: uuid
          nullable: true
        received_at:
          type: string
          format: date-time
          nullable: true
        billed_by:
          type: string
          format: uuid
          nullable: true
          description: User ID who marked the PO as billed
        billed_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp when PO was marked as billed
        invoice_number:
          type: string
          nullable: true
          description: Invoice number from vendor/supplier
        invoice_amount:
          type: number
          nullable: true
          description: Actual invoice amount (may differ from PO total)
        invoice_date:
          type: string
          format: date
          nullable: true
          description: Date on the vendor invoice
        bill_due_date:
          type: string
          format: date
          nullable: true
          description: Date the bill/invoice is due for payment
        credit_card_id:
          type: string
          format: uuid
          nullable: true
          description: ID of the company credit card used for this purchase
        credit_card:
          type: object
          nullable: true
          description: Credit card details (included in responses)
          properties:
            id:
              type: string
              format: uuid
            card_name:
              type: string
            card_type:
              type: string
              enum: [visa, mastercard, amex, discover, other]
        reimbursement_employee_id:
          type: string
          format: uuid
          nullable: true
          description: |
            ID of the employee to be reimbursed.
            **Required when paid_by is 'reimbursement'**.
        reimbursement_emp_number:
          type: string
          nullable: true
          description: Alternative to `reimbursement_employee_id` — resolve reimbursement employee by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        reimbursement_employee:
          type: object
          nullable: true
          description: Employee details for reimbursement (included in responses when applicable)
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            emp_number:
              type: string
            email:
              type: string
        line_items:
          type: array
          items:
            $ref: '#/components/schemas/PurchaseOrderLineItem'
        tax_lines:
          type: array
          description: |
            PO-level tax breakdown from po_taxes. When tax_amount > 0 but no detailed breakdown exists,
            returns a single fallback entry: [{ tax_name: "Tax", tax_rate: 0, tax_amount: <value> }].
          items:
            $ref: '#/components/schemas/POInvoiceTaxLine'
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/PurchaseOrderAttachment'
        invoice_taxes:
          type: array
          description: Tax breakdown for vendor invoice (when billed)
          items:
            $ref: '#/components/schemas/POInvoiceTaxLine'
        sync_status:
          type: string
          enum: [unsynced, pending, synced, error]
          default: unsynced
          description: Sync status for external accounting integrations
        sync_message:
          type: string
          nullable: true
          description: Optional message from the accounting system (e.g., error details)
        bill:
          type: object
          nullable: true
          description: |
            Grouped billing/invoice data. Null when the PO has not been billed (no invoice_number).
            Includes signed attachment URLs valid for 1 hour.
          properties:
            invoice_number:
              type: string
              description: Invoice number from vendor/supplier
            invoice_date:
              type: string
              format: date
              nullable: true
              description: Date on the vendor invoice
            invoice_amount:
              type: number
              nullable: true
              description: Actual invoice amount (may differ from PO total)
            bill_due_date:
              type: string
              format: date
              nullable: true
              description: Date the bill/invoice is due for payment
            tax_lines:
              type: array
              description: |
                Invoice-level tax breakdown. When invoice tax_amount > 0 but no detailed breakdown exists,
                returns a single fallback entry: [{ tax_name: "Tax", tax_rate: 0, tax_amount: <value> }].
              items:
                type: object
                properties:
                  tax_name:
                    type: string
                  tax_rate:
                    type: number
                  tax_amount:
                    type: number
                  tax_code_id:
                    type: string
                    format: uuid
                    nullable: true
                    description: Reference to the tax code used
                  component_amounts:
                    type: array
                    nullable: true
                    description: Per-component tax breakdown
                    items:
                      $ref: '#/components/schemas/TaxComponentAmount'
            attachments:
              type: array
              description: Invoice file attachments with signed download URLs
              items:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  file_name:
                    type: string
                  file_url:
                    type: string
                    nullable: true
                    description: Signed URL for downloading the file (expires in 1 hour)
                  mime_type:
                    type: string
                    nullable: true
                  file_size:
                    type: integer
                    nullable: true
        bill_count:
          type: integer
          default: 0
          description: Number of finalized bills linked to this PO
        bills:
          type: array
          description: Array of individual bills for multi-bill POs
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              bill_number:
                type: integer
              status:
                type: string
                enum: [draft, finalized]
              is_backorder:
                type: boolean
                default: false
              invoice_number:
                type: string
                nullable: true
              invoice_date:
                type: string
                format: date
                nullable: true
              bill_due_date:
                type: string
                format: date
                nullable: true
              subtotal:
                type: number
                nullable: true
              tax_amount:
                type: number
                nullable: true
              total_amount:
                type: number
                nullable: true
              tax_inclusive:
                type: boolean
                default: false
                description: Whether tax amounts are included in line item prices
              finalized_at:
                type: string
                format: date-time
                nullable: true
                description: Timestamp when the bill was finalized
              line_items:
                type: array
                description: |
                  Bill line items. Mirrors the underlying `po_bill_line_items`
                  database row 1:1. `line_total` returns the persisted DB value
                  with a computed fallback only when the column is null
                  (legacy rows).
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      format: uuid
                    po_bill_id:
                      type: string
                      format: uuid
                    po_line_item_id:
                      type: string
                      format: uuid
                      nullable: true
                      description: FK back to the originating purchase order line item.
                    description:
                      type: string
                    master_description:
                      type: string
                      nullable: true
                      description: Canonical product description from the price book master record.
                    sku:
                      type: string
                      nullable: true
                    cost_code:
                      type: string
                      nullable: true
                    product_id:
                      type: string
                      format: uuid
                      nullable: true
                    unit:
                      type: string
                      nullable: true
                    unit_price:
                      type: number
                    quantity:
                      type: number
                      nullable: true
                    line_total:
                      type: number
                    display_order:
                      type: integer
                      nullable: true
                    notes:
                      type: string
                      nullable: true
                    is_taxable:
                      type: boolean
                      default: true
                    tax_code_id:
                      type: string
                      format: uuid
                      nullable: true
                    created_at:
                      type: string
                      format: date-time
              tax_lines:
                type: array
                items:
                  type: object
                  properties:
                    tax_name:
                      type: string
                    tax_rate:
                      type: number
                    tax_amount:
                      type: number
                    tax_code_id:
                      type: string
                      format: uuid
                      nullable: true
                    component_amounts:
                      type: array
                      nullable: true
                      items:
                        $ref: '#/components/schemas/TaxComponentAmount'
              created_at:
                type: string
                format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        linked_invoice:
          allOf: [{ $ref: '#/components/schemas/LinkedInvoiceRef' }]
          nullable: true
          description: Most recent invoice that billed this PO (v2.26.0). `null` when not billed.



    POInvoiceTaxLine:
      type: object
      description: Individual tax line for a vendor invoice on a PO
      properties:
        id:
          type: string
          format: uuid
        purchase_order_id:
          type: string
          format: uuid
        tax_name:
          type: string
          description: Name of the tax (e.g., GST, PST, HST)
        tax_rate:
          type: number
          description: Tax rate as percentage (e.g., 5 for 5%)
        tax_amount:
          type: number
          description: Calculated tax amount
        tax_code_id:
          type: string
          format: uuid
          nullable: true
          description: Reference to the tax code used (from tax_codes table)
        component_amounts:
          type: array
          nullable: true
          description: Per-component tax breakdown when a multi-component tax code is used
          items:
            $ref: '#/components/schemas/TaxComponentAmount'
        display_order:
          type: integer
        created_at:
          type: string
          format: date-time

    PurchaseOrderLineItem:
      type: object
      description: |
        Raw purchase order line item, mirrored from the `purchase_order_line_items`
        table. Returned on both `GET /purchase-orders` and `GET /purchase-orders/{id}`
        with identical shape.
      properties:
        id:
          type: string
          format: uuid
        purchase_order_id:
          type: string
          format: uuid
        description:
          type: string
        master_description:
          type: string
          nullable: true
          description: Canonical product description from the price book master record (when linked).
        category:
          type: string
          nullable: true
        cost_code:
          type: string
          nullable: true
        sku:
          type: string
          nullable: true
        product_id:
          type: string
          format: uuid
          nullable: true
          description: Linked price book product ID (when matched).
        unit:
          type: string
          nullable: true
        unit_price:
          type: number
        quantity:
          type: number
          nullable: true
          description: Legacy quantity field. Prefer `qty_ordered` for ordered amounts.
        qty_ordered:
          type: number
          nullable: true
        qty_received:
          type: number
          nullable: true
        qty_invoiced:
          type: number
          nullable: true
        line_total:
          type: number
          description: Persisted line total. Falls back to a computed value when null in the database.
        max_amount:
          type: number
          nullable: true
          description: Optional maximum amount ceiling for this line item.
        display_order:
          type: integer
          nullable: true
        notes:
          type: string
          nullable: true
        is_sample:
          type: boolean
          default: false
        received_at:
          type: string
          format: date-time
          nullable: true
        received_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    PurchaseOrderAttachment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        file_name:
          type: string
        url:
          type: string
          description: Signed download URL valid for 1 hour. This replaces the internal file_path — consumers should use this URL to download the attachment.
        file_size:
          type: integer
          nullable: true
        file_type:
          type: string
          nullable: true
        mime_type:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        uploaded_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time

    PurchaseOrderPayment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        purchase_order_id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        amount:
          type: number
          description: Payment amount
        paid_at:
          type: string
          format: date
          description: Date the payment was made
        payment_method:
          type: string
          enum: [check, wire, credit_card, ach, cash, other]
          description: Method of payment
        reference:
          type: string
          nullable: true
          description: Check number, transaction ID, etc.
        notes:
          type: string
          nullable: true
        external_id:
          type: string
          nullable: true
          description: External system ID for this payment (e.g., QuickBooks payment ID)
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    PurchaseOrderPaymentCreate:
      type: object
      required:
        - amount
        - payment_method
      properties:
        amount:
          type: number
          minimum: 0.01
          description: Payment amount (must be greater than 0)
        paid_at:
          type: string
          format: date
          description: Date the payment was made (defaults to today)
        payment_method:
          type: string
          enum: [check, wire, credit_card, ach, cash, other]
          description: Method of payment
        reference:
          type: string
          maxLength: 100
          description: Check number, transaction ID, etc.
        notes:
          type: string
          maxLength: 500
          description: Optional payment notes
        external_id:
          type: string
          maxLength: 255
          nullable: true
          description: External system ID for this payment (e.g., QuickBooks payment ID). Used to track if the payment was recorded from a 3rd-party app.

    PurchaseOrderCreate:
      type: object
      description: |
        Create a new purchase order. At least one line item is **required**.
        
        **Validation Rules:**
        - When `destination_type` is "job", `work_order_id` is **required**
        - When `work_order_id` is provided, the project is automatically derived from the work order
      required:
        - line_items
      properties:
        po_number:
          type: string
        project_id:
          type: string
          format: uuid
        work_order_id:
          type: string
          format: uuid
          description: |
            Work order UUID. **Required when destination_type is 'job'**.
            When provided, project_id is automatically derived from the work order.
        vendor_id:
          type: string
          format: uuid
        po_type:
          type: string
          enum: [standard, service, blanket]
          description: Type of purchase order (defaults to 'standard')
        destination_type:
          type: string
          enum: [job, inventory, overhead]
          description: |
            Cost allocation destination.
            - **job**: Costs allocated to a specific work order (**requires work_order_id**)
            - **inventory**: Costs allocated to inventory
            - **overhead**: General company overhead expenses
        paid_by:
          type: string
          enum: [vendor_invoice, cash_debit, credit_card, reimbursement]
          default: vendor_invoice
          description: |
            How this expense was or will be paid.
            - **vendor_invoice**: Vendor will send an invoice (traditional PO flow)
            - **cash_debit**: Expense paid by cash or debit card
            - **credit_card**: Expense paid by company credit card (**requires credit_card_id**)
            - **reimbursement**: Employee paid out-of-pocket and needs reimbursement (**requires reimbursement_employee_id**)
        budget_amount:
          type: number
          description: Optional PO-level budget expectation
        not_to_exceed_amount:
          type: number
          description: Optional PO-level maximum spending cap
        notes:
          type: string
        internal_notes:
          type: string
        delivery_address:
          type: string
        expected_delivery_date:
          type: string
          format: date
        credit_card_id:
          type: string
          format: uuid
          nullable: true
          description: |
            ID of the company credit card used for this purchase.
            **Required when paid_by is 'credit_card'**.
        reimbursement_employee_id:
          type: string
          format: uuid
          nullable: true
          description: |
            ID of the employee to be reimbursed.
            **Required when paid_by is 'reimbursement'**.
        reimbursement_emp_number:
          type: string
          nullable: true
          description: Alternative to `reimbursement_employee_id` — resolve reimbursement employee by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        line_items:
          type: array
          items:
            type: object
            required:
              - description
              - quantity
              - unit_price
            properties:
              description:
                type: string
              quantity:
                type: number
              unit:
                type: string
              unit_price:
                type: number
              category:
                type: string
              sku:
                type: string
              notes:
                type: string
                max_amount:
                  type: number
                  description: Optional per-item ceiling amount
        tax_lines:
          type: array
          description: |
            Tax lines to apply to this purchase order. Each entry represents a distinct tax (e.g., GST, PST).
            When `tax_code_id` is provided, component-level breakdown is automatically resolved.
          items:
            type: object
            required:
              - tax_name
              - tax_rate
            properties:
              tax_name:
                type: string
                description: Display name for this tax (e.g., "GST", "PST")
              tax_rate:
                type: number
                description: Tax rate as a percentage (e.g., 5 for 5%)
              amount:
                type: number
                description: Pre-calculated tax amount. If omitted, defaults to 0.
              tax_code_id:
                type: string
                format: uuid
                nullable: true
                description: Optional tax code UUID for component-based tax resolution

    PurchaseOrderUpdate:
      type: object
      properties:
        vendor_id:
          type: string
          format: uuid
        paid_by:
          type: string
          enum: [vendor_invoice, cash_debit, credit_card, reimbursement]
          description: |
            How this expense was or will be paid.
            - **vendor_invoice**: Vendor will send an invoice
            - **cash_debit**: Expense paid by cash or debit card
            - **credit_card**: Expense paid by company credit card
            - **reimbursement**: Employee paid out-of-pocket and needs reimbursement
        budget_amount:
          type: number
          description: Optional PO-level budget expectation
        not_to_exceed_amount:
          type: number
          description: Optional PO-level maximum spending cap
        notes:
          type: string
        internal_notes:
          type: string
        delivery_address:
          type: string
        expected_delivery_date:
          type: string
          format: date
        credit_card_id:
          type: string
          format: uuid
          nullable: true
          description: |
            ID of the company credit card used for this purchase.
            **Required when paid_by is 'credit_card'**.
        reimbursement_employee_id:
          type: string
          format: uuid
          nullable: true
          description: |
            ID of the employee to be reimbursed.
            **Required when paid_by is 'reimbursement'**.
        reimbursement_emp_number:
          type: string
          nullable: true
          description: Alternative to `reimbursement_employee_id` — resolve reimbursement employee by human-readable emp_number (company-scoped). Provide either the UUID or the emp_number, not both.
        line_items:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              description:
                type: string
              quantity:
                type: number
              max_amount:
                type: number
                description: Optional per-item ceiling amount
              unit:
                type: string
              unit_price:
                type: number
              category:
                type: string
              sku:
                type: string
              notes:
                type: string
        tax_lines:
          type: array
          description: |
            Tax lines to replace on this purchase order (delete-and-reinsert).
            When provided, all existing tax lines are removed and replaced with this array.
          items:
            type: object
            required:
              - tax_name
              - tax_rate
            properties:
              tax_name:
                type: string
              tax_rate:
                type: number
              amount:
                type: number
              tax_code_id:
                type: string
                format: uuid
                nullable: true

    PORejectBody:
      type: object
      required:
        - rejection_reason
      properties:
        rejection_reason:
          type: string

    # ==========================================
    # INVOICE SCHEMAS
    # ==========================================
    Invoice:
      type: object
      properties:
        id:
          type: string
          format: uuid
        invoice_number:
          type: string
        invoice_type:
          type: string
          enum: [standard, progress, final, credit, retainage]
        customer_id:
          type: string
          format: uuid
          nullable: true
        customer:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
        project_id:
          type: string
          format: uuid
          nullable: true
        project:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            project_name:
              type: string
            project_code:
              type: string
        work_order_id:
          type: string
          format: uuid
          nullable: true
        work_order:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            phase_name:
              type: string
            work_order_number:
              type: string
        location_id:
          type: string
          format: uuid
          nullable: true
          description: Service/billing location ID
        location:
          type: object
          nullable: true
          description: Location details
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            address_line_1:
              type: string
              nullable: true
            city:
              type: string
              nullable: true
            province:
              type: string
              nullable: true
            postal_code:
              type: string
              nullable: true
        invoice_contacts:
          type: array
          description: Contacts associated with this invoice (for email delivery)
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              contact_id:
                type: string
                format: uuid
              contact:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  first_name:
                    type: string
                  last_name:
                    type: string
                  email:
                    type: string
                    nullable: true
                  phone:
                    type: string
                    nullable: true
                  title:
                    type: string
                    nullable: true
        status:
          type: string
          enum: [draft, sent, viewed, partial, paid, void]
        invoice_date:
          type: string
          format: date
        due_date:
          type: string
          format: date
          nullable: true
        revision_count:
          type: integer
          description: Number of times this invoice has been revised after initial send
          default: 0
        sent_at:
          type: string
          format: date-time
          nullable: true
        viewed_at:
          type: string
          format: date-time
          nullable: true
        paid_at:
          type: string
          format: date-time
          nullable: true
        voided_at:
          type: string
          format: date-time
          nullable: true
        subtotal:
          type: number
        tax_inclusive:
          type: boolean
          default: false
          description: >
            When false (default), tax is added on top of the subtotal.
            When true, tax is embedded in line item prices and does not increase the total.
        tax_rate:
          type: number
          nullable: true
        tax_amount:
          type: number
        net_taxable_amount:
          type: number
          description: >
            The effective taxable base after subtracting holdback (pre_tax method) and exempt line items.
            Useful for reconciliation with external accounting systems.
        total_amount:
          type: number
        amount_paid:
          type: number
        balance_due:
          type: number
          readOnly: true
          description: Remaining balance (auto-calculated as total_amount - amount_paid)
        holdback_percent:
          type: number
          nullable: true
          description: Holdback/retainage percentage (e.g., 10.00 for 10%). Passed from billing app.
        holdback_amount:
          type: number
          nullable: true
          description: Holdback amount to withhold from this invoice. Passed from billing app.
        holdback_label:
          type: string
          nullable: true
          maxLength: 50
          description: |
            Custom label for the holdback line on the invoice.
            Use "Holdback" for Canada or "Retainage" for US.
            Defaults to "Holdback" if not provided.
        holdback_tax_method:
          type: string
          nullable: true
          enum: [pre_tax, post_tax]
          description: |
            Tax treatment for holdback. "pre_tax" (Canada): holdback reduces
            the taxable base before tax is calculated. "post_tax" (USA): holdback
            is deducted after tax. Auto-set from jurisdiction if not provided.
        discount_type:
          type: string
          nullable: true
          enum: [percent, amount]
          description: |
            Type of invoice-level discount. "percent" applies discount_value as a
            percentage of the subtotal; "amount" applies discount_value as a flat
            dollar amount. Null when no discount is applied.
        discount_value:
          type: number
          nullable: true
          minimum: 0
          description: |
            Raw discount value. Interpreted as a percentage (e.g., 10 for 10%) when
            discount_type = "percent", or as a flat amount when discount_type = "amount".
        discount_amount:
          type: number
          nullable: true
          readOnly: true
          description: |
            Server-resolved dollar value of the discount, derived from discount_type
            and discount_value against the invoice subtotal. Read-only.
        discount_label:
          type: string
          nullable: true
          maxLength: 100
          description: |
            Custom label for the discount line on the invoice (e.g., "Customer Loyalty",
            "Volume Discount"). Defaults to "Discount" if not provided.
        discount_taxable:
          type: boolean
          default: true
          description: |
            When true (default), the discount is applied pre-tax: it reduces the taxable
            base proportionally across tax buckets before tax is calculated. When false,
            the discount is applied post-tax (subtracted from the final total after tax).
        payment_status:
          type: string
          enum: [unpaid, partially_paid, paid, overpaid]
        payment_terms:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        internal_notes:
          type: string
          nullable: true
        terms_and_conditions:
          type: string
          nullable: true
        void_reason:
          type: string
          nullable: true
        line_items:
          type: array
          items:
            $ref: '#/components/schemas/InvoiceLineItem'
        payments:
          type: array
          items:
            $ref: '#/components/schemas/InvoicePayment'
        taxes:
          type: array
          description: Multiple tax lines applied to this invoice
          items:
            $ref: '#/components/schemas/InvoiceTaxLine'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        customer_po_numbers:
          type: array
          nullable: true
          items:
            type: string
            maxLength: 100
          description: |
            Array of customer purchase order numbers associated with this invoice.
            These are typically collected from the original budget proposal and 
            all approved change order proposals being billed.

        billing_mode:
          type: string
          enum: [detailed, summary]
          description: Detailed (line-by-line) vs Summary (roll-up) mode.
        summary_group_by:
          type: string
          enum: [category, cost_code, date, source_type, employee, role]
          description: Grouping dimension for summary mode. Ignored when billing_mode is detailed.
        display_settings:
          type: object
          nullable: true
          description: Per-category display modes, column visibility, and fixed-price category_amounts
          properties:
            category_modes:
              type: object
              additionalProperties:
                type: string
                enum: [header_only, itemized, group_date, group_employee, group_role, group_name]
            column_visibility:
              type: object
              additionalProperties:
                type: object
                properties:
                  qty:
                    type: boolean
                  unit:
                    type: boolean
                  unit_price:
                    type: boolean
                  total:
                    type: boolean
            category_amounts:
              type: object
              additionalProperties:
                type: number
              description: Fixed-price override amounts per category (only present for Fixed Price invoices)
            category_labels:
              type: object
              additionalProperties:
                type: string
              description: Custom display labels per category
            virtual_categories:
              type: array
              items:
                type: string
              description: Keys of virtual categories with no underlying line items. Generate synthetic line items with source_type "virtual" in payloads
        sync_status:
          type: string
          enum: [unsynced, pending, synced, error]
        sync_message:
          type: string
          nullable: true
        tax_exemption_snapshot:
          type: array
          nullable: true
          description: |
            Point-in-time snapshot of customer tax exemptions active at invoice creation.
            Auto-populated from customer_tax_exemptions if not provided.
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              exemption_type:
                type: string
                description: Type of exemption (e.g., GST, PST, HST)
              jurisdiction_level:
                type: string
                enum: [global, regional]
              jurisdiction_name:
                type: string
                nullable: true
              id_number:
                type: string
              expires_at:
                type: string
                format: date
                nullable: true
        cost_links:
          type: array
          description: Unified projection of every consumed cost source (v2.26.0). See `InvoiceCostLink`.
          items:
            $ref: '#/components/schemas/InvoiceCostLink'

    InvoiceTaxLine:
      type: object
      description: Individual tax line on an invoice
      properties:
        id:
          type: string
          format: uuid
        invoice_id:
          type: string
          format: uuid
        tax_name:
          type: string
          description: Name of the tax (e.g., GST, PST, HST, State Tax)
        tax_rate:
          type: number
          description: Tax rate as percentage (e.g., 5 for 5%)
        tax_amount:
          type: number
          description: Calculated tax amount based on subtotal
        tax_code_id:
          type: string
          format: uuid
          nullable: true
          description: Reference to the tax code used (from tax_codes table)
        component_amounts:
          type: array
          nullable: true
          description: Per-component tax breakdown when a multi-component tax code is used
          items:
            $ref: '#/components/schemas/TaxComponentAmount'
        display_order:
          type: integer
          description: Order for display purposes
        created_at:
          type: string
          format: date-time

    InvoiceLineItem:
      type: object
      properties:
        id:
          type: string
          format: uuid
        invoice_id:
          type: string
          format: uuid
        description:
          type: string
        quantity:
          type: number
        unit:
          type: string
          nullable: true
        unit_price:
          type: number
        line_total:
          type: number
          readOnly: true
          description: Line total (auto-calculated as quantity × unit_price)
        category:
          type: string
          nullable: true
        cost_code:
          type: string
          nullable: true
        sort_order:
          type: integer
        source_type:
          type: string
          enum: [usage_record, purchase_order, labor, po_line_item, timesheet, timesheet_entry, virtual]
          nullable: true
          description: Origin type linking this line item to a source record. Virtual items are synthetic entries injected for virtual categories
        source_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of the originating source record
        purchase_order_id:
          type: string
          format: uuid
          nullable: true
          description: Parent Purchase Order UUID. Present when source_type is po_line_item; null otherwise.
        employee_name:
          type: string
          nullable: true
          description: Name of the employee associated with this line item
        entry_date:
          type: string
          format: date
          nullable: true
          description: Date of the source entry (e.g., timesheet date)
        role_name:
          type: string
          nullable: true
          description: Role/position of the employee for this line item
        internal_cost:
          type: number
          nullable: true
          description: Internal cost tracking (not visible to customers)
        is_excluded:
          type: boolean
          default: false
          description: When true, line item is excluded from invoice subtotal
        tax_code_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            Tax code for this line item (from tax_codes table). When explicitly set, this line uses the specified tax code instead of the document-level taxes.
            When null and the document has 2+ tax lines, the server may auto-populate this with a combined tax code whose component rates match all document-level taxes.
        is_taxable:
          type: boolean
          default: true
          description: Whether this line item is subject to tax. Set to false for tax-exempt items.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    InvoicePayment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        invoice_id:
          type: string
          format: uuid
        amount:
          type: number
        paid_at:
          type: string
          format: date
        payment_method:
          type: string
          enum: [check, wire, credit_card, ach, cash, other]
          nullable: true
        reference_number:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    InvoiceCreate:
      type: object
      properties:
        invoice_number:
          type: string
          maxLength: 50
          description: Optional custom invoice number. If not provided, one will be auto-generated by the database.
          example: "INV-2026-0001"
        customer_id:
          type: string
          format: uuid
          nullable: true
        project_id:
          type: string
          format: uuid
          nullable: true
        work_order_id:
          type: string
          format: uuid
          nullable: true
        location_id:
          type: string
          format: uuid
          nullable: true
          description: Service/billing location ID
        contact_ids:
          type: array
          nullable: true
          items:
            type: string
            format: uuid
          description: Array of contact UUIDs to associate with this invoice (used for email delivery)
        invoice_type:
          type: string
          enum: [standard, progress, final, credit, retainage]
          default: standard
        invoice_date:
          type: string
          format: date
        due_date:
          type: string
          format: date
          nullable: true
        tax_rate:
          type: number
          nullable: true
          description: "Legacy: Tax rate as decimal (e.g., 0.08 for 8%). Use 'taxes' array for multiple taxes."
        taxes:
          type: array
          description: "Tax lines to apply. Each line specifies a tax name and rate percentage. Optionally reference a tax_code_id for component-based tax tracking. When 2+ tax lines are provided, the server auto-resolves a combined tax code for inheriting line items (those with tax_code_id: null)."
          items:
            type: object
            required:
              - tax_name
              - tax_rate
            properties:
              tax_name:
                type: string
                description: Tax name (e.g., "GST", "PST", "HST", "State Tax")
                maxLength: 50
              tax_rate:
                type: number
                description: Tax rate as percentage (e.g., 5 for 5%, 7.5 for 7.5%)
                minimum: 0
                maximum: 100
              tax_code_id:
                type: string
                format: uuid
                nullable: true
                description: Optional reference to a tax code. When provided, component_amounts are calculated server-side from the tax code's components.
        tax_inclusive:
          type: boolean
          default: false
          description: >
            When false (default), tax is added on top of the subtotal.
            When true, tax is embedded in line item prices and does not increase the total.
        payment_terms:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        internal_notes:
          type: string
          nullable: true
        terms_and_conditions:
          type: string
          nullable: true
        line_items:
          type: array
          items:
            type: object
            required:
              - description
              - quantity
              - unit_price
            properties:
              description:
                type: string
              quantity:
                type: number
              unit:
                type: string
              unit_price:
                type: number
              category:
                type: string
              cost_code:
                type: string
              sort_order:
                type: integer
              source_type:
                type: string
                enum: [usage_record, purchase_order, labor, po_line_item, timesheet, timesheet_entry, virtual]
                nullable: true
              source_id:
                type: string
                format: uuid
                nullable: true
              purchase_order_id:
                type: string
                format: uuid
                nullable: true
                description: Parent Purchase Order UUID. Present when source_type is po_line_item; null otherwise.
              internal_cost:
                type: number
                nullable: true
              is_excluded:
                type: boolean
                default: false
              tax_code_id:
                type: string
                format: uuid
                nullable: true
                description: Override tax code for this line item. When set, this line uses the specified tax code instead of document-level taxes.
              is_taxable:
                type: boolean
                default: true
                description: Whether this line item is subject to tax. Set to false for tax-exempt items.
        send_immediately:
          type: boolean
          default: false
          description: |
            If true, sends the invoice email to the customer immediately after creation.
            The invoice status will be set to 'sent' instead of 'draft'.
        recipient_email:
          type: string
          format: email
          description: |
            Email address for sending the invoice when send_immediately is true.
            If not provided, uses the customer's email address.
        holdback_percent:
          type: number
          nullable: true
          minimum: 0
          maximum: 100
          description: Holdback/retainage percentage (e.g., 10.00 for 10%)
        holdback_amount:
          type: number
          nullable: true
          minimum: 0
          description: Holdback amount to withhold from this invoice
        holdback_label:
          type: string
          nullable: true
          maxLength: 50
          description: |
            Custom label for the holdback line on the invoice.
            Use "Holdback" for Canada or "Retainage" for US.
            Defaults to "Holdback" if not provided.
        holdback_tax_method:
          type: string
          nullable: true
          enum: [pre_tax, post_tax]
          description: |
            Tax treatment for holdback. "pre_tax" (Canada): holdback reduces
            the taxable base before tax. "post_tax" (USA): holdback deducted after tax.
        discount_type:
          type: string
          nullable: true
          enum: [percent, amount]
          description: |
            Type of invoice-level discount. "percent" applies discount_value as a
            percentage of the subtotal; "amount" applies discount_value as a flat
            dollar amount. Omit or null for no discount.
        discount_value:
          type: number
          nullable: true
          minimum: 0
          description: |
            Raw discount value. Interpreted as a percentage (e.g., 10 for 10%) when
            discount_type = "percent", or as a flat amount when discount_type = "amount".
            Server resolves this to discount_amount on the response.
        discount_label:
          type: string
          nullable: true
          maxLength: 100
          description: |
            Custom label for the discount line (e.g., "Volume Discount").
            Defaults to "Discount" if not provided.
        discount_taxable:
          type: boolean
          default: true
          description: |
            When true (default), discount is pre-tax: reduces taxable base proportionally
            across tax buckets. When false, discount is post-tax: subtracted from final
            total after tax.
        customer_po_numbers:
          type: array
          nullable: true
          items:
            type: string
            maxLength: 100
          maxItems: 20
          description: |
            Array of customer purchase order numbers associated with this invoice.
            These are typically collected from the original budget proposal and 
            all approved change order proposals being billed.
            
            When rendering on invoices/PDFs, display as comma-separated:
            "PO: PO-12345, PO-67890" or for single: "PO: PO-12345"
            
            If no PO numbers exist, send an empty array [] or omit the field.
          example: ["PO-12345", "PO-67890", "CO-001-PO"]

        billing_mode:
          type: string
          enum: [detailed, summary]
          default: detailed
        summary_group_by:
          type: string
          enum: [category, cost_code, date, source_type, employee, role]
          default: category
          description: Grouping dimension for summary mode. Only used when billing_mode is summary.
        tax_exemption_snapshot:
          type: array
          nullable: true
          description: |
            Point-in-time snapshot of customer tax exemptions active at invoice creation.
            If omitted, auto-fetched from customer's active (non-expired) exemptions.
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              exemption_type:
                type: string
              jurisdiction_level:
                type: string
                enum: [global, regional]
              jurisdiction_name:
                type: string
                nullable: true
              id_number:
                type: string
              expires_at:
                type: string
                format: date
                nullable: true

    InvoiceUpdate:
      type: object
      properties:
        customer_id:
          type: string
          format: uuid
          nullable: true
        project_id:
          type: string
          format: uuid
          nullable: true
        work_order_id:
          type: string
          format: uuid
          nullable: true
        invoice_type:
          type: string
          enum: [standard, progress, final, credit, retainage]
        invoice_date:
          type: string
          format: date
        due_date:
          type: string
          format: date
          nullable: true
        tax_rate:
          type: number
          nullable: true
          description: "Legacy: Use 'taxes' array for multiple taxes"
        taxes:
          type: array
          description: "Tax lines to apply (replaces existing taxes). Optionally reference a tax_code_id."
          items:
            type: object
            required:
              - tax_name
              - tax_rate
            properties:
              tax_name:
                type: string
                maxLength: 50
              tax_rate:
                type: number
                minimum: 0
                maximum: 100
              tax_code_id:
                type: string
                format: uuid
                nullable: true
                description: Optional reference to a tax code for component-based breakdown
        tax_inclusive:
          type: boolean
          default: false
          description: >
            When false (default), tax is added on top of the subtotal.
            When true, tax is embedded in line item prices and does not increase the total.
        payment_terms:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        internal_notes:
          type: string
          nullable: true
        terms_and_conditions:
          type: string
          nullable: true
        holdback_percent:
          type: number
          nullable: true
          minimum: 0
          maximum: 100
          description: Holdback/retainage percentage (e.g., 10.00 for 10%)
        holdback_amount:
          type: number
          nullable: true
          minimum: 0
          description: Holdback amount to withhold from this invoice
        holdback_label:
          type: string
          nullable: true
          maxLength: 50
          description: |
            Custom label for the holdback line on the invoice.
            Use "Holdback" for Canada or "Retainage" for US.
            Defaults to "Holdback" if not provided.
        holdback_tax_method:
          type: string
          nullable: true
          enum: [pre_tax, post_tax]
          description: |
            Tax treatment for holdback. "pre_tax" (Canada): holdback reduces
            the taxable base before tax. "post_tax" (USA): holdback deducted after tax.
        discount_type:
          type: string
          nullable: true
          enum: [percent, amount]
          description: |
            Type of invoice-level discount. Send null to clear an existing discount.
        discount_value:
          type: number
          nullable: true
          minimum: 0
          description: Percentage (when discount_type = "percent") or flat amount.
        discount_label:
          type: string
          nullable: true
          maxLength: 100
          description: Custom label for the discount line. Defaults to "Discount".
        discount_taxable:
          type: boolean
          description: |
            true (default) = pre-tax discount, false = post-tax discount.
        customer_po_numbers:
          type: array
          nullable: true
          items:
            type: string
            maxLength: 100
          maxItems: 20
          description: |
            Array of customer purchase order numbers to set or replace on this invoice.
            Send an empty array [] to clear existing PO numbers.
          example: ["PO-12345", "PO-67890"]
        tax_exemption_snapshot:
          type: array
          nullable: true
          description: |
            Replace the stored tax exemption snapshot. Send null to clear.
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              exemption_type:
                type: string
              jurisdiction_level:
                type: string
                enum: [global, regional]
              jurisdiction_name:
                type: string
                nullable: true
              id_number:
                type: string
              expires_at:
                type: string
                format: date
                nullable: true

    InvoiceVoidBody:
      type: object
      required:
        - reason
      properties:
        reason:
          type: string
          description: Reason for voiding the invoice
    InvoicePaymentCreate:
      type: object
      required:
        - amount
      properties:
        amount:
          type: number
          minimum: 0.01
          description: Payment amount (must be greater than 0)
        paid_at:
          type: string
          format: date
          description: Date the payment was made (defaults to today)
        payment_method:
          type: string
          enum: [check, wire, credit_card, ach, cash, other]
          nullable: true
        reference_number:
          type: string
          maxLength: 100
          nullable: true
        notes:
          type: string
          maxLength: 500
          nullable: true
        external_id:
          type: string
          maxLength: 255
          nullable: true
          description: External system ID for this payment (e.g., QuickBooks payment ID). Used to track if the payment was recorded from a 3rd-party app.

    # Work Type schemas (Settings)
    WorkType:
      type: object
      properties:
        id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        name:
          type: string
          description: Name of the work type (e.g., "Standard Labor", "Overtime")
        short_code:
          type: string
          nullable: true
          description: Short code for quick reference (e.g., "LAB", "OT")
        multiplier:
          type: number
          description: Pay multiplier (e.g., 1.0 for regular, 1.5 for overtime)
        is_billable:
          type: boolean
          description: Whether this work type is billable to clients
        requires_work_order:
          type: boolean
          description: Whether time entries require a work order reference
        is_active:
          type: boolean
          description: Whether this work type can be used for new entries
        display_order:
          type: integer
          description: Order in which to display in UI
        linked_time_off_type:
          type: string
          enum: [vacation, sick_leave, personal, training, unpaid]
          nullable: true
          description: Links to time-off tracking if this is a time-off work type
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, company_id, name, multiplier, is_billable, requires_work_order, is_active]

    WorkTypeCreate:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        short_code:
          type: string
          maxLength: 20
          nullable: true
        multiplier:
          type: number
          minimum: 0
          maximum: 10
          default: 1.0
        is_billable:
          type: boolean
          default: true
        requires_work_order:
          type: boolean
          default: true
        is_active:
          type: boolean
          default: true
        display_order:
          type: integer
          minimum: 0
        linked_time_off_type:
          type: string
          enum: [vacation, sick_leave, personal, training, unpaid]
          nullable: true

    WorkTypeUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        short_code:
          type: string
          maxLength: 20
          nullable: true
        multiplier:
          type: number
          minimum: 0
          maximum: 10
        is_billable:
          type: boolean
        requires_work_order:
          type: boolean
        is_active:
          type: boolean
        display_order:
          type: integer
          minimum: 0
        linked_time_off_type:
          type: string
          enum: [vacation, sick_leave, personal, training, unpaid]
          nullable: true

    # Company Settings schemas
    CompanyAddress:
      type: object
      description: Company business address
      properties:
        street:
          type: string
        city:
          type: string
        state:
          type: string
        zip:
          type: string
        country:
          type: string

    CompanyContactInfo:
      type: object
      description: Company contact information
      properties:
        email:
          type: string
          format: email
        phone:
          type: string

    DefaultWorkHours:
      type: object
      description: Default work hours configuration
      properties:
        working_days:
          type: array
          items:
            type: string
            enum: [monday, tuesday, wednesday, thursday, friday, saturday, sunday]
        start_time:
          type: string
          description: Default work start time (HH:MM format)
        end_time:
          type: string
          description: Default work end time (HH:MM format)
        break_duration_minutes:
          type: integer
          description: Default break duration in minutes

    CompanySettings:
      type: object
      description: Comprehensive company settings
      properties:
        name:
          type: string
          description: Company name
        industry:
          type: string
          nullable: true
          description: Industry type (e.g., commercial_construction, residential)
        employee_count:
          type: integer
          nullable: true
          description: Approximate number of employees
        address:
          $ref: '#/components/schemas/CompanyAddress'
          nullable: true
        contact_info:
          $ref: '#/components/schemas/CompanyContactInfo'
          nullable: true
        timezone:
          type: string
          description: Company timezone in IANA format (e.g., America/Los_Angeles)
        project_status_mode:
          type: string
          enum: [simple, advanced]
          description: |
            Project status workflow mode.
            - **simple**: Uses "active" and "closed" statuses only
            - **advanced**: Uses full workflow with lead, estimating, pending_approval, approved, active, complete, closed
        default_work_hours:
          $ref: '#/components/schemas/DefaultWorkHours'
          nullable: true
        expected_daily_hours:
          type: number
          nullable: true
          description: Expected hours per workday
        expected_weekly_hours:
          type: number
          nullable: true
          description: Expected hours per week
        timesheet_entry_mode:
          type: string
          enum: [hours, time]
          nullable: true
          description: |
            How employees enter time:
            - **hours**: Enter total hours worked
            - **time**: Enter start/end times
        timesheet_rounding_minutes:
          type: integer
          nullable: true
          description: Round timesheet entries to nearest N minutes (e.g., 15)
        timesheet_auto_break:
          type: boolean
          nullable: true
          description: Automatically deduct break time from timesheets
        allow_reports_optional:
          type: boolean
          description: |
            When true, work orders can be created with daily and completion report requirements disabled.
            Site leads can mark such work orders as complete directly without submitting reports.
        auto_project_code:
          type: boolean
          description: |
            When true, project codes are automatically generated in the format PRJ-YYYY-MM-### (e.g., PRJ-2026-03-001).
            The increment resets each month. Users and API callers can still override the auto-generated code.
        modules_enabled:
          type: object
          additionalProperties:
            type: boolean
          description: Map of feature module keys to enabled/disabled status
        roles:
          type: array
          description: Company-defined roles (read-only in this endpoint)
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
              description:
                type: string
                nullable: true
        departments:
          type: array
          description: Company departments (read-only in this endpoint)
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
      required: [name, timezone, project_status_mode, modules_enabled]

    CompanySettingsUpdate:
      type: object
      description: Fields that can be updated for company settings (all optional)
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Company name
        industry:
          type: string
          description: Industry type
        employee_count:
          type: integer
          minimum: 1
          description: Approximate number of employees
        address:
          $ref: '#/components/schemas/CompanyAddress'
        contact_info:
          $ref: '#/components/schemas/CompanyContactInfo'
        timezone:
          type: string
          description: Company timezone in IANA format
        project_status_mode:
          type: string
          enum: [simple, advanced]
          description: Changing this will automatically migrate existing projects
        default_work_hours:
          $ref: '#/components/schemas/DefaultWorkHours'
        expected_daily_hours:
          type: number
          description: Expected hours per workday
        expected_weekly_hours:
          type: number
          description: Expected hours per week
        timesheet_entry_mode:
          type: string
          enum: [hours, time]
        timesheet_rounding_minutes:
          type: integer
          minimum: 1
          maximum: 60
        timesheet_auto_break:
          type: boolean
        allow_reports_optional:
          type: boolean
          description: When true, allows creating work orders with optional daily/completion reports
        auto_project_code:
          type: boolean
          description: When true, project codes are auto-generated as PRJ-YYYY-MM-###

    # Finance Settings schemas
    FinanceSettings:
      type: object
      description: Financial configuration including tax rates, purchase order settings, contract terms, invoice defaults, cost codes, and branding
      properties:
        tax_rates:
          type: array
          description: Default tax rates applied to invoices
          items:
            type: object
            properties:
              name:
                type: string
                description: Tax rate name (e.g., "State Tax")
              rate:
                type: number
                description: Tax rate as percentage (e.g., 6.25 for 6.25%)
        purchase_orders:
          type: object
          description: Purchase order numbering and approval settings
          properties:
            po_number_prefix:
              type: string
              description: Prefix for PO numbers (e.g., "PO-")
            po_number_padding:
              type: integer
              description: Number of digits to pad PO sequence (e.g., 5 for "00001")
            po_number_next_sequence:
              type: integer
              description: Next sequence number for PO generation
            require_approval_threshold:
              type: number
              nullable: true
              description: Amount above which POs require approval
            allow_field_employees_create:
              type: boolean
              description: Whether field employees can create POs
            require_budget_on_quick_po:
              type: boolean
              description: Whether budget/not-to-exceed is required on quick PO requests
        terms:
          type: object
          description: Default contract/invoice terms
          properties:
            payment_due:
              type: string
              description: Payment due option (e.g., "net_30", "due_on_receipt")
            payment_terms:
              type: string
              nullable: true
              description: Payment terms text displayed on invoices
            terms_conditions:
              type: string
              nullable: true
              description: Standard terms and conditions
        invoices:
          type: object
          description: Invoice default settings
          properties:
            default_payment_due:
              type: string
              description: Default payment due terms
            default_notes:
              type: string
              nullable: true
              description: Default notes added to invoices
            header_text:
              type: string
              nullable: true
              description: Invoice header text
            footer_text:
              type: string
              nullable: true
              description: Invoice footer text
            cc_reply_to_email:
              type: string
              nullable: true
              description: Email address to CC on invoice emails and use as Reply-To address
        cost_codes:
          type: array
          description: Active cost codes for PO and invoice line items
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              code:
                type: string
                description: Cost code identifier (e.g., "MATERIALS", "LABOR")
              description:
                type: string
                nullable: true
                description: Human-readable description of the cost code
              is_active:
                type: boolean
              display_order:
                type: integer
                nullable: true
        branding:
          type: object
          description: Company branding settings for documents
          properties:
            show_logo_on_invoices:
              type: boolean
              description: Whether to display company logo on invoices and POs
            logo_url:
              type: string
              nullable: true
              description: URL to the company logo image (read-only, managed via branding UI)
        tax_codes:
          type: array
          description: Tax codes with multi-component support for accounting integration precision
          items:
            $ref: '#/components/schemas/TaxCode'
        pricing:
          type: object
          description: Product pricing defaults
          properties:
            default_product_margin_pct:
              type: number
              nullable: true
              minimum: 0
              maximum: 100
              description: Default margin percentage applied to products when no item-level, category-level, or flat override exists (0-100)

    TaxCode:
      type: object
      description: A composite tax code made up of one or more rate components (e.g., HST = Federal 5% + Provincial 8%)
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: Display name (e.g., "HST", "AZ Sales Tax")
        external_id:
          type: string
          nullable: true
          description: External system reference (e.g., QuickBooks Online tax code ID)
        is_active:
          type: boolean
          description: Whether the tax code is available for use
        total_rate:
          type: number
          description: Sum of all component rates (computed server-side)
        components:
          type: array
          description: Individual tax rate components that make up this code
          items:
            $ref: '#/components/schemas/TaxCodeComponent'

    TaxCodeComponent:
      type: object
      description: A single rate component within a tax code
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: Component name (e.g., "Federal", "Provincial", "State")
        rate:
          type: number
          description: Tax rate with up to 6 decimal precision (e.g., 5.125000)
        external_id:
          type: string
          nullable: true
          description: External system reference for the component (Sales context — used on Invoices)
        purchase_external_id:
          type: string
          nullable: true
          description: External system reference for the component (Purchase context — used on Bills/POs). Falls back to external_id if not set.

    TaxComponentAmount:
      type: object
      description: Per-component tax amount breakdown (stored in component_amounts JSONB). The `external_id` field is context-aware — for invoice taxes it contains the sales external ID, for bill/PO taxes it contains the purchase external ID (falling back to the sales ID if not set).
      properties:
        name:
          type: string
          description: Component name (e.g., "Federal", "Provincial")
        rate:
          type: number
          description: Component rate with 6-decimal precision
        amount:
          type: number
          description: Calculated tax amount for this component (2-decimal rounded)
        external_id:
          type: string
          nullable: true
          description: Context-resolved external system reference. For sales documents (invoices), this is the sales external_id. For purchase documents (bills/POs), this is the purchase_external_id (or sales external_id as fallback).

    FinanceSettingsUpdate:
      type: object
      description: Partial update for finance settings
      properties:
        tax_rates:
          type: array
          description: Default tax rates applied to invoices
          items:
            type: object
            required: [name, rate]
            properties:
              name:
                type: string
                maxLength: 100
              rate:
                type: number
                minimum: 0
                maximum: 100
                description: Tax rate as percentage (e.g., 6.25 for 6.25%)
        purchase_orders:
          type: object
          properties:
            po_number_prefix:
              type: string
              maxLength: 20
            po_number_padding:
              type: integer
              minimum: 1
              maximum: 10
            require_approval_threshold:
              type: number
              minimum: 0
              nullable: true
            allow_field_employees_create:
              type: boolean
            require_budget_on_quick_po:
              type: boolean
        terms:
          type: object
          properties:
            payment_due:
              type: string
              enum: [due_on_receipt, net_15, net_30, net_45, net_60]
            payment_terms:
              type: string
              maxLength: 5000
              nullable: true
            terms_conditions:
              type: string
              maxLength: 10000
              nullable: true
        invoices:
          type: object
          properties:
            default_payment_due:
              type: string
              enum: [due_on_receipt, net_15, net_30, net_45, net_60]
            default_notes:
              type: string
              maxLength: 5000
              nullable: true
            header_text:
              type: string
              maxLength: 1000
              nullable: true
            footer_text:
              type: string
              maxLength: 1000
              nullable: true
            cc_reply_to_email:
              type: string
              format: email
              nullable: true
        branding:
          type: object
          properties:
            show_logo_on_invoices:
              type: boolean
        tax_codes:
          type: array
          description: Tax codes to create or update. Omit `id` to create; include `id` to update. Components are replaced entirely on update.
          items:
            type: object
            required: [name, components]
            properties:
              id:
                type: string
                format: uuid
                description: Include to update an existing tax code; omit to create a new one
              name:
                type: string
                maxLength: 100
                description: Tax code display name
              external_id:
                type: string
                nullable: true
                maxLength: 255
                description: External system reference (e.g., QBO tax code ID)
              is_active:
                type: boolean
                default: true
                description: Set to false for soft deletion
              components:
                type: array
                minItems: 1
                description: At least 1 component required. Existing components are deleted and re-inserted on update.
                items:
                  type: object
                  required: [name, rate]
                  properties:
                    name:
                      type: string
                      maxLength: 100
                    rate:
                      type: number
                      minimum: 0
                      maximum: 100
                      description: Tax rate with up to 6 decimal places
                    external_id:
                      type: string
                      nullable: true
                      maxLength: 255
        pricing:
          type: object
          properties:
            default_product_margin_pct:
              type: number
              nullable: true
              minimum: 0
              maximum: 100
              description: Default margin percentage for product pricing (0-100)

    # ==========================================
    # CREDIT CARD SCHEMAS
    # ==========================================
    CreditCard:
      type: object
      description: Company credit/payment card for purchase order tracking
      properties:
        id:
          type: string
          format: uuid
        card_name:
          type: string
          description: Friendly name for the card (e.g., "Company Visa", "Field Purchases Card")
        card_type:
          type: string
          enum: [visa, mastercard, amex, discover, other]
          nullable: true
          description: Credit card network/brand
        external_id:
          type: string
          nullable: true
          description: External reference ID for integration with accounting systems
        is_active:
          type: boolean
          description: Whether the card is available for selection on new POs
        is_default:
          type: boolean
          description: Whether this is the default card for new POs
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, card_name, is_active, is_default]

    CreditCardCreate:
      type: object
      description: Create a new credit card
      required: [card_name]
      properties:
        card_name:
          type: string
          maxLength: 255
          description: Friendly name for the card
        card_type:
          type: string
          enum: [visa, mastercard, amex, discover, other]
          nullable: true
        external_id:
          type: string
          maxLength: 255
          nullable: true
          description: External reference ID for accounting integration
        is_active:
          type: boolean
          default: true
        is_default:
          type: boolean
          default: false
          description: Set as default card (will unset other defaults)

    CreditCardUpdate:
      type: object
      description: Update an existing credit card (all fields optional)
      properties:
        card_name:
          type: string
          maxLength: 255
        card_type:
          type: string
          enum: [visa, mastercard, amex, discover, other]
          nullable: true
        external_id:
          type: string
          maxLength: 255
          nullable: true
        is_active:
          type: boolean
        is_default:
          type: boolean

    # Custom Field schemas
    CustomField:
      type: object
      description: Custom field definition for projects or work orders
      properties:
        id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        field_name:
          type: string
          description: Display name of the field
        field_key:
          type: string
          description: Unique key used to store field values (auto-generated from field_name)
        field_type:
          type: string
          enum: [text, large_text, number, dropdown, date, checkbox, json]
          description: |
            Type of the field:
            - **text**: Single line text input
            - **large_text**: Multi-line text input
            - **number**: Numeric input
            - **dropdown**: Select from predefined options
            - **date**: Date picker
            - **checkbox**: Boolean toggle
            - **json**: Structured JSON data. For visible fields, use the `StructuredJsonFieldValue` 
              array format (see schema) to get formatted display with labels, currencies, dates, 
              and badges. Non-conforming JSON falls back to raw display.
        applies_to:
          type: string
          enum: [project, work_order, both]
          description: Which entities this field appears on
        dropdown_options:
          type: array
          items:
            type: string
          description: Available options for dropdown fields
        is_required:
          type: boolean
          description: Whether this field is required when creating/editing
        is_visible:
          type: boolean
          default: true
          description: |
            Whether this field is shown in the UI. Set to `false` to create hidden 
            fields for app-specific data invisible to users.
        is_readonly:
          type: boolean
          default: false
          description: |
            Whether this field is read-only in the UI. Set to `true` to prevent 
            manual edits - the field can only be updated via API.
        display_order:
          type: integer
          description: Order in which to display the field
        app_install_id:
          type: string
          format: uuid
          nullable: true
          description: If set, the app installation that created this field
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, company_id, field_name, field_key, field_type, applies_to, is_required, display_order]

    CustomFieldCreate:
      type: object
      description: |
        Create a new custom field definition. Apps can control field visibility and 
        editability to support various use cases:
        
        - **User-visible, user-editable**: Default behavior (is_visible: true, is_readonly: false)
        - **User-visible, app-managed**: Show computed values users can't edit (is_visible: true, is_readonly: true)
        - **Hidden, app-only**: Store internal data invisible to users (is_visible: false)
      required:
        - field_name
        - field_type
        - applies_to
      properties:
        field_name:
          type: string
          minLength: 1
          maxLength: 100
          description: Display name of the field
        field_type:
          type: string
          enum: [text, large_text, number, dropdown, date, checkbox, json]
          description: |
            Type of field input:
            - **text**: Single line text input
            - **large_text**: Multi-line text input
            - **number**: Numeric input
            - **dropdown**: Select from predefined options
            - **date**: Date picker
            - **checkbox**: Boolean toggle
            - **json**: Structured JSON data. For visible fields, use the `StructuredJsonFieldValue` 
              array format for formatted display. See schema for structure.
        applies_to:
          type: string
          enum: [project, work_order, both]
          description: Which entities this field appears on
        dropdown_options:
          type: array
          items:
            type: string
          default: []
          description: Options for dropdown fields (required when field_type is "dropdown")
        is_required:
          type: boolean
          default: false
          description: Whether this field is required
        is_visible:
          type: boolean
          default: true
          description: Set to `false` to hide field from UI (for app-internal data)
        is_readonly:
          type: boolean
          default: false
          description: Set to `true` to prevent UI edits (for app-computed values)

    CustomFieldUpdate:
      type: object
      description: |
        Update an existing custom field definition. At least one field must be provided.
        Apps can only update fields they created (matching app_install_id).
        
        **Note:** `field_type` and `applies_to` cannot be changed after creation as this 
        would break existing data.
      properties:
        field_name:
          type: string
          minLength: 1
          maxLength: 100
          description: Updated display name
        dropdown_options:
          type: array
          items:
            type: string
          description: Updated options for dropdown fields
        is_required:
          type: boolean
          description: Whether this field is required
        is_visible:
          type: boolean
          description: Whether this field is shown in the UI
        is_readonly:
          type: boolean
          description: Whether this field is read-only in the UI

    # Structured JSON Display Format for Custom Fields
    JsonFieldDisplayItem:
      type: object
      description: |
        Display-friendly format for visible JSON custom fields. When a JSON field 
        is visible to users, this structure provides formatted rendering with 
        labels, values, and optional styling.
      required:
        - label
        - value
      properties:
        label:
          type: string
          description: Human-readable label displayed on the left
          example: "Labor Cost"
        value:
          oneOf:
            - type: string
            - type: number
            - type: boolean
          description: The value to display (must be a primitive, not object/array)
          example: 15000
        type:
          type: string
          enum: [text, currency, date, percentage, badge]
          default: text
          description: |
            Formatting hint for display:
            - **text**: Display as-is (default)
            - **currency**: Format as $X,XXX
            - **date**: Format as "Jan 15, 2025" (expects ISO date string)
            - **percentage**: Append % symbol
            - **badge**: Render as colored badge pill
        color:
          type: string
          enum: [default, success, warning, error, info]
          description: Badge color (only used when type is "badge")
          example: "success"

    StructuredJsonFieldValue:
      type: array
      description: |
        Array of display items for visible JSON fields. Use this format when 
        `is_visible: true` to get user-friendly rendering instead of raw JSON.
        
        **Fallback behavior:** If the value doesn't match this structure (missing 
        required fields, non-primitive values, empty array), the UI will display 
        formatted raw JSON instead.
      items:
        $ref: '#/components/schemas/JsonFieldDisplayItem'
      example:
        - label: "Labor Cost"
          value: 15000
          type: "currency"
        - label: "Margin"
          value: 22
          type: "percentage"
        - label: "Status"
          value: "Approved"
          type: "badge"
          color: "success"

    # Skill schemas (Skills & Certifications Library)
    Skill:
      type: object
      description: Skill or certification definition in the company library
      properties:
        id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        skill_name:
          type: string
          description: Name of the skill or certification
        skill_category:
          type: string
          enum: [license, certification, training, equipment_operation, skill]
          description: |
            Category of the skill:
            - **license**: Government-issued license (e.g., CDL, electrician license)
            - **certification**: Professional certification (e.g., OSHA, first aid)
            - **training**: Completed training program
            - **equipment_operation**: Equipment operation capability
            - **skill**: General skill or competency
        description:
          type: string
          nullable: true
          description: Optional description of the skill
        is_active:
          type: boolean
          description: Whether this skill is active and can be assigned
        requires_certification_number:
          type: boolean
          description: Whether employees must provide a certification/license number
        requires_issue_date:
          type: boolean
          description: Whether employees must provide an issue date
        requires_expiry_date:
          type: boolean
          description: Whether employees must provide an expiry date
        requires_issuing_authority:
          type: boolean
          description: Whether employees must specify the issuing authority
        app_install_id:
          type: string
          format: uuid
          nullable: true
          description: If set, the app installation that created this skill (null if created manually)
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required: [id, company_id, skill_name, skill_category, is_active]

    SkillCreate:
      type: object
      description: Create a new skill definition in the company library
      required:
        - skill_name
        - skill_category
      properties:
        skill_name:
          type: string
          minLength: 1
          maxLength: 100
          description: Name of the skill or certification
        skill_category:
          type: string
          enum: [license, certification, training, equipment_operation, skill]
          description: Category of the skill
        description:
          type: string
          maxLength: 500
          nullable: true
          description: Optional description
        is_active:
          type: boolean
          default: true
          description: Whether this skill is active
        requires_certification_number:
          type: boolean
          default: false
          description: Require certification/license number when assigning to employee
        requires_issue_date:
          type: boolean
          default: false
          description: Require issue date when assigning to employee
        requires_expiry_date:
          type: boolean
          default: false
          description: Require expiry date when assigning to employee
        requires_issuing_authority:
          type: boolean
          default: false
          description: Require issuing authority when assigning to employee

    SkillUpdate:
      type: object
      description: |
        Update an existing skill definition. Apps can only update skills they created.
        
        **Note:** `skill_category` cannot be changed after creation.
      properties:
        skill_name:
          type: string
          minLength: 1
          maxLength: 100
          description: Updated skill name
        description:
          type: string
          maxLength: 500
          nullable: true
          description: Updated description
        is_active:
          type: boolean
          description: Whether this skill is active
        requires_certification_number:
          type: boolean
          description: Require certification/license number
        requires_issue_date:
          type: boolean
          description: Require issue date
        requires_expiry_date:
          type: boolean
          description: Require expiry date
        requires_issuing_authority:
          type: boolean
          description: Require issuing authority


    EmployeeSkill:
      type: object
      description: |
        A skill or certification assigned to an employee. The "type" of row
        (skill, certification, license, training, equipment_operation) is
        carried by the linked `skill.skill_category`. Cert-style columns
        (`certification_number`, `issue_date`, `expiry_date`,
        `issuing_authority`, `document_url`, `document_name`) exist on every
        row but are only required when the linked `company_skills` definition
        flips the matching `requires_*` flag.
      properties:
        id:
          type: string
          format: uuid
          description: employee_skills.id (use as `rowId` in subsequent calls)
        employee_id:
          type: string
          format: uuid
        skill:
          type: object
          description: Embedded company_skills definition (subset)
          properties:
            id: { type: string, format: uuid }
            skill_name: { type: string }
            skill_category:
              type: string
              enum: [license, certification, training, equipment_operation, skill]
            requires_certification_number: { type: boolean }
            requires_issue_date: { type: boolean }
            requires_expiry_date: { type: boolean }
            requires_issuing_authority: { type: boolean }
        certification_number: { type: string, nullable: true }
        issue_date: { type: string, format: date, nullable: true }
        expiry_date: { type: string, format: date, nullable: true }
        issuing_authority: { type: string, nullable: true }
        is_verified: { type: boolean }
        notes: { type: string, nullable: true }
        document_url:
          type: string
          nullable: true
          description: |
            Storage path inside the private `employee-certificates` bucket
            (`{companyId}/{employeeId}/{rowId}/{uuid}_{filename}`). Use the
            standard signed-URL flow to download.
        document_name:
          type: string
          nullable: true
          description: Original filename of the uploaded certificate document
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      required: [id, employee_id, skill]

    EmployeeSkillCreate:
      type: object
      description: |
        Assign a skill/certification to an employee. Discover required fields
        per skill via `GET /v1/skills` (the four `requires_*` flags). Missing
        required fields return a structured 400 (see `SkillFieldError`).
      required: [skill_id]
      properties:
        skill_id:
          type: string
          format: uuid
          description: company_skills.id (must belong to caller's company)
        certification_number: { type: string, nullable: true }
        issue_date: { type: string, format: date, nullable: true }
        expiry_date: { type: string, format: date, nullable: true }
        issuing_authority: { type: string, nullable: true }
        is_verified: { type: boolean, default: false }
        notes: { type: string, nullable: true }

    EmployeeSkillUpdate:
      type: object
      description: |
        Patch any subset of fields on an existing employee skill row.
        `skill_id` cannot be changed - delete and re-create instead.
        Required-field validation runs against the merged result.
      properties:
        certification_number: { type: string, nullable: true }
        issue_date: { type: string, format: date, nullable: true }
        expiry_date: { type: string, format: date, nullable: true }
        issuing_authority: { type: string, nullable: true }
        is_verified: { type: boolean }
        notes: { type: string, nullable: true }

    SkillFieldError:
      type: object
      description: |
        400 response returned when one or more `requires_*` fields are
        missing for the linked skill. `fields` lists the offending field
        names so apps can surface inline form errors.
      properties:
        error: { type: string, example: bad_request }
        message: { type: string }
        fields:
          type: array
          items: { type: string }
          example: ["expiry_date", "issuing_authority"]
      required: [error, message, fields]

    # Time Off schemas
    TimeOff:
      type: object
      properties:
        id:
          type: string
          format: uuid
        employee_id:
          type: string
          format: uuid
        company_id:
          type: string
          format: uuid
        time_off_type:
          type: string
          enum: [vacation, sick_leave, personal, unpaid, training, other]
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        start_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
          description: Start time (HH:MM) for partial day off
        end_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
          description: End time (HH:MM) for partial day off
        status:
          type: string
          enum: [approved, pending, denied]
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        employees:
          $ref: '#/components/schemas/Employee'
      required: [id, employee_id, company_id, time_off_type, start_date, end_date, status]

    TimeOffCreate:
      type: object
      required:
        - emp_number
        - start_date
        - end_date
      properties:
        emp_number:
          type: string
          description: Employee number to identify the employee
        start_date:
          type: string
          format: date
          description: Start date of time off (YYYY-MM-DD)
        end_date:
          type: string
          format: date
          description: End date of time off (YYYY-MM-DD)
        time_off_type:
          type: string
          enum: [vacation, sick_leave, personal, unpaid, training, other]
          default: vacation
          description: Type of time off
        start_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
          description: Start time (HH:MM) for partial day off
        end_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
          description: End time (HH:MM) for partial day off
        status:
          type: string
          enum: [approved, pending, denied]
          default: approved
          description: Approval status
        notes:
          type: string
          nullable: true
          description: Additional notes

    TimeOffUpdate:
      type: object
      properties:
        time_off_type:
          type: string
          enum: [vacation, sick_leave, personal, unpaid, training, other]
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        start_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
        end_time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          nullable: true
        status:
          type: string
          enum: [approved, pending, denied]
        notes:
          type: string
          nullable: true

    # ==========================================
    # APP AUTHENTICATION SCHEMAS
    # ==========================================
    EmbedTokenResponse:
      type: object
      properties:
        token:
          type: string
          description: Signed JWT token for embedded app authentication
        expires_at:
          type: integer
          description: Token expiration time in milliseconds (Unix timestamp * 1000)
        payload:
          type: object
          properties:
            company_id:
              type: string
              format: uuid
              description: Company ID
            install_id:
              type: string
              format: uuid
              description: App installation ID
            app_id:
              type: string
              format: uuid
              description: App catalog ID
            employee_id:
              type: string
              format: uuid
              nullable: true
              description: Employee ID (null if user has no employee record)
            employee_name:
              type: string
              description: User's display name
            email:
              type: string
              format: email
              description: User's email address
            roles:
              type: array
              items:
                type: string
                enum: [superuser, administrator, manager, scheduler, project_owner, site_lead, site_worker]
              description: Array of user roles
      required: [token, expires_at, payload]

    TokenVerificationResponse:
      type: object
      properties:
        valid:
          type: boolean
          description: Whether the token is valid
        error:
          type: string
          description: Error message if token is invalid
        payload:
          type: object
          description: Decoded token payload (only present if valid is true)
          properties:
            user_id:
              type: string
              format: uuid
            company_id:
              type: string
              format: uuid
            install_id:
              type: string
              format: uuid
            app_id:
              type: string
              format: uuid
            employee_id:
              type: string
              format: uuid
              nullable: true
            employee_name:
              type: string
            email:
              type: string
              format: email
            roles:
              type: array
              items:
                type: string
            issued_at:
              type: integer
              description: Token issue time (Unix timestamp)
            expires_at:
              type: integer
              description: Token expiration time (Unix timestamp)
      required: [valid]

    EquipmentAsset:
      type: object
      properties:
        id: { type: string, format: uuid }
        company_id: { type: string, format: uuid }
        product_id: { type: string, format: uuid, nullable: true }
        asset_tag: { type: string, nullable: true }
        serial_number: { type: string, nullable: true }
        name: { type: string }
        description: { type: string, nullable: true }
        status: { type: string, enum: [active, locked] }
        tracking_mode: { type: string, minLength: 1, maxLength: 40, pattern: '^[a-zA-Z0-9 _-]+$', description: 'Free-form meter unit (e.g. hours, km, miles, cycles, revolutions, flights). Stored lowercased. Reserved value "none" disables meter capture on checkout.' }
        current_reading: { type: number, nullable: true }
        last_reading_at: { type: string, format: date-time, nullable: true }
        purchase_date: { type: string, format: date, nullable: true }
        purchase_cost: { type: number, nullable: true }
        warranty_expiry: { type: string, format: date, nullable: true }
        location_description: { type: string, nullable: true }
        metadata: { type: object, nullable: true }
        app_metadata: { type: object, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    EquipmentAssetCreate:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 255 }
        asset_tag: { type: string, maxLength: 100, nullable: true }
        serial_number: { type: string, maxLength: 100, nullable: true }
        product_id: { type: string, format: uuid, nullable: true, description: "Link to price book product for auto-billing" }
        description: { type: string, nullable: true }
        status: { type: string, enum: [active, locked], default: active }
        tracking_mode: { type: string, minLength: 1, maxLength: 40, pattern: '^[a-zA-Z0-9 _-]+$', default: none, description: 'Free-form meter unit (e.g. hours, km, miles, cycles, revolutions, flights). Stored lowercased. Reserved value "none" disables meter capture on checkout.' }
        current_reading: { type: number, minimum: 0, nullable: true }
        purchase_date: { type: string, format: date, nullable: true }
        purchase_cost: { type: number, minimum: 0, nullable: true }
        warranty_expiry: { type: string, format: date, nullable: true }
        location_description: { type: string, maxLength: 500, nullable: true }
        metadata: { type: object, nullable: true }

    EquipmentAssetUpdate:
      type: object
      description: All fields are optional. Only provided fields are updated.
      properties:
        name: { type: string, maxLength: 255 }
        asset_tag: { type: string, nullable: true }
        serial_number: { type: string, nullable: true }
        product_id: { type: string, format: uuid, nullable: true }
        description: { type: string, nullable: true }
        status: { type: string, enum: [active, locked] }
        tracking_mode: { type: string, minLength: 1, maxLength: 40, pattern: '^[a-zA-Z0-9 _-]+$', description: 'Free-form meter unit (e.g. hours, km, miles, cycles, revolutions, flights). Stored lowercased. Reserved value "none" disables meter capture on checkout.' }
        current_reading: { type: number, nullable: true }
        location_description: { type: string, nullable: true }
        metadata: { type: object, nullable: true }

    EquipmentCheckout:
      type: object
      properties:
        id: { type: string, format: uuid }
        company_id: { type: string, format: uuid }
        asset_id: { type: string, format: uuid }
        work_order_id: { type: string, format: uuid }
        checked_out_by: { type: string, format: uuid, nullable: true }
        checked_out_at: { type: string, format: date-time }
        checked_in_at: { type: string, format: date-time, nullable: true }
        reading_at_checkout: { type: number, nullable: true }
        reading_at_checkin: { type: number, nullable: true }
        usage_record_id: { type: string, format: uuid, nullable: true }
        notes: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    EquipmentCheckoutCreate:
      type: object
      required: [work_order_id]
      properties:
        work_order_id: { type: string, format: uuid }
        checked_out_by: { type: string, format: uuid, nullable: true }
        reading_at_checkout: { type: number, minimum: 0, nullable: true }
        notes: { type: string, maxLength: 2000, nullable: true }

    EquipmentCheckinRequest:
      type: object
      properties:
        reading_at_checkin: { type: number, minimum: 0, nullable: true }
        notes: { type: string, maxLength: 2000, nullable: true }
        auto_bill: { type: boolean, default: false, description: "If true and asset has a product_id, creates a usage record" }

    EquipmentServiceRecord:
      type: object
      properties:
        id: { type: string, format: uuid }
        company_id: { type: string, format: uuid }
        asset_id: { type: string, format: uuid }
        record_type: { type: string, enum: [safety, maintenance, repair] }
        form_data: { type: object, nullable: true }
        result: { type: string, enum: [pass, fail, needs_repair] }
        performed_by: { type: string, format: uuid, nullable: true }
        performed_at: { type: string, format: date-time }
        next_due_at: { type: string, format: date-time, nullable: true }
        reading_at_service: { type: number, nullable: true }
        notes: { type: string, nullable: true }
        metadata: { type: object, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    EquipmentServiceRecordCreate:
      type: object
      required: [asset_id, record_type, result]
      properties:
        asset_id: { type: string, format: uuid }
        record_type: { type: string, enum: [safety, maintenance, repair] }
        form_data: { type: object, nullable: true, description: "Flexible service record checklist data" }
        result: { type: string, enum: [pass, fail, needs_repair] }
        performed_by: { type: string, format: uuid, nullable: true }
        performed_at: { type: string, format: date-time }
        next_due_at: { type: string, format: date-time, nullable: true }
        reading_at_service: { type: number, minimum: 0, nullable: true }
        notes: { type: string, maxLength: 2000, nullable: true }
        metadata: { type: object, nullable: true }

    VendorCredit:
      type: object
      properties:
        id:
          type: string
          format: uuid
        credit_number:
          type: string
        vendor_id:
          type: string
          format: uuid
        purchase_order_id:
          type: string
          format: uuid
          nullable: true
        project_id:
          type: string
          format: uuid
          nullable: true
        work_order_id:
          type: string
          format: uuid
          nullable: true
        date:
          type: string
          format: date
        subtotal:
          type: number
        tax_amount:
          type: number
        total_amount:
          type: number
        tax_inclusive:
          type: boolean
          default: false
        external_id:
          type: string
          nullable: true
          description: QBO VendorCredit ID after sync
        status:
          type: string
          enum: [draft, open, applied, void]
        sync_status:
          type: string
          enum: [unsynced, pending, synced, error]
          default: unsynced
        sync_message:
          type: string
          nullable: true
        sync_url:
          type: string
          format: uri
          nullable: true
        reference:
          type: string
          nullable: true
        memo:
          type: string
          nullable: true
        vendor:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            vendor_name:
              type: string
        project:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            project_name:
              type: string
            project_code:
              type: string
        work_order:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            phase_name:
              type: string
            work_order_number:
              type: string
        purchase_order:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            po_number:
              type: string
        line_items:
          type: array
          items:
            $ref: '#/components/schemas/VendorCreditLineItem'
        taxes:
          type: array
          items:
            $ref: '#/components/schemas/VendorCreditTaxLine'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        linked_invoice:
          allOf: [{ $ref: '#/components/schemas/LinkedInvoiceRef' }]
          nullable: true
          description: Most recent invoice that billed this credit (v2.26.0). `null` when not billed.



    VendorCreditLineItem:
      type: object
      properties:
        id:
          type: string
          format: uuid
        description:
          type: string
        quantity:
          type: number
        unit_price:
          type: number
        unit:
          type: string
        amount:
          type: number
        product_id:
          type: string
          format: uuid
          nullable: true
        cost_code_id:
          type: string
          format: uuid
          nullable: true
        sort_order:
          type: integer
        tax_code_id:
          type: string
          format: uuid
          nullable: true
          description: Tax code for line-level TaxCodeRef mapping
        is_taxable:
          type: boolean
          default: true

    VendorCreditTaxLine:
      type: object
      properties:
        id:
          type: string
          format: uuid
        tax_name:
          type: string
        tax_rate:
          type: number
        amount:
          type: number
        external_id:
          type: string
          nullable: true
        tax_code_id:
          type: string
          format: uuid
          nullable: true
          description: Tax code ID for combined code resolution
        component_amounts:
          type: array
          nullable: true
          description: Per-component tax breakdown for multi-rate taxes
          items:
            type: object
            properties:
              name:
                type: string
              rate:
                type: number
              amount:
                type: number
              external_id:
                type: string
        display_order:
          type: integer
        tax_inclusion:
          type: string
          default: exclusive

    VendorCreditCreate:
      type: object
      required: [vendor_id, line_items]
      properties:
        vendor_id:
          type: string
          format: uuid
        purchase_order_id:
          type: string
          format: uuid
          nullable: true
        project_id:
          type: string
          format: uuid
          nullable: true
        work_order_id:
          type: string
          format: uuid
          nullable: true
        date:
          type: string
          format: date
        reference:
          type: string
          maxLength: 200
        memo:
          type: string
          maxLength: 2000
        status:
          type: string
          enum: [draft, open]
          default: draft
        tax_inclusive:
          type: boolean
          default: false
        line_items:
          type: array
          minItems: 1
          items:
            type: object
            required: [description]
            properties:
              description:
                type: string
                maxLength: 500
              quantity:
                type: number
                minimum: 0
                default: 1
              unit_price:
                type: number
                minimum: 0
                default: 0
              unit:
                type: string
                maxLength: 20
                default: ea
              product_id:
                type: string
                format: uuid
                nullable: true
              cost_code_id:
                type: string
                format: uuid
                nullable: true
              tax_code_id:
                type: string
                format: uuid
                nullable: true
              is_taxable:
                type: boolean
                default: true
        tax_lines:
          type: array
          items:
            type: object
            required: [tax_name, tax_rate]
            properties:
              tax_name:
                type: string
                maxLength: 100
              tax_rate:
                type: number
                minimum: 0
                maximum: 100
              amount:
                type: number
                minimum: 0
                default: 0
              external_id:
                type: string
                maxLength: 200
                nullable: true
              tax_code_id:
                type: string
                format: uuid
                nullable: true
              component_amounts:
                type: array
                nullable: true
                items:
                  type: object
                  properties:
                    name:
                      type: string
                    rate:
                      type: number
                    amount:
                      type: number
                    external_id:
                      type: string

    VendorCreditUpdate:
      type: object
      properties:
        vendor_id:
          type: string
          format: uuid
        purchase_order_id:
          type: string
          format: uuid
          nullable: true
        project_id:
          type: string
          format: uuid
          nullable: true
        work_order_id:
          type: string
          format: uuid
          nullable: true
        date:
          type: string
          format: date
        reference:
          type: string
          maxLength: 200
        memo:
          type: string
          maxLength: 2000
        tax_inclusive:
          type: boolean
        line_items:
          type: array
          items:
            type: object
            required: [description]
            properties:
              description:
                type: string
              quantity:
                type: number
              unit_price:
                type: number
              unit:
                type: string
              product_id:
                type: string
                format: uuid
                nullable: true
              cost_code_id:
                type: string
                format: uuid
                nullable: true
              tax_code_id:
                type: string
                format: uuid
                nullable: true
              is_taxable:
                type: boolean
        tax_lines:
          type: array
          items:
            type: object
            properties:
              tax_name:
                type: string
              tax_rate:
                type: number
              amount:
                type: number
              external_id:
                type: string
                nullable: true
              tax_code_id:
                type: string
                format: uuid
                nullable: true
              component_amounts:
                type: array
                nullable: true
                items:
                  type: object
                  properties:
                    name:
                      type: string
                    rate:
                      type: number
                    amount:
                      type: number
                    external_id:
                      type: string
  headers:
    X-RateLimit-Limit:
      description: Maximum number of requests allowed per minute
      schema:
        type: integer
        example: 60
    X-RateLimit-Remaining:
      description: Number of requests remaining in the current rate limit window
      schema:
        type: integer
        example: 42
    X-RateLimit-Reset:
      description: Seconds until the rate limit window resets
      schema:
        type: integer
        example: 35
    Retry-After:
      description: Seconds to wait before retrying (only present on 429 responses)
      schema:
        type: integer
        example: 35


paths:
  # Employees endpoints
  /employees:
    get:
      summary: List employees
      operationId: listEmployees
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: search
          in: query
          schema:
            type: string
        - name: role_id
          in: query
          schema:
            type: string
          description: |
            Filter by role ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: department_id
          in: query
          schema:
            type: string
          description: |
            Filter by department ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: employee_type
          in: query
          schema:
            type: string
            enum: [site_worker, site_lead, project_owner]
          description: Filter employees by designation type
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync - only fetch records changed since your last sync.
            Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of UUIDs to filter by (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: is_active
          in: query
          schema:
            type: boolean
          description: Filter by active status. When omitted, both active and inactive employees are returned (but not deleted ones).
        - name: include_deleted
          in: query
          schema:
            type: boolean
            default: false
          description: |
            When set to `true`, includes soft-deleted employees in the results.
            Deleted employees will have `deleted: true` and `is_active: false`.
            Defaults to `false` (deleted employees are excluded).
      responses:
        '200':
          description: List of employees
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Employee'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Forbidden - Insufficient scopes
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    post:
      summary: Create employee
      operationId: createEmployee
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmployeeCreate'
      responses:
        '201':
          description: Employee created
          headers:
            Location:
              schema:
                type: string
              description: URL of the created resource
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Employee'
                  message:
                    type: string
                  id:
                    type: string
                    format: uuid
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden

  /employees/{id}:
    get:
      summary: Get employee by ID
      operationId: getEmployee
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: include_deleted
          in: query
          schema:
            type: boolean
            default: false
          description: |
            When set to `true`, allows fetching a soft-deleted employee by ID.
            Defaults to `false` (returns 404 for deleted employees).
      responses:
        '200':
          description: Employee details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Employee'
        '404':
          description: Employee not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update employee
      operationId: updateEmployee
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmployeeUpdate'
      responses:
        '200':
          description: Employee updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Employee'
                  message:
                    type: string
        '400':
          description: Bad request
        '404':
          description: Employee not found

    delete:
      summary: Delete employee (soft delete)
      operationId: deleteEmployee
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Employee deleted
        '404':
          description: Employee not found


  /employees/{id}/skills:
    get:
      summary: List employee skills & certifications
      operationId: listEmployeeSkills
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      description: |
        List all skills and certifications assigned to an employee. Each row
        embeds the linked `skill` definition so apps can branch on
        `skill.skill_category` and the `requires_*` flags without an extra
        `/v1/skills` lookup.

        **Requires scope:** `employees:read`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: List of employee skills
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/EmployeeSkill'
        '404':
          description: Employee not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    post:
      summary: Assign a skill or certification to an employee
      operationId: createEmployeeSkill
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      description: |
        Create an employee_skills row linking the employee to a
        `company_skills` definition. Cert-style fields
        (`certification_number`, `issue_date`, `expiry_date`,
        `issuing_authority`) are required only when the corresponding
        `requires_*` flag is set on the skill definition.

        **Requires scope:** `employees:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmployeeSkillCreate'
      responses:
        '201':
          description: Skill assigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EmployeeSkill'
        '400':
          description: |
            Validation error. When required `requires_*` fields are missing,
            the body matches `SkillFieldError` with the offending field names
            in `fields[]`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SkillFieldError'
        '404':
          description: Employee or skill not found / not in your company
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Employee already has this skill - use PATCH instead
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /employees/{id}/skills/{rowId}:
    get:
      summary: Get a single employee skill assignment
      operationId: getEmployeeSkill
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      description: |
        **Requires scope:** `employees:read`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: rowId
          in: path
          required: true
          description: employee_skills.id
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Employee skill row
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EmployeeSkill'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update an employee skill assignment
      operationId: updateEmployeeSkill
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      description: |
        Patch any subset of fields. Required-field validation runs against
        the merged result.

        **Requires scope:** `employees:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: rowId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmployeeSkillUpdate'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EmployeeSkill'
        '400':
          description: Validation error (see `SkillFieldError` for missing requirements)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SkillFieldError'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Remove a skill assignment from an employee
      operationId: deleteEmployeeSkill
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      description: |
        Deletes the `employee_skills` row and any attached document.

        **Requires scope:** `employees:delete`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: rowId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Deleted
        '404':
          description: Not found

  /employees/{id}/skills/{rowId}/document:
    post:
      summary: Upload a certificate document for an employee skill
      operationId: uploadEmployeeSkillDocument
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      description: |
        Upload (or replace) the certificate file attached to an employee
        skill row. Stored in the private `employee-certificates` bucket at
        `{companyId}/{employeeId}/{rowId}/{uuid}_{filename}`. The row's
        `document_url` and `document_name` columns are updated atomically;
        a DB write failure rolls the file back.

        **Requires scope:** `employees:write`
        **App requirement:** the app's catalog entry must have
        `allow_file_upload = true`.

        **Constraints**
        - Field name: `file`
        - Max size: 10 MB
        - Allowed MIME types: `application/pdf`, `image/png`, `image/jpeg`,
          `image/webp`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: rowId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '200':
          description: Document uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EmployeeSkill'
        '403':
          description: App is not granted `allow_file_upload`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Employee or skill row not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: File exceeds 10 MB
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: Unsupported MIME type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Remove the certificate document from an employee skill
      operationId: deleteEmployeeSkillDocument
      tags: [Employees]
      security:
        - ApiKeyAuth: []
      description: |
        Deletes the file from the `employee-certificates` bucket and clears
        `document_url` / `document_name`. The `employee_skills` row itself
        is preserved.

        **Requires scope:** `employees:delete`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: rowId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Document removed
        '404':
          description: Not found

  # Time Off endpoints
  /time-off:
    get:
      summary: List time-off records
      operationId: listTimeOff
      tags: [Time Off]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: employee_id
          in: query
          schema:
            type: string
          description: |
            Filter by employee ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: emp_number
          in: query
          schema:
            type: string
          description: Filter by employee number
        - name: start_date
          in: query
          schema:
            type: string
            format: date
          description: Filter by start date (YYYY-MM-DD)
        - name: end_date
          in: query
          schema:
            type: string
            format: date
          description: Filter by end date (YYYY-MM-DD)
        - name: status
          in: query
          schema:
            type: string
            enum: [approved, pending, denied]
          description: Filter by approval status
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync - only fetch records changed since your last sync.
            Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of time-off UUIDs to filter by (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: employee_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of employee UUIDs to filter by (maximum 15).
            Example: `uuid1,uuid2,uuid3`
      responses:
        '200':
          description: List of time-off records
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TimeOff'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create time-off record
      operationId: createTimeOff
      tags: [Time Off]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimeOffCreate'
      responses:
        '201':
          description: Time off created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimeOff'
                  message:
                    type: string
                  id:
                    type: string
                    format: uuid
        '400':
          description: Bad request - validation error or employee not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden

  /time-off/{id}:
    get:
      summary: Get time-off by ID
      operationId: getTimeOff
      tags: [Time Off]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Time-off details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimeOff'
        '404':
          description: Time-off record not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    put:
      summary: Update time-off record
      operationId: updateTimeOff
      tags: [Time Off]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimeOffUpdate'
      responses:
        '200':
          description: Time-off updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimeOff'
                  message:
                    type: string
        '400':
          description: Bad request
        '404':
          description: Time-off record not found

    delete:
      summary: Delete time-off record
      operationId: deleteTimeOff
      tags: [Time Off]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Time-off deleted
        '404':
          description: Time-off record not found

  # Projects endpoints
  /projects:
    get:
      summary: List projects
      operationId: listProjects
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: search
          in: query
          schema:
            type: string
        - name: customer_id
          in: query
          schema:
            type: string
          description: |
            Filter by customer ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: status
          in: query
          description: |
            Filter by project status. Values depend on company's project_status_mode.
            Use GET /projects/statuses to retrieve available statuses.
          schema:
            type: string
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync - only fetch records changed since your last sync.
            Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of project UUIDs to filter by (maximum 15).
            Example: `uuid1,uuid2,uuid3`
      responses:
        '200':
          description: List of projects
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Project'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create project
      operationId: createProject
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      description: |
        Create a new project. Supports two modes:
        
        **UUID-based** — provide `customer_id` and `location_id` to link existing entities.
        
        **Name-based (resolve-or-create)** — provide `customer_name` and `location_name` as text.
        WorkSkedge will match existing entities by name (case-insensitive) or create new ones automatically.
        Optional metadata fields (`customer_email`, `location_address_line_1`, etc.) are used when creating new entities.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectCreate'
            examples:
              uuid_based:
                summary: Create with UUIDs
                value:
                  project_name: "Office Renovation"
                  project_code: "PROJ-001"
                  customer_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                  location_id: "f1e2d3c4-b5a6-7890-fedc-ba0987654321"
                  status: "lead"
                  priority: "normal"
              name_based:
                summary: Create with names (resolve-or-create)
                value:
                  project_name: "Kitchen Remodel"
                  project_code: "PROJ-002"
                  customer_name: "Acme Corporation"
                  customer_email: "info@acme.com"
                  customer_phone: "555-0100"
                  location_name: "Acme HQ"
                  location_address_line_1: "123 Main St"
                  location_city: "Toronto"
                  location_province: "ON"
                  location_postal_code: "M5V 2T6"
                  location_country: "CA"
                  status: "lead"
                  priority: "high"
      responses:
        '201':
          description: Project created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'
                  message:
                    type: string
                  id:
                    type: string

  /projects/{id}:
    get:
      summary: Get project by ID
      operationId: getProject
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Project details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'

    patch:
      summary: Update project
      operationId: updateProject
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectUpdate'
      responses:
        '200':
          description: Project updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'
                  message:
                    type: string

    delete:
      summary: Delete project
      operationId: deleteProject
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Project deleted

  /projects/{id}/notes:
    get:
      tags: [Projects]
      summary: List project notes
      operationId: listProjectNotes
      description: |
        List internal notes attached to a project, ordered by most recent first.
        
        **Requires scope:** `projects:read`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: List of notes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Note'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
    post:
      tags: [Projects]
      summary: Create project note
      operationId: createProjectNote
      description: |
        Add an internal note to a project.
        
        **Requires scope:** `projects:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NoteCreate'
      responses:
        '201':
          description: Note created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Note'

  /projects/{id}/notes/{noteId}:
    patch:
      tags: [Projects]
      summary: Update project note
      operationId: updateProjectNote
      description: |
        Update the text of an existing project note.
        
        **Requires scope:** `projects:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: noteId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NoteUpdate'
      responses:
        '200':
          description: Note updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Note'
    delete:
      tags: [Projects]
      summary: Delete project note
      operationId: deleteProjectNote
      description: |
        Delete an internal note from a project.
        
        **Requires scope:** `projects:delete`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: noteId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Note deleted

  /projects/{id}/documents:
    get:
      summary: List project documents
      operationId: listProjectDocuments
      tags: [Projects]
      description: |
        Returns documents attached to a project with offset-based pagination.

        For storage-backed files (`type: "file"`), each item includes a **signed download URL**
        valid for 1 hour. For external links (`type: "link"`), the stored URL is returned directly.

        **Pagination:** Documents are stored on the project record (`projects.docs` JSON array)
        rather than as separate rows. The endpoint slices that array and signs URLs **only for
        the requested page**, so requesting page 1 does not sign every document. Defaults: `limit=100`,
        `offset=0`. Maximum `limit` is 100.

        This is a read-only endpoint — no special feature flag is required.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Project ID
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          required: false
          description: Number of items per page (default 100, max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - name: offset
          in: query
          required: false
          description: Number of items to skip (default 0)
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Paginated list of project documents
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      oneOf:
                        - type: object
                          properties:
                            type:
                              type: string
                              enum: [file]
                            name:
                              type: string
                              description: Original filename
                            path:
                              type: string
                              description: Storage path
                            url:
                              type: string
                              format: uri
                              description: Signed download URL (valid for 1 hour)
                            expires_in:
                              type: integer
                              description: URL validity in seconds
                              example: 3600
                        - type: object
                          properties:
                            type:
                              type: string
                              enum: [link]
                            label:
                              type: string
                              description: Display label for the link
                            url:
                              type: string
                              format: uri
                              description: External URL
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
              example:
                data:
                  - type: file
                    name: "blueprint.pdf"
                    path: "company-uuid/project-uuid/blueprint.pdf"
                    url: "https://example.supabase.co/storage/v1/object/sign/project-documents/..."
                    expires_in: 3600
                  - type: link
                    label: "Google Drive Folder"
                    url: "https://drive.google.com/..."
                meta:
                  total: 2
                  limit: 100
                  offset: 0
                  has_more: false
        '404':
          description: Project not found
    post:
      summary: Upload document to project
      operationId: uploadProjectDocument
      tags: [Projects]
      description: |
        Upload a file directly to a project's document storage.
        
        **⚠️ Restricted API**: This endpoint requires explicit permission.
        Your app must be approved for file upload access by a WorkSkedge administrator.
        Contact WorkSkedge support to request access to this API.
        
        **Supported file types:**
        - Images: JPEG, PNG, WebP, GIF
        - Documents: PDF, Word (.doc, .docx), Excel (.xlsx)
        - Text: Plain text files
        
        **Maximum file size:** 10MB
        
        Once uploaded, the file is added to the project's `docs` array with `type: "file"`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Project ID
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: The file to upload (max 10MB)
              required:
                - file
      responses:
        '201':
          description: File uploaded successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      type:
                        type: string
                        enum: [file]
                        description: Document type
                      path:
                        type: string
                        description: Storage path of the uploaded file
                      name:
                        type: string
                        description: Original filename
                      content_type:
                        type: string
                        description: MIME type of the file
                      size_bytes:
                        type: integer
                        description: File size in bytes
              example:
                data:
                  type: file
                  path: "company-uuid/project-uuid/abc123_document.pdf"
                  name: "document.pdf"
                  content_type: "application/pdf"
                  size_bytes: 123456
        '400':
          description: Bad request - no file provided or invalid Content-Type
        '403':
          description: |
            Forbidden - one of:
            - Missing required scope (projects:write)
            - App does not have file upload permission
        '404':
          description: Project not found
        '413':
          description: File too large (max 10MB)
        '415':
          description: Unsupported file type

  /projects/{id}/documents/{filename}:
    delete:
      summary: Delete document from project
      operationId: deleteProjectDocument
      tags: [Projects]
      description: |
        Delete a file from a project's document storage.
        
        **⚠️ Restricted API**: This endpoint requires explicit permission.
        Your app must be approved for file upload access by a WorkSkedge administrator.
        
        The file is removed from both the storage bucket and the project's `docs` array.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Project ID
          schema:
            type: string
            format: uuid
        - name: filename
          in: path
          required: true
          description: Original filename of the document to delete
          schema:
            type: string
      responses:
        '204':
          description: Document deleted successfully
        '403':
          description: |
            Forbidden - one of:
            - Missing required scope (projects:delete)
            - App does not have file upload permission
        '404':
          description: Project or document not found

  /projects/statuses:
    get:
      summary: List available project statuses
      description: |
        Returns the list of available project statuses for the company.
        The statuses depend on the company's project_status_mode (simple or advanced).
        
        **Simple mode** statuses:
        - active (default for new projects)
        - closed (terminal)
        
        **Advanced mode** statuses:
        - lead (default for new projects)
        - estimating
        - pending_approval
        - approved
        - active
        - complete
        - closed (terminal)
      operationId: listProjectStatuses
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: List of available project statuses
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      mode:
                        type: string
                        enum: [simple, advanced]
                        description: Company's current project status mode
                      statuses:
                        type: array
                        items:
                          $ref: '#/components/schemas/ProjectStatusDefinition'

  /projects/{id}/status:
    patch:
      summary: Change project status
      description: |
        Change a project's status. Use GET /projects/statuses to retrieve available statuses.
        
        Note: Changing to a terminal status (e.g., "closed") requires all work orders to be complete.
        "job_lost" is also a terminal status in advanced mode. Pass an optional `reason` field to record why the job was lost.
      operationId: changeProjectStatus
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  description: New status key (e.g., "active", "closed", "job_lost")
                reason:
                  type: string
                  description: Optional reason for the status change. Used when transitioning to "job_lost" to capture why the job was lost.
      responses:
        '200':
          description: Project status changed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'
                  message:
                    type: string
        '400':
          description: Invalid status or project cannot be changed to this status

  /projects/{id}/customer-pos:
    post:
      summary: Add customer PO numbers to project
      description: Add one or more customer purchase order numbers to a project. Duplicates are ignored.
      operationId: addProjectCustomerPos
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_po_numbers]
              properties:
                customer_po_numbers:
                  type: array
                  items:
                    type: string
                    maxLength: 100
                  maxItems: 20
                  description: PO numbers to add
                  example: ["PO-12345", "CO-001-PO"]
      responses:
        '200':
          description: PO numbers added
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'
        '404':
          description: Project not found
    delete:
      summary: Remove customer PO numbers from project
      description: Remove one or more customer purchase order numbers from a project.
      operationId: removeProjectCustomerPos
      tags: [Projects]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_po_numbers]
              properties:
                customer_po_numbers:
                  type: array
                  items:
                    type: string
                  description: PO numbers to remove
                  example: ["PO-12345"]
      responses:
        '200':
          description: PO numbers removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'
        '404':
          description: Project not found

  # Work Orders endpoints
  /work-orders:
    get:
      summary: List work orders
      operationId: listWorkOrders
      tags: [Work Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: search
          in: query
          schema:
            type: string
        - name: project_id
          in: query
          schema:
            type: string
          description: |
            Filter by project ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: status
          in: query
          schema:
            type: string
            enum: [created, draft, ready, assigned, active, on-hold, complete-pending, complete, closed, cancelled]
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync. Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: Comma-separated list of work order UUIDs (maximum 15)
        - name: project_ids
          in: query
          schema:
            type: string
          description: Comma-separated list of project UUIDs (maximum 15)
        - name: include_overhead
          in: query
          schema:
            type: string
            enum: ['true', 'false']
            default: 'false'
          description: |
            Include auto-created Project Overhead work orders. Defaults to `false` (overhead WOs are excluded).
      responses:
        '200':
          description: List of work orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WorkOrder'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create work order
      operationId: createWorkOrder
      tags: [Work Orders]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WorkOrderCreate'
      responses:
        '201':
          description: Work order created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WorkOrder'
                  message:
                    type: string
                  id:
                    type: string

  /work-orders/{id}:
    get:
      summary: Get work order by ID
      operationId: getWorkOrder
      tags: [Work Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Work order details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WorkOrder'

    patch:
      summary: Update work order
      operationId: updateWorkOrder
      tags: [Work Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WorkOrderUpdate'
      responses:
        '200':
          description: Work order updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WorkOrder'
                  message:
                    type: string

    delete:
      summary: Delete work order
      operationId: deleteWorkOrder
      tags: [Work Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Work order deleted

  /work-orders/{id}/notes:
    get:
      tags: [Work Orders]
      summary: List work order notes
      operationId: listWorkOrderNotes
      description: |
        List internal notes attached to a work order, ordered by most recent first.
        
        **Requires scope:** `work_orders:read`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: List of notes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Note'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
    post:
      tags: [Work Orders]
      summary: Create work order note
      operationId: createWorkOrderNote
      description: |
        Add an internal note to a work order.
        
        **Requires scope:** `work_orders:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NoteCreate'
      responses:
        '201':
          description: Note created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Note'

  /work-orders/{id}/notes/{noteId}:
    patch:
      tags: [Work Orders]
      summary: Update work order note
      operationId: updateWorkOrderNote
      description: |
        Update the text of an existing work order note.
        
        **Requires scope:** `work_orders:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: noteId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NoteUpdate'
      responses:
        '200':
          description: Note updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Note'
    delete:
      tags: [Work Orders]
      summary: Delete work order note
      operationId: deleteWorkOrderNote
      description: |
        Delete an internal note from a work order.
        
        **Requires scope:** `work_orders:delete`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: noteId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Note deleted

  # Timesheet Entries endpoints
  /timesheet-entries:
    get:
      summary: List timesheet entries
      operationId: listTimesheetEntries
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      description: |
        Get all timesheet entries with optional filtering by work order, project, employee, or date range.
        This is useful for cost tracking integrations that need to pull labor hours for specific jobs.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: work_order_id
          in: query
          schema:
            type: string
          description: |
            Filter by work order ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: project_id
          in: query
          schema:
            type: string
          description: |
            Filter by project ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Returns entries for all work orders in the specified project(s).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: employee_id
          in: query
          schema:
            type: string
          description: |
            Filter by employee ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: work_type_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by work type ID
        - name: start_date
          in: query
          schema:
            type: string
            format: date
          description: Filter entries on or after this date (YYYY-MM-DD)
        - name: end_date
          in: query
          schema:
            type: string
            format: date
          description: Filter entries on or before this date (YYYY-MM-DD)
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync. Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of entry UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: timesheet_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of timesheet UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: work_order_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of work order UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: project_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of project UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: employee_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of employee UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
      responses:
        '200':
          description: List of timesheet entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TimesheetEntry'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
              example:
                data:
                  - id: "550e8400-e29b-41d4-a716-446655440000"
                    entry_date: "2025-12-20"
                    hours: 8.0
                    notes: "Installed fixtures"
                    timesheet:
                      id: "550e8400-e29b-41d4-a716-446655440001"
                      timesheet_date: "2025-12-20"
                      status: "submitted"
                      employee:
                        id: "550e8400-e29b-41d4-a716-446655440002"
                        name: "John Doe"
                        emp_number: "001"
                    work_type:
                      id: "550e8400-e29b-41d4-a716-446655440003"
                      name: "Standard Labor"
                      short_code: "LAB"
                      multiplier: 1.0
                    work_order:
                      id: "550e8400-e29b-41d4-a716-446655440004"
                      phase_name: "Phase 1"
                      work_order_number: "WO-001"
                      project:
                        id: "550e8400-e29b-41d4-a716-446655440005"
                        project_name: "Main Project"
                        project_code: "MP-001"
                meta:
                  total: 42
                  limit: 20
                  offset: 0

    post:
      summary: Create timesheet entry
      operationId: createTimesheetEntry
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimesheetEntryCreate'
      responses:
        '201':
          description: Timesheet entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimesheetEntry'
                  message:
                    type: string

  /timesheet-entries/{id}:
    get:
      summary: Get timesheet entry by ID
      operationId: getTimesheetEntry
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Timesheet entry details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimesheetEntry'
        '404':
          description: Timesheet entry not found

    patch:
      summary: Update timesheet entry
      operationId: updateTimesheetEntry
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimesheetEntryUpdate'
      responses:
        '200':
          description: Timesheet entry updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimesheetEntry'
                  message:
                    type: string

    delete:
      summary: Delete timesheet entry
      operationId: deleteTimesheetEntry
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Timesheet entry deleted

  # Timesheets endpoints
  /timesheets:
    get:
      summary: List timesheets
      operationId: listTimesheets
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: employee_id
          in: query
          schema:
            type: string
          description: |
            Filter by employee ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, submitted, locked, processed]
          description: Filter by timesheet status
        - name: timesheet_date
          in: query
          schema:
            type: string
            format: date
          description: Filter by exact timesheet date (YYYY-MM-DD)
        - name: start_date
          in: query
          schema:
            type: string
            format: date
          description: Filter timesheets on or after this date (YYYY-MM-DD)
        - name: end_date
          in: query
          schema:
            type: string
            format: date
          description: Filter timesheets on or before this date (YYYY-MM-DD)
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync. Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of timesheet UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: employee_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of employee UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
      responses:
        '200':
          description: List of timesheets
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Timesheet'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create timesheet
      operationId: createTimesheet
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimesheetCreate'
      responses:
        '201':
          description: Timesheet created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Timesheet'
                  message:
                    type: string
                  id:
                    type: string

  /timesheets/{id}:
    get:
      summary: Get timesheet by ID
      operationId: getTimesheet
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Timesheet details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Timesheet'

    patch:
      summary: Update timesheet
      operationId: updateTimesheet
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimesheetUpdate'
      responses:
        '200':
          description: Timesheet updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Timesheet'
                  message:
                    type: string

    delete:
      summary: Delete timesheet
      operationId: deleteTimesheet
      tags: [Timesheets]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Timesheet deleted

  # Customers endpoints
  /customers:
    get:
      summary: List customers
      operationId: listCustomers
      tags: [Customers]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: search
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync. Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of customer UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
      responses:
        '200':
          description: List of customers
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Customer'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create customer
      operationId: createCustomer
      tags: [Customers]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomerCreate'
      responses:
        '201':
          description: Customer created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Customer'
                  message:
                    type: string
                  id:
                    type: string

  /customers/{id}:
    get:
      summary: Get customer by ID
      operationId: getCustomer
      tags: [Customers]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Customer details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Customer'

    patch:
      summary: Update customer
      operationId: updateCustomer
      tags: [Customers]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomerUpdate'
      responses:
        '200':
          description: Customer updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Customer'
                  message:
                    type: string

    delete:
      summary: Delete customer
      operationId: deleteCustomer
      tags: [Customers]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Customer deleted

  # Locations endpoints
  /job-addresses:
    get:
      summary: List saved job site addresses
      description: |
        Read-only listing of the company-wide repo of reusable job-site addresses.
        Each project / work order keeps its own snapshot of the address it was created with,
        so editing or deleting a saved repo row never changes existing records.
        Returns rows ordered by `last_used_at` (most recently used first).
        Pass the chosen `id` as `site_address_id` on `POST /v1/projects` to attach it without retyping.
      operationId: listJobAddresses
      tags: [Job Addresses]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - name: search
          in: query
          description: Case-insensitive match against name, address_line_1, or city.
          schema: { type: string }
        - name: updated_since
          in: query
          description: ISO-8601 timestamp; only rows updated after this are returned.
          schema: { type: string, format: date-time }
        - name: ids
          in: query
          description: Comma-separated list of job-address UUIDs.
          schema: { type: string }
      responses:
        '200':
          description: List of saved job site addresses
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/JobAddress' }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }

  /job-addresses/{id}:
    get:
      summary: Get a single saved job site address
      operationId: getJobAddress
      tags: [Job Addresses]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Job site address record
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/JobAddress' }
        '404':
          description: Not found

  /locations:
    get:
      summary: List locations
      operationId: listLocations
      tags: [Locations]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: search
          in: query
          schema:
            type: string
        - name: customer_id
          in: query
          schema:
            type: string
          description: |
            Filter by customer ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync. Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of location UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: customer_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of customer UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
      responses:
        '200':
          description: List of locations
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Location'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create location
      operationId: createLocation
      tags: [Locations]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LocationCreate'
      responses:
        '201':
          description: Location created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Location'
                  message:
                    type: string
                  id:
                    type: string

  /locations/{id}:
    get:
      summary: Get location by ID
      operationId: getLocation
      tags: [Locations]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Location details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Location'

    patch:
      summary: Update location
      operationId: updateLocation
      tags: [Locations]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LocationUpdate'
      responses:
        '200':
          description: Location updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Location'
                  message:
                    type: string

    delete:
      summary: Delete location
      operationId: deleteLocation
      tags: [Locations]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Location deleted

  # Contacts endpoints
  /contacts:
    get:
      summary: List contacts
      operationId: listContacts
      tags: [Contacts]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: search
          in: query
          schema:
            type: string
          description: Search by first name, last name, or email
        - name: customer_id
          in: query
          schema:
            type: string
          description: |
            Filter by customer ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: location_id
          in: query
          schema:
            type: string
          description: |
            Filter by location ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync. Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of contact UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: customer_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of customer UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: location_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of location UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
      responses:
        '200':
          description: List of contacts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Contact'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create contact
      operationId: createContact
      tags: [Contacts]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactCreate'
      responses:
        '201':
          description: Contact created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Contact'
                  message:
                    type: string
                  id:
                    type: string

  /contacts/{id}:
    get:
      summary: Get contact by ID
      operationId: getContact
      tags: [Contacts]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Contact details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Contact'

    patch:
      summary: Update contact
      operationId: updateContact
      tags: [Contacts]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactUpdate'
      responses:
        '200':
          description: Contact updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Contact'
                  message:
                    type: string

    delete:
      summary: Delete contact
      operationId: deleteContact
      tags: [Contacts]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Contact deleted

  /customers/{id}/documents:
    get:
      summary: List customer compliance documents
      operationId: listCustomerDocuments
      tags: [Customers]
      description: |
        Returns the compliance documents (insurance, W9, licenses, contracts, tax exemption, etc.)
        attached to a customer. Each entry with a stored file includes a **signed download URL**
        valid for 1 hour (`expires_in: 3600`).

        Requires the `customers:read` scope. This is a read-only endpoint — no restricted flag required.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Customer ID
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: List of customer documents
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AccountDocument' }
        '403':
          description: Missing `customers:read` scope
        '404':
          description: Customer not found
    post:
      summary: Upload a compliance document to a customer
      operationId: uploadCustomerDocument
      tags: [Customers]
      description: |
        Upload a file as a compliance document for a customer (insurance, COI, W9, licenses, etc.).

        **⚠️ Restricted API**: Requires the `customers:write` scope **and** the
        `allow_customer_document_upload` feature flag, which is toggled per-app by a
        WorkSkedge superuser in the Apps Catalog → Restricted API Capabilities section.

        **Supported file types:** JPEG, PNG, WebP, GIF, PDF, Word (.doc, .docx), Excel (.xlsx), plain text.
        **Maximum file size:** 10MB.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Customer ID
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The file to upload (max 10MB)
                document_type:
                  type: string
                  default: other
                  enum: [insurance, w9, license, contract, subcontract_declaration, tax_exempt, compliance, other]
                document_name:
                  type: string
                  description: Display name (defaults to the original filename)
                issuer:
                  type: string
                reference_number:
                  type: string
                issued_date:
                  type: string
                  format: date
                expires_at:
                  type: string
                  format: date
                  description: Expiration date (also accepted as `expiry_date`)
                description:
                  type: string
                  description: Free-form notes (stored on the `notes` column)
      responses:
        '201':
          description: Document uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/AccountDocument' }
        '400':
          description: Invalid request (missing file, bad date, invalid `document_type`)
        '403':
          description: |
            Forbidden — one of:
            - Missing `customers:write` scope
            - App does not have `allow_customer_document_upload` enabled
        '404':
          description: Customer not found
        '413':
          description: File too large (max 10MB)
        '415':
          description: Unsupported file type

  /customers/{id}/documents/{document_id}:
    delete:
      summary: Delete a customer compliance document
      operationId: deleteCustomerDocument
      tags: [Customers]
      description: |
        Deletes both the `customer_documents` row and the underlying storage object.

        **⚠️ Restricted API**: Requires the `customers:write` scope **and** the
        `allow_customer_document_upload` feature flag.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: document_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Document deleted
        '403':
          description: Missing `customers:write` scope or `allow_customer_document_upload` is disabled
        '404':
          description: Customer or document not found

  # Vendor endpoints
  /vendors:
    get:
      summary: List vendors
      operationId: listVendors
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: search
          in: query
          schema:
            type: string
          description: Search by vendor name
        - name: type
          in: query
          schema:
            type: string
            enum: [distributor, manufacturer, subcontractor, rental_provider, supplier, partner]
            description: Filter by vendor type
        - name: tags
          in: query
          schema:
            type: string
          description: Filter by tags (comma-separated)
        - name: status
          in: query
          schema:
            type: string
            enum: [Active, Inactive]
          description: Filter by vendor status
      responses:
        '200':
          description: List of vendors
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/Vendor'
                        - type: object
                          properties:
                            contacts:
                              type: array
                              items:
                                $ref: '#/components/schemas/VendorContact'
                            locations:
                              type: array
                              items:
                                $ref: '#/components/schemas/VendorLocation'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create vendor
      operationId: createVendor
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VendorCreate'
      responses:
        '201':
          description: Vendor created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Vendor'
                  message:
                    type: string
                  id:
                    type: string

  /vendors/{id}:
    get:
      summary: Get vendor by ID
      operationId: getVendor
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Vendor details with contacts and locations
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Vendor'
                      - type: object
                        properties:
                          contacts:
                            type: array
                            items:
                              $ref: '#/components/schemas/VendorContact'
                          locations:
                            type: array
                            items:
                              $ref: '#/components/schemas/VendorLocation'

    patch:
      summary: Update vendor
      operationId: updateVendor
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VendorUpdate'
      responses:
        '200':
          description: Vendor updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Vendor'
                  message:
                    type: string

    delete:
      summary: Delete vendor
      operationId: deleteVendor
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Vendor deleted

  /vendors/{id}/contacts:
    get:
      summary: List vendor contacts
      operationId: listVendorContacts
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Vendor ID
      responses:
        '200':
          description: List of vendor contacts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/VendorContact'

    post:
      summary: Create vendor contact
      operationId: createVendorContact
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Vendor ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VendorContactCreate'
      responses:
        '201':
          description: Vendor contact created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/VendorContact'
                  message:
                    type: string
                  id:
                    type: string

  /vendors/{id}/contacts/{contact_id}:
    patch:
      summary: Update vendor contact
      operationId: updateVendorContact
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Vendor ID
        - name: contact_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Contact ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VendorContactUpdate'
      responses:
        '200':
          description: Vendor contact updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/VendorContact'
                  message:
                    type: string

    delete:
      summary: Delete vendor contact
      operationId: deleteVendorContact
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Vendor ID
        - name: contact_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Contact ID
      responses:
        '204':
          description: Vendor contact deleted

  /vendors/{id}/locations:
    get:
      summary: List vendor locations
      operationId: listVendorLocations
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Vendor ID
      responses:
        '200':
          description: List of vendor locations
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/VendorLocation'

    post:
      summary: Create vendor location
      operationId: createVendorLocation
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Vendor ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VendorLocationCreate'
      responses:
        '201':
          description: Vendor location created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/VendorLocation'
                  message:
                    type: string
                  id:
                    type: string

  /vendors/{id}/locations/{location_id}:
    patch:
      summary: Update vendor location
      operationId: updateVendorLocation
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Vendor ID
        - name: location_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Location ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VendorLocationUpdate'
      responses:
        '200':
          description: Vendor location updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/VendorLocation'
                  message:
                    type: string

    delete:
      summary: Delete vendor location
      operationId: deleteVendorLocation
      tags: [Vendors]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Vendor ID
        - name: location_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Location ID
      responses:
        '204':
          description: Vendor location deleted

  /vendors/{id}/documents:
    get:
      summary: List vendor compliance documents
      operationId: listVendorDocuments
      tags: [Vendors]
      description: |
        Returns the compliance documents (insurance, COI, W9, WSIB, licenses, subcontract declarations,
        prequalification, etc.) attached to a vendor. Each entry with a stored file includes a
        **signed download URL** valid for 1 hour (`expires_in: 3600`).

        Requires the `vendors:read` scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Vendor ID
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: List of vendor documents
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AccountDocument' }
        '403':
          description: Missing `vendors:read` scope
        '404':
          description: Vendor not found
    post:
      summary: Upload a compliance document to a vendor
      operationId: uploadVendorDocument
      tags: [Vendors]
      description: |
        Upload a file as a compliance document for a vendor.

        **⚠️ Restricted API**: Requires the `vendors:write` scope **and** the
        `allow_vendor_document_upload` feature flag, toggled per-app by a WorkSkedge
        superuser in the Apps Catalog → Restricted API Capabilities section.

        **Supported file types:** JPEG, PNG, WebP, GIF, PDF, Word (.doc, .docx), Excel (.xlsx), plain text.
        **Maximum file size:** 10MB.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Vendor ID
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The file to upload (max 10MB)
                document_type:
                  type: string
                  default: other
                  enum: [insurance, w9, license, contract, subcontract_declaration, wsib, prequalification, compliance, other]
                document_name:
                  type: string
                issuer:
                  type: string
                reference_number:
                  type: string
                issued_date:
                  type: string
                  format: date
                expires_at:
                  type: string
                  format: date
                description:
                  type: string
                  description: Free-form notes (stored on the `notes` column)
      responses:
        '201':
          description: Document uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/AccountDocument' }
        '400':
          description: Invalid request (missing file, bad date, invalid `document_type`)
        '403':
          description: |
            Forbidden — one of:
            - Missing `vendors:write` scope
            - App does not have `allow_vendor_document_upload` enabled
        '404':
          description: Vendor not found
        '413':
          description: File too large (max 10MB)
        '415':
          description: Unsupported file type

  /vendors/{id}/documents/{document_id}:
    delete:
      summary: Delete a vendor compliance document
      operationId: deleteVendorDocument
      tags: [Vendors]
      description: |
        Deletes both the `vendor_documents` row and the underlying storage object.

        **⚠️ Restricted API**: Requires the `vendors:write` scope **and** the
        `allow_vendor_document_upload` feature flag.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: document_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Document deleted
        '403':
          description: Missing `vendors:write` scope or `allow_vendor_document_upload` is disabled
        '404':
          description: Vendor or document not found

  # Webhooks endpoints
  /webhooks:
    get:
      summary: List webhook subscriptions
      operationId: listWebhooks
      tags: [Webhooks]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: List of webhook subscriptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookSubscription'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create webhook subscription
      operationId: createWebhook
      tags: [Webhooks]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookSubscriptionCreate'
      responses:
        '201':
          description: Webhook subscription created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookSubscription'
                  message:
                    type: string
                  id:
                    type: string

  /webhooks/{id}:
    delete:
      summary: Delete webhook subscription
      operationId: deleteWebhook
      tags: [Webhooks]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Webhook subscription deleted

  # Reports endpoints
  /reports:
    get:
      summary: List reports
      operationId: listReports
      tags: [Reports]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: work_order_id
          in: query
          schema:
            type: string
            format: uuid
        - name: report_type
          in: query
          schema:
            type: string
            enum: [daily, completion]
        - name: start_date
          in: query
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          schema:
            type: string
            format: date
      responses:
        '200':
          description: List of reports
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Report'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create/submit report
      operationId: createReport
      tags: [Reports]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportCreate'
      responses:
        '201':
          description: Report created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Report'
                  message:
                    type: string
                  id:
                    type: string

  /reports/{id}:
    get:
      summary: Get report by ID
      operationId: getReport
      tags: [Reports]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Report details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Report'

    patch:
      summary: Update report
      description: Update an existing report's scope_checklist, notes, or photos. Cannot update report_type or report_date.
      operationId: updateReport
      tags: [Reports]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportUpdate'
      responses:
        '200':
          description: Report updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Report'
                  message:
                    type: string
        '404':
          description: Report not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /reports/{id}/approve:
    post:
      summary: Approve completion report
      description: Approve a completion report. Only applicable to reports with report_type='completion'. Updates work order status to 'complete'.
      operationId: approveReport
      tags: [Reports]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Report approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Report'
                  message:
                    type: string
        '400':
          description: Invalid request (e.g., not a completion report, already approved)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Report not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /reports/{id}/photos:
    get:
      summary: List report photos
      operationId: listReportPhotos
      tags: [Reports]
      description: |
        Returns all photos attached to a report, each with a **signed download URL** valid for 1 hour.

        **Scope required:** `reports:read` (no feature flag needed — read-only).
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Report ID
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: List of photos attached to the report
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Stable photo identifier (null for legacy URL-only entries)
                        path:
                          type: string
                          description: Storage path inside the `report-photos` bucket
                        name:
                          type: string
                        content_type:
                          type: string
                          nullable: true
                        size_bytes:
                          type: integer
                          nullable: true
                        uploaded_at:
                          type: string
                          format: date-time
                          nullable: true
                        uploaded_by_app_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: App install that uploaded the photo (null for UI uploads)
                        signed_url:
                          type: string
                          format: uri
                          nullable: true
                          description: Signed download URL (valid for `expires_in` seconds)
                        expires_in:
                          type: integer
                          nullable: true
                          example: 3600
              example:
                data:
                  - id: "11111111-2222-3333-4444-555555555555"
                    path: "company-uuid/report-uuid/11111111-2222-3333-4444-555555555555_site-photo.jpg"
                    name: "site-photo.jpg"
                    content_type: "image/jpeg"
                    size_bytes: 248392
                    uploaded_at: "2026-04-24T18:30:00Z"
                    uploaded_by_app_id: "abc12345-6789-..."
                    signed_url: "https://...supabase.co/storage/v1/object/sign/report-photos/..."
                    expires_in: 3600
        '401':
          description: Missing or invalid API key
        '403':
          description: Missing required scope (`reports:read`)
        '404':
          description: Report not found

    post:
      summary: Upload photo to report
      operationId: uploadReportPhoto
      tags: [Reports]
      description: |
        Upload an image to a daily or completion report.

        **⚠️ Restricted API**: This endpoint requires explicit permission.
        Your app must have the `allow_file_upload` flag enabled by a WorkSkedge administrator
        (the same flag that gates `POST /projects/:id/documents`).
        Contact WorkSkedge to request access.

        **Constraints:**
        - Maximum file size: **5 MB**
        - Allowed MIME types: `image/jpeg`, `image/png`, `image/webp`, `image/gif`
        - Maximum **50 photos** per report
        - Reports with `approved_at` set are locked (returns 409)

        Once uploaded, the photo is appended to the report's `photos` array as an object
        with `{ id, url, name, content_type, size_bytes, uploaded_at, uploaded_by_app_id }`.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Report ID
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The image to upload (max 5MB, image MIME types only)
      responses:
        '201':
          description: Photo uploaded and attached to the report
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      path:
                        type: string
                      name:
                        type: string
                      content_type:
                        type: string
                      size_bytes:
                        type: integer
                      signed_url:
                        type: string
                        format: uri
                        nullable: true
                      expires_in:
                        type: integer
                        nullable: true
              example:
                data:
                  id: "11111111-2222-3333-4444-555555555555"
                  path: "company-uuid/report-uuid/11111111-2222-3333-4444-555555555555_site-photo.jpg"
                  name: "site-photo.jpg"
                  content_type: "image/jpeg"
                  size_bytes: 248392
                  signed_url: "https://...supabase.co/storage/v1/object/sign/report-photos/..."
                  expires_in: 3600
        '400':
          description: Bad request — missing `file` field or wrong Content-Type
        '401':
          description: Missing or invalid API key
        '403':
          description: |
            Forbidden — one of:
            - Missing required scope (`reports:write`)
            - App does not have file upload permission (`allow_file_upload` is disabled)
        '404':
          description: Report not found
        '409':
          description: Report is locked (`approved_at` is set) or 50-photo cap reached
        '413':
          description: File exceeds 5 MB
        '415':
          description: Unsupported MIME type

  /reports/{id}/photos/{photoId}:
    delete:
      summary: Delete photo from report
      operationId: deleteReportPhoto
      tags: [Reports]
      description: |
        Remove a single photo from a report. Deletes the file from storage **and** drops it
        from the report's `photos` array.

        **⚠️ Restricted API**: Requires `reports:delete` (or `reports:write`) scope and the
        `allow_file_upload` flag enabled on your app.

        `photoId` is the `id` returned by the upload endpoint. For legacy URL-only photo
        entries, you may pass the trailing `{uuid}` segment of the storage path.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Report ID
          schema:
            type: string
            format: uuid
        - name: photoId
          in: path
          required: true
          description: Photo ID returned from the upload endpoint
          schema:
            type: string
      responses:
        '204':
          description: Photo deleted
        '401':
          description: Missing or invalid API key
        '403':
          description: |
            Forbidden — one of:
            - Missing required scope (`reports:delete` or `reports:write`)
            - App does not have file upload permission
        '404':
          description: Report or photo not found
        '409':
          description: Report is locked (`approved_at` is set)

  # ==========================================
  # SCHEDULES ENDPOINTS (Full CRUD)
  # ==========================================
  /schedules:
    get:
      summary: List schedules
      operationId: listSchedules
      tags: [Schedules]
      description: |
        Retrieve daily work schedule blocks for employees. Schedules represent specific 
        daily work blocks within an assignment (e.g., Jan 15, 8am-5pm).
        
        **Requires scope:** `schedules:read`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: work_order_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by work order
        - name: employee_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by employee
        - name: start_date
          in: query
          schema:
            type: string
            format: date
          description: Filter schedules on or after this date
        - name: end_date
          in: query
          schema:
            type: string
            format: date
          description: Filter schedules on or before this date
        - name: is_active
          in: query
          schema:
            type: boolean
          description: Filter by active status (default includes active only)
      responses:
        '200':
          description: List of schedules
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Schedule'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create schedule day
      operationId: createSchedule
      tags: [Schedules]
      description: |
        Add a single schedule day to an existing assignment. Mirrors the in-app
        Schedule Wizard exactly — emits the same webhooks and notifications.
        See `docs/api/scheduling.md` for the full decision tree and conflict policy.

        **Requires scope:** `schedules:write`
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScheduleCreateRequest'
      responses:
        '201':
          description: Schedule day created. Emits `schedule.batch_updated`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScheduleWriteResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: |
            Hard conflict. `code` is one of:
            `conflict` — duplicate `(assignment_id, work_date)`, duplicate
            `idempotency_key`, or duplicate `external_id`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /schedules/batch:
    post:
      summary: Batch create schedule days
      operationId: batchCreateSchedules
      tags: [Schedules]
      description: |
        Add N schedule days to an existing assignment in a single call.
        Returns `created_count`, the new `schedule_ids[]`, and a `warnings[]`
        array mirroring the in-app overlap/time-off banners.

        **Requires scope:** `schedules:write`
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScheduleBatchCreateRequest'
      responses:
        '200':
          description: Days created. Emits `schedule.batch_updated`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      assignment_id:
                        type: string
                        format: uuid
                      created_count:
                        type: integer
                      schedule_ids:
                        type: array
                        items:
                          type: string
                          format: uuid
                      warnings:
                        type: array
                        items:
                          $ref: '#/components/schemas/SchedulingWarning'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Hard conflict (duplicate day, idempotency_key, or external_id).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /schedules/{id}:
    get:
      summary: Get schedule by ID
      operationId: getSchedule
      tags: [Schedules]
      description: |
        Retrieve a single schedule block by its ID.
        
        **Requires scope:** `schedules:read`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Schedule details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Schedule'
        '404':
          description: Schedule not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update schedule day
      operationId: updateSchedule
      tags: [Schedules]
      description: |
        Update times, notes, `work_date`, or `is_active` (soft-delete) on a
        single schedule day. Any time/work_date change automatically stamps
        `is_override = true`, matching the UI override semantics.

        **Requires scope:** `schedules:write`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScheduleUpdateRequest'
      responses:
        '200':
          description: Schedule updated. Emits `schedule.batch_updated`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScheduleWriteResponse'
        '404':
          description: Schedule not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Hard conflict (duplicate `(assignment_id, work_date)`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Delete schedule day
      operationId: deleteSchedule
      tags: [Schedules]
      description: |
        Hard-delete a single schedule day. Emits `schedule.batch_deleted`
        scoped to that date for the parent assignment.

        **Requires scope:** `schedules:delete`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Schedule deleted
        '404':
          description: Schedule not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'


  # ==========================================
  # ASSIGNMENTS ENDPOINTS (Full CRUD)
  # ==========================================
  /assignments:
    get:
      summary: List assignments
      operationId: listAssignments
      tags: [Assignments]
      description: |
        Retrieve employee assignments to work orders. Assignments represent the high-level 
        record of an employee's duration on a work order (e.g., Jan 15-20).
        
        **Requires scope:** `assignments:read`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: employee_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by single employee ID
        - name: employee_ids
          in: query
          schema:
            type: string
          description: Filter by multiple employee IDs (comma-separated UUIDs)
        - name: work_order_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by single work order ID
        - name: work_order_ids
          in: query
          schema:
            type: string
          description: Filter by multiple work order IDs (comma-separated UUIDs)
        - name: start_date
          in: query
          schema:
            type: string
            format: date
          description: Filter assignments that overlap with dates on or after this date
        - name: end_date
          in: query
          schema:
            type: string
            format: date
          description: Filter assignments that overlap with dates on or before this date
      responses:
        '200':
          description: List of assignments
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Assignment'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create assignment
      operationId: createAssignment
      tags: [Assignments]
      description: |
        Create an employee assignment on a work order, optionally with a
        `schedule_days[]` array that inserts the day rows in the same call.
        Mirrors the in-app Assign Schedule button — same webhooks, same
        notifications, same work-order status transitions.

        Pass `idempotency_key` to make retries safe; a duplicate
        `(company_id, idempotency_key)` returns the original record with
        `"idempotent_replay": true`.

        **Requires scope:** `assignments:write` (plus `schedules:write` when
        `schedule_days[]` is provided).
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssignmentCreateRequest'
      responses:
        '201':
          description: |
            Assignment created. Emits `assignment.created`; if `schedule_days[]`
            was provided, also emits `schedule.batch_created` and (when the WO
            transitions ready/draft → assigned/active) `work_order.status_changed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssignmentWriteResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: |
            Hard conflict. `code` is one of:
            `conflict` — duplicate `(work_order, employee, dates)` assignment,
            duplicate `idempotency_key`, or duplicate `external_id`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /assignments/{id}:
    get:
      summary: Get assignment by ID
      operationId: getAssignment
      tags: [Assignments]
      description: |
        Retrieve a single assignment by its ID. Includes detailed information about 
        the assigned employee, work order, project, and location.
        
        **Requires scope:** `assignments:read`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Assignment details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Assignment'
        '404':
          description: Assignment not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update assignment
      operationId: updateAssignment
      tags: [Assignments]
      description: |
        Update the assignment header (dates, employee swap, or `external_id`).
        Existing schedule days are preserved; use `PATCH /v1/schedules/{id}`
        or `DELETE /v1/schedules/{id}` to change individual days.

        **Requires scope:** `assignments:write`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssignmentUpdateRequest'
      responses:
        '200':
          description: Assignment updated. Emits `assignment.updated`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssignmentWriteResponse'
        '404':
          description: Assignment not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Delete assignment
      operationId: deleteAssignment
      tags: [Assignments]
      description: |
        Cascade-delete the assignment and every `work_order_schedules` row
        under it. Emits `schedule.batch_deleted` followed by
        `assignment.deleted`, and queues a calendar cancellation if the
        employee was previously notified.

        **Requires scope:** `assignments:delete`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Assignment deleted
        '404':
          description: Assignment not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'


  # ==========================================
  # CUSTOM BOOKINGS ENDPOINTS (Full CRUD)
  # ==========================================
  /custom-bookings:
    get:
      summary: List custom bookings
      operationId: listCustomBookings
      tags: [Custom Bookings]
      description: |
        Retrieve non-work-order time bookings for employees (training, meetings, etc.).
        
        **Requires scope:** `custom_bookings:read`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: employee_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by single employee ID
        - name: employee_ids
          in: query
          schema:
            type: string
          description: Filter by multiple employee IDs (comma-separated UUIDs)
        - name: booking_date
          in: query
          schema:
            type: string
            format: date
          description: Filter by exact booking date
        - name: start_date
          in: query
          schema:
            type: string
            format: date
          description: Filter bookings on or after this date
        - name: end_date
          in: query
          schema:
            type: string
            format: date
          description: Filter bookings on or before this date
        - name: work_type_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by work type
        - name: is_active
          in: query
          schema:
            type: boolean
          description: Filter by active status
        - name: external_id
          in: query
          schema:
            type: string
          description: Filter by external system ID
        - name: is_availability
          in: query
          schema:
            type: boolean
          description: Filter by availability note status
      responses:
        '200':
          description: List of custom bookings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CustomBooking'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create custom booking
      operationId: createCustomBooking
      tags: [Custom Bookings]
      description: |
        Create a non-work-order time booking for an employee.

        Provide either `booking_date` (single date) **or** `booking_dates`
        (array, 1–90 dates) — never both. When `booking_dates` is used, one row
        is inserted per date, one `custom_booking.created` webhook fires per
        row, and the optional `external_id` is shared across every row for
        later group lookup. Duplicate dates are de-duplicated server-side.

        **Response shape**
        - Single-date requests return `{ data: <CustomBooking> }` (unchanged).
        - Multi-date requests return `{ data: { created, external_id, bookings[] } }`.

        **Requires scope:** `custom_bookings:write`
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomBookingCreate'
            examples:
              single_date:
                summary: Single booking date
                value:
                  employee_id: "11111111-1111-1111-1111-111111111111"
                  booking_date: "2025-01-20"
                  name: "Safety Training"
                  start_time: "09:00"
                  end_time: "12:00"
                  external_id: "training-123"
              bulk_dates:
                summary: Bulk-create across multiple dates
                value:
                  employee_id: "11111111-1111-1111-1111-111111111111"
                  booking_dates: ["2025-01-20", "2025-01-21", "2025-01-22"]
                  name: "Safety Training"
                  start_time: "09:00"
                  end_time: "12:00"
                  external_id: "training-123"
      responses:
        '201':
          description: Custom booking created. Single-date returns a single row; multi-date returns a bulk summary.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CustomBooking'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CustomBookingBatchResponse'
        '400':
          description: Validation error (e.g., both `booking_date` and `booking_dates` provided, or more than 90 dates)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /custom-bookings/batch:
    post:
      summary: Batch create custom bookings
      operationId: batchCreateCustomBookings
      tags: [Custom Bookings]
      description: |
        Create the same booking for multiple employees across one or many dates.
        Useful for group events like toolbox meetings, safety training, or
        company meetings.

        Provide either `booking_date` (single date) **or** `booking_dates`
        (array, 1–90 dates) — never both. The total fan-out
        (`employee_ids.length × booking_dates.length`) must not exceed
        **500 rows** per request. All created bookings share the same
        `external_id` (if provided) for group tracking, and one
        `custom_booking.created` webhook fires per created row.

        **Requires scope:** `custom_bookings:write`
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomBookingBatchCreate'
            examples:
              single_date_many_employees:
                summary: One date, many employees
                value:
                  employee_ids:
                    - "11111111-1111-1111-1111-111111111111"
                    - "22222222-2222-2222-2222-222222222222"
                  booking_date: "2025-01-20"
                  name: "Toolbox Meeting"
                  start_time: "07:00"
                  end_time: "07:30"
                  external_id: "toolbox-2025-01-20"
              bulk_dates_many_employees:
                summary: Many dates × many employees (fan-out)
                value:
                  employee_ids:
                    - "11111111-1111-1111-1111-111111111111"
                    - "22222222-2222-2222-2222-222222222222"
                  booking_dates: ["2025-01-20", "2025-01-21", "2025-01-22"]
                  name: "Safety Training Week"
                  start_time: "09:00"
                  end_time: "12:00"
                  external_id: "training-week-2025-03"
      responses:
        '200':
          description: Batch bookings created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomBookingBatchResponse'
        '400':
          description: Validation error (invalid employee IDs, both date fields provided, more than 90 dates, or fan-out exceeds 500 rows)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /custom-bookings/{id}:
    get:
      summary: Get custom booking by ID
      operationId: getCustomBooking
      tags: [Custom Bookings]
      description: |
        Retrieve a single custom booking by its ID.
        
        **Requires scope:** `custom_bookings:read`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Custom booking details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomBooking'
        '404':
          description: Custom booking not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update custom booking
      operationId: updateCustomBooking
      tags: [Custom Bookings]
      description: |
        Update an existing custom booking.
        
        **Requires scope:** `custom_bookings:write`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomBookingUpdate'
      responses:
        '200':
          description: Custom booking updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomBooking'
        '404':
          description: Custom booking not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Delete custom booking
      operationId: deleteCustomBooking
      tags: [Custom Bookings]
      description: |
        Soft-delete a custom booking by setting `is_active` to false.
        
        **Requires scope:** `custom_bookings:delete`
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Custom booking deleted
        '404':
          description: Custom booking not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ==========================================
  # PURCHASE ORDERS ENDPOINTS
  # ==========================================
  /purchase-orders:
    get:
      summary: List purchase orders
      operationId: listPurchaseOrders
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, submitted, approved, rejected, partially_received, received, billed, cancelled]
        - name: payment_status
          in: query
          schema:
            type: string
            enum: [unpaid, partially_paid, paid, overpaid, closed]
          description: |
            Filter by payment status. Example: `paid`
        - name: project_id
          in: query
          schema:
            type: string
          description: |
            Filter by project ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: vendor_id
          in: query
          schema:
            type: string
          description: |
            Filter by vendor ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: work_order_id
          in: query
          schema:
            type: string
          description: |
            Filter by work order ID. Accepts a single UUID or comma-separated list of UUIDs (maximum 15).
            Example: `uuid1` or `uuid1,uuid2,uuid3`
        - name: search
          in: query
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync. Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of purchase order UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: work_order_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of work order UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: project_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of project UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: vendor_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of vendor UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: date_from
          in: query
          schema:
            type: string
            format: date
          description: |
            Filter POs created on or after this date (YYYY-MM-DD).
            Filters against `created_at`. Example: `2026-01-01`
        - name: date_to
          in: query
          schema:
            type: string
            format: date
          description: |
            Filter POs created on or before this date (YYYY-MM-DD).
            Filters against `created_at`. Inclusive of the full day.
            Example: `2026-03-15`
        - name: sync_status
          in: query
          schema:
            type: string
            enum: [unsynced, pending, synced, error]
          description: |
            Filter by sync status for accounting integrations.
      responses:
        '200':
          description: Purchase orders list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PurchaseOrder'
                  total:
                    type: integer
                    description: Total number of records (convenience field, also in meta.total)
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create purchase order
      operationId: createPurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PurchaseOrderCreate'
      responses:
        '201':
          description: Purchase order created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
                  message:
                    type: string

  /purchase-orders/{id}:
    get:
      summary: Get purchase order
      operationId: getPurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Purchase order details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'

    patch:
      summary: Update purchase order (draft only)
      operationId: updatePurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PurchaseOrderUpdate'
      responses:
        '200':
          description: Purchase order updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
                  message:
                    type: string

    delete:
      summary: Delete purchase order (draft only)
      operationId: deletePurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Purchase order deleted

  /purchase-orders/{id}/submit:
    post:
      summary: Submit purchase order for approval
      operationId: submitPurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Purchase order submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
                  message:
                    type: string

  /purchase-orders/{id}/approve:
    post:
      summary: Approve purchase order
      operationId: approvePurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Purchase order approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
                  message:
                    type: string

  /purchase-orders/{id}/reject:
    post:
      summary: Reject purchase order
      operationId: rejectPurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PORejectBody'
      responses:
        '200':
          description: Purchase order rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
                  message:
                    type: string

  /purchase-orders/{id}/receive:
    post:
      summary: Mark purchase order as received
      operationId: receivePurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Purchase order received
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
                  message:
                    type: string

  /purchase-orders/{id}/bill:
    post:
      summary: Mark purchase order as billed
      description: |
        Mark an approved or received purchase order as billed with invoice details.
        Records invoice number, amount, and date for reconciliation.
        Calculates variance between PO total and invoice amount.
      operationId: billPurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - invoice_number
                - invoice_amount
              properties:
                invoice_number:
                  type: string
                  description: Invoice number from vendor
                invoice_amount:
                  type: number
                  description: Total amount on the invoice
                invoice_date:
                  type: string
                  format: date
                  description: Date on the invoice
                bill_due_date:
                  type: string
                  format: date
                  description: Date the bill/invoice is due for payment
                line_items:
                  type: array
                  description: Optional detailed line items for this bill
                  items:
                    type: object
                    required:
                      - description
                    properties:
                      description:
                        type: string
                      quantity:
                        type: number
                        default: 1
                      unit:
                        type: string
                        nullable: true
                      unit_price:
                        type: number
                        default: 0
                      cost_code:
                        type: string
                        nullable: true
                      is_taxable:
                        type: boolean
                        default: true
                        description: Whether this line item is subject to tax
                      tax_code_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: Override tax code for this line item
                tax_lines:
                  type: array
                  description: Optional tax lines for this bill
                  items:
                    type: object
                    properties:
                      name:
                        type: string
                      rate:
                        type: number
                      amount:
                        type: number
                      inclusion:
                        type: string
                        enum: [included, excluded]
                        default: excluded
      responses:
        '200':
          description: Purchase order billed (status will be 'billed' or 'partially_billed' depending on total)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
                  message:
                    type: string
        '400':
          description: Invalid request or PO not in valid state for billing

  /purchase-orders/{id}/cancel:
    post:
      summary: Cancel purchase order
      description: |
        Cancel a purchase order. Can optionally include a cancellation reason.
      operationId: cancelPurchaseOrder
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Reason for cancellation
      responses:
        '200':
          description: Purchase order cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
                  message:
                    type: string

  /purchase-orders/{id}/attachments:
    get:
      summary: List purchase order attachments
      operationId: listPOAttachments
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Attachments list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PurchaseOrderAttachment'

    post:
      summary: Upload attachment to purchase order
      operationId: addPOAttachment
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - file_name
                - file_path
              properties:
                file_name:
                  type: string
                file_path:
                  type: string
                description:
                  type: string
      responses:
        '201':
          description: Attachment added
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrderAttachment'
                  message:
                    type: string

  /purchase-orders/{id}/attachments/{attachment_id}:
    delete:
      summary: Delete purchase order attachment
      operationId: deletePOAttachment
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: attachment_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Attachment deleted

  # Purchase Order Payments endpoints
  /purchase-orders/{id}/payments:
    get:
      summary: List payments for a purchase order
      operationId: listPOPayments
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: payment_method
          in: query
          schema:
            type: string
            enum: [check, wire, credit_card, ach, cash, other]
        - name: start_date
          in: query
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          schema:
            type: string
            format: date
      responses:
        '200':
          description: List of payments
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PurchaseOrderPayment'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Record a payment for a purchase order
      operationId: recordPOPayment
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PurchaseOrderPaymentCreate'
      responses:
        '201':
          description: Payment recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrderPayment'
                  message:
                    type: string
        '400':
          description: Invalid request
        '404':
          description: Purchase order not found

  /purchase-orders/{id}/payments/{payment_id}:
    delete:
      summary: Delete a payment record
      operationId: deletePOPayment
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: payment_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Payment deleted
        '404':
          description: Payment or purchase order not found

  /purchase-orders/{id}/sync-status:
    patch:
      summary: Update purchase order sync status
      operationId: updatePOSyncStatus
      tags: [Purchase Orders]
      security:
        - ApiKeyAuth: []
      description: |
        Update `sync_status` and optional `sync_message` for a purchase order.
        This endpoint is designed for accounting/ERP systems syncing POs.

        **Requires scope:** `purchase_orders:write`

        Cannot update sync status on cancelled POs.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sync_status]
              properties:
                sync_status:
                  type: string
                  enum: [unsynced, pending, synced, error]
                sync_message:
                  type: string
                  nullable: true
                sync_url:
                  type: string
                  format: uri
                  nullable: true
                  description: URL linking to the synced record in the external accounting system (e.g., QuickBooks bill page)
      responses:
        '200':
          description: Sync status updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PurchaseOrder'
        '400':
          description: Invalid request (e.g., PO is cancelled)
        '404':
          description: Purchase order not found

  # ==========================================
  # CREDIT CARDS ENDPOINTS
  # ==========================================
  /credit-cards:
    get:
      summary: List credit cards
      operationId: listCreditCards
      tags: [Credit Cards]
      security:
        - ApiKeyAuth: []
      description: |
        List all credit/payment cards configured for the company.
        
        **Requires scope:** `credit_cards:read`
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: is_active
          in: query
          schema:
            type: boolean
          description: Filter by active status
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: Filter records updated on or after this timestamp
        - name: ids
          in: query
          schema:
            type: string
          description: Comma-separated list of credit card UUIDs
      responses:
        '200':
          description: List of credit cards
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CreditCard'
                  total:
                    type: integer

    post:
      summary: Create credit card
      operationId: createCreditCard
      tags: [Credit Cards]
      security:
        - ApiKeyAuth: []
      description: |
        Create a new credit/payment card for the company.
        
        **Requires scope:** `credit_cards:write`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreditCardCreate'
      responses:
        '201':
          description: Credit card created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CreditCard'

  /credit-cards/{id}:
    get:
      summary: Get credit card by ID
      operationId: getCreditCard
      tags: [Credit Cards]
      security:
        - ApiKeyAuth: []
      description: |
        Get a specific credit card by ID.
        
        **Requires scope:** `credit_cards:read`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Credit card details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CreditCard'
        '404':
          description: Credit card not found

    patch:
      summary: Update credit card
      operationId: updateCreditCard
      tags: [Credit Cards]
      security:
        - ApiKeyAuth: []
      description: |
        Update an existing credit card.
        
        **Requires scope:** `credit_cards:write`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreditCardUpdate'
      responses:
        '200':
          description: Credit card updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CreditCard'
        '404':
          description: Credit card not found

    delete:
      summary: Delete credit card
      operationId: deleteCreditCard
      tags: [Credit Cards]
      security:
        - ApiKeyAuth: []
      description: |
        Delete a credit card. Cannot delete if the card is linked to existing purchase orders.
        
        **Requires scope:** `credit_cards:delete`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Credit card deleted
        '404':
          description: Credit card not found
        '409':
          description: Cannot delete - card is linked to purchase orders

  # ==========================================
  # INVOICES ENDPOINTS
  # ==========================================
  /invoices:
    get:
      summary: List invoices
      operationId: listInvoices
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, sent, viewed, partial, paid, void]
        - name: payment_status
          in: query
          schema:
            type: string
            enum: [unpaid, partially_paid, paid, overpaid]
        - name: invoice_type
          in: query
          schema:
            type: string
            enum: [standard, progress, final, credit, retainage]
        - name: customer_id
          in: query
          schema:
            type: string
            format: uuid
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
        - name: work_order_id
          in: query
          schema:
            type: string
            format: uuid
        - name: search
          in: query
          schema:
            type: string
          description: Search by invoice number
        - name: billing_mode
          in: query
          schema:
            type: string
            enum: [detailed, summary]
          description: Filter by billing mode.
        - name: sync_status
          in: query
          schema:
            type: string
            enum: [unsynced, pending, synced, error]
          description: Filter by sync status.
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            Filter records updated on or after this timestamp (ISO 8601 format).
            Useful for delta sync. Example: `2025-12-26T12:00:00Z`
        - name: ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of invoice UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: customer_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of customer UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: project_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of project UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
        - name: work_order_ids
          in: query
          schema:
            type: string
          description: |
            Comma-separated list of work order UUIDs (maximum 15).
            Example: `uuid1,uuid2,uuid3`
      responses:
        '200':
          description: List of invoices
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Invoice'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create invoice
      operationId: createInvoice
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      description: |
        Create a new invoice. Invoice number will be auto-generated if not provided.
        Line items are created along with the invoice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceCreate'
      responses:
        '201':
          description: Invoice created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'
                  message:
                    type: string
                  id:
                    type: string

  /invoices/{id}:
    get:
      summary: Get invoice by ID
      operationId: getInvoice
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Invoice details with customer, project, work_order, line_items, and payments
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'

    patch:
      summary: Update invoice (draft only)
      operationId: updateInvoice
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      description: |
        Update an invoice. Only invoices with status 'draft' can be updated.
        Sent or paid invoices cannot be modified.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceUpdate'
      responses:
        '200':
          description: Invoice updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'
                  message:
                    type: string
        '400':
          description: Invoice cannot be updated (not in draft status)

    delete:
      summary: Delete invoice (draft only)
      operationId: deleteInvoice
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      description: |
        Delete an invoice. Only invoices with status 'draft' can be deleted.
        Use void endpoint for sent/paid invoices.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Invoice deleted
        '400':
          description: Invoice cannot be deleted (not in draft status)

  /invoices/{id}/sync-status:
    patch:
      summary: Update invoice sync status
      operationId: updateInvoiceSyncStatus
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      description: |
        Update `sync_status` and optional `sync_message` for an invoice.
        This endpoint is designed for accounting/ERP systems syncing after an invoice is sent.

        **Requires scope:** `invoices:write`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sync_status]
              properties:
                sync_status:
                  type: string
                  enum: [unsynced, pending, synced, error]
                sync_message:
                  type: string
                  nullable: true
                sync_url:
                  type: string
                  format: uri
                  nullable: true
                  description: URL linking to the synced record in the external accounting system
      responses:
        '200':
          description: Sync status updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'

  /invoices/{id}/send:
    post:
      summary: Send invoice
      operationId: sendInvoice
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      description: |
        Send an invoice to the customer via email. Updates status to 'sent', records sent_at timestamp,
        and sends an email notification to the customer (or specified recipient).
        
        The email uses the company's configured invoice email template.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                recipient_email:
                  type: string
                  format: email
                  description: Optional email address override. If not provided, uses the customer's email.
      responses:
        '200':
          description: Invoice sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'
                  message:
                    type: string
        '400':
          description: Invoice cannot be sent (already voided)

  /invoices/{id}/void:
    post:
      summary: Void invoice
      operationId: voidInvoice
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      description: |
        Void an invoice. Requires a reason. Voided invoices cannot be modified or deleted.
        Draft invoices should be deleted instead of voided.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceVoidBody'
      responses:
        '200':
          description: Invoice voided
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'
                  message:
                    type: string
        '400':
          description: Invoice cannot be voided (already voided or is draft)

  /invoices/{id}/payments:
    get:
      summary: List invoice payments
      operationId: listInvoicePayments
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: List of payments for this invoice
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InvoicePayment'

    post:
      summary: Record payment
      operationId: recordInvoicePayment
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      description: |
        Record a payment against an invoice. Updates invoice payment_status and amount_paid.
        If total payments equal or exceed total_amount, invoice status becomes 'paid'.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoicePaymentCreate'
      responses:
        '201':
          description: Payment recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InvoicePayment'
                  message:
                    type: string
                  id:
                    type: string
        '400':
          description: Invalid payment (e.g., invoice is void)

  /invoices/{id}/payments/{payment_id}:
    delete:
      summary: Delete payment
      operationId: deleteInvoicePayment
      tags: [Invoices]
      security:
        - ApiKeyAuth: []
      description: |
        Delete a payment record from an invoice. Recalculates invoice payment_status.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: payment_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Payment deleted
        '404':
          description: Payment or invoice not found

  # Company Settings endpoints
  /company-settings:
    get:
      summary: Get company settings
      operationId: getCompanySettings
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Returns comprehensive company settings including business profile,
        operational settings, timezone, work hours, timesheet configuration,
        and read-only lists of roles and departments.
        
        **Required scope:** `settings:read`
      responses:
        '200':
          description: Company settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CompanySettings'
        '401':
          description: Unauthorized - Invalid or missing API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Forbidden - Insufficient permissions (missing settings:read scope)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update company settings
      operationId: updateCompanySettings
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Update company settings. Only provided fields will be updated.
        
        **Required scope:** `settings:write`
        
        **Note:** Changing `project_status_mode` from "simple" to "advanced" 
        (or vice versa) will automatically migrate existing projects to the 
        new status workflow using database triggers.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompanySettingsUpdate'
            examples:
              updateTimezone:
                summary: Update timezone
                value:
                  timezone: "America/New_York"
              updateWorkHours:
                summary: Update work hours
                value:
                  default_work_hours:
                    working_days: ["monday", "tuesday", "wednesday", "thursday", "friday"]
                    start_time: "08:00"
                    end_time: "17:00"
                    break_duration_minutes: 30
                  expected_daily_hours: 8
                  expected_weekly_hours: 40
              updateBusinessProfile:
                summary: Update business profile
                value:
                  name: "Acme Construction LLC"
                  industry: "commercial_construction"
                  address:
                    street: "123 Main St"
                    city: "Los Angeles"
                    state: "CA"
                    zip: "90001"
                    country: "USA"
                  contact_info:
                    email: "info@acme.com"
                    phone: "(555) 123-4567"
              switchToAdvancedMode:
                summary: Switch to advanced project status mode
                value:
                  project_status_mode: "advanced"
      responses:
        '200':
          description: Settings updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CompanySettings'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Unauthorized - Invalid or missing API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Forbidden - Insufficient permissions (missing settings:write scope)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # Work Types (Settings) endpoints
  /work-types:
    get:
      summary: List work types
      operationId: listWorkTypes
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: search
          in: query
          description: Search by name or short_code
          schema:
            type: string
        - name: is_active
          in: query
          schema:
            type: boolean
        - name: is_billable
          in: query
          schema:
            type: boolean
        - name: requires_work_order
          in: query
          schema:
            type: boolean
      responses:
        '200':
          description: List of work types
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WorkType'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create work type
      operationId: createWorkType
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WorkTypeCreate'
      responses:
        '201':
          description: Work type created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WorkType'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Conflict - work type with same name exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /work-types/{id}:
    get:
      summary: Get work type by ID
      operationId: getWorkType
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Work type details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WorkType'
        '404':
          description: Work type not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update work type
      operationId: updateWorkType
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WorkTypeUpdate'
      responses:
        '200':
          description: Work type updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WorkType'
        '404':
          description: Work type not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Conflict - work type with same name exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Delete work type
      operationId: deleteWorkType
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Work type deleted
        '404':
          description: Work type not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Conflict - work type is in use by timesheet entries
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # Finance Settings Endpoints
  /finance-settings:
    get:
      summary: Get finance settings
      operationId: getFinanceSettings
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Retrieve all finance-related settings for the company including
        tax rates, purchase order defaults, contract terms, invoice settings,
        cost codes, and branding configuration.
        
        **Requires scope:** `settings:read`
      responses:
        '200':
          description: Finance settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/FinanceSettings'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    patch:
      summary: Update finance settings
      operationId: updateFinanceSettings
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Update finance-related settings. All fields are optional - only provided
        fields will be updated. Cost codes are managed separately via dedicated endpoints.
        
        **Requires scope:** `settings:write`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FinanceSettingsUpdate'
            examples:
              updateTaxRates:
                summary: Update tax rates
                value:
                  tax_rates:
                    - name: "State Tax"
                      rate: 6.25
                    - name: "County Tax"
                      rate: 2
              updatePOSettings:
                summary: Update PO settings
                value:
                  purchase_orders:
                    require_approval_threshold: 5000
                    allow_field_employees_create: true
                    require_budget_on_quick_po: false
              updateBranding:
                summary: Toggle logo on invoices
                value:
                  branding:
                    show_logo_on_invoices: true
              updatePricing:
                summary: Set default product margin
                value:
                  pricing:
                    default_product_margin_pct: 25.0
              createTaxCode:
                summary: Create a new multi-component tax code
                value:
                  tax_codes:
                    - name: "AZ Sales Tax"
                      external_id: "QBO-AZ-SALES"
                      components:
                        - name: "State"
                          rate: 5.6
                          external_id: "AZ-STATE"
                        - name: "County"
                          rate: 0.7
                          external_id: "AZ-COUNTY"
                        - name: "City"
                          rate: 1.75
                          external_id: "AZ-CITY"
              updateTaxCode:
                summary: Update an existing tax code (components are replaced entirely)
                value:
                  tax_codes:
                    - id: "existing-tax-code-uuid"
                      name: "AZ Sales Tax (Updated)"
                      components:
                        - name: "State"
                          rate: 5.6
                        - name: "County"
                          rate: 0.8
              deactivateTaxCode:
                summary: Soft-delete a tax code
                value:
                  tax_codes:
                    - id: "existing-tax-code-uuid"
                      name: "Old Tax Code"
                      is_active: false
                      components:
                        - name: "Placeholder"
                          rate: 0
      responses:
        '200':
          description: Updated finance settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/FinanceSettings'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'


  # Custom Fields Endpoints
  /custom-fields:
    get:
      summary: List custom fields
      operationId: listCustomFields
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Retrieve all custom field definitions for the company.
        Custom fields can be used to add company-specific data to projects and work orders.
        
        **Requires scope:** `settings:read`
      parameters:
        - name: applies_to
          in: query
          description: Filter by entity type the field applies to
          schema:
            type: string
            enum: [project, work_order, both]
        - name: limit
          in: query
          description: Maximum number of items to return (default 20, max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          description: Number of items to skip for pagination
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: List of custom field definitions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CustomField'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create custom field
      operationId: createCustomField
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Create a new custom field definition. The field will be tagged with your 
        app's install ID and can only be updated/deleted by your app.
        
        **Visibility & Editability Controls:**
        - `is_visible: false` - Hide field from UI (for app-internal data)
        - `is_readonly: true` - Prevent manual edits (for app-computed values)
        
        **Requires scope:** `settings:write`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomFieldCreate'
            examples:
              textField:
                summary: Text field for external reference
                value:
                  field_name: "External ID"
                  field_type: "text"
                  applies_to: "project"
                  is_required: false
              dropdownField:
                summary: Dropdown for priority classification
                value:
                  field_name: "Priority Level"
                  field_type: "dropdown"
                  applies_to: "both"
                  dropdown_options: ["Low", "Medium", "High", "Critical"]
                  is_required: true
              hiddenAppData:
                summary: Hidden JSON field for app-specific data
                value:
                  field_name: "Sync Metadata"
                  field_type: "json"
                  applies_to: "project"
                  is_visible: false
                  is_readonly: true
              visibleReadonly:
                summary: Visible but app-managed field
                value:
                  field_name: "Estimated Cost"
                  field_type: "number"
                  applies_to: "work_order"
                  is_visible: true
                  is_readonly: true
      responses:
        '201':
          description: Custom field created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomField'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Conflict - custom field with same key already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /custom-fields/{id}:
    get:
      summary: Get custom field by ID
      operationId: getCustomField
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Retrieve a specific custom field definition by ID.
        
        **Requires scope:** `settings:read`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Custom field details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomField'
        '404':
          description: Custom field not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      summary: Update custom field
      operationId: updateCustomField
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Update an existing custom field definition. Apps can only update fields 
        they created (matching app_install_id).
        
        **Restrictions:**
        - Can only update fields created by your app
        - Cannot change `field_type` or `applies_to` (would break existing data)
        
        **Requires scope:** `settings:write`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomFieldUpdate'
            examples:
              updateName:
                summary: Update field name
                value:
                  field_name: "Updated Field Name"
              updateVisibility:
                summary: Change visibility settings
                value:
                  is_visible: true
                  is_readonly: false
              updateDropdown:
                summary: Update dropdown options
                value:
                  dropdown_options: ["Option A", "Option B", "Option C"]
      responses:
        '200':
          description: Custom field updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomField'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Forbidden - cannot update fields created by other apps or manually
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Custom field not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Delete custom field
      operationId: deleteCustomField
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Delete a custom field definition. Apps can only delete custom fields
        they created (matching app_install_id).
        
        **Requires scope:** `settings:delete`
        
        **Important:** You can only delete custom fields that your app created.
        Attempting to delete fields created manually or by other apps will return 403.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Custom field deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      deleted:
                        type: boolean
        '403':
          description: Forbidden - cannot delete fields created by other apps or manually
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Custom field not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ==========================================
  # SKILLS ENDPOINTS (Skills & Certifications Library)
  # ==========================================
  /skills:
    get:
      summary: List skills
      operationId: listSkills
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        List all skill definitions in the company's skill library.
        
        **Requires scope:** `skills:read` or `settings:read`
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: category
          in: query
          schema:
            type: string
            enum: [license, certification, training, equipment_operation, skill]
          description: Filter by skill category
        - name: is_active
          in: query
          schema:
            type: boolean
          description: Filter by active status
        - name: search
          in: query
          schema:
            type: string
          description: Search by skill name
      responses:
        '200':
          description: List of skills
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Skill'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create skill
      operationId: createSkill
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Create a new skill definition in the company's skill library.
        The skill will be tagged with your app's install ID.
        
        **Requires scope:** `skills:write` or `settings:write`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SkillCreate'
            examples:
              license:
                summary: License requiring expiry tracking
                value:
                  skill_name: "Class A CDL"
                  skill_category: "license"
                  description: "Commercial driver's license - Class A"
                  requires_certification_number: true
                  requires_expiry_date: true
                  requires_issuing_authority: true
              certification:
                summary: Professional certification
                value:
                  skill_name: "OSHA 10-Hour"
                  skill_category: "certification"
                  description: "OSHA safety training certification"
                  requires_certification_number: true
                  requires_issue_date: true
              skill:
                summary: Simple skill (no certification)
                value:
                  skill_name: "Electrical"
                  skill_category: "skill"
                  description: "General electrical knowledge"
      responses:
        '201':
          description: Skill created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Skill'
        '400':
          description: Validation error or duplicate skill name
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /skills/{id}:
    get:
      summary: Get skill by ID
      operationId: getSkill
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Get a specific skill definition by ID.
        
        **Requires scope:** `skills:read` or `settings:read`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Skill details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Skill'
        '404':
          description: Skill not found

    patch:
      summary: Update skill
      operationId: updateSkill
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Update an existing skill definition.
        
        **Restrictions:**
        - Can only update skills created by your app (matching `app_install_id`)
        - Cannot change `skill_category` (would break existing assignments)
        
        **Requires scope:** `skills:write` or `settings:write`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SkillUpdate'
            examples:
              deactivate:
                summary: Deactivate a skill
                value:
                  is_active: false
              updateRequirements:
                summary: Update certification requirements
                value:
                  requires_expiry_date: true
                  requires_issuing_authority: true
      responses:
        '200':
          description: Skill updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Skill'
        '400':
          description: Validation error or duplicate skill name
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Forbidden - cannot update skills created by other apps or manually
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Skill not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Delete skill
      operationId: deleteSkill
      tags: [Settings]
      security:
        - ApiKeyAuth: []
      description: |
        Delete a skill definition.
        
        **Restrictions:**
        - Can only delete skills created by your app (matching `app_install_id`)
        - Cannot delete if skill is assigned to any employees
        
        **Requires scope:** `skills:delete` or `settings:delete`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Skill deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      deleted:
                        type: boolean
        '400':
          description: Cannot delete - skill is assigned to employees
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Forbidden - cannot delete skills created by other apps or manually
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Skill not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # App Authentication Endpoints
  /apps-generate-session-token:
    post:
      summary: Generate signed JWT session token for embedded app
      operationId: generateSessionToken
      tags: [App Authentication]
      description: |
        Generates a signed JWT session token for authenticating users in embedded apps.
        The token contains user identity, roles, and company context.
        Tokens expire after 1 hour and should be refreshed before expiration.
        
        **Note:** This endpoint requires user authentication via Supabase Auth header,
        not an API key. It is called by the WorkSkedge frontend when loading embedded apps
        (iframe mode) and when processing redirect-mode authentication for standalone apps.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - install_id
              properties:
                install_id:
                  type: string
                  format: uuid
                  description: The app installation ID
      responses:
        '200':
          description: Token generated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmbedTokenResponse'
        '401':
          description: Unauthorized - user not authenticated
        '404':
          description: App install not found or access denied

  /apps-verify-session-token:
    post:
      summary: Verify session JWT token (optional)
      operationId: verifySessionToken
      tags: [App Authentication]
      description: |
        Verifies a JWT session token issued by `apps-generate-session-token`.
        
        **Self-Verification Recommended:** Developers can verify tokens themselves using the
        `session_signing_secret` provided in the `app.installed` webhook. This is faster and
        doesn't require a network call.
        
        This endpoint is provided as an alternative for developers who prefer API-based verification.
        
        **Note:** This endpoint requires API key authentication via the X-API-Key header.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - token
                - install_id
              properties:
                token:
                  type: string
                  description: The JWT token to verify
                install_id:
                  type: string
                  format: uuid
                  description: The app installation ID (must match token)
      responses:
        '200':
          description: Verification result (check `valid` field)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenVerificationResponse'

  # ==================== APP METADATA ENDPOINTS ====================
  /metadata/{entityType}/{entityId}:
    post:
      summary: Upsert app metadata on entity
      operationId: upsertMetadata
      tags: [App Metadata]
      security:
        - ApiKeyAuth: []
      description: |
        Attach or update metadata on a WorkSkedge entity. The metadata is automatically
        namespaced to your app (based on app name slug).
        
        **Requires scope:** `app:write`
      parameters:
        - name: entityType
          in: path
          required: true
          schema:
            type: string
            enum: [projects, work_orders, employees, customers, vendors, invoices, purchase_orders, schedules, equipment]
          description: Type of entity to attach metadata to (use plural form)
        - name: entityId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: ID of the entity
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MetadataPayload'
            examples:
              badge:
                summary: Status badge
                value:
                  display:
                    type: badge
                    label: "Synced"
                    intent: success
                  trigger:
                    type: modal
                    target: "/sync-details"
              warning:
                summary: Warning with context
                value:
                  display:
                    type: badge
                    label: "Action Required"
                    intent: warning
                    icon: "alert-triangle"
                  trigger:
                    type: navigate
                    target: "/resolve"
                  context:
                    issue_id: "abc123"
                    priority: "high"
      responses:
        '200':
          description: Metadata upserted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  entity_type:
                    type: string
                  entity_id:
                    type: string
                    format: uuid
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Forbidden - missing app:write scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Entity not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      summary: Remove app metadata from entity
      operationId: deleteMetadata
      tags: [App Metadata]
      security:
        - ApiKeyAuth: []
      description: |
        Remove your app's metadata from a WorkSkedge entity.
        
        **Requires scope:** `app:delete`
      parameters:
        - name: entityType
          in: path
          required: true
          schema:
            type: string
            enum: [projects, work_orders, employees, customers, vendors, invoices, purchase_orders, schedules, equipment]
        - name: entityId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Metadata removed successfully
        '403':
          description: Forbidden - missing app:delete scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Entity or metadata not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /metadata/batch:
    post:
      summary: Batch upsert metadata across multiple entities
      operationId: batchUpsertMetadata
      tags: [App Metadata]
      security:
        - ApiKeyAuth: []
      description: |
        Update metadata on multiple entities in a single request.
        Maximum 100 items per batch.
        
        **Requires scope:** `app:write`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MetadataBatch'
            examples:
              multipleEntities:
                summary: Update multiple work orders
                value:
                  items:
                    - entity_type: work_orders
                      entity_id: "uuid-1"
                      metadata:
                        display:
                          type: badge
                          label: "Synced"
                          intent: success
                    - entity_type: work_orders
                      entity_id: "uuid-2"
                      metadata:
                        display:
                          type: badge
                          label: "Pending"
                          intent: warning
      responses:
        '200':
          description: Batch processed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  processed:
                    type: integer
                    description: Number of items processed
                  failed:
                    type: integer
                    description: Number of items that failed
                  errors:
                    type: array
                    items:
                      type: object
                      properties:
                        entity_type:
                          type: string
                        entity_id:
                          type: string
                        error:
                          type: string
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Forbidden - missing metadata:write scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ==========================================================================
  # Product Endpoints
  # ==========================================================================

  /v1/products:
    get:
      tags: [Products]
      summary: List products
      description: Returns paginated list of products. Filterable by type, category, active status, and search.
      operationId: listProducts
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
        - name: type
          in: query
          schema: { type: string, enum: [material, equipment, labor, service] }
        - name: category_id
          in: query
          schema: { type: string, format: uuid }
        - name: is_active
          in: query
          schema: { type: boolean }
        - name: is_billable
          in: query
          schema: { type: boolean }
          description: Filter by billable status
        - name: search
          in: query
          schema: { type: string }
          description: Search by product name or SKU
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - name: ids
          in: query
          schema: { type: string }
          description: Comma-separated UUIDs
      responses:
        '200':
          description: Products list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
    post:
      tags: [Products]
      summary: Create or upsert product
      description: |
        Creates a new product. If a product with the same SKU already exists, it will be updated (upsert).
        Uses UoM sanitization (e.g., "Each" → "ea", "Hours" → "hr").
      operationId: createProduct
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductCreate'
      responses:
        '200':
          description: Product created/upserted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Product'

  /v1/products/{id}:
    get:
      tags: [Products]
      summary: Get product by ID
      operationId: getProduct
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Product details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Product'
    patch:
      tags: [Products]
      summary: Update product
      operationId: updateProduct
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductUpdate'
      responses:
        '200':
          description: Product updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Product'
    delete:
      tags: [Products]
      summary: Delete product
      description: |
        Deletes a product. If the product has usage records, it is soft-deleted (deactivated) instead.
      operationId: deleteProduct
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Product deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      deleted: { type: boolean }

  # ==========================================================================
  # Usage Record Endpoints
  # ==========================================================================

  /v1/usage-records:
    get:
      tags: [Usage Records]
      summary: List usage records for a work order
      description: Returns usage records for a specific work order. `work_order_id` is required.
      operationId: listUsageRecords
      parameters:
        - name: work_order_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
        - name: product_id
          in: query
          schema: { type: string }
          description: Filter by product ID (comma-separated UUIDs)
        - name: status
          in: query
          schema: { type: string, enum: [pending, invoiced, written-off] }
        - name: source
          in: query
          schema: { type: string, enum: [field_worker, api, office] }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
          description: Return only records with `updated_at >= updated_since`
        - name: ids
          in: query
          schema: { type: string }
          description: Comma-separated list of usage_record UUIDs to fetch
      responses:
        '200':
          description: Usage records list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/UsageRecord'
                  total:
                    type: integer
                    description: Total matching rows (ignores limit/offset)
    post:
      tags: [Usage Records]
      summary: Create usage record
      description: |
        Creates a usage record with automatic price snapshots. The `unit_cost_snapshot` and 
        `resale_price_snapshot` are calculated at creation time from the product's current pricing.
      operationId: createUsageRecord
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UsageRecordCreate'
      responses:
        '200':
          description: Usage record created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/UsageRecord'

  /v1/usage-records/{id}:
    get:
      tags: [Usage Records]
      summary: Get usage record by ID
      operationId: getUsageRecord
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Usage record details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/UsageRecord'
    patch:
      tags: [Usage Records]
      summary: Update usage record
      description: Only records with status 'pending' can be updated.
      operationId: updateUsageRecord
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UsageRecordUpdate'
      responses:
        '200':
          description: Usage record updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/UsageRecord'
    delete:
      tags: [Usage Records]
      summary: Delete usage record
      description: |
        Hard delete. Only records with `status='pending'` can be deleted; attempting to
        delete an `invoiced` or `written-off` record returns 400. Fires `usage_record.deleted`
        webhook with payload `{ id, deleted: true }`.
      operationId: deleteUsageRecord
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Usage record deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      deleted: { type: boolean }

  # ==========================================================================
  # Equipment Endpoints
  # ==========================================================================

  /v1/equipment:
    get:
      tags: [Equipment]
      summary: List equipment assets
      description: |
        List all equipment assets for the company. Supports filtering by status, tracking mode,
        product, and work order assignment.
        
        **Requires scope:** `equipment:read`
      operationId: listEquipmentAssets
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
        - name: status
          in: query
          schema: { type: string, enum: [active, locked] }
        - name: tracking_mode
          in: query
          schema: { type: string, minLength: 1, maxLength: 40, pattern: '^[a-zA-Z0-9 _-]+$' }
          description: Filter by meter unit (free-form, lowercased). Reserved value "none" filters assets without meter capture.
        - name: product_id
          in: query
          schema: { type: string }
          description: Filter by product ID (comma-separated UUIDs)
        - name: work_order_id
          in: query
          schema: { type: string, format: uuid }
          description: Filter assets assigned (currently or historically) to this work order
        - name: search
          in: query
          schema: { type: string }
          description: Search by name, asset_tag, or serial_number
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - name: ids
          in: query
          schema: { type: string }
          description: Comma-separated UUIDs (max 15)
      responses:
        '200':
          description: Equipment asset list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/EquipmentAsset'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
    post:
      tags: [Equipment]
      summary: Create equipment asset
      operationId: createEquipmentAsset
      description: |
        Create a new equipment asset. Optionally link to a product for auto-billing on check-in.
        
        **Requires scope:** `equipment:write`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EquipmentAssetCreate'
      responses:
        '200':
          description: Asset created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EquipmentAsset'

  /v1/equipment/available:
    get:
      tags: [Equipment]
      summary: List available equipment
      operationId: listAvailableEquipment
      description: |
        List equipment assets that are `active` and not currently checked out.
        
        **Requires scope:** `equipment:read`
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - name: product_id
          in: query
          schema: { type: string }
        - name: search
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Available assets
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/EquipmentAsset'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

  /v1/equipment/service-records:
    post:
      tags: [Equipment]
      summary: Submit service record
      operationId: submitEquipmentServiceRecord
      description: |
        Submit a service record for an equipment asset. If the result is `fail`, the asset is
        automatically locked.
        
        **Requires scope:** `equipment:write`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EquipmentServiceRecordCreate'
      responses:
        '200':
          description: Service record submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EquipmentServiceRecord'

  /v1/equipment/{id}:
    get:
      tags: [Equipment]
      summary: Get equipment asset by ID
      operationId: getEquipmentAsset
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Asset details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EquipmentAsset'
    patch:
      tags: [Equipment]
      summary: Update equipment asset
      operationId: updateEquipmentAsset
      description: |
        Update an asset. Changing `status` between `active` and `locked` triggers the
        `equipment.status_changed` webhook.
        
        **Requires scope:** `equipment:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EquipmentAssetUpdate'
      responses:
        '200':
          description: Asset updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EquipmentAsset'
    delete:
      tags: [Equipment]
      summary: Delete equipment asset
      operationId: deleteEquipmentAsset
      description: |
        **Requires scope:** `equipment:delete`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Asset deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      deleted: { type: boolean }

  /v1/equipment/{id}/checkout:
    post:
      tags: [Equipment]
      summary: Check out asset to work order
      operationId: checkoutEquipment
      description: |
        Assign an equipment asset to a work order. Asset must be `active` and not already checked out.
        
        **Requires scope:** `equipment:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EquipmentCheckoutCreate'
      responses:
        '200':
          description: Checkout created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EquipmentCheckout'

  /v1/equipment/{id}/checkin:
    post:
      tags: [Equipment]
      summary: Check in asset
      operationId: checkinEquipment
      description: |
        Check in an asset from its current checkout. If `auto_bill: true` and the asset has a
        linked product, a usage record is automatically created.
        
        **Requires scope:** `equipment:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EquipmentCheckinRequest'
      responses:
        '200':
          description: Asset checked in
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EquipmentCheckout'

  /v1/equipment/{id}/reading:
    post:
      tags: [Equipment]
      summary: Post odometer/hours reading
      operationId: postEquipmentReading
      description: |
        Update the current odometer or hours reading for a tracked asset.
        
        **Requires scope:** `equipment:write`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reading]
              properties:
                reading: { type: number, minimum: 0 }
                reading_at: { type: string, format: date-time }
      responses:
        '200':
          description: Reading updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EquipmentAsset'

  /v1/equipment/{id}/service-records:
    get:
      tags: [Equipment]
      summary: List service records for asset
      operationId: listEquipmentServiceRecords
      description: |
        **Requires scope:** `equipment:read`
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, default: 20 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - name: record_type
          in: query
          schema: { type: string, enum: [safety, maintenance, repair] }
        - name: result
          in: query
          schema: { type: string, enum: [pass, fail, needs_repair] }
      responses:
        '200':
          description: Service record list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/EquipmentServiceRecord'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

  # ==========================================================================
  # Vendor Credits
  # ==========================================================================

  /v1/vendor-credits:
    get:
      tags: [Vendor Credits]
      summary: List vendor credits
      operationId: listVendorCredits
      description: |
        Returns a paginated list of vendor credits for the authenticated company.
        
        **Requires scope:** `vendor_credits:read`
      parameters:
        - name: vendor_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by vendor ID
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by project ID
        - name: work_order_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by work order ID
        - name: purchase_order_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by purchase order ID
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, open, applied, void]
          description: Filter by status
        - name: sync_status
          in: query
          schema:
            type: string
            enum: [unsynced, pending, synced, error]
          description: Filter by sync status
        - name: updated_since
          in: query
          schema:
            type: string
            format: date-time
          description: Delta sync filter
        - name: search
          in: query
          schema:
            type: string
          description: Search credit number, reference, or memo
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Vendor credit list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/VendorCredit'
                  total:
                    type: integer
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
    post:
      tags: [Vendor Credits]
      summary: Create vendor credit
      operationId: createVendorCredit
      description: |
        Create a new vendor credit. At least one line item is required.
        
        **Requires scope:** `vendor_credits:write`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VendorCreditCreate'
      responses:
        '201':
          description: Vendor credit created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VendorCredit'

  /v1/vendor-credits/{id}:
    get:
      tags: [Vendor Credits]
      summary: Get vendor credit
      operationId: getVendorCredit
      description: |
        Returns a single vendor credit with line items and taxes.
        
        **Requires scope:** `vendor_credits:read`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Vendor credit detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/VendorCredit'
    patch:
      tags: [Vendor Credits]
      summary: Update vendor credit
      operationId: updateVendorCredit
      description: |
        Update a draft or open vendor credit. Applied and void credits cannot be updated.
        
        **Requires scope:** `vendor_credits:write`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VendorCreditUpdate'
      responses:
        '200':
          description: Vendor credit updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VendorCredit'
    delete:
      tags: [Vendor Credits]
      summary: Delete vendor credit
      operationId: deleteVendorCredit
      description: |
        Delete a vendor credit. Only draft credits can be deleted.
        
        **Requires scope:** `vendor_credits:delete`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Vendor credit deleted

  /v1/vendor-credits/{id}/sync-status:
    patch:
      tags: [Vendor Credits]
      summary: Update vendor credit sync status
      operationId: updateVendorCreditSyncStatus
      description: |
        Update `sync_status` and optional `sync_message` for a vendor credit.
        This endpoint is designed for accounting/ERP systems syncing vendor credits.

        No webhook is dispatched on sync status updates to avoid echo loops.

        `PUT` is also accepted as a legacy alias for backward compatibility.

        **Requires scope:** `vendor_credits:write`
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sync_status]
              properties:
                sync_status:
                  type: string
                  enum: [unsynced, pending, synced, error]
                sync_message:
                  type: string
                  nullable: true
                sync_url:
                  type: string
                  format: uri
                  nullable: true
      responses:
        '200':
          description: Sync status updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VendorCredit'
