openapi: 3.1.0

info:
  title: Workday.Money API
  version: "1.0.0"
  summary: Почасовой учёт заработка — проекты, работа, отчёты.
  description: |
    REST API почасового трекера Workday.Money.

    **Величины.** Деньги — строка с двумя знаками после точки («112.50») и
    поле `currency` рядом. Суммы разных валют не складываются: итоги приходят
    списком по валютам. Время — целые секунды, поля с окончанием `_seconds`.
    Даты работы — `ГГГГ-ММ-ДД` (календарный день по часовому поясу
    пользователя), моменты времени — ISO 8601 с `Z`.

    **Часовой пояс** обязателен для всех запросов, которые пишут: поле
    `timezone` (`Europe/Kyiv`) или `utc_offset_minutes` (минуты вперёд от UTC).
    Если пояс не передан и не сохранён у аккаунта — `400 timezone_required`.

    **Ответы.** Успех — `{"data": …, "warnings": […]}`, отказ —
    `{"error": {…}}`. Поле `warnings` присутствует всегда.

    **Ограничения.** Запись задним числом недоступна. Записанный отрезок не
    изменяется и не удаляется. Время незавершённой задачи можно только
    уменьшить. Проект архивируется, но не удаляется.
  contact:
    name: Workday.Money
    email: service@workday.money
    url: https://workday.money/api/
  license:
    name: Proprietary
    url: https://workday.money/terms/
  termsOfService: https://workday.money/terms/

servers:
  - url: https://api.workday.money/v1
    description: Рабочий
  - url: https://api.staging.workday.money/v1
    description: Испытательный

security:
  - bearerAuth: []

tags:
  - name: Учётная запись
    description: Сведения об аккаунте и правах ключа.
  - name: Проекты
    description: Проекты, ставки и вид отчётности.
  - name: Работа
    description: "Незавершённые задачи: запуск, пауза, завершение, правка."
  - name: Отчёты
    description: Записанная работа. Только чтение.

paths:
  /me:
    get:
      tags: [Учётная запись]
      summary: Сведения об аккаунте и правах ключа
      operationId: getMe
      responses:
        "200":
          description: Учётная запись
          content:
            application/json:
              schema:
                type: object
                required: [data, warnings]
                properties:
                  data:
                    $ref: "#/components/schemas/Account"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
              example:
                data:
                  account_id: acc_9f2b7c1d4e0a
                  email: dima@deulin.com.ua
                  plan: pro
                  plan_until: "2026-09-27T00:00:00Z"
                  project_limit: null
                  projects_used: 6
                  timezone_known: true
                  scopes: [work.read, work.write, projects.read, projects.write]
                warnings: []
        "401":
          $ref: "#/components/responses/Unauthorized"

  /projects:
    get:
      tags: [Проекты]
      summary: Список проектов
      operationId: listProjects
      parameters:
        - name: state
          in: query
          description: Какие проекты вернуть.
          schema:
            type: string
            enum: [active, archived, all]
            default: active
      responses:
        "200":
          description: Проекты
          content:
            application/json:
              schema:
                type: object
                required: [data, warnings]
                properties:
                  data:
                    type: object
                    required: [projects]
                    properties:
                      projects:
                        type: array
                        items:
                          $ref: "#/components/schemas/Project"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
              example:
                data:
                  projects:
                    - id: 3Kq1xYt0aB
                      name: Acme — интеграция
                      rate: "45.00"
                      currency: USD
                      state: active
                      report:
                        period: month
                        days: []
                        week_start: mon
                      sheet:
                        connected: true
                        url: https://docs.google.com/spreadsheets/d/1AbC…/edit#gid=1804
                warnings: []
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Проекты]
      summary: Завести проект
      description: |
        Повтор с тем же названием возвращает существующий проект и добавляет
        предупреждение `project_exists`.
      operationId: createProject
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProjectCreate"
            example:
              name: Acme — интеграция
              rate: "45.00"
              currency: USD
              report:
                period: month
              timezone: Europe/Kyiv
      responses:
        "201":
          description: Проект заведён
          content:
            application/json:
              schema:
                type: object
                required: [data, warnings]
                properties:
                  data:
                    type: object
                    properties:
                      project:
                        $ref: "#/components/schemas/Project"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Ограничение плана
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: plan_limit
                  message: Project limit reached for this plan
                  details: { plan: free, project_limit: 1, projects_used: 1 }
                  retryable: false
                  doc: https://workday.money/api/errors#plan_limit
                  request_id: req_01J8ZK7QB2V8Y

  /projects/{project_id}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Проекты]
      summary: Один проект
      operationId: getProject
      responses:
        "200":
          description: Проект
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      project:
                        $ref: "#/components/schemas/Project"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Проекты]
      summary: Изменить проект
      description: |
        Меняет только переданные поля. Новая ставка применяется к работе,
        записанной после изменения; стоимость записанных отрезков не меняется.
        Архивирование и возврат из архива — поле `state`.
      operationId: updateProject
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProjectUpdate"
            example:
              rate: "50.00"
              report:
                period: days
                days: [1, 15]
      responses:
        "200":
          description: Изменён
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      project:
                        $ref: "#/components/schemas/Project"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Проекты]
      summary: Недоступно
      description: |
        Удаление проекта через API не поддерживается. Используйте `PATCH` с
        `state: "archived"`.
      operationId: deleteProjectNotAllowed
      responses:
        "405":
          description: Метод не поддерживается
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: method_not_allowed
                  message: "Use PATCH with state=archived"
                  retryable: false
                  doc: https://workday.money/api/errors#method_not_allowed
                  request_id: req_01J8ZK7QB2V8Y

  /work:
    get:
      tags: [Работа]
      summary: Незавершённые задачи
      description: Идущие, приостановленные и заведённые задачи. Завершённых здесь нет.
      operationId: listWork
      parameters:
        - name: state
          in: query
          schema:
            type: string
            enum: [running, paused, all]
            default: all
        - name: project_id
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Незавершённая работа
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      as_of:
                        type: string
                        format: date-time
                      draft_version:
                        type: integer
                        description: Версия состояния для условных запросов.
                      tasks:
                        type: array
                        items:
                          $ref: "#/components/schemas/Task"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
              example:
                data:
                  as_of: "2026-08-12T09:31:02Z"
                  draft_version: 57
                  tasks:
                    - id: 0c1b7f22-9a44-4f1e-8c2d-7b3a1e5d0f88
                      project_id: 3Kq1xYt0aB
                      title: Разбор выгрузки
                      state: running
                      earned: "37.50"
                      currency: USD
                      elapsed_seconds: 3000
                      billable_seconds: 3000
                      limit_seconds: 7200
                      unpaid: false
                      started_at: "2026-08-12T08:41:02Z"
                warnings: []

  /work/start:
    post:
      tags: [Работа]
      summary: Начать работу
      description: |
        Создаёт задачу и запускает отсчёт. Отсчёт идёт по часам сервера.

        Повтор с тем же названием в том же проекте возвращает существующую
        задачу с предупреждением `task_exists`.
      operationId: startWork
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StartRequest"
            example:
              project_id: 3Kq1xYt0aB
              title: Разбор выгрузки
              limit_seconds: 7200
              timezone: Europe/Kyiv
              on_running: pause_others
      responses:
        "200":
          description: Задача идёт
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      task:
                        $ref: "#/components/schemas/Task"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: "Другая задача уже идёт, при `on_running: reject`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /work/pause:
    post:
      tags: [Работа]
      summary: Приостановить
      description: |
        Останавливает отсчёт без записи. Накопленное время остаётся у задачи.
        Принимает `task_id` либо `all: true`.
      operationId: pauseWork
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PauseRequest"
            example:
              task_id: 0c1b7f22-9a44-4f1e-8c2d-7b3a1e5d0f88
              timezone: Europe/Kyiv
      responses:
        "200":
          description: Остановлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      paused:
                        type: array
                        items:
                          $ref: "#/components/schemas/Task"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
        "400":
          $ref: "#/components/responses/BadRequest"

  /work/finish:
    post:
      tags: [Работа]
      summary: Завершить задачу и записать работу
      description: |
        Записывает отрезки работы — по одному на каждый календарный день, в
        который шла задача, — и удаляет задачу из незавершённых. Отменить
        нельзя.

        Заголовок `Idempotency-Key` обязателен. Повтор с тем же ключом
        возвращает прежний ответ без повторной записи. Ключ хранится 90 дней.

        Если к проекту подключена таблица и строка в неё не записана, в
        `warnings` приходит `sheet_not_written` с перечнем `entry_ids`.
        Повторная отправка строки не выполняется.
      operationId: finishWork
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FinishRequest"
            example:
              task_id: 0c1b7f22-9a44-4f1e-8c2d-7b3a1e5d0f88
              timezone: Europe/Kyiv
      responses:
        "200":
          description: Записано
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      finished:
                        $ref: "#/components/schemas/FinishedTask"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
              example:
                data:
                  finished:
                    task_id: 0c1b7f22-9a44-4f1e-8c2d-7b3a1e5d0f88
                    project_id: 3Kq1xYt0aB
                    earned: "45.00"
                    currency: USD
                    billable_seconds: 3600
                    actual_seconds: 3720
                    entries:
                      - id: 0c1b7f22-9a44-4f1e-8c2d-7b3a1e5d0f88_2026-08-12
                        work_date: "2026-08-12"
                        earned: "45.00"
                        currency: USD
                        billable_seconds: 3600
                        actual_seconds: 3720
                warnings:
                  - code: sheet_not_written
                    message: The row was not written to the project spreadsheet.
                    details:
                      entry_ids: [0c1b7f22-9a44-4f1e-8c2d-7b3a1e5d0f88_2026-08-12]
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Ключ повтора занят другим запросом
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /work/{task_id}:
    parameters:
      - $ref: "#/components/parameters/TaskId"
    patch:
      tags: [Работа]
      summary: Изменить незавершённую задачу
      description: |
        Меняет лимит оплачиваемого времени и отметку «без оплаты». Название и
        накопленное время не изменяются.
      operationId: updateTask
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                limit_seconds:
                  type: integer
                  minimum: 0
                  description: |
                    Предел оплачиваемого времени. Время сверх предела
                    записывается, но не оплачивается. `0` — предела нет.
                unpaid:
                  type: boolean
                  description: Время учитывается, стоимость равна нулю.
            example:
              limit_seconds: 0
              unpaid: true
      responses:
        "200":
          description: Изменена
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      task:
                        $ref: "#/components/schemas/Task"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
        "404":
          $ref: "#/components/responses/NotFound"

  /work/{task_id}/trim:
    parameters:
      - $ref: "#/components/parameters/TaskId"
    post:
      tags: [Работа]
      summary: Уменьшить время незавершённой задачи
      description: |
        Задаёт итоговое время задачи. Значение должно быть меньше текущего:
        увеличение недоступно (`409 trim_not_downward`). Задача должна быть
        остановлена (`409 task_running`).
      operationId: trimTask
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [remaining_seconds]
              properties:
                remaining_seconds:
                  type: integer
                  minimum: 0
            example:
              remaining_seconds: 1800
      responses:
        "200":
          description: Изменено
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      task:
                        $ref: "#/components/schemas/Task"
                  warnings:
                    $ref: "#/components/schemas/Warnings"
        "409":
          description: Задача идёт либо запрошено увеличение
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
              example:
                error:
                  code: trim_not_downward
                  message: remaining_seconds must be less than current
                  details: { current_seconds: 1500 }
                  retryable: false
                  doc: https://workday.money/api/errors#trim_not_downward
                  request_id: req_01J8ZK7QB2V8Y

  /today:
    get:
      tags: [Отчёты]
      summary: Итоги дня
      description: Итоги по проектам и валютам за календарный день пользователя.
      operationId: getToday
      parameters:
        - name: date
          in: query
          schema:
            type: string
            format: date
        - $ref: "#/components/parameters/Timezone"
      responses:
        "200":
          description: Итоги
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      date:
                        type: string
                        format: date
                      totals:
                        type: array
                        items:
                          $ref: "#/components/schemas/DayTotal"
                  warnings:
                    $ref: "#/components/schemas/Warnings"

  /report:
    get:
      tags: [Отчёты]
      summary: Записанная работа за период
      operationId: getReport
      parameters:
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: project_id
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            default: 200
            maximum: 1000
        - name: cursor
          in: query
          description: Значение `next_cursor` из предыдущего ответа.
          schema:
            type: string
      responses:
        "200":
          description: Записи
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      totals:
                        type: array
                        items:
                          $ref: "#/components/schemas/DayTotal"
                      entries:
                        type: array
                        items:
                          $ref: "#/components/schemas/Entry"
                      next_cursor:
                        type: [string, "null"]
                  warnings:
                    $ref: "#/components/schemas/Warnings"
        "400":
          $ref: "#/components/responses/BadRequest"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Ключ выдаётся в приложении: меню → «AI-агент и API» → «Создать ключ».
        Показывается один раз. Передаётся только заголовком `Authorization`.

  parameters:
    ProjectId:
      name: project_id
      in: path
      required: true
      schema:
        type: string
    TaskId:
      name: task_id
      in: path
      required: true
      schema:
        type: string
    Timezone:
      name: timezone
      in: query
      description: |
        Часовой пояс, например `Europe/Kyiv`. Альтернатива —
        `utc_offset_minutes` (минуты вперёд от UTC).
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Уникальная строка запроса, например UUID. Повтор с тем же ключом
        возвращает прежний ответ.
      schema:
        type: string

  responses:
    Unauthorized:
      description: Ключ не передан или не распознан
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    BadRequest:
      description: Некорректный запрос
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    NotFound:
      description: Объект не найден
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

  schemas:
    Money:
      type: string
      pattern: "^-?\\d+\\.\\d{2}$"
      examples: ["112.50"]
      description: Сумма строкой с двумя знаками после точки.

    Currency:
      type: string
      examples: [UAH, USD, EUR, KZT]

    Warnings:
      type: array
      description: Предупреждения при успешном запросе. Поле присутствует всегда.
      items:
        type: object
        required: [code, message]
        properties:
          code:
            type: string
          message:
            type: string
          details:
            type: object

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, retryable, request_id]
          properties:
            code:
              type: string
              description: Машинный признак отказа. Разбирать следует его.
            message:
              type: string
              description: Пояснение на английском. Может изменяться.
            details:
              type: object
            retryable:
              type: boolean
              description: Имеет ли смысл повтор запроса.
            doc:
              type: string
              format: uri
            request_id:
              type: string

    Account:
      type: object
      properties:
        account_id: { type: string }
        email: { type: string, format: email }
        plan:
          type: string
          enum: [free, trial, pro]
        plan_until: { type: [string, "null"], format: date-time }
        trial_ends_at: { type: [string, "null"], format: date-time }
        project_limit:
          type: [integer, "null"]
          description: Предел числа проектов. `null` — без предела.
        projects_used: { type: integer }
        timezone_known:
          type: boolean
          description: |
            Сохранён ли часовой пояс у аккаунта. Если `false`, пишущие запросы
            должны передавать его сами.
        scopes:
          type: array
          items: { type: string }

    Project:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        rate: { $ref: "#/components/schemas/Money" }
        currency: { $ref: "#/components/schemas/Currency" }
        state:
          type: string
          enum: [active, archived]
        report:
          type: object
          properties:
            period:
              type: string
              enum: [none, week, semimonth, month, days]
              description: Разбивка отчёта для заказчика.
            days:
              type: array
              items: { type: integer, minimum: 1, maximum: 31 }
              description: "Числа месяца; только при `period: days`."
            week_start:
              type: string
              enum: [mon, sun]
        sheet:
          type: object
          properties:
            connected: { type: boolean }
            url: { type: [string, "null"], format: uri }
            unreachable:
              type: boolean
              description: Таблица недоступна — удалена или закрыт доступ.

    ProjectCreate:
      type: object
      additionalProperties: false
      required: [name, rate, currency]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        rate: { $ref: "#/components/schemas/Money" }
        currency: { $ref: "#/components/schemas/Currency" }
        report:
          type: object
          additionalProperties: false
          properties:
            period:
              type: string
              enum: [none, week, semimonth, month, days]
            days:
              type: array
              items: { type: integer, minimum: 1, maximum: 31 }
            week_start:
              type: string
              enum: [mon, sun]
        timezone: { type: string }
        utc_offset_minutes: { type: integer, minimum: -840, maximum: 840 }

    ProjectUpdate:
      type: object
      additionalProperties: false
      properties:
        name: { type: string }
        rate: { $ref: "#/components/schemas/Money" }
        currency: { $ref: "#/components/schemas/Currency" }
        state:
          type: string
          enum: [active, archived]
        report:
          type: object
          additionalProperties: false
          properties:
            period: { type: string, enum: [none, week, semimonth, month, days] }
            days:
              type: array
              items: { type: integer, minimum: 1, maximum: 31 }
            week_start: { type: string, enum: [mon, sun] }

    Task:
      type: object
      description: Незавершённая задача.
      properties:
        id: { type: string }
        project_id: { type: string }
        title: { type: string }
        state:
          type: string
          enum: [running, paused, idle]
        earned: { $ref: "#/components/schemas/Money" }
        currency: { $ref: "#/components/schemas/Currency" }
        elapsed_seconds:
          type: integer
          description: Накопленное время.
        billable_seconds:
          type: integer
          description: Оплачиваемая часть накопленного времени.
        limit_seconds: { type: integer }
        unpaid: { type: boolean }
        started_at: { type: [string, "null"], format: date-time }

    StartRequest:
      type: object
      additionalProperties: false
      required: [project_id, title]
      properties:
        project_id: { type: string }
        title: { type: string, minLength: 1, maxLength: 200 }
        limit_seconds: { type: integer, minimum: 0 }
        unpaid: { type: boolean }
        timezone: { type: string }
        utc_offset_minutes: { type: integer, minimum: -840, maximum: 840 }
        on_running:
          type: string
          enum: [allow, pause_others, reject]
          default: allow
          description: |
            Поведение, если другая задача уже идёт: `allow` — запустить
            параллельно, `pause_others` — остановить остальные, `reject` —
            вернуть `409`.

    PauseRequest:
      type: object
      additionalProperties: false
      properties:
        task_id: { type: string }
        all: { type: boolean }
        project_id: { type: string }
        timezone: { type: string }
        utc_offset_minutes: { type: integer, minimum: -840, maximum: 840 }

    FinishRequest:
      type: object
      additionalProperties: false
      required: [task_id]
      properties:
        task_id: { type: string }
        timezone: { type: string }
        utc_offset_minutes: { type: integer, minimum: -840, maximum: 840 }

    Entry:
      type: object
      description: Записанный отрезок работы. Не изменяется.
      properties:
        id: { type: string }
        project_id: { type: string }
        task: { type: string }
        work_date: { type: string, format: date }
        earned: { $ref: "#/components/schemas/Money" }
        currency: { $ref: "#/components/schemas/Currency" }
        billable_seconds: { type: integer }
        actual_seconds:
          type: integer
          description: Фактическое время, включая неоплачиваемое.
        rate:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: Ставка на момент записи.
        recorded_at: { type: string, format: date-time }

    FinishedTask:
      type: object
      properties:
        task_id: { type: string }
        project_id: { type: string }
        earned: { $ref: "#/components/schemas/Money" }
        currency: { $ref: "#/components/schemas/Currency" }
        billable_seconds: { type: integer }
        actual_seconds: { type: integer }
        entries:
          type: array
          description: По одной записи на каждый календарный день работы.
          items:
            $ref: "#/components/schemas/Entry"

    DayTotal:
      type: object
      properties:
        project_id: { type: string }
        project_name: { type: string }
        earned: { $ref: "#/components/schemas/Money" }
        currency: { $ref: "#/components/schemas/Currency" }
        billable_seconds: { type: integer }
