Merge pull request 'Версия 3.2.0: контракт v2, оформление «Лес»' (#2) from develop into main

Reviewed-on: #2
This commit is contained in:
wt committed 2026-10-31 19:17:46 +00:00
commit f0e4eefd3a
48 files changed
+1719 -225

No files matched your search

+53 -10
View File
@@ -28,19 +28,25 @@ BalanceWatch/
├── AGENTS.md ├── AGENTS.md
├── README.md ├── README.md
├── .github/workflows/build.yml # CI: сборка для симулятора и устройства ├── .github/workflows/build.yml # CI: сборка для симулятора и устройства
├── branding/ # аватар репозитория, не входит в бандл ├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия)
├── branding/ # icon.svg, avatar, banner, render.sh; не входит в бандл
├── docs/ # CONTRACT_V2.md, скриншоты для баннера
├── BalanceWatch.xcodeproj/ # один таргет и одна схема — BalanceWatch ├── BalanceWatch.xcodeproj/ # один таргет и одна схема — BalanceWatch
└── BalanceWatch/ └── BalanceWatch/
├── App/ ├── App/
│ ├── BalanceWatchApp.swift # @main, ModelContainer, тема │ ├── BalanceWatchApp.swift # @main, ModelContainer, тема
│ ├── WatchRootView.swift # главный экран: баланс, месяц, навигация │ ├── WatchRootView.swift # главный экран: баланс, месяц, навигация
│ ├── WatchAddTransactionView.swift # добавление операции │ ├── WatchAddTransactionView.swift # добавление операции (+ выбор счёта)
│ ├── WatchAccountsView.swift # CurrencyContext в окружении, счета,
│ │ # выбор пространства (ledger)
│ └── WatchFeatures.swift # аналитика, операции, бюджеты, │ └── WatchFeatures.swift # аналитика, операции, бюджеты,
│ # категории, корректировка баланса, настройки │ # категории, корректировка баланса, настройки
├── Models/ # SwiftData-модели и системные категории ├── Models/ # SwiftData-модели и системные категории;
│ # V2Records.swift — счета, переводы, курсы, SyncRecord
├── Shared/ # BalanceModelContainer, AppTheme, ├── Shared/ # BalanceModelContainer, AppTheme,
│ # CategoryDesignOptions, ServerSync (ServerAccountStore) │ # CategoryDesignOptions, ServerSync (ServerAccountStore)
├── Utilities/ # FinanceCalculations, форматирование валют и дат ├── Utilities/ # FinanceCalculations, Money (сотые, курсы, конвертация),
│ # форматирование валют и дат
├── Views/Components/CategoryIconView.swift ├── Views/Components/CategoryIconView.swift
└── Assets.xcassets └── Assets.xcassets
``` ```
@@ -81,7 +87,20 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
### 2. SwiftData ### 2. SwiftData
Схема зарегистрирована в `BalanceModelContainer.schema`: Схема зарегистрирована в `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-поля без стратегии миграции; новые поля делай со значением persisted-поля без стратегии миграции; новые поля делай со значением
@@ -107,13 +126,18 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
Центральные правила — в `FinanceCalculations`: Центральные правила — в `FinanceCalculations`:
- общий баланс = доходы − расходы; - суммы считаются в целых сотых (`Money`), курсы — десятичные строки,
округление половиной от нуля; интервалы полуоткрытые `[начало, конец)`;
- общий баланс = доходы − расходы + переводы, всё в базовой валюте
(`CurrencyContext`: `setting baseCurrency`, иначе `@AppStorage("currencyCode")`);
нет курса — сумма не входит, показывается «нет курса C→B»;
- баланс счёта — в его валюте без конвертации;
- операции с `isBalanceAdjustment == true` входят в общий баланс, но - операции с `isBalanceAdjustment == true` входят в общий баланс, но
**не** входят в периодическую аналитику (месяц/3 мес./12 мес.); **не** входят в периодическую аналитику (месяц/3 мес./12 мес.);
- расходы группируются по `categoryName`. - расходы группируются по `categoryName`.
Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web). Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web)
Не меняй их только здесь. и описаны в `docs/CONTRACT_V2.md` §2–3. Не меняй их только здесь.
### 5. Синхронизация (общий контракт) ### 5. Синхронизация (общий контракт)
@@ -129,7 +153,15 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
изменения сервера и остальных клиентов сломает синхронизацию. изменения сервера и остальных клиентов сломает синхронизацию.
- При смене URL сервера сессия очищается, cursor/push-state - При смене URL сервера сессия очищается, cursor/push-state
сбрасываются, пользователь логинится заново (`saveServer`). Сохраняй сбрасываются, пользователь логинится заново (`saveServer`). Сохраняй
это поведение. это поведение. Смена сервера и выход возвращают личное пространство.
- v2: перед синхронизацией запрашивается `GET /v1/capabilities`; 404 —
сервер v1, тогда `supportsV2 == false`: счета и общие бюджеты скрыты,
`setting` не отправляется. Незнакомые сущности при pull пропускаются.
- Общее пространство передаётся заголовком `X-Balance-Ledger` на `v1/sync`;
cursor, push-state и журнал удалений имеют суффикс `LedgerSelection.keySuffix`.
- Из сущностей v2 часы отправляют только `setting` (базовая валюта);
счета, переводы, курсы, регулярные операции и прочее только читают.
Регулярные операции часы не создают.
### 6. Безопасность ### 6. Безопасность
@@ -162,7 +194,10 @@ CI (`.github/workflows/build.yml`) собирает проект на `macos-15`
симулятора и для устройства без подписи. Перед PR сборка должна быть симулятора и для устройства без подписи. Перед PR сборка должна быть
зелёной в обоих вариантах. зелёной в обоих вариантах.
Тестового таргета пока нет. Если добавляешь тесты, не завязывай их на Тестового таргета пока нет. `Utilities/Money.swift` не зависит от
SwiftData/SwiftUI, а `FinanceCalculations` работает через протоколы
`LedgerEntry`/`LedgerTransfer`, поэтому денежную математику можно проверить
отдельным `swiftc`-прогоном на macOS (с заглушкой `FinanceTransaction`). Если добавляешь тесты, не завязывай их на
текущую дату и локаль — явно фиксируй `Calendar`, `Date` и formatter'ы. текущую дату и локаль — явно фиксируй `Calendar`, `Date` и formatter'ы.
Добавив тестовый таргет, добавь и шаг `test` в CI. Добавив тестовый таргет, добавь и шаг `test` в CI.
@@ -176,3 +211,11 @@ CI (`.github/workflows/build.yml`) собирает проект на `macos-15`
- [ ] Секреты и токены не попали в репозиторий и логи. - [ ] Секреты и токены не попали в репозиторий и логи.
- [ ] CI собирает проект для симулятора и для устройства. - [ ] CI собирает проект для симулятора и для устройства.
- [ ] README/AGENTS.md обновлены, если поменялась структура или сборка. - [ ] README/AGENTS.md обновлены, если поменялась структура или сборка.
## Оформление
- Цвета интерфейса — только из `BalancePalette` (`Shared/AppTheme.swift`),
палитра «Лес», общая с BalanceFlutter. Не возвращай indigo/purple.
- Иконка, аватар и баннер рендерятся из SVG: `bash branding/render.sh`
(нужен `resvg`). PNG в `AppIcon.appiconset` руками не правь.
- Аватар репозитория в Gitea обновляется из `branding/avatar.png`.
+4 -4
View File
@@ -238,7 +238,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14; CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = K3AS449A74; DEVELOPMENT_TEAM = K3AS449A74;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -247,7 +247,7 @@
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
INFOPLIST_KEY_WKWatchKitApp = YES; INFOPLIST_KEY_WKWatchKitApp = YES;
INFOPLIST_KEY_WKWatchOnly = YES; INFOPLIST_KEY_WKWatchOnly = YES;
MARKETING_VERSION = 3.1.1; MARKETING_VERSION = 3.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp; PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
@@ -265,7 +265,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14; CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = K3AS449A74; DEVELOPMENT_TEAM = K3AS449A74;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Баланс"; INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
@@ -273,7 +273,7 @@
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
INFOPLIST_KEY_WKWatchKitApp = YES; INFOPLIST_KEY_WKWatchKitApp = YES;
INFOPLIST_KEY_WKWatchOnly = YES; INFOPLIST_KEY_WKWatchOnly = YES;
MARKETING_VERSION = 3.1.1; MARKETING_VERSION = 3.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp; PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
+5 -3
View File
@@ -3,15 +3,17 @@ import SwiftUI
@main @main
struct BalanceWatchApp: App { struct BalanceWatchApp: App {
private let modelContainer = BalanceModelContainer.make() private let containers = LedgerContainers()
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
@AppStorage(LedgerSelection.scopeKey) private var ledgerScope = ""
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
WatchRootView() WatchRootView()
.tint(.indigo) .id(ledgerScope)
.tint(BalancePalette.accent)
.preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme) .preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme)
.modelContainer(containers.container(for: ledgerScope))
} }
.modelContainer(modelContainer)
} }
} }
+136
View File
@@ -0,0 +1,136 @@
import SwiftData
import SwiftUI
// Контракт v2 на часах: контекст валют, список счетов (только чтение)
// и выбор пространства (личное или общий бюджет).
private struct CurrencyContextKey: EnvironmentKey {
static let defaultValue = CurrencyContext(base: SupportedCurrency.RUB.rawValue)
}
extension EnvironmentValues {
/// Базовая валюта, валюты счетов и курсы текущего пространства.
var currencyContext: CurrencyContext {
get { self[CurrencyContextKey.self] }
set { self[CurrencyContextKey.self] = newValue }
}
}
/// Строит `CurrencyContext` из данных пространства и кладёт в окружение.
struct CurrencyContextProvider<Content: View>: View {
@Query private var accounts: [FinanceAccount]
@Query private var rates: [ExchangeRate]
@Query(filter: #Predicate<SyncRecord> { $0.entityName == "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 ? BalancePalette.expense : .secondary)
}
}
}
}
struct WatchLedgersView: View {
@StateObject private var account = ServerAccountStore.shared
@State private var ledgers: [ServerLedger] = []
@State private var code = ""
var body: some View {
List {
Section {
choice("Личный", id: "", ledger: nil)
ForEach(ledgers) { choice($0.name, id: $0.id, ledger: $0) }
} footer: {
Text("Данные каждого бюджета хранятся на часах отдельно.")
}
Section("Присоединиться") {
TextField("Код приглашения", text: $code)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Присоединиться") {
Task {
if let ledger = await account.joinLedger(code: code) {
code = ""
if !ledgers.contains(ledger) { ledgers.append(ledger) }
account.selectLedger(ledger)
}
}
}
.disabled(code.trimmingCharacters(in: .whitespaces).isEmpty || account.isBusy)
}
if account.isBusy { ProgressView() }
if let message = account.message { Text(message).font(.footnote).foregroundStyle(.secondary) }
}
.navigationTitle("Бюджет")
.task { if let value = await account.loadLedgers() { ledgers = value } }
}
private func choice(_ title: String, id: String, ledger: ServerLedger?) -> some View {
Button {
account.selectLedger(ledger)
} label: {
HStack {
Text(title).lineLimit(1)
Spacer()
if account.ledgerID == id { Image(systemName: "checkmark") }
}
}
.disabled(account.isBusy)
}
}
+48 -1
View File
@@ -4,8 +4,12 @@ import SwiftUI
struct WatchAddTransactionView: View { struct WatchAddTransactionView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.currencyContext) private var currency
@StateObject private var account = ServerAccountStore.shared
@Query(sort: \CustomCategory.createdAt) @Query(sort: \CustomCategory.createdAt)
private var customCategories: [CustomCategory] private var customCategories: [CustomCategory]
@Query(sort: [SortDescriptor(\FinanceAccount.sortOrder), SortDescriptor(\FinanceAccount.name)])
private var accounts: [FinanceAccount]
private let transaction: FinanceTransaction? private let transaction: FinanceTransaction?
@State private var kind: TransactionKind @State private var kind: TransactionKind
@@ -13,6 +17,8 @@ struct WatchAddTransactionView: View {
@State private var categoryName: String @State private var categoryName: String
@State private var date: Date @State private var date: Date
@State private var note: String @State private var note: String
/// Пустая строка — «Без счёта».
@State private var accountID: String
init(transaction: FinanceTransaction? = nil) { init(transaction: FinanceTransaction? = nil) {
self.transaction = transaction self.transaction = transaction
@@ -23,6 +29,31 @@ struct WatchAddTransactionView: View {
?? FinanceCategory.categories(for: initialKind)[0].name) ?? FinanceCategory.categories(for: initialKind)[0].name)
_date = State(initialValue: transaction?.date ?? .now) _date = State(initialValue: transaction?.date ?? .now)
_note = State(initialValue: transaction?.note ?? "") _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] { private var categories: [FinanceCategory] {
@@ -54,6 +85,13 @@ struct WatchAddTransactionView: View {
TextField("Сумма", value: $amount, format: .number) 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) { Picker("Категория", selection: $categoryName) {
ForEach(categories) { category in ForEach(categories) { category in
Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name) Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name)
@@ -80,7 +118,10 @@ struct WatchAddTransactionView: View {
} }
private func save() { private func save() {
let amount = Money.double(Money.cents(amount))
guard amount > 0 else { return } guard amount > 0 else { return }
let newAccount = accountID.isEmpty ? nil : accountID
let newCurrency = account.supportsV2 ? effectiveCurrency : nil
let category = categories.first { $0.name == categoryName } let category = categories.first { $0.name == categoryName }
?? FinanceCategory.categories(for: kind)[0] ?? FinanceCategory.categories(for: kind)[0]
if let transaction { if let transaction {
@@ -92,6 +133,10 @@ struct WatchAddTransactionView: View {
transaction.categoryColorName = category.colorName transaction.categoryColorName = category.colorName
transaction.date = date transaction.date = date
transaction.note = note transaction.note = note
if transaction.accountID != newAccount {
transaction.accountID = newAccount
transaction.currency = newCurrency
}
transaction.syncUpdatedAt = .now transaction.syncUpdatedAt = .now
} else { } else {
modelContext.insert(FinanceTransaction( modelContext.insert(FinanceTransaction(
@@ -102,7 +147,9 @@ struct WatchAddTransactionView: View {
categoryIcon: category.icon, categoryIcon: category.icon,
categoryEmoji: category.emoji, categoryEmoji: category.emoji,
categoryColorName: category.colorName, categoryColorName: category.colorName,
kind: kind kind: kind,
accountID: newAccount,
currency: newCurrency
)) ))
} }
dismiss() dismiss()
+62 -18
View File
@@ -29,17 +29,19 @@ private enum WatchAnalyticsPeriod: String, CaseIterable, Identifiable {
struct WatchAnalyticsView: View { struct WatchAnalyticsView: View {
@Query private var transactions: [FinanceTransaction] @Query private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue @Environment(\.currencyContext) private var currency
@State private var period = WatchAnalyticsPeriod.month @State private var period = WatchAnalyticsPeriod.month
private var currencyCode: String { currency.base }
private var interval: DateInterval { period.interval() } private var interval: DateInterval { period.interval() }
private var summary: FinancePeriodSummary { private var summary: FinancePeriodSummary {
FinanceCalculations.summary(transactions: transactions, in: interval) FinanceCalculations.summary(transactions: transactions, in: interval, context: currency)
} }
private var spending: [CategorySpending] { private var spending: [CategorySpending] {
Array(FinanceCalculations.spendingByCategory( Array(FinanceCalculations.spendingByCategory(
transactions: transactions, transactions: transactions,
in: interval in: interval,
context: currency
).prefix(6)) ).prefix(6))
} }
private var savingsRate: Double { private var savingsRate: Double {
@@ -53,13 +55,17 @@ struct WatchAnalyticsView: View {
} }
Section("Итоги") { Section("Итоги") {
metric("Доходы", summary.income, .green) metric("Доходы", summary.income, BalancePalette.income)
metric("Расходы", summary.expenses, .red) metric("Расходы", summary.expenses, BalancePalette.expense)
LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode)) LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode))
LabeledContent( LabeledContent(
"Сбережения", "Сбережения",
value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%" value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%"
) )
if let warning = currency.missingRateWarning(summary.missing) {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.caption2).foregroundStyle(.orange)
}
} }
Section("Категории") { Section("Категории") {
@@ -94,7 +100,7 @@ struct WatchAnalyticsView: View {
private func tint(for item: CategorySpending) -> Color { private func tint(for item: CategorySpending) -> Color {
item.colorName == "auto" item.colorName == "auto"
? FinanceCategory.find(named: item.name)?.tint ?? .indigo ? FinanceCategory.find(named: item.name)?.tint ?? BalancePalette.forest
: FinanceCategory.color(named: item.colorName) : FinanceCategory.color(named: item.colorName)
} }
} }
@@ -103,7 +109,7 @@ struct WatchTransactionsView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Query(sort: \FinanceTransaction.date, order: .reverse) @Query(sort: \FinanceTransaction.date, order: .reverse)
private var transactions: [FinanceTransaction] 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 selectedKind: TransactionKind?
@State private var searchText = "" @State private var searchText = ""
@State private var showingAdd = false @State private var showingAdd = false
@@ -141,8 +147,11 @@ struct WatchTransactionsView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(transaction.categoryName).lineLimit(1) Text(transaction.categoryName).lineLimit(1)
Text((transaction.kind == .income ? "+" : "−") Text((transaction.kind == .income ? "+" : "−")
+ transaction.amount.formattedCurrency(code: currencyCode)) + transaction.amount.formattedCurrency(code: currency.currency(of: transaction)))
.font(.caption2) .font(.caption2)
if let title = currency.accountTitle(transaction.accountID) {
Text(title).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
} }
} }
} }
@@ -164,7 +173,7 @@ struct WatchTransactionsView: View {
private func tint(_ transaction: FinanceTransaction) -> Color { private func tint(_ transaction: FinanceTransaction) -> Color {
transaction.categoryColorName == "auto" transaction.categoryColorName == "auto"
? FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo ? FinanceCategory.find(named: transaction.categoryName)?.tint ?? BalancePalette.forest
: FinanceCategory.color(named: transaction.categoryColorName) : FinanceCategory.color(named: transaction.categoryColorName)
} }
} }
@@ -172,9 +181,11 @@ struct WatchTransactionsView: View {
struct WatchBudgetsView: View { struct WatchBudgetsView: View {
@Query private var budgets: [MonthlyBudget] @Query private var budgets: [MonthlyBudget]
@Query private var transactions: [FinanceTransaction] @Query private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue @Environment(\.currencyContext) private var currency
@State private var showingEditor = false @State private var showingEditor = false
private var currencyCode: String { currency.base }
private var interval: DateInterval { private var interval: DateInterval {
FinanceCalculations.monthInterval(containing: .now) FinanceCalculations.monthInterval(containing: .now)
} }
@@ -184,7 +195,8 @@ struct WatchBudgetsView: View {
private var spending: [String: Double] { private var spending: [String: Double] {
Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory( Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory(
transactions: transactions, transactions: transactions,
in: interval in: interval,
context: currency
).map { ($0.name, $0.amount) }) ).map { ($0.name, $0.amount) })
} }
@@ -255,7 +267,7 @@ private struct WatchBudgetEditor: View {
private func save() { private func save() {
for category in categories { 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 } let existing = budgets.first { $0.categoryName == category.name }
if value > 0 { if value > 0 {
if let existing { if let existing {
@@ -429,11 +441,16 @@ struct WatchBalanceAdjustmentView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Query private var transactions: [FinanceTransaction] @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 desired = 0.0
@State private var note = "" @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 } private var difference: Double { desired - current }
var body: some View { var body: some View {
@@ -453,26 +470,53 @@ struct WatchBalanceAdjustmentView: View {
private func save() { private func save() {
guard abs(difference) >= 0.005 else { return } guard abs(difference) >= 0.005 else { return }
modelContext.insert(FinanceTransaction( modelContext.insert(FinanceTransaction(
amount: abs(difference), amount: Money.double(Money.cents(abs(difference))),
note: note.isEmpty ? "Ручная корректировка баланса" : note, note: note.isEmpty ? "Ручная корректировка баланса" : note,
categoryName: "Корректировка", categoryName: "Корректировка",
categoryIcon: "slider.horizontal.3", categoryIcon: "slider.horizontal.3",
categoryColorName: "indigo", categoryColorName: "indigo",
isBalanceAdjustment: true, isBalanceAdjustment: true,
kind: difference > 0 ? .income : .expense kind: difference > 0 ? .income : .expense,
currency: account.supportsV2 ? currency.base : nil
)) ))
dismiss() dismiss()
} }
} }
struct WatchSettingsView: View { 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("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
@Query(filter: #Predicate<SyncRecord> { $0.entityName == "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(entityName: "setting", recordID: SyncRecord.baseCurrencyKey, payload: payload, syncUpdatedAt: .now))
}
})
}
var body: some View { var body: some View {
Form { Form {
Picker("Валюта", selection: $currencyCode) { Picker("Валюта", selection: baseCurrency) {
ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) } ForEach(currencyCodes, id: \.self) { Text($0).tag($0) }
} }
Picker("Тема", selection: $appTheme) { Picker("Тема", selection: $appTheme) {
ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) } ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) }
+59 -15
View File
@@ -2,16 +2,29 @@ import SwiftData
import SwiftUI import SwiftUI
struct WatchRootView: View { struct WatchRootView: View {
var body: some View {
CurrencyContextProvider { WatchHomeView() }
}
}
private struct WatchHomeView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.currencyContext) private var currency
@StateObject private var account = ServerAccountStore.shared
@Query(sort: \FinanceTransaction.date, order: .reverse) @Query(sort: \FinanceTransaction.date, order: .reverse)
private var transactions: [FinanceTransaction] private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue @Query private var transfers: [FinanceTransfer]
@State private var showingAddTransaction = false @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 { private var monthSummary: FinancePeriodSummary {
FinanceCalculations.summary( FinanceCalculations.summary(
transactions: transactions, transactions: transactions,
in: FinanceCalculations.monthInterval(containing: .now) in: FinanceCalculations.monthInterval(containing: .now),
context: currency
) )
} }
@@ -20,19 +33,27 @@ struct WatchRootView: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Image(systemName: "wallet.bifold")
.font(.caption2.weight(.semibold))
Text("Общий баланс") Text("Общий баланс")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) }
Text(FinanceCalculations.totalBalance(transactions: transactions) .foregroundStyle(BalancePalette.mint.opacity(0.85))
.formattedCurrency(code: currencyCode)) Text(total.value.formattedCurrency(code: currencyCode))
.font(.title3.bold()) .font(.title3.bold())
.minimumScaleFactor(0.7) .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) .frame(maxWidth: .infinity, alignment: .leading)
.padding(12) .padding(12)
.background( .background(
LinearGradient( LinearGradient(
colors: [.indigo, .purple], colors: [BalancePalette.cardTop, BalancePalette.cardBottom],
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
), ),
@@ -43,13 +64,15 @@ struct WatchRootView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
watchMetric( watchMetric(
title: "Доходы", title: "Доходы",
symbol: "arrow.down.left",
amount: monthSummary.income, amount: monthSummary.income,
tint: .green tint: BalancePalette.income
) )
watchMetric( watchMetric(
title: "Расходы", title: "Расходы",
symbol: "arrow.up.right",
amount: monthSummary.expenses, amount: monthSummary.expenses,
tint: .red tint: BalancePalette.expense
) )
} }
@@ -60,8 +83,16 @@ struct WatchRootView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(BalancePalette.forest)
VStack(spacing: 4) { VStack(spacing: 4) {
if account.supportsV2 {
NavigationLink {
WatchAccountsView()
} label: {
Label("Счета", systemImage: "creditcard")
}
}
NavigationLink { NavigationLink {
WatchAnalyticsView() WatchAnalyticsView()
} label: { } label: {
@@ -111,9 +142,9 @@ struct WatchRootView: View {
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
Text((transaction.kind == .income ? "+" : "−") Text((transaction.kind == .income ? "+" : "−")
+ transaction.amount.formattedCurrency(code: currencyCode)) + transaction.amount.formattedCurrency(code: currency.currency(of: transaction)))
.font(.caption2.weight(.semibold)) .font(.caption2.weight(.semibold))
.foregroundStyle(transaction.kind == .income ? .green : .secondary) .foregroundStyle(transaction.kind == .income ? BalancePalette.income : .secondary)
} }
Spacer() Spacer()
} }
@@ -121,7 +152,7 @@ struct WatchRootView: View {
} }
} }
} }
.navigationTitle("Баланс") .navigationTitle(account.ledgerID.isEmpty ? "Баланс" : account.ledgerName)
.sheet(isPresented: $showingAddTransaction) { .sheet(isPresented: $showingAddTransaction) {
WatchAddTransactionView() WatchAddTransactionView()
} }
@@ -134,25 +165,38 @@ struct WatchRootView: View {
} }
} }
private func watchMetric(title: String, amount: Double, tint: Color) -> some View { private func watchMetric(title: String, symbol: String, amount: Double, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(title) Label(title, systemImage: symbol)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.labelStyle(TintedIconLabelStyle(tint: tint))
Text(amount.formattedCurrency(code: currencyCode)) Text(amount.formattedCurrency(code: currencyCode))
.font(.caption.bold()) .font(.caption.bold())
.foregroundStyle(tint)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.65) .minimumScaleFactor(0.65)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(9) .padding(9)
.background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) .background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 12))
} }
private func transactionTint(_ transaction: FinanceTransaction) -> Color { private func transactionTint(_ transaction: FinanceTransaction) -> Color {
if transaction.categoryColorName == "auto" { if transaction.categoryColorName == "auto" {
return FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo return FinanceCategory.find(named: transaction.categoryName)?.tint ?? BalancePalette.forest
} }
return FinanceCategory.color(named: transaction.categoryColorName) return FinanceCategory.color(named: transaction.categoryColorName)
} }
} }
/// Иконка метрики в цвете метрики, подпись — вторичным цветом.
private struct TintedIconLabelStyle: LabelStyle {
let tint: Color
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 3) {
configuration.icon.foregroundStyle(tint)
configuration.title
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

+5
View File
@@ -11,6 +11,8 @@ final class CustomCategory {
var kindRawValue: String = "expense" var kindRawValue: String = "expense"
var createdAt: Date = Date.now var createdAt: Date = Date.now
var syncUpdatedAt: Date = Date.now var syncUpdatedAt: Date = Date.now
var syncID: String?
var extraPayload: Data?
init( init(
id: UUID = UUID(), id: UUID = UUID(),
@@ -29,9 +31,12 @@ final class CustomCategory {
self.colorName = colorName self.colorName = colorName
self.kindRawValue = kind.rawValue self.kindRawValue = kind.rawValue
self.createdAt = createdAt self.createdAt = createdAt
self.syncID = id.uuidString.lowercased()
self.syncUpdatedAt = syncUpdatedAt self.syncUpdatedAt = syncUpdatedAt
} }
var wireID: String { syncID ?? id.uuidString }
var kind: TransactionKind { var kind: TransactionKind {
get { TransactionKind(rawValue: kindRawValue) ?? .expense } get { TransactionKind(rawValue: kindRawValue) ?? .expense }
set { kindRawValue = newValue.rawValue } set { kindRawValue = newValue.rawValue }
@@ -28,6 +28,15 @@ final class FinanceTransaction {
var isBalanceAdjustment: Bool = false var isBalanceAdjustment: Bool = false
var kindRawValue: String = "expense" var kindRawValue: String = "expense"
var syncUpdatedAt: Date = Date.now 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( init(
id: UUID = UUID(), id: UUID = UUID(),
@@ -40,6 +49,8 @@ final class FinanceTransaction {
categoryColorName: String = "auto", categoryColorName: String = "auto",
isBalanceAdjustment: Bool = false, isBalanceAdjustment: Bool = false,
kind: TransactionKind, kind: TransactionKind,
accountID: String? = nil,
currency: String? = nil,
syncUpdatedAt: Date = .now syncUpdatedAt: Date = .now
) { ) {
self.id = id self.id = id
@@ -52,9 +63,14 @@ final class FinanceTransaction {
self.categoryColorName = categoryColorName self.categoryColorName = categoryColorName
self.isBalanceAdjustment = isBalanceAdjustment self.isBalanceAdjustment = isBalanceAdjustment
self.kindRawValue = kind.rawValue self.kindRawValue = kind.rawValue
self.accountID = accountID
self.currency = currency
self.syncID = id.uuidString.lowercased()
self.syncUpdatedAt = syncUpdatedAt self.syncUpdatedAt = syncUpdatedAt
} }
var wireID: String { syncID ?? id.uuidString }
var kind: TransactionKind { var kind: TransactionKind {
get { TransactionKind(rawValue: kindRawValue) ?? .expense } get { TransactionKind(rawValue: kindRawValue) ?? .expense }
set { kindRawValue = newValue.rawValue } set { kindRawValue = newValue.rawValue }
+5
View File
@@ -10,6 +10,8 @@ final class MonthlyBudget {
var limit: Double = 0 var limit: Double = 0
var monthStart: Date = Date.now var monthStart: Date = Date.now
var syncUpdatedAt: Date = Date.now var syncUpdatedAt: Date = Date.now
var syncID: String?
var extraPayload: Data?
init( init(
id: UUID = UUID(), id: UUID = UUID(),
@@ -26,6 +28,9 @@ final class MonthlyBudget {
self.categoryEmoji = categoryEmoji self.categoryEmoji = categoryEmoji
self.limit = limit self.limit = limit
self.monthStart = monthStart self.monthStart = monthStart
self.syncID = id.uuidString.lowercased()
self.syncUpdatedAt = syncUpdatedAt self.syncUpdatedAt = syncUpdatedAt
} }
var wireID: String { syncID ?? id.uuidString }
} }
+6 -5
View File
@@ -30,8 +30,9 @@ struct SyncDeletionRecord: Codable, Identifiable {
} }
enum SyncDeletionStore { enum SyncDeletionStore {
private static let storageKey = "balance.server.deletionJournal.v1" // Журнал свой у каждого пространства (§7): у личного — прежний ключ.
private static let quarantineKey = "balance.server.deletionJournal.corrupt" 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() private static let lock = NSLock()
static func add(entity: String, recordID: String, updatedAt: Date = .now) { static func add(entity: String, recordID: String, updatedAt: Date = .now) {
@@ -93,17 +94,17 @@ enum SyncDeletionStore {
extension ModelContext { extension ModelContext {
func deleteForSync(_ transaction: FinanceTransaction) { func deleteForSync(_ transaction: FinanceTransaction) {
SyncDeletionStore.add(entity:"transaction", recordID:transaction.id.uuidString) SyncDeletionStore.add(entity:"transaction", recordID:transaction.wireID)
delete(transaction) delete(transaction)
} }
func deleteForSync(_ category: CustomCategory) { func deleteForSync(_ category: CustomCategory) {
SyncDeletionStore.add(entity:"category", recordID:category.id.uuidString) SyncDeletionStore.add(entity:"category", recordID:category.wireID)
delete(category) delete(category)
} }
func deleteForSync(_ budget: MonthlyBudget) { func deleteForSync(_ budget: MonthlyBudget) {
SyncDeletionStore.add(entity:"budget", recordID:budget.id.uuidString) SyncDeletionStore.add(entity:"budget", recordID:budget.wireID)
delete(budget) delete(budget)
} }
} }
+102
View File
@@ -0,0 +1,102 @@
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 {
/// Не `entity`: так называется встроенное свойство CoreData, и предикат
/// по нему ломает SQL-запрос SwiftData.
var entityName: String = ""
var recordID: String = ""
var payload: Data = Data()
var syncUpdatedAt: Date = Date.now
init(entityName: String, recordID: String, payload: Data, syncUpdatedAt: Date) {
self.entityName = entityName
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 }
}
+17
View File
@@ -23,3 +23,20 @@ enum AppTheme: String, CaseIterable, Identifiable {
} }
} }
} }
/// Палитра «Лес» — та же, что у пресета по умолчанию во Flutter-клиенте и у
/// иконки приложения.
enum BalancePalette {
/// Акцент интерфейса (кнопки, ссылки, выбор) — мятный, читается на чёрном.
static let accent = Color(red: 0x7f / 255, green: 0xd6 / 255, blue: 0xb9 / 255)
/// Основной цвет пресета «Лес».
static let forest = Color(red: 0x35 / 255, green: 0x68 / 255, blue: 0x59 / 255)
/// Градиент карточки общего баланса (как фон иконки).
static let cardTop = Color(red: 0x3f / 255, green: 0x7d / 255, blue: 0x6b / 255)
static let cardBottom = Color(red: 0x1a / 255, green: 0x4a / 255, blue: 0x3e / 255)
/// Текст на карточке баланса.
static let mint = Color(red: 0xbd / 255, green: 0xf7 / 255, blue: 0xe3 / 255)
/// Доходы — teal, расходы — deep orange, как во Flutter.
static let income = Color(red: 0x26 / 255, green: 0xb3 / 255, blue: 0xa3 / 255)
static let expense = Color(red: 0xff / 255, green: 0x6e / 255, blue: 0x40 / 255)
}
@@ -6,15 +6,19 @@ enum BalanceModelContainer {
FinanceTransaction.self, FinanceTransaction.self,
MonthlyBudget.self, MonthlyBudget.self,
CustomCategory.self, CustomCategory.self,
SyncTombstone.self SyncTombstone.self,
FinanceAccount.self,
FinanceTransfer.self,
ExchangeRate.self,
SyncRecord.self
]) ])
static func make() -> ModelContainer { /// `scope` пустой — личные данные (прежнее хранилище по умолчанию);
let localConfiguration = ModelConfiguration( /// иначе — отдельное хранилище общего пространства (сервер + пользователь + ledger).
schema: schema, static func make(scope: String = "") -> ModelContainer {
isStoredInMemoryOnly: false, let localConfiguration = scope.isEmpty
cloudKitDatabase: .none ? ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
) : ModelConfiguration("BalanceLedger-\(scope)", schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
do { do {
return try ModelContainer( return try ModelContainer(
@@ -23,7 +27,7 @@ enum BalanceModelContainer {
) )
} catch { } catch {
let recoveryConfiguration = ModelConfiguration( let recoveryConfiguration = ModelConfiguration(
"BalanceRecovery", scope.isEmpty ? "BalanceRecovery" : "BalanceRecovery-\(scope)",
schema: schema, schema: schema,
isStoredInMemoryOnly: true, isStoredInMemoryOnly: true,
cloudKitDatabase: .none 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
}
}
+263 -24
View File
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation import Foundation
import Security import Security
import SwiftData import SwiftData
@@ -88,12 +89,53 @@ private struct SyncResponse: Decodable {
private struct TransactionPayload: Codable { private struct TransactionPayload: Codable {
let amount: Double; let date: Date; let note: String; let categoryName: String; let categoryIcon: String 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 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 { private struct CategoryPayload: Codable {
let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date 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 { private struct BudgetPayload: Codable {
let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date 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 { private enum ServerCoding {
@@ -124,6 +166,21 @@ private enum ServerCoding {
} }
return value 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 { enum ServerSyncError: LocalizedError {
@@ -145,8 +202,12 @@ final class ServerAccountStore: ObservableObject {
@Published private(set) var isBusy = false @Published private(set) var isBusy = false
@Published private(set) var lastSyncAt: Date? @Published private(set) var lastSyncAt: Date?
@Published var message: String? @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 defaults = UserDefaults.standard
private let capabilitiesKey = "balance.server.capabilities"
private var session: ServerSession? private var session: ServerSession?
private let sessionKey = "balance.server.session" private let sessionKey = "balance.server.session"
private let serverKey = "balance.server.url" private let serverKey = "balance.server.url"
@@ -154,12 +215,17 @@ final class ServerAccountStore: ObservableObject {
var serverURL: String { defaults.string(forKey: serverKey) ?? "" } var serverURL: String { defaults.string(forKey: serverKey) ?? "" }
var isSignedIn: Bool { session != nil } 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() { private init() {
if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) { if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) {
session = value; signedInEmail = value.user.email session = value; signedInEmail = value.user.email
} }
lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date 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 { func saveServer(_ value: String) throws {
@@ -167,7 +233,10 @@ final class ServerAccountStore: ObservableObject {
throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.") throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.")
} }
let normalized = try normalizedServer(value) 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) } 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 { func logout() async {
if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse } 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 { func sync(context: ModelContext) async {
@@ -184,6 +253,10 @@ final class ServerAccountStore: ObservableObject {
defer { isBusy = false } defer { isBusy = false }
do { do {
try context.save() try context.save()
await refreshCapabilities()
if !ledgerID.isEmpty && !supportsLedgers {
throw ServerSyncError.response("Сервер не поддерживает общие бюджеты. Выберите личное пространство.")
}
let cutoff = Date.now let cutoff = Date.now
let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast
let threshold = lastPush <= cutoff.addingTimeInterval(5) 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 pushTransactions(context: context, after: threshold, through: cutoff, cursor: initialCursor)
try await pushCategories(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) 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 pushTombstones(through: cutoff, cursor: initialCursor)
try await pullChanges(context: context, cursor: initialCursor) try await pullChanges(context: context, cursor: initialCursor)
@@ -222,22 +296,36 @@ final class ServerAccountStore: ObservableObject {
} catch { message = error.localizedDescription } } 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() } if authorized { try await refreshIfNeeded() }
let base = try normalizedServer(serverURL) let base = try normalizedServer(serverURL)
guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL } guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL }
var request = URLRequest(url: url); request.httpMethod = "POST" var request = URLRequest(url: url); request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
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) request.httpBody = try ServerCoding.encoder.encode(body)
}
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") } 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 { guard (200..<300).contains(http.statusCode) else {
let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data) let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data)
if path == "v1/sync", envelope?.error.code == "invalid_json" { if path == "v1/sync", envelope?.error.code == "invalid_json" {
throw ServerSyncError.response("Обновите Balance Server до версии из текущего архива приложения.") 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)") throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)")
} }
if Response.self == EmptyResponse.self { return EmptyResponse() as! Response } 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) } 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() { private func clearSyncState() {
for key in defaults.dictionaryRepresentation().keys for key in defaults.dictionaryRepresentation().keys
where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") { where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") {
defaults.removeObject(forKey: key) defaults.removeObject(forKey: key)
} }
} }
private func cursorKey() -> String { "balance.server.cursor.\(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")" } private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" + LedgerSelection.keySuffix }
private func deviceID() -> String { private func deviceID() -> String {
if let value = defaults.string(forKey: "balance.server.deviceID") { return value } if let value = defaults.string(forKey: "balance.server.deviceID") { return value }
let value = UUID().uuidString; defaults.set(value, 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) let items = try context.fetch(descriptor)
guard !items.isEmpty else { break } guard !items.isEmpty else { break }
let changes = try items.map { item in 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) 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.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value)) 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) try await sendPush(changes, cursor: cursor)
offset += items.count offset += items.count
@@ -316,7 +459,7 @@ final class ServerAccountStore: ObservableObject {
guard !items.isEmpty else { break } guard !items.isEmpty else { break }
let changes = try items.map { item in 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) 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) try await sendPush(changes, cursor: cursor)
offset += items.count offset += items.count
@@ -342,7 +485,7 @@ final class ServerAccountStore: ObservableObject {
guard !items.isEmpty else { break } guard !items.isEmpty else { break }
let changes = try items.map { item in 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) 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) try await sendPush(changes, cursor: cursor)
offset += items.count 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.entityName == "setting" && item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound
}))
try await sendPush(items.map { SyncChange(entity:$0.entityName, id:$0.recordID, deleted:false, updatedAt:$0.syncUpdatedAt, payload:$0.payload) }, cursor:cursor)
}
private func pushTombstones(through cutoff: Date, cursor: Int64) async throws { private func pushTombstones(through cutoff: Date, cursor: Int64) async throws {
var offset = 0 var offset = 0
while true { while true {
@@ -408,7 +560,21 @@ final class ServerAccountStore: ObservableObject {
private func apply(_ changes: [SyncChange], context: ModelContext) throws { private func apply(_ changes: [SyncChange], context: ModelContext) throws {
for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) { for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) {
if let id = UUID(uuidString:change.id) { 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 { if change.deleted {
switch change.entity { switch change.entity {
case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) } case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
@@ -416,26 +582,90 @@ final class ServerAccountStore: ObservableObject {
case "budget": if let item = try budget(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 default: break
} }
} else if let data = change.payload { return
}
guard let data = change.payload else { return }
switch change.entity { switch change.entity {
case "transaction": case "transaction":
guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { continue } guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { return }
if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } } let extras = ServerCoding.extras(data, known:TransactionPayload.keys)
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)) } 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": case "category":
guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { continue } guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { return }
if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } } let extras = ServerCoding.extras(data, known:CategoryPayload.keys)
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)) } 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": case "budget":
guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { continue } guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { return }
if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } } let extras = ServerCoding.extras(data, known:BudgetPayload.keys)
else { context.insert(MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)) } 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 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
} }
SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt)
} }
/// 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.entityName == 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(entityName:entity, recordID:recordID, payload:data, syncUpdatedAt:change.updatedAt)) }
} }
private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? { private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? {
@@ -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) 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 { if account.isSignedIn {
Section("Синхронизация") { Section("Синхронизация") {
Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy) Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy)
@@ -10,10 +10,13 @@ enum SupportedCurrency: String, CaseIterable, Identifiable {
} }
extension Double { extension Double {
/// Целые суммы без копеек, остальные — ровно с двумя знаками («8 480,50 ₽»),
/// как во Flutter-клиенте.
func formattedCurrency(code: String) -> String { func formattedCurrency(code: String) -> String {
formatted( let whole = (self * 100).rounded().truncatingRemainder(dividingBy: 100) == 0
return formatted(
.currency(code: code) .currency(code: code)
.precision(.fractionLength(0 ... 2)) .precision(.fractionLength(whole ? 0 : 2))
.presentation(.narrow) .presentation(.narrow)
) )
} }
@@ -3,6 +3,7 @@ import Foundation
struct FinancePeriodSummary { struct FinancePeriodSummary {
let income: Double let income: Double
let expenses: Double let expenses: Double
var missing: Set<String> = []
var balance: Double { income - expenses } var balance: Double { income - expenses }
} }
@@ -17,11 +18,58 @@ struct CategorySpending: Identifiable {
var id: String { name } var id: String { name }
} }
/// Финансовые правила (общие для экосистемы, см. docs/CONTRACT_V2.md §2–3).
/// Расчёты ведутся в целых сотых; общие итоги — в базовой валюте.
enum FinanceCalculations { 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 { static func monthInterval(containing date: Date, calendar: Calendar = .current) -> DateInterval {
@@ -29,43 +77,52 @@ enum FinanceCalculations {
?? DateInterval(start: date, duration: 30 * 24 * 60 * 60) ?? DateInterval(start: date, duration: 30 * 24 * 60 * 60)
} }
/// Доходы и расходы периода `[начало, конец)` без корректировок, в базовой валюте.
static func summary( static func summary(
transactions: [FinanceTransaction], transactions: [LedgerEntry],
in interval: DateInterval in interval: DateInterval,
context: CurrencyContext
) -> FinancePeriodSummary { ) -> FinancePeriodSummary {
let periodTransactions = transactions.filter { var income = MoneyTotal(), expenses = MoneyTotal()
interval.contains($0.date) && !$0.isBalanceAdjustment 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 return FinancePeriodSummary(
.filter { $0.kind == .income } income: income.value,
.reduce(0) { $0 + $1.amount } expenses: expenses.value,
let expenses = periodTransactions missing: income.missing.union(expenses.missing)
.filter { $0.kind == .expense } )
.reduce(0) { $0 + $1.amount }
return FinancePeriodSummary(income: income, expenses: expenses)
} }
static func spendingByCategory( static func spendingByCategory(
transactions: [FinanceTransaction], transactions: [FinanceTransaction],
in interval: DateInterval in interval: DateInterval,
context: CurrencyContext
) -> [CategorySpending] { ) -> [CategorySpending] {
let expenses = transactions.filter { let expenses = transactions.filter {
$0.kind == .expense $0.kind == .expense
&& !$0.isBalanceAdjustment && !$0.isBalanceAdjustment
&& interval.contains($0.date) && contains(interval, $0.date)
} }
let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName) let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName)
return grouped.map { name, items in 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, name: name,
icon: items.first?.categoryIcon ?? "circle.fill", icon: items.first?.categoryIcon ?? "circle.fill",
emoji: items.first?.categoryEmoji ?? "", emoji: items.first?.categoryEmoji ?? "",
colorName: items.first?.categoryColorName ?? "auto", colorName: items.first?.categoryColorName ?? "auto",
amount: items.reduce(0) { $0 + $1.amount } amount: total.value
) )
} }
.sorted { $0.amount > $1.amount } .sorted { $0.amount > $1.amount }
} }
/// Полуоткрытый интервал: `DateInterval.contains` включает конец.
static func contains(_ interval: DateInterval, _ date: Date) -> Bool {
date >= interval.start && date < interval.end
}
} }
+159
View File
@@ -0,0 +1,159 @@
import Foundation
/// Денежная арифметика контракта v2 (§0, §2): суммы — целые сотые,
/// курсы — десятичные строки, округление до сотых половиной от нуля.
/// Файл не зависит от SwiftUI/SwiftData.
enum Money {
private static let posix = Locale(identifier: "en_US_POSIX")
/// Число с провода → целые сотые (лишние знаки округляются).
static func cents(_ value: Double) -> Int64 {
guard value.isFinite else { return 0 }
let decimal = Decimal(string: String(value), locale: posix) ?? Decimal(value)
return rounded(decimal * 100)
}
static func double(_ cents: Int64) -> Double { Double(cents) / 100 }
/// Курс из строки: только цифры и не более одной точки, больше нуля.
static func rate(_ text: String) -> Decimal? {
let trimmed = text.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, trimmed.filter({ $0 == "." }).count <= 1,
trimmed.allSatisfy({ $0.isASCII && ($0.isNumber || $0 == ".") }),
trimmed.first != ".", trimmed.last != ".",
let value = Decimal(string: trimmed, locale: posix), value > 0
else { return nil }
return value
}
/// `cents · rate` (или `cents / rate` для обратной пары), округлено до сотых.
static func convert(_ cents: Int64, rate: Decimal, inverse: Bool) -> Int64 {
let value = Decimal(cents)
return rounded(inverse ? value / rate : value * rate)
}
private static func rounded(_ value: Decimal) -> Int64 {
var source = value
var result = Decimal()
NSDecimalRound(&result, &source, 0, .plain) // половина — от нуля
return NSDecimalNumber(decimal: result).int64Value
}
}
struct RateQuote {
let from: String
let to: String
let rate: String
let date: Date
let updatedAt: Date
}
/// Перевод сумм в базовую валюту по последнему курсу на дату расчёта.
struct CurrencyConverter {
let base: String
private var best: [String: (rate: Decimal, inverse: Bool, date: Date, updatedAt: Date)] = [:]
init(base: String, quotes: [RateQuote], asOf: Date) {
let base = base.uppercased()
self.base = base
for quote in quotes where quote.date <= asOf {
let from = quote.from.uppercased(), to = quote.to.uppercased()
guard from != to, let rate = Money.rate(quote.rate) else { continue }
let other: String, inverse: Bool
if to == base { other = from; inverse = false }
else if from == base { other = to; inverse = true }
else { continue }
if let current = best[other] {
// Последняя по дате; при равной дате прямая пара важнее
// обратной; затем — более поздний updatedAt.
let newer = (quote.date, inverse ? 0 : 1, quote.updatedAt)
let old = (current.date, current.inverse ? 0 : 1, current.updatedAt)
guard newer > old else { continue }
}
best[other] = (rate, inverse, quote.date, quote.updatedAt)
}
}
/// `nil` — нет курса.
func toBase(_ cents: Int64, from currency: String) -> Int64? {
let code = currency.uppercased()
if code == base || cents == 0 { return cents }
guard let quote = best[code] else { return nil }
return Money.convert(cents, rate: quote.rate, inverse: quote.inverse)
}
}
protocol LedgerEntry {
var amount: Double { get }
var isIncome: Bool { get }
var isBalanceAdjustment: Bool { get }
var date: Date { get }
var accountID: String? { get }
var currency: String? { get }
}
protocol LedgerTransfer {
var fromAccountID: String { get }
var toAccountID: String { get }
var amount: Double { get }
var incomingAmount: Double { get }
}
/// Сумма в базовой валюте и валюты, для которых не нашлось курса.
struct MoneyTotal {
var cents: Int64 = 0
var missing: Set<String> = []
var value: Double { Money.double(cents) }
mutating func add(_ cents: Int64, _ currency: String, _ converter: CurrencyConverter) {
if let converted = converter.toBase(cents, from: currency) { self.cents += converted }
else { missing.insert(currency.uppercased()) }
}
}
/// Всё, что нужно для расчётов в базовой валюте: базовая валюта,
/// валюты и названия счетов, курсы.
struct CurrencyContext {
let converter: CurrencyConverter
private let accountCurrencies: [String: String]
private let accountNames: [String: String]
var base: String { converter.base }
init(base: String, accounts: [(id: String, name: String, currency: String)] = [], quotes: [RateQuote] = [], asOf: Date = .now) {
let base = base.uppercased()
converter = CurrencyConverter(base: base, quotes: quotes, asOf: asOf)
var currencies: [String: String] = [:], names: [String: String] = [:]
for account in accounts {
let key = account.id.lowercased()
currencies[key] = account.currency.isEmpty ? base : account.currency.uppercased()
names[key] = account.name
}
accountCurrencies = currencies
accountNames = names
}
/// Валюта счёта; удалённый счёт или пустая валюта — базовая.
func accountCurrency(_ id: String?) -> String {
guard let id else { return base }
return accountCurrencies[id.lowercased()] ?? base
}
/// `currency`, иначе валюта счёта, иначе базовая.
func currency(of entry: LedgerEntry) -> String {
if let code = entry.currency, !code.isEmpty { return code.uppercased() }
return accountCurrency(entry.accountID)
}
/// Название счёта, «Удалённый счёт» или `nil` для «Без счёта».
func accountTitle(_ id: String?) -> String? {
guard let id else { return nil }
return accountNames[id.lowercased()] ?? "Удалённый счёт"
}
func missingRateWarning(_ missing: Set<String>) -> String? {
guard !missing.isEmpty else { return nil }
return missing.sorted().map { "нет курса \($0)→\(base)" }.joined(separator: ", ")
}
}
+35 -3
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="branding/banner.png" alt="BalanceWatch — учёт личных финансов на Apple Watch" width="100%"> <img src="branding/banner.png" alt="BalanceWatch — личные финансы на Apple Watch" width="100%">
</p> </p>
# BalanceWatch # BalanceWatch
@@ -13,13 +13,15 @@
```text ```text
BalanceWatch/ BalanceWatch/
├── branding/ # аватар и баннер для README (PNG + SVG-исходники) ├── branding/ # иконка, аватар и баннер (SVG-исходники, PNG, render.sh)
├── docs/ # скриншоты для баннера
├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия, одинаковая во всех репозиториях)
├── BalanceWatch.xcodeproj/ ├── BalanceWatch.xcodeproj/
└── BalanceWatch/ └── BalanceWatch/
├── App/ # точка входа и экраны часов ├── App/ # точка входа и экраны часов
├── Models/ # SwiftData-модели (копия из монорепы) ├── Models/ # SwiftData-модели (копия из монорепы)
├── Shared/ # контейнер SwiftData, тема, ServerSync ├── Shared/ # контейнер SwiftData, тема, ServerSync
├── Utilities/ # FinanceCalculations, форматирование валют ├── Utilities/ # FinanceCalculations, Money (сотые, курсы), форматирование валют
├── Views/Components/ # CategoryIconView ├── Views/Components/ # CategoryIconView
└── Assets.xcassets # иконка приложения └── Assets.xcassets # иконка приложения
``` ```
@@ -28,6 +30,24 @@ BalanceWatch/
любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в
таргет — править `project.pbxproj` вручную не нужно. таргет — править `project.pbxproj` вручную не нужно.
## Контракт v2
Часы реализуют клиентскую часть `docs/CONTRACT_V2.md` в компактном виде:
- синхронизируются и хранятся счета, переводы, курсы и настройка
`baseCurrency`; общий баланс, аналитика и бюджеты считаются в базовой
валюте, при отсутствии курса показывается «нет курса C→B»;
- экран «Счета» (только чтение) с балансами счетов и «Без счёта»; при
добавлении операции можно выбрать счёт, валюта следует за счётом;
- общие бюджеты: в «Настройки → Сервер → Бюджет» выбирается личное или общее
пространство, можно присоединиться по коду; данные каждого пространства
лежат в отдельном хранилище;
- регулярные операции, цели, долги, вложения и теги хранятся и не теряются
при пересохранении, но интерфейса для них нет; регулярные операции часы не
создают;
- если `GET /v1/capabilities` отвечает 404 (сервер v1), счета и общие
бюджеты скрыты, новые сущности не отправляются.
## Сборка ## Сборка
1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее. 1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее.
@@ -56,3 +76,15 @@ Simulator и watchOS device (без подписи). Запускается на
кода из монорепозитория. Протокол синхронизации и правила расчётов должны кода из монорепозитория. Протокол синхронизации и правила расчётов должны
совпадать с остальными клиентами Balance: при изменении контракта на совпадать с остальными клиентами Balance: при изменении контракта на
сервере переносите правки и сюда. сервере переносите правки и сюда.
## Оформление
Палитра «Лес» — та же, что у пресета по умолчанию в BalanceFlutter: карточка
баланса в зелёном градиенте, доходы бирюзовые (↙), расходы оранжевые (↗),
мятный акцент (`BalancePalette` в `Shared/AppTheme.swift`). Иконка — тот же
рисунок, что у Flutter-приложения (`branding/icon.svg`).
После правки SVG или скриншотов в `docs/` выполни `bash branding/render.sh`
(нужен `resvg`): скрипт перерисует все размеры иконки в
`Assets.xcassets/AppIcon.appiconset`, `branding/avatar.png` (512×512) и
`branding/banner.png` (1280×640). Шрифты Roboto лежат в `branding/fonts`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 77 KiB

+19 -49
View File
@@ -1,60 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512">
<!-- Repository avatar: the watch with the app icon (palette «Лес»).
Render with branding/render.sh (needs resvg). -->
<defs> <defs>
<radialGradient id="bg" cx="50%" cy="40%" r="75%"> <radialGradient id="bg" cx="50%" cy="42%" r="75%">
<stop offset="0" stop-color="#2a2368"/> <stop offset="0" stop-color="#25594c"/>
<stop offset="1" stop-color="#0d0b26"/> <stop offset="1" stop-color="#0c241e"/>
</radialGradient> </radialGradient>
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0"> <linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2b2660"/> <stop offset="0" stop-color="#173f35"/>
<stop offset="0.5" stop-color="#3b3580"/> <stop offset="0.5" stop-color="#245a4b"/>
<stop offset="1" stop-color="#2b2660"/> <stop offset="1" stop-color="#173f35"/>
</linearGradient> </linearGradient>
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6d68c9"/> <stop offset="0" stop-color="#5a9c87"/>
<stop offset="1" stop-color="#3a3488"/> <stop offset="1" stop-color="#1f5245"/>
</linearGradient> </linearGradient>
<linearGradient id="teal" x1="0" y1="0" x2="1" y2="1"> <clipPath id="screen"><rect x="132" y="142" width="248" height="228" rx="58"/></clipPath>
<stop offset="0" stop-color="#2fc7b0"/> <filter id="haze" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="40"/></filter>
<stop offset="1" stop-color="#3f6fd8"/>
</linearGradient>
<linearGradient id="coral" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff8a5c"/>
<stop offset="1" stop-color="#e2475f"/>
</linearGradient>
<radialGradient id="ball" cx="38%" cy="35%" r="70%">
<stop offset="0" stop-color="#ffb08f"/>
<stop offset="0.5" stop-color="#f2614f"/>
<stop offset="1" stop-color="#b7323f"/>
</radialGradient>
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="10"/>
</filter>
</defs> </defs>
<!-- background -->
<rect width="512" height="512" fill="url(#bg)"/> <rect width="512" height="512" fill="url(#bg)"/>
<ellipse cx="256" cy="256" rx="170" ry="150" fill="#39b997" opacity="0.22" filter="url(#haze)"/>
<!-- straps -->
<path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/> <path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/>
<path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/> <path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/>
<rect x="394" y="196" width="22" height="50" rx="8" fill="#4f8f7b"/>
<!-- crown and side button --> <rect x="396" y="270" width="14" height="40" rx="6" fill="#3f7d6b"/>
<rect x="394" y="196" width="22" height="50" rx="8" fill="#5b56b8"/> <rect x="106" y="116" width="300" height="280" rx="78" fill="url(#case)"/>
<rect x="396" y="270" width="14" height="40" rx="6" fill="#4a4599"/> <rect x="124" y="134" width="264" height="244" rx="62" fill="#04120e"/>
<image x="132" y="132" width="248" height="248" xlink:href="../BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png" clip-path="url(#screen)"/>
<!-- case -->
<rect x="106" y="116" width="294" height="280" rx="78" fill="url(#case)"/>
<!-- screen -->
<rect x="124" y="134" width="258" height="244" rx="62" fill="#07061a"/>
<!-- soft glow behind the mark -->
<ellipse cx="253" cy="256" rx="80" ry="90" fill="#4c45c2" opacity="0.45" filter="url(#glow)"/>
<!-- balance mark: two loops and a ball, as in the app icon -->
<rect x="190" y="166" width="126" height="94" rx="47" fill="none" stroke="url(#teal)" stroke-width="28"/>
<rect x="190" y="252" width="126" height="94" rx="47" fill="none" stroke="url(#coral)" stroke-width="28"/>
<!-- teal loop overlaps coral on the left, like the original figure -->
<path d="M204 213 v40" stroke="url(#teal)" stroke-width="28" stroke-linecap="round"/>
<circle cx="253" cy="256" r="24" fill="url(#ball)"/>
<ellipse cx="246" cy="247" rx="8" ry="5" fill="#ffffff" opacity="0.35"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 264 KiB

+62 -64
View File
@@ -1,80 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 560" width="1280" height="560"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 640" width="1280" height="640" font-family="Roboto, sans-serif">
<!-- Source of banner.png; same layout and palette («Лес») as the BalanceFlutter
banner. Render with branding/render.sh (needs resvg). Screenshots: docs/. -->
<defs> <defs>
<radialGradient id="bg" cx="30%" cy="45%" r="90%"> <radialGradient id="bg" cx="70%" cy="45%" r="85%">
<stop offset="0" stop-color="#2a2368"/> <stop offset="0" stop-color="#25594c"/>
<stop offset="1" stop-color="#0b0a22"/> <stop offset="1" stop-color="#0c241e"/>
</radialGradient> </radialGradient>
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2b2660"/>
<stop offset="0.5" stop-color="#3b3580"/>
<stop offset="1" stop-color="#2b2660"/>
</linearGradient>
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6d68c9"/> <stop offset="0" stop-color="#5a9c87"/>
<stop offset="1" stop-color="#3a3488"/> <stop offset="1" stop-color="#1f5245"/>
</linearGradient> </linearGradient>
<linearGradient id="teal" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2fc7b0"/> <stop offset="0" stop-color="#143a31"/>
<stop offset="1" stop-color="#3f6fd8"/> <stop offset="0.5" stop-color="#22574a"/>
<stop offset="1" stop-color="#143a31"/>
</linearGradient> </linearGradient>
<linearGradient id="coral" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ff8a5c"/> <stop offset="0" stop-color="#26b3a3"/>
<stop offset="1" stop-color="#e2475f"/> <stop offset="0.5" stop-color="#a2f2d8"/>
<stop offset="1" stop-color="#f4511e"/>
</linearGradient> </linearGradient>
<linearGradient id="title" x1="0" y1="0" x2="1" y2="0"> <filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
<stop offset="0" stop-color="#ffffff"/> <feGaussianBlur stdDeviation="40"/>
<stop offset="1" stop-color="#c9c5ff"/>
</linearGradient>
<radialGradient id="ball" cx="38%" cy="35%" r="70%">
<stop offset="0" stop-color="#ffb08f"/>
<stop offset="0.5" stop-color="#f2614f"/>
<stop offset="1" stop-color="#b7323f"/>
</radialGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="10"/>
</filter> </filter>
<filter id="haze" x="-50%" y="-50%" width="200%" height="200%"> <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="60"/> <feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#04120e" flood-opacity="0.7"/>
</filter> </filter>
<clipPath id="iconClip"><rect x="72" y="88" width="120" height="120" rx="28"/></clipPath>
<clipPath id="backScreen"><rect x="704" y="150" width="206" height="246" rx="44"/></clipPath>
<clipPath id="frontScreen"><rect x="884" y="228" width="250" height="298" rx="54"/></clipPath>
</defs> </defs>
<rect width="1280" height="560" fill="url(#bg)"/> <rect width="1280" height="640" fill="url(#bg)"/>
<!-- ambient colour haze --> <ellipse cx="930" cy="340" rx="300" ry="210" fill="#39b997" opacity="0.25" filter="url(#glow)"/>
<circle cx="330" cy="200" r="150" fill="#2fc7b0" opacity="0.18" filter="url(#haze)"/>
<circle cx="360" cy="380" r="150" fill="#e2475f" opacity="0.16" filter="url(#haze)"/>
<!-- watch (same drawing as avatar.svg), centred at (340, 280) --> <!-- left: identity -->
<g transform="translate(340 280) scale(0.95) translate(-256 -256)"> <image x="72" y="88" width="120" height="120" xlink:href="../BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png" clip-path="url(#iconClip)"/>
<path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/> <text x="68" y="306" font-size="84" font-weight="700" fill="#ffffff">BalanceWatch</text>
<path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/> <rect x="74" y="330" width="120" height="6" rx="3" fill="url(#accent)"/>
<rect x="394" y="196" width="22" height="50" rx="8" fill="#5b56b8"/> <text x="72" y="384" font-size="28" font-weight="500" fill="#a2f2d8">Личные финансы на Apple Watch</text>
<rect x="396" y="270" width="14" height="40" rx="6" fill="#4a4599"/> <text x="72" y="428" font-size="22" fill="#9cc4b6">Самостоятельное приложение для часов,</text>
<rect x="106" y="116" width="294" height="280" rx="78" fill="url(#case)"/> <text x="72" y="458" font-size="22" fill="#9cc4b6">синхронизация с self-hosted BalanceServer</text>
<rect x="124" y="134" width="258" height="244" rx="62" fill="#07061a"/>
<ellipse cx="253" cy="256" rx="80" ry="90" fill="#4c45c2" opacity="0.45" filter="url(#glow)"/> <g font-size="18" font-weight="500" fill="#dff7ee">
<rect x="190" y="166" width="126" height="94" rx="47" fill="none" stroke="url(#teal)" stroke-width="28"/> <rect x="72" y="494" width="128" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
<rect x="190" y="252" width="126" height="94" rx="47" fill="none" stroke="url(#coral)" stroke-width="28"/> <text x="136" y="518" text-anchor="middle">watchOS 10+</text>
<circle cx="253" cy="256" r="24" fill="url(#ball)"/> <rect x="210" y="494" width="96" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
<ellipse cx="246" cy="247" rx="8" ry="5" fill="#ffffff" opacity="0.35"/> <text x="258" y="518" text-anchor="middle">SwiftUI</text>
<rect x="316" y="494" width="112" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
<text x="372" y="518" text-anchor="middle">SwiftData</text>
<rect x="72" y="540" width="214" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
<text x="179" y="564" text-anchor="middle">Счета и валюты</text>
<rect x="296" y="540" width="186" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
<text x="389" y="564" text-anchor="middle">Общие бюджеты</text>
</g> </g>
<!-- text --> <!-- back watch: accounts screen -->
<g font-family="Noto Sans, DejaVu Sans, sans-serif"> <path d="M752 60 h110 l-8 76 h-94 z" fill="url(#strap)"/>
<text x="640" y="240" font-size="80" font-weight="700" fill="url(#title)" letter-spacing="-1">BalanceWatch</text> <path d="M760 410 h94 l8 76 h-110 z" fill="url(#strap)"/>
<text x="644" y="300" font-size="32" fill="#b9b5e8">Учёт личных финансов на Apple Watch</text> <g filter="url(#shadow)">
<rect x="918" y="216" width="14" height="40" rx="6" fill="#4f8f7b"/>
<rect x="690" y="136" width="234" height="274" rx="62" fill="url(#case)"/>
</g>
<rect x="700" y="146" width="214" height="254" rx="50" fill="#000000"/>
<image x="704" y="150" width="206" height="246" preserveAspectRatio="xMidYMid slice" xlink:href="../docs/accounts.png" clip-path="url(#backScreen)"/>
<!-- tags --> <!-- front watch: home screen -->
<g font-size="22" font-weight="600" fill="#e8e6ff"> <path d="M944 120 h130 l-10 92 h-110 z" fill="url(#strap)"/>
<rect x="644" y="344" width="166" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/> <path d="M954 542 h110 l10 92 h-130 z" fill="url(#strap)"/>
<text x="727" y="374" text-anchor="middle">watchOS 10+</text> <g filter="url(#shadow)">
<rect x="824" y="344" width="122" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/> <rect x="1150" y="300" width="18" height="52" rx="8" fill="#4f8f7b"/>
<text x="885" y="374" text-anchor="middle">SwiftUI</text> <rect x="1152" y="370" width="12" height="40" rx="5" fill="#3f7d6b"/>
<rect x="960" y="344" width="142" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/> <rect x="868" y="212" width="282" height="330" rx="74" fill="url(#case)"/>
<text x="1031" y="374" text-anchor="middle">SwiftData</text>
</g>
<g font-size="22" font-weight="600">
<rect x="644" y="402" width="288" height="44" rx="22" fill="#2fc7b0" fill-opacity="0.16" stroke="#2fc7b0" stroke-opacity="0.5"/>
<text x="788" y="432" text-anchor="middle" fill="#8ff0df">Sync с BalanceServer</text>
</g>
</g> </g>
<rect x="880" y="224" width="258" height="306" rx="60" fill="#000000"/>
<image x="884" y="228" width="250" height="298" preserveAspectRatio="xMidYMid slice" xlink:href="../docs/home.png" clip-path="url(#frontScreen)"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

+203
View File
@@ -0,0 +1,203 @@
Copyright 2011 Google Inc. All Rights Reserved.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- Watch app icon, same artwork as BalanceFlutter: the in-app wallet mark in the «Лес» palette. Income (teal) and
expense (deep orange) cards stick out of the wallet. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" stop-color="#3f7d6b"/>
<stop offset="1" stop-color="#1a4a3e"/>
</linearGradient>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#bdf7e3"/>
<stop offset="1" stop-color="#8fe6c9"/>
</linearGradient>
<linearGradient id="income" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#26b3a3"/>
<stop offset="1" stop-color="#00897b"/>
</linearGradient>
<linearGradient id="expense" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff8a5c"/>
<stop offset="1" stop-color="#f4511e"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b2620" flood-opacity="0.45"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<g filter="url(#shadow)">
<!-- cards behind the wallet; arrows match the app's income/expense icons -->
<g transform="rotate(-10 400 330)">
<rect x="236" y="176" width="320" height="300" rx="36" fill="url(#income)"/>
<path d="M454 236 L338 352 M338 262 V352 H428" fill="none" stroke="#ffffff" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g transform="rotate(10 624 330)">
<rect x="468" y="176" width="320" height="300" rx="36" fill="url(#expense)"/>
<path d="M570 352 L686 236 M596 236 H686 V326" fill="none" stroke="#ffffff" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<!-- wallet -->
<rect x="196" y="420" width="632" height="420" rx="72" fill="url(#body)"/>
<!-- clasp tab -->
<rect x="590" y="560" width="268" height="152" rx="76" fill="#116b57"/>
<circle cx="666" cy="636" r="30" fill="#bdf7e3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Re-renders the watch app icon (every size in AppIcon.appiconset), avatar.png
# and banner.png from their SVG sources. Requires resvg
# (https://github.com/linebender/resvg). Uses the bundled Roboto fonts, so the
# result does not depend on system fonts. icon.svg is the same artwork as the
# BalanceFlutter app icon (palette «Лес»).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
fonts=(
--skip-system-fonts
--use-font-file fonts/Roboto-Regular.ttf
--use-font-file fonts/Roboto-Medium.ttf
--use-font-file fonts/Roboto-Bold.ttf
)
icons=../BalanceWatch/Assets.xcassets/AppIcon.appiconset
for file in "$icons"/BalanceIcon-*.png; do
size="${file##*-}"; size="${size%.png}"
resvg -w "$size" -h "$size" icon.svg "$file"
done
resvg "${fonts[@]}" -w 512 -h 512 avatar.svg avatar.png
resvg "${fonts[@]}" banner.svg banner.png
+287
View File
@@ -0,0 +1,287 @@
# Контракт экосистемы 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`, где `id` —
ключ). Сервер сравнивает `id` как есть, с учётом регистра, поэтому клиент
хранит `id` полученной записи в точности как пришёл и отправляет его так же
(старые клиенты могли создавать UUID в верхнем регистре).
### Возможности сервера
`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`).
Клиент v2 записывает `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` без изменений; иначе
берётся запись `rate` с `from=C,to=B` или обратная `from=B,to=C` (тогда
делим). Выбор: самая поздняя `date` не позже даты расчёта (курсы с будущей
датой не используются); при равной дате прямая пара важнее обратной; затем
более поздний `updatedAt`.
- Результат округляется до сотых половиной вверх (от нуля). Считать через
целые сотые и десятичную строку курса, не через `double`-накопление.
- Нет курса → сумма **не входит** в общие итоги, клиент показывает
предупреждение «нет курса C→B». Внутри счёта суммы складываются в валюте
счёта без конвертации.
- Курс для периода — последний на дату расчёта (не на дату операции).
## 3. Баланс (расширяет финансовые правила v1)
- Баланс счёта (в его валюте) = доходы − расходы с этим `accountId`
(включая корректировки) + `toAmount` входящих переводов − `amount`
исходящих.
- «Без счёта» = транзакции без `accountId` (в своих валютах).
- Общий баланс = сумма всех транзакций в базовой валюте + переводы
(входящая часть − исходящая, каждая в базовой валюте). При одной валюте
переводы дают ноль, и общий баланс совпадает с v1.
- Переводы не входят в аналитику и бюджеты. Корректировки — как в v1.
- Аналитика и бюджеты складывают суммы в базовой валюте.
- Архивный счёт скрыт из выбора, но его баланс входит в общий.
- Транзакции и переводы, ссылающиеся на удалённый счёт, остаются; клиент
показывает их как «Удалённый счёт».
## 4. Регулярные операции
- Повторения считаются в календаре **UTC** от даты `startDate` (её UTC-дата
и время суток): `daily` — каждые `interval` дней, `weekly` — каждые
`7·interval` дней, `monthly` — каждые `interval` месяцев в тот же день
(если дня нет — последний день месяца), `yearly` — то же с годами
(29 февраля → 28 февраля). `endDate` сравнивается по UTC-дате и включается:
повторение в день `endDate` создаётся, после — нет.
- `occurrence` = UTC-дата `YYYY-MM-DD`; дата транзакции = эта дата + время
суток `startDate`.
- ID транзакции **детерминирован**: UUID v5 от пространства имён
`6f1c3f5e-8a8d-4c3a-9d2b-4b1f0e7a2c11` и строки
`recurring:<recurringId>:<occurrence>` (нижний регистр). Два устройства
создают одну и ту же запись — дублей нет.
- `auto`: клиент создаёт все повторения в `(generatedThrough, сегодня UTC]`
(с `startDate`, если `generatedThrough` пуст) и ставит
`generatedThrough` = последнее обработанное. Удалённое пользователем
повторение не воскрешается, т.к. `generatedThrough` уже позади. Не более
366 повторений за раз.
- `remind`: клиент показывает повторения из того же окна как «ожидают»;
«Записать» создаёт транзакцию с тем же детерминированным ID и двигает
`generatedThrough` только через подряд обработанные повторения (если
записать более позднее раньше, более раннее остаётся ожидать).
«Пропустить» двигает `generatedThrough` до этого повторения включительно —
более ранние ожидающие тоже считаются пропущенными.
- `isPaused` — ничего не создаётся и не напоминается.
- Часы регулярные операции не создают (их создают телефон и десктоп).
## 5. Цели, долги — см. §1. Цели и долги не меняют баланс.
## 6. Теги
Строки после `trim`, 1–32 символа, без учёта регистра уникальны в операции,
не больше 10 на операцию. Порядок сохраняется. Аналитика и бюджеты тегами не
фильтруются; список операций — фильтруется.
## 7. Общие бюджеты (ledgers)
Пространство данных (ledger) — отдельный набор записей со своим курсором.
У каждого пользователя есть **личное** пространство (как в v1). Общее
пространство создаёт владелец и приглашает участников по коду.
| Метод | Путь | Тело / ответ |
| --- | --- | --- |
| GET | `/v1/ledgers` | `{"ledgers":[{"id","name","role","members":[{"userId","email","role"}]}]}` — только общие |
| POST | `/v1/ledgers` | `{"name"}` → ledger, `role:"owner"` |
| PATCH | `/v1/ledgers/{id}` | `{"name"}` (владелец) |
| DELETE | `/v1/ledgers/{id}` | владелец: удалить пространство со всеми записями и вложениями |
| POST | `/v1/ledgers/{id}/invites` | → `{"code","expiresAt"}`; код из 10 символов, 7 дней, одноразовый (владелец) |
| POST | `/v1/ledgers/join` | `{"code"}` → ledger, `role:"member"` |
| DELETE | `/v1/ledgers/{id}/members/{userId}` | владелец удаляет участника; участник — себя (`userId` = свой) |
- Пространство выбирается заголовком `X-Balance-Ledger: <id>` на
`/v1/sync` и `/v1/attachments/*`. Без заголовка — личное пространство.
Не участник → `403 forbidden`, несуществующее → `404 not_found`.
- Владелец не может выйти (`409 owner_cannot_leave`), даже из пустого
пространства — только удалить его.
- Ответы: создание пространства и приглашения — `201`; вход по коду и
переименование — `200`; удаление пространства и участника — `204`.
Повторный вход участника возвращает пространство и не тратит код.
`/v1/ledgers/join` ограничен по частоте вместе со входом (30 запросов в
минуту с IP).
- Клиент, получивший `403`/`404` для выбранного пространства, может сам
вернуться в личное (Flutter) или показать ошибку и предложить переключиться
(часы).
- Все участники равны в правах на записи (last-write-wins как в v1).
- Клиент хранит данные пространства отдельно: scope = сервер + пользователь
+ пространство. Переключение пространства не переносит записи. Курсор и
очередь изменений — свои у каждого пространства.
- Ошибки: `invalid_code` (400), `invalid_name` (400: пустое после `trim`,
длиннее 100 символов или с управляющими символами), `invalid_json` (400),
`forbidden` (403), `not_found` (404, в том числе удаление не-участника),
`owner_cannot_leave` (409). Коды приглашений хранятся на сервере только
как хеш.
## 8. Вложения
| Метод | Путь | Смысл |
| --- | --- | --- |
| PUT | `/v1/attachments/{sha256}` | тело — байты файла, `Content-Type` из списка; сервер проверяет SHA-256 и размер; повтор идемпотентен (`200`, первый раз `201`) |
| GET | `/v1/attachments/{sha256}` | байты и `Content-Type`; `404`, если в пространстве нет такого файла |
- Порядок клиента: сначала PUT байтов, потом запись `attachment` через sync.
- Файлы хранятся в пространстве; один и тот же файл в двух пространствах
хранится дважды.
- Ответ PUT: `{"sha256","size","contentType"}`. GET отдаёт
`Content-Disposition: attachment`.
- Когда в пространстве не осталось живых записей `attachment` с этим
`sha256`, сервер может удалить файл. Файл, на который ещё не ссылалась ни
одна запись, хранится не меньше 24 часов после загрузки (клиент загружает
байты раньше записи).
- Лимит тела 10 МиБ действует только для загрузки вложения; остальные
запросы — 2 МиБ, как в v1.
- Ошибки: `payload_too_large` (413), `unsupported_media_type` (415),
`hash_mismatch` (400, в том числе неверный формат хеша в пути).
- Клиент скачивает файл лениво при просмотре и кеширует локально.
- Сообщение ошибки для незнакомой сущности на сервере осталось как в v1
(«entity must be transaction, category, or budget») ради совместимости с
записанными ответами; код `invalid_change` верный.
## 9. Совместимость с клиентами v1
- Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и
общие пространства; транзакции в других валютах складывает как есть.
- При редактировании транзакции старый клиент теряет `accountId`,
`currency`, `tags`, `recurringId`, `occurrence`. Поэтому все клиенты
экосистемы обновляются вместе с сервером.
- Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются
на v1 до своего обновления.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB