Версия 3.2.0: контракт v2, оформление «Лес» #2
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: ", ")
|
||||
}
|
||||
}
|
||||
@@ -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 или новее.
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Контракт экосистемы v2
|
||||
|
||||
> Общий документ BalanceServer, BalanceFlutter и BalanceWatch. Одинаковая
|
||||
> копия лежит в каждом репозитории: `docs/ecosystem/CONTRACT_V2.md`
|
||||
> (Flutter), `docs/CONTRACT_V2.md` (сервер, часы). Меняется только во всех
|
||||
> трёх сразу.
|
||||
|
||||
v2 добавляет счета и переводы, регулярные операции, мультивалютность, цели,
|
||||
теги, вложения (фото чеков), общие бюджеты и долги. Всё сделано
|
||||
**добавлением**: формат `/v1/sync`, курсор, `sequence`, `updatedAt`,
|
||||
tombstone и last-write-wins не меняются. Клиент v1 продолжает работать с
|
||||
сервером v2, но не видит новых данных (ограничения — в конце).
|
||||
|
||||
## 0. Общие правила
|
||||
|
||||
1. **Незнакомая сущность** при pull пропускается молча (курсор двигается).
|
||||
2. **Незнакомые поля** payload знакомой сущности клиент v2 **сохраняет** и
|
||||
отправляет обратно без изменений при следующем сохранении записи
|
||||
(forward compatibility). Клиент v1 их теряет — поэтому обновлять нужно все
|
||||
клиенты.
|
||||
3. Все новые поля payload необязательные. Отсутствие поля = значение по
|
||||
умолчанию, указанное ниже.
|
||||
4. Деньги на проводе — числа (`amount`, `limit`, `target`…), как в v1;
|
||||
внутри клиентов — целые сотые. Курс валют — **строка** десятичного числа.
|
||||
5. Даты — ISO 8601 UTC. Интервалы — `[начало, конец)`.
|
||||
6. Коды валют — ISO 4217 в верхнем регистре (`RUB`, `EUR`, `USD`, `SEK`, …).
|
||||
7. Идентификаторы записей — UUID в нижнем регистре, кроме `setting` (ключ).
|
||||
|
||||
### Возможности сервера
|
||||
|
||||
`GET /v1/capabilities` (без авторизации):
|
||||
|
||||
```json
|
||||
{
|
||||
"contract": 2,
|
||||
"entities": ["transaction","category","budget","account","transfer",
|
||||
"recurring","goal","debt","rate","setting","attachment"],
|
||||
"attachments": {"maxBytes": 10485760,
|
||||
"types": ["image/jpeg","image/png","image/heic","application/pdf"]},
|
||||
"ledgers": true
|
||||
}
|
||||
```
|
||||
|
||||
Сервер v1 отвечает 404 — клиент v2 тогда скрывает вложения и общие бюджеты и
|
||||
не отправляет новые сущности.
|
||||
|
||||
## 1. Сущности
|
||||
|
||||
Белый список сервера: `transaction`, `category`, `budget`, `account`,
|
||||
`transfer`, `recurring`, `goal`, `debt`, `rate`, `setting`, `attachment`.
|
||||
|
||||
### transaction — новые поля
|
||||
|
||||
| Поле | Тип | По умолчанию | Смысл |
|
||||
| --- | --- | --- | --- |
|
||||
| `accountId` | string? | нет | счёт; без него — «Без счёта» |
|
||||
| `currency` | string? | валюта счёта, иначе базовая | валюта суммы |
|
||||
| `tags` | [string]? | `[]` | теги, см. §6 |
|
||||
| `recurringId` | string? | нет | создана из регулярной операции |
|
||||
| `occurrence` | string? | нет | дата повторения `YYYY-MM-DD` |
|
||||
|
||||
`kindRawValue` остаётся `income` / `expense`. Перевод — **не** транзакция.
|
||||
|
||||
### account
|
||||
|
||||
```json
|
||||
{"name":"Карта","kindRawValue":"card","icon":"creditcard.fill",
|
||||
"colorName":"blue","currency":"RUB","createdAt":"…","isArchived":false,
|
||||
"sortOrder":0}
|
||||
```
|
||||
|
||||
`kindRawValue`: `cash` | `card` | `deposit` | `other` (незнакомое → `other`).
|
||||
`currency` по умолчанию — базовая.
|
||||
|
||||
### transfer
|
||||
|
||||
```json
|
||||
{"fromAccountId":"…","toAccountId":"…","amount":1000.0,"toAmount":10.5,
|
||||
"date":"…","note":""}
|
||||
```
|
||||
|
||||
`toAmount` нужен, когда валюты счетов различаются; по умолчанию = `amount`.
|
||||
`amount` — в валюте счёта-источника, `toAmount` — в валюте счёта-получателя.
|
||||
|
||||
### recurring
|
||||
|
||||
```json
|
||||
{"kindRawValue":"expense","amount":990.0,"currency":"RUB","accountId":null,
|
||||
"categoryName":"Подписки","categoryIcon":"repeat","categoryEmoji":"",
|
||||
"categoryColorName":"cyan","note":"Связь","tags":[],
|
||||
"frequency":"monthly","interval":1,"startDate":"2026-01-05T09:00:00Z",
|
||||
"endDate":null,"mode":"auto","isPaused":false,"generatedThrough":null}
|
||||
```
|
||||
|
||||
`frequency`: `daily` | `weekly` | `monthly` | `yearly`; `interval` ≥ 1;
|
||||
`mode`: `auto` (создаётся само) | `remind` (напоминание, пользователь
|
||||
подтверждает). `generatedThrough` — дата `YYYY-MM-DD` последнего
|
||||
обработанного повторения.
|
||||
|
||||
### goal
|
||||
|
||||
```json
|
||||
{"name":"Отпуск","target":150000.0,"currency":"RUB","deadline":null,
|
||||
"accountId":null,"saved":20000.0,"icon":"airplane","colorName":"sky",
|
||||
"createdAt":"…","isArchived":false}
|
||||
```
|
||||
|
||||
Прогресс = баланс счёта `accountId`, если он задан, иначе `saved`.
|
||||
|
||||
### debt
|
||||
|
||||
```json
|
||||
{"person":"Иван","direction":"owedToMe","amount":5000.0,"currency":"RUB",
|
||||
"date":"…","dueDate":null,"note":"","repaid":1000.0,"isClosed":false}
|
||||
```
|
||||
|
||||
`direction`: `iOwe` | `owedToMe`. Остаток = `amount − repaid`. Долги **не**
|
||||
входят в баланс и аналитику: деньги, которые реально ушли или пришли,
|
||||
пользователь записывает обычной операцией.
|
||||
|
||||
### rate
|
||||
|
||||
```json
|
||||
{"from":"USD","to":"RUB","rate":"92.35","date":"2026-10-31T00:00:00Z"}
|
||||
```
|
||||
|
||||
`rate` — сколько единиц `to` стоит одна единица `from`, до 6 знаков после
|
||||
точки.
|
||||
|
||||
### setting
|
||||
|
||||
`id` — ключ, payload `{"value": …}`. Сейчас ключ один: `baseCurrency`
|
||||
(строка ISO 4217) — валюта, в которой считаются общие суммы. Если записи нет,
|
||||
базовая валюта — локальная настройка устройства (поведение v1).
|
||||
|
||||
### attachment
|
||||
|
||||
```json
|
||||
{"transactionId":"…","sha256":"<64 hex>","contentType":"image/jpeg",
|
||||
"size":182345,"name":"чек.jpg","createdAt":"…"}
|
||||
```
|
||||
|
||||
Метаданные синхронизируются обычным `/v1/sync`; сами байты — через §8.
|
||||
|
||||
## 2. Валюты и курсы
|
||||
|
||||
- Валюта транзакции: `currency`, иначе валюта её счёта, иначе базовая.
|
||||
- Перевод суммы `x` из `C` в базовую `B`: при `C == B` без изменений; иначе
|
||||
берётся последняя (по `date`, затем по `updatedAt`) запись `rate` с
|
||||
`from=C,to=B` или обратная `from=B,to=C` (тогда делим). Если подходящих
|
||||
записей несколько с одной датой, прямая пара важнее обратной.
|
||||
- Результат округляется до сотых половиной вверх (от нуля). Считать через
|
||||
целые сотые и десятичную строку курса, не через `double`-накопление.
|
||||
- Нет курса → сумма **не входит** в общие итоги, клиент показывает
|
||||
предупреждение «нет курса C→B». Внутри счёта суммы складываются в валюте
|
||||
счёта без конвертации.
|
||||
- Курс для периода — последний на дату расчёта (не на дату операции).
|
||||
|
||||
## 3. Баланс (расширяет финансовые правила v1)
|
||||
|
||||
- Баланс счёта (в его валюте) = доходы − расходы с этим `accountId`
|
||||
(включая корректировки) + `toAmount` входящих переводов − `amount`
|
||||
исходящих.
|
||||
- «Без счёта» = транзакции без `accountId` (в своих валютах).
|
||||
- Общий баланс = сумма всех транзакций в базовой валюте + переводы
|
||||
(входящая часть − исходящая, каждая в базовой валюте). При одной валюте
|
||||
переводы дают ноль, и общий баланс совпадает с v1.
|
||||
- Переводы не входят в аналитику и бюджеты. Корректировки — как в v1.
|
||||
- Аналитика и бюджеты складывают суммы в базовой валюте.
|
||||
- Архивный счёт скрыт из выбора, но его баланс входит в общий.
|
||||
- Транзакции и переводы, ссылающиеся на удалённый счёт, остаются; клиент
|
||||
показывает их как «Удалённый счёт».
|
||||
|
||||
## 4. Регулярные операции
|
||||
|
||||
- Повторения считаются в календаре **UTC** от даты `startDate` (её UTC-дата
|
||||
и время суток): `daily` — каждые `interval` дней, `weekly` — каждые
|
||||
`7·interval` дней, `monthly` — каждые `interval` месяцев в тот же день
|
||||
(если дня нет — последний день месяца), `yearly` — то же с годами
|
||||
(29 февраля → 28 февраля). Повторение после `endDate` не создаётся.
|
||||
- `occurrence` = UTC-дата `YYYY-MM-DD`; дата транзакции = эта дата + время
|
||||
суток `startDate`.
|
||||
- ID транзакции **детерминирован**: UUID v5 от пространства имён
|
||||
`6f1c3f5e-8a8d-4c3a-9d2b-4b1f0e7a2c11` и строки
|
||||
`recurring:<recurringId>:<occurrence>` (нижний регистр). Два устройства
|
||||
создают одну и ту же запись — дублей нет.
|
||||
- `auto`: клиент создаёт все повторения в `(generatedThrough, сегодня UTC]`
|
||||
(с `startDate`, если `generatedThrough` пуст) и ставит
|
||||
`generatedThrough` = последнее обработанное. Удалённое пользователем
|
||||
повторение не воскрешается, т.к. `generatedThrough` уже позади. Не более
|
||||
366 повторений за раз.
|
||||
- `remind`: клиент показывает повторения из того же окна как «ожидают»;
|
||||
«Записать» создаёт транзакцию с тем же детерминированным ID, «Пропустить»
|
||||
только двигает `generatedThrough`.
|
||||
- `isPaused` — ничего не создаётся и не напоминается.
|
||||
- Часы регулярные операции не создают (их создают телефон и десктоп).
|
||||
|
||||
## 5. Цели, долги — см. §1. Цели и долги не меняют баланс.
|
||||
|
||||
## 6. Теги
|
||||
|
||||
Строки после `trim`, 1–32 символа, без учёта регистра уникальны в операции,
|
||||
не больше 10 на операцию. Порядок сохраняется. Аналитика и бюджеты тегами не
|
||||
фильтруются; список операций — фильтруется.
|
||||
|
||||
## 7. Общие бюджеты (ledgers)
|
||||
|
||||
Пространство данных (ledger) — отдельный набор записей со своим курсором.
|
||||
У каждого пользователя есть **личное** пространство (как в v1). Общее
|
||||
пространство создаёт владелец и приглашает участников по коду.
|
||||
|
||||
| Метод | Путь | Тело / ответ |
|
||||
| --- | --- | --- |
|
||||
| GET | `/v1/ledgers` | `{"ledgers":[{"id","name","role","members":[{"userId","email","role"}]}]}` — только общие |
|
||||
| POST | `/v1/ledgers` | `{"name"}` → ledger, `role:"owner"` |
|
||||
| PATCH | `/v1/ledgers/{id}` | `{"name"}` (владелец) |
|
||||
| DELETE | `/v1/ledgers/{id}` | владелец: удалить пространство со всеми записями и вложениями |
|
||||
| POST | `/v1/ledgers/{id}/invites` | → `{"code","expiresAt"}`; код из 10 символов, 7 дней, одноразовый (владелец) |
|
||||
| POST | `/v1/ledgers/join` | `{"code"}` → ledger, `role:"member"` |
|
||||
| DELETE | `/v1/ledgers/{id}/members/{userId}` | владелец удаляет участника; участник — себя (`userId` = свой) |
|
||||
|
||||
- Пространство выбирается заголовком `X-Balance-Ledger: <id>` на
|
||||
`/v1/sync` и `/v1/attachments/*`. Без заголовка — личное пространство.
|
||||
Не участник → `403 forbidden`, несуществующее → `404 not_found`.
|
||||
- Владелец не может выйти, пока есть участники (только удалить пространство).
|
||||
- Все участники равны в правах на записи (last-write-wins как в v1).
|
||||
- Клиент хранит данные пространства отдельно: scope = сервер + пользователь
|
||||
+ пространство. Переключение пространства не переносит записи. Курсор и
|
||||
очередь изменений — свои у каждого пространства.
|
||||
- Ошибки: `invalid_code` (400), `forbidden` (403), `not_found` (404),
|
||||
`owner_cannot_leave` (409). Коды приглашений хранятся на сервере только
|
||||
как хеш.
|
||||
|
||||
## 8. Вложения
|
||||
|
||||
| Метод | Путь | Смысл |
|
||||
| --- | --- | --- |
|
||||
| PUT | `/v1/attachments/{sha256}` | тело — байты файла, `Content-Type` из списка; сервер проверяет SHA-256 и размер; повтор идемпотентен (`200`, первый раз `201`) |
|
||||
| GET | `/v1/attachments/{sha256}` | байты и `Content-Type`; `404`, если в пространстве нет такого файла |
|
||||
|
||||
- Порядок клиента: сначала PUT байтов, потом запись `attachment` через sync.
|
||||
- Файлы хранятся в пространстве; один и тот же файл в двух пространствах
|
||||
хранится дважды.
|
||||
- Когда в пространстве не осталось живых записей `attachment` с этим
|
||||
`sha256`, сервер может удалить файл.
|
||||
- Ошибки: `payload_too_large` (413), `unsupported_media_type` (415),
|
||||
`hash_mismatch` (400).
|
||||
- Клиент скачивает файл лениво при просмотре и кеширует локально.
|
||||
|
||||
## 9. Совместимость с клиентами v1
|
||||
|
||||
- Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и
|
||||
общие пространства; транзакции в других валютах складывает как есть.
|
||||
- При редактировании транзакции старый клиент теряет `accountId`,
|
||||
`currency`, `tags`, `recurringId`, `occurrence`. Поэтому все клиенты
|
||||
экосистемы обновляются вместе с сервером.
|
||||
- Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются
|
||||
на v1 до своего обновления.
|
||||
Reference in new issue
Block a user