# Контракт экосистемы 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` (ключ). ### Возможности сервера `GET /v1/capabilities` (без авторизации): ```json { "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 ```json {"name":"Карта","kindRawValue":"card","icon":"creditcard.fill", "colorName":"blue","currency":"RUB","createdAt":"…","isArchived":false, "sortOrder":0} ``` `kindRawValue`: `cash` | `card` | `deposit` | `other` (незнакомое → `other`). `currency` по умолчанию — базовая. ### transfer ```json {"fromAccountId":"…","toAccountId":"…","amount":1000.0,"toAmount":10.5, "date":"…","note":""} ``` `toAmount` нужен, когда валюты счетов различаются; по умолчанию = `amount`. `amount` — в валюте счёта-источника, `toAmount` — в валюте счёта-получателя. ### recurring ```json {"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 ```json {"name":"Отпуск","target":150000.0,"currency":"RUB","deadline":null, "accountId":null,"saved":20000.0,"icon":"airplane","colorName":"sky", "createdAt":"…","isArchived":false} ``` Прогресс = баланс счёта `accountId`, если он задан, иначе `saved`. ### debt ```json {"person":"Иван","direction":"owedToMe","amount":5000.0,"currency":"RUB", "date":"…","dueDate":null,"note":"","repaid":1000.0,"isClosed":false} ``` `direction`: `iOwe` | `owedToMe`. Остаток = `amount − repaid`. Долги **не** входят в баланс и аналитику: деньги, которые реально ушли или пришли, пользователь записывает обычной операцией. ### rate ```json {"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 ```json {"transactionId":"…","sha256":"<64 hex>","contentType":"image/jpeg", "size":182345,"name":"чек.jpg","createdAt":"…"} ``` Метаданные синхронизируются обычным `/v1/sync`; сами байты — через §8. ## 2. Валюты и курсы - Валюта транзакции: `currency`, иначе валюта её счёта, иначе базовая. - Перевод суммы `x` из `C` в базовую `B`: при `C == B` без изменений; иначе берётся последняя (по `date`, затем по `updatedAt`) запись `rate` с `from=C,to=B` или обратная `from=B,to=C` (тогда делим). Если подходящих записей несколько с одной датой, прямая пара важнее обратной. - Результат округляется до сотых половиной вверх (от нуля). Считать через целые сотые и десятичную строку курса, не через `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` не создаётся. - `occurrence` = UTC-дата `YYYY-MM-DD`; дата транзакции = эта дата + время суток `startDate`. - ID транзакции **детерминирован**: UUID v5 от пространства имён `6f1c3f5e-8a8d-4c3a-9d2b-4b1f0e7a2c11` и строки `recurring::` (нижний регистр). Два устройства создают одну и ту же запись — дублей нет. - `auto`: клиент создаёт все повторения в `(generatedThrough, сегодня UTC]` (с `startDate`, если `generatedThrough` пуст) и ставит `generatedThrough` = последнее обработанное. Удалённое пользователем повторение не воскрешается, т.к. `generatedThrough` уже позади. Не более 366 повторений за раз. - `remind`: клиент показывает повторения из того же окна как «ожидают»; «Записать» создаёт транзакцию с тем же детерминированным ID, «Пропустить» только двигает `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: ` на `/v1/sync` и `/v1/attachments/*`. Без заголовка — личное пространство. Не участник → `403 forbidden`, несуществующее → `404 not_found`. - Владелец не может выйти, пока есть участники (только удалить пространство). - Все участники равны в правах на записи (last-write-wins как в v1). - Клиент хранит данные пространства отдельно: scope = сервер + пользователь + пространство. Переключение пространства не переносит записи. Курсор и очередь изменений — свои у каждого пространства. - Ошибки: `invalid_code` (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. - Файлы хранятся в пространстве; один и тот же файл в двух пространствах хранится дважды. - Когда в пространстве не осталось живых записей `attachment` с этим `sha256`, сервер может удалить файл. - Ошибки: `payload_too_large` (413), `unsupported_media_type` (415), `hash_mismatch` (400). - Клиент скачивает файл лениво при просмотре и кеширует локально. ## 9. Совместимость с клиентами v1 - Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и общие пространства; транзакции в других валютах складывает как есть. - При редактировании транзакции старый клиент теряет `accountId`, `currency`, `tags`, `recurringId`, `occurrence`. Поэтому все клиенты экосистемы обновляются вместе с сервером. - Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются на v1 до своего обновления.