diff --git a/Sources/TwigsCore/Budget.swift b/Sources/TwigsCore/Budget.swift index 18f6c65..82d551b 100644 --- a/Sources/TwigsCore/Budget.swift +++ b/Sources/TwigsCore/Budget.swift @@ -5,15 +5,31 @@ public struct Budget: Identifiable, Hashable, Codable { public let name: String public let description: String? public let currencyCode: String? + + public init(id: String, name: String, description: String?, currencyCode: String?) { + self.id = id + self.name = name + self.description = description + self.currencyCode = currencyCode + } } -public struct BudgetOverview { +public struct BudgetOverview: Equatable { public let budget: Budget public let balance: Int - public var expectedIncome: Int = 0 - public var actualIncome: Int = 0 - public var expectedExpenses: Int = 0 - public var actualExpenses: Int = 0 + public var expectedIncome: Int + public var actualIncome: Int + public var expectedExpenses: Int + public var actualExpenses: Int + + public init(budget: Budget, balance: Int, expectedIncome: Int = 0, actualIncome: Int = 0, expectedExpenses: Int = 0, actualExpenses: Int = 0) { + self.budget = budget + self.balance = balance + self.expectedIncome = expectedIncome + self.actualIncome = actualIncome + self.expectedExpenses = expectedExpenses + self.actualExpenses = actualExpenses + } } public protocol BudgetRepository { diff --git a/Sources/TwigsCore/Category.swift b/Sources/TwigsCore/Category.swift index 0cd8dbf..143b4be 100644 --- a/Sources/TwigsCore/Category.swift +++ b/Sources/TwigsCore/Category.swift @@ -8,6 +8,38 @@ public struct Category: Identifiable, Hashable, Codable { public let amount: Int public let expense: Bool public let archived: Bool + + public init( + budgetId: String, + id: String = "", + title: String = "", + description: String? = "", + amount: Int = 0, + expense: Bool = true, + archived: Bool = false + ) { + self.budgetId = budgetId + self.id = id + self.title = title + self.description = description + self.amount = amount + self.expense = expense + self.archived = archived + } +} + +extension Category { + public var type: TransactionType { + if (self.expense) { + return .expense + } else { + return .income + } + } + + public var amountString: String { + return self.amount > 0 ? String(format: "%.02d", Double(self.amount) / 100.0) : "" + } } public protocol CategoryRepository { diff --git a/Sources/TwigsCore/RecurringTransaction.swift b/Sources/TwigsCore/RecurringTransaction.swift index 6a4d8f1..9723df9 100644 --- a/Sources/TwigsCore/RecurringTransaction.swift +++ b/Sources/TwigsCore/RecurringTransaction.swift @@ -13,12 +13,52 @@ public struct RecurringTransaction: Identifiable, Hashable, Codable { public let description: String? public let frequency: Frequency public let start: Date - public let end: Date? + public let finish: Date? public let amount: Int public let categoryId: String? public let expense: Bool public let createdBy: String public let budgetId: String + + public init( + id: String = "", + title: String = "", + description: String? = nil, + frequency: Frequency = Frequency(unit: FrequencyUnit.daily, count: 1, time: Time(hours: 9, minutes: 0, seconds: 0)!)!, + start: Date = Date(), + finish: Date? = nil, + amount: Int = 0, + categoryId: String? = nil, + expense: Bool = true, + createdBy: String, + budgetId: String + ) { + self.id = id + self.title = title + self.description = description + self.frequency = frequency + self.start = start + self.finish = finish + self.amount = amount + self.categoryId = categoryId + self.expense = expense + self.createdBy = createdBy + self.budgetId = budgetId + } +} + +extension RecurringTransaction { + public var type: TransactionType { + if (self.expense) { + return .expense + } else { + return .income + } + } + + public var amountString: String { + return self.amount > 0 ? String(format: "%.02f", Double(self.amount) / 100.0) : "" + } } public struct Frequency: Hashable, Codable, CustomStringConvertible { @@ -100,7 +140,18 @@ public struct Frequency: Hashable, Codable, CustomStringConvertible { } } -public enum FrequencyUnit: Hashable, CustomStringConvertible { +public enum FrequencyUnit: Identifiable, Hashable, CustomStringConvertible, CaseIterable { + public var id: String { + return self.baseName + } + + public static var allCases: [FrequencyUnit] = [ + .daily, + .weekly(Set()), + .monthly(.fixed(1)), + .yearly(DayOfYear(month: 1, day: 1)!), + ] + case daily case weekly(Set) case monthly(DayOfMonth) @@ -111,11 +162,11 @@ public enum FrequencyUnit: Hashable, CustomStringConvertible { case .daily: return "D" case .weekly(let daysOfWeek): - return String(format: "W;%s", daysOfWeek.map { $0.rawValue }.joined(separator: ",")) + return "W;\(daysOfWeek.map { $0.rawValue }.joined(separator: ","))" case .monthly(let dayOfMonth): - return String(format: "M;%s", dayOfMonth.description) + return "M;\(dayOfMonth.description)" case .yearly(let dayOfYear): - return String(format: "Y;%s", dayOfYear.description) + return "Y;\(dayOfYear.description)" } } @@ -131,6 +182,19 @@ public enum FrequencyUnit: Hashable, CustomStringConvertible { return String(localized: "Every \(count) year(s) on \(dayOfYear.description) at \(time.description)") } } + + public var baseName: String { + switch self { + case .daily: + return "day" + case .weekly(_): + return "week" + case .monthly(_): + return "month" + case .yearly(_): + return "year" + } + } } public struct Time: Hashable, CustomStringConvertible { @@ -169,13 +233,13 @@ public struct Time: Hashable, CustomStringConvertible { } public enum DayOfMonth: Hashable, CustomStringConvertible { - case positional(Position, DayOfWeek) + case ordinal(Ordinal, DayOfWeek) case fixed(Int) - public init?(position: Position, dayOfWeek: DayOfWeek) { - if position == .day { + public init?(ordinal: Ordinal, dayOfWeek: DayOfWeek) { + if ordinal == .day { return nil } - self = .positional(position, dayOfWeek) + self = .ordinal(ordinal, dayOfWeek) } public init?(day: Int) { @@ -187,7 +251,7 @@ public enum DayOfMonth: Hashable, CustomStringConvertible { public init?(from string: String) { let parts = string.split(separator: "-") - guard let position = Position.init(rawValue: String(parts[0])) else { + guard let position = Ordinal.init(rawValue: String(parts[0])) else { return nil } if position == .day { @@ -199,21 +263,21 @@ public enum DayOfMonth: Hashable, CustomStringConvertible { guard let dayOfWeek = DayOfWeek(rawValue: String(parts[1])) else { return nil } - self = .positional(position, dayOfWeek) + self = .ordinal(position, dayOfWeek) } } public var description: String { switch self { - case .positional(let position, let dayOfWeek): - return "\(position)-\(dayOfWeek)" + case .ordinal(let position, let dayOfWeek): + return "\(position.rawValue)-\(dayOfWeek)" case .fixed(let day): - return "\(Position.day)-\(day)" + return "\(Ordinal.day.rawValue)-\(day)" } } } -public enum Position: String, Hashable { +public enum Ordinal: String, Hashable, CaseIterable { case day = "DAY" case first = "FIRST" case second = "SECOND" @@ -222,14 +286,18 @@ public enum Position: String, Hashable { case last = "LAST" } -public enum DayOfWeek: String, Hashable { +public enum DayOfWeek: String, Hashable, CaseIterable, Identifiable { + public var id: String { + return self.rawValue + } + + case sunday = "SUNDAY" case monday = "MONDAY" case tuesday = "TUESDAY" case wednesday = "WEDNESDAY" case thursday = "THURSDAY" case friday = "FRIDAY" case saturday = "SATURDAY" - case sunday = "SUNDAY" } public struct DayOfYear: Hashable, CustomStringConvertible { @@ -237,17 +305,7 @@ public struct DayOfYear: Hashable, CustomStringConvertible { public let day: Int public init?(month: Int, day: Int) { - var maxDay: Int - switch month { - case 2: - maxDay = 29; - break; - case 4, 6, 9, 11: - maxDay = 30; - break; - default: - maxDay = 31; - } + let maxDay = DayOfYear.maxDays(inMonth: month) if day < 1 || day > maxDay { return nil } @@ -271,10 +329,21 @@ public struct DayOfYear: Hashable, CustomStringConvertible { public var description: String { return String(format: "%02d-%02d", self.month, self.day) } + + public static func maxDays(inMonth month: Int) -> Int { + switch month { + case 2: + return 29; + case 4, 6, 9, 11: + return 30; + default: + return 31; + } + } } public protocol RecurringTransactionsRepository { - func getRecurringTransactions(budgetId: String) async throws -> [RecurringTransaction] + func getRecurringTransactions(_ budgetId: String) async throws -> [RecurringTransaction] func getRecurringTransaction(_ id: String) async throws -> RecurringTransaction func createRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction func updateRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction diff --git a/Sources/TwigsCore/Transaction.swift b/Sources/TwigsCore/Transaction.swift index 4d60357..b5bedbf 100644 --- a/Sources/TwigsCore/Transaction.swift +++ b/Sources/TwigsCore/Transaction.swift @@ -17,10 +17,26 @@ public struct Transaction: Identifiable, Hashable, Codable { public let expense: Bool public let createdBy: String public let budgetId: String + + public init(id: String = "", title: String = "", description: String? = "", date: Date = Date(), amount: Int = 0, categoryId: String? = "", expense: Bool = true, createdBy: String, budgetId: String) { + self.id = id + self.title = title + self.description = description + self.date = date + self.amount = amount + self.categoryId = categoryId + self.expense = expense + self.createdBy = createdBy + self.budgetId = budgetId + } } public struct BalanceResponse: Codable { public let balance: Int + + public init(balance: Int) { + self.balance = balance + } } public enum TransactionType: Int, CaseIterable, Identifiable, Hashable { @@ -40,7 +56,7 @@ extension Transaction { } public var amountString: String { - return String(Double(self.amount) / 100.0) + return self.amount > 0 ? String(format: "%.02d", Double(self.amount) / 100.0) : "" } } diff --git a/Sources/TwigsCore/TwigsApiService.swift b/Sources/TwigsCore/TwigsApiService.swift index 126be23..d7ebeab 100644 --- a/Sources/TwigsCore/TwigsApiService.swift +++ b/Sources/TwigsCore/TwigsApiService.swift @@ -14,7 +14,7 @@ open class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTrans self.init(RequestHelper()) } - init(_ requestHelper: RequestHelper) { + public init(_ requestHelper: RequestHelper) { self.requestHelper = requestHelper } @@ -221,7 +221,7 @@ open class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTrans } // MARK: Recurring Transactions - open func getRecurringTransactions(budgetId: String) async throws -> [RecurringTransaction] { + open func getRecurringTransactions(_ budgetId: String) async throws -> [RecurringTransaction] { return try await requestHelper.get("/api/recurringtransactions", queries: ["budgetId": [budgetId]]) } @@ -242,26 +242,26 @@ open class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTrans } } -class RequestHelper { +public class RequestHelper { let decoder = JSONDecoder() private var _baseUrl: String? = nil var baseUrl: String? { get { - self.baseUrl + self._baseUrl } set { guard var correctServer = newValue?.lowercased() else { return } if !correctServer.starts(with: "http://") && !correctServer.starts(with: "https://") { - correctServer = "http://\(correctServer)" + correctServer = "https://\(correctServer)" } self._baseUrl = correctServer } } var token: String? - init() { + public init() { self.decoder.dateDecodingStrategy = .formatted(Date.iso8601DateFormatter) } @@ -317,11 +317,14 @@ class RequestHelper { var request = URLRequest(url: url) request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpMethod = "DELETE" + if let token = self.token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } let (_, res) = try await URLSession.shared.data(for: request) guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { switch (res as? HTTPURLResponse)?.statusCode { - case 400: throw NetworkError.badRequest + case 400: throw NetworkError.badRequest(nil) case 401, 403: throw NetworkError.unauthorized case 404: throw NetworkError.notFound default: throw NetworkError.unknown @@ -351,16 +354,46 @@ class RequestHelper { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, res) = try await URLSession.shared.data(for: request) - guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { - switch (res as? HTTPURLResponse)?.statusCode { - case 400: throw NetworkError.badRequest + var data: Data? = nil + var res: URLResponse? + do { + (data, res) = try await URLSession.shared.data(for: request) + guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { + switch (res as? HTTPURLResponse)?.statusCode ?? -1 { + case 400: + var reason: String? = nil + if let data = data { + reason = String(decoding: data, as: UTF8.self) + } + throw NetworkError.badRequest(reason) + case 401, 403: throw NetworkError.unauthorized + case 404: throw NetworkError.notFound + case 500...599: throw NetworkError.server + default: throw NetworkError.unknown + } + } + } catch { + switch (res as? HTTPURLResponse)?.statusCode ?? -1 { case 401, 403: throw NetworkError.unauthorized case 404: throw NetworkError.notFound - default: throw NetworkError.unknown + case 500...599: throw NetworkError.server + default: + var reason: String = error.localizedDescription + if let data = data { + reason = String(decoding: data, as: UTF8.self) + } + throw NetworkError.badRequest(reason) } } - return try self.decoder.decode(ResultType.self, from: data) + do { + guard let data = data else { + throw NetworkError.unknown + } + return try self.decoder.decode(ResultType.self, from: data) + } catch { + print("error decoding json: \(error)") + throw NetworkError.jsonParsingFailed(error) + } } } @@ -373,10 +406,12 @@ public enum NetworkError: Error, Equatable { return true case (.unauthorized, .unauthorized): return true - case (.badRequest, .badRequest): - return true + case (let .badRequest(reason1), let .badRequest(reason2)): + return reason1 == reason2 case (.invalidUrl, .invalidUrl): return true + case (.server, .server): + return true case (let .jsonParsingFailed(error1), let .jsonParsingFailed(error2)): return error1.localizedDescription == error2.localizedDescription default: @@ -395,10 +430,12 @@ public enum NetworkError: Error, Equatable { return "deleted" case .unauthorized: return "unauthorized" - case .badRequest: + case .badRequest(_): return "badRequest" case .invalidUrl: return "invalidUrl" + case .server: + return "server" case .jsonParsingFailed(_): return "jsonParsingFailed" } @@ -409,8 +446,9 @@ public enum NetworkError: Error, Equatable { case notFound case deleted case unauthorized - case badRequest + case badRequest(String?) case invalidUrl + case server case jsonParsingFailed(Error) } diff --git a/Sources/TwigsCore/User.swift b/Sources/TwigsCore/User.swift index 9eb61b2..65f096d 100644 --- a/Sources/TwigsCore/User.swift +++ b/Sources/TwigsCore/User.swift @@ -10,25 +10,66 @@ import Foundation public struct User: Codable, Equatable, Hashable { public let id: String public let username: String + public let password: String? public let email: String? public let avatar: String? + + public init(id: String, username: String, email: String?, password: String?, avatar: String?) { + self.id = id + self.username = username + self.email = email + self.password = password + self.avatar = avatar + } + + public func copy( + username: String? = nil, + email: String? = nil, + password: String? = nil, + avatar: String? = nil + ) -> User { + return User( + id: self.id, + username: username ?? self.username, + email: email ?? self.email, + password: password ?? self.password, + avatar: avatar ?? self.avatar + ) + } } public struct LoginRequest: Codable { public let username: String public let password: String + + public init(username: String, password: String) { + self.username = username + self.password = password + } } public struct LoginResponse: Codable { public let token: String public let expiration: String public let userId: String + + public init(token: String, expiration: String, userId: String) { + self.token = token + self.expiration = expiration + self.userId = userId + } } public struct RegistrationRequest: Codable { public let username: String public let email: String public let password: String + + public init(username: String, email: String, password: String) { + self.username = username + self.email = email + self.password = password + } } public protocol UserRepository {