From c0f882212034794ef7d80cebb45efe8031e5502e Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Sat, 5 Sep 2026 23:44:08 +0200 Subject: [PATCH] add category and payment fields for bills --- PayForMe.xcodeproj/project.pbxproj | 12 +- PayForMe/Model/Bill.swift | 5 +- PayForMe/Model/CospendTag.swift | 75 ++++ PayForMe/Model/Project.swift | 45 ++- PayForMe/Services/NetworkService.swift | 24 ++ PayForMe/Services/ProjectManager.swift | 19 + PayForMe/Strings/cs.lproj/Localizable.strings | 12 + PayForMe/Strings/de.lproj/Localizable.strings | 12 + PayForMe/Strings/en.lproj/Localizable.strings | 12 + PayForMe/Strings/es.lproj/Localizable.strings | 12 + PayForMe/Strings/fr.lproj/Localizable.strings | 12 + PayForMe/Strings/ru.lproj/Localizable.strings | 12 + .../Views/BillDetail/BillDetailView.swift | 21 + .../BillDetail/BillDetailViewModel.swift | 40 +- PayForMe/Views/BillList/BillCell.swift | 43 ++- PayForMeTests/BillDetailViewModelTests.swift | 361 ++++++++++++++++++ PayForMeTests/BillTests.swift | 28 +- PayForMeTests/JSONDecodingTests.swift | 135 +++++++ PayForMeTests/NetworkRequestTests.swift | 242 +++++++++++- PayForMeTests/TestHelpers.swift | 28 +- 20 files changed, 1131 insertions(+), 19 deletions(-) create mode 100644 PayForMe/Model/CospendTag.swift create mode 100644 PayForMeTests/BillDetailViewModelTests.swift diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index e5acab8..d434265 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 48E7F75B2403FD6B000CE4E6 /* FancyLoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E7F75A2403FD6B000CE4E6 /* FancyLoadingButton.swift */; }; 56E7DB46FDCDC5B46C475020 /* BalanceCalculationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */; }; 57BC8D25B91CE40F4669650C /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */; }; + 62475CF1301F718C0097B7C9 /* CospendTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62475CF0301F718C0097B7C9 /* CospendTag.swift */; }; 62A13D412FA3AB760036EE34 /* ShareProjectQRCodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A13D402FA3AB700036EE34 /* ShareProjectQRCodeViewModel.swift */; }; 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */; }; 650059FC23DDE1C300D1D599 /* Person.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650059FB23DDE1C300D1D599 /* Person.swift */; }; @@ -60,6 +61,8 @@ 6591C8E524052C240054BA24 /* CommunicationIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6591C8E424052C240054BA24 /* CommunicationIndicator.swift */; }; 6591C8E7240542250054BA24 /* LoadingDots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6591C8E6240542250054BA24 /* LoadingDots.swift */; }; 65A65E3F2527529D00C86533 /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A65E3E2527529D00C86533 /* Combine.swift */; }; + 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00012F1A000100000001 /* StorageMigrationTests.swift */; }; + 65AF00042F1A000100000004 /* BillDetailViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00032F1A000100000003 /* BillDetailViewModelTests.swift */; }; 65C9E2CE23E1B8F500814B37 /* BalanceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C9E2CD23E1B8F500814B37 /* BalanceList.swift */; }; 65C9E2D023E1B96B00814B37 /* BalanceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C9E2CF23E1B96B00814B37 /* BalanceViewModel.swift */; }; 65D225F32428038500C3F1EC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 65D225F52428038500C3F1EC /* Localizable.strings */; }; @@ -67,7 +70,6 @@ BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */; }; D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EED3194A2342518B0E74079 /* BillTests.swift */; }; DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */; }; - 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00012F1A000100000001 /* StorageMigrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -98,6 +100,7 @@ 48CC8F7323F5C16D00DCE3D8 /* AddBillView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBillView.swift; sourceTree = ""; }; 48CF709A240FF82000770B97 /* AddMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMemberView.swift; sourceTree = ""; }; 48E7F75A2403FD6B000CE4E6 /* FancyLoadingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyLoadingButton.swift; sourceTree = ""; }; + 62475CF0301F718C0097B7C9 /* CospendTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CospendTag.swift; sourceTree = ""; }; 62A13D402FA3AB700036EE34 /* ShareProjectQRCodeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareProjectQRCodeViewModel.swift; sourceTree = ""; }; 650059FB23DDE1C300D1D599 /* Person.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Person.swift; sourceTree = ""; }; 65005A0123DDFB1A00D1D599 /* WhoPaidView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhoPaidView.swift; sourceTree = ""; }; @@ -106,7 +109,6 @@ 6514934B26CFC71000F00851 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/LaunchScreen.strings; sourceTree = ""; }; 6514934C26CFC71000F00851 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlExtensionsTests.swift; sourceTree = ""; }; - 65AF00012F1A000100000001 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; 6523FC502558153600BCD843 /* AddPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPasswordView.swift; sourceTree = ""; }; 6533A02A23D888B100FF822B /* BillCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillCell.swift; sourceTree = ""; }; 654754B72528736F00A82EB6 /* Views+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views+Extensions.swift"; sourceTree = ""; }; @@ -148,6 +150,8 @@ 6591C8E6240542250054BA24 /* LoadingDots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoadingDots.swift; path = PayForMe/Views/LoadingDots.swift; sourceTree = SOURCE_ROOT; }; 65A65E3E2527529D00C86533 /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = ""; }; 65AA00A6252C881E00C5DEE8 /* PayForMe.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PayForMe.entitlements; sourceTree = ""; }; + 65AF00012F1A000100000001 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; + 65AF00032F1A000100000003 /* BillDetailViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillDetailViewModelTests.swift; sourceTree = ""; }; 65B848F9243337DC0096F51C /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 65C9E2CD23E1B8F500814B37 /* BalanceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceList.swift; sourceTree = ""; }; 65C9E2CF23E1B96B00814B37 /* BalanceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceViewModel.swift; sourceTree = ""; }; @@ -237,6 +241,7 @@ 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */, 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */, 65AF00012F1A000100000001 /* StorageMigrationTests.swift */, + 65AF00032F1A000100000003 /* BillDetailViewModelTests.swift */, ); path = PayForMeTests; sourceTree = ""; @@ -294,6 +299,7 @@ children = ( 656E8CB523D9C88A00B3ED10 /* Project.swift */, 65662A1823D7322B00303207 /* Bill.swift */, + 62475CF0301F718C0097B7C9 /* CospendTag.swift */, 65662A1C23D7353400303207 /* Server.swift */, 650059FB23DDE1C300D1D599 /* Person.swift */, ); @@ -552,6 +558,7 @@ 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */, DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */, 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */, + 65AF00042F1A000100000004 /* BillDetailViewModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -578,6 +585,7 @@ 658FC55925273E4D004A49AA /* AddProjectQRViewModel.swift in Sources */, 489D35FA23E081120029F59F /* Util.swift in Sources */, 65A65E3F2527529D00C86533 /* Combine.swift in Sources */, + 62475CF1301F718C0097B7C9 /* CospendTag.swift in Sources */, 489995DA23F6EC6F008B7E38 /* OnboardingView.swift in Sources */, 6591C8E524052C240054BA24 /* CommunicationIndicator.swift in Sources */, 65662A0523D7320000303207 /* SceneDelegate.swift in Sources */, diff --git a/PayForMe/Model/Bill.swift b/PayForMe/Model/Bill.swift index e4b5651..15e64a0 100644 --- a/PayForMe/Model/Bill.swift +++ b/PayForMe/Model/Bill.swift @@ -15,6 +15,8 @@ struct Bill: Codable, Identifiable, Hashable { var payer_id: Int var owers: [Person] var `repeat`: String? + var categoryid: Int? + var paymentmodeid: Int? var lastchanged: Int? func paramsFor(_ backend: ProjectBackend) -> [String: Any] { @@ -26,8 +28,9 @@ struct Bill: Codable, Identifiable, Hashable { ] if backend == .cospend { dict["payed_for"] = owers.map { $0.id.description }.joined(separator: ",") + dict["categoryid"] = (categoryid ?? 0).description + dict["paymentmodeid"] = (paymentmodeid ?? 0).description dict["paymentmode"] = "n" - dict["categoryid"] = "0" if let rep = self.repeat { dict["repeat"] = rep diff --git a/PayForMe/Model/CospendTag.swift b/PayForMe/Model/CospendTag.swift new file mode 100644 index 0000000..34e6262 --- /dev/null +++ b/PayForMe/Model/CospendTag.swift @@ -0,0 +1,75 @@ +// +// CospendTag.swift +// PayForMe +// + +import Foundation + +struct CospendTag: Codable, Identifiable, Hashable { + let id: Int + let name: String? + let color: String? + let icon: String? + let order: Int? + + var displayName: String { + guard let name = name, !name.isEmpty else { return "—" } + return name + } + + var label: String { + guard let icon = icon, !icon.isEmpty else { return displayName } + return "\(icon) \(displayName)" + } +} + +struct CospendProjectTags: Decodable { + let categories: [CospendTag] + let paymentModes: [CospendTag] + + static let empty = CospendProjectTags(categories: [], paymentModes: []) + + init(categories: [CospendTag], paymentModes: [CospendTag]) { + self.categories = categories + self.paymentModes = paymentModes + } + + private enum CodingKeys: String, CodingKey { + case categories + case paymentmodes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + categories = Self.decodeList(from: container, forKey: .categories) + paymentModes = Self.decodeList(from: container, forKey: .paymentmodes) + } + + private static func decodeList(from container: KeyedDecodingContainer, forKey key: CodingKeys) -> [CospendTag] { + if let keyed = try? container.decode([String: CospendTag].self, forKey: key) { + return sorted(Array(keyed.values)) + } + if let list = try? container.decode([CospendTag].self, forKey: key) { + return sorted(list) + } + return [] + } + + private static func sorted(_ tags: [CospendTag]) -> [CospendTag] { + tags.sorted { lhs, rhs in + let lhsOrder = lhs.order ?? Int.max + let rhsOrder = rhs.order ?? Int.max + guard lhsOrder == rhsOrder else { return lhsOrder < rhsOrder } + return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + } + } +} + +let previewCategories = [ + CospendTag(id: 122, name: "Grocery", color: "#ffaa00", icon: "🛒", order: 0), + CospendTag(id: 126, name: "Excursion/Culture", color: "#0055ff", icon: "🚸", order: 0), +] +let previewPaymentModes = [ + CospendTag(id: 37, name: "Cash", color: "#556B2F", icon: "💵", order: 0), + CospendTag(id: 40, name: "Online service", color: "#9932CC", icon: "🌎", order: 0), +] diff --git a/PayForMe/Model/Project.swift b/PayForMe/Model/Project.swift index b1ed482..2648eb1 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -17,6 +17,8 @@ class Project: Codable, Identifiable { var members: [Int: Person] var bills: [Bill] + var categories: [CospendTag] + var paymentModes: [CospendTag] var me: Int? let projectId: String @@ -25,15 +27,17 @@ class Project: Codable, Identifiable { self.init(name: name, password: password, token: token, backend: backend, url: url, id: nil, projectId: projectId) } - fileprivate init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, id: Int?, me: Int? = nil, projectId: String) { + fileprivate init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, id: Int?, me: Int? = nil, projectId: String, categories: [CospendTag] = [], paymentModes: [CospendTag] = []) { self.name = name self.password = password self.token = token self.backend = backend self.url = url self.id = id - members = [:] - bills = [] + self.members = [:] + self.bills = [] + self.categories = categories + self.paymentModes = paymentModes self.me = me self.projectId = projectId } @@ -81,6 +85,29 @@ struct StoredProject: Codable { } } +extension Project { + func category(for bill: Bill) -> CospendTag? { + tag(id: bill.categoryid, in: categories) + } + + func paymentMode(for bill: Bill) -> CospendTag? { + tag(id: bill.paymentmodeid, in: paymentModes) + } + + private func tag(id: Int?, in tags: [CospendTag]) -> CospendTag? { + guard let id = id, id != 0 else { return nil } + return tags.first { $0.id == id } + } + + func listedCategoryId(_ id: Int) -> Int { listedId(id, in: categories) } + + func listedPaymentModeId(_ id: Int) -> Int { listedId(id, in: paymentModes) } + + private func listedId(_ id: Int, in tags: [CospendTag]) -> Int { + tags.contains { $0.id == id } ? id : 0 + } +} + extension Project: Equatable { static func == (lhs: Project, rhs: Project) -> Bool { return lhs.url == rhs.url && lhs.name == rhs.name && lhs.backend == rhs.backend && lhs.password == rhs.password @@ -107,7 +134,17 @@ enum ProjectBackend: Int, Codable { } } -let previewProject = Project(name: "TestProject", password: "TestPassword", token: "asdasdas", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 0, projectId: "TestProject") +let previewProject = Project( + name: "TestProject", + password: "TestPassword", + token: "asdasdas", + backend: .cospend, + url: URL(string: "https://testserver.de")!, + id: 0, + projectId: "TestProject", + categories: [CospendTag(id: 0, name: "Food", color: "FFFEEE", icon: "", order: 0)], + paymentModes: [CospendTag(id: 0, name: "Cash", color: "010010", icon: "", order: 0)] +) let previewProjects = [ previewProject, Project(name: "test1", password: "test23", token: "dasdasa", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 1, projectId: "test1"), diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 42ba278..a7a8004 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -77,6 +77,30 @@ class NetworkService { .eraseToAnyPublisher() } + + func loadTagsPublisher(_ project: Project) -> AnyPublisher { + guard project.backend == .cospend else { + return Just(CospendProjectTags.empty).eraseToAnyPublisher() + } + let request = buildURLRequest("", params: [:], project: project) + return URLSession.shared.dataTaskPublisher(for: request) + .compactMap { data, response -> Data? in + guard let httpResponse = response as? HTTPURLResponse else { + print("Network Error") + return nil + } + guard httpResponse.statusCode == 200 else { + print("Network Error: Status code: \(httpResponse.statusCode) \(httpResponse.description)") + return nil + } + return data + } + .decode(type: CospendProjectTags.self, decoder: decoder) + .replaceError(with: .empty) + .replaceEmpty(with: .empty) + .eraseToAnyPublisher() + } + func testProject(_ project: Project) -> AnyPublisher<(Project, Int), Never> { let request = buildURLRequest("dummy", params: [:], project: project) let requestPub = URLSession.shared.dataTaskPublisher(for: request) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index a8b474c..70865cd 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -13,6 +13,7 @@ class ProjectManager: ObservableObject { private var cancellable: Cancellable? private var loadCancellable: AnyCancellable? + private var tagsCancellable: AnyCancellable? @Published private(set) var projects = [Project]() @@ -61,6 +62,8 @@ class ProjectManager: ObservableObject { func loadBillsAndMembers(completion: (() -> Void)? = nil) { let project = currentProject + loadTags(for: project) + let billsPublisher = NetworkService.shared.loadBillsPublisher(project) let membersPublisher = NetworkService.shared.loadMembersPublisher(project) @@ -88,6 +91,21 @@ class ProjectManager: ObservableObject { ) } + + private func loadTags(for project: Project) { + tagsCancellable = NetworkService.shared.loadTagsPublisher(project) + .receive(on: DispatchQueue.main) + .sink { [weak self] tags in + project.categories = tags.categories + project.paymentModes = tags.paymentModes + // `Project` is a class, so the assignments above are already visible everywhere — + // but only re-publishing makes SwiftUI redraw. Compared by identity, because + // `Project: Equatable` ignores the id and would accept a different project. + guard let self = self, self.currentProject === project else { return } + self.currentProject = project + } + } + private func sendBillToServer(bill: Bill, update: Bool, completion: @escaping () -> Void) { cancellable?.cancel() cancellable = nil @@ -274,6 +292,7 @@ extension ProjectManager { return } loadCancellable?.cancel() + tagsCancellable?.cancel() currentProject = project loadBillsAndMembers() defaults.set(project.id, forKey: "projectID") diff --git a/PayForMe/Strings/cs.lproj/Localizable.strings b/PayForMe/Strings/cs.lproj/Localizable.strings index 7bbb911..890aa33 100644 --- a/PayForMe/Strings/cs.lproj/Localizable.strings +++ b/PayForMe/Strings/cs.lproj/Localizable.strings @@ -24,6 +24,9 @@ "Bills" = "Účty"; /* */ +"Category" = "Kategorie"; + + "Changed date" = "Změněné datum"; @@ -81,6 +84,12 @@ "Members" = "Členové"; +"No category" = "Bez kategorie"; + + +"No payment mode" = "Bez způsobu platby"; + + "None" = "Žádní"; @@ -92,6 +101,9 @@ /* BillDetail.swift */ "Payer" = "Plátce"; + +"Payment mode" = "Způsob platby"; + /* ProjectQRPermissionCheckerView.swift */ "Please allow us to use the camera in order to scan the Cospend QR code" = "Pokud chcete naskenovat QR kód z Cospend, povolte použití kamery zařízení"; diff --git a/PayForMe/Strings/de.lproj/Localizable.strings b/PayForMe/Strings/de.lproj/Localizable.strings index 0c4cfdb..0b5159e 100644 --- a/PayForMe/Strings/de.lproj/Localizable.strings +++ b/PayForMe/Strings/de.lproj/Localizable.strings @@ -21,6 +21,9 @@ "Bills" = "Ausgaben"; /* */ +"Category" = "Kategorie"; + + "Changed date" = "Geändertes Datum"; @@ -78,6 +81,12 @@ "Members" = "Mitglieder"; +"No category" = "Keine Kategorie"; + + +"No payment mode" = "Keine Zahlungsart"; + + "None" = "Niemand"; @@ -93,6 +102,9 @@ "Payer" = "Zahler"; + +"Payment mode" = "Zahlungsart"; + /* ProjectQRPermissionCheckerView.swift */ "Please allow us to use the camera in order to scan the Cospend QR code" = "Bitte erlaube der App die Kamera zu benutzen, um den Cospend QR-Code scannen zu können"; diff --git a/PayForMe/Strings/en.lproj/Localizable.strings b/PayForMe/Strings/en.lproj/Localizable.strings index 8fc28df..687ab1d 100644 --- a/PayForMe/Strings/en.lproj/Localizable.strings +++ b/PayForMe/Strings/en.lproj/Localizable.strings @@ -24,6 +24,9 @@ "Bills" = "Bills"; +"Category" = "Category"; + + "Changed date" = "Changed date"; @@ -81,6 +84,12 @@ "Members" = "Members"; +"No category" = "No category"; + + +"No payment mode" = "No payment mode"; + + "None" = "None"; @@ -96,6 +105,9 @@ /* BillDetail.swift */ "Payer" = "Payer"; + +"Payment mode" = "Payment mode"; + /* ProjectQRPermissionCheckerView.swift */ "Please allow us to use the camera in order to scan the Cospend QR code" = "Please allow us to use the camera in order to scan the Cospend QR code"; diff --git a/PayForMe/Strings/es.lproj/Localizable.strings b/PayForMe/Strings/es.lproj/Localizable.strings index c297e8d..624d6c8 100644 --- a/PayForMe/Strings/es.lproj/Localizable.strings +++ b/PayForMe/Strings/es.lproj/Localizable.strings @@ -21,6 +21,9 @@ "Bills" = "Cuentas"; /* */ +"Category" = "Categoría"; + + "Changed date" = "Fecha modificada"; /* */ @@ -78,6 +81,12 @@ "Members" = "Membros"; +"No category" = "Sin categoría"; + + +"No payment mode" = "Sin método de pago"; + + "None" = "Nadie"; @@ -89,6 +98,9 @@ "Payer" = "Pagador"; + +"Payment mode" = "Método de pago"; + /* */ "Please allow us to use the camera in order to scan the Cospend QR code" = "Permítanos utilizar la cámara para escanear el código QR de Cospend."; diff --git a/PayForMe/Strings/fr.lproj/Localizable.strings b/PayForMe/Strings/fr.lproj/Localizable.strings index 3f52860..8ff24f2 100644 --- a/PayForMe/Strings/fr.lproj/Localizable.strings +++ b/PayForMe/Strings/fr.lproj/Localizable.strings @@ -21,6 +21,9 @@ "Bills" = "Récapitulatif"; /* */ +"Category" = "Catégorie"; + + "Changed date" = "Date modifiée"; /* */ @@ -78,6 +81,12 @@ "Members" = "Membres"; +"No category" = "Sans catégorie"; + + +"No payment mode" = "Aucun moyen de paiement"; + + "None" = "Personne"; @@ -89,6 +98,9 @@ "Payer" = "Prêteur"; + +"Payment mode" = "Moyen de paiement"; + /* */ "Please allow us to use the camera in order to scan the Cospend QR code" = "Permettez-nous d'utiliser l'appareil photo pour scanner le code QR de Cospend."; diff --git a/PayForMe/Strings/ru.lproj/Localizable.strings b/PayForMe/Strings/ru.lproj/Localizable.strings index 985ec73..71e1966 100644 --- a/PayForMe/Strings/ru.lproj/Localizable.strings +++ b/PayForMe/Strings/ru.lproj/Localizable.strings @@ -21,6 +21,9 @@ "Bills" = "Счета"; /* */ +"Category" = "Категория"; + + "Changed date" = "Измененная дата"; /* */ @@ -78,6 +81,12 @@ "Members" = "Участники"; +"No category" = "Без категории"; + + +"No payment mode" = "Без способа оплаты"; + + "None" = "Никто"; @@ -89,6 +98,9 @@ "Payer" = "Кто платил"; + +"Payment mode" = "Способ оплаты"; + /* */ "Please allow us to use the camera in order to scan the Cospend QR code" = "Пожалуйста, разрешите нам использовать камеру для сканирования QR-кода Cospend"; diff --git a/PayForMe/Views/BillDetail/BillDetailView.swift b/PayForMe/Views/BillDetail/BillDetailView.swift index bde69a7..002fcb6 100644 --- a/PayForMe/Views/BillDetail/BillDetailView.swift +++ b/PayForMe/Views/BillDetail/BillDetailView.swift @@ -50,6 +50,27 @@ struct BillDetailView: View { Label("Bill date", systemImage: "calendar").labelStyle(.iconOnly) } } + + if !viewModel.currentProject.categories.isEmpty || !viewModel.currentProject.paymentModes.isEmpty { + Section { + if !viewModel.currentProject.categories.isEmpty { + Picker("Category", selection: viewModel.categoryPickerSelection) { + Text("No category").tag(0) + ForEach(viewModel.currentProject.categories) { category in + Text(category.label).tag(category.id) + } + } + } + if !viewModel.currentProject.paymentModes.isEmpty { + Picker("Payment mode", selection: viewModel.paymentModePickerSelection) { + Text("No payment mode").tag(0) + ForEach(viewModel.currentProject.paymentModes) { paymentMode in + Text(paymentMode.label).tag(paymentMode.id) + } + } + } + } + } Section(header: Text("Owers")) { PotentialOwersView(vm: viewModel.povm) } diff --git a/PayForMe/Views/BillDetail/BillDetailViewModel.swift b/PayForMe/Views/BillDetail/BillDetailViewModel.swift index e8c7ca4..eb23c61 100644 --- a/PayForMe/Views/BillDetail/BillDetailViewModel.swift +++ b/PayForMe/Views/BillDetail/BillDetailViewModel.swift @@ -7,6 +7,7 @@ import Combine import Foundation +import SwiftUI class BillDetailViewModel: ObservableObject { var manager = ProjectManager.shared @@ -30,6 +31,12 @@ class BillDetailViewModel: ObservableObject { @Published var billDate: Date = Date() + @Published + var selectedCategoryId = 0 + + @Published + var selectedPaymentModeId = 0 + var povm: PotentialOwersViewModel init(currentBill: Bill) { @@ -57,18 +64,37 @@ class BillDetailViewModel: ObservableObject { .eraseToAnyPublisher() } + var categoryPickerSelection: Binding { + Binding( + get: { self.currentProject.listedCategoryId(self.selectedCategoryId) }, + set: { self.selectedCategoryId = $0 } + ) + } + + var paymentModePickerSelection: Binding { + Binding( + get: { self.currentProject.listedPaymentModeId(self.selectedPaymentModeId) }, + set: { self.selectedPaymentModeId = $0 } + ) + } + func createBill() -> Bill? { let safeAmount = amount.replacingOccurrences(of: ",", with: ".") guard let doubleAmount = Double(safeAmount) else { return nil } - let billID = currentBill.id - let date = billDate - - let actualOwers = povm.actualOwers() - - return Bill(id: billID, amount: doubleAmount, what: topic, date: date, payer_id: selectedPayer, owers: actualOwers, repeat: currentProject.backend == .cospend ? "n" : nil, lastchanged: 0) + var bill = currentBill + bill.amount = doubleAmount + bill.what = topic + bill.date = billDate + bill.payer_id = selectedPayer + bill.owers = povm.actualOwers() + bill.repeat = currentProject.backend == .cospend ? currentBill.repeat : nil + bill.categoryid = selectedCategoryId + bill.paymentmodeid = selectedPaymentModeId + bill.lastchanged = 0 + return bill } func prefillData() { @@ -84,5 +110,7 @@ class BillDetailViewModel: ObservableObject { } } billDate = currentBill.date + selectedCategoryId = currentBill.categoryid ?? 0 + selectedPaymentModeId = currentBill.paymentmodeid ?? 0 } } diff --git a/PayForMe/Views/BillList/BillCell.swift b/PayForMe/Views/BillList/BillCell.swift index 5619102..ee65511 100644 --- a/PayForMe/Views/BillList/BillCell.swift +++ b/PayForMe/Views/BillList/BillCell.swift @@ -18,6 +18,7 @@ struct BillCell: View { VStack(alignment: .leading, spacing: 10) { Text(bill.what).font(.headline) PersonsView(bill: bill, members: viewModel.currentProject.members) + tagLine } Spacer() VStack(alignment: .trailing, spacing: 10) { @@ -27,6 +28,38 @@ struct BillCell: View { } } + private var category: CospendTag? { + viewModel.currentProject.category(for: bill) + } + + private var paymentMode: CospendTag? { + viewModel.currentProject.paymentMode(for: bill) + } + + @ViewBuilder + private var tagLine: some View { + if let tagText = tagText { + tagText + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var tagText: Text? { + switch (category, paymentMode) { + case let (category?, paymentMode?): + return Text(category.label) + Text(verbatim: " · ") + Text(paymentMode.label) + case let (category?, nil): + return Text(category.label) + case let (nil, paymentMode?): + return Text(paymentMode.label) + case (nil, nil): + return nil + } + } + func amountString() -> String { return "\(String(format: "%.2f", bill.amount))" } @@ -35,8 +68,16 @@ struct BillCell: View { struct BillCell_Previews: PreviewProvider { static var previews: some View { let viewModel = BillListViewModel() - previewProject.bills = previewBills + previewProject.bills = previewBills.enumerated().map { index, bill in + guard index < 2 else { return bill } + var tagged = bill + tagged.categoryid = previewCategories[index].id + tagged.paymentmodeid = previewPaymentModes[index].id + return tagged + } previewProject.members = previewPersons + previewProject.categories = previewCategories + previewProject.paymentModes = previewPaymentModes viewModel.currentProject = previewProject return BillList(viewModel: viewModel) } diff --git a/PayForMeTests/BillDetailViewModelTests.swift b/PayForMeTests/BillDetailViewModelTests.swift new file mode 100644 index 0000000..1e89dd3 --- /dev/null +++ b/PayForMeTests/BillDetailViewModelTests.swift @@ -0,0 +1,361 @@ +// +// BillDetailViewModelTests.swift +// PayForMeTests +// + +import XCTest +@testable import PayForMe + +class BillDetailViewModelTests: XCTestCase { + + private var savedProject: Project! + + override func setUp() { + super.setUp() + URLProtocol.registerClass(MockURLProtocol.self) + MockURLProtocol.reset() + savedProject = ProjectManager.shared.currentProject + } + + override func tearDown() { + URLProtocol.unregisterClass(MockURLProtocol.self) + ProjectManager.shared.currentProject = savedProject + super.tearDown() + } + + private func makeViewModel( + bill: Bill, + categories: [CospendTag] = [], + paymentModes: [CospendTag] = [] + ) -> BillDetailViewModel { + let project = Project.makeCospend(categories: categories, + paymentModes: paymentModes) + project.members = [testAlice.id: testAlice, testBob.id: testBob] + ProjectManager.shared.currentProject = project + return BillDetailViewModel(currentBill: bill) + } + + // MARK: - createBill() keeps the category id + + func testCreateBill_keepsNegativeCategoryId_whenCategoriesLoaded() { + // The regression: a settlement bill's built-in category is not in the project list, and + // the list IS loaded, so the old clearing logic applied and wrote 0. + let vm = makeViewModel( + bill: .make(categoryid: testUnlistedNegativeTagId), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.createBill()?.categoryid, testUnlistedNegativeTagId) + } + + func testCreateBill_keepsUnknownPositiveCategoryId() { + // A category deleted on the server, its id still on the bill. Verified against a live + // instance: the server neither validates nor resets such an id, so there is nothing to + // tidy up — clearing it would be the app changing a field the user never touched. + let vm = makeViewModel( + bill: .make(categoryid: testUnlistedPositiveTagId), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.createBill()?.categoryid, testUnlistedPositiveTagId) + } + + func testCreateBill_keepsUnknownPositiveCategoryId_whileThePickerShowsNone() { + // Display and saved value disagree here, and that is the design: the picker has no row for + // an id the project does not list, while the id itself is nobody's business but the + // server's. + let vm = makeViewModel( + bill: .make(categoryid: testUnlistedPositiveTagId), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, 0, "picker reads none") + XCTAssertEqual(vm.createBill()?.categoryid, testUnlistedPositiveTagId, + "but the id goes out unchanged") + } + + func testCreateBill_keepsCategoryId_whenCategoryListIsEmpty() { + // Tags never loaded (first launch, or the tag request failed). The picker is hidden in + // this state, so nobody could have chosen anything — the id must pass through. + let vm = makeViewModel(bill: .make(categoryid: testCategoryGrocery.id)) + + XCTAssertEqual(vm.createBill()?.categoryid, testCategoryGrocery.id) + } + + func testCreateBill_keepsSelectedCategoryFromList() { + let vm = makeViewModel( + bill: .make(categoryid: testCategoryGrocery.id), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.createBill()?.categoryid, testCategoryGrocery.id) + } + + func testCreateBill_zeroCategoryIdStaysZero() { + // "none" has to remain settable — 0 is the encoding the API uses for it. + let vm = makeViewModel(bill: .make(categoryid: 0), categories: [testCategoryGrocery]) + + XCTAssertEqual(vm.createBill()?.categoryid, 0) + } + + func testCreateBill_nilCategoryIdBecomesZero() { + // A bill from a project without categories carries nil; prefillData maps that to 0. + let vm = makeViewModel(bill: .make(), categories: [testCategoryGrocery]) + + XCTAssertEqual(vm.createBill()?.categoryid, 0) + } + + // MARK: - createBill() keeps the payment mode id + + func testCreateBill_keepsNegativePaymentModeId() { + let vm = makeViewModel( + bill: .make(paymentmodeid: testUnlistedNegativeTagId), + paymentModes: [testPaymentModeCash] + ) + + XCTAssertEqual(vm.createBill()?.paymentmodeid, testUnlistedNegativeTagId) + } + + func testCreateBill_keepsUnknownPositivePaymentModeId() { + // A payment mode deleted in Cospend, the bill edited afterwards. The server keeps such an + // id too — bill 509 of the test project carries `paymentmodeid = 9` with only four payment + // modes in existence, and the server reports `paymentmode: "n"` without touching the id. + let vm = makeViewModel( + bill: .make(paymentmodeid: testUnlistedPositiveTagId), + paymentModes: [testPaymentModeCash] + ) + + XCTAssertEqual(vm.createBill()?.paymentmodeid, testUnlistedPositiveTagId) + } + + func testCreateBill_keepsUnknownPositiveId_whenListNotLoaded() { + // An empty list cannot be told apart from a tag request that failed, and treating it as + // "nothing is listed" would clear every id on the next save. So this passes through, even + // though it means a deleted last tag keeps its dangling id. + let vm = makeViewModel(bill: .make(categoryid: testUnlistedPositiveTagId, + paymentmodeid: testUnlistedPositiveTagId)) + + XCTAssertEqual(vm.createBill()?.categoryid, testUnlistedPositiveTagId) + XCTAssertEqual(vm.createBill()?.paymentmodeid, testUnlistedPositiveTagId) + } + + func testCreateBill_keepsSelectedPaymentModeFromList() { + let vm = makeViewModel( + bill: .make(paymentmodeid: testPaymentModeCash.id), + paymentModes: [testPaymentModeCash] + ) + + XCTAssertEqual(vm.createBill()?.paymentmodeid, testPaymentModeCash.id) + } + + // MARK: - What ends up on the wire + + func testCreateBill_unlistedIds_surviveParamsForCospend() { + // The assertion that describes what the server actually receives. + let vm = makeViewModel( + bill: .make( + categoryid: testUnlistedNegativeTagId, + paymentmodeid: testUnlistedNegativeTagId + ), + categories: [testCategoryGrocery], + paymentModes: [testPaymentModeCash] + ) + + guard let params = vm.createBill()?.paramsFor(.cospend) else { + return XCTFail("createBill() returned nil") + } + XCTAssertEqual(params["categoryid"] as? String, "-11") + XCTAssertEqual(params["paymentmodeid"] as? String, "-11") + // The legacy char stays the fixed "n"; the server derives the real one from + // `paymentmodeid` and discards what the client sent. + XCTAssertEqual(params["paymentmode"] as? String, "n") + } + + // MARK: - prefillData() + + func testPrefill_takesNegativeCategoryIdAsSelection() { + // Guards against a future "let's just clean it up while filling the form". + let vm = makeViewModel( + bill: .make(categoryid: testUnlistedNegativeTagId), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.selectedCategoryId, testUnlistedNegativeTagId) + } + + // MARK: - Picker selection binding + // + // The contract these pin down: display is 0 for an id the project does not list, while the + // stored selection keeps the original. Both halves belong in the same assertion, because + // "shows none but saves -11" is a statement about two values. + + func testCategoryPickerSelection_showsIdFromList() { + let vm = makeViewModel( + bill: .make(categoryid: testCategoryGrocery.id), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, testCategoryGrocery.id) + } + + func testCategoryPickerSelection_showsZeroForZero() { + let vm = makeViewModel(bill: .make(categoryid: 0), categories: [testCategoryGrocery]) + + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, 0) + } + + func testCategoryPickerSelection_showsZeroForNegativeId() { + // A settlement bill: Cospend's built-in reimbursement category is not in the project list. + let vm = makeViewModel( + bill: .make(categoryid: testUnlistedNegativeTagId), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, 0, "picker reads as none") + XCTAssertEqual(vm.selectedCategoryId, testUnlistedNegativeTagId, "selection keeps the id") + } + + func testCategoryPickerSelection_showsZeroForUnknownPositiveId() { + let vm = makeViewModel( + bill: .make(categoryid: testUnlistedPositiveTagId), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, 0) + XCTAssertEqual(vm.selectedCategoryId, testUnlistedPositiveTagId) + } + + func testPaymentModePickerSelection_showsIdFromList() { + let vm = makeViewModel( + bill: .make(paymentmodeid: testPaymentModeCash.id), + paymentModes: [testPaymentModeCash] + ) + + XCTAssertEqual(vm.paymentModePickerSelection.wrappedValue, testPaymentModeCash.id) + } + + func testPaymentModePickerSelection_showsZeroForNegativeId() { + let vm = makeViewModel( + bill: .make(paymentmodeid: testUnlistedNegativeTagId), + paymentModes: [testPaymentModeCash] + ) + + XCTAssertEqual(vm.paymentModePickerSelection.wrappedValue, 0) + XCTAssertEqual(vm.selectedPaymentModeId, testUnlistedNegativeTagId) + } + + func testCategoryPickerSelection_showsIdOnceTagListArrivesLate() { + // prefillData() runs once in init, while the tag lists arrive later via + // manager.$currentProject. Until they do, even a perfectly valid id reads as none — and it + // has to start showing itself on its own once the list lands, selection unchanged. + let vm = makeViewModel(bill: .make(categoryid: testCategoryGrocery.id)) + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, 0, "not listed yet") + + let loaded = Project.makeCospend(categories: [testCategoryGrocery]) + loaded.members = [testAlice.id: testAlice, testBob.id: testBob] + ProjectManager.shared.currentProject = loaded + + XCTAssertEqual(vm.selectedCategoryId, testCategoryGrocery.id) + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, testCategoryGrocery.id) + } + + // MARK: - Writing through the binding + + func testCategoryPickerSelection_writeThroughKeepsSelection() { + let vm = makeViewModel(bill: .make(categoryid: 0), categories: [testCategoryGrocery]) + + vm.categoryPickerSelection.wrappedValue = testCategoryGrocery.id + + XCTAssertEqual(vm.selectedCategoryId, testCategoryGrocery.id) + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, testCategoryGrocery.id) + } + + func testCategoryPickerSelection_writingZeroReplacesUnlistedId() { + // Pins the half we own: IF SwiftUI calls `set` when the already-marked "none" row is + // tapped, the orphaned id is deliberately replaced by 0 — no guard, no special case. + // Whether SwiftUI actually does that is not claimed here. + let vm = makeViewModel( + bill: .make(categoryid: testUnlistedNegativeTagId), + categories: [testCategoryGrocery] + ) + + vm.categoryPickerSelection.wrappedValue = 0 + + XCTAssertEqual(vm.selectedCategoryId, 0) + } + + func testPaymentModePickerSelection_writeThroughKeepsSelection() { + let vm = makeViewModel(bill: .make(paymentmodeid: 0), paymentModes: [testPaymentModeCash]) + + vm.paymentModePickerSelection.wrappedValue = testPaymentModeCash.id + + XCTAssertEqual(vm.selectedPaymentModeId, testPaymentModeCash.id) + } + + // MARK: - Display mapping must not reach the saved bill + + func testCreateBill_keepsNegativeCategoryId_whileThePickerShowsNone() { + // The user's requirement in one test. Overlaps with + // testCreateBill_keepsNegativeCategoryId_whenCategoriesLoaded on purpose — the extra + // assertion is the display one, which stops anyone from "fixing" the data path by making + // the saved value follow what the picker shows. + let vm = makeViewModel( + bill: .make(categoryid: testUnlistedNegativeTagId), + categories: [testCategoryGrocery] + ) + + XCTAssertEqual(vm.categoryPickerSelection.wrappedValue, 0) + XCTAssertEqual(vm.createBill()?.categoryid, testUnlistedNegativeTagId) + } + + func testCreateBill_keepsNegativePaymentModeId_whileThePickerShowsNone() { + let vm = makeViewModel( + bill: .make(paymentmodeid: testUnlistedNegativeTagId), + paymentModes: [testPaymentModeCash] + ) + + XCTAssertEqual(vm.paymentModePickerSelection.wrappedValue, 0) + XCTAssertEqual(vm.createBill()?.paymentmodeid, testUnlistedNegativeTagId) + } + + // MARK: - A stale tag list must not change what gets saved + + func testCreateBill_keepsIdsTheListsCannotKnowAbout() { + // The lists are loaded but may be older than the bill: the bills request can succeed on + // its own and bring a tag created server-side since the last tag load. Judging an id + // against that list is exactly what must not happen, in either direction. + let vm = makeViewModel(bill: .make(categoryid: 200, paymentmodeid: 201), + categories: [testCategoryGrocery], + paymentModes: [testPaymentModeCash]) + + XCTAssertEqual(vm.createBill()?.categoryid, 200) + XCTAssertEqual(vm.createBill()?.paymentmodeid, 201) + } + + // MARK: - The repetition survives an edit too + + func testCreateBill_keepsTheRepetitionOfAnExistingBill() { + // Same class of silent loss as the category: a bill set to repeat weekly in Cospend's web + // UI stopped repeating as soon as somebody corrected its amount here, and the app has no + // UI to set it back. + let vm = makeViewModel(bill: .make(repeat: "w")) + + XCTAssertEqual(vm.createBill()?.repeat, "w") + } + + func testCreateBill_keepsNoRepetitionAsN() { + let vm = makeViewModel(bill: .make(repeat: "n")) + + XCTAssertEqual(vm.createBill()?.repeat, "n") + } + + func testCreateBill_iHateMoney_hasNoRepetition() { + // iHateMoney has no such concept; `paramsFor` drops the parameter for that backend anyway. + let project = Project.makeIHateMoney() + project.members = [testAlice.id: testAlice, testBob.id: testBob] + ProjectManager.shared.currentProject = project + let vm = BillDetailViewModel(currentBill: .make(repeat: "w")) + + XCTAssertNil(vm.createBill()?.repeat) + } +} diff --git a/PayForMeTests/BillTests.swift b/PayForMeTests/BillTests.swift index c6d9209..95b29e0 100644 --- a/PayForMeTests/BillTests.swift +++ b/PayForMeTests/BillTests.swift @@ -59,11 +59,30 @@ class BillTests: XCTestCase { } func testCospend_paymentMode_isN() { - // The Cospend API requires paymentmode; "n" means "no specific payment mode". + // The legacy char stays fixed at "n": with `paymentmodeid` set the server derives the real + // char from the payment mode's `old_id` itself and discards whatever the client sent. + // Verified against a live instance. let params = makeBill().paramsFor(.cospend) XCTAssertEqual(params["paymentmode"] as? String, "n") } + func testCospend_paymentModeId_isSent() { + // The id is what actually selects the payment mode. + let params = Bill.make(paymentmodeid: 37).paramsFor(.cospend) + XCTAssertEqual(params["paymentmodeid"] as? String, "37") + } + + func testCospend_paymentModeId_defaultsToZero() { + // 0 is the API's encoding for "no payment mode". + XCTAssertEqual(makeBill().paramsFor(.cospend)["paymentmodeid"] as? String, "0") + } + + func testCospend_categoryId_isSent() { + // Used to be hardcoded to "0", which wiped the category on every single save. + let params = Bill.make(categoryid: 122).paramsFor(.cospend) + XCTAssertEqual(params["categoryid"] as? String, "122") + } + func testCospend_categoryId_isZero() { // "0" is the default uncategorised category in Cospend. let params = makeBill().paramsFor(.cospend) @@ -138,6 +157,13 @@ class BillTests: XCTestCase { "iHateMoney must NOT receive paymentmode") } + func testIHateMoney_ignoresPaymentModeChar() { + // The char is a Cospend concept. Passing one must not leak it into an iHateMoney request. + let params = makeBill().paramsFor(.iHateMoney) + XCTAssertNil(params["paymentmode"], + "iHateMoney must NOT receive paymentmode, even when a char is passed") + } + func testIHateMoney_noCategoryId() { let params = makeBill().paramsFor(.iHateMoney) XCTAssertNil(params["categoryid"], diff --git a/PayForMeTests/JSONDecodingTests.swift b/PayForMeTests/JSONDecodingTests.swift index 1278e01..ebe198d 100644 --- a/PayForMeTests/JSONDecodingTests.swift +++ b/PayForMeTests/JSONDecodingTests.swift @@ -266,4 +266,139 @@ class JSONDecodingTests: XCTestCase { } XCTAssertEqual(sorted.map { $0.id }, [2, 3, 1]) } + + // MARK: - CospendTag legacy payment mode char + + func testCospendProjectTags_decodesDictForm() throws { + let json = """ + { + "categories": { + "122": {"id": 122, "name": "Grocery", "color": "#ffaa00", "icon": "🛒", "order": 0} + }, + "paymentmodes": { + "37": {"id": 37, "name": "Cash", "color": "#556B2F", "icon": "💵", "order": 0} + } + } + """.data(using: .utf8)! + + let tags = try decoder.decode(CospendProjectTags.self, from: json) + XCTAssertEqual(tags.categories.map(\.id), [122]) + XCTAssertEqual(tags.paymentModes.map(\.id), [37]) + } + + func testCospendProjectTags_decodesArrayForm() throws { + // The fallback branch: PHP emits [] for an empty collection, and a populated array is the + // same code path. + let json = """ + { + "categories": [ + {"id": 122, "name": "Grocery", "color": "#ffaa00", "icon": "🛒", "order": 0} + ], + "paymentmodes": [] + } + """.data(using: .utf8)! + + let tags = try decoder.decode(CospendProjectTags.self, from: json) + XCTAssertEqual(tags.categories.map(\.id), [122]) + XCTAssertTrue(tags.paymentModes.isEmpty) + } + + func testCospendProjectTags_missingKeysYieldEmptyLists() throws { + // A project response without either key must decode, not throw — the app then simply has + // no tags and hides the pickers. + let json = """ + {"id": "proj", "name": "Test"} + """.data(using: .utf8)! + + let tags = try decoder.decode(CospendProjectTags.self, from: json) + XCTAssertTrue(tags.categories.isEmpty) + XCTAssertTrue(tags.paymentModes.isEmpty) + } + + func testCospendProjectTags_nullNameFallsBackToDash() throws { + // `name` is nullable per the spec. displayName must not produce an empty picker row. + let json = """ + { + "categories": { + "5": {"id": 5, "name": null, "color": null, "icon": null, "order": 0} + }, + "paymentmodes": {} + } + """.data(using: .utf8)! + + let tags = try decoder.decode(CospendProjectTags.self, from: json) + let category = try XCTUnwrap(tags.categories.first) + XCTAssertNil(category.name) + XCTAssertEqual(category.displayName, "—") + XCTAssertEqual(category.label, "—", "without an icon the label is just the display name") + } + + func testCospendProjectTags_malformedPayloadYieldsEmptyList() throws { + // Neither a dict nor an array, i.e. Cospend changed the response format for the field. + // Decoding must not throw — the project response as a whole still has to arrive — the tags + // are simply gone. `decodeList` swallows the error via `try?`, so this is the documented + // failure mode: no tags, no complaint. + let json = """ + {"categories": "nonsense", "paymentmodes": {}} + """.data(using: .utf8)! + + let tags = try decoder.decode(CospendProjectTags.self, from: json) + XCTAssertTrue(tags.categories.isEmpty) + XCTAssertTrue(tags.paymentModes.isEmpty) + } + + func testCospendProjectTags_sortsByOrderThenName() throws { + // Dict decoding loses any order the server had, so the sort is what the picker relies on. + // Cospend leaves `order` at 0 unless tags were reordered by hand, which is why the name + // decides far more often than the explicit order does. + let json = """ + { + "categories": { + "3": {"id": 3, "name": "Zebra", "color": null, "icon": null, "order": 0}, + "1": {"id": 1, "name": "Apple", "color": null, "icon": null, "order": 0}, + "2": {"id": 2, "name": "Middle", "color": null, "icon": null, "order": -1} + }, + "paymentmodes": {} + } + """.data(using: .utf8)! + + let tags = try decoder.decode(CospendProjectTags.self, from: json) + XCTAssertEqual(tags.categories.map(\.id), [2, 1, 3], + "order first (-1 before 0), then displayName (Apple before Zebra)") + } + + func testCospendProjectTags_sortsNamesLocalized() throws { + // Cospend's categories are named in whatever language the project is kept in. Comparing + // with plain `<` goes by UTF-16 code units, which files every umlaut and every lowercase + // name behind "Z". + let json = """ + { + "categories": { + "1": {"id": 1, "name": "Zoo", "color": null, "icon": null, "order": 0}, + "2": {"id": 2, "name": "Ärzte", "color": null, "icon": null, "order": 0}, + "3": {"id": 3, "name": "apfel", "color": null, "icon": null, "order": 0} + }, + "paymentmodes": {} + } + """.data(using: .utf8)! + + let tags = try decoder.decode(CospendProjectTags.self, from: json) + XCTAssertEqual(tags.categories.map(\.name), ["apfel", "Ärzte", "Zoo"]) + } + + func testCospendProjectTags_missingOrderSortsLast() throws { + // A nil order maps to Int.max, so such a tag lands behind every explicitly ordered one. + let json = """ + { + "categories": { + "1": {"id": 1, "name": "Apple", "color": null, "icon": null}, + "2": {"id": 2, "name": "Zebra", "color": null, "icon": null, "order": 5} + }, + "paymentmodes": {} + } + """.data(using: .utf8)! + + let tags = try decoder.decode(CospendProjectTags.self, from: json) + XCTAssertEqual(tags.categories.map(\.id), [2, 1]) + } } diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift index 848687a..ff9365c 100644 --- a/PayForMeTests/NetworkRequestTests.swift +++ b/PayForMeTests/NetworkRequestTests.swift @@ -55,6 +55,17 @@ class NetworkRequestTests: XCTestCase { super.tearDown() } + // Cospend receives bill params as URL query items, so assertions on what was sent read them + // back off the captured request. + private func queryItems(of request: URLRequest?) -> [String: String] { + guard let url = request?.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let items = components.queryItems + else { return [:] } + return Dictionary(items.compactMap { item in item.value.map { (item.name, $0) } }, + uniquingKeysWith: { first, _ in first }) + } + // Returns a handler that yields an empty JSON array with a given status code. private func jsonHandler(status: Int = 200, body: String = "[]") -> (URLRequest) throws -> (HTTPURLResponse, Data) { return { request in @@ -265,13 +276,17 @@ class NetworkRequestTests: XCTestCase { // MARK: - loadBills() — graceful empty result on error func testLoadBills_on404_publisherCompletesWithoutEmittingBills() { + // Not emitting is the point: a failed request must not overwrite the bills already on + // screen with an empty list. The subscriber in ProjectManager simply does not run, so the + // last good state survives the failed refresh. What is still missing is telling the user + // that the refresh failed at all — the silence is by design, the lack of feedback is not. let project = Project.makeCospend() MockURLProtocol.requestHandler = { req in (.notFound(for: req.url!), Data()) } var didReceiveBills = false - let exp = expectation(description: "publisher completes without emitting (status quo, not ideal)") + let exp = expectation(description: "publisher completes without emitting") NetworkService.shared.loadBillsPublisher(project) .handleEvents(receiveCompletion: { _ in exp.fulfill() }) @@ -280,7 +295,7 @@ class NetworkRequestTests: XCTestCase { waitForExpectations(timeout: 2) XCTAssertFalse(didReceiveBills, - "loadBillsPublisher must NOT emit on 404 — but should when proper feedback is implemented") + "loadBillsPublisher must NOT emit on 404 — that would clear the bill list") } func testLoadMembers_returnsEmptyDictOnNetworkFailure() { @@ -300,6 +315,105 @@ class NetworkRequestTests: XCTestCase { waitForExpectations(timeout: 2) } + // MARK: - loadTags() — a failure has to emit, not hang + + func testLoadTags_on500_emitsEmptyLists() { + // The subscriber must get exactly one value either way. Empty means the pickers and the + // tag line drop out until a refresh succeeds; the ids on the bills are untouched. + let project = Project.makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 500) + + var received: [CospendProjectTags] = [] + let exp = expectation(description: "value received") + + NetworkService.shared.loadTagsPublisher(project) + .sink { tags in + received.append(tags) + exp.fulfill() + } + .store(in: &subscriptions) + + waitForExpectations(timeout: 2) + XCTAssertEqual(received.count, 1, "must emit exactly once") + XCTAssertTrue(received.first?.categories.isEmpty ?? false) + XCTAssertTrue(received.first?.paymentModes.isEmpty ?? false) + } + + func testLoadTags_onMalformedPayload_emitsEmptyLists() { + // The other failure shape: a 200 whose body does not decode fails the stream rather than + // ending it empty. Both need covering, or the subscriber never runs. + let project = Project.makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "not json at all") + + var received: [CospendProjectTags] = [] + let exp = expectation(description: "value received") + + NetworkService.shared.loadTagsPublisher(project) + .sink { tags in + received.append(tags) + exp.fulfill() + } + .store(in: &subscriptions) + + waitForExpectations(timeout: 2) + XCTAssertEqual(received.count, 1) + XCTAssertTrue(received.first?.categories.isEmpty ?? false) + } + + func testCospend_loadTags_decodesProjectRootResponse() { + // Categories and payment modes have no endpoint of their own — Cospend ships them inside + // the project info, keyed by id (see ExtraProjectInfo in swagger.json). + let project = Project.makeCospend(token: "tok", password: "pass", projectId: "proj") + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: """ + { + "id": "proj", + "name": "Test", + "categories": { + "122": {"id": 122, "projectid": "proj", "name": "Grocery", "color": "#ffaa00", "icon": "🛒", "order": 0} + }, + "paymentmodes": { + "37": {"id": 37, "projectid": "proj", "name": "Cash", "color": "#556B2F", "icon": "💵", "order": 0} + } + } + """) + + var received: CospendProjectTags? + let exp = expectation(description: "tags received") + + NetworkService.shared.loadTagsPublisher(project) + .sink { tags in + received = tags + exp.fulfill() + } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.hasSuffix("/index.php/apps/cospend/api/projects/tok/pass"), + "Tags come from the project root, without an endpoint suffix. Got: \(url)") + XCTAssertEqual(received?.categories.map(\.id), [122]) + XCTAssertEqual(received?.categories.first?.label, "🛒 Grocery") + XCTAssertEqual(received?.paymentModes.map(\.id), [37]) + XCTAssertEqual(received?.paymentModes.first?.label, "💵 Cash") + } + + func testIHateMoney_loadTags_emitsEmptyWithoutSendingARequest() { + // iHateMoney has no equivalent concept, so asking its server would be a wasted round trip. + let project = Project.makeIHateMoney() + MockURLProtocol.requestHandler = jsonHandler() + + var received: [CospendProjectTags] = [] + NetworkService.shared.loadTagsPublisher(project) + .sink { received.append($0) } + .store(in: &subscriptions) + + XCTAssertEqual(received.count, 1) + XCTAssertTrue(received.first?.categories.isEmpty ?? false) + XCTAssertTrue(received.first?.paymentModes.isEmpty ?? false) + XCTAssertNil(MockURLProtocol.lastCapturedRequest, + "iHateMoney must not hit the network for Cospend tags") + } + // MARK: - HTTP methods func testLoadBills_usesGET() { @@ -361,6 +475,47 @@ class NetworkRequestTests: XCTestCase { XCTAssertTrue(url.contains("/bills"), "POST bill URL must contain /bills. Got: \(url)") } + func testCospend_postBill_sendsTheTagIds() { + // The two ids are what actually select category and payment mode. `paymentmode` stays the + // fixed legacy "n": with `paymentmodeid` set the server derives the real char from the + // payment mode's `old_id` itself and discards ours — verified against a live instance. + ProjectManager.shared.currentProject = .makeCospend(paymentModes: [testPaymentModeCash]) + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let exp = expectation(description: "request intercepted") + NetworkService.shared.postBillPublisher( + bill: .make(categoryid: testCategoryGrocery.id, paymentmodeid: testPaymentModeCash.id) + ) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + let query = queryItems(of: MockURLProtocol.lastCapturedRequest) + XCTAssertEqual(query["categoryid"], "122") + XCTAssertEqual(query["paymentmodeid"], "37") + XCTAssertEqual(query["paymentmode"], "n") + } + + func testCospend_postBill_sendsIdsTheProjectDoesNotList() { + // A tag deleted on the server, or one of Cospend's built-in categories such as the + // reimbursement one on a settlement bill. Neither is in the project's lists, and both have + // to reach the server unchanged. + ProjectManager.shared.currentProject = .makeCospend() + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: "{}") + + let exp = expectation(description: "request intercepted") + NetworkService.shared.postBillPublisher( + bill: .make(categoryid: testUnlistedNegativeTagId, paymentmodeid: testUnlistedPositiveTagId) + ) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + let query = queryItems(of: MockURLProtocol.lastCapturedRequest) + XCTAssertEqual(query["categoryid"], "-11") + XCTAssertEqual(query["paymentmodeid"], "999") + } + func testCospend_postBill_paramsInQueryStringNotBody() { // Cospend receives bill params as URL query items, not a JSON body. // Sending a JSON body to Cospend would be silently ignored by the server. @@ -512,4 +667,87 @@ class NetworkRequestTests: XCTestCase { // Any thrown error is acceptable } } + + // MARK: - The whole rule, end to end through ProjectManager + + /// Tags fail while bills and members succeed — the split that became possible when tags got + /// their own subscription. Before that they shared a `Zip` with bills and members, and a + /// stumbling project root took the entire bill list down with it, because `Zip` waits for one + /// element from every publisher. + func testLoadBillsAndMembers_tagRequestFails_clearsTheTagsButKeepsTheBills() { + let saved = ProjectManager.shared.currentProject + defer { ProjectManager.shared.currentProject = saved } + + let project = Project.makeCospend(categories: [testCategoryGrocery], + paymentModes: [testPaymentModeCash]) + ProjectManager.shared.currentProject = project + + MockURLProtocol.requestHandler = { request in + let path = request.url!.path + let ok = { (body: String) -> (HTTPURLResponse, Data) in + (HTTPURLResponse(url: request.url!, statusCode: 200, + httpVersion: nil, headerFields: nil)!, + body.data(using: .utf8)!) + } + if path.hasSuffix("/bills") { return ok("[]") } + if path.hasSuffix("/members") { return ok("[]") } + // The project root: this is the tag request, and it stumbles. + return (HTTPURLResponse(url: request.url!, statusCode: 500, + httpVersion: nil, headerFields: nil)!, Data()) + } + + let loaded = expectation(description: "bills and members finished") + ProjectManager.shared.loadBillsAndMembers { loaded.fulfill() } + waitForExpectations(timeout: 2) + + // The tags sink hops through the main queue, so let anything already queued run first. + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 2) + + XCTAssertTrue(project.categories.isEmpty, + "a failed tag request empties the lists, so the pickers drop out") + XCTAssertTrue(project.paymentModes.isEmpty) + XCTAssertNotNil(ProjectManager.shared.currentProject, + "but bills and members still arrive — that is the whole point of the split") + } + + func testLoadBillsAndMembers_tagRequestSucceeds_replacesTheLists() { + let saved = ProjectManager.shared.currentProject + defer { ProjectManager.shared.currentProject = saved } + + let project = Project.makeCospend() + ProjectManager.shared.currentProject = project + + MockURLProtocol.requestHandler = { request in + let path = request.url!.path + let body: String + if path.hasSuffix("/bills") || path.hasSuffix("/members") { + body = "[]" + } else { + body = """ + {"id":"my-project","name":"Test", + "categories":{"122":{"id":122,"projectid":"my-project","name":"Grocery", + "color":"#ffaa00","icon":"\u{1F6D2}","order":0}}, + "paymentmodes":{}} + """ + } + return (HTTPURLResponse(url: request.url!, statusCode: 200, + httpVersion: nil, headerFields: nil)!, + body.data(using: .utf8)!) + } + + let loaded = expectation(description: "bills and members finished") + ProjectManager.shared.loadBillsAndMembers { loaded.fulfill() } + waitForExpectations(timeout: 2) + + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 2) + + XCTAssertEqual(project.categories.map(\.id), [122], + "only a successful response replaces the lists — that is how a tag deleted " + + "server-side disappears") + } + } diff --git a/PayForMeTests/TestHelpers.swift b/PayForMeTests/TestHelpers.swift index 76bdddf..e3c38d8 100644 --- a/PayForMeTests/TestHelpers.swift +++ b/PayForMeTests/TestHelpers.swift @@ -77,9 +77,13 @@ extension Project { token: String = "mytoken", password: String = "mypass", url: String = "https://nextcloud.example.com", - projectId: String = "my-project" + projectId: String = "my-project", + categories: [CospendTag] = [], + paymentModes: [CospendTag] = [] ) -> Project { - Project( + // `categories` / `paymentModes` are `var`s on the class, not init parameters, so they get + // assigned after construction. + let project = Project( name: "test-project", password: password, token: token, @@ -87,6 +91,9 @@ extension Project { url: URL(string: url)!, projectId: projectId, ) + project.categories = categories + project.paymentModes = paymentModes + return project } static func makeIHateMoney( @@ -112,6 +119,19 @@ let testAlice = Person(id: 1, weight: 1, name: "Alice", activated: true) let testBob = Person(id: 2, weight: 1, name: "Bob", activated: true) let testCarla = Person(id: 3, weight: 1, name: "Carla", activated: true) +// MARK: - Tag fixtures + +let testCategoryGrocery = CospendTag(id: 122, name: "Grocery", color: "#ffaa00", icon: "🛒", order: 0) +let testPaymentModeCash = CospendTag(id: 37, name: "Cash", color: "#556B2F", icon: "💵", order: 0) +let testPaymentModeCustom = CospendTag(id: 55, name: "Voucher", color: "#123456", icon: "🎟", order: 0) + +/// Stands in for Cospend's built-in categories, which are not bound to a project and therefore +/// never appear in `ExtraProjectInfo.categories`. The concrete value is deliberately arbitrary — +/// what is established is only that such ids exist and are negative, not a mapping to a name. +let testUnlistedNegativeTagId = -11 +/// A positive id the project does not (or no longer) know: the tag deleted on the server. +let testUnlistedPositiveTagId = 999 + // MARK: - Bill fixtures extension Bill { @@ -123,6 +143,8 @@ extension Bill { payerId: Int = 1, owers: [Person] = [testAlice, testBob, testCarla], repeat: String? = "n", + categoryid: Int? = nil, + paymentmodeid: Int? = nil, lastchanged: Int? = nil ) -> Bill { Bill( @@ -133,6 +155,8 @@ extension Bill { payer_id: payerId, owers: owers, repeat: `repeat`, + categoryid: categoryid, + paymentmodeid: paymentmodeid, lastchanged: lastchanged ) }