initial version
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Account)
|
||||
public class Account: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Account> {
|
||||
return NSFetchRequest<Account>(entityName: "Account")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var inputMode: String
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var sources: NSSet?
|
||||
@NSManaged public var goals: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = "Account"
|
||||
inputMode = InputMode.simple.rawValue
|
||||
notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||
customFrequencyMonths = 1
|
||||
sortOrder = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Account {
|
||||
var sourcesArray: [InvestmentSource] {
|
||||
let set = sources as? Set<InvestmentSource> ?? []
|
||||
return set.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var goalsArray: [Goal] {
|
||||
let set = goals as? Set<Goal> ?? []
|
||||
return set.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
|
||||
var frequency: NotificationFrequency {
|
||||
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||
}
|
||||
|
||||
var currencyCode: String? {
|
||||
currency?.isEmpty == false ? currency : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(AppSettings)
|
||||
public class AppSettings: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<AppSettings> {
|
||||
return NSFetchRequest<AppSettings>(entityName: "AppSettings")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var currency: String
|
||||
@NSManaged public var defaultNotificationTime: Date?
|
||||
@NSManaged public var enableAnalytics: Bool
|
||||
@NSManaged public var onboardingCompleted: Bool
|
||||
@NSManaged public var lastSyncDate: Date?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var inputMode: String
|
||||
@NSManaged public var selectedAccountId: UUID?
|
||||
@NSManaged public var showAllAccounts: Bool
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
let localeCode = Locale.current.currency?.identifier
|
||||
currency = CurrencyPicker.commonCodes.contains(localeCode ?? "")
|
||||
? (localeCode ?? "EUR")
|
||||
: "EUR"
|
||||
enableAnalytics = true
|
||||
onboardingCompleted = false
|
||||
inputMode = InputMode.simple.rawValue
|
||||
showAllAccounts = true
|
||||
createdAt = Date()
|
||||
|
||||
// Default notification time: 9:00 AM
|
||||
var components = DateComponents()
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
defaultNotificationTime = Calendar.current.date(from: components)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension AppSettings {
|
||||
var currencySymbol: String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = currency
|
||||
return formatter.currencySymbol ?? "€"
|
||||
}
|
||||
|
||||
var notificationTimeString: String {
|
||||
guard let time = defaultNotificationTime else { return "9:00 AM" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: time)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension AppSettings {
|
||||
static func getOrCreate(in context: NSManagedObjectContext) -> AppSettings {
|
||||
let request: NSFetchRequest<AppSettings> = AppSettings.fetchRequest()
|
||||
request.fetchLimit = 1
|
||||
|
||||
if let existing = try? context.fetch(request).first {
|
||||
return existing
|
||||
}
|
||||
|
||||
let new = AppSettings(context: context)
|
||||
try? context.save()
|
||||
return new
|
||||
}
|
||||
|
||||
static func markOnboardingComplete(in context: NSManagedObjectContext) {
|
||||
let settings = getOrCreate(in: context)
|
||||
settings.onboardingCompleted = true
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Asset)
|
||||
public class Asset: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Asset> {
|
||||
return NSFetchRequest<Asset>(entityName: "Asset")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var symbol: String?
|
||||
@NSManaged public var type: String
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var sources: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = ""
|
||||
type = AssetType.stock.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum AssetType: String, CaseIterable, Identifiable {
|
||||
case stock
|
||||
case etf
|
||||
case crypto
|
||||
case fund
|
||||
case cash
|
||||
case realEstate
|
||||
case other
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .stock: return "Stock"
|
||||
case .etf: return "ETF"
|
||||
case .crypto: return "Crypto"
|
||||
case .fund: return "Fund"
|
||||
case .cash: return "Cash"
|
||||
case .realEstate: return "Real Estate"
|
||||
case .other: return "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
@objc(Category)
|
||||
public class Category: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Category> {
|
||||
return NSFetchRequest<Category>(entityName: "Category")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var colorHex: String
|
||||
@NSManaged public var icon: String
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var sources: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
sortOrder = 0
|
||||
colorHex = "#3B82F6"
|
||||
icon = "chart.pie.fill"
|
||||
name = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Category {
|
||||
var color: Color {
|
||||
Color(hex: colorHex) ?? .blue
|
||||
}
|
||||
|
||||
var sourcesArray: [InvestmentSource] {
|
||||
let set = sources as? Set<InvestmentSource> ?? []
|
||||
return set.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var sourceCount: Int {
|
||||
sources?.count ?? 0
|
||||
}
|
||||
|
||||
var totalValue: Decimal {
|
||||
sourcesArray.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated accessors for sources
|
||||
|
||||
extension Category {
|
||||
@objc(addSourcesObject:)
|
||||
@NSManaged public func addToSources(_ value: InvestmentSource)
|
||||
|
||||
@objc(removeSourcesObject:)
|
||||
@NSManaged public func removeFromSources(_ value: InvestmentSource)
|
||||
|
||||
@objc(addSources:)
|
||||
@NSManaged public func addToSources(_ values: NSSet)
|
||||
|
||||
@objc(removeSources:)
|
||||
@NSManaged public func removeFromSources(_ values: NSSet)
|
||||
}
|
||||
|
||||
// MARK: - Default Categories
|
||||
|
||||
extension Category {
|
||||
static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [
|
||||
("Stocks", "#10B981", "chart.line.uptrend.xyaxis"),
|
||||
("Bonds", "#3B82F6", "building.columns.fill"),
|
||||
("Real Estate", "#F59E0B", "house.fill"),
|
||||
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
|
||||
("Cash", "#6B7280", "banknote.fill"),
|
||||
("ETFs", "#EC4899", "chart.bar.fill"),
|
||||
("Retirement", "#14B8A6", "person.fill"),
|
||||
("Other", "#64748B", "ellipsis.circle.fill")
|
||||
]
|
||||
|
||||
static func createDefaultCategories(in context: NSManagedObjectContext) {
|
||||
for (index, categoryData) in defaultCategories.enumerated() {
|
||||
let category = Category(context: context)
|
||||
category.name = categoryData.name
|
||||
category.colorHex = categoryData.colorHex
|
||||
category.icon = categoryData.icon
|
||||
category.sortOrder = Int16(index)
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Goal)
|
||||
public class Goal: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Goal> {
|
||||
return NSFetchRequest<Goal>(entityName: "Goal")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var targetAmount: NSDecimalNumber?
|
||||
@NSManaged public var targetDate: Date?
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var account: Account?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = ""
|
||||
isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Goal {
|
||||
var targetDecimal: Decimal {
|
||||
targetAmount?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(InvestmentSource)
|
||||
public class InvestmentSource: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<InvestmentSource> {
|
||||
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var category: Category?
|
||||
@NSManaged public var account: Account?
|
||||
@NSManaged public var snapshots: NSSet?
|
||||
@NSManaged public var transactions: NSSet?
|
||||
@NSManaged public var asset: Asset?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
isActive = true
|
||||
notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||
customFrequencyMonths = 1
|
||||
name = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Frequency
|
||||
|
||||
enum NotificationFrequency: String, CaseIterable, Identifiable {
|
||||
case monthly = "monthly"
|
||||
case quarterly = "quarterly"
|
||||
case semiannual = "semiannual"
|
||||
case annual = "annual"
|
||||
case custom = "custom"
|
||||
case never = "never"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .monthly: return "Monthly"
|
||||
case .quarterly: return "Quarterly"
|
||||
case .semiannual: return "Semi-Annual"
|
||||
case .annual: return "Annual"
|
||||
case .custom: return "Custom"
|
||||
case .never: return "Never"
|
||||
}
|
||||
}
|
||||
|
||||
var months: Int {
|
||||
switch self {
|
||||
case .monthly: return 1
|
||||
case .quarterly: return 3
|
||||
case .semiannual: return 6
|
||||
case .annual: return 12
|
||||
case .custom, .never: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension InvestmentSource {
|
||||
/// Returns snapshots sorted by date descending (most recent first)
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var snapshotsArray: [Snapshot] {
|
||||
let set = snapshots as? Set<Snapshot> ?? []
|
||||
return set.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
/// Returns snapshots sorted by date ascending (oldest first)
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var sortedSnapshotsByDateAscending: [Snapshot] {
|
||||
let set = snapshots as? Set<Snapshot> ?? []
|
||||
return set.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
/// Returns the most recent snapshot without sorting all snapshots
|
||||
/// Performance: O(n) instead of O(n log n) for sorting
|
||||
var latestSnapshot: Snapshot? {
|
||||
guard let set = snapshots as? Set<Snapshot>, !set.isEmpty else { return nil }
|
||||
return set.max(by: { $0.date < $1.date })
|
||||
}
|
||||
|
||||
var latestValue: Decimal {
|
||||
latestSnapshot?.value?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var snapshotCount: Int {
|
||||
snapshots?.count ?? 0
|
||||
}
|
||||
|
||||
/// Returns transactions sorted by date descending
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var transactionsArray: [Transaction] {
|
||||
let set = transactions as? Set<Transaction> ?? []
|
||||
return set.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
var frequency: NotificationFrequency {
|
||||
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||
}
|
||||
|
||||
var nextReminderDate: Date? {
|
||||
if let account = account {
|
||||
return account.nextReminderDate
|
||||
}
|
||||
|
||||
guard frequency != .never else { return nil }
|
||||
|
||||
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
|
||||
guard let lastSnapshot = latestSnapshot else {
|
||||
return Date() // Remind now if no snapshots
|
||||
}
|
||||
|
||||
return Calendar.current.date(byAdding: .month, value: months, to: lastSnapshot.date)
|
||||
}
|
||||
|
||||
var needsUpdate: Bool {
|
||||
if let account = account {
|
||||
guard let nextDate = account.nextReminderDate else { return false }
|
||||
return Date() >= nextDate
|
||||
}
|
||||
|
||||
guard let nextDate = nextReminderDate else { return false }
|
||||
return Date() >= nextDate
|
||||
}
|
||||
|
||||
// MARK: - Performance Metrics
|
||||
|
||||
/// Calculates total return percentage. Performance: Uses O(n) min/max instead of sorting.
|
||||
var totalReturn: Decimal {
|
||||
guard let set = snapshots as? Set<Snapshot>, set.count >= 2 else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
// Performance: Find min and max by date in one pass instead of sorting twice
|
||||
guard let first = set.min(by: { $0.date < $1.date }),
|
||||
let last = set.max(by: { $0.date < $1.date }),
|
||||
let firstValue = first.value?.decimalValue,
|
||||
firstValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let lastValue = last.value?.decimalValue ?? Decimal.zero
|
||||
return ((lastValue - firstValue) / firstValue) * 100
|
||||
}
|
||||
|
||||
/// Performance: Iterates snapshots directly without sorting
|
||||
var totalContributions: Decimal {
|
||||
guard let set = snapshots as? Set<Snapshot> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, snapshot in
|
||||
result + (snapshot.contribution?.decimalValue ?? Decimal.zero)
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalInvested: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
let amount = transaction.decimalAmount
|
||||
switch transaction.transactionType {
|
||||
case .buy:
|
||||
return result + amount
|
||||
case .sell:
|
||||
return result - amount
|
||||
default:
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalDividends: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
transaction.transactionType == .dividend ? result + transaction.decimalAmount : result
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalFees: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account Scheduling
|
||||
|
||||
extension Account {
|
||||
var nextReminderDate: Date? {
|
||||
guard frequency != .never else { return nil }
|
||||
|
||||
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
|
||||
guard let latestSnapshotDate = sourcesArray
|
||||
.compactMap({ $0.latestSnapshot?.date })
|
||||
.max() else {
|
||||
return Date()
|
||||
}
|
||||
|
||||
return Calendar.current.date(byAdding: .month, value: months, to: latestSnapshotDate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated accessors for snapshots
|
||||
|
||||
extension InvestmentSource {
|
||||
@objc(addSnapshotsObject:)
|
||||
@NSManaged public func addToSnapshots(_ value: Snapshot)
|
||||
|
||||
@objc(removeSnapshotsObject:)
|
||||
@NSManaged public func removeFromSnapshots(_ value: Snapshot)
|
||||
|
||||
@objc(addSnapshots:)
|
||||
@NSManaged public func addToSnapshots(_ values: NSSet)
|
||||
|
||||
@objc(removeSnapshots:)
|
||||
@NSManaged public func removeFromSnapshots(_ values: NSSet)
|
||||
|
||||
@objc(addTransactionsObject:)
|
||||
@NSManaged public func addToTransactions(_ value: Transaction)
|
||||
|
||||
@objc(removeTransactionsObject:)
|
||||
@NSManaged public func removeFromTransactions(_ value: Transaction)
|
||||
|
||||
@objc(addTransactions:)
|
||||
@NSManaged public func addToTransactions(_ values: NSSet)
|
||||
|
||||
@objc(removeTransactions:)
|
||||
@NSManaged public func removeFromTransactions(_ values: NSSet)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="AppSettings" representedClassName="AppSettings" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" attributeType="String" defaultValueString="EUR"/>
|
||||
<attribute name="defaultNotificationTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="enableAnalytics" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
|
||||
<attribute name="lastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="onboardingCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="selectedAccountId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="showAllAccounts" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
</entity>
|
||||
<entity name="Account" representedClassName="Account" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" optional="YES" attributeType="String"/>
|
||||
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Category" representedClassName="Category" syncable="YES">
|
||||
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
|
||||
<relationship name="asset" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Asset" inverseName="sources" inverseEntity="Asset"/>
|
||||
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="sources" inverseEntity="Category"/>
|
||||
<relationship name="snapshots" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Snapshot" inverseName="source" inverseEntity="Snapshot"/>
|
||||
<relationship name="transactions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Transaction" inverseName="source" inverseEntity="Transaction"/>
|
||||
</entity>
|
||||
<entity name="Asset" representedClassName="Asset" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="symbol" optional="YES" attributeType="String"/>
|
||||
<attribute name="type" attributeType="String" defaultValueString="stock"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="asset" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Transaction" representedClassName="Transaction" syncable="YES">
|
||||
<attribute name="amount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="price" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="shares" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="type" attributeType="String" defaultValueString="buy"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="transactions" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Goal" representedClassName="Goal" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
||||
</entity>
|
||||
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
|
||||
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="predictionData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="sourceId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="validUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
<entity name="PremiumStatus" representedClassName="PremiumStatus" syncable="YES">
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isFamilyShared" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="isPremium" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="lastVerificationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="originalPurchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="productIdentifier" optional="YES" attributeType="String"/>
|
||||
<attribute name="purchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="transactionId" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="Snapshot" representedClassName="Snapshot" syncable="YES">
|
||||
<attribute name="contribution" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="value" optional="YES" attributeType="Decimal"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
</model>
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PredictionCache)
|
||||
public class PredictionCache: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<PredictionCache> {
|
||||
return NSFetchRequest<PredictionCache>(entityName: "PredictionCache")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var sourceId: UUID?
|
||||
@NSManaged public var algorithm: String
|
||||
@NSManaged public var predictionData: Data?
|
||||
@NSManaged public var calculatedAt: Date
|
||||
@NSManaged public var validUntil: Date
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
calculatedAt = Date()
|
||||
// Cache valid for 24 hours
|
||||
validUntil = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date()
|
||||
algorithm = PredictionAlgorithm.linear.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prediction Algorithm
|
||||
|
||||
enum PredictionAlgorithm: String, CaseIterable, Identifiable {
|
||||
case linear = "linear"
|
||||
case exponentialSmoothing = "exponential_smoothing"
|
||||
case movingAverage = "moving_average"
|
||||
case holtTrend = "holt_trend"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .linear: return "Linear Regression"
|
||||
case .exponentialSmoothing: return "Exponential Smoothing"
|
||||
case .movingAverage: return "Moving Average"
|
||||
case .holtTrend: return "Holt Trend"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .linear:
|
||||
return "Projects future values based on historical trend line"
|
||||
case .exponentialSmoothing:
|
||||
return "Gives more weight to recent data points"
|
||||
case .movingAverage:
|
||||
return "Smooths out short-term fluctuations"
|
||||
case .holtTrend:
|
||||
return "Captures level and trend changes over time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension PredictionCache {
|
||||
var isValid: Bool {
|
||||
Date() < validUntil
|
||||
}
|
||||
|
||||
var algorithmType: PredictionAlgorithm {
|
||||
PredictionAlgorithm(rawValue: algorithm) ?? .linear
|
||||
}
|
||||
|
||||
var predictions: [Prediction]? {
|
||||
guard let data = predictionData else { return nil }
|
||||
return try? JSONDecoder().decode([Prediction].self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension PredictionCache {
|
||||
static func getCache(
|
||||
for sourceId: UUID?,
|
||||
algorithm: PredictionAlgorithm,
|
||||
in context: NSManagedObjectContext
|
||||
) -> PredictionCache? {
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
|
||||
if let sourceId = sourceId {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == %@ AND algorithm == %@",
|
||||
sourceId as CVarArg,
|
||||
algorithm.rawValue
|
||||
)
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == nil AND algorithm == %@",
|
||||
algorithm.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
request.fetchLimit = 1
|
||||
|
||||
guard let cache = try? context.fetch(request).first,
|
||||
cache.isValid else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
static func saveCache(
|
||||
for sourceId: UUID?,
|
||||
algorithm: PredictionAlgorithm,
|
||||
predictions: [Prediction],
|
||||
in context: NSManagedObjectContext
|
||||
) {
|
||||
// Remove old cache
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
if let sourceId = sourceId {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == %@ AND algorithm == %@",
|
||||
sourceId as CVarArg,
|
||||
algorithm.rawValue
|
||||
)
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == nil AND algorithm == %@",
|
||||
algorithm.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
if let oldCache = try? context.fetch(request) {
|
||||
oldCache.forEach { context.delete($0) }
|
||||
}
|
||||
|
||||
// Create new cache
|
||||
let cache = PredictionCache(context: context)
|
||||
cache.sourceId = sourceId
|
||||
cache.algorithm = algorithm.rawValue
|
||||
cache.predictionData = try? JSONEncoder().encode(predictions)
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
static func invalidateAll(in context: NSManagedObjectContext) {
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
if let caches = try? context.fetch(request) {
|
||||
caches.forEach { context.delete($0) }
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PremiumStatus)
|
||||
public class PremiumStatus: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<PremiumStatus> {
|
||||
return NSFetchRequest<PremiumStatus>(entityName: "PremiumStatus")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var isPremium: Bool
|
||||
@NSManaged public var purchaseDate: Date?
|
||||
@NSManaged public var productIdentifier: String?
|
||||
@NSManaged public var transactionId: String?
|
||||
@NSManaged public var isFamilyShared: Bool
|
||||
@NSManaged public var lastVerificationDate: Date?
|
||||
@NSManaged public var originalPurchaseDate: Date?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
isPremium = false
|
||||
isFamilyShared = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension PremiumStatus {
|
||||
var daysSincePurchase: Int? {
|
||||
guard let purchaseDate = purchaseDate else { return nil }
|
||||
return Calendar.current.dateComponents([.day], from: purchaseDate, to: Date()).day
|
||||
}
|
||||
|
||||
var needsVerification: Bool {
|
||||
guard let lastVerification = lastVerificationDate else { return true }
|
||||
let hoursSinceVerification = Calendar.current.dateComponents(
|
||||
[.hour],
|
||||
from: lastVerification,
|
||||
to: Date()
|
||||
).hour ?? 0
|
||||
return hoursSinceVerification >= 24
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension PremiumStatus {
|
||||
static func getOrCreate(in context: NSManagedObjectContext) -> PremiumStatus {
|
||||
let request: NSFetchRequest<PremiumStatus> = PremiumStatus.fetchRequest()
|
||||
request.fetchLimit = 1
|
||||
|
||||
if let existing = try? context.fetch(request).first {
|
||||
return existing
|
||||
}
|
||||
|
||||
let new = PremiumStatus(context: context)
|
||||
try? context.save()
|
||||
return new
|
||||
}
|
||||
|
||||
static func updateStatus(
|
||||
isPremium: Bool,
|
||||
productIdentifier: String?,
|
||||
transactionId: String?,
|
||||
isFamilyShared: Bool,
|
||||
in context: NSManagedObjectContext
|
||||
) {
|
||||
let status = getOrCreate(in: context)
|
||||
status.isPremium = isPremium
|
||||
status.productIdentifier = productIdentifier
|
||||
status.transactionId = transactionId
|
||||
status.isFamilyShared = isFamilyShared
|
||||
status.lastVerificationDate = Date()
|
||||
|
||||
if isPremium && status.purchaseDate == nil {
|
||||
status.purchaseDate = Date()
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Snapshot)
|
||||
public class Snapshot: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Snapshot> {
|
||||
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@NSManaged public var value: NSDecimalNumber?
|
||||
@NSManaged public var contribution: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
date = Date()
|
||||
createdAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Snapshot {
|
||||
var decimalValue: Decimal {
|
||||
value?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalContribution: Decimal {
|
||||
contribution?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var formattedValue: String {
|
||||
CurrencyFormatter.format(value?.decimalValue ?? Decimal.zero, style: .currency, maximumFractionDigits: 2)
|
||||
}
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var monthYearString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparison with Previous
|
||||
|
||||
extension Snapshot {
|
||||
func change(from previousSnapshot: Snapshot?) -> Decimal {
|
||||
guard let previous = previousSnapshot,
|
||||
let previousValue = previous.value?.decimalValue,
|
||||
previousValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let currentValue = value?.decimalValue ?? Decimal.zero
|
||||
return currentValue - previousValue
|
||||
}
|
||||
|
||||
func percentageChange(from previousSnapshot: Snapshot?) -> Decimal {
|
||||
guard let previous = previousSnapshot,
|
||||
let previousValue = previous.value?.decimalValue,
|
||||
previousValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let currentValue = value?.decimalValue ?? Decimal.zero
|
||||
return ((currentValue - previousValue) / previousValue) * 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Transaction)
|
||||
public class Transaction: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
|
||||
return NSFetchRequest<Transaction>(entityName: "Transaction")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@NSManaged public var type: String
|
||||
@NSManaged public var shares: NSDecimalNumber?
|
||||
@NSManaged public var price: NSDecimalNumber?
|
||||
@NSManaged public var amount: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
date = Date()
|
||||
type = TransactionType.buy.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum TransactionType: String, CaseIterable, Identifiable {
|
||||
case buy
|
||||
case sell
|
||||
case dividend
|
||||
case fee
|
||||
case transfer
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .buy: return "Buy"
|
||||
case .sell: return "Sell"
|
||||
case .dividend: return "Dividend"
|
||||
case .fee: return "Fee"
|
||||
case .transfer: return "Transfer"
|
||||
}
|
||||
}
|
||||
|
||||
var isInvestmentFlow: Bool {
|
||||
self == .buy || self == .sell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Transaction {
|
||||
var decimalShares: Decimal {
|
||||
shares?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalPrice: Decimal {
|
||||
price?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalAmount: Decimal {
|
||||
if let amount = amount?.decimalValue, amount != 0 {
|
||||
return amount
|
||||
}
|
||||
return decimalShares * decimalPrice
|
||||
}
|
||||
|
||||
var transactionType: TransactionType {
|
||||
TransactionType(rawValue: type) ?? .buy
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user