openapi: 3.1.0
info:
  title: Поровну API
  version: 1.0.0
  summary: Учёт общих расходов группы без аккаунтов
  description: |
    Комната определяется адресом из 128 бит (22 символа base64url) и даёт право чтения.
    Право записи даёт отдельный ключ редактирования (тоже 128 бит), его выдают один раз при создании комнаты
    и передают заголовком `Authorization: Bearer <key>`. Сервер хранит только SHA-256 ключа.

    Все деньги это целые числа минорных единиц валюты (копейки, центы), курс это целое число миллионных долей
    (1.0 = 1000000). Числа с плавающей точкой не принимаются.

    Оптимистичная блокировка: каждая запись требует заголовок `If-Match: <version>`, версию комнаты, которую видел клиент.
    Совпадение: применить. Версия устарела, но тот же объект не менялся: слияние. Тот же объект менялся: `409 conflict`.

    Только JSON. Cookie не используются. CORS закрыт, если origin не в списке разрешённых.
  contact: { name: Ewsir, url: 'https://t.me/Ewsirdev' }
servers:
  - url: https://ewsirdev-budget.ewsirdev.workers.dev
security: []
tags:
  - { name: rooms }
  - { name: expenses }
  - { name: members }
  - { name: read }
paths:
  /api/health:
    get:
      operationId: health
      summary: Проверка живости и доступности D1
      responses:
        '200': { description: Работает, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, time: { type: integer } } } } } }
        '503': { description: База недоступна }
  /api/rooms:
    post:
      operationId: createRoom
      tags: [rooms]
      summary: Создать комнату
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateRoom' }
      responses:
        '201':
          description: Комната создана. `editToken` показывается только сейчас.
          content: { application/json: { schema: { $ref: '#/components/schemas/Created' } } }
        '400': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/rooms/import:
    post:
      operationId: importRoom
      tags: [rooms]
      summary: Создать новую комнату из JSON-выгрузки
      description: Документ проверяется целиком (до 1000 расходов, 640 КБ). Создаётся новая комната с новыми ключами, исходная не меняется.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/ExportDoc' } } }
      responses:
        '201': { description: Создано, content: { application/json: { schema: { $ref: '#/components/schemas/Created' } } } }
        '400': { $ref: '#/components/responses/Error' }
        '413': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/rooms/{id}:
    parameters: [{ $ref: '#/components/parameters/RoomId' }]
    get:
      operationId: getRoom
      tags: [read]
      summary: Полное состояние комнаты
      description: С ключом в `Authorization` поле `canEdit` равно true. Поддерживает `If-None-Match` (ETag зависит от версии и права).
      parameters:
        - { name: If-None-Match, in: header, schema: { type: string } }
        - { $ref: '#/components/parameters/Auth' }
      responses:
        '200': { description: Снимок, headers: { ETag: { schema: { type: string } } }, content: { application/json: { schema: { $ref: '#/components/schemas/Snapshot' } } } }
        '304': { description: Не изменилось }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      operationId: renameRoom
      tags: [rooms]
      summary: Переименовать комнату
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [name], additionalProperties: false, properties: { name: { type: string, maxLength: 60 } } } } } }
      responses:
        '200': { $ref: '#/components/responses/Written' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Conflict' }
        '428': { $ref: '#/components/responses/Error' }
    delete:
      operationId: deleteRoom
      tags: [rooms]
      summary: Удалить комнату навсегда
      description: Нужны ключ редактирования, точная версия в `If-Match` и название комнаты в теле как подтверждение.
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [confirm], additionalProperties: false, properties: { confirm: { type: string } } } } } }
      responses:
        '204': { description: Удалено вместе со всеми данными }
        '400': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Conflict' }
  /api/rooms/{id}/settle:
    parameters: [{ $ref: '#/components/parameters/RoomId' }]
    get:
      operationId: settle
      tags: [read]
      summary: Балансы и переводы «кто кому»
      description: Жадный алгоритм по открытым расходам. Переводов не больше k−1 для k участников с ненулевым балансом.
      responses:
        '200': { description: Расчёт, content: { application/json: { schema: { $ref: '#/components/schemas/Settlement' } } } }
        '404': { $ref: '#/components/responses/Error' }
  /api/rooms/{id}/log:
    parameters: [{ $ref: '#/components/parameters/RoomId' }]
    get:
      operationId: log
      tags: [read]
      summary: Журнал изменений (новые сверху)
      parameters:
        - { name: before, in: query, schema: { type: integer }, description: Версии строго меньше этой }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        '200': { description: Страница журнала, content: { application/json: { schema: { type: object, properties: { version: { type: integer }, entries: { type: array, items: { $ref: '#/components/schemas/LogEntry' } } } } } } }
  /api/rooms/{id}/export:
    parameters: [{ $ref: '#/components/parameters/RoomId' }]
    get:
      operationId: exportRoom
      tags: [read]
      summary: Выгрузка CSV или JSON
      description: CSV защищён от формул (ячейки с `= + - @` в начале получают апостроф), UTF-8 с BOM.
      parameters: [{ name: format, in: query, schema: { type: string, enum: [json, csv], default: json } }]
      responses:
        '200':
          description: Файл
          content:
            application/json: { schema: { $ref: '#/components/schemas/ExportDoc' } }
            text/csv: { schema: { type: string } }
  /api/rooms/{id}/members:
    parameters: [{ $ref: '#/components/parameters/RoomId' }]
    post:
      operationId: addMember
      tags: [members]
      summary: Добавить участника (до 30)
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [name], additionalProperties: false, properties: { name: { type: string, maxLength: 40 } } } } } }
      responses:
        '200': { $ref: '#/components/responses/Written' }
        '409': { $ref: '#/components/responses/Conflict' }
  /api/rooms/{id}/members/{memberId}:
    parameters: [{ $ref: '#/components/parameters/RoomId' }, { name: memberId, in: path, required: true, schema: { type: string } }]
    patch:
      operationId: patchMember
      tags: [members]
      summary: Переименовать или убрать в архив
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: false, properties: { name: { type: string }, archived: { type: boolean } } } } } }
      responses:
        '200': { $ref: '#/components/responses/Written' }
        '409': { $ref: '#/components/responses/Conflict' }
  /api/rooms/{id}/expenses:
    parameters: [{ $ref: '#/components/parameters/RoomId' }]
    post:
      operationId: addExpense
      tags: [expenses]
      summary: Добавить расход или перевод (до 5000 записей в комнате)
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExpenseInput' } } } }
      responses:
        '200': { $ref: '#/components/responses/Written' }
        '400': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Conflict' }
        '428': { $ref: '#/components/responses/Error' }
  /api/rooms/{id}/expenses/{expenseId}:
    parameters: [{ $ref: '#/components/parameters/RoomId' }, { name: expenseId, in: path, required: true, schema: { type: string } }]
    put:
      operationId: replaceExpense
      tags: [expenses]
      summary: Заменить расход целиком
      description: Конфликтует (409), если этот расход менялся после версии из `If-Match`. Закрытые в расчёте и удалённые расходы менять нельзя.
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExpenseInput' } } } }
      responses:
        '200': { $ref: '#/components/responses/Written' }
        '409': { $ref: '#/components/responses/Conflict' }
    delete:
      operationId: deleteExpense
      tags: [expenses]
      summary: Мягкое удаление (можно вернуть)
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      responses:
        '200': { $ref: '#/components/responses/Written' }
        '409': { $ref: '#/components/responses/Conflict' }
  /api/rooms/{id}/expenses/{expenseId}/restore:
    parameters: [{ $ref: '#/components/parameters/RoomId' }, { name: expenseId, in: path, required: true, schema: { type: string } }]
    post:
      operationId: restoreExpense
      tags: [expenses]
      summary: Вернуть удалённый расход
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      responses:
        '200': { $ref: '#/components/responses/Written' }
        '409': { $ref: '#/components/responses/Conflict' }
  /api/rooms/{id}/close:
    parameters: [{ $ref: '#/components/parameters/RoomId' }]
    post:
      operationId: closePeriod
      tags: [rooms]
      summary: Закрыть расчёт
      description: Замораживает открытые расходы и записывает переводы в историю. `If-Match` должен точно совпасть с текущей версией, иначе 409.
      security: [{ editKey: [] }]
      parameters: [{ $ref: '#/components/parameters/IfMatch' }]
      responses:
        '200': { $ref: '#/components/responses/Written' }
        '409': { $ref: '#/components/responses/Conflict' }
components:
  securitySchemes:
    editKey: { type: http, scheme: bearer, description: 'Ключ редактирования из ответа createRoom (22 символа base64url)' }
  parameters:
    RoomId: { name: id, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{22}$' } }
    IfMatch: { name: If-Match, in: header, required: true, schema: { type: integer }, description: Версия комнаты, которую видел клиент }
    Auth: { name: Authorization, in: header, schema: { type: string }, description: 'Bearer <ключ редактирования>, необязательно для чтения' }
  responses:
    Error:
      description: Ошибка
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Conflict:
      description: Конфликт версий или состояния. Клиент должен перечитать комнату.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    RateLimited:
      description: Слишком много запросов
      headers: { Retry-After: { schema: { type: integer } } }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Written:
      description: Применено. В ответе новая версия и свежий снимок.
      content: { application/json: { schema: { type: object, properties: { version: { type: integer }, expenseId: { type: string }, memberId: { type: string }, period: { type: integer }, snapshot: { $ref: '#/components/schemas/Snapshot' } } } } }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string, examples: [conflict, busy, read_only, bad_token, bad_amount, percent_sum, name_taken, rate_limited] }
        reason: { type: string, description: 'room_changed | entity_changed | stale' }
        version: { type: integer }
    CreateRoom:
      type: object
      required: [name, base]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 60 }
        base: { type: string, description: Валюта расчёта, examples: [RUB] }
        members: { type: array, maxItems: 30, items: { type: string, maxLength: 40 } }
    Created:
      type: object
      properties:
        id: { type: string }
        editToken: { type: string }
        version: { type: integer }
        members: { type: array, items: { $ref: '#/components/schemas/Member' } }
    Member:
      type: object
      properties: { id: { type: string }, name: { type: string }, archived: { type: boolean } }
    SplitEntry:
      type: object
      required: [m]
      additionalProperties: false
      properties:
        m: { type: string, description: id участника }
        v: { type: integer, description: 'equal: игнорируется; shares: 1..1000; amounts: минорные единицы валюты расхода; percent: сотые доли процента (10000 = 100%)' }
    ExpenseInput:
      type: object
      required: [description, amount, currency, payer, mode, split, date]
      additionalProperties: false
      properties:
        kind: { type: string, enum: [expense, transfer], default: expense, description: 'transfer: один получатель, mode equal' }
        description: { type: string, maxLength: 140 }
        amount: { type: integer, minimum: 1, maximum: 99999999999, description: Минорные единицы }
        currency: { type: string }
        rate: { type: integer, description: 'Миллионные доли: сколько валюты комнаты за 1 единицу валюты расхода. Обязателен, если валюта отличается от валюты комнаты' }
        payer: { type: string }
        mode: { type: string, enum: [equal, shares, amounts, percent] }
        split: { type: array, minItems: 1, maxItems: 30, items: { $ref: '#/components/schemas/SplitEntry' } }
        date: { type: string, format: date }
    Expense:
      allOf:
        - $ref: '#/components/schemas/ExpenseInput'
        - type: object
          properties:
            id: { type: string }
            createdAt: { type: integer }
            updatedAt: { type: integer }
            deletedAt: { type: [integer, 'null'] }
            period: { type: [integer, 'null'], description: Номер закрытого расчёта }
    Transfer:
      type: object
      properties: { from: { type: string }, to: { type: string }, amount: { type: integer } }
    Period:
      type: object
      properties: { n: { type: integer }, closedAt: { type: integer }, total: { type: integer }, transfers: { type: array, items: { $ref: '#/components/schemas/Transfer' } } }
    Snapshot:
      type: object
      properties:
        room: { type: object, properties: { id: { type: string }, name: { type: string }, base: { type: string }, version: { type: integer }, createdAt: { type: integer }, updatedAt: { type: integer } } }
        members: { type: array, items: { $ref: '#/components/schemas/Member' } }
        expenses: { type: array, items: { $ref: '#/components/schemas/Expense' } }
        periods: { type: array, items: { $ref: '#/components/schemas/Period' } }
        canEdit: { type: boolean }
    Settlement:
      type: object
      properties:
        base: { type: string }
        version: { type: integer }
        openCount: { type: integer }
        total: { type: integer }
        balances: { type: array, items: { type: object, properties: { member: { type: string }, amount: { type: integer } } } }
        transfers: { type: array, items: { $ref: '#/components/schemas/Transfer' } }
    LogEntry:
      type: object
      properties: { version: { type: integer }, ts: { type: integer }, actor: { type: [string, 'null'] }, action: { type: string }, ref: { type: string }, summary: { type: object } }
    ExportDoc:
      type: object
      required: [format, room, members, expenses, periods]
      properties:
        format: { const: budget-split/1 }
        exportedAt: { type: integer }
        room: { type: object, properties: { name: { type: string }, base: { type: string } } }
        members: { type: array, items: { $ref: '#/components/schemas/Member' } }
        expenses: { type: array, items: { $ref: '#/components/schemas/Expense' } }
        periods: { type: array, items: { $ref: '#/components/schemas/Period' } }
