288 lines
19 KiB
Markdown
288 lines
19 KiB
Markdown
# Контракт экосистемы 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 до своего обновления.
|