Implement ecosystem contract v2: capabilities, shared ledgers, attachments
- GET /v1/capabilities and the extended sync entity allowlist (§0, §1). - Shared ledgers (§7): ledgers, members and hashed one-time invite codes; X-Balance-Ledger selects the space on /v1/sync and /v1/attachments with its own records, change stream and cursor. Personal space keeps the v1 tables and behaviour; the migration only adds tables. - Attachments (§8): SHA-256, size and media type checks, blob files in BALANCE_ATTACHMENTS_DIR (default: attachments next to the database), garbage collection of unreferenced blobs with a grace period for fresh uploads, and ledger deletion removes its blobs. - Tests for every new endpoint and error code; API, sync, data model, security, deployment and README docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10272f3851
commit
dfb4fe4e1a
17 files changed
+2496
-56
No files matched your search
@@ -0,0 +1,258 @@
|
||||
# Контракт экосистемы 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:<recurringId>:<occurrence>` (нижний регистр). Два устройства
|
||||
создают одну и ту же запись — дублей нет.
|
||||
- `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: <id>` на
|
||||
`/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 до своего обновления.
|
||||
Reference in new issue
Block a user