Версия 3.2.0: контракт v2, оформление «Лес» #2

Merged
wt merged 5 commits from develop into main 2026-10-31 19:17:48 +00:00
17 changed files with 1267 additions and 103 deletions
Showing only changes of commit 0ccb7e1861 - Show all commits

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.
+4 -2
View File
@@ -3,15 +3,17 @@ import SwiftUI
@main
struct BalanceWatchApp: App {
private let modelContainer = BalanceModelContainer.make()
private let containers = LedgerContainers()
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
@AppStorage(LedgerSelection.scopeKey) private var ledgerScope = ""
var body: some Scene {
WindowGroup {
WatchRootView()
.id(ledgerScope)
.tint(.indigo)
.preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme)
.modelContainer(containers.container(for: ledgerScope))
}
.modelContainer(modelContainer)
}
}
+136
View File
@@ -0,0 +1,136 @@
import SwiftData
import SwiftUI
// Контракт v2 на часах: контекст валют, список счетов (только чтение)
// и выбор пространства (личное или общий бюджет).
private struct CurrencyContextKey: EnvironmentKey {
static let defaultValue = CurrencyContext(base: SupportedCurrency.RUB.rawValue)
}
extension EnvironmentValues {
/// Базовая валюта, валюты счетов и курсы текущего пространства.
var currencyContext: CurrencyContext {
get { self[CurrencyContextKey.self] }
set { self[CurrencyContextKey.self] = newValue }
}
}
/// Строит `CurrencyContext` из данных пространства и кладёт в окружение.
struct CurrencyContextProvider<Content: View>: View {
@Query private var accounts: [FinanceAccount]
@Query private var rates: [ExchangeRate]
@Query(filter: #Predicate<SyncRecord> { $0.entity == "setting" && $0.recordID == "baseCurrency" })
private var baseSetting: [SyncRecord]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@ViewBuilder let content: Content
var body: some View {
content.environment(\.currencyContext, CurrencyContext(
base: baseSetting.first?.stringValue ?? currencyCode,
accounts: accounts.map { ($0.syncID, $0.name, $0.currency) },
quotes: rates.map(\.quote)
))
}
}
struct WatchAccountsView: View {
@Environment(\.currencyContext) private var currency
@Query(sort: [SortDescriptor(\FinanceAccount.sortOrder), SortDescriptor(\FinanceAccount.name)])
private var accounts: [FinanceAccount]
@Query private var transactions: [FinanceTransaction]
@Query private var transfers: [FinanceTransfer]
private var total: MoneyTotal {
FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency)
}
var body: some View {
List {
Section("Всего, \(currency.base)") {
Text(total.value.formattedCurrency(code: currency.base)).font(.headline)
if let warning = currency.missingRateWarning(total.missing) {
Label(warning, systemImage: "exclamationmark.triangle").font(.caption2).foregroundStyle(.orange)
}
}
if accounts.isEmpty {
Text("Счетов пока нет. Их создают на телефоне или компьютере.")
.font(.footnote).foregroundStyle(.secondary)
}
ForEach(accounts) { account in
row(
account.name + (account.isArchived ? " (архив)" : ""),
icon: account.icon,
tint: FinanceCategory.color(named: account.colorName),
cents: FinanceCalculations.accountBalance(accountID: account.syncID, transactions: transactions, transfers: transfers),
code: currency.accountCurrency(account.syncID)
)
}
let unassigned = FinanceCalculations.unassignedBalances(transactions: transactions, context: currency)
ForEach(unassigned.keys.sorted(), id: \.self) { code in
row("Без счёта", icon: "tray.fill", tint: .gray, cents: unassigned[code] ?? 0, code: code)
}
}
.navigationTitle("Счета")
}
private func row(_ title: String, icon: String, tint: Color, cents: Int64, code: String) -> some View {
HStack {
CategoryIconView(systemName: icon, emoji: "", tint: tint, size: 13, containerSize: 30)
VStack(alignment: .leading) {
Text(title).lineLimit(1)
Text(Money.double(cents).formattedCurrency(code: code))
.font(.caption2).foregroundStyle(cents < 0 ? .red : .secondary)
}
}
}
}
struct WatchLedgersView: View {
@StateObject private var account = ServerAccountStore.shared
@State private var ledgers: [ServerLedger] = []
@State private var code = ""
var body: some View {
List {
Section {
choice("Личный", id: "", ledger: nil)
ForEach(ledgers) { choice($0.name, id: $0.id, ledger: $0) }
} footer: {
Text("Данные каждого бюджета хранятся на часах отдельно.")
}
Section("Присоединиться") {
TextField("Код приглашения", text: $code)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Присоединиться") {
Task {
if let ledger = await account.joinLedger(code: code) {
code = ""
if !ledgers.contains(ledger) { ledgers.append(ledger) }
account.selectLedger(ledger)
}
}
}
.disabled(code.trimmingCharacters(in: .whitespaces).isEmpty || account.isBusy)
}
if account.isBusy { ProgressView() }
if let message = account.message { Text(message).font(.footnote).foregroundStyle(.secondary) }
}
.navigationTitle("Бюджет")
.task { if let value = await account.loadLedgers() { ledgers = value } }
}
private func choice(_ title: String, id: String, ledger: ServerLedger?) -> some View {
Button {
account.selectLedger(ledger)
} label: {
HStack {
Text(title).lineLimit(1)
Spacer()
if account.ledgerID == id { Image(systemName: "checkmark") }
}
}
.disabled(account.isBusy)
}
}
+48 -1
View File
@@ -4,8 +4,12 @@ import SwiftUI
struct WatchAddTransactionView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Environment(\.currencyContext) private var currency
@StateObject private var account = ServerAccountStore.shared
@Query(sort: \CustomCategory.createdAt)
private var customCategories: [CustomCategory]
@Query(sort: [SortDescriptor(\FinanceAccount.sortOrder), SortDescriptor(\FinanceAccount.name)])
private var accounts: [FinanceAccount]
private let transaction: FinanceTransaction?
@State private var kind: TransactionKind
@@ -13,6 +17,8 @@ struct WatchAddTransactionView: View {
@State private var categoryName: String
@State private var date: Date
@State private var note: String
/// Пустая строка — «Без счёта».
@State private var accountID: String
init(transaction: FinanceTransaction? = nil) {
self.transaction = transaction
@@ -23,6 +29,31 @@ struct WatchAddTransactionView: View {
?? FinanceCategory.categories(for: initialKind)[0].name)
_date = State(initialValue: transaction?.date ?? .now)
_note = State(initialValue: transaction?.note ?? "")
_accountID = State(initialValue: transaction?.accountID ?? "")
}
/// Активные счета; текущий счёт операции остаётся в списке, даже если
/// он в архиве или удалён.
private var accountChoices: [(id: String, title: String)] {
var values = accounts.filter { !$0.isArchived }.map { ($0.syncID, $0.name) }
if let current = transaction?.accountID,
!values.contains(where: { $0.0.lowercased() == current.lowercased() }) {
values.append((current, currency.accountTitle(current) ?? ""))
}
return [("", "Без счёта")] + values
}
private var showsAccounts: Bool {
account.supportsV2 && (!accounts.isEmpty || transaction?.accountID != nil)
}
/// Валюта следует за счётом; у существующей операции без смены счёта
/// валюта не меняется.
private var effectiveCurrency: String {
if let transaction, (transaction.accountID ?? "") == accountID {
return currency.currency(of: transaction)
}
return accountID.isEmpty ? currency.base : currency.accountCurrency(accountID)
}
private var categories: [FinanceCategory] {
@@ -54,6 +85,13 @@ struct WatchAddTransactionView: View {
TextField("Сумма", value: $amount, format: .number)
if showsAccounts {
Picker("Счёт", selection: $accountID) {
ForEach(accountChoices, id: \.id) { Text($0.title).tag($0.id) }
}
LabeledContent("Валюта", value: effectiveCurrency)
}
Picker("Категория", selection: $categoryName) {
ForEach(categories) { category in
Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name)
@@ -80,7 +118,10 @@ struct WatchAddTransactionView: View {
}
private func save() {
let amount = Money.double(Money.cents(amount))
guard amount > 0 else { return }
let newAccount = accountID.isEmpty ? nil : accountID
let newCurrency = account.supportsV2 ? effectiveCurrency : nil
let category = categories.first { $0.name == categoryName }
?? FinanceCategory.categories(for: kind)[0]
if let transaction {
@@ -92,6 +133,10 @@ struct WatchAddTransactionView: View {
transaction.categoryColorName = category.colorName
transaction.date = date
transaction.note = note
if transaction.accountID != newAccount {
transaction.accountID = newAccount
transaction.currency = newCurrency
}
transaction.syncUpdatedAt = .now
} else {
modelContext.insert(FinanceTransaction(
@@ -102,7 +147,9 @@ struct WatchAddTransactionView: View {
categoryIcon: category.icon,
categoryEmoji: category.emoji,
categoryColorName: category.colorName,
kind: kind
kind: kind,
accountID: newAccount,
currency: newCurrency
))
}
dismiss()
+58 -14
View File
@@ -29,17 +29,19 @@ private enum WatchAnalyticsPeriod: String, CaseIterable, Identifiable {
struct WatchAnalyticsView: View {
@Query private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@Environment(\.currencyContext) private var currency
@State private var period = WatchAnalyticsPeriod.month
private var currencyCode: String { currency.base }
private var interval: DateInterval { period.interval() }
private var summary: FinancePeriodSummary {
FinanceCalculations.summary(transactions: transactions, in: interval)
FinanceCalculations.summary(transactions: transactions, in: interval, context: currency)
}
private var spending: [CategorySpending] {
Array(FinanceCalculations.spendingByCategory(
transactions: transactions,
in: interval
in: interval,
context: currency
).prefix(6))
}
private var savingsRate: Double {
@@ -60,6 +62,10 @@ struct WatchAnalyticsView: View {
"Сбережения",
value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%"
)
if let warning = currency.missingRateWarning(summary.missing) {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.caption2).foregroundStyle(.orange)
}
}
Section("Категории") {
@@ -103,7 +109,7 @@ struct WatchTransactionsView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \FinanceTransaction.date, order: .reverse)
private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@Environment(\.currencyContext) private var currency
@State private var selectedKind: TransactionKind?
@State private var searchText = ""
@State private var showingAdd = false
@@ -141,8 +147,11 @@ struct WatchTransactionsView: View {
VStack(alignment: .leading) {
Text(transaction.categoryName).lineLimit(1)
Text((transaction.kind == .income ? "+" : "−")
+ transaction.amount.formattedCurrency(code: currencyCode))
+ transaction.amount.formattedCurrency(code: currency.currency(of: transaction)))
.font(.caption2)
if let title = currency.accountTitle(transaction.accountID) {
Text(title).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
}
}
}
@@ -172,9 +181,11 @@ struct WatchTransactionsView: View {
struct WatchBudgetsView: View {
@Query private var budgets: [MonthlyBudget]
@Query private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@Environment(\.currencyContext) private var currency
@State private var showingEditor = false
private var currencyCode: String { currency.base }
private var interval: DateInterval {
FinanceCalculations.monthInterval(containing: .now)
}
@@ -184,7 +195,8 @@ struct WatchBudgetsView: View {
private var spending: [String: Double] {
Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory(
transactions: transactions,
in: interval
in: interval,
context: currency
).map { ($0.name, $0.amount) })
}
@@ -255,7 +267,7 @@ private struct WatchBudgetEditor: View {
private func save() {
for category in categories {
let value = limits[category.name, default: 0]
let value = Money.double(Money.cents(limits[category.name, default: 0]))
let existing = budgets.first { $0.categoryName == category.name }
if value > 0 {
if let existing {
@@ -429,11 +441,16 @@ struct WatchBalanceAdjustmentView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Query private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@Query private var transfers: [FinanceTransfer]
@Environment(\.currencyContext) private var currency
@StateObject private var account = ServerAccountStore.shared
@State private var desired = 0.0
@State private var note = ""
private var current: Double { FinanceCalculations.totalBalance(transactions: transactions) }
private var currencyCode: String { currency.base }
private var current: Double {
FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency).value
}
private var difference: Double { desired - current }
var body: some View {
@@ -453,26 +470,53 @@ struct WatchBalanceAdjustmentView: View {
private func save() {
guard abs(difference) >= 0.005 else { return }
modelContext.insert(FinanceTransaction(
amount: abs(difference),
amount: Money.double(Money.cents(abs(difference))),
note: note.isEmpty ? "Ручная корректировка баланса" : note,
categoryName: "Корректировка",
categoryIcon: "slider.horizontal.3",
categoryColorName: "indigo",
isBalanceAdjustment: true,
kind: difference > 0 ? .income : .expense
kind: difference > 0 ? .income : .expense,
currency: account.supportsV2 ? currency.base : nil
))
dismiss()
}
}
struct WatchSettingsView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.currencyContext) private var currency
@StateObject private var account = ServerAccountStore.shared
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
@Query(filter: #Predicate<SyncRecord> { $0.entity == "setting" && $0.recordID == "baseCurrency" })
private var baseSetting: [SyncRecord]
private var currencyCodes: [String] {
let codes = SupportedCurrency.allCases.map(\.rawValue)
return codes.contains(currency.base) ? codes : codes + [currency.base]
}
/// Базовая валюта: на сервере v2 — общая настройка `baseCurrency`,
/// иначе — локальная настройка часов (как в v1).
private var baseCurrency: Binding<String> {
Binding(get: { currency.base }, set: { code in
currencyCode = code
guard account.supportsV2 || !baseSetting.isEmpty,
let payload = try? JSONSerialization.data(withJSONObject: ["value": code]) else { return }
if let record = baseSetting.first {
record.payload = payload
record.syncUpdatedAt = .now
} else {
modelContext.insert(SyncRecord(entity: "setting", recordID: SyncRecord.baseCurrencyKey, payload: payload, syncUpdatedAt: .now))
}
})
}
var body: some View {
Form {
Picker("Валюта", selection: $currencyCode) {
ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) }
Picker("Валюта", selection: baseCurrency) {
ForEach(currencyCodes, id: \.self) { Text($0).tag($0) }
}
Picker("Тема", selection: $appTheme) {
ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) }
+30 -6
View File
@@ -2,16 +2,29 @@ import SwiftData
import SwiftUI
struct WatchRootView: View {
var body: some View {
CurrencyContextProvider { WatchHomeView() }
}
}
private struct WatchHomeView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.currencyContext) private var currency
@StateObject private var account = ServerAccountStore.shared
@Query(sort: \FinanceTransaction.date, order: .reverse)
private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@Query private var transfers: [FinanceTransfer]
@State private var showingAddTransaction = false
private var currencyCode: String { currency.base }
private var total: MoneyTotal {
FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency)
}
private var monthSummary: FinancePeriodSummary {
FinanceCalculations.summary(
transactions: transactions,
in: FinanceCalculations.monthInterval(containing: .now)
in: FinanceCalculations.monthInterval(containing: .now),
context: currency
)
}
@@ -23,10 +36,14 @@ struct WatchRootView: View {
Text("Общий баланс")
.font(.caption)
.foregroundStyle(.secondary)
Text(FinanceCalculations.totalBalance(transactions: transactions)
.formattedCurrency(code: currencyCode))
Text(total.value.formattedCurrency(code: currencyCode))
.font(.title3.bold())
.minimumScaleFactor(0.7)
if let warning = currency.missingRateWarning(total.missing.union(monthSummary.missing)) {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.caption2)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
@@ -62,6 +79,13 @@ struct WatchRootView: View {
.buttonStyle(.borderedProminent)
VStack(spacing: 4) {
if account.supportsV2 {
NavigationLink {
WatchAccountsView()
} label: {
Label("Счета", systemImage: "creditcard")
}
}
NavigationLink {
WatchAnalyticsView()
} label: {
@@ -111,7 +135,7 @@ struct WatchRootView: View {
.font(.caption)
.lineLimit(1)
Text((transaction.kind == .income ? "+" : "−")
+ transaction.amount.formattedCurrency(code: currencyCode))
+ transaction.amount.formattedCurrency(code: currency.currency(of: transaction)))
.font(.caption2.weight(.semibold))
.foregroundStyle(transaction.kind == .income ? .green : .secondary)
}
@@ -121,7 +145,7 @@ struct WatchRootView: View {
}
}
}
.navigationTitle("Баланс")
.navigationTitle(account.ledgerID.isEmpty ? "Баланс" : account.ledgerName)
.sheet(isPresented: $showingAddTransaction) {
WatchAddTransactionView()
}
+5
View File
@@ -11,6 +11,8 @@ final class CustomCategory {
var kindRawValue: String = "expense"
var createdAt: Date = Date.now
var syncUpdatedAt: Date = Date.now
var syncID: String?
var extraPayload: Data?
init(
id: UUID = UUID(),
@@ -29,9 +31,12 @@ final class CustomCategory {
self.colorName = colorName
self.kindRawValue = kind.rawValue
self.createdAt = createdAt
self.syncID = id.uuidString.lowercased()
self.syncUpdatedAt = syncUpdatedAt
}
var wireID: String { syncID ?? id.uuidString }
var kind: TransactionKind {
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
set { kindRawValue = newValue.rawValue }
@@ -28,6 +28,15 @@ final class FinanceTransaction {
var isBalanceAdjustment: Bool = false
var kindRawValue: String = "expense"
var syncUpdatedAt: Date = Date.now
/// v2: счёт (`accountId` на проводе), `nil` — «Без счёта».
var accountID: String?
/// v2: валюта суммы; `nil` — валюта счёта, иначе базовая.
var currency: String?
/// Идентификатор записи на проводе. `nil` у записей, созданных до v2:
/// для них остаётся `id.uuidString`, чтобы не плодить дубли на сервере.
var syncID: String?
/// Незнакомые поля payload (теги, recurringId, …) — отправляются обратно как есть.
var extraPayload: Data?
init(
id: UUID = UUID(),
@@ -40,6 +49,8 @@ final class FinanceTransaction {
categoryColorName: String = "auto",
isBalanceAdjustment: Bool = false,
kind: TransactionKind,
accountID: String? = nil,
currency: String? = nil,
syncUpdatedAt: Date = .now
) {
self.id = id
@@ -52,9 +63,14 @@ final class FinanceTransaction {
self.categoryColorName = categoryColorName
self.isBalanceAdjustment = isBalanceAdjustment
self.kindRawValue = kind.rawValue
self.accountID = accountID
self.currency = currency
self.syncID = id.uuidString.lowercased()
self.syncUpdatedAt = syncUpdatedAt
}
var wireID: String { syncID ?? id.uuidString }
var kind: TransactionKind {
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
set { kindRawValue = newValue.rawValue }
+5
View File
@@ -10,6 +10,8 @@ final class MonthlyBudget {
var limit: Double = 0
var monthStart: Date = Date.now
var syncUpdatedAt: Date = Date.now
var syncID: String?
var extraPayload: Data?
init(
id: UUID = UUID(),
@@ -26,6 +28,9 @@ final class MonthlyBudget {
self.categoryEmoji = categoryEmoji
self.limit = limit
self.monthStart = monthStart
self.syncID = id.uuidString.lowercased()
self.syncUpdatedAt = syncUpdatedAt
}
var wireID: String { syncID ?? id.uuidString }
}
+6 -5
View File
@@ -30,8 +30,9 @@ struct SyncDeletionRecord: Codable, Identifiable {
}
enum SyncDeletionStore {
private static let storageKey = "balance.server.deletionJournal.v1"
private static let quarantineKey = "balance.server.deletionJournal.corrupt"
// Журнал свой у каждого пространства (§7): у личного — прежний ключ.
private static var storageKey: String { "balance.server.deletionJournal.v1" + LedgerSelection.keySuffix }
private static var quarantineKey: String { "balance.server.deletionJournal.corrupt" + LedgerSelection.keySuffix }
private static let lock = NSLock()
static func add(entity: String, recordID: String, updatedAt: Date = .now) {
@@ -93,17 +94,17 @@ enum SyncDeletionStore {
extension ModelContext {
func deleteForSync(_ transaction: FinanceTransaction) {
SyncDeletionStore.add(entity:"transaction", recordID:transaction.id.uuidString)
SyncDeletionStore.add(entity:"transaction", recordID:transaction.wireID)
delete(transaction)
}
func deleteForSync(_ category: CustomCategory) {
SyncDeletionStore.add(entity:"category", recordID:category.id.uuidString)
SyncDeletionStore.add(entity:"category", recordID:category.wireID)
delete(category)
}
func deleteForSync(_ budget: MonthlyBudget) {
SyncDeletionStore.add(entity:"budget", recordID:budget.id.uuidString)
SyncDeletionStore.add(entity:"budget", recordID:budget.wireID)
delete(budget)
}
}
+100
View File
@@ -0,0 +1,100 @@
import Foundation
import SwiftData
// Сущности контракта v2 (docs/CONTRACT_V2.md). Часы их только читают:
// создают и меняют их телефон и десктоп. `syncID` — id записи на проводе,
// `extraPayload` — незнакомые поля payload.
@Model
final class FinanceAccount {
var syncID: String = ""
var name: String = ""
var kindRawValue: String = "other"
var icon: String = "creditcard.fill"
var colorName: String = "indigo"
/// Пустая строка — базовая валюта.
var currency: String = ""
var createdAt: Date = Date.now
var isArchived: Bool = false
var sortOrder: Int = 0
var extraPayload: Data?
var syncUpdatedAt: Date = Date.now
init(syncID: String, syncUpdatedAt: Date) {
self.syncID = syncID
self.syncUpdatedAt = syncUpdatedAt
}
var key: String { syncID.lowercased() }
}
@Model
final class FinanceTransfer {
var syncID: String = ""
var fromAccountID: String = ""
var toAccountID: String = ""
var amount: Double = 0
/// `nil` — равна `amount`.
var toAmount: Double?
var date: Date = Date.now
var note: String = ""
var extraPayload: Data?
var syncUpdatedAt: Date = Date.now
init(syncID: String, syncUpdatedAt: Date) {
self.syncID = syncID
self.syncUpdatedAt = syncUpdatedAt
}
}
@Model
final class ExchangeRate {
var syncID: String = ""
var from: String = ""
var to: String = ""
/// Десятичная строка, сколько единиц `to` стоит одна единица `from`.
var rate: String = ""
var date: Date = Date.now
var extraPayload: Data?
var syncUpdatedAt: Date = Date.now
init(syncID: String, syncUpdatedAt: Date) {
self.syncID = syncID
self.syncUpdatedAt = syncUpdatedAt
}
var quote: RateQuote { RateQuote(from: from, to: to, rate: rate, date: date, updatedAt: syncUpdatedAt) }
}
/// Записи, которые часы хранят без разбора: `recurring`, `goal`, `debt`,
/// `attachment`, `setting`. Payload хранится целиком, поэтому все поля сохраняются.
@Model
final class SyncRecord {
var entity: String = ""
var recordID: String = ""
var payload: Data = Data()
var syncUpdatedAt: Date = Date.now
init(entity: String, recordID: String, payload: Data, syncUpdatedAt: Date) {
self.entity = entity
self.recordID = recordID
self.payload = payload
self.syncUpdatedAt = syncUpdatedAt
}
static let storedEntities: Set<String> = ["recurring", "goal", "debt", "attachment", "setting"]
static let baseCurrencyKey = "baseCurrency"
/// Значение `setting` (`{"value": "USD"}`), если это строка.
var stringValue: String? {
(try? JSONSerialization.jsonObject(with: payload) as? [String: Any])?["value"] as? String
}
}
extension FinanceTransaction: LedgerEntry {
var isIncome: Bool { kind == .income }
}
extension FinanceTransfer: LedgerTransfer {
var incomingAmount: Double { toAmount ?? amount }
}
@@ -6,15 +6,19 @@ enum BalanceModelContainer {
FinanceTransaction.self,
MonthlyBudget.self,
CustomCategory.self,
SyncTombstone.self
SyncTombstone.self,
FinanceAccount.self,
FinanceTransfer.self,
ExchangeRate.self,
SyncRecord.self
])
static func make() -> ModelContainer {
let localConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none
)
/// `scope` пустой — личные данные (прежнее хранилище по умолчанию);
/// иначе — отдельное хранилище общего пространства (сервер + пользователь + ledger).
static func make(scope: String = "") -> ModelContainer {
let localConfiguration = scope.isEmpty
? ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
: ModelConfiguration("BalanceLedger-\(scope)", schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
do {
return try ModelContainer(
@@ -23,7 +27,7 @@ enum BalanceModelContainer {
)
} catch {
let recoveryConfiguration = ModelConfiguration(
"BalanceRecovery",
scope.isEmpty ? "BalanceRecovery" : "BalanceRecovery-\(scope)",
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
@@ -40,3 +44,17 @@ enum BalanceModelContainer {
}
}
}
/// Кеш контейнеров по пространствам: переключение туда-обратно не
/// открывает хранилище заново.
@MainActor
final class LedgerContainers {
private var items: [String: ModelContainer] = [:]
func container(for scope: String) -> ModelContainer {
if let item = items[scope] { return item }
let item = BalanceModelContainer.make(scope: scope)
items[scope] = item
return item
}
}
+277 -38
View File
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
import Security
import SwiftData
@@ -88,12 +89,53 @@ private struct SyncResponse: Decodable {
private struct TransactionPayload: Codable {
let amount: Double; let date: Date; let note: String; let categoryName: String; let categoryIcon: String
let categoryEmoji: String; let categoryColorName: String; let isBalanceAdjustment: Bool; let kindRawValue: String
var accountId: String? = nil; var currency: String? = nil
static let keys: Set<String> = ["amount", "date", "note", "categoryName", "categoryIcon", "categoryEmoji", "categoryColorName", "isBalanceAdjustment", "kindRawValue", "accountId", "currency"]
}
private struct CategoryPayload: Codable {
let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date
static let keys: Set<String> = ["name", "icon", "emoji", "colorName", "kindRawValue", "createdAt"]
}
private struct BudgetPayload: Codable {
let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date
static let keys: Set<String> = ["categoryName", "categoryIcon", "categoryEmoji", "limit", "monthStart"]
}
// v2: все поля необязательные (§0.3), отсутствие — значение по умолчанию.
private struct AccountPayload: Decodable {
let name: String?; let kindRawValue: String?; let icon: String?; let colorName: String?; let currency: String?
let createdAt: Date?; let isArchived: Bool?; let sortOrder: Int?
static let keys: Set<String> = ["name", "kindRawValue", "icon", "colorName", "currency", "createdAt", "isArchived", "sortOrder"]
}
private struct TransferPayload: Decodable {
let fromAccountId: String?; let toAccountId: String?; let amount: Double?; let toAmount: Double?; let date: Date?; let note: String?
static let keys: Set<String> = ["fromAccountId", "toAccountId", "amount", "toAmount", "date", "note"]
}
private struct RatePayload: Decodable {
let from: String?; let to: String?; let rate: String?; let date: Date?
static let keys: Set<String> = ["from", "to", "rate", "date"]
}
struct ServerCapabilities: Codable {
let contract: Int
let ledgers: Bool?
}
struct ServerLedger: Codable, Identifiable, Hashable {
let id: String
let name: String
let role: String?
}
private struct LedgerList: Decodable { let ledgers: [ServerLedger] }
/// Выбранное пространство данных (§7). Пустой id — личное.
enum LedgerSelection {
static let idKey = "balance.server.ledger.id"
static let nameKey = "balance.server.ledger.name"
/// Ключ хранилища: хеш сервер + пользователь + ledger. Пустой — личное.
static let scopeKey = "balance.server.ledger.scope"
static var id: String { UserDefaults.standard.string(forKey: idKey) ?? "" }
static var scope: String { UserDefaults.standard.string(forKey: scopeKey) ?? "" }
/// Суффикс ключей курсора, push-state и журнала удалений.
static var keySuffix: String { scope.isEmpty ? "" : ".ledger.\(scope)" }
}
private enum ServerCoding {
@@ -124,6 +166,21 @@ private enum ServerCoding {
}
return value
}()
/// Известные поля поверх сохранённых незнакомых (§0.2).
static func payload<T: Encodable>(_ value: T, extras: Data?) throws -> Data {
var object = extras.flatMap { try? decoder.decode([String: JSONValue].self, from: $0) } ?? [:]
let known = try decoder.decode([String: JSONValue].self, from: encoder.encode(value))
object.merge(known) { $1 }
return try encoder.encode(object)
}
/// Незнакомые поля payload; `nil`, если их нет.
static func extras(_ data: Data, known: Set<String>) -> Data? {
guard var object = try? decoder.decode([String: JSONValue].self, from: data) else { return nil }
for key in known { object.removeValue(forKey: key) }
return object.isEmpty ? nil : try? encoder.encode(object)
}
}
enum ServerSyncError: LocalizedError {
@@ -145,8 +202,12 @@ final class ServerAccountStore: ObservableObject {
@Published private(set) var isBusy = false
@Published private(set) var lastSyncAt: Date?
@Published var message: String?
@Published private(set) var capabilities: ServerCapabilities?
@Published private(set) var ledgerID = LedgerSelection.id
@Published private(set) var ledgerName = UserDefaults.standard.string(forKey: LedgerSelection.nameKey) ?? ""
private let defaults = UserDefaults.standard
private let capabilitiesKey = "balance.server.capabilities"
private var session: ServerSession?
private let sessionKey = "balance.server.session"
private let serverKey = "balance.server.url"
@@ -154,12 +215,17 @@ final class ServerAccountStore: ObservableObject {
var serverURL: String { defaults.string(forKey: serverKey) ?? "" }
var isSignedIn: Bool { session != nil }
/// Сервер v2 (`GET /v1/capabilities` ответил). На v1 (404) счета,
/// курсы, общие пространства скрыты и новые сущности не отправляются.
var supportsV2: Bool { isSignedIn && (capabilities?.contract ?? 1) >= 2 }
var supportsLedgers: Bool { supportsV2 && capabilities?.ledgers == true }
private init() {
if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) {
session = value; signedInEmail = value.user.email
}
lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date
if let data = defaults.data(forKey: capabilitiesKey) { capabilities = try? JSONDecoder().decode(ServerCapabilities.self, from: data) }
}
func saveServer(_ value: String) throws {
@@ -167,7 +233,10 @@ final class ServerAccountStore: ObservableObject {
throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.")
}
let normalized = try normalizedServer(value)
if normalized != serverURL { clearSession(); defaults.set(normalized, forKey: serverKey); clearSyncState() }
if normalized != serverURL {
clearSession(); defaults.set(normalized, forKey: serverKey); clearSyncState()
setCapabilities(nil); selectLedger(nil)
}
}
func register(email: String, password: String, context: ModelContext) async { await authenticate(path: "v1/auth/register", email: email, password: password, context: context) }
@@ -175,7 +244,7 @@ final class ServerAccountStore: ObservableObject {
func logout() async {
if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse }
clearSession(); message = "Вы вышли из аккаунта."
clearSession(); selectLedger(nil); message = "Вы вышли из аккаунта."
}
func sync(context: ModelContext) async {
@@ -184,6 +253,10 @@ final class ServerAccountStore: ObservableObject {
defer { isBusy = false }
do {
try context.save()
await refreshCapabilities()
if !ledgerID.isEmpty && !supportsLedgers {
throw ServerSyncError.response("Сервер не поддерживает общие бюджеты. Выберите личное пространство.")
}
let cutoff = Date.now
let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast
let threshold = lastPush <= cutoff.addingTimeInterval(5)
@@ -194,6 +267,7 @@ final class ServerAccountStore: ObservableObject {
try await pushTransactions(context: context, after: threshold, through: cutoff, cursor: initialCursor)
try await pushCategories(context: context, after: threshold, through: cutoff, cursor: initialCursor)
try await pushBudgets(context: context, after: threshold, through: cutoff, cursor: initialCursor)
if supportsV2 { try await pushSettings(context: context, after: threshold, through: cutoff, cursor: initialCursor) }
try await pushTombstones(through: cutoff, cursor: initialCursor)
try await pullChanges(context: context, cursor: initialCursor)
@@ -222,22 +296,36 @@ final class ServerAccountStore: ObservableObject {
} catch { message = error.localizedDescription }
}
private func request<Response: Decodable, Body: Encodable>(path: String, body: Body, authorized: Bool, retry: Bool = true) async throws -> Response {
private func request<Response: Decodable>(path: String, method: String = "POST", body: (any Encodable)?, authorized: Bool, retry: Bool = true) async throws -> Response {
if authorized { try await refreshIfNeeded() }
let base = try normalizedServer(serverURL)
guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL }
var request = URLRequest(url: url); request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var request = URLRequest(url: url); request.httpMethod = method
if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
request.httpBody = try ServerCoding.encoder.encode(body)
if path == "v1/sync" || path.hasPrefix("v1/attachments/"), !ledgerID.isEmpty {
request.setValue(ledgerID, forHTTPHeaderField: "X-Balance-Ledger")
}
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try ServerCoding.encoder.encode(body)
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") }
if http.statusCode == 401, authorized, retry { try await refreshSession(); return try await self.request(path:path, body:body, authorized:true, retry:false) }
if http.statusCode == 401, authorized, retry { try await refreshSession(); return try await self.request(path:path, method:method, body:body, authorized:true, retry:false) }
guard (200..<300).contains(http.statusCode) else {
let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data)
if path == "v1/sync", envelope?.error.code == "invalid_json" {
throw ServerSyncError.response("Обновите Balance Server до версии из текущего архива приложения.")
}
if path.hasPrefix("v1/ledgers") || (path == "v1/sync" && !ledgerID.isEmpty) {
switch envelope?.error.code {
case "invalid_code": throw ServerSyncError.response("Код приглашения неверный или устарел.")
case "forbidden": throw ServerSyncError.response("Нет доступа к общему бюджету.")
case "not_found": throw ServerSyncError.response("Общий бюджет не найден.")
case "owner_cannot_leave": throw ServerSyncError.response("Владелец не может выйти, пока есть участники.")
default: break
}
}
throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)")
}
if Response.self == EmptyResponse.self { return EmptyResponse() as! Response }
@@ -260,14 +348,69 @@ final class ServerAccountStore: ObservableObject {
}
private func clearSession() { session = nil; signedInEmail = nil; KeychainStore.delete(sessionKey) }
// MARK: Возможности сервера и пространства (v2)
private func setCapabilities(_ value: ServerCapabilities?) {
capabilities = value
if let value, let data = try? JSONEncoder().encode(value) { defaults.set(data, forKey: capabilitiesKey) }
else { defaults.removeObject(forKey: capabilitiesKey) }
}
/// 404 — сервер v1. Сетевая ошибка оставляет прежнее значение.
private func refreshCapabilities() async {
guard let base = try? normalizedServer(serverURL), let url = URL(string: base + "/v1/capabilities"),
let (data, response) = try? await URLSession.shared.data(from: url),
let http = response as? HTTPURLResponse else { return }
if http.statusCode == 404 { setCapabilities(ServerCapabilities(contract: 1, ledgers: false)) }
else if (200..<300).contains(http.statusCode), let value = try? JSONDecoder().decode(ServerCapabilities.self, from: data) {
setCapabilities(value)
}
}
/// Общие пространства пользователя (личное в список не входит).
func loadLedgers() async -> [ServerLedger]? {
guard session != nil, !isBusy else { return nil }
isBusy = true; defer { isBusy = false }
do {
let list: LedgerList = try await request(path: "v1/ledgers", method: "GET", body: nil, authorized: true)
return list.ledgers
} catch { message = error.localizedDescription; return nil }
}
func joinLedger(code: String) async -> ServerLedger? {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard session != nil, !isBusy, !trimmed.isEmpty else { return nil }
isBusy = true; defer { isBusy = false }
do {
let ledger: ServerLedger = try await request(path: "v1/ledgers/join", body: ["code": trimmed], authorized: true)
message = "Вы присоединились к «\(ledger.name)»."
return ledger
} catch { message = error.localizedDescription; return nil }
}
/// `nil` — личное пространство. Данные, курсор и очередь у каждого
/// пространства свои; записи при переключении не переносятся.
func selectLedger(_ ledger: ServerLedger?) {
guard !isBusy || ledger == nil else { return }
ledgerID = ledger?.id ?? ""; ledgerName = ledger?.name ?? ""
defaults.set(ledgerID, forKey: LedgerSelection.idKey)
defaults.set(ledgerName, forKey: LedgerSelection.nameKey)
defaults.set(ledger.map { ledgerScope(for: $0.id) } ?? "", forKey: LedgerSelection.scopeKey)
}
private func ledgerScope(for id: String) -> String {
let source = "\(serverURL)|\(session?.user.id ?? "")|\(id.lowercased())"
return SHA256.hash(data: Data(source.utf8)).prefix(16).map { String(format: "%02x", $0) }.joined()
}
private func clearSyncState() {
for key in defaults.dictionaryRepresentation().keys
where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") {
defaults.removeObject(forKey: key)
}
}
private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" }
private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" }
private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" + LedgerSelection.keySuffix }
private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" + LedgerSelection.keySuffix }
private func deviceID() -> String {
if let value = defaults.string(forKey: "balance.server.deviceID") { return value }
let value = UUID().uuidString; defaults.set(value, forKey: "balance.server.deviceID"); return value
@@ -289,8 +432,8 @@ final class ServerAccountStore: ObservableObject {
let items = try context.fetch(descriptor)
guard !items.isEmpty else { break }
let changes = try items.map { item in
let value = TransactionPayload(amount:item.amount, date:item.date, note:item.note, categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, categoryColorName:item.categoryColorName, isBalanceAdjustment:item.isBalanceAdjustment, kindRawValue:item.kindRawValue)
return SyncChange(entity:"transaction", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
let value = TransactionPayload(amount:item.amount, date:item.date, note:item.note, categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, categoryColorName:item.categoryColorName, isBalanceAdjustment:item.isBalanceAdjustment, kindRawValue:item.kindRawValue, accountId:item.accountID, currency:item.currency)
return SyncChange(entity:"transaction", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload))
}
try await sendPush(changes, cursor: cursor)
offset += items.count
@@ -316,7 +459,7 @@ final class ServerAccountStore: ObservableObject {
guard !items.isEmpty else { break }
let changes = try items.map { item in
let value = CategoryPayload(name:item.name, icon:item.icon, emoji:item.emoji, colorName:item.colorName, kindRawValue:item.kindRawValue, createdAt:item.createdAt)
return SyncChange(entity:"category", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
return SyncChange(entity:"category", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload))
}
try await sendPush(changes, cursor: cursor)
offset += items.count
@@ -342,7 +485,7 @@ final class ServerAccountStore: ObservableObject {
guard !items.isEmpty else { break }
let changes = try items.map { item in
let value = BudgetPayload(categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, limit:item.limit, monthStart:item.monthStart)
return SyncChange(entity:"budget", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
return SyncChange(entity:"budget", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload))
}
try await sendPush(changes, cursor: cursor)
offset += items.count
@@ -351,6 +494,15 @@ final class ServerAccountStore: ObservableObject {
}
}
/// Из сущностей v2 часы меняют только `setting` (базовая валюта).
private func pushSettings(context: ModelContext, after threshold: Date, through cutoff: Date, cursor: Int64) async throws {
let lowerBound = threshold, upperBound = cutoff
let items = try context.fetch(FetchDescriptor<SyncRecord>(predicate: #Predicate { item in
item.entity == "setting" && item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound
}))
try await sendPush(items.map { SyncChange(entity:$0.entity, id:$0.recordID, deleted:false, updatedAt:$0.syncUpdatedAt, payload:$0.payload) }, cursor:cursor)
}
private func pushTombstones(through cutoff: Date, cursor: Int64) async throws {
var offset = 0
while true {
@@ -408,36 +560,114 @@ final class ServerAccountStore: ObservableObject {
private func apply(_ changes: [SyncChange], context: ModelContext) throws {
for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) {
if let id = UUID(uuidString:change.id) {
if change.deleted {
switch change.entity {
case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
case "category": if let item = try category(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
case "budget": if let item = try budget(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
default: break
}
} else if let data = change.payload {
switch change.entity {
case "transaction":
guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { continue }
if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
else { context.insert(FinanceTransaction(id:id, amount:value.amount, date:value.date, note:value.note, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, categoryColorName:value.categoryColorName, isBalanceAdjustment:value.isBalanceAdjustment, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, syncUpdatedAt:change.updatedAt)) }
case "category":
guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { continue }
if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
else { context.insert(CustomCategory(id:id, name:value.name, icon:value.icon, emoji:value.emoji, colorName:value.colorName, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, createdAt:value.createdAt, syncUpdatedAt:change.updatedAt)) }
case "budget":
guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { continue }
if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
else { context.insert(MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)) }
default: break
}
}
switch change.entity {
case "transaction", "category", "budget":
if let id = UUID(uuidString:change.id) { try applyV1(change, id:id, context:context) }
case "account", "transfer", "rate":
try applyV2(change, context:context)
case let entity where SyncRecord.storedEntities.contains(entity):
try applyRecord(change, context:context)
default:
break // незнакомая сущность: пропускаем, курсор двигается (§0.1)
}
SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt)
}
}
private func applyV1(_ change: SyncChange, id: UUID, context: ModelContext) throws {
if change.deleted {
switch change.entity {
case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
case "category": if let item = try category(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
case "budget": if let item = try budget(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
default: break
}
return
}
guard let data = change.payload else { return }
switch change.entity {
case "transaction":
guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { return }
let extras = ServerCoding.extras(data, known:TransactionPayload.keys)
if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
else {
let item = FinanceTransaction(id:id, amount:value.amount, date:value.date, note:value.note, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, categoryColorName:value.categoryColorName, isBalanceAdjustment:value.isBalanceAdjustment, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, accountID:value.accountId, currency:value.currency, syncUpdatedAt:change.updatedAt)
item.syncID = change.id; item.extraPayload = extras
context.insert(item)
}
case "category":
guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { return }
let extras = ServerCoding.extras(data, known:CategoryPayload.keys)
if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
else {
let item = CustomCategory(id:id, name:value.name, icon:value.icon, emoji:value.emoji, colorName:value.colorName, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, createdAt:value.createdAt, syncUpdatedAt:change.updatedAt)
item.syncID = change.id; item.extraPayload = extras
context.insert(item)
}
case "budget":
guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { return }
let extras = ServerCoding.extras(data, known:BudgetPayload.keys)
if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
else {
let item = MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)
item.syncID = change.id; item.extraPayload = extras
context.insert(item)
}
default: break
}
}
private func applyV2(_ change: SyncChange, context: ModelContext) throws {
let recordID = change.id
switch change.entity {
case "account":
let existing = try context.fetch(FetchDescriptor<FinanceAccount>(predicate:#Predicate { $0.syncID == recordID })).first
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
if change.deleted { if let existing { context.delete(existing) }; return }
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(AccountPayload.self, from:data) else { return }
let item = existing ?? { let item = FinanceAccount(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
item.name = value.name ?? ""
let kind = value.kindRawValue ?? "other"
item.kindRawValue = ["cash", "card", "deposit", "other"].contains(kind) ? kind : "other"
item.icon = value.icon ?? "creditcard.fill"; item.colorName = value.colorName ?? "indigo"
item.currency = value.currency?.uppercased() ?? ""; item.createdAt = value.createdAt ?? change.updatedAt
item.isArchived = value.isArchived ?? false; item.sortOrder = value.sortOrder ?? 0
item.extraPayload = ServerCoding.extras(data, known:AccountPayload.keys); item.syncUpdatedAt = change.updatedAt
case "transfer":
let existing = try context.fetch(FetchDescriptor<FinanceTransfer>(predicate:#Predicate { $0.syncID == recordID })).first
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
if change.deleted { if let existing { context.delete(existing) }; return }
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(TransferPayload.self, from:data) else { return }
let item = existing ?? { let item = FinanceTransfer(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
item.fromAccountID = value.fromAccountId ?? ""; item.toAccountID = value.toAccountId ?? ""
item.amount = value.amount ?? 0; item.toAmount = value.toAmount
item.date = value.date ?? change.updatedAt; item.note = value.note ?? ""
item.extraPayload = ServerCoding.extras(data, known:TransferPayload.keys); item.syncUpdatedAt = change.updatedAt
case "rate":
let existing = try context.fetch(FetchDescriptor<ExchangeRate>(predicate:#Predicate { $0.syncID == recordID })).first
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
if change.deleted { if let existing { context.delete(existing) }; return }
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(RatePayload.self, from:data) else { return }
let item = existing ?? { let item = ExchangeRate(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
item.from = value.from?.uppercased() ?? ""; item.to = value.to?.uppercased() ?? ""
item.rate = value.rate ?? ""; item.date = value.date ?? change.updatedAt
item.extraPayload = ServerCoding.extras(data, known:RatePayload.keys); item.syncUpdatedAt = change.updatedAt
default: break
}
}
/// recurring, goal, debt, attachment, setting — payload целиком.
/// Регулярные операции часы не исполняют (§4).
private func applyRecord(_ change: SyncChange, context: ModelContext) throws {
let entity = change.entity, recordID = change.id
let existing = try context.fetch(FetchDescriptor<SyncRecord>(predicate:#Predicate { $0.entity == entity && $0.recordID == recordID })).first
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
if change.deleted { if let existing { context.delete(existing) }; return }
guard let data = change.payload else { return }
if let existing { existing.payload = data; existing.syncUpdatedAt = change.updatedAt }
else { context.insert(SyncRecord(entity:entity, recordID:recordID, payload:data, syncUpdatedAt:change.updatedAt)) }
}
private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? {
let recordID = id
var descriptor = FetchDescriptor<FinanceTransaction>(predicate:#Predicate { item in item.id == recordID })
@@ -504,6 +734,15 @@ struct ServerAccountView: View {
Button("Зарегистрироваться") { Task { await account.register(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.count < 8 || account.isBusy)
}
}
if account.isSignedIn && (account.supportsLedgers || !account.ledgerID.isEmpty) {
Section("Пространство") {
NavigationLink {
WatchLedgersView()
} label: {
LabeledContent("Бюджет", value: account.ledgerID.isEmpty ? "Личный" : account.ledgerName)
}
}
}
if account.isSignedIn {
Section("Синхронизация") {
Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy)
@@ -3,6 +3,7 @@ import Foundation
struct FinancePeriodSummary {
let income: Double
let expenses: Double
var missing: Set<String> = []
var balance: Double { income - expenses }
}
@@ -17,11 +18,58 @@ struct CategorySpending: Identifiable {
var id: String { name }
}
/// Финансовые правила (общие для экосистемы, см. docs/CONTRACT_V2.md §2–3).
/// Расчёты ведутся в целых сотых; общие итоги — в базовой валюте.
enum FinanceCalculations {
static func totalBalance(transactions: [FinanceTransaction]) -> Double {
transactions.reduce(0) { balance, transaction in
balance + (transaction.kind == .income ? transaction.amount : -transaction.amount)
/// Общий баланс: все операции (включая корректировки и будущие даты)
/// плюс входящие минус исходящие части переводов — всё в базовой валюте.
static func totalBalance(
transactions: [LedgerEntry],
transfers: [LedgerTransfer] = [],
context: CurrencyContext
) -> MoneyTotal {
var total = MoneyTotal()
for item in transactions {
let cents = Money.cents(item.amount)
total.add(item.isIncome ? cents : -cents, context.currency(of: item), context.converter)
}
for transfer in transfers {
total.add(Money.cents(transfer.incomingAmount), context.accountCurrency(transfer.toAccountID), context.converter)
total.add(-Money.cents(transfer.amount), context.accountCurrency(transfer.fromAccountID), context.converter)
}
return total
}
/// Баланс счёта в его валюте, без конвертации.
static func accountBalance(
accountID: String,
transactions: [LedgerEntry],
transfers: [LedgerTransfer]
) -> Int64 {
let key = accountID.lowercased()
var cents: Int64 = 0
for item in transactions where item.accountID?.lowercased() == key {
let value = Money.cents(item.amount)
cents += item.isIncome ? value : -value
}
for transfer in transfers {
if transfer.toAccountID.lowercased() == key { cents += Money.cents(transfer.incomingAmount) }
if transfer.fromAccountID.lowercased() == key { cents -= Money.cents(transfer.amount) }
}
return cents
}
/// «Без счёта»: операции без `accountId`, по валютам.
static func unassignedBalances(
transactions: [LedgerEntry],
context: CurrencyContext
) -> [String: Int64] {
var result: [String: Int64] = [:]
for item in transactions where item.accountID == nil {
let value = Money.cents(item.amount)
result[context.currency(of: item), default: 0] += item.isIncome ? value : -value
}
return result
}
static func monthInterval(containing date: Date, calendar: Calendar = .current) -> DateInterval {
@@ -29,43 +77,52 @@ enum FinanceCalculations {
?? DateInterval(start: date, duration: 30 * 24 * 60 * 60)
}
/// Доходы и расходы периода `[начало, конец)` без корректировок, в базовой валюте.
static func summary(
transactions: [FinanceTransaction],
in interval: DateInterval
transactions: [LedgerEntry],
in interval: DateInterval,
context: CurrencyContext
) -> FinancePeriodSummary {
let periodTransactions = transactions.filter {
interval.contains($0.date) && !$0.isBalanceAdjustment
var income = MoneyTotal(), expenses = MoneyTotal()
for item in transactions where contains(interval, item.date) && !item.isBalanceAdjustment {
if item.isIncome { income.add(Money.cents(item.amount), context.currency(of: item), context.converter) }
else { expenses.add(Money.cents(item.amount), context.currency(of: item), context.converter) }
}
let income = periodTransactions
.filter { $0.kind == .income }
.reduce(0) { $0 + $1.amount }
let expenses = periodTransactions
.filter { $0.kind == .expense }
.reduce(0) { $0 + $1.amount }
return FinancePeriodSummary(income: income, expenses: expenses)
return FinancePeriodSummary(
income: income.value,
expenses: expenses.value,
missing: income.missing.union(expenses.missing)
)
}
static func spendingByCategory(
transactions: [FinanceTransaction],
in interval: DateInterval
in interval: DateInterval,
context: CurrencyContext
) -> [CategorySpending] {
let expenses = transactions.filter {
$0.kind == .expense
&& !$0.isBalanceAdjustment
&& interval.contains($0.date)
&& contains(interval, $0.date)
}
let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName)
return grouped.map { name, items in
CategorySpending(
var total = MoneyTotal()
for item in items { total.add(Money.cents(item.amount), context.currency(of: item), context.converter) }
return CategorySpending(
name: name,
icon: items.first?.categoryIcon ?? "circle.fill",
emoji: items.first?.categoryEmoji ?? "",
colorName: items.first?.categoryColorName ?? "auto",
amount: items.reduce(0) { $0 + $1.amount }
amount: total.value
)
}
.sorted { $0.amount > $1.amount }
}
/// Полуоткрытый интервал: `DateInterval.contains` включает конец.
static func contains(_ interval: DateInterval, _ date: Date) -> Bool {
date >= interval.start && date < interval.end
}
}
+159
View File
@@ -0,0 +1,159 @@
import Foundation
/// Денежная арифметика контракта v2 (§0, §2): суммы — целые сотые,
/// курсы — десятичные строки, округление до сотых половиной от нуля.
/// Файл не зависит от SwiftUI/SwiftData.
enum Money {
private static let posix = Locale(identifier: "en_US_POSIX")
/// Число с провода → целые сотые (лишние знаки округляются).
static func cents(_ value: Double) -> Int64 {
guard value.isFinite else { return 0 }
let decimal = Decimal(string: String(value), locale: posix) ?? Decimal(value)
return rounded(decimal * 100)
}
static func double(_ cents: Int64) -> Double { Double(cents) / 100 }
/// Курс из строки: только цифры и не более одной точки, больше нуля.
static func rate(_ text: String) -> Decimal? {
let trimmed = text.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, trimmed.filter({ $0 == "." }).count <= 1,
trimmed.allSatisfy({ $0.isASCII && ($0.isNumber || $0 == ".") }),
trimmed.first != ".", trimmed.last != ".",
let value = Decimal(string: trimmed, locale: posix), value > 0
else { return nil }
return value
}
/// `cents · rate` (или `cents / rate` для обратной пары), округлено до сотых.
static func convert(_ cents: Int64, rate: Decimal, inverse: Bool) -> Int64 {
let value = Decimal(cents)
return rounded(inverse ? value / rate : value * rate)
}
private static func rounded(_ value: Decimal) -> Int64 {
var source = value
var result = Decimal()
NSDecimalRound(&result, &source, 0, .plain) // половина — от нуля
return NSDecimalNumber(decimal: result).int64Value
}
}
struct RateQuote {
let from: String
let to: String
let rate: String
let date: Date
let updatedAt: Date
}
/// Перевод сумм в базовую валюту по последнему курсу на дату расчёта.
struct CurrencyConverter {
let base: String
private var best: [String: (rate: Decimal, inverse: Bool, date: Date, updatedAt: Date)] = [:]
init(base: String, quotes: [RateQuote], asOf: Date) {
let base = base.uppercased()
self.base = base
for quote in quotes where quote.date <= asOf {
let from = quote.from.uppercased(), to = quote.to.uppercased()
guard from != to, let rate = Money.rate(quote.rate) else { continue }
let other: String, inverse: Bool
if to == base { other = from; inverse = false }
else if from == base { other = to; inverse = true }
else { continue }
if let current = best[other] {
// Последняя по дате; при равной дате прямая пара важнее
// обратной; затем — более поздний updatedAt.
let newer = (quote.date, inverse ? 0 : 1, quote.updatedAt)
let old = (current.date, current.inverse ? 0 : 1, current.updatedAt)
guard newer > old else { continue }
}
best[other] = (rate, inverse, quote.date, quote.updatedAt)
}
}
/// `nil` — нет курса.
func toBase(_ cents: Int64, from currency: String) -> Int64? {
let code = currency.uppercased()
if code == base || cents == 0 { return cents }
guard let quote = best[code] else { return nil }
return Money.convert(cents, rate: quote.rate, inverse: quote.inverse)
}
}
protocol LedgerEntry {
var amount: Double { get }
var isIncome: Bool { get }
var isBalanceAdjustment: Bool { get }
var date: Date { get }
var accountID: String? { get }
var currency: String? { get }
}
protocol LedgerTransfer {
var fromAccountID: String { get }
var toAccountID: String { get }
var amount: Double { get }
var incomingAmount: Double { get }
}
/// Сумма в базовой валюте и валюты, для которых не нашлось курса.
struct MoneyTotal {
var cents: Int64 = 0
var missing: Set<String> = []
var value: Double { Money.double(cents) }
mutating func add(_ cents: Int64, _ currency: String, _ converter: CurrencyConverter) {
if let converted = converter.toBase(cents, from: currency) { self.cents += converted }
else { missing.insert(currency.uppercased()) }
}
}
/// Всё, что нужно для расчётов в базовой валюте: базовая валюта,
/// валюты и названия счетов, курсы.
struct CurrencyContext {
let converter: CurrencyConverter
private let accountCurrencies: [String: String]
private let accountNames: [String: String]
var base: String { converter.base }
init(base: String, accounts: [(id: String, name: String, currency: String)] = [], quotes: [RateQuote] = [], asOf: Date = .now) {
let base = base.uppercased()
converter = CurrencyConverter(base: base, quotes: quotes, asOf: asOf)
var currencies: [String: String] = [:], names: [String: String] = [:]
for account in accounts {
let key = account.id.lowercased()
currencies[key] = account.currency.isEmpty ? base : account.currency.uppercased()
names[key] = account.name
}
accountCurrencies = currencies
accountNames = names
}
/// Валюта счёта; удалённый счёт или пустая валюта — базовая.
func accountCurrency(_ id: String?) -> String {
guard let id else { return base }
return accountCurrencies[id.lowercased()] ?? base
}
/// `currency`, иначе валюта счёта, иначе базовая.
func currency(of entry: LedgerEntry) -> String {
if let code = entry.currency, !code.isEmpty { return code.uppercased() }
return accountCurrency(entry.accountID)
}
/// Название счёта, «Удалённый счёт» или `nil` для «Без счёта».
func accountTitle(_ id: String?) -> String? {
guard let id else { return nil }
return accountNames[id.lowercased()] ?? "Удалённый счёт"
}
func missingRateWarning(_ missing: Set<String>) -> String? {
guard !missing.isEmpty else { return nil }
return missing.sorted().map { "нет курса \($0)→\(base)" }.joined(separator: ", ")
}
}
+20 -1
View File
@@ -14,12 +14,13 @@
```text
BalanceWatch/
├── branding/ # аватар и баннер для README (PNG + SVG-исходники)
├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия, одинаковая во всех репозиториях)
├── BalanceWatch.xcodeproj/
└── BalanceWatch/
├── App/ # точка входа и экраны часов
├── Models/ # SwiftData-модели (копия из монорепы)
├── Shared/ # контейнер SwiftData, тема, ServerSync
├── Utilities/ # FinanceCalculations, форматирование валют
├── Utilities/ # FinanceCalculations, Money (сотые, курсы), форматирование валют
├── Views/Components/ # CategoryIconView
└── Assets.xcassets # иконка приложения
```
@@ -28,6 +29,24 @@ BalanceWatch/
любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в
таргет — править `project.pbxproj` вручную не нужно.
## Контракт v2
Часы реализуют клиентскую часть `docs/CONTRACT_V2.md` в компактном виде:
- синхронизируются и хранятся счета, переводы, курсы и настройка
`baseCurrency`; общий баланс, аналитика и бюджеты считаются в базовой
валюте, при отсутствии курса показывается «нет курса C→B»;
- экран «Счета» (только чтение) с балансами счетов и «Без счёта»; при
добавлении операции можно выбрать счёт, валюта следует за счётом;
- общие бюджеты: в «Настройки → Сервер → Бюджет» выбирается личное или общее
пространство, можно присоединиться по коду; данные каждого пространства
лежат в отдельном хранилище;
- регулярные операции, цели, долги, вложения и теги хранятся и не теряются
при пересохранении, но интерфейса для них нет; регулярные операции часы не
создают;
- если `GET /v1/capabilities` отвечает 404 (сервер v1), счета и общие
бюджеты скрыты, новые сущности не отправляются.
## Сборка
1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее.
+258
View File
@@ -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 до своего обновления.