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

288 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Контракт экосистемы 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` (без авторизации):
```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`).
Клиент v2 записывает `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` без изменений; иначе
берётся запись `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 до своего обновления.