openapi: 3.0.3
info:
  title: Keito API v2
  description: |
    Harvest-compatible REST API for time tracking, expenses, projects, invoicing, and reporting.

    ## Authentication

    Two methods are supported:

    1. **API Key** — `Authorization: Bearer kto_...` + `Keito-Account-Id: <company_cuid>` header
    2. **WorkOS JWT** — `Authorization: Bearer <jwt>` (company resolved from token claims)

    ## Billable-rate visibility

    Billable-rate properties are optional and are omitted unless the caller is an
    owner/admin, a manager with `canViewBillableRates`, or (for a specific project)
    an active assignee with `canViewProjectFinancials`. Project-scoped viewers only
    receive rates for their permitted projects. Project-manager status alone does
    not grant rate visibility. Internal `cost_rate` values are never serialized by
    these tracking/read endpoints.

    `GET /api/v2/tasks?project_id=...` returns the authenticated user's current
    `effective_billable_rate` for each project/task option when authorized. Historical
    time entries return their stored `billable_rate`; it is never replaced with the
    current configured rate.

    All responses include `X-Keito-API-Version: 2`.

    ## Team role compatibility migration

    The administrator workspace directory follows Harvest's separation between
    descriptive reporting roles and permission roles. In `GET /api/v2/users`,
    `roles` now contains active custom reporting-role names and `access_roles`
    contains Keito permission roles. Existing consumers must migrate permission checks from `roles` to `access_roles`.
    Workspace-scoped capacity is returned as `weekly_capacity` in seconds.

    Personal read-only sync keys keep their intentionally reduced user shape:
    they do not receive email, capacity, custom reporting roles, or teammate
    relationships. Their existing `roles` property remains the reduced
    permission-role representation for backward compatibility.

    ## Scoped API keys

    New API keys can be created with full account access or the least-privilege
    ClickUp mapping automation (`clickup:mappings:read` and `clickup:mappings:write`) or the
    cost-rate automation scope set (`users:read`, `cost_rates:read`, and
    `cost_rates:write`). Legacy keys and configurations without versioned scope
    enforcement retain their existing full-access behavior. Cost-rate routes
    additionally require the authenticated key owner to be a workspace owner or
    administrator.
  version: "2.0"
  contact:
    name: Keito Support

servers:
  - url: http://localhost:3000
    description: Local development
  - url: https://app.keito.ai
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Time Entries
  - name: External References
  - name: Timesheet Approvals
  - name: Expenses
  - name: Projects
  - name: Project Assignments
  - name: Clients
  - name: Contacts
  - name: ClickUp
  - name: Tasks
  - name: Users
  - name: Roles
  - name: Invoices
  - name: Estimates
  - name: Retainers
  - name: Invoice Messages
  - name: Reports
  - name: Planning
  - name: Webhooks
  - name: Backup

paths:
  /api/v2/clickup/locations:
    get:
      tags: [ClickUp]
      summary: Discover locations through the authenticated user's ClickUp grant
      operationId: listClickUpLocations
      description: |
        Requires clickup:mappings:read, active internal membership and either
        administrator access or explicit canManageClickUpMappings delegation.
        Discovery uses the caller's own connection to the selected Workspace.
        With no parameters returns Spaces with Time Tracking enabled. space_id
        adds Folders and folderless Lists; folder_id selects Lists in that Folder.
        Repeated or unknown query parameters are rejected.
      parameters:
        - { name: space_id, in: query, schema: { type: string, minLength: 1, maxLength: 100 } }
        - { name: folder_id, in: query, description: Requires space_id, schema: { type: string, minLength: 1, maxLength: 100 } }
      responses:
        "200": { description: "Object containing spaces, folders and lists arrays" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /api/v2/clickup/mappings:
    get:
      tags: [ClickUp]
      summary: List mappings within the authenticated user's permitted projects
      operationId: listClickUpMappings
      description: Requires clickup:mappings:read and current mapping permission. Includes disabled mappings to retain historical identities.
      responses:
        "200": { description: Object containing a mappings array; each mapping includes its version and minimal project/task labels }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [ClickUp]
      summary: Idempotently create or replace a versioned mapping
      operationId: upsertClickUpMapping
      description: |
        Requires clickup:mappings:write and current mapping permission. Managers
        must manage both the previous and new project. A required active Keito
        task must belong to or be assigned to the project. Provider hierarchy is
        verified through the caller's own ClickUp connection; labels are fetched
        from ClickUp. Manual mappings win over automatic mappings, with List >
        Folder > Space precedence. Disabled/archived overrides prevent fallback.
        expectedVersion=0 creates a mapping; replacing requires its current
        version. A stale version returns 409. Reuse the exact Idempotency-Key and
        body to recover an uncertain result. Fresh grant/target authorization is
        checked before a stored response can replay. No projects are created.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [locationType, clickUpSpaceId, projectId, taskId, expectedVersion]
              properties:
                locationType: { type: string, enum: [SPACE, FOLDER, LIST] }
                clickUpSpaceId: { type: string, minLength: 1, maxLength: 100 }
                clickUpFolderId: { type: string, nullable: true, maxLength: 100, description: Required for FOLDER }
                clickUpListId: { type: string, nullable: true, maxLength: 100, description: Required for LIST }
                clickUpSpaceName: { type: string, nullable: true, maxLength: 500 }
                clickUpFolderName: { type: string, nullable: true, maxLength: 500 }
                clickUpListName: { type: string, nullable: true, maxLength: 500 }
                projectId: { type: string, description: Keito project CUID }
                taskId: { type: string, description: Required Keito task CUID }
                billableOverride: { type: boolean, nullable: true, description: Null inherits Keito policy }
                expectedVersion: { type: integer, minimum: 0 }
      responses:
        "200": { description: Saved mapping with incremented version }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  # ─── TIME ENTRIES ──────────────────────────────────────────────
  /api/v2/time_entries:
    get:
      tags: [Time Entries]
      summary: List time entries
      operationId: listTimeEntries
      description: Stored `billable_rate` is included only when authorized for the entry's project. Internal cost rates are never returned. Personal read-only sync responses include a minimal `task_assignment` (`id`, `billable`, `is_active`) but omit its rates, budgets, and totals.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: user_id
          in: query
          schema: { type: string }
          description: Filter by user CUID
        - name: client_id
          in: query
          schema: { type: string }
        - name: project_id
          in: query
          schema: { type: string }
        - name: task_id
          in: query
          schema: { type: string }
        - name: is_billed
          in: query
          schema: { type: boolean }
        - name: invoice_id
          in: query
          schema: { type: string, minLength: 1, maxLength: 191 }
          description: Filter by a Keito invoice CUID linked directly or through a grouped invoice line. Combined with other filters before pagination. Blank, conflicting repeated, or internally whitespace-containing values return 400. Unknown invoices or invoices outside the current workspace return an empty list. Not available to personal read-only sync keys.
        - name: is_running
          in: query
          schema: { type: boolean }
        - name: approval_status
          in: query
          schema: { type: string, enum: [unsubmitted, submitted, approved, rejected] }
          description: Filter by Keito approval state. Not available to personal read-only sync keys.
        - name: from
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD)
        - name: to
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD)
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - name: source
          in: query
          schema: { type: string, enum: [web, cli, api, agent, calendar, desktop, mobile, integration, extension] }
          description: Filter by entry source. Comma-separated for multiple (e.g. `agent,cli`).
        - name: external_reference_id
          in: query
          schema: { type: string }
          description: External object identifier. Must be used with `external_reference_service`.
        - name: external_reference_service
          in: query
          schema: { type: string }
          description: External service identifier such as `zendesk.com`. Must be used with `external_reference_id`.
        - name: external_reference_group_id
          in: query
          schema: { type: string }
          description: Optional external group or scope identifier.
        - name: external_reference_account_id
          in: query
          schema: { type: string }
          description: Optional external account identifier.
      responses:
        "200":
          description: Paginated list of time entries
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      time_entries:
                        type: array
                        items:
                          $ref: "#/components/schemas/TimeEntry"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

    post:
      tags: [Time Entries]
      summary: Create a time entry
      operationId: createTimeEntry
      description: |
        Supports `Idempotency-Key` for safe retries: the same key with the same body
        replays the stored response (`Idempotency-Replayed: true`) for 24 hours;
        the same key with a different body or under different permissions is a 409.
        AI app connections (MCP connector tokens) may not create `source: calendar` entries.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TimeEntryCreate"
      responses:
        "200":
          description: Created time entry
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TimeEntry"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyDomainOrWorkspaceConflict" }

  /api/v2/time_entries/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Time entry CUID

    patch:
      tags: [Time Entries]
      summary: Update a time entry
      operationId: updateTimeEntry
      description: |
        Update fields on a time entry. `source` is immutable after creation.
        Metadata uses merge semantics — new keys are merged into existing metadata.
        Set metadata to `null` to clear it. Supports `Idempotency-Key` for safe retries.
        Calendar-derived entries are not found for AI app connections.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TimeEntryUpdate"
      responses:
        "200":
          description: Updated time entry
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TimeEntry"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyDomainOrWorkspaceConflict" }

    delete:
      tags: [Time Entries]
      summary: Delete a time entry
      operationId: deleteTimeEntry
      description: Cannot delete locked or approved entries. Supports `Idempotency-Key`. Not available to AI app connections.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      responses:
        "204":
          description: Time entry deleted
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }

  /api/v2/time_entries/{id}/stop:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Time entry CUID

    patch:
      tags: [Time Entries]
      summary: Stop a running timer
      operationId: stopTimeEntryTimer
      description: |
        Stops a running timer using server-side elapsed-time calculation. Empty request bodies are accepted.
        Supports `Idempotency-Key`: a replay returns the stored response for this entry and never
        stops a different timer.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes: { type: string, description: Optional notes replacement when the entry is editable. }
                stopped_at: { type: string, format: date-time, description: Optional explicit stop instant for offline replay. }
      responses:
        "200":
          description: Stopped time entry
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TimeEntry"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyDomainOrWorkspaceConflict" }

  /api/v2/time_entries/{id}/restart:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Time entry CUID

    patch:
      tags: [Time Entries]
      summary: Restart a stopped timer
      operationId: restartTimeEntryTimer
      description: |
        Restarts the same time-entry row as a running timer. Pass `replace_running:true` to stop any
        currently running timer first. Supports `Idempotency-Key`. Not available to AI app connections.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                replace_running: { type: boolean, default: false }
      responses:
        "200":
          description: Restarted running time entry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TimeEntry"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            Conflict, including `running_entry` when another timer is running, or an
            `Idempotency-Key` conflict (`key_reused`, `authorization_changed`, `in_progress`,
            `idempotency_unresolved`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RunningTimerConflict"

  /api/v2/time_entries/{id}/adjust_running_timer:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Running time entry CUID

    patch:
      tags: [Time Entries]
      summary: Remove a reviewed inactive period from a running timer
      operationId: adjustRunningTimeEntryTimer
      description: |
        Owner-only atomic correction used after a desktop inactivity review. The
        stop action ends the entry at `inactive_started_at`. The continue action
        also creates a separate running entry at `resumed_at`, leaving an exact
        gap. Keito does not store raw device-activity telemetry.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  additionalProperties: false
                  required: [action, inactive_started_at, expected_timer_started_at]
                  properties:
                    action: { type: string, enum: [stop_and_remove] }
                    inactive_started_at: { type: string, format: date-time }
                    expected_timer_started_at: { type: string, format: date-time }
                - type: object
                  additionalProperties: false
                  required: [action, inactive_started_at, resumed_at, expected_timer_started_at]
                  properties:
                    action: { type: string, enum: [continue_and_remove] }
                    inactive_started_at: { type: string, format: date-time }
                    resumed_at: { type: string, format: date-time }
                    expected_timer_started_at: { type: string, format: date-time }
      responses:
        "200":
          description: Adjusted stopped entry and optional continued running entry
          content:
            application/json:
              schema:
                type: object
                required: [time_entry, running_time_entry]
                properties:
                  time_entry: { $ref: "#/components/schemas/TimeEntry" }
                  running_time_entry:
                    allOf:
                      - $ref: "#/components/schemas/TimeEntry"
                    nullable: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }

  /api/v2/time_entries/{id}/external_reference:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Time entry CUID

    delete:
      tags: [Time Entries]
      summary: Remove a time entry external reference
      operationId: removeTimeEntryExternalReference
      description: Removes all external-reference fields. Locked entries cannot be changed.
      responses:
        "204":
          description: External reference removed
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/approvals/submit:
    post:
      tags: [Timesheet Approvals]
      summary: Submit your own time for approval
      operationId: submitTimesheet
      description: |
        Submits the authenticated person's completed, unsubmitted and unbilled time entries in
        the period, exactly like "Submit for approval" on the Day and Week views, and notifies
        their approvers. There is no `user_id`: submitting on behalf of a teammate is not
        available through the API. Without `fingerprint` the current state is submitted; with
        one, the request fails with `submission_stale` if the period changed since it was taken.
        A running timer in the period returns `running_timer`. Requires the Timesheet approvals
        feature (Pro or Business) switched on for the workspace. Personal read-only sync keys
        cannot call this. Supports `Idempotency-Key`; a 409 releases the key.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApprovalSubmitRequest" }
      responses:
        "200":
          description: Submitted (a period with nothing to submit returns `submitted_count` 0)
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApprovalSubmitResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: "`running_timer`, `submission_stale` or `submission_conflict`, or an `Idempotency-Key` conflict."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/approvals/approve:
    post:
      tags: [Timesheet Approvals]
      summary: Approve a person's submitted time
      operationId: approveTimesheet
      description: |
        Approves every submitted time entry and expense for one person in the period (optionally
        one project), exactly like Approve on Time > Approval. Approved time is locked and its
        week refuses new or changed time (`approved_timesheet_week_locked`) until an approver
        withdraws the approval in Keito.

        Who may approve matches the app: owners and administrators approve anyone in the
        workspace; managers approve the teammates assigned to them and, only with the
        "approve time on projects they manage" permission, time on the projects they manage.
        Members cannot approve. A person outside the caller's reach returns 403; a person or
        project from another workspace returns 404.

        Approval runs the same preflight review as the app. Blockers (for example a running
        timer or unsubmitted time in the period) return `approval_blocked` and cannot be
        acknowledged. Warnings (hours below expected, time outside working capacity,
        overlapping entries) return `approval_warnings_unacknowledged` with the current
        `preflight`; retry with its `fingerprint` as `preflight_fingerprint` and every warning
        code in `acknowledged_warning_codes`. A workspace that sets Hours below expected to
        "Don't warn" in Settings never returns `HOURS_BELOW_EXPECTED`; still listing that code in
        `acknowledged_warning_codes` is accepted. A fingerprint that no longer matches returns
        `approval_preflight_stale` with the fresh preflight. A change made while the approval
        is saved returns `approval_conflict`. Supports `Idempotency-Key`; a 409 releases the key.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApprovalApproveRequest" }
      responses:
        "200":
          description: Approved (nothing submitted in scope returns zero counts)
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApprovalApproveResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Preflight review required or a concurrent change, or an `Idempotency-Key` conflict.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApprovalConflict" }

  /api/v2/approvals/reject:
    post:
      tags: [Timesheet Approvals]
      summary: Reject a person's submitted time
      operationId: rejectTimesheet
      description: |
        Returns every submitted time entry and expense for one person in the period (optionally
        one project) to unsubmitted, exactly like Reject on Time > Approval. The person is
        notified with the optional `reason`. The same approver rules as
        `POST /api/v2/approvals/approve` apply. Supports `Idempotency-Key`.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApprovalRejectRequest" }
      responses:
        "200":
          description: Rejected (nothing submitted in scope returns zero counts)
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApprovalActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /api/v2/external_references/summary:
    get:
      tags: [External References]
      summary: Summarize time for an external object
      operationId: summarizeExternalReference
      description: Returns company-wide aggregates without exposing individual time entries or rates. `last_used` is omitted when the caller cannot track time to that project.
      parameters:
        - name: service
          in: query
          required: true
          schema: { type: string }
          example: zendesk.com
        - name: id
          in: query
          required: true
          schema: { type: string }
          example: "12345"
        - name: group_id
          in: query
          schema: { type: string }
        - name: account_id
          in: query
          schema: { type: string }
      responses:
        "200":
          description: External-reference time summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExternalReferenceSummary"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ─── EXPENSES ──────────────────────────────────────────────────
  /api/v2/expenses:
    get:
      tags: [Expenses]
      summary: List expenses
      operationId: listExpenses
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: user_id
          in: query
          description: |
            Optional expense owner filter. Use `me` for the authenticated API-key
            user. When omitted, owners and administrators receive expenses for the
            whole workspace; other members receive only their own expenses.
          schema: { type: string }
        - name: client_id
          in: query
          schema: { type: string }
        - name: project_id
          in: query
          schema: { type: string }
        - name: is_billed
          in: query
          schema: { type: boolean }
        - name: invoice_id
          in: query
          schema: { type: string, minLength: 1, maxLength: 191 }
          description: Filter by a Keito invoice CUID linked directly or through a grouped invoice line. Combined with other filters before pagination. Blank, conflicting repeated, or internally whitespace-containing values return 400. Unknown invoices or invoices outside the current workspace return an empty list.
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - name: source
          in: query
          schema: { type: string, enum: [web, cli, api, agent] }
      responses:
        "200":
          description: Paginated list of expenses
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      expenses:
                        type: array
                        items:
                          $ref: "#/components/schemas/Expense"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

    post:
      tags: [Expenses]
      summary: Create an expense
      operationId: createExpense
      description: |
        Records bookkeeping data only; no payment is executed. Supports `Idempotency-Key`
        for safe retries. Every expense carries the `currency` its `total_cost` is denominated in.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ExpenseCreate"
      responses:
        "200":
          description: Created expense
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Expense"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyDomainOrWorkspaceConflict" }

  /api/v2/expenses/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Expense CUID

    get:
      tags: [Expenses]
      summary: Get an expense
      operationId: getExpense
      responses:
        "200":
          description: Expense
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Expense"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

    patch:
      tags: [Expenses]
      summary: Update an expense
      operationId: updateExpense
      description: |
        Approved, billed or locked expenses cannot be changed. Supports `Idempotency-Key` for
        safe retries. Not available to AI app connections.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ExpenseUpdate"
      responses:
        "200":
          description: Updated expense
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Expense"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

    delete:
      tags: [Expenses]
      summary: Delete an expense
      operationId: deleteExpense
      description: |
        Approved, billed or locked expenses cannot be deleted. Supports `Idempotency-Key`.
        Not available to AI app connections.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      responses:
        "200":
          description: Expense deleted
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, enum: [true] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /api/v2/expense_categories:
    get:
      tags: [Expenses]
      summary: List expense categories
      operationId: listExpenseCategories
      description: Categories the workspace records expenses against. Requires the expenses feature.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: is_active
          in: query
          schema: { type: boolean }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated expense categories
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      expense_categories:
                        type: array
                        items:
                          $ref: "#/components/schemas/ExpenseCategory"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

  # ─── PROJECTS ──────────────────────────────────────────────────
  /api/v2/projects:
    get:
      tags: [Projects]
      summary: List projects
      operationId: listProjects
      description: Owners/admins see all workspace projects. Other internal users see only actively assigned projects. Rates, fees, budgets, and internal costs are omitted unless the bound user has the matching UX permission.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: is_active
          in: query
          schema: { type: boolean, default: true }
        - name: client_id
          in: query
          schema: { type: string }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - name: owner_id
          in: query
          schema: { type: string }
          description: Eligible workspace member ID, or `unassigned`.
      responses:
        "200":
          description: Paginated list of projects
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      projects:
                        type: array
                        items:
                          $ref: "#/components/schemas/Project"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

    post:
      tags: [Projects]
      summary: Create a project
      operationId: createProject
      description: |
        Requires owner/admin access or manager access with canEditProjects permission.

        Pass `template_id` (from `GET /api/v2/project_templates`) to create the project from a
        saved project template. The template supplies billing, budget and visibility settings,
        tasks and people, so the request may only add `name`, `client_id`, `code`, `notes`,
        `owner_id`, `starts_on` and `ends_on`; any billing, budget or task field alongside
        `template_id` is rejected with 400. `client_id` is optional when the template is tied to a
        client and overrides that client when supplied. Shared tasks or people that are no longer
        active in the workspace are skipped; when no task survives, default tasks are assigned.

        Workspace task default rates are stored in the workspace currency. When the client bills
        in another currency, each assigned task's default rate is converted into the client currency
        at the current exchange rate before it is stored, exactly as the web project form does; when
        no rate is known the workspace figure is kept. Servers that do this advertise
        `project-task-rate-currency-conversion` in `X-Keito-Features`.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProjectCreate"
            examples:
              defaultTasks:
                value:
                  client_id: cli_abc123
                  name: Agent Implementation
                  code: AGENT
              explicitTasks:
                value:
                  client_id: cli_abc123
                  name: Agent Implementation
                  code: AGENT
                  task_ids: [tsk_development]
              fromTemplate:
                value:
                  template_id: ptpl_onboarding
                  client_id: cli_abc123
                  name: Agent Implementation
      responses:
        "201":
          description: Created project
          headers:
            Location: { schema: { type: string } }
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Project"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "413": { description: Request body exceeds 131072 bytes. }
        "415": { description: Content-Type must be JSON and Content-Encoding must be identity. }

  /api/v2/project_templates:
    get:
      tags: [Projects]
      summary: List project templates
      operationId: listProjectTemplates
      description: |
        Saved project templates for the workspace, for picking a `template_id` to pass to
        `POST /api/v2/projects`. Requires owner/admin access or manager access with
        canEditProjects permission. Rates, fees and budgets are never returned; the template
        applies them when a project is created.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Project templates
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      project_templates:
                        type: array
                        items:
                          $ref: "#/components/schemas/ProjectTemplate"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/projects/archive_review:
    get:
      tags: [Projects]
      summary: Review active projects that may be ready to archive
      operationId: reviewProjectsForArchive
      description: >-
        Lists active projects with no tracked time in the requested inactivity window.
        Owners and admins see the workspace; managers with Edit projects see only
        projects they actively manage. The result is paginated and includes warning
        reasons for uninvoiced work, running timers, retainers, recurring invoices
        and future schedules. Review does not change projects.
      parameters:
        - name: inactive_days
          in: query
          schema: { type: integer, minimum: 1, maximum: 3650, default: 365 }
        - name: started_before
          in: query
          schema: { type: string, format: date }
        - name: imported_only
          in: query
          schema: { type: boolean, default: false }
        - name: client_id
          in: query
          schema: { type: string }
        - name: search
          in: query
          schema: { type: string, maxLength: 255 }
        - name: readiness
          in: query
          schema: { type: string, enum: [all, ready, needs_review], default: all }
        - $ref: "#/components/parameters/page"
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200": { description: "Paginated archive candidates, warning reasons, review timestamp and total counts." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/projects/archive_review/preflight:
    post:
      tags: [Projects]
      summary: Recheck selected projects before archive
      operationId: preflightProjectArchive
      description: >-
        Rechecks up to 100 exact project IDs against the review criteria and
        timestamp. Returns per-project status, warning reasons and a signed token
        only if every project is eligible. The token is bound to the user,
        workspace, IDs and warning reasons and expires within 30 minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_ids, criteria, reviewed_at]
              properties:
                project_ids: { type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: { type: string } }
                criteria:
                  type: object
                  required: [inactive_days, imported_only]
                  properties:
                    inactive_days: { type: integer, minimum: 1, maximum: 3650 }
                    started_before: { type: string, format: date }
                    imported_only: { type: boolean }
                reviewed_at: { type: string, format: date-time }
      responses:
        "200": { description: "Exact selection statuses, warnings and optional review token." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "412": { description: Review timestamp is stale or in the future. }

  /api/v2/projects/archive_review/archive:
    post:
      tags: [Projects]
      summary: Archive explicitly confirmed projects
      operationId: archiveReviewedProjects
      description: >-
        Archives only IDs matching the signed preflight token, after explicit
        confirmation and warning acknowledgement. Rechecks tenant, manager scope,
        activity and warnings before changing each project. Reports per-ID
        outcomes; preserves project history and may stop running timers.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [review_token, confirm_project_ids, confirm_archive]
              properties:
                review_token: { type: string }
                confirm_project_ids: { type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: { type: string } }
                confirm_archive: { type: boolean, enum: [true] }
                acknowledge_warnings: { type: boolean, default: false }
      responses:
        "200": { description: "Archived, blocked and failed counts with per-project outcomes." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "412": { description: "Invalid or changed review, selection or warning state." }
        "428": { description: Warning acknowledgement is required. }

  /api/v2/projects/{projectId}:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Projects]
      summary: Get a visible project
      operationId: getProject
      description: Uses the same assignment visibility and financial-field redaction as the Keito project page. Invisible and cross-workspace IDs return 404.
      responses:
        "200":
          description: Project details
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Projects]
      summary: Update, archive, or restore a project
      operationId: updateProject
      description: Owners/admins may update any project. Managers require canEditProjects and current project-manager status. Commercial fields also require billable-rate permission. Pass is_active to archive or restore.
      parameters:
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectUpdate" }
      responses:
        "200":
          description: Updated project
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "412": { description: If-Match does not match the current project version. }
        "413": { description: Request body exceeds 131072 bytes. }
        "415": { description: Content-Type must be JSON and Content-Encoding must be identity. }
    delete:
      tags: [Projects]
      summary: Permanently delete an archived project
      operationId: deleteProject
      description: Owner/admin only. The project must already be archived and If-Match must contain its current ETag. Historical invoice lines are preserved.
      parameters:
        - name: If-Match
          in: header
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      responses:
        "200": { description: Project deleted and invoice history preserved. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "412": { description: If-Match does not match the current project version. }
        "428": { description: If-Match is required. }

  # ─── PROJECT ASSIGNMENTS ───────────────────────────────────────
  /api/v2/projects/{projectId}/user_assignments:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Project Assignments]
      summary: List the people assigned to a project
      operationId: listProjectUserAssignments
      description: |
        Harvest-compatible project team list. Readable by owners/admins and by people
        assigned to the project; invisible and cross-workspace projects return 404.
        `use_default_rates`, `hourly_rate` and `budget` are returned only to callers
        with billable-rate visibility (owners/admins, or managers with the
        billable-rates permission) and are omitted otherwise. Email addresses and
        cached totals are never returned. Full-access credentials only.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: is_active
          in: query
          schema: { type: boolean }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated project team
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      user_assignments:
                        type: array
                        items: { $ref: "#/components/schemas/ProjectUserAssignment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Project Assignments]
      summary: Add a person to a project
      operationId: createProjectUserAssignment
      description: |
        Uses the same rules as adding a person in the project editor. Requires an
        owner/admin, or a manager with project-edit permission who currently manages
        the project. Managers may add ordinary people only: project-manager status,
        financial visibility, turning off time tracking and person budgets are
        administrator-only, and client members can only be added by administrators.
        `budget` also requires billable-rate visibility. Adding a previously removed
        person restores their assignment; an active assignment returns 409. Project
        rates are managed in the project editor and are not writable here.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectUserAssignmentCreate" }
      responses:
        "201":
          description: Person added or restored
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectUserAssignment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }

  /api/v2/projects/{projectId}/user_assignments/{id}:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string }
      - $ref: "#/components/parameters/PathId"
    get:
      tags: [Project Assignments]
      summary: Get one project user assignment
      operationId: getProjectUserAssignment
      description: Same visibility and field redaction as the list. IDs on another project or workspace return 404.
      responses:
        "200":
          description: Project user assignment
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectUserAssignment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Project Assignments]
      summary: Update or restore a project user assignment
      operationId: updateProjectUserAssignment
      description: |
        Same rules as the project member permissions table. Managers who manage the
        project may only restore or remove ordinary people (`is_active`), or change
        `is_project_manager` for teammates when the workspace allows project managers
        to assign managers; they cannot change their own assignment or another
        project manager's. Everything else is administrator-only, and `budget` also
        requires billable-rate visibility. Pass the ETag in If-Match to avoid
        overwriting a concurrent change.
      parameters:
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectUserAssignmentUpdate" }
      responses:
        "200":
          description: Updated project user assignment
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectUserAssignment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "412": { description: If-Match does not match the current assignment version. }
    delete:
      tags: [Project Assignments]
      summary: Remove a person from a project
      operationId: deleteProjectUserAssignment
      description: |
        Deactivates the assignment exactly like Remove in the project editor. The
        assignment is never hard-deleted, so logged time and history are kept and the
        person can be restored with PATCH `is_active: true`. Returns the deactivated
        assignment.
      parameters:
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      responses:
        "200":
          description: Deactivated project user assignment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectUserAssignment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "412": { description: If-Match does not match the current assignment version. }

  /api/v2/projects/{projectId}/task_assignments:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Project Assignments]
      summary: List a project's task assignments
      operationId: listProjectTaskAssignments
      description: |
        Per-project task settings, including task budgets. Readable by owners/admins
        and people assigned to the project. `billable` and `hourly_rate` follow the
        project-financials visibility rule; `budget` follows the project-budget rule
        (which includes "show budget to everyone" for assigned people). Omitted
        otherwise. Full-access credentials only.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: is_active
          in: query
          schema: { type: boolean }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated task assignments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      task_assignments:
                        type: array
                        items: { $ref: "#/components/schemas/ProjectTaskAssignment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Project Assignments]
      summary: Assign a workspace task to a project
      operationId: createProjectTaskAssignment
      description: |
        Adds an existing, active workspace task (see `GET /api/v2/tasks`) to the
        project, or restores an archived assignment. Requires an owner/admin, or a
        manager with project-edit permission who currently manages the project.
        `billable`, `hourly_rate` and `budget` also require billable-rate visibility.
        An already-active assignment returns 409. Use `POST /api/v2/tasks` to create
        a task that exists only on this project.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectTaskAssignmentCreate" }
      responses:
        "201":
          description: Task assigned or restored
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectTaskAssignment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }

  /api/v2/projects/{projectId}/task_assignments/{id}:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string }
      - $ref: "#/components/parameters/PathId"
    get:
      tags: [Project Assignments]
      summary: Get one project task assignment
      operationId: getProjectTaskAssignment
      description: Same visibility and field redaction as the list. IDs on another project or workspace return 404.
      responses:
        "200":
          description: Project task assignment
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectTaskAssignment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Project Assignments]
      summary: Update a task's budget, rate, billability, or active state on a project
      operationId: updateProjectTaskAssignment
      description: |
        Requires an owner/admin, or a manager with project-edit permission who
        currently manages the project. `billable`, `hourly_rate` and `budget` also
        require billable-rate visibility; send `null` to clear a rate or budget. A
        new `hourly_rate` applies to time tracked from now on; existing time entries
        are not repriced. `is_active: false` archives the task on this project
        without deleting anything; `true` restores it.
      parameters:
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectTaskAssignmentUpdate" }
      responses:
        "200":
          description: Updated task assignment
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectTaskAssignment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "412": { description: If-Match does not match the current assignment version. }
    delete:
      tags: [Project Assignments]
      summary: Remove a task from a project
      operationId: deleteProjectTaskAssignment
      description: |
        Same as Remove in the project editor. If any time was logged against the
        task on this project the assignment is archived (`outcome: archived`) and can
        be restored with PATCH `is_active: true`; only an assignment nothing was
        logged against is deleted (`outcome: deleted`), along with its task when the
        task existed only on this project.
      parameters:
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      responses:
        "200":
          description: Task removed from the project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectTaskAssignmentRemoval" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "412": { description: If-Match does not match the current assignment version. }

  # ─── CLIENTS ───────────────────────────────────────────────────
  /api/v2/clients:
    get:
      tags: [Clients]
      summary: List clients
      description: >-
        Returns full client records (billing address, tax and discount terms,
        payment terms). Requires an owner or administrator, or a manager with
        the client management grant or an invoicing grant; other users receive
        403.

        Pass `fields=minimal` for the roster a project picker needs —
        `id`, `name`, `currency` and `is_active` only. That shape is also
        available to a manager who may create projects (`canEditProjects`)
        but holds no client or invoicing grant; anyone else receives 403
        `Client lookup requires project, client management, or invoicing
        permissions`. Any other `fields` value is refused with 400. Servers
        that support it advertise `clients-minimal-fields` in
        `X-Keito-Features`.
      operationId: listClients
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: fields
          in: query
          schema: { type: string, enum: [minimal] }
          description: Return only `id`, `name`, `currency` and `is_active` per client.
        - name: is_active
          in: query
          schema: { type: boolean }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated list of clients
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      clients:
                        type: array
                        items:
                          oneOf:
                            - $ref: "#/components/schemas/Client"
                            - $ref: "#/components/schemas/ClientMinimal"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

    post:
      tags: [Clients]
      summary: Create a client
      operationId: createClient
      description: Requires owner/admin access or manager access with canEditClientsAndTasks.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClientCreate"
      responses:
        "201":
          description: Created client
          headers:
            Location: { schema: { type: string } }
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Client"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { description: Request body exceeds 131072 bytes. }
        "415": { description: Content-Type must be JSON and Content-Encoding must be identity. }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /api/v2/clients/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }

    get:
      tags: [Clients]
      summary: Get a client
      description: >-
        Returns the full client record including contacts. Requires an owner or
        administrator, or a manager with the client management grant or an
        invoicing grant; other users receive 403.
      operationId: getClient
      responses:
        "200":
          description: Client details
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Client"
        "404": { $ref: "#/components/responses/NotFound" }

    patch:
      tags: [Clients]
      summary: Update a client
      operationId: updateClient
      description: Requires owner/admin access or manager access with canEditClientsAndTasks. Only owners/admins may change is_active.
      parameters:
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClientUpdate"
      responses:
        "200":
          description: Updated client
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Client"
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "412": { description: If-Match does not match the current client version. }
        "413": { description: Request body exceeds 131072 bytes. }
        "415": { description: Content-Type must be JSON and Content-Encoding must be identity. }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
    delete:
      tags: [Clients]
      summary: Permanently delete an archived client
      operationId: deleteClient
      description: Owner/admin only. The client must be archived, have no projects or financial documents, and have a current If-Match value.
      parameters:
        - name: If-Match
          in: header
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      responses:
        "200": { description: Client deleted. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }
        "412": { description: If-Match does not match the current client version. }
        "428": { description: If-Match is required. }

  # ─── CONTACTS ──────────────────────────────────────────────────
  /api/v2/contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      description: >-
        Contacts are client personal data. Requires an owner or administrator,
        or a manager with the client management grant or an invoicing grant;
        other users receive 403.
      operationId: listContacts
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: client_id
          in: query
          schema: { type: string }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated list of contacts
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      contacts:
                        type: array
                        items:
                          $ref: "#/components/schemas/Contact"
        "401": { $ref: "#/components/responses/Unauthorized" }

    post:
      tags: [Contacts]
      summary: Create a contact
      operationId: createContact
      description: >-
        Requires manager or admin permissions. The body is accepted with or
        without a JSON `Content-Type`. An `Idempotency-Key` makes retries safe
        for 24 hours; invalid bodies and unknown clients never consume it.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactCreate"
      responses:
        "200":
          description: Created contact
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/ForbiddenOrCapabilityRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  # ─── TASKS ─────────────────────────────────────────────────────
  /api/v2/tasks:
    get:
      tags: [Tasks]
      summary: List tasks
      operationId: listTasks
      description: |
        Pass `project_id` to return tasks assigned to that project. Authorized
        callers also receive the authenticated user's current
        `effective_billable_rate` and the project billing currency for each task.
        Callers who can view the project budget also receive the active task
        assignment's `budget`, including `null` when that assignment has no budget.
        Requests without `project_id` omit `budget` because workspace-global tasks
        do not have a single project budget. Unauthorized callers receive the same
        task list with protected rate and budget properties omitted.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: is_active
          in: query
          schema: { type: boolean }
        - name: project_id
          in: query
          schema: { type: string }
          description: Return tasks assigned to this project and resolve project/task tracking rates.
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated list of tasks
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      tasks:
                        type: array
                        items:
                          $ref: "#/components/schemas/Task"
              examples:
                authorizedTrackingOptions:
                  summary: Project-authorized caller
                  value:
                    tasks:
                      - id: task_development
                        name: Development
                        billable_by_default: true
                        effective_billable_rate: 150
                        currency: GBP
                        budget: 40
                        is_default: true
                        is_active: true
                        parent_task_id: null
                    page: 1
                    per_page: 100
                    total_pages: 1
                    total_entries: 1
                ordinaryAssignee:
                  summary: Caller without billable-rate visibility
                  value:
                    tasks:
                      - id: task_development
                        name: Development
                        billable_by_default: true
                        is_default: true
                        is_active: true
                        parent_task_id: null
                    page: 1
                    per_page: 100
                    total_pages: 1
                    total_entries: 1
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

    post:
      tags: [Tasks]
      summary: Create a project-scoped task
      operationId: createProjectTask
      description: |
        Creates an active task usable only on `project_id` and assigns it to that
        project. Requires a full-access credential and an internal workspace
        administrator or a manager with project-edit permission who manages the
        selected project. A manager also needs billable-rate visibility to set
        `billable`, `hourly_rate`, or `budget`. Omitted `billable` inherits the
        project's billable setting. Reusing the name of an active task on the
        project returns 409; an archived assignment with that name is restored.
        Supports Idempotency-Key for safe retries.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectTaskCreate" }
      responses:
        "201":
          description: Project task and assignment created or restored
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectTaskCreated" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }

  # ─── USERS ─────────────────────────────────────────────────────
  /api/v2/roles:
    get:
      tags: [Roles]
      summary: List custom reporting roles
      operationId: listRoles
      description: |
        Returns active custom reporting-role assignments for the authenticated
        workspace, newest role first. Requires owner/administrator access and a
        full-access key or the `users:read` cost-rate automation scope. Personal
        read-only sync keys are not permitted.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated reporting roles
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      roles:
                        type: array
                        items: { $ref: "#/components/schemas/Role" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/users:
    get:
      tags: [Users]
      summary: List workspace users for administrator automation
      operationId: listUsers
      description: |
        Full-access and cost-rate automation keys receive a Harvest-compatible,
        workspace-scoped directory for team and payroll integrations and require
        owner/administrator access. `roles` contains descriptive custom reporting
        roles; `access_roles` contains permission roles. Imported placeholders use
        their recoverable original address, while contact-erased members remain
        redacted. Cost and billable rates are never included.

        Personal read-only sync keys receive the separate reduced directory shape.
        It is limited to active internal human identities, omits email, capacity,
        custom roles and relationships, and keeps the existing permission values in
        its `roles` property for compatibility.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: is_active
          in: query
          schema: { type: boolean }
        - name: employee_id
          in: query
          schema: { type: string, maxLength: 100 }
      responses:
        "200":
          description: Paginated workspace user directory
          content:
            application/json:
              schema:
                oneOf:
                  - allOf:
                      - $ref: "#/components/schemas/PaginationEnvelope"
                      - type: object
                        properties:
                          users:
                            type: array
                            items: { $ref: "#/components/schemas/WorkspaceUser" }
                  - allOf:
                      - $ref: "#/components/schemas/PaginationEnvelope"
                      - type: object
                        properties:
                          users:
                            type: array
                            items: { $ref: "#/components/schemas/SyncWorkspaceUser" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/users/{id}/teammates:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Workspace manager user CUID
    get:
      tags: [Users]
      summary: List a manager's assigned teammates
      operationId: listUserTeammates
      description: |
        Returns minimal identities for the teammates directly assigned to a Keito
        manager in the authenticated workspace. Requires owner/administrator access
        and a full-access key or the `users:read` cost-rate automation scope. A
        same-workspace user without the manager permission role returns 422.
        Personal read-only sync keys are not permitted.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated assigned teammate identities
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      teammates:
                        type: array
                        items: { $ref: "#/components/schemas/Teammate" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: The requested workspace user is not a manager

  /api/v2/users/me:
    get:
      tags: [Users]
      summary: Get current user
      operationId: getCurrentUser
      description: >-
        Returns the bound user's profile, the workspace policy a client needs
        (`company`) and what the member may do (`capabilities`). Servers that
        advertise `users-me-workspace-calendar` in `X-Keito-Features` include
        the workspace's `week_start_day`, `fiscal_year_end_month` and a
        human-readable `time_rounding_description`, and report
        `capabilities.can_view_billable_rates`. Personal read-only sync keys
        receive the reduced profile without `company`.
      responses:
        "200":
          description: Current authenticated user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

  /api/v2/users/{id}/cost_rates:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Workspace user CUID
    get:
      tags: [Users]
      summary: List effective-dated cost rates
      operationId: listUserCostRates
      description: Requires owner/administrator access and the `cost_rates:read` scope for a scoped API key.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated cost-rate history, oldest effective date first
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      cost_rates:
                        type: array
                        items: { $ref: "#/components/schemas/CostRate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Users]
      summary: Create an effective-dated cost rate
      operationId: createUserCostRate
      description: |
        Requires owner/administrator access, the `cost_rates:write` scope for a
        scoped API key, and an Idempotency-Key. `start_date` is required and may
        be in the future. Keito closes only the adjacent preceding rate and derives
        the new rate's end date from the next scheduled rate. Existing time-entry
        cost snapshots are not rewritten.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CostRateCreate" }
      responses:
        "201":
          description: Created cost rate
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CostRate" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyOrDomainConflict" }

  # ─── PLANNING ─────────────────────────────────────────────────
  /api/v2/planning/me:
    get:
      tags:
        - Planning
      summary: Read your own schedule
      operationId: getOwnPlanning
      security:
        - bearerAuth: []
      description: "Business only. Owners, administrators and authorized planning managers retain access; other active internal people need canReadOwnPlanningApi, off by default and independent of team Plan visibility. Existing personal sync keys are accepted without rotation. Only the authenticated person is returned. Unknown, repeated and identity-selector parameters are rejected. Default: the current workspace week. Supply both from and to for a custom range of at most 84 inclusive civil days. Each page is built in a repeatable-read snapshot. Preserve range and per_page, and send snapshot_version on all later pages. A changed snapshot returns 409 with reason=snapshot_changed; restart at page 1. Availability always covers the complete range. Assignments sort by start_date then id. No actual hours, notes, money, teammates, leave types or external leave identifiers. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial."
      parameters:
        - name: from
          in: query
          schema:
            type: string
            format: date
          description: Inclusive start; requires to.
        - name: to
          in: query
          schema:
            type: string
            format: date
          description: Inclusive end; requires from.
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
        - name: snapshot_version
          in: query
          schema:
            type: string
            pattern: ^ps1_[A-Za-z0-9_-]{43}$
          description: Required for page > 1; use the page-1 token.
      responses:
        "200":
          description: Private own schedule, including complete-range availability
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PersonalPlanningSchedule"
        "400":
          description: Invalid, unknown or duplicate query parameter, invalid calendar date, incomplete range or missing snapshot
        "401":
          description: Invalid, expired or revoked credential
        "403":
          description: Current entitlement, membership, credential scope or own-read permission denies access
        "409":
          description: "snapshot_changed: discard accumulated pages and restart"

  /api/v2/assignments:
    get:
      tags:
        - Planning
      summary: List planning assignments
      operationId: listPlanningAssignments
      description: Requires Business entitlement and existing authorized planning read access. Personal sync keys use GET /api/v2/planning/me instead. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: client_id
          in: query
          schema:
            type: string
        - name: project_id
          in: query
          schema:
            type: string
        - name: user_id
          in: query
          schema:
            type: string
        - name: role_id
          in: query
          schema:
            type: string
          description: Filter assigned people by custom role. Comma-separated values are accepted.
        - name: project_status
          in: query
          schema:
            type: string
            enum:
              - active
              - tentative
              - archived
          description: Comma-separated values are accepted.
        - name: include_tentative
          in: query
          schema:
            type: boolean
            default: true
        - name: include_tentative_in_capacity
          in: query
          schema:
            type: boolean
            default: false
        - name: include_placeholders
          in: query
          schema:
            type: boolean
            default: true
        - name: include_time_off
          in: query
          schema:
            type: boolean
            default: true
      responses:
        "200":
          description: Paginated list of planning assignments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      assignments:
                        type: array
                        items:
                          $ref: "#/components/schemas/PlanningAssignment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags:
        - Planning
      summary: Create a planning assignment
      operationId: createPlanningAssignment
      description: Business planning managers and administrators only, within current person/project boundaries. Without the contract header, the existing response and behavior are unchanged. Contract 1 requires Idempotency-Key and PATCH/DELETE also require the exact strong ETag from item GET in If-Match. Authorization is rechecked before receipt replay. Mutation, audit, webhook enqueue and completed retry receipt commit atomically. Same-key concurrent duplicates have one effect. Changed key reuse returns 409. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PlanningAssignmentCreate"
      responses:
        "200":
          description: "Legacy: expanded assignment. Contract 1: intrinsic assignment item with ETag (including deletion tombstone)."
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PlanningAssignment"
                  - $ref: "#/components/schemas/PlanningAssignmentItem"
          headers:
            ETag:
              schema:
                type: string
              description: Strong validator for contract-1 item fields only.
            Idempotency-Replayed:
              schema:
                type: string
                enum:
                  - "true"
              description: Original completed receipt replayed after current authorization.
        "400":
          description: Malformed contract, key, strong If-Match, body or date
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Same key reused with changed method, route, body, contract or precondition
        "412":
          description: Assignment revision changed or assignment deleted; fetch current state and use a new key for a new decision
        "428":
          description: Required Idempotency-Key or If-Match missing
        "503":
          description: Safe writes disabled; no legacy fallback
      parameters:
        - name: X-Keito-Planning-Contract
          in: header
          required: false
          schema:
            type: string
            enum:
              - "1"
          description: Opt into contract 1. Omitting this header preserves legacy semantics. Unsupported values return 400; disabled contract returns 503.
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 8
            maxLength: 255
            pattern: ^[A-Za-z0-9._-]+$
          description: Required with contract 1. Workspace + actor + key identifies a 24-hour receipt. Retry with identical method, route, body, contract and If-Match.

  /api/v2/assignments/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    patch:
      tags:
        - Planning
      summary: Update a planning assignment
      operationId: updatePlanningAssignment
      description: Business planning managers and administrators only, within current person/project boundaries. Without the contract header, the existing response and behavior are unchanged. Contract 1 requires Idempotency-Key and PATCH/DELETE also require the exact strong ETag from item GET in If-Match. Authorization is rechecked before receipt replay. Mutation, audit, webhook enqueue and completed retry receipt commit atomically. Same-key concurrent duplicates have one effect. Changed key reuse returns 409. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PlanningAssignmentUpdate"
      responses:
        "200":
          description: "Legacy: expanded assignment. Contract 1: intrinsic assignment item with ETag (including deletion tombstone)."
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PlanningAssignment"
                  - $ref: "#/components/schemas/PlanningAssignmentItem"
          headers:
            ETag:
              schema:
                type: string
              description: Strong validator for contract-1 item fields only.
            Idempotency-Replayed:
              schema:
                type: string
                enum:
                  - "true"
              description: Original completed receipt replayed after current authorization.
        "400":
          description: Malformed contract, key, strong If-Match, body or date
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Same key reused with changed method, route, body, contract or precondition
        "412":
          description: Assignment revision changed or assignment deleted; fetch current state and use a new key for a new decision
        "428":
          description: Required Idempotency-Key or If-Match missing
        "503":
          description: Safe writes disabled; no legacy fallback
      parameters:
        - name: X-Keito-Planning-Contract
          in: header
          required: false
          schema:
            type: string
            enum:
              - "1"
          description: Opt into contract 1. Omitting this header preserves legacy semantics. Unsupported values return 400; disabled contract returns 503.
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 8
            maxLength: 255
            pattern: ^[A-Za-z0-9._-]+$
          description: Required with contract 1. Workspace + actor + key identifies a 24-hour receipt. Retry with identical method, route, body, contract and If-Match.
        - name: If-Match
          in: header
          required: false
          schema:
            type: string
            example: '"planning-assignment_id-r3"'
          description: "Required with contract 1: exactly one specific strong assignment ETag. Wildcards, weak ETags and lists are rejected."
    delete:
      tags:
        - Planning
      summary: Delete a planning assignment
      operationId: deletePlanningAssignment
      description: Business planning managers and administrators only, within current person/project boundaries. Without the contract header, the existing response and behavior are unchanged. Contract 1 requires Idempotency-Key and PATCH/DELETE also require the exact strong ETag from item GET in If-Match. Authorization is rechecked before receipt replay. Mutation, audit, webhook enqueue and completed retry receipt commit atomically. Same-key concurrent duplicates have one effect. Changed key reuse returns 409. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial.
      responses:
        "200":
          description: "Legacy: expanded assignment. Contract 1: intrinsic assignment item with ETag (including deletion tombstone)."
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PlanningAssignment"
                  - $ref: "#/components/schemas/PlanningAssignmentItem"
          headers:
            ETag:
              schema:
                type: string
              description: Strong validator for contract-1 item fields only.
            Idempotency-Replayed:
              schema:
                type: string
                enum:
                  - "true"
              description: Original completed receipt replayed after current authorization.
        "400":
          description: Malformed contract, key, strong If-Match, body or date
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Same key reused with changed method, route, body, contract or precondition
        "412":
          description: Assignment revision changed or assignment deleted; fetch current state and use a new key for a new decision
        "428":
          description: Required Idempotency-Key or If-Match missing
        "503":
          description: Safe writes disabled; no legacy fallback
      parameters:
        - name: X-Keito-Planning-Contract
          in: header
          required: false
          schema:
            type: string
            enum:
              - "1"
          description: Opt into contract 1. Omitting this header preserves legacy semantics. Unsupported values return 400; disabled contract returns 503.
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 8
            maxLength: 255
            pattern: ^[A-Za-z0-9._-]+$
          description: Required with contract 1. Workspace + actor + key identifies a 24-hour receipt. Retry with identical method, route, body, contract and If-Match.
        - name: If-Match
          in: header
          required: false
          schema:
            type: string
            example: '"planning-assignment_id-r3"'
          description: "Required with contract 1: exactly one specific strong assignment ETag. Wildcards, weak ETags and lists are rejected."
    get:
      tags:
        - Planning
      summary: Get a planning assignment and strong ETag
      operationId: getPlanningAssignment
      security:
        - bearerAuth: []
      description: Business administrators and authorized planning managers within their current read scope. Returns assignment-owned fields only, including a soft-deletion tombstone. Joined labels and derived tracked/planned totals are excluded so the revision ETag is strong. Personal sync credentials cannot use this endpoint. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial.
      responses:
        "200":
          description: Intrinsic assignment item
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlanningAssignmentItem"
          headers:
            ETag:
              schema:
                type: string
              description: Use this exact strong validator as If-Match for contract-1 PATCH/DELETE.
            Cache-Control:
              schema:
                type: string
              example: private, no-store
        "401":
          description: Credential denied
        "403":
          description: Business entitlement or planning permission denied
        "404":
          description: Assignment missing or outside current read scope

  /api/v2/placeholders:
    get:
      tags:
        - Planning
      summary: List planning placeholders
      operationId: listPlanningPlaceholders
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated placeholder list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      placeholders:
                        type: array
                        items:
                          $ref: "#/components/schemas/PlanningPlaceholder"
        "403":
          $ref: "#/components/responses/Forbidden"
      description: Requires Business entitlement and existing authorized planning read access. Personal sync keys use GET /api/v2/planning/me instead. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial.

  /api/v2/time-off:
    get:
      tags:
        - Planning
      summary: List planning time off
      operationId: listPlanningTimeOff
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: user_id
          in: query
          schema:
            type: string
        - name: role_id
          in: query
          schema:
            type: string
          description: Filter people by custom role. Comma-separated values are accepted.
      responses:
        "200":
          description: Paginated time off list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      time_off:
                        type: array
                        items:
                          $ref: "#/components/schemas/PlanningTimeOff"
        "403":
          $ref: "#/components/responses/Forbidden"
      description: Requires Business entitlement and existing authorized planning read access. Personal sync keys use GET /api/v2/planning/me instead. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial.

  /api/v2/capacity:
    get:
      tags:
        - Planning
      summary: List planning capacity
      operationId: listPlanningCapacity
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: user_id
          in: query
          schema:
            type: string
        - name: role_id
          in: query
          schema:
            type: string
          description: Filter people by custom role. Comma-separated values are accepted.
        - name: include_tentative_in_capacity
          in: query
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Paginated person-day capacity rows
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      capacity:
                        type: array
                        items:
                          $ref: "#/components/schemas/PlanningCapacity"
        "403":
          $ref: "#/components/responses/Forbidden"
      description: Requires Business entitlement and existing authorized planning read access. Personal sync keys use GET /api/v2/planning/me instead. Business entitlement and current permission are checked on every request. 403 reason distinguishes entitlement from permission denial.

  /api/v2/webhooks:
    get:
      tags:
        - Webhooks
      summary: List webhook endpoints
      operationId: listWebhooks
      description: "Administrator only. Planning event subscriptions require Business entitlement. "
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 2000
            default: 100
      responses:
        "200":
          description: Paginated webhook endpoint list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      webhooks:
                        type: array
                        items:
                          $ref: "#/components/schemas/WebhookEndpoint"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags:
        - Webhooks
      summary: Create a webhook endpoint
      operationId: createWebhook
      description: Administrator only. Planning event subscriptions require Business entitlement. Public HTTPS destinations only; unsafe IPv4/IPv6 and DNS answers and redirects are rejected. payload_version defaults to 1; choose 2 with a signing secret for minimal versioned events. Deliveries are at least once; deduplicate by stable delivery ID and use entity revisions to ignore reversed events. Periodically reconcile calendar changes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEndpointCreate"
      responses:
        "200":
          description: Created webhook endpoint
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "503":
          description: Version-2 registration disabled

  /api/v2/webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    delete:
      tags:
        - Webhooks
      summary: Delete a webhook endpoint
      operationId: deleteWebhook
      description: "Administrator only. Planning event subscriptions require Business entitlement. "
      responses:
        "200":
          description: Deactivated webhook endpoint
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required:
                  - id
                  - deleted
                properties:
                  id:
                    type: string
                  deleted:
                    type: boolean
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v2/invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      operationId: listInvoices
      description: >-
        Requires the `can_view_invoices` capability reported by
        `GET /api/v2/users/me`: owners and admins always, managers only with
        the `canViewInvoices` grant. Any other API key or first-party token
        receives a 403 with `Insufficient permissions to view invoices`. An AI
        app connection (MCP connector token) whose member lacks the capability
        receives a 403 with `error: mcp_capability_required` and
        `capability: can_view_invoices` instead. Servers that enforce this gate
        advertise `invoice-view-permission-gate` in `X-Keito-Features`.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: client_id
          in: query
          schema: { type: string }
        - name: state
          in: query
          style: form
          explode: true
          description: >-
            Invoice states to include. Repeat the parameter
            (`state=open&state=paid`) or pass one comma-separated value
            (`state=open,paid`); values are case-insensitive. An unknown value
            is a 400 `Invalid invoice state`. Omit it for every state.
          schema:
            type: array
            items: { type: string, enum: [draft, open, paid, closed, voided] }
        - name: overdue
          in: query
          description: >-
            `true` returns only open invoices with a balance due whose due date
            has passed in the workspace timezone — an invoice is never overdue
            on its due date. It narrows `state` to `open`; combining it with a
            `state` list that excludes `open` is a 400
            `overdue=true requires state to include open`. Any value other than
            `true` or `false` is a 400.
          schema: { type: boolean }
        - name: include_line_items
          in: query
          description: >-
            `false` omits `line_items` from every row, for a lighter list. Any
            value other than `true` or `false` is a 400.
          schema: { type: boolean, default: true }
        - name: from
          in: query
          schema: { type: string, format: date }
          description: Issue date start
        - name: to
          in: query
          schema: { type: string, format: date }
          description: Issue date end
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - name: owner_id
          in: query
          schema: { type: string }
          description: Eligible workspace member ID, or `unassigned`.
      responses:
        "200":
          description: Paginated list of invoices
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      invoices:
                        type: array
                        items:
                          $ref: "#/components/schemas/Invoice"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ForbiddenOrCapabilityRequired" }

    post:
      tags: [Invoices]
      summary: Create an invoice
      operationId: createInvoice
      description: >-
        Requires an owner/admin key or a manager with the
        `canEditDraftInvoices` permission. When `tax` or `tax2` is omitted, the
        client VAT default is used, then the workspace invoice default. The
        body is accepted with or without a JSON `Content-Type`. Send an
        `Idempotency-Key` to make retries safe: a refused request (unknown
        client, invalid body, failed import whose draft was removed) releases
        the key, a created draft is replayed for 24 hours. AI app connections
        need the `can_create_invoices` capability.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoiceCreate"
      responses:
        "200":
          description: Created invoice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/ForbiddenOrCapabilityRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "413": { description: Request body exceeds 524288 bytes. }

  /api/v2/invoices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }

    get:
      tags: [Invoices]
      summary: Get an invoice
      operationId: getInvoice
      description: >-
        Requires the `can_view_invoices` capability, as for `GET /api/v2/invoices`;
        otherwise a 403. An invoice outside the caller's company is a 404.
      responses:
        "200":
          description: Invoice with line items
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "403": { $ref: "#/components/responses/ForbiddenOrCapabilityRequired" }
        "404": { $ref: "#/components/responses/NotFound" }

    patch:
      tags: [Invoices]
      summary: Update an invoice
      operationId: updateInvoice
      description: Requires manager or admin permissions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoiceUpdate"
      responses:
        "200":
          description: Updated invoice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

    delete:
      tags: [Invoices]
      summary: Delete an invoice
      operationId: deleteInvoice
      description: Admin only. Can only delete draft invoices.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  deleted: { type: boolean }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/invoices/{id}/pdf:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }

    get:
      tags: [Invoices]
      summary: Download an invoice PDF
      operationId: downloadInvoicePdf
      description: >-
        Returns the current rendered invoice as a private PDF download. The
        invoice must belong to the API key's workspace.
      responses:
        "200":
          description: Rendered invoice PDF
          headers:
            Content-Disposition:
              schema: { type: string }
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/invoices/{id}/attachments:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }

    get:
      tags: [Invoices]
      summary: List invoice attachments
      operationId: listInvoiceAttachments
      description: >-
        Files attached to the invoice, including generated time reports
        (`kind: time_report`). Uploaded files ride along as real email
        attachments when the invoice is sent with `include_attachments`.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of invoice attachments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      invoice_attachments:
                        type: array
                        items:
                          $ref: "#/components/schemas/InvoiceAttachment"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

    post:
      tags: [Invoices]
      summary: Upload an invoice attachment
      operationId: uploadInvoiceAttachment
      description: >-
        Adds a supporting file to a draft or open invoice. Paid, closed and
        voided invoices are read-only. Limits: 9 MB per file, 10 files and
        50 MB in total per invoice. Accepted types: PDF, JPEG, PNG, GIF, WebP,
        Word (.doc/.docx), Excel (.xls/.xlsx), CSV and plain text. Browsers that
        send an empty or generic MIME type are resolved from the file extension.
        Requires an owner/admin key or a manager with the matching invoice
        permission (`canEditDraftInvoices` for drafts, `canManageInvoices` for
        open invoices).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "201":
          description: Created invoice attachment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceAttachment"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

    delete:
      tags: [Invoices]
      summary: Delete an invoice attachment
      operationId: deleteInvoiceAttachment
      description: Removes a file from a draft or open invoice.
      parameters:
        - name: attachment_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Attachment removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/invoices/{id}/attachments/{attachmentId}/download:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: attachmentId
        in: path
        required: true
        schema: { type: string }

    get:
      tags: [Invoices]
      summary: Download an invoice attachment
      operationId: downloadInvoiceAttachment
      description: >-
        Streams the stored file with `Content-Disposition: attachment`,
        `X-Content-Type-Options: nosniff` and `Cache-Control: private, no-store`.
      responses:
        "200":
          description: Attachment file
          headers:
            Content-Disposition:
              schema: { type: string }
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── ESTIMATES ─────────────────────────────────────────────────
  /api/v2/estimates:
    get:
      tags: [Estimates]
      summary: List estimates
      operationId: listEstimates
      description: >-
        Any workspace member's API key may list estimates. An AI app connection
        is admitted only when its member is a manager with `canViewInvoices`
        (estimates have no view permission of their own); otherwise a 403 with
        `error: mcp_capability_required` and `capability: can_view_invoices`.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: client_id
          in: query
          schema: { type: string }
        - name: state
          in: query
          schema: { type: string, enum: [draft, sent, viewed, accepted, declined, expired] }
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
        - name: owner_id
          in: query
          schema: { type: string }
          description: Eligible workspace member ID, or `unassigned`.
      responses:
        "200":
          description: Paginated list of estimates
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      estimates:
                        type: array
                        items:
                          $ref: "#/components/schemas/Estimate"
        "401": { $ref: "#/components/responses/Unauthorized" }

    post:
      tags: [Estimates]
      summary: Create an estimate
      operationId: createEstimate
      description: >-
        Requires `canManageEstimates` manager permission or admin. The body is
        accepted with or without a JSON `Content-Type`; numeric fields also
        accept numeric strings. An `Idempotency-Key` makes retries safe for 24
        hours; invalid bodies, unknown clients and ineligible owners never
        consume it. AI app connections need the `can_manage_estimates`
        capability.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EstimateCreate"
      responses:
        "200":
          description: Created estimate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Estimate"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/ForbiddenOrCapabilityRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /api/v2/estimates/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }

    get:
      tags: [Estimates]
      summary: Get an estimate
      operationId: getEstimate
      responses:
        "200":
          description: Estimate with line items
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Estimate"
        "404": { $ref: "#/components/responses/NotFound" }

    patch:
      tags: [Estimates]
      summary: Update an estimate
      operationId: updateEstimate
      description: Requires `canManageEstimates` manager permission or admin.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EstimateUpdate"
      responses:
        "200":
          description: Updated estimate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Estimate"
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

    delete:
      tags: [Estimates]
      summary: Delete an estimate
      operationId: deleteEstimate
      description: Requires `canManageEstimates`; only draft estimates can be deleted.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  deleted: { type: boolean }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/estimates/{id}/send:
    parameters:
      - $ref: "#/components/parameters/PathId"
    post:
      tags: [Estimates]
      summary: Mark an estimate as sent
      operationId: sendEstimate
      description: >-
        Moves the estimate to `sent` and records an activity row with no
        recipients. No email is delivered by this endpoint. Requires
        `canManageEstimates`; refused for estimates with no line items and for
        accepted or declined estimates. An `Idempotency-Key` makes retries
        safe; a refused request never consumes it. AI app connections need the
        `can_manage_estimates` capability.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      responses:
        "200":
          description: Estimate marked as sent
          content:
            application/json:
              schema:
                type: object
                required: [id, state]
                properties:
                  id: { type: string }
                  state: { type: string, example: SENT }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/ForbiddenOrCapabilityRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /api/v2/estimates/{id}/accept:
    parameters:
      - $ref: "#/components/parameters/PathId"
    post:
      tags: [Estimates]
      summary: Mark an estimate as accepted
      operationId: acceptEstimate
      responses:
        "200": { description: Estimate accepted }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/estimates/{id}/decline:
    parameters:
      - $ref: "#/components/parameters/PathId"
    post:
      tags: [Estimates]
      summary: Mark an estimate as declined
      operationId: declineEstimate
      responses:
        "200": { description: Estimate declined }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/estimates/{id}/convert:
    parameters:
      - $ref: "#/components/parameters/PathId"
    post:
      tags: [Estimates]
      summary: Convert an accepted estimate to a draft invoice
      operationId: convertEstimate
      responses:
        "200": { description: Estimate converted }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/estimates/{id}/duplicate:
    parameters:
      - $ref: "#/components/parameters/PathId"
    post:
      tags: [Estimates]
      summary: Duplicate an estimate as a draft
      operationId: duplicateEstimate
      responses:
        "200":
          description: Duplicated estimate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Estimate"
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── INVOICE MESSAGES ──────────────────────────────────────────
  /api/v2/invoices/{id}/messages:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }

    get:
      tags: [Invoice Messages]
      summary: List invoice messages
      operationId: listInvoiceMessages
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of invoice messages
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      invoice_messages:
                        type: array
                        items:
                          $ref: "#/components/schemas/InvoiceMessage"

    post:
      tags: [Invoice Messages]
      summary: Send an invoice
      operationId: sendInvoice
      description: >-
        Sends the invoice via email and transitions state to OPEN. Use
        `event_type: "mark_sent"` to transition a draft to OPEN without sending
        email or requiring recipients. Use
        `event_type: "resend"` to email another copy of a previously sent open
        or paid invoice: the invoice keeps its state, number, dates, and first-sent
        timestamp, a `resend` message is recorded, accounting integrations are
        not re-posted, and a second resend within one minute is refused.

        Optional `confirm` binds the request to the invoice as the caller last
        saw it: `amount` (compared at two decimals) and `currency` (case
        insensitive) must equal the invoice's current values, and
        `recipient_count`, when given, must equal the number of distinct To
        recipients (0 for `mark_sent`). Any mismatch returns 409 with
        `error: confirmation_mismatch`, `expected` and `received`, and nothing
        changes. Requests without `confirm` behave as before.

        An `Idempotency-Key` makes retries safe for 24 hours. Every validation
        above runs before the key is claimed, so a refused request never
        consumes it; a completed send, including a post-issue delivery failure,
        is replayed rather than re-issued. Manager access is required. AI app
        connections additionally need `canManageInvoices` for every
        `event_type` (`can_send_invoices`).
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoiceMessageCreate"
      responses:
        "200":
          description: Created invoice message
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceMessage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/ForbiddenOrCapabilityRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/InvoiceConfirmationOrIdempotencyConflict" }

  /api/v2/invoices/{id}/payments:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }

    get:
      tags: [Invoices]
      summary: List an invoice's payments
      operationId: listInvoicePayments
      description: >-
        Payments recorded against the invoice, newest first (by payment date,
        then by when they were recorded). Requires the `can_view_invoices`
        capability, as for `GET /api/v2/invoices`. Each row carries
        `deletable`: true only for a manually recorded payment when the caller
        may manage invoices, which is exactly what
        `DELETE /api/v2/invoices/{id}/payments/{paymentId}` will accept.
        Servers that offer this list advertise `invoice-payments-list` in
        `X-Keito-Features`.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: Paginated list of the invoice's payments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      payments:
                        type: array
                        items:
                          $ref: "#/components/schemas/InvoicePayment"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

    post:
      tags: [Invoices]
      summary: Record an invoice payment
      operationId: createInvoicePayment
      description: >-
        Records a partial or full payment. A payment equal to the remaining due
        amount marks the invoice paid; partial payments leave it open. Draft
        invoices and overpayments are rejected. Requires `canManageInvoices`.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoicePaymentCreate"
      responses:
        "201":
          description: Recorded invoice payment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoicePayment"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /api/v2/invoices/{id}/payments/{paymentId}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: paymentId
        in: path
        required: true
        schema: { type: string }

    delete:
      tags: [Invoices]
      summary: Delete a manually recorded invoice payment
      operationId: deleteInvoicePayment
      description: >-
        Removes a payment that was recorded by hand and recalculates the
        invoice's amount due and state from the payments that remain, so a
        paid invoice becomes open again. Requires an owner/admin key or a
        manager with `canManageInvoices`. Payments that came from a gateway,
        an accounting integration, a retainer or an import are refused with a
        409 and must be reversed where they were made. When the payment has a
        Xero or QuickBooks copy, that copy is reversed first exactly as the
        web does: `deleted` is true only once the reversal is confirmed
        (`accounting_sync.status` `SYNCED`) or there was nothing to reverse
        (`NOT_APPLICABLE`); otherwise the payment is kept in Keito, `outcome`
        is `kept_in_keito` and `message` says why. Send an `Idempotency-Key` to
        make retries safe: a completed deletion is replayed for 24 hours, while
        a refusal or a payment kept in Keito releases the key so the same
        request can be retried. Servers that offer this route advertise
        `invoice-payment-delete` in `X-Keito-Features`.
      parameters:
        - $ref: "#/components/parameters/OptionalIdempotencyKey"
      responses:
        "200":
          description: Deletion outcome
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoicePaymentDeletion"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >-
            The payment was not recorded manually, or the `Idempotency-Key`
            was already used for a different request.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ─── REPORTS ───────────────────────────────────────────────────
  /api/v2/projects/{projectId}/budget_summary:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Projects]
      summary: Read-only monetary budget progress
      operationId: getProjectMoneyBudgetSummary
      description: |
        Explicit opt-in aggregate view for active internal standard members.
        Owners and administrators retain their existing financial authority
        on this dedicated surface, including unassigned projects. Other users
        require `canViewAssignedProjectMoneyBudgets` on an active project
        assignment, or `canViewManagedProjectMoneyBudgets` and a manager role
        with an active project-manager assignment. Grants default off. The
        server rollout flag can disable this surface without altering legacy
        financial access. Client and restricted accountant memberships, MCP
        connections, read-only sync and cost-rate scoped keys are denied.

        Supports monetary project and task-fee budgets only. Monetary project
        budgets use their configured billable spending basis, including
        expenses when configured. Internal-cost budgets are excluded.
        Monthly budgets report the current month, with no all-time task rows.
        Currency follows the project client's currency, falling back to the
        workspace currency. Rates, costs, invoices, names and time entries
        are excluded. Aggregate spending may still allow rate inference.

        Existing project, task, report and billing API responses are unchanged.
        This is a safe read and grants no editing or invoicing capabilities.
      parameters:
        - name: include_inactive_tasks
          in: query
          schema: { type: boolean, default: false }
          description: Include archived task budgets and their historical spend.
      responses:
        "200":
          description: Allowlisted budget progress, never raw finance records
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [budget, spent, remaining, percent, isHours, isMonthly, currency, tasks]
                properties:
                  budget: { type: number }
                  spent: { type: number }
                  remaining: { type: number }
                  percent: { type: number }
                  isHours: { type: boolean, enum: [false] }
                  isMonthly: { type: boolean }
                  currency: { type: string }
                  tasks:
                    type: array
                    items:
                      type: object
                      additionalProperties: false
                      required: [taskId, budget, spent, remaining, percent, isHours, isMonthly]
                      properties:
                        taskId: { type: string }
                        budget: { type: number }
                        spent: { type: number }
                        remaining: { type: number }
                        percent: { type: number }
                        isHours: { type: boolean, enum: [false] }
                        isMonthly: { type: boolean, enum: [false] }
        "400":
          description: Invalid or conflicting query parameters
        "401":
          description: Missing or invalid authentication
        "403":
          description: Grant, role, membership, credential scope or rollout gate denied
        "404":
          description: Invisible project or monetary budget unavailable

  /api/v2/projects/{projectId}/billing_summary:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Reports]
      summary: Project billing summary
      operationId: getProjectBillingSummary
      description: |
        How much has been invoiced for one project. `invoiced` counts issued
        invoices only (open, paid, closed) per invoice currency and never sums
        across currencies; drafts are reported separately in `draft_amount`.
        `uninvoiced` follows the uninvoiced report (workspace rounding,
        historical effective billing rates, the workspace approval policy) and
        `budget` is the project budget report row when the project has one.

        Access follows the uninvoiced report: an owner/admin, or a manager with
        both `canViewBillableRates` and `canViewInvoices`, limited to projects
        they actively manage; other projects and cross-workspace ids read as
        404. Personal read-only sync keys are not admitted. AI app connections
        (MCP connector tokens) also need the `can_view_financial_reports`
        capability and never see calendar-derived time in the uninvoiced
        figures (`calendar_time_excluded: true`).

        `from` and `to` (YYYYMMDD or YYYY-MM-DD, at most 365 days) narrow both
        the invoices by issue date and the uninvoiced work by spent date; give
        both or neither.
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.keito.ai/api/v2/projects/$PROJECT_ID/billing_summary?from=20260901&to=20260930" \
              -H "Authorization: Bearer $KEITO_API_KEY" \
              -H "Keito-Account-Id: $KEITO_ACCOUNT_ID"
      parameters:
        - name: from
          in: query
          required: false
          schema: { type: string }
          description: Window start (YYYYMMDD or YYYY-MM-DD). Requires `to`.
        - name: to
          in: query
          required: false
          schema: { type: string }
          description: Window end (YYYYMMDD or YYYY-MM-DD). Requires `from`.
      responses:
        "200":
          description: Project billing summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectBillingSummary"
              example:
                project:
                  id: clxproject123
                  name: Review Platform
                  code: REVIEW
                  client: { id: clxclient123, name: Review Client }
                  currency: GBP
                  is_fixed_fee: false
                  is_active: true
                period: { from: "2026-09-01", to: "2026-09-30" }
                invoiced:
                  amount: 1250
                  currency: GBP
                  by_currency:
                    - { currency: GBP, amount: 1250 }
                  draft_amount: 300
                invoices_by_state: { draft: 1, open: 1, paid: 0, closed: 0, voided: 0 }
                uninvoiced:
                  hours: 1.25
                  time_amount: 125
                  expense_amount: 1.5
                  amount: 126.5
                  currency: GBP
                budget: null
                calendar_time_excluded: true
                includes_unapproved_billable_work: false
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/ForbiddenOrCapabilityRequired" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/reports/project_budget:
    get:
      tags: [Reports]
      summary: Project budget report
      operationId: getProjectBudgetReport
      description: |
        Harvest-compatible project budget report. Requires a full-access API
        key (or WorkOS JWT) bound to an owner/admin, or to a manager with
        `canViewBillableRates`. Managers receive only projects they actively
        manage; personal read-only sync and cost-rate keys are not admitted.

        Monetary budget consumption uses Keito's rounded time and historical
        effective billing rates. Invoice discounts, payments, and retainer
        credits do not change `budget_spent`.
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.keito.ai/api/v2/reports/project_budget?is_active=true" \
              -H "Authorization: Bearer $KEITO_API_KEY" \
              -H "Keito-Account-Id: $KEITO_ACCOUNT_ID"
      parameters:
        - $ref: "#/components/parameters/page"
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 2000, default: 2000 }
        - name: is_active
          in: query
          schema: { type: boolean }
          description: Return only active or inactive projects.
      responses:
        "200":
          description: Project budget report
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/HarvestReportPaginationEnvelope"
                  - type: object
                    required: [results]
                    properties:
                      results:
                        type: array
                        items:
                          $ref: "#/components/schemas/ProjectBudgetReportResult"
              example:
                results:
                  - project_id: clxproject123
                    project_name: Implementation
                    client_id: clxclient123
                    client_name: Example Client
                    budget_is_monthly: false
                    budget_by: project
                    is_active: true
                    budget: 20000
                    budget_spent: 5250
                    budget_remaining: 14750
                per_page: 2000
                total_pages: 1
                total_entries: 1
                next_page: null
                previous_page: null
                page: 1
                links:
                  first: /api/v2/reports/project_budget?page=1&is_active=true&per_page=2000
                  next: null
                  previous: null
                  last: /api/v2/reports/project_budget?page=1&is_active=true&per_page=2000
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/reports/uninvoiced:
    get:
      tags: [Reports]
      summary: Uninvoiced report
      operationId: getUninvoicedReport
      description: |
        Harvest-compatible project-level uninvoiced report. Requires a
        full-access API key (or WorkOS JWT) bound to an owner/admin, or to a
        manager with both `canViewBillableRates` and `canViewInvoices`.
        Managers receive only projects they actively manage. AI app connections
        (MCP connector tokens) also need the `can_view_financial_reports`
        capability and never see calendar-derived time in the totals.

        Time uses the workspace rounding rule and historical effective billing
        rate. Approval eligibility follows the workspace invoice setting.
        Running and rejected time are excluded. Fixed-fee balance is the project
        fee less non-voided service invoice lines; payments, invoice discounts,
        tax, and retainer-funding lines do not reduce it. For date-filtered fixed
        fees, the balance appears when the range ends on or after the project
        start date, falling back to project creation date.

        Multi-month ranges are supported within the documented 365-day limit;
        there is no separate one- or two-month limit. Results are paginated by
        project, with `per_page` defaulting to and capped at 2000. Follow
        `links.next` until it is null, retaining the same date range and
        `include_fixed_fee` choice. Date filters select the reporting period;
        they do not turn this report into an immutable historical snapshot.
        Retainer balances are not returned or subtracted from these amounts.
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.keito.ai/api/v2/reports/uninvoiced?from=20260101&to=20260131" \
              -H "Authorization: Bearer $KEITO_API_KEY" \
              -H "Keito-Account-Id: $KEITO_ACCOUNT_ID"
      parameters:
        - $ref: "#/components/parameters/page"
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 2000, default: 2000 }
        - name: from
          in: query
          required: true
          schema: { type: string, pattern: '^\d{8}$', example: "20260101" }
          description: Inclusive start date in YYYYMMDD format.
        - name: to
          in: query
          required: true
          schema: { type: string, pattern: '^\d{8}$', example: "20260131" }
          description: Inclusive end date in YYYYMMDD format; ranges are limited to 365 days.
        - name: include_fixed_fee
          in: query
          schema: { type: boolean, default: true }
          description: Include fixed-fee projects and their remaining fee balance.
      responses:
        "200":
          description: Uninvoiced report
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/HarvestReportPaginationEnvelope"
                  - type: object
                    required: [results]
                    properties:
                      results:
                        type: array
                        items:
                          $ref: "#/components/schemas/UninvoicedReportResult"
              example:
                results:
                  - client_id: clxclient123
                    client_name: Example Client
                    project_id: clxproject123
                    project_name: Implementation
                    currency: GBP
                    total_hours: 42.5
                    uninvoiced_hours: 12.5
                    uninvoiced_expenses: 125
                    uninvoiced_amount: 1375
                per_page: 2000
                total_pages: 1
                total_entries: 1
                next_page: null
                previous_page: null
                page: 1
                links:
                  first: /api/v2/reports/uninvoiced?page=1&from=20260101&to=20260131&per_page=2000
                  next: null
                  previous: null
                  last: /api/v2/reports/uninvoiced?page=1&from=20260101&to=20260131&per_page=2000
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/reports/time/team:
    get:
      tags: [Reports]
      summary: Team time report
      operationId: getTeamTimeReport
      description: |
        Aggregated time report by team member. Requires manager or admin permissions.
        Date range cannot exceed 365 days.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: from
          in: query
          required: true
          schema: { type: string }
          description: Start date in YYYYMMDD format
        - name: to
          in: query
          required: true
          schema: { type: string }
          description: End date in YYYYMMDD format
        - name: client_id
          in: query
          schema: { type: string }
          description: Filter report rows to time entries for projects under this client.
        - name: project_id
          in: query
          schema: { type: string }
          description: Filter report rows to one project.
        - name: source
          in: query
          schema: { type: string, enum: [web, cli, api, agent, calendar, desktop, mobile, extension] }
          description: >-
            Filter report rows by time-entry source. `mobile` is time tracked on
            the first-party phone apps; servers that accept it advertise
            `team-report-source-mobile` in `X-Keito-Features`.
      responses:
        "200":
          description: Team time report
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      results:
                        type: array
                        items:
                          $ref: "#/components/schemas/TeamTimeResult"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

  /api/v2/reports/time/clients:
    get:
      tags: [Reports]
      summary: Time report by client
      operationId: getTimeReportByClients
      description: |
        The web Time Report's "Clients" breakdown for any internal member
        (feature flag `time-reports-v1`). Totals use the workspace time-rounding
        rule and the historical effective billing rate, so a client and the
        browser agree. Time on fixed-fee projects always counts towards hours
        and contributes to billable amounts only when `include_fixed_fee=true`.

        Who sees whose time follows the web report: a member sees their own
        entries, a manager additionally their managed projects and assigned
        teammates, and an owner or administrator the whole workspace. `user_id`
        is limited to `me` or the caller's own id unless the caller is an owner
        or administrator (403 `user_id must be "me" or your own user id`).
        External client members are refused (403). There is no plan gate.

        Amounts and rates (`currency`, `billable_amount`, `rate`,
        `billable_amounts`, `rates`, `summary.billable_amounts`) appear only when
        the caller may view billable rates; otherwise they are omitted, not
        zeroed, and `summary.amounts_visible` is false. `summary` is computed
        over every row of the report, before paging. Hours are decimal hours to
        two places. Rows are ordered by name.

        Not available to AI app (MCP connector) tokens or personal read-only
        sync keys.
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.keito.ai/api/v2/reports/time/clients?from=20260907&to=20260913&user_id=me" \
              -H "Authorization: Bearer $KEITO_API_KEY" \
              -H "Keito-Account-Id: $KEITO_ACCOUNT_ID"
      parameters:
        - $ref: "#/components/parameters/page"
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 2000, default: 100 }
        - name: from
          in: query
          required: true
          schema: { type: string, example: "20260907" }
          description: Inclusive start date, YYYYMMDD or YYYY-MM-DD.
        - name: to
          in: query
          required: true
          schema: { type: string, example: "20260913" }
          description: Inclusive end date, YYYYMMDD or YYYY-MM-DD; ranges are limited to 365 days.
        - name: user_id
          in: query
          schema: { type: string, example: me }
          description: Limit to one person's time. `me` is the caller; any other id needs owner or administrator access.
        - name: client_id
          in: query
          schema: { type: string }
          description: Limit to projects under one client.
        - name: project_id
          in: query
          schema: { type: string }
          description: Limit to one project.
        - name: include_fixed_fee
          in: query
          schema: { type: boolean, default: false }
          description: Price time on fixed-fee projects at billable rates. Hours always count.
        - name: active_only
          in: query
          schema: { type: boolean, default: false }
          description: Only time on active projects.
      responses:
        "200":
          description: Time report by client
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/HarvestReportPaginationEnvelope"
                  - type: object
                    required: [results, summary]
                    properties:
                      results:
                        type: array
                        items:
                          $ref: "#/components/schemas/TimeReportRow"
                      summary:
                        $ref: "#/components/schemas/TimeReportSummary"
              example:
                results:
                  - id: clxclientacme
                    name: Acme
                    total_hours: 3.25
                    billable_hours: 2.75
                    non_billable_hours: 0.5
                    fixed_fee_hours: 0
                    currency: GBP
                    billable_amount: 275
                    rate: 100
                    billable_amounts: [{ currency: GBP, amount: 275 }]
                    rates: [{ currency: GBP, rate: 100 }]
                  - id: clxclientglobex
                    name: Globex
                    total_hours: 2
                    billable_hours: 2
                    non_billable_hours: 0
                    fixed_fee_hours: 0
                    currency: USD
                    billable_amount: 160
                    rate: 80
                    billable_amounts: [{ currency: USD, amount: 160 }]
                    rates: [{ currency: USD, rate: 80 }]
                summary:
                  total_hours: 5.25
                  billable_hours: 4.75
                  non_billable_hours: 0.5
                  fixed_fee_hours: 0
                  billable_percent: 90.48
                  amounts_visible: true
                  billable_amounts:
                    - { currency: GBP, amount: 275 }
                    - { currency: USD, amount: 160 }
                  rounding:
                    mode: min_15
                    description: rounded to the nearest 15 minutes
                per_page: 100
                total_pages: 1
                total_entries: 2
                next_page: null
                previous_page: null
                page: 1
                links:
                  first: /api/v2/reports/time/clients?page=1&from=20260907&to=20260913&per_page=100
                  next: null
                  previous: null
                  last: /api/v2/reports/time/clients?page=1&from=20260907&to=20260913&per_page=100
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

  /api/v2/reports/time/projects:
    get:
      tags: [Reports]
      summary: Time report by project
      operationId: getTimeReportByProjects
      description: |
        The web Time Report's "Projects" breakdown for any internal member
        (feature flag `time-reports-v1`). Totals use the workspace time-rounding
        rule and the historical effective billing rate, so a client and the
        browser agree. Time on fixed-fee projects always counts towards hours
        and contributes to billable amounts only when `include_fixed_fee=true`.

        Who sees whose time follows the web report: a member sees their own
        entries, a manager additionally their managed projects and assigned
        teammates, and an owner or administrator the whole workspace. `user_id`
        is limited to `me` or the caller's own id unless the caller is an owner
        or administrator (403 `user_id must be "me" or your own user id`).
        External client members are refused (403). There is no plan gate.

        Amounts and rates (`currency`, `billable_amount`, `rate`,
        `billable_amounts`, `rates`, `summary.billable_amounts`) appear only when
        the caller may view billable rates; otherwise they are omitted, not
        zeroed, and `summary.amounts_visible` is false. `summary` is computed
        over every row of the report, before paging. Hours are decimal hours to
        two places. Rows are ordered by name. Project rows carry
        `client_id`, `client_name`, `code` and `is_fixed_fee`.

        Not available to AI app (MCP connector) tokens or personal read-only
        sync keys.
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.keito.ai/api/v2/reports/time/projects?from=20260907&to=20260913&user_id=me" \
              -H "Authorization: Bearer $KEITO_API_KEY" \
              -H "Keito-Account-Id: $KEITO_ACCOUNT_ID"
      parameters:
        - $ref: "#/components/parameters/page"
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 2000, default: 100 }
        - name: from
          in: query
          required: true
          schema: { type: string, example: "20260907" }
          description: Inclusive start date, YYYYMMDD or YYYY-MM-DD.
        - name: to
          in: query
          required: true
          schema: { type: string, example: "20260913" }
          description: Inclusive end date, YYYYMMDD or YYYY-MM-DD; ranges are limited to 365 days.
        - name: user_id
          in: query
          schema: { type: string, example: me }
          description: Limit to one person's time. `me` is the caller; any other id needs owner or administrator access.
        - name: client_id
          in: query
          schema: { type: string }
          description: Limit to projects under one client.
        - name: project_id
          in: query
          schema: { type: string }
          description: Limit to one project.
        - name: include_fixed_fee
          in: query
          schema: { type: boolean, default: false }
          description: Price time on fixed-fee projects at billable rates. Hours always count.
        - name: active_only
          in: query
          schema: { type: boolean, default: false }
          description: Only time on active projects.
      responses:
        "200":
          description: Time report by project
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/HarvestReportPaginationEnvelope"
                  - type: object
                    required: [results, summary]
                    properties:
                      results:
                        type: array
                        items:
                          $ref: "#/components/schemas/TimeReportProjectRow"
                      summary:
                        $ref: "#/components/schemas/TimeReportSummary"
              example:
                results:
                  - id: clxprojectacme
                    name: Acme Build
                    client_id: clxclientacme
                    client_name: Acme
                    code: ACME
                    is_fixed_fee: false
                    total_hours: 3.25
                    billable_hours: 2.75
                    non_billable_hours: 0.5
                    fixed_fee_hours: 0
                    currency: GBP
                    billable_amount: 275
                    rate: 100
                    billable_amounts: [{ currency: GBP, amount: 275 }]
                    rates: [{ currency: GBP, rate: 100 }]
                  - id: clxprojectglobex
                    name: Globex Rollout
                    client_id: clxclientglobex
                    client_name: Globex
                    code: GLX
                    is_fixed_fee: false
                    total_hours: 2
                    billable_hours: 2
                    non_billable_hours: 0
                    fixed_fee_hours: 0
                    currency: USD
                    billable_amount: 160
                    rate: 80
                    billable_amounts: [{ currency: USD, amount: 160 }]
                    rates: [{ currency: USD, rate: 80 }]
                summary:
                  total_hours: 5.25
                  billable_hours: 4.75
                  non_billable_hours: 0.5
                  fixed_fee_hours: 0
                  billable_percent: 90.48
                  amounts_visible: true
                  billable_amounts:
                    - { currency: GBP, amount: 275 }
                    - { currency: USD, amount: 160 }
                  rounding:
                    mode: min_15
                    description: rounded to the nearest 15 minutes
                per_page: 100
                total_pages: 1
                total_entries: 2
                next_page: null
                previous_page: null
                page: 1
                links:
                  first: /api/v2/reports/time/projects?page=1&from=20260907&to=20260913&per_page=100
                  next: null
                  previous: null
                  last: /api/v2/reports/time/projects?page=1&from=20260907&to=20260913&per_page=100
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

  /api/v2/reports/time/tasks:
    get:
      tags: [Reports]
      summary: Time report by task
      operationId: getTimeReportByTasks
      description: |
        The web Time Report's "Tasks" breakdown for any internal member
        (feature flag `time-reports-v1`). Totals use the workspace time-rounding
        rule and the historical effective billing rate, so a client and the
        browser agree. Time on fixed-fee projects always counts towards hours
        and contributes to billable amounts only when `include_fixed_fee=true`.

        Who sees whose time follows the web report: a member sees their own
        entries, a manager additionally their managed projects and assigned
        teammates, and an owner or administrator the whole workspace. `user_id`
        is limited to `me` or the caller's own id unless the caller is an owner
        or administrator (403 `user_id must be "me" or your own user id`).
        External client members are refused (403). There is no plan gate.

        Amounts and rates (`currency`, `billable_amount`, `rate`,
        `billable_amounts`, `rates`, `summary.billable_amounts`) appear only when
        the caller may view billable rates; otherwise they are omitted, not
        zeroed, and `summary.amounts_visible` is false. `summary` is computed
        over every row of the report, before paging. Hours are decimal hours to
        two places. Rows are ordered by name. Task rows carry
        `percent_of_total` (share of the report's hours); a task billed on
        projects for clients in different currencies has `currency`,
        `billable_amount` and `rate` null with the split in `billable_amounts`.

        Not available to AI app (MCP connector) tokens or personal read-only
        sync keys.
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.keito.ai/api/v2/reports/time/tasks?from=20260907&to=20260913&user_id=me" \
              -H "Authorization: Bearer $KEITO_API_KEY" \
              -H "Keito-Account-Id: $KEITO_ACCOUNT_ID"
      parameters:
        - $ref: "#/components/parameters/page"
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 2000, default: 100 }
        - name: from
          in: query
          required: true
          schema: { type: string, example: "20260907" }
          description: Inclusive start date, YYYYMMDD or YYYY-MM-DD.
        - name: to
          in: query
          required: true
          schema: { type: string, example: "20260913" }
          description: Inclusive end date, YYYYMMDD or YYYY-MM-DD; ranges are limited to 365 days.
        - name: user_id
          in: query
          schema: { type: string, example: me }
          description: Limit to one person's time. `me` is the caller; any other id needs owner or administrator access.
        - name: client_id
          in: query
          schema: { type: string }
          description: Limit to projects under one client.
        - name: project_id
          in: query
          schema: { type: string }
          description: Limit to one project.
        - name: include_fixed_fee
          in: query
          schema: { type: boolean, default: false }
          description: Price time on fixed-fee projects at billable rates. Hours always count.
        - name: active_only
          in: query
          schema: { type: boolean, default: false }
          description: Only time on active projects.
      responses:
        "200":
          description: Time report by task
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/HarvestReportPaginationEnvelope"
                  - type: object
                    required: [results, summary]
                    properties:
                      results:
                        type: array
                        items:
                          $ref: "#/components/schemas/TimeReportTaskRow"
                      summary:
                        $ref: "#/components/schemas/TimeReportSummary"
              example:
                results:
                  - id: clxtaskdesign
                    name: Design
                    percent_of_total: 52.38
                    total_hours: 2.75
                    billable_hours: 2.25
                    non_billable_hours: 0.5
                    fixed_fee_hours: 0
                    currency: GBP
                    billable_amount: 225
                    rate: 100
                    billable_amounts: [{ currency: GBP, amount: 225 }]
                    rates: [{ currency: GBP, rate: 100 }]
                  - id: clxtaskreview
                    name: Review
                    percent_of_total: 47.62
                    total_hours: 2.5
                    billable_hours: 2.5
                    non_billable_hours: 0
                    fixed_fee_hours: 0
                    currency: null
                    billable_amount: null
                    rate: null
                    billable_amounts: [{ currency: GBP, amount: 50 }, { currency: USD, amount: 160 }]
                    rates: [{ currency: GBP, rate: 100 }, { currency: USD, rate: 80 }]
                summary:
                  total_hours: 5.25
                  billable_hours: 4.75
                  non_billable_hours: 0.5
                  fixed_fee_hours: 0
                  billable_percent: 90.48
                  amounts_visible: true
                  billable_amounts:
                    - { currency: GBP, amount: 275 }
                    - { currency: USD, amount: 160 }
                  rounding:
                    mode: min_15
                    description: rounded to the nearest 15 minutes
                per_page: 100
                total_pages: 1
                total_entries: 2
                next_page: null
                previous_page: null
                page: 1
                links:
                  first: /api/v2/reports/time/tasks?page=1&from=20260907&to=20260913&per_page=100
                  next: null
                  previous: null
                  last: /api/v2/reports/time/tasks?page=1&from=20260907&to=20260913&per_page=100
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/WorkspaceMismatch" }

  # ─── WORKSPACE BACKUP ────────────────────────────────────────
  /api/v2/retainers:
    get:
      tags: [Retainers]
      summary: List retainers with corrected ledger balances
      operationId: listRetainers
      description: |
        Read-only financial access for owners, administrators, and managers with
        invoice-view permission, using normal full-access API credentials or a
        WorkOS JWT. MCP connector, personal read-only sync, and scoped automation
        credentials do not gain access to retainers.

        Balances use the current corrected Keito ledger, excluding currently
        voided activities. Optional `as_of` includes every activity dated on or
        before that calendar date, including corrections entered later. It does
        not reconstruct what was recorded at the time. Without `as_of`, all
        current non-voided activities are included, even future-dated activities.
        Status and other retainer metadata are current, regardless of `as_of`.
        Amounts are exact decimal strings in each retainer's currency. Do not sum
        different currencies. Imported history may be incomplete.

        Retainers are ordered by creation time, then ID. Each response is a
        consistent read; separate pages are not an immutable snapshot. Follow all
        pages and avoid concurrent ledger changes while collecting month-end
        figures. Unknown or repeated query parameters are rejected.
      parameters:
        - $ref: "#/components/parameters/page"
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 2000, default: 100 }
        - name: client_id
          in: query
          schema: { type: string, minLength: 1, maxLength: 191 }
        - name: status
          in: query
          description: Current retainer status; omitted includes both states.
          schema: { type: string, enum: [ACTIVE, ARCHIVED] }
        - $ref: "#/components/parameters/retainerAsOf"
      responses:
        "200":
          description: Paginated retainers and their computed balances
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    required: [retainers]
                    properties:
                      retainers:
                        type: array
                        items: { $ref: "#/components/schemas/RetainerBalance" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/retainers/{retainerId}:
    get:
      tags: [Retainers]
      summary: Get a retainer with its corrected ledger balance
      operationId: getRetainer
      description: |
        Uses the same financial permission and current-corrected-ledger contract
        as the retainer list. Returns 404 for an unknown retainer or a retainer
        outside the selected account. Only `as_of` is accepted as a query
        parameter; repeated or unknown parameters are rejected. This endpoint
        does not create, change, or archive retainers.
      parameters:
        - name: retainerId
          in: path
          required: true
          schema: { type: string, minLength: 1, maxLength: 191 }
        - $ref: "#/components/parameters/retainerAsOf"
      responses:
        "200":
          description: Retainer and its computed balance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RetainerBalance" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/backup:
    get:
      tags: [Backup]
      summary: Get the workspace backup manifest
      operationId: getWorkspaceBackupManifest
      description: |
        Returns the versioned list of portable workspace resources and record counts.
        Only workspace owners and administrators can use backup endpoints. Download
        every page of every resource to produce a complete portable backup.
      responses:
        "200":
          description: Backup manifest
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BackupManifest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v2/backup/{resource}:
    get:
      tags: [Backup]
      summary: Download one page of a backup resource
      operationId: getWorkspaceBackupResource
      description: |
        Returns records in deterministic creation order. Decimal and bigint values
        are strings to preserve precision. This endpoint has its own versioned backup
        contract and does not change the response shape of the normal API v2 endpoints.

        For `resource=retainers`, owners and administrators with full-access API
        credentials can read the current retainer records and their nested
        activities, including voided activities. Use `scope` and
        `coverage_project_id` to interpret project versus client-wide coverage;
        client-wide retainers have a null `coverage_project_id`. The legacy
        project anchor is not the coverage definition. Current statuses are
        `ACTIVE` and `ARCHIVED`, and activity types are `ADD_FUNDS`, `DRAW`, and
        signed `ADJUSTMENT` values. Amounts are decimal strings.

        Activity `balance_after` values are stored snapshots and are not
        recalculated across the ledger after backdating, edits, or voids. For
        corrected balances, sum current non-voided activity amounts by type and
        activity date, or use `GET /api/v2/retainers?as_of=YYYY-MM-DD`.

        Only the pagination query parameters below are supported. This backup
        does not calculate retainer balances, filter by `client_id` or `status`,
        or implement `as_of`. It is a current export, not an immutable historical
        snapshot: activities can be corrected, voided, or removed later. Follow
        every page before reconciling an export. Personal read-only sync keys
        and MCP connector credentials cannot access this backup endpoint.
      parameters:
        - name: resource
          in: path
          required: true
          schema:
            type: string
            enum: [workspace, members, roles, teammate_assignments, clients, projects, tasks, time_entries, expense_categories, expenses, invoice_settings, bank_accounts, invoice_item_categories, invoices, estimates, retainers, recurring_invoices, planning_assignments, planning_placeholders, planning_time_off, planning_working_patterns, planning_milestones, time_off_types, time_off_allowances, company_holidays, saved_reports, invitations, webhook_endpoints, audit_logs]
        - $ref: "#/components/parameters/page"
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
          description: Backup pages are capped at 500 records because records can contain nested detail.
      responses:
        "200":
          description: Paginated backup records
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BackupResourcePage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/backup/files/invoice_expense_receipts/{id}:
    get:
      tags: [Backup]
      summary: Download an invoice's immutable expense receipt copy
      operationId: downloadBackupInvoiceExpenseReceipt
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Receipt file
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key (kto_...) or WorkOS JWT

  parameters:
    retainerAsOf:
      name: as_of
      in: query
      description: |
        Inclusive activity calendar date in YYYY-MM-DD format (UTC). Must be a
        real date. Late-entered activities dated through this date count;
        currently voided activities do not. Omitted includes all current activity
        dates. This is a corrected-ledger cutoff, not a recorded-at-time snapshot.
      schema: { type: string, format: date, pattern: '^\d{4}-\d{2}-\d{2}$', example: "2026-10-31" }
    PathId:
      name: id
      in: path
      required: true
      schema: { type: string }
    page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    per_page:
      name: per_page
      in: query
      schema: { type: integer, minimum: 1, maximum: 2000, default: 100 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Unique key for safely retrying the same mutation for 24 hours.
      schema:
        type: string
        minLength: 8
        maxLength: 255
        pattern: '^[A-Za-z0-9._-]+$'
    OptionalIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Unique key for safely retrying the same mutation for 24 hours.
      schema:
        type: string
        minLength: 8
        maxLength: 255
        pattern: '^[A-Za-z0-9._-]+$'

  headers:
    IdempotencyReplayed:
      description: Present (`true`) when the response was replayed from an earlier request with the same `Idempotency-Key`.
      schema: { type: string, enum: ["true"] }

  responses:
    IdempotencyConflict:
      description: |
        The `Idempotency-Key` was already used for a different request (`key_reused`), the original
        request ran under different permissions (`authorization_changed`), is still processing
        (`in_progress`), or never finished (`idempotency_unresolved` — read the resource before
        retrying with a new key).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    IdempotencyOrDomainConflict:
      description: |
        A conflict described by `message` (for example an approved, locked or already-running
        entry), or an `Idempotency-Key` conflict (`key_reused`, `authorization_changed`,
        `in_progress`, `idempotency_unresolved`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    IdempotencyDomainOrWorkspaceConflict:
      description: |
        A conflict described by `message` (for example `running_timer_conflict`, a timer that is
        not running, or an approved-week lock), an `Idempotency-Key` conflict (`key_reused`,
        `authorization_changed`, `in_progress`, `idempotency_unresolved`), or — for an AI app
        connection whose `Keito-Account-Id` names a workspace other than the one it is bound
        to — `workspace_mismatch` with `bound_company_id`.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: "#/components/schemas/Error"
              - $ref: "#/components/schemas/WorkspaceMismatch"
    WorkspaceMismatch:
      description: |
        The request came from an AI app connection (MCP connector token) that is bound to one
        workspace, and `Keito-Account-Id` named another. Every route an AI app may reach returns
        this; the app should re-read `GET /api/v2/users/me` and use `bound_company_id`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/WorkspaceMismatch" }
    ForbiddenOrCapabilityRequired:
      description: |
        Forbidden by the route's role or permission rule, or — for an AI app
        connection (MCP connector token) whose member lacks the capability the
        route needs — `error: mcp_capability_required` with the `capability`
        name as it appears under `capabilities` on `GET /api/v2/users/me`.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: "#/components/schemas/Error"
              - $ref: "#/components/schemas/McpCapabilityRequired"
    InvoiceConfirmationOrIdempotencyConflict:
      description: |
        `confirmation_mismatch`: the invoice's current amount, currency or
        recipient count differs from `confirm`; `expected` and `received` say
        how, and nothing changed. Or an `Idempotency-Key` conflict
        (`key_reused`, `authorization_changed`, `in_progress`, `idempotency_unresolved`).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: "#/components/schemas/Error"
              - $ref: "#/components/schemas/InvoiceConfirmationMismatch"
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Forbidden
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Conflict
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    # ── Common ─────────────────────────────────────────────────
    Error:
      type: object
      properties:
        error:
          type: string
          example: bad request
        error_description:
          type: string
          example: project_id is required
        message:
          type: string
          example: project_id is required

    WorkspaceMismatch:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          required: [error, bound_company_id]
          properties:
            error: { type: string, enum: [workspace_mismatch] }
            bound_company_id:
              type: string
              description: The workspace the AI app connection is bound to.

    McpCapabilityRequired:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          required: [error, capability]
          properties:
            error: { type: string, enum: [mcp_capability_required] }
            capability:
              type: string
              description: The `capabilities` key on `GET /api/v2/users/me` the member must hold.
              enum:
                - can_track_time
                - can_track_expenses
                - can_edit_time_billability
                - can_create_field_clients
                - can_view_team_time
                - can_view_invoices
                - can_create_invoices
                - can_send_invoices
                - can_record_invoice_payments
                - can_manage_estimates
                - can_create_projects
                - can_view_financial_reports
                - can_view_billable_rates

    InvoiceConfirmationMismatch:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          required: [error, expected, received]
          properties:
            error: { type: string, enum: [confirmation_mismatch] }
            expected:
              type: object
              description: The invoice's current `amount` (two-decimal string) and `currency`, or `recipient_count`.
              additionalProperties: true
            received:
              type: object
              additionalProperties: true

    UserCapabilities:
      type: object
      description: >-
        What the authenticated member may do through API v2. `can_view_invoices`
        follows the granular view-invoices permission (managers who can edit or
        send invoices always hold it too). AI app connections are refused with
        `mcp_capability_required` on routes whose capability is false here.
      required:
        - can_track_time
        - can_track_expenses
        - can_edit_time_billability
        - can_create_field_clients
        - can_view_team_time
        - can_view_invoices
        - can_create_invoices
        - can_send_invoices
        - can_record_invoice_payments
        - can_manage_estimates
        - can_create_projects
        - can_view_financial_reports
        - can_view_billable_rates
      properties:
        can_track_time: { type: boolean }
        can_track_expenses: { type: boolean }
        can_edit_time_billability: { type: boolean }
        can_create_field_clients: { type: boolean, description: "Manager with `canEditClientsAndTasks`, or owner/admin." }
        can_view_team_time: { type: boolean }
        can_view_invoices: { type: boolean, description: "Manager with `canViewInvoices`, or owner/admin." }
        can_create_invoices: { type: boolean, description: "Manager with `canEditDraftInvoices`, or owner/admin." }
        can_send_invoices: { type: boolean, description: "Manager with `canManageInvoices`, or owner/admin." }
        can_record_invoice_payments: { type: boolean }
        can_manage_estimates: { type: boolean, description: "Manager with `canManageEstimates`, or owner/admin." }
        can_create_projects: { type: boolean }
        can_view_financial_reports: { type: boolean, description: "Manager with both `canViewBillableRates` and `canViewInvoices`, or owner/admin." }
        can_view_billable_rates: { type: boolean, description: "Manager with `canViewBillableRates`, or owner/admin. The same grant that reveals `hourly_rate`, task rates and `default_hourly_rate` on reads." }

    RunningTimerConflict:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            running_entry:
              $ref: "#/components/schemas/TimeEntry"
            running_entry_count:
              type: integer
              minimum: 1

    PaginationLinks:
      type: object
      properties:
        first: { type: string }
        next: { type: string, nullable: true }
        previous: { type: string, nullable: true }
        last: { type: string }

    PaginationEnvelope:
      type: object
      properties:
        per_page: { type: integer }
        total_pages: { type: integer }
        total_entries: { type: integer }
        page: { type: integer }
        links: { $ref: "#/components/schemas/PaginationLinks" }

    BackupManifest:
      type: object
      required: [backup_schema_version, generated_at, workspace, resources, excluded, notes]
      properties:
        backup_schema_version: { type: integer, example: 1 }
        generated_at: { type: string, format: date-time }
        workspace:
          type: object
          required: [id, name]
          properties:
            id: { type: string }
            name: { type: string }
        resources:
          type: array
          items:
            type: object
            required: [name, description, total_entries, endpoint]
            properties:
              name: { type: string }
              description: { type: string }
              total_entries: { type: integer }
              endpoint: { type: string }
        excluded:
          type: array
          items: { type: string }
        notes:
          type: array
          items: { type: string }

    BackupResourcePage:
      allOf:
        - $ref: "#/components/schemas/PaginationEnvelope"
        - type: object
          required: [backup_schema_version, resource, records]
          properties:
            backup_schema_version: { type: integer, example: 1 }
            resource: { type: string }
            records:
              type: array
              items:
                type: object
                additionalProperties: true
              description: Resource-specific backup records with snake_case keys.

    IdName:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
          nullable: true

    Metadata:
      type: object
      nullable: true
      additionalProperties: true
      description: Arbitrary JSON object, max 4 KB serialized.

    ExternalReference:
      type: object
      required: [id]
      nullable: true
      description: |
        Links a time entry to an object in another system, such as a ticket, issue or task.
        Keito shows it as a linked chip on the Day view, in the time entry dialog and in the
        Detailed Time report; the chip opens `permalink` and is labelled with `display_name`
        (falling back to `id`). `id` is required whenever `external_reference` is sent; every
        other field is optional. Send `null` (or use `DELETE .../external_reference`) to remove it.
      properties:
        id: { type: string, maxLength: 255, description: Identifier of the external object. Required. }
        group_id: { type: string, maxLength: 255, nullable: true, description: "Optional group or scope identifier, such as a project or board." }
        account_id: { type: string, maxLength: 255, nullable: true, description: Optional account or tenant identifier in the external system. }
        permalink:
          type: string
          format: uri
          maxLength: 2048
          nullable: true
          description: Link to the external object. Must be an absolute `http` or `https` URL.
        service: { type: string, maxLength: 100, nullable: true, example: zendesk.com }
        service_name: { type: string, maxLength: 255, nullable: true, example: Zendesk }
        display_name: { type: string, maxLength: 255, nullable: true, example: "#4821 Invoice export fails", description: Human-readable label for the chip. }
        group_name: { type: string, maxLength: 255, nullable: true, example: Support queue, description: Human-readable name for `group_id`. }
        parent_id: { type: string, maxLength: 255, nullable: true, description: "Optional parent object identifier, such as the epic of an issue." }

    ExternalReferenceSummary:
      type: object
      required: [total_hours, my_hours, entry_count, last_used, my_running_entry_id]
      properties:
        total_hours: { type: number }
        my_hours: { type: number }
        entry_count: { type: integer }
        last_used:
          type: object
          nullable: true
          properties:
            project_id: { type: string }
            project_name: { type: string }
            task_id: { type: string }
            task_name: { type: string }
        my_running_entry_id: { type: string, nullable: true }

    # ── Time Entries ───────────────────────────────────────────
    SyncTaskAssignment:
      type: object
      nullable: true
      required: [id, billable, is_active]
      description: Minimal project-specific task assignment returned to personal read-only sync credentials. Rates, budgets, and aggregate totals are excluded.
      properties:
        id: { type: string }
        billable: { type: boolean }
        is_active: { type: boolean }

    TimeEntry:
      type: object
      properties:
        id: { type: string }
        user_id: { type: string }
        project_id: { type: string }
        task_id: { type: string }
        task_assignment: { $ref: "#/components/schemas/SyncTaskAssignment" }
        user: { $ref: "#/components/schemas/IdName" }
        project: { $ref: "#/components/schemas/IdName" }
        task: { $ref: "#/components/schemas/IdName" }
        spent_date: { type: string, format: date }
        hours:
          type: number
          description: Legacy stored decimal-hours value. Existing integrations can continue using this field unchanged.
        duration_seconds:
          type: integer
          minimum: 0
          description: Actual saved duration in seconds before workspace rounding.
        rounded_hours:
          type: number
          minimum: 0
          description: Hours after applying the workspace time-rounding policy; used by totals, reports, budgets, and new invoice quantities.
        notes: { type: string, nullable: true }
        is_running: { type: boolean }
        timer_started_at: { type: string, format: date-time, nullable: true }
        started_time: { type: string, nullable: true, description: "HH:mm" }
        ended_time: { type: string, nullable: true, description: "HH:mm" }
        approval_status:
          type: string
          enum: [unsubmitted, submitted, approved, rejected]
          description: |
            Keito timesheet approval state. This is the authoritative approval field: time approved
            in Keito reports `approved` (and `is_locked: true`) while `is_closed` stays `false`.
            Omitted from personal read-only sync responses.
        is_locked:
          type: boolean
          description: The entry cannot be edited, for example because it was approved or invoiced.
        is_closed:
          type: boolean
          description: |
            Harvest compatibility flag. `true` only for entries imported from Harvest that Harvest
            had approved. It is not set by Keito's own approval workflow; use `approval_status`.
        is_billed: { type: boolean }
        invoice_id:
          type: string
          nullable: true
          readOnly: true
          description: Keito invoice CUID linked directly or through a grouped invoice line. Null when no local invoice line links the entry, including manually billed or historical imported entries without a link. Never a Harvest invoice ID. Omitted from personal read-only sync responses. Invoice access permissions are unchanged.
        billable: { type: boolean }
        budgeted: { type: boolean }
        billable_rate:
          type: number
          nullable: true
          description: Stored historical billable rate. Omitted unless authorized for this project.
        source:
          type: string
          enum: [web, cli, api, agent, calendar, desktop, mobile, integration, extension]
        metadata: { $ref: "#/components/schemas/Metadata" }
        external_reference: { $ref: "#/components/schemas/ExternalReference" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ApprovalSubmitRequest:
      type: object
      additionalProperties: false
      required: [from, to]
      properties:
        from: { type: string, format: date, description: First day of the period (YYYY-MM-DD). }
        to: { type: string, format: date, description: "Last day of the period (YYYY-MM-DD), at most 366 days after `from`." }
        fingerprint:
          type: string
          pattern: "^[a-f0-9]{64}$"
          description: Optional snapshot from a previous submit response; the request fails if the period changed since.

    ApprovalSubmitResult:
      type: object
      required: [submitted_count, submitted_seconds, fingerprint]
      properties:
        submitted_count: { type: integer, minimum: 0 }
        submitted_seconds: { type: integer, minimum: 0 }
        fingerprint: { type: string, description: Snapshot of the period that was submitted. }

    ApprovalReviewScope:
      type: object
      required: [user_id, from, to]
      properties:
        user_id: { type: string, description: The person whose time is reviewed. }
        from: { type: string, format: date }
        to: { type: string, format: date, description: "At most 366 days after `from`." }
        project_id: { type: string, description: Limit the review to one project. }

    ApprovalApproveRequest:
      allOf:
        - $ref: "#/components/schemas/ApprovalReviewScope"
        - type: object
          properties:
            preflight_fingerprint:
              type: string
              pattern: "^[a-f0-9]{64}$"
              description: "`preflight.fingerprint` from a 409 response. Required when the scope has warnings."
            acknowledged_warning_codes:
              type: array
              maxItems: 3
              items: { type: string, enum: [HOURS_BELOW_EXPECTED, TIME_OUTSIDE_WORKING_CAPACITY, OVERLAPPING_TIMESTAMP_ENTRIES] }
              description: Every current warning code must be listed.
      description: Unknown properties are rejected.

    ApprovalRejectRequest:
      allOf:
        - $ref: "#/components/schemas/ApprovalReviewScope"
        - type: object
          properties:
            reason: { type: string, maxLength: 2000, description: Sent to the person in the rejection notification. }
      description: Unknown properties are rejected.

    ApprovalActionResult:
      type: object
      required: [time_entry_count, expense_count]
      properties:
        time_entry_count: { type: integer, minimum: 0 }
        expense_count: { type: integer, minimum: 0 }

    ApprovalApproveResult:
      allOf:
        - $ref: "#/components/schemas/ApprovalActionResult"
        - type: object
          required: [preflight_fingerprint, acknowledged_warning_codes]
          properties:
            preflight_fingerprint: { type: string }
            acknowledged_warning_codes: { type: array, items: { type: string } }

    ApprovalPreflightIssue:
      type: object
      properties:
        code:
          type: string
          enum: [PARTIAL_SUBMISSION, RUNNING_TIMER, MISSING_REQUIRED_NOTES, INVALID_WORK_CONTEXT, HOURS_BELOW_EXPECTED, TIME_OUTSIDE_WORKING_CAPACITY, OVERLAPPING_TIMESTAMP_ENTRIES]
        severity: { type: string, enum: [blocker, warning] }
        count: { type: integer }
        summary: { type: string }
        dates: { type: array, items: { type: string, format: date } }
        records:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              type: { type: string, enum: [time_entry, expense] }
              date: { type: string, format: date }

    ApprovalPreflight:
      type: object
      properties:
        fingerprint: { type: string }
        can_approve: { type: boolean }
        submitted_time_entry_count: { type: integer }
        submitted_expense_count: { type: integer }
        blockers: { type: array, items: { $ref: "#/components/schemas/ApprovalPreflightIssue" } }
        warnings: { type: array, items: { $ref: "#/components/schemas/ApprovalPreflightIssue" } }

    ApprovalConflict:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            error:
              type: string
              enum: [approval_blocked, approval_warnings_unacknowledged, approval_preflight_stale, approval_conflict]
            preflight: { $ref: "#/components/schemas/ApprovalPreflight" }

    TimeEntryCreate:
      type: object
      required: [project_id, task_id, spent_date]
      properties:
        project_id: { type: string }
        task_id: { type: string }
        spent_date: { type: string, format: date }
        user_id: { type: string, description: Managers can create entries for other users. }
        hours: { type: number, minimum: 0, maximum: 24 }
        notes: { type: string }
        billable: { type: boolean }
        is_running: { type: boolean, description: "When true without started_time, the timer starts now and uses the current date in the user's effective timezone, regardless of spent_date. To backdate a running timer, provide both spent_date and started_time; past starts require the workspace setting and future starts are rejected." }
        replace_running: { type: boolean, description: "When true with is_running, stop any currently running timer before creating this one. When false or omitted with is_running, the API returns 409 if another timer is already running." }
        started_time: { type: string, description: "HH:mm. When supplied with is_running, combines with spent_date to resolve an explicit timer start; when omitted, a running timer starts now. Otherwise used for start/end time entries." }
        ended_time: { type: string, description: "HH:mm" }
        source:
          type: string
          enum: [web, cli, api, agent, calendar, desktop, mobile, integration]
        metadata: { $ref: "#/components/schemas/Metadata" }
        external_reference: { $ref: "#/components/schemas/ExternalReference" }

    TimeEntryUpdate:
      type: object
      properties:
        project_id: { type: string }
        task_id: { type: string }
        spent_date: { type: string, format: date }
        hours: { type: number, minimum: 0, maximum: 24 }
        notes: { type: string, nullable: true }
        billable: { type: boolean }
        started_time: { type: string, nullable: true }
        ended_time: { type: string, nullable: true }
        metadata:
          $ref: "#/components/schemas/Metadata"
          description: Merged into existing metadata. Set to null to clear.
        external_reference:
          $ref: "#/components/schemas/ExternalReference"
          description: Replaces the external reference. Set to null to remove it.

    # ── Expenses ───────────────────────────────────────────────
    Expense:
      type: object
      properties:
        id: { type: string }
        user_id: { type: string }
        project_id: { type: string }
        expense_category_id: { type: string }
        user: { $ref: "#/components/schemas/IdName" }
        project: { $ref: "#/components/schemas/IdName" }
        expense_category: { $ref: "#/components/schemas/IdName" }
        spent_date: { type: string, format: date }
        total_cost: { type: number }
        currency:
          type: string
          nullable: true
          description: ISO 4217 code `total_cost` is denominated in — the client's currency when the project has one, else the workspace currency.
        units: { type: integer, nullable: true }
        notes: { type: string, nullable: true }
        billable: { type: boolean }
        receipt_file_name: { type: string, nullable: true }
        receipt_content_type: { type: string, nullable: true }
        receipt_url: { type: string, nullable: true }
        attach_receipt_to_invoice:
          type: boolean
          description: Whether this receipt should be included with client invoice emails when the expense is invoiced.
        is_locked: { type: boolean }
        is_billed: { type: boolean }
        invoice_id:
          type: string
          nullable: true
          readOnly: true
          description: Keito invoice CUID linked directly or through a grouped invoice line. Null when no local invoice line links the expense, including manually billed or historical imported expenses without a link. Never a Harvest invoice ID. Invoice access permissions are unchanged.
        approval_status:
          type: string
          enum: [unsubmitted, submitted, approved, rejected]
        source:
          type: string
          enum: [web, cli, api, agent]
        metadata: { $ref: "#/components/schemas/Metadata" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ExpenseCategory:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        unit_name: { type: string, nullable: true }
        unit_price: { type: number, nullable: true }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ExpenseUpdate:
      type: object
      description: Every field is optional; at least one is required. Unit-priced categories take `units`, fixed-price categories take `total_cost`.
      properties:
        project_id: { type: string }
        expense_category_id: { type: string }
        spent_date: { type: string, format: date }
        total_cost: { type: number, nullable: true }
        units: { type: number, nullable: true }
        adjustment_reason:
          type: string
          enum: [returned_materials, vendor_refund, correction, imported_adjustment, other]
        notes: { type: string, nullable: true }
        billable: { type: boolean }
        attach_receipt_to_invoice: { type: boolean }
        metadata: { $ref: "#/components/schemas/Metadata" }

    ExpenseCreate:
      type: object
      required: [project_id, expense_category_id, spent_date]
      properties:
        project_id: { type: string }
        expense_category_id: { type: string }
        spent_date: { type: string, format: date }
        total_cost: { type: number, minimum: 0, description: Either total_cost or units required. }
        units: { type: integer, minimum: 1, description: Either total_cost or units required. }
        notes: { type: string }
        billable: { type: boolean }
        attach_receipt_to_invoice:
          type: boolean
          default: true
        source:
          type: string
          enum: [web, cli, api, agent]
        metadata: { $ref: "#/components/schemas/Metadata" }

    # ── Projects ───────────────────────────────────────────────
    Project:
      type: object
      properties:
        id: { type: string }
        client: { $ref: "#/components/schemas/IdName" }
        owner_id: { type: string, nullable: true }
        owner: { $ref: "#/components/schemas/RecordOwner" }
        name: { type: string }
        code: { type: string, nullable: true }
        is_active: { type: boolean }
        can_track_time:
          type: boolean
          description: >-
            Whether the bound user may put new time on this project: false for
            archived projects, true for owners and administrators on any active
            project, otherwise the user's assignment must allow time tracking.
            Servers that return it advertise `project-can-track-time` in
            `X-Keito-Features`. Absent from the personal-sync representation.
        is_billable: { type: boolean }
        is_fixed_fee: { type: boolean }
        bill_by: { type: string }
        hourly_rate:
          type: number
          nullable: true
          description: Omitted unless authorized for this project.
        fee: { type: number, nullable: true }
        budget_by: { type: string }
        budget: { type: number, nullable: true }
        budget_is_monthly: { type: boolean }
        budget_counts_billable_hours_only:
          type: boolean
          default: false
          description: When true, Total Project Hours budgets count only billable time. Ignored by other budget types.
        notify_when_over_budget: { type: boolean }
        over_budget_notification_percentage: { type: number, nullable: true }
        show_budget_to_all: { type: boolean }
        cost_budget: { type: number, nullable: true }
        cost_budget_include_expenses: { type: boolean }
        starts_on: { type: string, format: date, nullable: true }
        ends_on: { type: string, format: date, nullable: true }
        notes: { type: string, nullable: true }
        tasks:
          type: array
          description: Active project task assignments. Task budgets are omitted when the caller cannot view the project budget.
          items:
            $ref: "#/components/schemas/Task"
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProjectTemplate:
      type: object
      required: [id, name, project_name, client_id, task_count, people_count, created_at, updated_at]
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        project_name:
          type: string
          description: Default name offered for projects created from the template.
        client_id:
          type: string
          nullable: true
          description: Client the template is tied to, or null when it applies to any client.
        client_name: { type: string, nullable: true }
        is_billable: { type: boolean }
        is_fixed_fee: { type: boolean }
        bill_by: { type: string, enum: [PROJECT, TASKS, PEOPLE, NONE] }
        budget_by: { type: string, enum: [PROJECT, PROJECT_HOURS, PROJECT_COST, TASK, TASK_FEES, PERSON, NONE] }
        task_count: { type: integer }
        people_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProjectCreate:
      type: object
      additionalProperties: false
      required: [name]
      description: "`client_id` is required unless `template_id` names a template tied to a client."
      properties:
        template_id:
          type: string
          description: Project template ID from GET /api/v2/project_templates. Only name, client_id, code, notes, owner_id, starts_on and ends_on may accompany it.
        client_id: { type: string }
        owner_id:
          type: string
          description: Eligible workspace member ID. Defaults to the authenticated caller.
        name: { type: string, minLength: 1, maxLength: 255 }
        code: { type: string, maxLength: 50 }
        notes: { type: string, maxLength: 65535 }
        is_billable: { type: boolean, default: true }
        bill_by:
          type: string
          enum: [PROJECT, TASKS, PEOPLE, NONE]
          description: Defaults to PROJECT for billable projects and NONE for non-billable projects.
        budget_by:
          type: string
          enum: [PROJECT, PROJECT_HOURS, PROJECT_COST, TASK, TASK_FEES, PERSON, NONE]
          default: NONE
        is_fixed_fee: { type: boolean, default: false }
        hourly_rate: { type: number, minimum: 0 }
        fee:
          type: number
          minimum: 0
          maximum: 99999999.99
          description: Required when is_fixed_fee is true.
        budget: { type: number, minimum: 0, maximum: 99999999.99 }
        cost_budget: { type: number, minimum: 0, maximum: 99999999.99 }
        budget_is_monthly: { type: boolean }
        budget_counts_billable_hours_only:
          type: boolean
          default: false
          description: When true, Total Project Hours budgets count only billable time. Ignored by other budget types.
        cost_budget_include_expenses: { type: boolean }
        notify_when_over_budget: { type: boolean }
        over_budget_notification_percentage: { type: number, minimum: 0, maximum: 100 }
        show_budget_to_all: { type: boolean }
        starts_on: { type: string, format: date }
        ends_on: { type: string, format: date, description: Must be on or after starts_on. }
        task_ids:
          type: array
          items: { type: string }
          description: >-
            Active workspace task IDs to assign. Omit to assign default tasks; pass an empty array
            to assign none. Each task's workspace default rate is converted into the client's
            currency when the two differ.

    ProjectUpdate:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        client_id: { type: string }
        name: { type: string, minLength: 1, maxLength: 255 }
        code: { type: string, maxLength: 50, nullable: true }
        notes: { type: string, maxLength: 65535, nullable: true }
        is_active: { type: boolean }
        is_billable: { type: boolean }
        bill_by: { type: string, enum: [PROJECT, TASKS, PEOPLE, NONE] }
        budget_by: { type: string, enum: [PROJECT, PROJECT_HOURS, PROJECT_COST, TASK, TASK_FEES, PERSON, NONE] }
        is_fixed_fee: { type: boolean }
        hourly_rate: { type: number, minimum: 0, maximum: 99999999.99 }
        fee: { type: number, minimum: 0, maximum: 99999999.99 }
        budget: { type: number, minimum: 0, maximum: 99999999.99 }
        cost_budget: { type: number, minimum: 0, maximum: 99999999.99 }
        budget_is_monthly: { type: boolean }
        budget_counts_billable_hours_only: { type: boolean }
        cost_budget_include_expenses: { type: boolean }
        notify_when_over_budget: { type: boolean }
        over_budget_notification_percentage: { type: number, minimum: 0, maximum: 100 }
        show_budget_to_all: { type: boolean }
        starts_on: { type: string, format: date }
        ends_on: { type: string, format: date, description: Must be on or after the effective starts_on. }

    RetainerBalance:
      type: object
      additionalProperties: false
      required: [id, name, client_id, project_id, scope, currency, status, created_at, updated_at, funded, drawn, adjustments, remaining, as_of, balance_basis, activity_date_basis, status_basis]
      properties:
        id: { type: string }
        name: { type: string, nullable: true }
        client_id: { type: string }
        project_id:
          type: string
          nullable: true
          description: Covered project for PROJECT scope; null for CLIENT scope. The legacy project anchor is never used as coverage.
        scope: { type: string, enum: [PROJECT, CLIENT] }
        currency: { type: string, description: Retainer currency; balances are never converted or combined across currencies. }
        status: { type: string, enum: [ACTIVE, ARCHIVED], description: Current status even when as_of is supplied. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        funded: { type: string, pattern: '^-?\d+\.\d{2}$', example: "1000.25", description: Sum of included ADD_FUNDS amounts. }
        drawn: { type: string, pattern: '^-?\d+\.\d{2}$', example: "250.10", description: Sum of included DRAW amounts. }
        adjustments: { type: string, pattern: '^-?\d+\.\d{2}$', example: "-10.05", description: Signed sum of included ADJUSTMENT amounts. }
        remaining: { type: string, pattern: '^-?\d+\.\d{2}$', example: "740.10", description: Funded plus adjustments minus drawn; may be negative. }
        as_of: { type: string, format: date, nullable: true, description: Requested inclusive cutoff; null means all current activity dates. }
        balance_basis: { type: string, enum: [current_corrected_ledger] }
        activity_date_basis: { type: string, enum: [UTC] }
        status_basis: { type: string, enum: [current] }

    # ── Clients ────────────────────────────────────────────────
    Client:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        address: { type: string, nullable: true }
        currency: { type: string }
        is_active: { type: boolean }
        payment_terms: { type: string }
        payment_days: { type: integer, nullable: true }
        tax: { type: number, nullable: true }
        tax2: { type: number, nullable: true }
        discount: { type: number, nullable: true }
        statement_key: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ClientMinimal:
      type: object
      description: The roster shape returned for `GET /api/v2/clients?fields=minimal`.
      additionalProperties: false
      required: [id, name, currency, is_active]
      properties:
        id: { type: string }
        name: { type: string }
        currency: { type: string, description: ISO 4217 code the client is billed in. }
        is_active: { type: boolean }

    ClientCreate:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string }
        address: { type: string }
        currency: { type: string, description: 3-letter ISO code. Defaults to company currency. }
        payment_terms:
          type: string
          enum: [upon_receipt, net_15, net_30, net_45, net_60, custom]
        payment_days: { type: integer }
        tax: { type: number }
        tax2: { type: number }
        discount: { type: number }
        is_active: { type: boolean }

    ClientUpdate:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name: { type: string }
        address: { type: string, nullable: true }
        currency: { type: string, description: 3-letter ISO code. }
        payment_terms:
          type: string
          enum: [company_default, upon_receipt, net_15, net_30, net_45, net_60, custom]
        payment_days: { type: integer, nullable: true }
        tax: { type: number, nullable: true }
        tax2: { type: number, nullable: true }
        discount: { type: number, nullable: true }
        is_active: { type: boolean }

    # ── Contacts ───────────────────────────────────────────────
    Contact:
      type: object
      properties:
        id: { type: string }
        client: { $ref: "#/components/schemas/IdName" }
        first_name: { type: string }
        last_name: { type: string, nullable: true }
        title: { type: string, nullable: true }
        email: { type: string, nullable: true }
        invoice_recipient_status:
          type: string
          enum: [none, recipient, cc, bcc]
        phone_office: { type: string, nullable: true }
        phone_mobile: { type: string, nullable: true }
        fax: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ContactCreate:
      type: object
      required: [client_id, first_name]
      properties:
        client_id: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        title: { type: string }
        email: { type: string }
        invoice_recipient_status:
          type: string
          enum: [none, recipient, cc, bcc]
          default: none
        phone_office: { type: string }
        phone_mobile: { type: string }
        fax: { type: string }

    # ── Tasks ──────────────────────────────────────────────────
    Task:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        billable_by_default: { type: boolean }
        default_hourly_rate:
          type: number
          nullable: true
          description: Raw task default. Omitted unless the caller has workspace-wide billable-rate visibility.
        effective_billable_rate:
          type: number
          nullable: true
          description: Current effective rate for the authenticated user and requested project/task. Returned only with project_id and authorized visibility; null for non-billable or missing-rate options.
        currency:
          type: string
          description: ISO 4217 client currency, falling back to company currency. Returned with effective_billable_rate.
        budget:
          type: number
          nullable: true
          description: Project-scoped active task-assignment budget. Returned only with project context and budget visibility; omitted for workspace-global tasks, inactive or missing assignments, and unauthorized callers. An explicit zero is returned as 0.
        is_default: { type: boolean }
        is_active: { type: boolean }
        parent_task_id: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProjectTaskCreate:
      type: object
      additionalProperties: false
      required: [project_id, name]
      properties:
        project_id: { type: string, description: Project in the authenticated workspace. }
        name: { type: string, minLength: 1, maxLength: 255 }
        billable: { type: boolean, description: Defaults to the project's billable setting. }
        hourly_rate: { type: number, nullable: true, minimum: 0, maximum: 99999999.99 }
        budget: { type: number, nullable: true, minimum: 0, maximum: 99999999.99 }

    ProjectTaskCreated:
      allOf:
        - $ref: "#/components/schemas/Task"
        - type: object
          required: [project_id, assignment_id]
          properties:
            project_id: { type: string }
            assignment_id: { type: string }
            billable:
              type: boolean
              description: Omitted without billable-rate visibility.
            hourly_rate:
              type: number
              nullable: true
              description: Project assignment rate. Omitted without billable-rate visibility.
            budget:
              type: number
              nullable: true
              description: Project assignment budget. Omitted without billable-rate visibility.

    # ── Project assignments ────────────────────────────────────
    ProjectUserAssignment:
      type: object
      required: [id, project, user, is_active, is_project_manager, can_track_time, can_view_project_financials, can_view_internal_costs, created_at, updated_at]
      properties:
        id: { type: string }
        project:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            code: { type: string, nullable: true }
        user: { $ref: "#/components/schemas/IdName" }
        is_active: { type: boolean }
        is_project_manager: { type: boolean }
        can_track_time: { type: boolean }
        can_view_project_financials: { type: boolean }
        can_view_internal_costs: { type: boolean }
        use_default_rates:
          type: boolean
          description: Omitted without billable-rate visibility.
        hourly_rate:
          type: number
          nullable: true
          description: Project-specific person rate when use_default_rates is false. Omitted without billable-rate visibility; read-only here.
        budget:
          type: number
          nullable: true
          description: Person hour budget (used when the project budgets by person). Omitted without billable-rate visibility.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProjectUserAssignmentCreate:
      type: object
      additionalProperties: false
      required: [user_id]
      properties:
        user_id: { type: string, description: "An active, assignable member of the authenticated workspace." }
        is_project_manager: { type: boolean, description: Administrator only when true. }
        can_track_time: { type: boolean, description: Defaults to true; only administrators may set false. }
        can_view_project_financials: { type: boolean, description: Administrator only when true. }
        can_view_internal_costs: { type: boolean, description: Administrator only when true; implies can_view_project_financials. }
        budget: { type: number, nullable: true, minimum: 0, maximum: 99999999.99, description: Person hour budget. Administrator only. }

    ProjectUserAssignmentUpdate:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        is_active: { type: boolean }
        is_project_manager: { type: boolean }
        can_track_time: { type: boolean }
        can_view_project_financials: { type: boolean }
        can_view_internal_costs: { type: boolean }
        budget: { type: number, nullable: true, minimum: 0, maximum: 99999999.99 }

    ProjectTaskAssignment:
      type: object
      required: [id, project, task, is_active, created_at, updated_at]
      properties:
        id: { type: string }
        project:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            code: { type: string, nullable: true }
        task: { $ref: "#/components/schemas/IdName" }
        is_active: { type: boolean }
        billable:
          type: boolean
          description: Omitted without project-financials visibility.
        hourly_rate:
          type: number
          nullable: true
          description: Task rate on this project. Omitted without project-financials visibility.
        budget:
          type: number
          nullable: true
          description: Task budget on this project (hours, or fees when the project budgets by task fees). Omitted without project-budget visibility.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProjectTaskAssignmentCreate:
      type: object
      additionalProperties: false
      required: [task_id]
      properties:
        task_id: { type: string, description: An active workspace task (not a task that belongs to another project). }
        billable: { type: boolean, description: Defaults to the task's billable-by-default setting. }
        hourly_rate: { type: number, minimum: 0, maximum: 99999999.99, description: Defaults to the task's default rate. }
        budget: { type: number, minimum: 0, maximum: 99999999.99 }

    ProjectTaskAssignmentUpdate:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        billable: { type: boolean }
        hourly_rate: { type: number, nullable: true, minimum: 0, maximum: 99999999.99 }
        budget: { type: number, nullable: true, minimum: 0, maximum: 99999999.99 }
        is_active: { type: boolean }

    ProjectTaskAssignmentRemoval:
      type: object
      required: [id, outcome, is_active]
      properties:
        id: { type: string }
        outcome: { type: string, enum: [archived, deleted] }
        is_active: { type: boolean, enum: [false] }

    # ── Users ──────────────────────────────────────────────────
    User:
      type: object
      properties:
        id: { type: string }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        email: { type: string }
        telephone: { type: string, nullable: true }
        timezone: { type: string }
        has_access_to_all_future_projects: { type: boolean }
        is_contractor: { type: boolean }
        is_active: { type: boolean }
        weekly_capacity: { type: integer, description: Seconds per week (e.g. 144000 = 40h) }
        default_hourly_rate:
          type: number
          nullable: true
          description: Omitted unless the caller has workspace billable-rate visibility.
        currency: { type: string }
        roles: { type: array, items: { type: string } }
        user_type:
          type: string
          enum: [human, agent]
        avatar_url: { type: string, nullable: true }
        company: { $ref: "#/components/schemas/CurrentUserCompany" }
        capabilities: { $ref: "#/components/schemas/UserCapabilities" }
        connection:
          type: object
          description: Present only for AI app connections (MCP connector tokens).
          properties:
            label: { type: string }
            workspace_bound: { type: boolean }
            calendar_data_excluded: { type: boolean, enum: [true] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CurrentUserCompany:
      type: object
      description: >-
        The workspace the credential is bound to, with the policy a time or
        expense client applies locally. Enum-like strings are lower-case.
      required:
        - id
        - name
        - currency
        - timezone
        - date_format
        - time_rounding
        - time_rounding_description
        - week_start_day
        - fiscal_year_end_month
        - manual_entry_format
        - require_notes
        - allow_past_timer_starts
        - allow_future_expense_dates
        - internal_time_notes_enabled
        - internal_time_notes_visibility
        - time_entry_notes_mode
        - time_billability_overrides_enabled
      properties:
        id: { type: string }
        name: { type: string }
        currency: { type: string, description: ISO 4217 workspace currency. }
        timezone: { type: string, description: IANA zone every date boundary is computed in. }
        date_format: { type: string, nullable: true, description: "The workspace's display date format, e.g. `DD/MM/YYYY`." }
        time_rounding:
          type: string
          description: Workspace rounding mode (lower-cased `TimeRounding`), e.g. `none`, `min_15`, `minimum_15`, `up_15`.
        time_rounding_description:
          type: string
          nullable: true
          description: >-
            The rounding rule in words, for report captions that explain why
            commercial hours differ from tracked time. Null when the workspace
            does not round (`time_rounding: none`).
        week_start_day:
          type: string
          enum: [monday, tuesday, wednesday, thursday, friday, saturday, sunday]
          description: First day of the workspace week, for week ranges in timesheets and reports.
        fiscal_year_end_month:
          type: integer
          minimum: 1
          maximum: 12
          description: Last month of the workspace's fiscal year (1 = January).
        manual_entry_format: { type: string, enum: [start_end, duration] }
        require_notes: { type: boolean }
        allow_past_timer_starts: { type: boolean }
        allow_future_expense_dates: { type: boolean }
        internal_time_notes_enabled: { type: boolean, description: False for external client memberships. }
        internal_time_notes_visibility: { type: string, description: Lower-cased `InternalTimeNotesVisibility`. }
        time_entry_notes_mode: { type: string, description: Lower-cased `TimeEntryNotesMode`. }
        time_billability_overrides_enabled: { type: boolean, description: False for external client memberships. }

    WorkspaceUser:
      type: object
      required: [id, email, is_active, is_contractor, weekly_capacity, currency, roles, access_roles, created_at]
      properties:
        id: { type: string }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        email: { type: string, format: email }
        employee_id: { type: string, nullable: true }
        is_active: { type: boolean }
        is_contractor: { type: boolean }
        weekly_capacity:
          type: integer
          description: Seconds per week from this workspace membership.
        currency:
          type: string
          description: ISO 4217 currency in which this person's cost rates are stored.
        roles:
          type: array
          items: { type: string }
          description: Active custom reporting-role names; these do not grant permissions.
        access_roles:
          type: array
          items: { type: string }
          description: Workspace permission roles used for authorization.
        created_at: { type: string, format: date-time }

    SyncWorkspaceUser:
      type: object
      required: [id, first_name, last_name, is_active, is_contractor, roles]
      description: Reduced personal read-only sync directory entry.
      properties:
        id: { type: string }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        is_active: { type: boolean }
        is_contractor: { type: boolean }
        roles:
          type: array
          items: { type: string }
          description: Permission roles retained in this reduced compatibility shape.

    Role:
      type: object
      required: [id, name, user_ids, created_at, updated_at]
      properties:
        id: { type: string }
        name: { type: string }
        user_ids:
          type: array
          items: { type: string }
          description: User CUIDs with an active assignment to this reporting role.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Teammate:
      type: object
      required: [id, first_name, last_name, email]
      properties:
        id: { type: string }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        email: { type: string, format: email }

    CostRate:
      type: object
      required: [id, amount, currency, start_date, end_date, created_at, updated_at]
      properties:
        id: { type: string }
        amount: { type: number, minimum: 0, maximum: 99999999.99, multipleOf: 0.01 }
        currency:
          type: string
          description: ISO 4217 member currency. Keito does not silently convert API cost-rate writes.
        start_date: { type: string, format: date, nullable: true }
        end_date: { type: string, format: date, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CostRateCreate:
      type: object
      additionalProperties: false
      required: [amount, start_date]
      properties:
        amount: { type: number, minimum: 0, maximum: 99999999.99, multipleOf: 0.01 }
        start_date:
          type: string
          format: date
          description: Required effective date in YYYY-MM-DD format; future dates are supported.

    # ── Planning ────────────────────────────────────────────────
    PersonalPlanningAssignment:
      type: object
      additionalProperties: false
      required:
        - id
        - revision
        - project
        - task
        - start_date
        - end_date
        - daily_allocations
        - state
        - link
      properties:
        id:
          type: string
        revision:
          type: integer
          minimum: 1
          readOnly: true
        project:
          type: object
          additionalProperties: false
          required:
            - id
            - name
            - code
          properties:
            id:
              type: string
            name:
              type: string
            code:
              type: string
              nullable: true
        task:
          type: object
          additionalProperties: false
          required:
            - id
            - name
          properties:
            id:
              type: string
            name:
              type: string
          nullable: true
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        daily_allocations:
          type: array
          items:
            type: object
            additionalProperties: false
            required:
              - date
              - hours
            properties:
              date:
                type: string
                format: date
              hours:
                type: number
        state:
          type: string
          enum:
            - CONFIRMED
            - TENTATIVE
        link:
          type: string
          nullable: true
          description: Workspace-relative Plan URL only when the caller can open Plan; otherwise null.

    PersonalPlanningAvailability:
      type: object
      additionalProperties: false
      required:
        - date
        - base_hours
        - available_hours
        - scheduled_hours
        - time_off_hours
        - is_public_holiday
        - over_allocated_hours
      properties:
        date:
          type: string
          format: date
        base_hours:
          type: number
        available_hours:
          type: number
        scheduled_hours:
          type: number
        time_off_hours:
          type: number
        is_public_holiday:
          type: boolean
        over_allocated_hours:
          type: number

    PersonalPlanningSchedule:
      type: object
      additionalProperties: false
      required:
        - person_id
        - workspace_timezone
        - week_start
        - range
        - snapshot_version
        - assignments
        - availability
        - capabilities
        - pagination
      properties:
        person_id:
          type: string
        workspace_timezone:
          type: string
        week_start:
          type: string
          enum:
            - MONDAY
            - TUESDAY
            - WEDNESDAY
            - THURSDAY
            - FRIDAY
            - SATURDAY
            - SUNDAY
        range:
          type: object
          additionalProperties: false
          required:
            - from
            - to
          properties:
            from:
              type: string
              format: date
            to:
              type: string
              format: date
        snapshot_version:
          type: string
          pattern: ^ps1_[A-Za-z0-9_-]{43}$
        assignments:
          type: array
          items:
            $ref: "#/components/schemas/PersonalPlanningAssignment"
        availability:
          type: array
          items:
            $ref: "#/components/schemas/PersonalPlanningAvailability"
        capabilities:
          type: object
          additionalProperties: false
          required:
            - own_read
            - manage_assignments
            - webhooks
          properties:
            own_read:
              type: boolean
            manage_assignments:
              type: boolean
            webhooks:
              type: boolean
        pagination:
          type: object
          additionalProperties: false
          required:
            - page
            - per_page
            - total_entries
            - total_pages
            - next_page
          properties:
            page:
              type: integer
            per_page:
              type: integer
            total_entries:
              type: integer
            total_pages:
              type: integer
            next_page:
              type: integer
              nullable: true

    PlanningAssignmentItem:
      type: object
      additionalProperties: false
      required:
        - id
        - revision
        - project_id
        - task_id
        - user_id
        - placeholder_id
        - start_date
        - end_date
        - hours_per_day
        - allocation_mode
        - total_hours
        - status
        - notes
        - color
        - recurrence_series_id
        - deleted_at
      properties:
        id:
          type: string
        revision:
          type: integer
          minimum: 1
          readOnly: true
        project_id:
          type: string
        task_id:
          type: string
          nullable: true
        user_id:
          type: string
          nullable: true
        placeholder_id:
          type: string
          nullable: true
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        hours_per_day:
          type: number
        allocation_mode:
          type: string
          enum:
            - HOURS_PER_DAY
            - TOTAL_HOURS
        total_hours:
          type: number
          nullable: true
        status:
          type: string
          enum:
            - CONFIRMED
            - TENTATIVE
        notes:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        recurrence_series_id:
          type: string
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true

    PlanningWebhookEventV2:
      type: object
      additionalProperties: false
      required:
        - id
        - payload_version
        - event_type
        - entity_type
        - entity_id
        - revision
        - occurred_at
      properties:
        id:
          type: string
          description: Stable delivery identity across retries; deduplicate by this ID.
        payload_version:
          type: integer
          enum:
            - 2
        event_type:
          type: string
          enum:
            - assignment.created
            - assignment.updated
            - assignment.deleted
            - time_off.created
            - time_off.approved
            - time_off.rejected
            - time_off.cancelled
        entity_type:
          type: string
          enum:
            - planning_assignment
            - planning_time_off
        entity_id:
          type: string
        revision:
          type: integer
          minimum: 0
          description: Assignment revisions start at 1; time-off uses its existing zero-based version.
        occurred_at:
          type: string
          format: date-time

    PlanningAssignment:
      type: object
      properties:
        id:
          type: string
        project:
          type: object
          properties:
            id:
              type: string
            name:
              type: string
            code:
              type: string
              nullable: true
            status:
              type: string
              enum:
                - ACTIVE
                - TENTATIVE
                - ARCHIVED
            color:
              type: string
              nullable: true
            exclude_from_utilisation:
              type: boolean
            client:
              $ref: "#/components/schemas/IdName"
        task:
          type: object
          allOf:
            - $ref: "#/components/schemas/IdName"
          nullable: true
        user:
          type: object
          allOf:
            - $ref: "#/components/schemas/IdName"
          nullable: true
        placeholder:
          type: object
          allOf:
            - $ref: "#/components/schemas/PlanningPlaceholder"
          nullable: true
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        hours_per_day:
          type: number
        allocation_mode:
          type: string
          enum:
            - HOURS_PER_DAY
            - TOTAL_HOURS
        total_hours:
          type: number
          nullable: true
        status:
          type: string
          enum:
            - CONFIRMED
            - TENTATIVE
        notes:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        recurrence_series_id:
          type: string
          nullable: true
          readOnly: true
          description: Shared by every occurrence of a weekly-repeat booking, so a series can be grouped; null for a one-off booking. Set by Keito and not writable through the API.
        planned_hours:
          type: number
        tracked_hours:
          type: number
        tracked_by_date:
          type: object
          additionalProperties:
            type: number
        tracked_ratio:
          type: number
        deleted_at:
          type: string
          format: date-time
          nullable: true
        revision:
          type: integer
          minimum: 1
          readOnly: true

    PlanningAssignmentCreate:
      type: object
      required:
        - project_id
        - start_date
        - end_date
        - hours_per_day
      properties:
        project_id:
          type: string
        task_id:
          type: string
          nullable: true
        user_id:
          type: string
          nullable: true
        placeholder_id:
          type: string
          nullable: true
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        hours_per_day:
          type: number
          minimum: 0.25
          maximum: 24
        allocation_mode:
          type: string
          enum:
            - HOURS_PER_DAY
            - TOTAL_HOURS
          default: HOURS_PER_DAY
        total_hours:
          type: number
          nullable: true
          minimum: 0.25
          maximum: 10000
        status:
          type: string
          enum:
            - CONFIRMED
            - TENTATIVE
          default: CONFIRMED
        notes:
          type: string
          nullable: true
          maxLength: 5000
        color:
          type: string
          nullable: true
          maxLength: 30
      description: Exactly one of `user_id` or `placeholder_id` is required.
      additionalProperties: false

    PlanningAssignmentUpdate:
      type: object
      properties:
        project_id:
          type: string
        task_id:
          type: string
          nullable: true
        user_id:
          type: string
          nullable: true
        placeholder_id:
          type: string
          nullable: true
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        hours_per_day:
          type: number
          minimum: 0.25
          maximum: 24
        allocation_mode:
          type: string
          enum:
            - HOURS_PER_DAY
            - TOTAL_HOURS
        total_hours:
          type: number
          nullable: true
          minimum: 0.25
          maximum: 10000
        status:
          type: string
          enum:
            - CONFIRMED
            - TENTATIVE
        notes:
          type: string
          nullable: true
          maxLength: 5000
        color:
          type: string
          nullable: true
          maxLength: 30
      description: All fields are optional; if either assignee field is supplied exactly one assignee must be non-null.
      additionalProperties: false

    PlanningPlaceholder:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        color:
          type: string
          nullable: true
        weekly_capacity:
          type: integer
          description: Seconds per week

    PlanningTimeOff:
      type: object
      properties:
        id:
          type: string
        user_id:
          type: string
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        hours_per_day:
          type: number
          nullable: true
        type:
          type: string
          enum:
            - ANNUAL_LEAVE
            - SICK
            - PUBLIC_HOLIDAY
            - PARENTAL
            - OTHER
        time_off_type_id:
          type: string
          nullable: true
          description: Stable workspace catalogue ID for native bookings
        type_name:
          type: string
          description: Current catalogue label, or the legacy enum label for external/system rows
        counts_against_allowance:
          type: boolean
          description: Booking-time allowance snapshot
        approval_status:
          type: string
          enum:
            - PENDING
            - APPROVED
            - REJECTED
        notes:
          type: string
          nullable: true
        source:
          type: string
          enum:
            - MANUAL
            - CHARLIE_HR
        external_id:
          type: string
          nullable: true

    PlanningCapacity:
      type: object
      properties:
        user:
          type: object
          properties:
            id:
              type: string
            name:
              type: string
        date:
          type: string
          format: date
        available_hours:
          type: number
        scheduled_hours:
          type: number
        time_off_hours:
          type: number
        utilisation:
          type: number
        over_allocated_hours:
          type: number
        is_public_holiday:
          type: boolean

    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
          nullable: true
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
            enum:
              - assignment.created
              - assignment.updated
              - assignment.deleted
              - time_off.created
              - time_off.approved
              - time_off.rejected
              - time_off.cancelled
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        payload_version:
          type: integer
          enum:
            - 1
            - 2
          default: 1
          description: Opt in to minimal signed v2 events. Version 1 preserves existing payloads.

    WebhookEndpointCreate:
      type: object
      required:
        - url
        - events
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
        name:
          type: string
          nullable: true
          maxLength: 120
        secret:
          type: string
          nullable: true
          minLength: 16
          description: At least 16 characters; required for payload_version=2. HMAC-SHA256 signs timestamp + "." + raw body in X-Keito-Webhook-Signature; timestamp is X-Keito-Webhook-Timestamp.
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - assignment.created
              - assignment.updated
              - assignment.deleted
              - time_off.created
              - time_off.approved
              - time_off.rejected
              - time_off.cancelled
        payload_version:
          type: integer
          enum:
            - 1
            - 2
          default: 1
          description: Opt in to minimal signed v2 events. Version 1 preserves existing payloads.

    Invoice:
      type: object
      properties:
        id: { type: string }
        client_id: { type: string }
        created_by_id: { type: string }
        owner_id: { type: string, nullable: true }
        owner: { $ref: "#/components/schemas/RecordOwner" }
        client: { $ref: "#/components/schemas/IdName" }
        creator:
          type: object
          properties:
            id: { type: string }
            name: { type: string, nullable: true }
            email: { type: string }
        number: { type: string }
        purchase_order: { type: string, nullable: true }
        subject: { type: string, nullable: true }
        state:
          type: string
          enum: [draft, open, paid, closed, voided]
        is_overdue:
          type: boolean
          description: >-
            Open, with a balance due, and past its due date in the workspace
            timezone. Present on list and detail responses.
        amount: { type: number }
        due_amount: { type: number }
        tax: { type: number, nullable: true }
        tax_amount: { type: number, nullable: true }
        tax2: { type: number, nullable: true }
        tax2_amount: { type: number, nullable: true }
        discount: { type: number, nullable: true }
        discount_amount: { type: number, nullable: true }
        currency: { type: string }
        issue_date: { type: string, format: date }
        due_date: { type: string, format: date }
        payment_term:
          type: string
          enum: [upon_receipt, net_15, net_30, net_45, net_60, custom]
        notes: { type: string, nullable: true }
        period_start: { type: string, format: date, nullable: true }
        period_end: { type: string, format: date, nullable: true }
        service_period_start: { type: string, format: date, nullable: true }
        service_period_end: { type: string, format: date, nullable: true }
        sent_at: { type: string, format: date-time, nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }
        closed_at: { type: string, format: date-time, nullable: true }
        payments_count:
          type: integer
          description: Number of payments recorded against the invoice. Present on list and detail responses.
        last_payment_at:
          type: string
          format: date-time
          nullable: true
          description: When the most recent payment was made, or null. Present on list and detail responses.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        line_items:
          type: array
          description: Omitted from the list when `include_line_items=false`.
          items:
            $ref: "#/components/schemas/LineItem"

    InvoiceCreate:
      type: object
      additionalProperties: false
      required: [client_id]
      properties:
        client_id: { type: string }
        owner_id:
          type: string
          description: Eligible workspace member ID. Defaults to the authenticated caller.
        number:
          type: string
          description: Optional custom invoice number. Must be unique in the workspace.
        subject: { type: string }
        issue_date: { type: string, format: date, description: Defaults to today. }
        due_date: { type: string, format: date }
        payment_term:
          type: string
          enum: [upon_receipt, net_15, net_30, net_45, net_60, custom]
          default: net_30
        currency: { type: string }
        purchase_order: { type: string }
        tax: { type: number }
        tax2: { type: number }
        discount: { type: number }
        notes: { type: string }
        period_start: { type: string, format: date }
        period_end: { type: string, format: date }
        service_period_start: { type: string, format: date }
        service_period_end: { type: string, format: date }
        line_items:
          description: >-
            Explicit line items. Mutually exclusive with `line_items_import`.
          type: array
          items:
            type: object
            additionalProperties: false
            properties:
              kind: { type: string, default: Service }
              description: { type: string }
              quantity: { type: number, default: 1, minimum: 0 }
              unit_price: { type: number, default: 0, minimum: 0 }
              effect:
                type: string
                enum: [charge, credit]
                default: charge
                description: >-
                  Use `credit` for credit lines. `quantity` and `unit_price` must
                  stay non-negative.
              taxed:
                type: boolean
                description: Defaults to true when the effective invoice tax rate is non-zero.
              taxed2:
                type: boolean
                description: Defaults to true when the effective secondary tax rate is non-zero.
              project_id:
                type: string
                description: >-
                  Project the line bills. Must belong to the invoice's client (and so to the
                  caller's workspace); otherwise the request is refused with 404 and nothing is
                  created.
              sort_order: { type: integer }
        line_items_import:
          $ref: "#/components/schemas/InvoiceLineItemsImport"

    InvoiceLineItemsImport:
      type: object
      additionalProperties: false
      description: >-
        Build the invoice's line items from tracked time and expenses instead of
        supplying them explicitly. Only billable, not-yet-invoiced work is
        included, and the workspace's approval policy decides whether unapproved
        work is eligible. Imported lines are taxable when the effective invoice
        tax rate is non-zero. Imported time entries and expenses are marked as billed.
        Mutually exclusive with `line_items`; at least one of `time` or `expenses`
        is required.
      properties:
        project_ids:
          type: array
          description: Restrict the import to these projects. Omit for all of the client's projects.
          items: { type: string }
        time:
          type: object
          additionalProperties: false
          required: [summary_type]
          properties:
            summary_type:
              type: string
              enum: [project, task, people, detailed]
              description: >-
                How imported time is grouped into line items. `detailed` creates
                one line per time entry.
            include_notes:
              type: boolean
              default: false
              description: >-
                For grouped time-entry imports (`project`, `task`, or `people`),
                append each client-facing note as bullet lines. Blank notes are
                skipped and repeated notes are retained. Internal notes are never
                included. Keito renders these using existing invoice-description
                formatting, with HTML-looking content displayed as text. Ignored for
                `detailed`, which already includes each entry's note in its own line.
            user_ids:
              type: array
              description: >-
                Restrict the time import to entries owned by these workspace users.
                Omit to include all owners; an empty array imports no time entries.
                Repeated IDs are treated as one owner.
              items: { type: string }
            from:
              type: string
              format: date
              description: Defaults to the earliest uninvoiced entry.
            to:
              type: string
              format: date
              description: Defaults to the latest uninvoiced entry.
        expenses:
          type: object
          additionalProperties: false
          required: [summary_type]
          properties:
            summary_type:
              type: string
              enum: [project, category, people, detailed]
            user_ids:
              type: array
              description: >-
                Restrict the expense import to entries owned by these workspace users.
                Omit to include all owners; an empty array imports no expenses.
                Repeated IDs are treated as one owner.
              items: { type: string }
            from: { type: string, format: date }
            to: { type: string, format: date }

    InvoiceUpdate:
      type: object
      additionalProperties: false
      properties:
        owner_id:
          type: string
          nullable: true
          description: Eligible workspace member ID. Omit to leave unchanged; null clears the owner.
        number:
          type: string
          description: A unique invoice number for this workspace.
        subject: { type: string }
        issue_date: { type: string, format: date }
        due_date: { type: string, format: date }
        purchase_order: { type: string }
        tax: { type: number }
        tax2: { type: number }
        discount: { type: number }
        notes: { type: string }
        period_start: { type: string, format: date }
        period_end: { type: string, format: date }
        service_period_start: { type: string, format: date, nullable: true }
        service_period_end: { type: string, format: date, nullable: true }
        line_items:
          type: array
          minItems: 1
          description: >-
            Patch existing draft line items in place. Every object must include
            the line item `id`; replacement arrays and new line items are not
            accepted. Editing imported lines preserves the billed state of the
            source time entries and expenses.
          items:
            $ref: "#/components/schemas/InvoiceLineItemUpdate"

    InvoiceLineItemUpdate:
      type: object
      additionalProperties: false
      required: [id]
      properties:
        id: { type: string }
        kind: { type: string, minLength: 1, maxLength: 255 }
        effect: { type: string, enum: [charge, credit] }
        description: { type: string, maxLength: 65535 }
        quantity: { type: number, minimum: 0 }
        unit_price: { type: number, minimum: 0 }
        taxed: { type: boolean }
        taxed2: { type: boolean }

    # ── Estimates ──────────────────────────────────────────────
    Estimate:
      type: object
      properties:
        id: { type: string }
        company_id: { type: string }
        client_id: { type: string }
        created_by_id: { type: string }
        owner_id: { type: string, nullable: true }
        owner: { $ref: "#/components/schemas/RecordOwner" }
        client: { $ref: "#/components/schemas/IdName" }
        number: { type: string }
        purchase_order: { type: string, nullable: true }
        subject: { type: string, nullable: true }
        state:
          type: string
          enum: [draft, sent, viewed, accepted, declined, expired]
        amount: { type: number }
        tax: { type: number, nullable: true }
        tax_amount: { type: number, nullable: true }
        tax2: { type: number, nullable: true }
        tax2_amount: { type: number, nullable: true }
        discount: { type: number, nullable: true }
        discount_amount: { type: number, nullable: true }
        currency: { type: string }
        issue_date: { type: string, format: date }
        valid_until: { type: string, format: date, nullable: true }
        notes: { type: string, nullable: true }
        intro_message: { type: string, nullable: true }
        sent_at: { type: string, format: date-time, nullable: true }
        viewed_at: { type: string, format: date-time, nullable: true }
        accepted_at: { type: string, format: date-time, nullable: true }
        declined_at: { type: string, format: date-time, nullable: true }
        expired_at: { type: string, format: date-time, nullable: true }
        accepted_by_name: { type: string, nullable: true }
        decline_reason: { type: string, nullable: true }
        converted_invoice_id: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        line_items:
          type: array
          items:
            $ref: "#/components/schemas/LineItem"

    EstimateCreate:
      type: object
      required: [client_id]
      properties:
        client_id: { type: string }
        owner_id:
          type: string
          nullable: true
          description: Eligible workspace member ID. Defaults to the authenticated caller.
        number: { type: string }
        subject: { type: string }
        issue_date: { type: string, format: date }
        valid_until: { type: string, format: date, nullable: true }
        currency: { type: string }
        purchase_order: { type: string }
        tax: { type: number }
        tax2: { type: number }
        discount: { type: number }
        notes: { type: string }
        intro_message: { type: string }
        line_items:
          type: array
          items:
            type: object
            properties:
              kind: { type: string, default: SERVICE }
              description: { type: string }
              quantity: { type: number, default: 1 }
              unit_price: { type: number, default: 0 }
              taxed: { type: boolean, default: false }
              taxed2: { type: boolean, default: false }
              project_id:
                type: string
                nullable: true
                description: >-
                  Project the line refers to. Must belong to the estimate's client (and so to
                  the caller's workspace); otherwise the request is refused with 404.

    EstimateUpdate:
      allOf:
        - $ref: "#/components/schemas/EstimateCreate"

    RecordOwner:
      type: object
      nullable: true
      properties:
        id: { type: string }
        name: { type: string, nullable: true }
        email: { type: string, format: email }

    LineItem:
      type: object
      properties:
        id: { type: string }
        kind: { type: string }
        effect: { type: string, enum: [charge, credit] }
        description: { type: string, nullable: true }
        quantity: { type: number }
        unit_price: { type: number }
        amount: { type: number }
        taxed: { type: boolean }
        taxed2: { type: boolean }
        project_id: { type: string, nullable: true }
        sort_order: { type: integer }

    # ── Invoice Messages ───────────────────────────────────────
    InvoiceAttachment:
      type: object
      properties:
        id: { type: string }
        invoice_id: { type: string }
        file_name: { type: string }
        file_size: { type: integer, description: Size in bytes }
        content_type: { type: string }
        kind:
          type: string
          enum: [upload, time_report]
          description: >-
            `upload` for files added by a person, `time_report` for the Excel
            time report Keito generated from the time billed on the invoice.
        uploaded_by_id: { type: string }
        download_url: { type: string }
        uploaded_by:
          type: object
          nullable: true
          properties:
            id: { type: string }
            name: { type: string, nullable: true }
            email: { type: string, format: email }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    InvoiceMessage:
      type: object
      properties:
        id: { type: string }
        invoice_id: { type: string }
        sent_by: { type: string, nullable: true }
        sent_by_email: { type: string, nullable: true }
        sent_from: { type: string, nullable: true }
        sent_from_email: { type: string, nullable: true }
        recipients: {}
        cc_recipients:
          type: array
          items:
            type: object
            required: [email]
            properties:
              name: { type: string }
              email: { type: string, format: email }
        subject: { type: string, nullable: true }
        body: { type: string, nullable: true }
        attach_pdf: { type: boolean }
        attach_expense_receipts: { type: boolean }
        attach_files:
          type: boolean
          description: Whether uploaded invoice attachments (and generated time reports) were included in the email.
        send_me_a_copy: { type: boolean }
        thank_you: { type: boolean }
        reminder: { type: boolean }
        event_type: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    InvoiceMessageCreate:
      type: object
      description: >-
        `recipients` is required for email events and omitted for `mark_sent`.
      properties:
        recipients:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: object
            required: [email]
            properties:
              name: { type: string }
              email: { type: string, format: email }
        cc_recipients:
          type: array
          default: []
          maxItems: 50
          description: Optional CC recipients. The combined recipients and cc_recipients limit is 50.
          items:
            type: object
            required: [email]
            properties:
              name: { type: string }
              email: { type: string, format: email }
        attach_expense_receipts:
          type: boolean
          default: true
          description: Include expense receipts selected when the invoiced expenses were recorded.
        expense_receipt_delivery:
          type: string
          enum: [separate, combined_pdf]
          default: separate
          description: Send selected expense receipts as separate original files or as one combined PDF. Combined PDF supports PDF, JPEG, and PNG receipts.
        subject: { type: string, maxLength: 500 }
        body: { type: string, maxLength: 20000 }
        attach_pdf: { type: boolean, default: true }
        send_me_a_copy: { type: boolean, default: false }
        include_attachments:
          type: boolean
          default: true
          description: Include the invoice's uploaded files and generated time report as email attachments. The invoice PDF, receipts and files must fit within the 9 MB email limit.
        event_type:
          type: string
          enum: [send, mark_sent, resend, reminder, thank_you]
          default: send
        confirm:
          $ref: "#/components/schemas/InvoiceConfirmation"

    InvoiceConfirmation:
      type: object
      additionalProperties: false
      required: [amount, currency]
      description: >-
        What the caller believes is about to be issued. Compared with the
        invoice before any state change; a mismatch is a 409 `confirmation_mismatch`.
      properties:
        amount:
          oneOf:
            - { type: number }
            - { type: string, pattern: '^-?\\d+(\\.\\d+)?$' }
          description: Invoice total, compared at two decimal places.
        currency: { type: string, minLength: 3, maxLength: 3, example: GBP }
        recipient_count:
          type: integer
          minimum: 0
          description: Number of distinct To recipients after de-duplication; 0 for `mark_sent`.

    InvoicePaymentDeletion:
      type: object
      required: [id, invoice_id, deleted, outcome, accounting_sync]
      properties:
        id: { type: string }
        invoice_id: { type: string }
        deleted:
          type: boolean
          description: True when the payment is gone from Keito.
        outcome:
          type: string
          enum: [deleted, kept_in_keito]
        accounting_sync:
          type: object
          required: [provider, status]
          properties:
            provider: { type: string, enum: [XERO, QUICKBOOKS] }
            status: { type: string, enum: [SYNCED, PENDING, NEEDS_ATTENTION, NOT_APPLICABLE] }
            message: { type: string }
        message:
          type: string
          description: Only when `outcome` is `kept_in_keito`; why the payment was not removed.

    InvoicePaymentCreate:
      type: object
      additionalProperties: false
      required: [amount]
      properties:
        amount: { type: number, minimum: 0.01, maximum: 99999999.99 }
        paid_at:
          type: string
          format: date-time
          description: Defaults to the current time.
        notes: { type: string, maxLength: 65535 }
        transaction_id: { type: string, minLength: 1, maxLength: 255 }
        payment_gateway: { type: string, minLength: 1, maxLength: 255 }
        send_thank_you:
          type: boolean
          description: Overrides the workspace's automatic thank-you preference for this payment.

    InvoicePayment:
      type: object
      properties:
        id: { type: string }
        invoice_id: { type: string }
        amount: { type: number }
        paid_at: { type: string, format: date-time }
        paid_date: { type: string, format: date }
        recorded_by: { type: string, nullable: true }
        recorded_by_email: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        transaction_id: { type: string, nullable: true }
        payment_gateway_id: { type: string, nullable: true }
        payment_gateway: { type: string, nullable: true }
        origin:
          type: string
          enum: [manual, stripe, paypal, retainer, xero, quickbooks, harvest_import, legacy]
        deletable:
          type: boolean
          description: >-
            Only on `GET /api/v2/invoices/{id}/payments`. True when the payment
            was recorded manually and the caller may manage invoices.
        accounting_sync: { type: object }
        thank_you: { type: object }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # ── Reports ────────────────────────────────────────────────
    HarvestReportPaginationEnvelope:
      allOf:
        - $ref: "#/components/schemas/PaginationEnvelope"
        - type: object
          required: [next_page, previous_page]
          properties:
            next_page: { type: integer, nullable: true }
            previous_page: { type: integer, nullable: true }

    ProjectBudgetReportResult:
      type: object
      required: [project_id, project_name, client_id, client_name, budget_is_monthly, budget_by, is_active, budget, budget_spent, budget_remaining]
      properties:
        project_id: { type: string }
        project_name: { type: string }
        client_id: { type: string }
        client_name: { type: string }
        budget_is_monthly: { type: boolean }
        budget_by:
          type: string
          enum: [project, project_hours, project_cost, task, task_fees, person]
        is_active: { type: boolean }
        budget: { type: number }
        budget_spent: { type: number }
        budget_remaining: { type: number }

    AmountByCurrency:
      type: object
      required: [currency, amount]
      properties:
        currency: { type: string, example: GBP }
        amount: { type: number, example: 275 }

    RateByCurrency:
      type: object
      required: [currency, rate]
      properties:
        currency: { type: string, example: GBP }
        rate: { type: number, example: 100, description: Weighted effective billable rate per hour. }

    TimeReportRow:
      type: object
      description: |
        One row of a member-facing time report. Hours are decimal hours to two
        places, rounded per entry by the workspace rule. The amount and rate
        fields are present only when the caller may view billable rates.
      required: [id, name, total_hours, billable_hours, non_billable_hours, fixed_fee_hours]
      properties:
        id: { type: string }
        name: { type: string }
        total_hours: { type: number }
        billable_hours: { type: number }
        non_billable_hours: { type: number }
        fixed_fee_hours: { type: number, description: Hours on fixed-fee projects (a subset of total_hours). }
        currency:
          type: string
          nullable: true
          description: The one currency every amount on the row is in; null when the row mixes currencies or has nothing billable.
        billable_amount:
          type: number
          nullable: true
          description: The billable amount in `currency`; null when the row mixes currencies.
        rate:
          type: number
          nullable: true
          description: The weighted effective rate in `currency`; null when mixed or nothing is billable.
        billable_amounts:
          type: array
          items: { $ref: "#/components/schemas/AmountByCurrency" }
        rates:
          type: array
          items: { $ref: "#/components/schemas/RateByCurrency" }

    TimeReportProjectRow:
      allOf:
        - $ref: "#/components/schemas/TimeReportRow"
        - type: object
          required: [client_id, client_name, code, is_fixed_fee]
          properties:
            client_id: { type: string }
            client_name: { type: string }
            code: { type: string, nullable: true }
            is_fixed_fee: { type: boolean }

    TimeReportTaskRow:
      allOf:
        - $ref: "#/components/schemas/TimeReportRow"
        - type: object
          required: [percent_of_total]
          properties:
            percent_of_total: { type: number, description: "This task's share of the report's hours, in percent." }

    TimeReportSummary:
      type: object
      description: Totals over every row of the report, before paging.
      required: [total_hours, billable_hours, non_billable_hours, fixed_fee_hours, billable_percent, amounts_visible, rounding]
      properties:
        total_hours: { type: number }
        billable_hours: { type: number }
        non_billable_hours: { type: number }
        fixed_fee_hours: { type: number }
        billable_percent: { type: number, description: Billable hours as a percentage of total hours; 0 for an empty report. }
        amounts_visible:
          type: boolean
          description: False when the caller may not view billable rates; every amount and rate is then omitted from the rows and this summary.
        billable_amounts:
          type: array
          description: Present only when `amounts_visible` is true.
          items: { $ref: "#/components/schemas/AmountByCurrency" }
        rounding:
          type: object
          required: [mode, description]
          properties:
            mode: { type: string, example: min_15, description: "The workspace time-rounding setting, lower-cased." }
            description:
              type: string
              nullable: true
              example: rounded to the nearest 15 minutes
              description: Human-readable rounding rule; null when the workspace does not round.

    ProjectBillingSummary:
      type: object
      required: [project, period, invoiced, invoices_by_state, uninvoiced, budget, calendar_time_excluded, includes_unapproved_billable_work]
      properties:
        project:
          type: object
          required: [id, name, code, client, currency, is_fixed_fee, is_active]
          properties:
            id: { type: string }
            name: { type: string }
            code: { type: string, nullable: true }
            client: { $ref: "#/components/schemas/IdName" }
            currency: { type: string, example: GBP, description: The client's currency; every amount outside `by_currency` is in it. }
            is_fixed_fee: { type: boolean }
            is_active: { type: boolean }
        period:
          type: object
          nullable: true
          required: [from, to]
          properties:
            from: { type: string, format: date }
            to: { type: string, format: date }
        invoiced:
          type: object
          required: [amount, currency, by_currency, draft_amount]
          properties:
            amount:
              type: number
              description: Issued (open, paid, closed) invoice line amounts for this project in the project currency.
            currency: { type: string }
            by_currency:
              type: array
              description: Issued amounts per invoice currency; never summed across currencies.
              items:
                type: object
                required: [currency, amount]
                properties:
                  currency: { type: string }
                  amount: { type: number }
            draft_amount:
              type: number
              description: Draft invoice line amounts for this project in the project currency; not part of `amount`.
        invoices_by_state:
          type: object
          required: [draft, open, paid, closed, voided]
          properties:
            draft: { type: integer }
            open: { type: integer }
            paid: { type: integer }
            closed: { type: integer }
            voided: { type: integer }
        uninvoiced:
          type: object
          required: [hours, time_amount, expense_amount, amount, currency]
          properties:
            hours: { type: number, description: Unbilled billable hours (workspace rounding). }
            time_amount: { type: number }
            expense_amount: { type: number }
            amount: { type: number, description: Unbilled time or fixed-fee balance plus unbilled expenses. }
            currency: { type: string }
        budget:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ProjectBudgetReportResult"
        calendar_time_excluded:
          type: boolean
          description: True when calendar-derived time was left out of the uninvoiced figures (always true for AI app connections).
        includes_unapproved_billable_work:
          type: boolean

    UninvoicedReportResult:
      type: object
      required: [client_id, client_name, project_id, project_name, currency, total_hours, uninvoiced_hours, uninvoiced_expenses, uninvoiced_amount]
      properties:
        client_id: { type: string }
        client_name: { type: string }
        project_id: { type: string }
        project_name: { type: string }
        currency: { type: string, example: GBP }
        total_hours:
          type: number
          description: Billable hours in the selected period that satisfy the workspace approval policy.
        uninvoiced_hours:
          type: number
          description: Unbilled billable hours in the selected period.
        uninvoiced_expenses:
          type: number
          description: Unbilled billable expense amount in the selected period.
        uninvoiced_amount:
          type: number
          description: Unbilled time or fixed-fee balance plus unbilled expenses.

    TeamTimeResult:
      type: object
      properties:
        user_id: { type: string }
        user_name: { type: string }
        is_contractor: { type: boolean }
        weekly_capacity: { type: integer, description: Seconds per week }
        avatar_url: { type: string, nullable: true }
        total_hours:
          type: number
          description: >-
            Hours rounded per the workspace time-rounding setting
            (Harvest-compatible). Equals actual_hours when rounding is off.
        billable_hours:
          type: number
          description: >-
            Billable hours rounded per the workspace time-rounding setting
            (Harvest-compatible).
        currency: { type: string }
        billable_amount:
          type: number
          description: Rounded billable hours multiplied by the billable rate.
        actual_hours:
          type: number
          description: >-
            Keito extension. Actual unrounded hours for payroll and
            accountability consumers; additive so Harvest-compatible clients
            are unaffected.
