Various changes/fixes to API
I don't recall everything that's changed and I've been bad about making smaller commits but at a high level, custom initializers were added, network failures are handled better, amountStrings are empty when amount is 0, and some other minor things were changed.
This commit is contained in:
parent
544f9de221
commit
58eb9699a4
6 changed files with 264 additions and 52 deletions
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<DayOfWeek>)
|
||||
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
|
||||
|
|
|
@ -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) : ""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
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 {
|
||||
case 400: throw NetworkError.badRequest
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue