- 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>
16 KiB
Контракт экосистемы 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. Общие правила
- Незнакомая сущность при pull пропускается молча (курсор двигается).
- Незнакомые поля payload знакомой сущности клиент v2 сохраняет и отправляет обратно без изменений при следующем сохранении записи (forward compatibility). Клиент v1 их теряет — поэтому обновлять нужно все клиенты.
- Все новые поля payload необязательные. Отсутствие поля = значение по умолчанию, указанное ниже.
- Деньги на проводе — числа (
amount,limit,target…), как в v1; внутри клиентов — целые сотые. Курс валют — строка десятичного числа. - Даты — ISO 8601 UTC. Интервалы —
[начало, конец). - Коды валют — ISO 4217 в верхнем регистре (
RUB,EUR,USD,SEK, …). - Идентификаторы записей — UUID в нижнем регистре, кроме
setting(ключ).
Возможности сервера
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).
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без изменений; иначе берётся последняя (по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 до своего обновления.