- Sync and store accounts, transfers, rates and the baseCurrency setting; keep recurring, goal, debt and attachment records as raw payloads. - Skip unknown entities on pull; preserve unknown payload fields of transactions, categories and budgets and send them back on re-save. - Compute balances in integer cents: total balance, analytics and budgets in the base currency, per-account balances in the account currency, "нет курса C→B" warning when a rate is missing. - Read-only accounts screen and an optional account picker when adding a transaction (currency follows the account). - Shared ledgers: choose personal or shared in settings, join by code, separate SwiftData store, cursor and deletion journal per ledger, X-Balance-Ledger header on sync. - Hide v2 features and do not push new entities when GET /v1/capabilities returns 404. - Keep the wire id of records (lowercase UUID for new ones) so edits of records created by other clients do not create duplicates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
3.8 KiB
Swift
111 lines
3.8 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
@Model
|
|
final class SyncTombstone {
|
|
var id: UUID = UUID()
|
|
var entity: String = ""
|
|
var recordID: String = ""
|
|
var updatedAt: Date = Date.now
|
|
|
|
init(entity: String, recordID: String, updatedAt: Date = .now) {
|
|
self.entity = entity
|
|
self.recordID = recordID
|
|
self.updatedAt = updatedAt
|
|
}
|
|
}
|
|
|
|
struct SyncDeletionRecord: Codable, Identifiable {
|
|
let id: UUID
|
|
let entity: String
|
|
let recordID: String
|
|
let updatedAt: Date
|
|
|
|
init(entity: String, recordID: String, updatedAt: Date = .now) {
|
|
self.id = UUID()
|
|
self.entity = entity
|
|
self.recordID = recordID
|
|
self.updatedAt = updatedAt
|
|
}
|
|
}
|
|
|
|
enum SyncDeletionStore {
|
|
// Журнал свой у каждого пространства (§7): у личного — прежний ключ.
|
|
private static var storageKey: String { "balance.server.deletionJournal.v1" + LedgerSelection.keySuffix }
|
|
private static var quarantineKey: String { "balance.server.deletionJournal.corrupt" + LedgerSelection.keySuffix }
|
|
private static let lock = NSLock()
|
|
|
|
static func add(entity: String, recordID: String, updatedAt: Date = .now) {
|
|
guard supportedEntities.contains(entity), UUID(uuidString:recordID) != nil else { return }
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
var records = loadUnlocked()
|
|
records.removeAll { $0.entity == entity && $0.recordID == recordID }
|
|
records.append(SyncDeletionRecord(entity:entity, recordID:recordID, updatedAt:updatedAt))
|
|
saveUnlocked(records)
|
|
}
|
|
|
|
static func page(through cutoff: Date, offset: Int, limit: Int) -> [SyncDeletionRecord] {
|
|
guard offset >= 0, limit > 0 else { return [] }
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
let records = loadUnlocked()
|
|
.filter { $0.updatedAt <= cutoff && supportedEntities.contains($0.entity) && UUID(uuidString:$0.recordID) != nil }
|
|
.sorted { $0.updatedAt < $1.updatedAt }
|
|
guard offset < records.count else { return [] }
|
|
return Array(records[offset..<min(offset + limit, records.count)])
|
|
}
|
|
|
|
static func remove(entity: String, recordID: String, through updatedAt: Date) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
var records = loadUnlocked()
|
|
records.removeAll {
|
|
$0.entity == entity && $0.recordID == recordID && $0.updatedAt <= updatedAt
|
|
}
|
|
saveUnlocked(records)
|
|
}
|
|
|
|
private static let supportedEntities = Set(["transaction", "category", "budget"])
|
|
|
|
private static func loadUnlocked() -> [SyncDeletionRecord] {
|
|
let defaults = UserDefaults.standard
|
|
guard let data = defaults.data(forKey:storageKey) else { return [] }
|
|
do {
|
|
return try JSONDecoder().decode([SyncDeletionRecord].self, from:data)
|
|
} catch {
|
|
defaults.set(data, forKey:quarantineKey)
|
|
defaults.removeObject(forKey:storageKey)
|
|
return []
|
|
}
|
|
}
|
|
|
|
private static func saveUnlocked(_ records: [SyncDeletionRecord]) {
|
|
let defaults = UserDefaults.standard
|
|
guard !records.isEmpty else {
|
|
defaults.removeObject(forKey:storageKey)
|
|
return
|
|
}
|
|
if let data = try? JSONEncoder().encode(records) {
|
|
defaults.set(data, forKey:storageKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ModelContext {
|
|
func deleteForSync(_ transaction: FinanceTransaction) {
|
|
SyncDeletionStore.add(entity:"transaction", recordID:transaction.wireID)
|
|
delete(transaction)
|
|
}
|
|
|
|
func deleteForSync(_ category: CustomCategory) {
|
|
SyncDeletionStore.add(entity:"category", recordID:category.wireID)
|
|
delete(category)
|
|
}
|
|
|
|
func deleteForSync(_ budget: MonthlyBudget) {
|
|
SyncDeletionStore.add(entity:"budget", recordID:budget.wireID)
|
|
delete(budget)
|
|
}
|
|
}
|