Implement contract v2 on the watch: accounts, rates, base currency, ledgers

- Sync and store accounts, transfers, rates and the baseCurrency setting;
  keep recurring, goal, debt and attachment records as raw payloads.
- Skip unknown entities on pull; preserve unknown payload fields of
  transactions, categories and budgets and send them back on re-save.
- Compute balances in integer cents: total balance, analytics and budgets
  in the base currency, per-account balances in the account currency,
  "нет курса C→B" warning when a rate is missing.
- Read-only accounts screen and an optional account picker when adding a
  transaction (currency follows the account).
- Shared ledgers: choose personal or shared in settings, join by code,
  separate SwiftData store, cursor and deletion journal per ledger,
  X-Balance-Ledger header on sync.
- Hide v2 features and do not push new entities when
  GET /v1/capabilities returns 404.
- Keep the wire id of records (lowercase UUID for new ones) so edits of
  records created by other clients do not create duplicates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wtandClaude Opus 5.5 committed 2026-10-31 23:30:01 +07:00
1 parent 29a4664401
commit 0ccb7e1861
17 files changed
+1267 -103

No files matched your search

+43 -9
View File
@@ -28,19 +28,24 @@ BalanceWatch/
├── AGENTS.md
├── README.md
├── .github/workflows/build.yml # CI: сборка для симулятора и устройства
├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия)
├── branding/ # аватар репозитория, не входит в бандл
├── BalanceWatch.xcodeproj/ # один таргет и одна схема — BalanceWatch
└── BalanceWatch/
├── App/
│ ├── BalanceWatchApp.swift # @main, ModelContainer, тема
│ ├── WatchRootView.swift # главный экран: баланс, месяц, навигация
│ ├── WatchAddTransactionView.swift # добавление операции
│ ├── WatchAddTransactionView.swift # добавление операции (+ выбор счёта)
│ ├── WatchAccountsView.swift # CurrencyContext в окружении, счета,
│ │ # выбор пространства (ledger)
│ └── WatchFeatures.swift # аналитика, операции, бюджеты,
│ # категории, корректировка баланса, настройки
├── Models/ # SwiftData-модели и системные категории
├── Models/ # SwiftData-модели и системные категории;
│ # V2Records.swift — счета, переводы, курсы, SyncRecord
├── Shared/ # BalanceModelContainer, AppTheme,
│ # CategoryDesignOptions, ServerSync (ServerAccountStore)
├── Utilities/ # FinanceCalculations, форматирование валют и дат
├── Utilities/ # FinanceCalculations, Money (сотые, курсы, конвертация),
│ # форматирование валют и дат
├── Views/Components/CategoryIconView.swift
└── Assets.xcassets
```
@@ -81,7 +86,20 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
### 2. SwiftData
Схема зарегистрирована в `BalanceModelContainer.schema`:
`FinanceTransaction`, `MonthlyBudget`, `CustomCategory`, `SyncTombstone`.
`FinanceTransaction`, `MonthlyBudget`, `CustomCategory`, `SyncTombstone`,
`FinanceAccount`, `FinanceTransfer`, `ExchangeRate`, `SyncRecord`.
- Каждое пространство данных (§7 контракта) — отдельное хранилище:
личное — прежнее хранилище по умолчанию, общее —
`BalanceLedger-<scope>`, где scope — хеш сервер + пользователь + ledger
(`LedgerContainers`, `LedgerSelection`). Корневой view пересоздаётся
через `.id(scope)`.
- `syncID` — id записи на проводе. У записей, созданных до v2, он `nil`, и
отправляется прежний `id.uuidString` (иначе на сервере появятся дубли);
новые записи получают UUID в нижнем регистре, полученные — id с сервера.
- `extraPayload` хранит незнакомые поля payload и отправляет их обратно
(§0.2). `SyncRecord` хранит `recurring`, `goal`, `debt`, `attachment`,
`setting` целиком.
- У пользователей уже есть локальные базы. Не удаляй и не переименовывай
persisted-поля без стратегии миграции; новые поля делай со значением
@@ -107,13 +125,18 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
Центральные правила — в `FinanceCalculations`:
- общий баланс = доходы − расходы;
- суммы считаются в целых сотых (`Money`), курсы — десятичные строки,
округление половиной от нуля; интервалы полуоткрытые `[начало, конец)`;
- общий баланс = доходы − расходы + переводы, всё в базовой валюте
(`CurrencyContext`: `setting baseCurrency`, иначе `@AppStorage("currencyCode")`);
нет курса — сумма не входит, показывается «нет курса C→B»;
- баланс счёта — в его валюте без конвертации;
- операции с `isBalanceAdjustment == true` входят в общий баланс, но
**не** входят в периодическую аналитику (месяц/3 мес./12 мес.);
- расходы группируются по `categoryName`.
Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web).
Не меняй их только здесь.
Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web)
и описаны в `docs/CONTRACT_V2.md` §2–3. Не меняй их только здесь.
### 5. Синхронизация (общий контракт)
@@ -129,7 +152,15 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
изменения сервера и остальных клиентов сломает синхронизацию.
- При смене URL сервера сессия очищается, cursor/push-state
сбрасываются, пользователь логинится заново (`saveServer`). Сохраняй
это поведение.
это поведение. Смена сервера и выход возвращают личное пространство.
- v2: перед синхронизацией запрашивается `GET /v1/capabilities`; 404 —
сервер v1, тогда `supportsV2 == false`: счета и общие бюджеты скрыты,
`setting` не отправляется. Незнакомые сущности при pull пропускаются.
- Общее пространство передаётся заголовком `X-Balance-Ledger` на `v1/sync`;
cursor, push-state и журнал удалений имеют суффикс `LedgerSelection.keySuffix`.
- Из сущностей v2 часы отправляют только `setting` (базовая валюта);
счета, переводы, курсы, регулярные операции и прочее только читают.
Регулярные операции часы не создают.
### 6. Безопасность
@@ -162,7 +193,10 @@ CI (`.github/workflows/build.yml`) собирает проект на `macos-15`
симулятора и для устройства без подписи. Перед PR сборка должна быть
зелёной в обоих вариантах.
Тестового таргета пока нет. Если добавляешь тесты, не завязывай их на
Тестового таргета пока нет. `Utilities/Money.swift` не зависит от
SwiftData/SwiftUI, а `FinanceCalculations` работает через протоколы
`LedgerEntry`/`LedgerTransfer`, поэтому денежную математику можно проверить
отдельным `swiftc`-прогоном на macOS (с заглушкой `FinanceTransaction`). Если добавляешь тесты, не завязывай их на
текущую дату и локаль — явно фиксируй `Calendar`, `Date` и formatter'ы.
Добавив тестовый таргет, добавь и шаг `test` в CI.