Files
BalanceServer/docs/CONTRACT_V2.md
T
wtandClaude Opus 5.5 7fbe86a943
CI / Rust (fmt / clippy / test / build) (push) Successful in 1m4s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 1m36s
Contract v2: clarify ids, rates, recurring, ledger and attachment details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 23:51:38 +07:00

19 KiB
Raw Permalink Blame History

Контракт экосистемы v2

Общий документ BalanceServer, BalanceFlutter и BalanceWatch. Одинаковая копия лежит в каждом репозитории: docs/ecosystem/CONTRACT_V2.md (Flutter), docs/CONTRACT_V2.md (сервер, часы). Меняется только во всех трёх сразу.

v2 добавляет счета и переводы, регулярные операции, мультивалютность, цели, теги, вложения (фото чеков), общие бюджеты и долги. Всё сделано добавлением: формат /v1/sync, курсор, sequence, updatedAt, tombstone и last-write-wins не меняются. Клиент v1 продолжает работать с сервером v2, но не видит новых данных (ограничения — в конце).

0. Общие правила

  1. Незнакомая сущность при pull пропускается молча (курсор двигается).
  2. Незнакомые поля payload знакомой сущности клиент v2 сохраняет и отправляет обратно без изменений при следующем сохранении записи (forward compatibility). Клиент v1 их теряет — поэтому обновлять нужно все клиенты.
  3. Все новые поля payload необязательные. Отсутствие поля = значение по умолчанию, указанное ниже.
  4. Деньги на проводе — числа (amount, limit, target…), как в v1; внутри клиентов — целые сотые. Курс валют — строка десятичного числа.
  5. Даты — ISO 8601 UTC. Интервалы — [начало, конец).
  6. Коды валют — ISO 4217 в верхнем регистре (RUB, EUR, USD, SEK, …).
  7. Новые записи получают UUID в нижнем регистре (кроме setting, где id — ключ). Сервер сравнивает id как есть, с учётом регистра, поэтому клиент хранит id полученной записи в точности как пришёл и отправляет его так же (старые клиенты могли создавать UUID в верхнем регистре).

Возможности сервера

GET /v1/capabilities (без авторизации):

{
  "contract": 2,
  "entities": ["transaction","category","budget","account","transfer",
               "recurring","goal","debt","rate","setting","attachment"],
  "attachments": {"maxBytes": 10485760,
                  "types": ["image/jpeg","image/png","image/heic","application/pdf"]},
  "ledgers": true
}

Сервер v1 отвечает 404 — клиент v2 тогда скрывает вложения и общие бюджеты и не отправляет новые сущности.

1. Сущности

Белый список сервера: transaction, category, budget, account, transfer, recurring, goal, debt, rate, setting, attachment.

transaction — новые поля

Поле Тип По умолчанию Смысл
accountId string? нет счёт; без него — «Без счёта»
currency string? валюта счёта, иначе базовая валюта суммы
tags [string]? [] теги, см. §6
recurringId string? нет создана из регулярной операции
occurrence string? нет дата повторения YYYY-MM-DD

kindRawValue остаётся income / expense. Перевод — не транзакция.

account

{"name":"Карта","kindRawValue":"card","icon":"creditcard.fill",
 "colorName":"blue","currency":"RUB","createdAt":"…","isArchived":false,
 "sortOrder":0}

kindRawValue: cash | card | deposit | other (незнакомое → other). Клиент v2 записывает currency явно при создании счёта. Отсутствие поля (старая запись) = текущая базовая валюта.

transfer

{"fromAccountId":"…","toAccountId":"…","amount":1000.0,"toAmount":10.5,
 "date":"…","note":""}

toAmount нужен, когда валюты счетов различаются; по умолчанию = amount. amount — в валюте счёта-источника, toAmount — в валюте счёта-получателя.

recurring

{"kindRawValue":"expense","amount":990.0,"currency":"RUB","accountId":null,
 "categoryName":"Подписки","categoryIcon":"repeat","categoryEmoji":"",
 "categoryColorName":"cyan","note":"Связь","tags":[],
 "frequency":"monthly","interval":1,"startDate":"2026-01-05T09:00:00Z",
 "endDate":null,"mode":"auto","isPaused":false,"generatedThrough":null}

frequency: daily | weekly | monthly | yearly; interval ≥ 1; mode: auto (создаётся само) | remind (напоминание, пользователь подтверждает). generatedThrough — дата YYYY-MM-DD последнего обработанного повторения.

goal

{"name":"Отпуск","target":150000.0,"currency":"RUB","deadline":null,
 "accountId":null,"saved":20000.0,"icon":"airplane","colorName":"sky",
 "createdAt":"…","isArchived":false}

Прогресс = баланс счёта accountId, если он задан, иначе saved.

debt

{"person":"Иван","direction":"owedToMe","amount":5000.0,"currency":"RUB",
 "date":"…","dueDate":null,"note":"","repaid":1000.0,"isClosed":false}

direction: iOwe | owedToMe. Остаток = amount − repaid. Долги не входят в баланс и аналитику: деньги, которые реально ушли или пришли, пользователь записывает обычной операцией.

rate

{"from":"USD","to":"RUB","rate":"92.35","date":"2026-10-31T00:00:00Z"}

rate — сколько единиц to стоит одна единица from, до 6 знаков после точки.

setting

id — ключ, payload {"value": …}. Сейчас ключ один: baseCurrency (строка ISO 4217) — валюта, в которой считаются общие суммы. Если записи нет, базовая валюта — локальная настройка устройства (поведение v1).

attachment

{"transactionId":"…","sha256":"<64 hex>","contentType":"image/jpeg",
 "size":182345,"name":"чек.jpg","createdAt":"…"}

Метаданные синхронизируются обычным /v1/sync; сами байты — через §8.

2. Валюты и курсы

  • Валюта транзакции: currency, иначе валюта её счёта, иначе базовая.
  • Перевод суммы x из C в базовую B: при C == B без изменений; иначе берётся запись rate с from=C,to=B или обратная from=B,to=C (тогда делим). Выбор: самая поздняя date не позже даты расчёта (курсы с будущей датой не используются); при равной дате прямая пара важнее обратной; затем более поздний updatedAt.
  • Результат округляется до сотых половиной вверх (от нуля). Считать через целые сотые и десятичную строку курса, не через double-накопление.
  • Нет курса → сумма не входит в общие итоги, клиент показывает предупреждение «нет курса C→B». Внутри счёта суммы складываются в валюте счёта без конвертации.
  • Курс для периода — последний на дату расчёта (не на дату операции).

3. Баланс (расширяет финансовые правила v1)

  • Баланс счёта (в его валюте) = доходы − расходы с этим accountId (включая корректировки) + toAmount входящих переводов − amount исходящих.
  • «Без счёта» = транзакции без accountId (в своих валютах).
  • Общий баланс = сумма всех транзакций в базовой валюте + переводы (входящая часть − исходящая, каждая в базовой валюте). При одной валюте переводы дают ноль, и общий баланс совпадает с v1.
  • Переводы не входят в аналитику и бюджеты. Корректировки — как в v1.
  • Аналитика и бюджеты складывают суммы в базовой валюте.
  • Архивный счёт скрыт из выбора, но его баланс входит в общий.
  • Транзакции и переводы, ссылающиеся на удалённый счёт, остаются; клиент показывает их как «Удалённый счёт».

4. Регулярные операции

  • Повторения считаются в календаре UTC от даты startDate (её UTC-дата и время суток): daily — каждые interval дней, weekly — каждые 7·interval дней, monthly — каждые interval месяцев в тот же день (если дня нет — последний день месяца), yearly — то же с годами (29 февраля → 28 февраля). endDate сравнивается по UTC-дате и включается: повторение в день endDate создаётся, после — нет.
  • occurrence = UTC-дата YYYY-MM-DD; дата транзакции = эта дата + время суток startDate.
  • ID транзакции детерминирован: UUID v5 от пространства имён 6f1c3f5e-8a8d-4c3a-9d2b-4b1f0e7a2c11 и строки recurring:<recurringId>:<occurrence> (нижний регистр). Два устройства создают одну и ту же запись — дублей нет.
  • auto: клиент создаёт все повторения в (generatedThrough, сегодня UTC] (с startDate, если generatedThrough пуст) и ставит generatedThrough = последнее обработанное. Удалённое пользователем повторение не воскрешается, т.к. generatedThrough уже позади. Не более 366 повторений за раз.
  • remind: клиент показывает повторения из того же окна как «ожидают»; «Записать» создаёт транзакцию с тем же детерминированным ID и двигает generatedThrough только через подряд обработанные повторения (если записать более позднее раньше, более раннее остаётся ожидать). «Пропустить» двигает generatedThrough до этого повторения включительно — более ранние ожидающие тоже считаются пропущенными.
  • isPaused — ничего не создаётся и не напоминается.
  • Часы регулярные операции не создают (их создают телефон и десктоп).

5. Цели, долги — см. §1. Цели и долги не меняют баланс.

6. Теги

Строки после trim, 1–32 символа, без учёта регистра уникальны в операции, не больше 10 на операцию. Порядок сохраняется. Аналитика и бюджеты тегами не фильтруются; список операций — фильтруется.

7. Общие бюджеты (ledgers)

Пространство данных (ledger) — отдельный набор записей со своим курсором. У каждого пользователя есть личное пространство (как в v1). Общее пространство создаёт владелец и приглашает участников по коду.

Метод Путь Тело / ответ
GET /v1/ledgers {"ledgers":[{"id","name","role","members":[{"userId","email","role"}]}]} — только общие
POST /v1/ledgers {"name"} → ledger, role:"owner"
PATCH /v1/ledgers/{id} {"name"} (владелец)
DELETE /v1/ledgers/{id} владелец: удалить пространство со всеми записями и вложениями
POST /v1/ledgers/{id}/invites → {"code","expiresAt"}; код из 10 символов, 7 дней, одноразовый (владелец)
POST /v1/ledgers/join {"code"} → ledger, role:"member"
DELETE /v1/ledgers/{id}/members/{userId} владелец удаляет участника; участник — себя (userId = свой)
  • Пространство выбирается заголовком X-Balance-Ledger: <id> на /v1/sync и /v1/attachments/*. Без заголовка — личное пространство. Не участник → 403 forbidden, несуществующее → 404 not_found.
  • Владелец не может выйти (409 owner_cannot_leave), даже из пустого пространства — только удалить его.
  • Ответы: создание пространства и приглашения — 201; вход по коду и переименование — 200; удаление пространства и участника — 204. Повторный вход участника возвращает пространство и не тратит код. /v1/ledgers/join ограничен по частоте вместе со входом (30 запросов в минуту с IP).
  • Клиент, получивший 403/404 для выбранного пространства, может сам вернуться в личное (Flutter) или показать ошибку и предложить переключиться (часы).
  • Все участники равны в правах на записи (last-write-wins как в v1).
  • Клиент хранит данные пространства отдельно: scope = сервер + пользователь
    • пространство. Переключение пространства не переносит записи. Курсор и очередь изменений — свои у каждого пространства.
  • Ошибки: invalid_code (400), invalid_name (400: пустое после trim, длиннее 100 символов или с управляющими символами), invalid_json (400), forbidden (403), not_found (404, в том числе удаление не-участника), owner_cannot_leave (409). Коды приглашений хранятся на сервере только как хеш.

8. Вложения

Метод Путь Смысл
PUT /v1/attachments/{sha256} тело — байты файла, Content-Type из списка; сервер проверяет SHA-256 и размер; повтор идемпотентен (200, первый раз 201)
GET /v1/attachments/{sha256} байты и Content-Type; 404, если в пространстве нет такого файла
  • Порядок клиента: сначала PUT байтов, потом запись attachment через sync.
  • Файлы хранятся в пространстве; один и тот же файл в двух пространствах хранится дважды.
  • Ответ PUT: {"sha256","size","contentType"}. GET отдаёт Content-Disposition: attachment.
  • Когда в пространстве не осталось живых записей attachment с этим sha256, сервер может удалить файл. Файл, на который ещё не ссылалась ни одна запись, хранится не меньше 24 часов после загрузки (клиент загружает байты раньше записи).
  • Лимит тела 10 МиБ действует только для загрузки вложения; остальные запросы — 2 МиБ, как в v1.
  • Ошибки: payload_too_large (413), unsupported_media_type (415), hash_mismatch (400, в том числе неверный формат хеша в пути).
  • Клиент скачивает файл лениво при просмотре и кеширует локально.
  • Сообщение ошибки для незнакомой сущности на сервере осталось как в v1 («entity must be transaction, category, or budget») ради совместимости с записанными ответами; код invalid_change верный.

9. Совместимость с клиентами v1

  • Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и общие пространства; транзакции в других валютах складывает как есть.
  • При редактировании транзакции старый клиент теряет accountId, currency, tags, recurringId, occurrence. Поэтому все клиенты экосистемы обновляются вместе с сервером.
  • Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются на v1 до своего обновления.