19 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, гдеid— ключ). Сервер сравниваетidкак есть, с учётом регистра, поэтому клиент хранитidполученной записи в точности как пришёл и отправляет его так же (старые клиенты могли создавать UUID в верхнем регистре).
Возможности сервера
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).
Клиент v2 записывает 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без изменений; иначе берётся запись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 до своего обновления.