From c667c564ad4f4ef83cc17a759128c7079bdfd45b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 12 Dec 2025 20:50:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20MemberDTO=20=EB=B0=8F=20Entity?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TravelMemberDTO를 MemberDTO로 리네이밍 및 구조 변경 - MemberRole 초기화 로직 추가 및 FetchMemberResponseDTO 적용 --- .../DTO/Member/FetchMemberResponseDTO.swift | 24 +---------- Data/Sources/DTO/Member/MemberDTO.swift | 29 +++++++++++++ Domain/Sources/Entity/Member/MemberRole.swift | 4 ++ .../Mock/MockRecordExpenseUseCase.swift | 17 -------- .../Expense/RecordExpenseUseCase.swift | 42 ------------------- .../Expense/RecordExpenseUseCaseError.swift | 27 ------------ 6 files changed, 35 insertions(+), 108 deletions(-) create mode 100644 Data/Sources/DTO/Member/MemberDTO.swift delete mode 100644 Domain/Sources/UseCase/Expense/Mock/MockRecordExpenseUseCase.swift delete mode 100644 Domain/Sources/UseCase/Expense/RecordExpenseUseCase.swift delete mode 100644 Domain/Sources/UseCase/Expense/RecordExpenseUseCaseError.swift diff --git a/Data/Sources/DTO/Member/FetchMemberResponseDTO.swift b/Data/Sources/DTO/Member/FetchMemberResponseDTO.swift index 5443353d..02c241ca 100644 --- a/Data/Sources/DTO/Member/FetchMemberResponseDTO.swift +++ b/Data/Sources/DTO/Member/FetchMemberResponseDTO.swift @@ -9,28 +9,8 @@ import Foundation import Domain public struct FetchMemberResponseDTO: Decodable { - let currentUser: MemberInfo - let members: [MemberInfo] - - struct MemberInfo: Decodable { - let userId: String - let name: String - let email: String? - let role: String - let avatarUrl: String? - } -} - -private extension FetchMemberResponseDTO.MemberInfo { - func toDomain() -> TravelMember { - TravelMember( - id: userId, - name: name, - role: MemberRole(rawValue: role.lowercased()) ?? .member, - email: email, - avatarUrl: avatarUrl - ) - } + let currentUser: MemberDTO + let members: [MemberDTO] } extension FetchMemberResponseDTO { diff --git a/Data/Sources/DTO/Member/MemberDTO.swift b/Data/Sources/DTO/Member/MemberDTO.swift new file mode 100644 index 00000000..4853fec2 --- /dev/null +++ b/Data/Sources/DTO/Member/MemberDTO.swift @@ -0,0 +1,29 @@ +// +// TravelMemberDTO.swift +// Data +// +// Created by 홍석현 on 12/12/25. +// + +import Foundation +import Domain + +public struct MemberDTO: Codable { + let userId: String + let name: String + let email: String? + let role: String? + let avatarUrl: String? +} + +public extension MemberDTO { + func toDomain() -> TravelMember { + TravelMember( + id: userId, + name: name, + role: MemberRole(value: role), + email: email, + avatarUrl: avatarUrl + ) + } +} diff --git a/Domain/Sources/Entity/Member/MemberRole.swift b/Domain/Sources/Entity/Member/MemberRole.swift index 5abef23d..28a583f3 100644 --- a/Domain/Sources/Entity/Member/MemberRole.swift +++ b/Domain/Sources/Entity/Member/MemberRole.swift @@ -10,6 +10,10 @@ import Foundation public enum MemberRole: String, Equatable, Hashable, Decodable { case owner = "owner" case member = "member" + + public init(value: String?) { + self = .init(rawValue: value?.lowercased() ?? "member") ?? .member + } public var displayName: String { switch self { diff --git a/Domain/Sources/UseCase/Expense/Mock/MockRecordExpenseUseCase.swift b/Domain/Sources/UseCase/Expense/Mock/MockRecordExpenseUseCase.swift deleted file mode 100644 index 95822389..00000000 --- a/Domain/Sources/UseCase/Expense/Mock/MockRecordExpenseUseCase.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// MockRecordExpenseUseCase.swift -// Domain -// -// Created by 홍석현 on 11/28/25. -// - -import Foundation - -public struct MockRecordExpenseUseCase: RecordExpenseUseCaseProtocol { - public init() {} - - public func execute(travelId: String, expense: Expense) async throws { - // Mock implementation - do nothing - print("Mock: Recording expense - \(expense.title) for travel: \(travelId)") - } -} diff --git a/Domain/Sources/UseCase/Expense/RecordExpenseUseCase.swift b/Domain/Sources/UseCase/Expense/RecordExpenseUseCase.swift deleted file mode 100644 index e209a887..00000000 --- a/Domain/Sources/UseCase/Expense/RecordExpenseUseCase.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// RecordExpenseUseCase.swift -// Domain -// -// Created by 홍석현 on 11/17/25. -// - -import Foundation -import ComposableArchitecture - -public protocol RecordExpenseUseCaseProtocol { - func execute(travelId: String, expense: Expense) async throws -} - -public struct RecordExpenseUseCase: RecordExpenseUseCaseProtocol { - private let repository: ExpenseRepositoryProtocol - - public init(repository: ExpenseRepositoryProtocol) { - self.repository = repository - } - - public func execute(travelId: String, expense: Expense) async throws { - try expense.validate() - try await repository.save(travelId: travelId, expense: expense) - } -} - -// MARK: - DependencyKey -public enum RecordExpenseUseCaseDependencyKey: DependencyKey { - public static var liveValue: any RecordExpenseUseCaseProtocol = MockRecordExpenseUseCase() - - public static var testValue: any RecordExpenseUseCaseProtocol = MockRecordExpenseUseCase() - - public static var previewValue: any RecordExpenseUseCaseProtocol = MockRecordExpenseUseCase() -} - -public extension DependencyValues { - var recordExpenseUseCase: any RecordExpenseUseCaseProtocol { - get { self[RecordExpenseUseCaseDependencyKey.self] } - set { self[RecordExpenseUseCaseDependencyKey.self] = newValue } - } -} diff --git a/Domain/Sources/UseCase/Expense/RecordExpenseUseCaseError.swift b/Domain/Sources/UseCase/Expense/RecordExpenseUseCaseError.swift deleted file mode 100644 index 6d711936..00000000 --- a/Domain/Sources/UseCase/Expense/RecordExpenseUseCaseError.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// RecordExpenseUseCaseError.swift -// Domain -// -// Created by 홍석현 on 11/26/25. -// - -import Foundation - -public enum RecordExpenseUseCaseError: Error { - case validationFailed(ExpenseError) // 비즈니스 검증 실패 - case saveFailed(String) // 저장 실패 (서버/네트워크) - case unknown(Error) // 예상치 못한 에러 -} - -extension RecordExpenseUseCaseError: LocalizedError { - public var errorDescription: String? { - switch self { - case .validationFailed(let expenseError): - return expenseError.localizedDescription - case .saveFailed(let reason): - return "지출 기록에 실패했습니다: \(reason)" - case .unknown(let error): - return "알 수 없는 오류가 발생했습니다: \(error.localizedDescription)" - } - } -} From 3820311f3bbe2a6a0d05bad87edb48f6b3ecafa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 12 Dec 2025 20:50:30 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20Expense=20Entity=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20DTO=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expense 구조체에서 payerId/Name을 TravelMember 타입의 payer로 변경 - CreateExpenseInput을 ExpenseInput으로 통합 및 일반화 - 관련 Request/Response DTO 필드 업데이트 --- .../Request/CreateExpenseRequestDTO.swift | 22 +----- .../Request/UpdateExpenseRequestDTO.swift | 22 ------ .../Response/TravelExpenseResponseDTO.swift | 23 ++---- .../Entity/Expense/CreateExpenseInput.swift | 42 ----------- Domain/Sources/Entity/Expense/Expense.swift | 53 +++++++------- .../Sources/Entity/Expense/ExpenseInput.swift | 72 +++++++++++++++++++ 6 files changed, 103 insertions(+), 131 deletions(-) delete mode 100644 Domain/Sources/Entity/Expense/CreateExpenseInput.swift create mode 100644 Domain/Sources/Entity/Expense/ExpenseInput.swift diff --git a/Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift b/Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift index 0bed0b90..497affdb 100644 --- a/Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift +++ b/Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift @@ -13,27 +13,7 @@ public struct CreateExpenseRequestDTO: Encodable { let amount: Double let currency: String let expenseDate: String + let payerId: String let category: String let participantIds: [String] } - -extension Expense { - func toCreateRequestDTO() -> CreateExpenseRequestDTO { - // yyyy-MM-dd 형식으로 날짜 변환 (사용자가 선택한 날짜 그대로 전송) - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - dateFormatter.calendar = Calendar(identifier: .gregorian) - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - // Plain date는 timezone 변환하지 않음 (기본값 = TimeZone.current) - let dateString = dateFormatter.string(from: expenseDate) - - return CreateExpenseRequestDTO( - title: title, - amount: amount, - currency: currency, - expenseDate: dateString, - category: category.rawValue, - participantIds: participants.map { $0.id } - ) - } -} diff --git a/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift b/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift index 0e7dc84a..f3436111 100644 --- a/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift +++ b/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift @@ -17,25 +17,3 @@ public struct UpdateExpenseRequestDTO: Encodable { let payerId: String let participantIds: [String] } - -extension Expense { - func toUpdateRequestDTO() -> UpdateExpenseRequestDTO { - // yyyy-MM-dd 형식으로 날짜 변환 (사용자가 선택한 날짜 그대로 전송) - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - dateFormatter.calendar = Calendar(identifier: .gregorian) - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - // Plain date는 timezone 변환하지 않음 (기본값 = TimeZone.current) - let dateString = dateFormatter.string(from: expenseDate) - - return UpdateExpenseRequestDTO( - title: title, - amount: amount, - currency: currency, - expenseDate: dateString, - category: category.rawValue, - payerId: payerId, - participantIds: participants.map { $0.id } - ) - } -} diff --git a/Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift b/Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift index 1293d6ce..f1cc739e 100644 --- a/Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift +++ b/Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift @@ -16,23 +16,9 @@ public struct ExpenseDTO: Codable { let convertedAmount: Double let expenseDate: String let category: String - let payerId: String - let payerName: String + let payer: MemberDTO let authorId: String - let participants: [ParticipantDTO] - - struct ParticipantDTO: Codable { - let memberId: String - let name: String - - func toDomain() -> TravelMember { - TravelMember( - id: memberId, - name: name, - role: .member - ) - } - } + let expenseMembers: [MemberDTO] func toDomain() -> Expense? { // yyyy-MM-dd 날짜 파싱 (서버에서 받은 날짜 그대로 사용) @@ -50,7 +36,7 @@ public struct ExpenseDTO: Codable { let expenseCategory = ExpenseCategory(rawValue: category) ?? .other // Participant 변환 - let members = participants.map { $0.toDomain() } + let members = expenseMembers.map { $0.toDomain() } return Expense( id: id, @@ -60,8 +46,7 @@ public struct ExpenseDTO: Codable { convertedAmount: convertedAmount, expenseDate: date, category: expenseCategory, - payerId: payerId, - payerName: payerName, + payer: payer.toDomain(), participants: members ) } diff --git a/Domain/Sources/Entity/Expense/CreateExpenseInput.swift b/Domain/Sources/Entity/Expense/CreateExpenseInput.swift deleted file mode 100644 index db5fe9a0..00000000 --- a/Domain/Sources/Entity/Expense/CreateExpenseInput.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// CreateExpenseInput.swift -// Domain -// -// Created by 홍석현 on 11/30/25. -// - -import Foundation - -public struct CreateExpenseInput { - public let title: String - public let amount: Double - public let currency: String - public let convertedAmount: Double - public let expenseDate: Date - public let category: ExpenseCategory - public let payerId: String - public let payerName: String - public let participants: [TravelMember] - - public init( - title: String, - amount: Double, - currency: String, - convertedAmount: Double, - expenseDate: Date, - category: ExpenseCategory, - payerId: String, - payerName: String, - participants: [TravelMember] - ) { - self.title = title - self.amount = amount - self.currency = currency - self.convertedAmount = convertedAmount - self.expenseDate = expenseDate - self.category = category - self.payerId = payerId - self.payerName = payerName - self.participants = participants - } -} diff --git a/Domain/Sources/Entity/Expense/Expense.swift b/Domain/Sources/Entity/Expense/Expense.swift index e282bf58..bfbc67ed 100644 --- a/Domain/Sources/Entity/Expense/Expense.swift +++ b/Domain/Sources/Entity/Expense/Expense.swift @@ -15,8 +15,7 @@ public struct Expense: Identifiable, Equatable, Hashable { public let convertedAmount: Double // 환산 금액 public let expenseDate: Date public let category: ExpenseCategory - public let payerId: String - public let payerName: String + public let payer: TravelMember public let participants: [TravelMember] public init( id: String, @@ -26,8 +25,7 @@ public struct Expense: Identifiable, Equatable, Hashable { convertedAmount: Double, expenseDate: Date, category: ExpenseCategory, - payerId: String, - payerName: String, + payer: TravelMember, participants: [TravelMember] ) { self.id = id @@ -37,12 +35,12 @@ public struct Expense: Identifiable, Equatable, Hashable { self.convertedAmount = convertedAmount self.expenseDate = expenseDate self.category = category - self.payerId = payerId - self.payerName = payerName + self.payer = payer self.participants = participants } } +// MARK: - Validation extension Expense { public func validate() throws { // 금액 검증 @@ -61,12 +59,23 @@ extension Expense { } // 지불자가 참가자 목록에 있는지 검증 - guard participants.contains(where: { $0.id == payerId }) else { + guard participants.contains(where: { $0.id == payer.id }) else { throw ExpenseError.payerNotInParticipants } } } +// MARK: - Helper +extension Expense { + public func formatExpenseDate() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.calendar = Calendar(identifier: .gregorian) + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + return dateFormatter.string(from: expenseDate) + } +} + // MARK: - Mock Data extension Expense { public static let mockMembers = [ @@ -83,8 +92,7 @@ extension Expense { convertedAmount: 405000, expenseDate: Date().addingTimeInterval(-86400 * 2), category: .accommodation, - payerId: "user1", - payerName: "김민수", + payer: mockMembers[0], participants: mockMembers ) @@ -96,8 +104,7 @@ extension Expense { convertedAmount: 32400, expenseDate: Date().addingTimeInterval(-86400), category: .foodAndDrink, - payerId: "user2", - payerName: "이지은", + payer: mockMembers[1], participants: mockMembers ) @@ -109,8 +116,7 @@ extension Expense { convertedAmount: 243000, expenseDate: Date().addingTimeInterval(-86400), category: .activity, - payerId: "user1", - payerName: "김민수", + payer: mockMembers[0], participants: mockMembers ) @@ -122,8 +128,7 @@ extension Expense { convertedAmount: 22500, expenseDate: Date().addingTimeInterval(-3600), category: .transportation, - payerId: "user3", - payerName: "박서준", + payer: mockMembers[2], participants: mockMembers ) @@ -135,8 +140,7 @@ extension Expense { convertedAmount: 135000, expenseDate: Date(), category: .shopping, - payerId: "user2", - payerName: "이지은", + payer: mockMembers[1], participants: [mockMembers[0], mockMembers[1]] ) @@ -148,8 +152,7 @@ extension Expense { convertedAmount: 10800, expenseDate: Date().addingTimeInterval(-86400 * 2 + 3600), // 2일 전 category: .foodAndDrink, - payerId: "user1", - payerName: "김민수", + payer: mockMembers[0], participants: mockMembers ) @@ -161,8 +164,7 @@ extension Expense { convertedAmount: 18000, expenseDate: Date().addingTimeInterval(-86400 * 2 - 3600), // 2일 전 category: .transportation, - payerId: "user2", - payerName: "이지은", + payer: mockMembers[1], participants: mockMembers ) @@ -174,8 +176,7 @@ extension Expense { convertedAmount: 40500, expenseDate: Date().addingTimeInterval(-86400), // 1일 전 category: .foodAndDrink, - payerId: "user3", - payerName: "박서준", + payer: mockMembers[2], participants: mockMembers ) @@ -187,8 +188,7 @@ extension Expense { convertedAmount: 13500, expenseDate: Date().addingTimeInterval(-3600 * 2), // 오늘 category: .foodAndDrink, - payerId: "user1", - payerName: "김민수", + payer: mockMembers[0], participants: [mockMembers[0], mockMembers[1]] ) @@ -200,8 +200,7 @@ extension Expense { convertedAmount: 28800, expenseDate: Date().addingTimeInterval(3600), // 오늘 (미래?) -> 테스트용으로 오늘로 간주 category: .transportation, - payerId: "user2", - payerName: "이지은", + payer: mockMembers[1], participants: mockMembers ) diff --git a/Domain/Sources/Entity/Expense/ExpenseInput.swift b/Domain/Sources/Entity/Expense/ExpenseInput.swift new file mode 100644 index 00000000..319a76ea --- /dev/null +++ b/Domain/Sources/Entity/Expense/ExpenseInput.swift @@ -0,0 +1,72 @@ +// +// ExpenseInput.swift +// Domain +// +// Created by 홍석현 on 11/30/25. +// + +import Foundation + +public struct ExpenseInput { + public let title: String + public let amount: Double + public let currency: String + public let expenseDate: Date + public let category: ExpenseCategory + public let payerId: String + public let participantIds: [String] + + public init( + title: String, + amount: Double, + currency: String, + expenseDate: Date, + category: ExpenseCategory, + payerId: String, + participantIds: [String] + ) { + self.title = title + self.amount = amount + self.currency = currency + self.expenseDate = expenseDate + self.category = category + self.payerId = payerId + self.participantIds = participantIds + } +} + +// MARK: - Validation +extension ExpenseInput { + public func validate() throws { + // 금액 검증 + guard amount > 0 else { + throw ExpenseError.invalidAmount(amount) + } + + // 제목 검증 + guard !title.trimmingCharacters(in: .whitespaces).isEmpty else { + throw ExpenseError.emptyTitle + } + + // 참가자 검증 + guard !participantIds.isEmpty else { + throw ExpenseError.invalidParticipants + } + + // 지불자가 참가자 목록에 있는지 검증 + guard participantIds.contains(payerId) else { + throw ExpenseError.payerNotInParticipants + } + } +} + +// MARK: - Helper +extension ExpenseInput { + public func formatExpenseDate() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.calendar = Calendar(identifier: .gregorian) + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + return dateFormatter.string(from: expenseDate) + } +} From 1c9c9fd0aedd5c4d45efae6c4336ee15c6a34341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 12 Dec 2025 20:50:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20Expense=20Repository=20?= =?UTF-8?q?=EB=B0=8F=20UseCase=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExpenseInput 및 Member 구조 변경에 따른 Repository 프로토콜 및 구현체 수정 - Create/Update ExpenseUseCase 및 Mock 객체 업데이트 - CalculateSettlementUseCase 정산 로직 타입 수정 반영 --- .../Expense/ExpenseRepository.swift | 27 +++++++++++++--- .../ExpenseRepositoryProtocol.swift | 4 +-- .../Mock/MockExpenseRepository.swift | 17 +++++----- .../Expense/CreateExpenseUseCase.swift | 19 ++--------- .../Mock/MockUpdateExpenseUseCase.swift | 6 ++-- .../Expense/UpdateExpenseUseCase.swift | 8 ++--- .../CalculateSettlementUseCase.swift | 32 +++++++++++++++---- 7 files changed, 67 insertions(+), 46 deletions(-) diff --git a/Data/Sources/Repository/Expense/ExpenseRepository.swift b/Data/Sources/Repository/Expense/ExpenseRepository.swift index a69467e8..c163e1a4 100644 --- a/Data/Sources/Repository/Expense/ExpenseRepository.swift +++ b/Data/Sources/Repository/Expense/ExpenseRepository.swift @@ -56,14 +56,31 @@ public final class ExpenseRepository: ExpenseRepositoryProtocol { } } - public func save(travelId: String, expense: Expense) async throws { - let requestDTO = expense.toCreateRequestDTO() + public func save(travelId: String, input: ExpenseInput) async throws { + let requestDTO = CreateExpenseRequestDTO( + title: input.title, + amount: input.amount, + currency: input.currency, + expenseDate: input.formatExpenseDate(), + payerId: input.payerId, + category: input.category.rawValue, + participantIds: input.participantIds + ) + try await remote.createExpense(travelId: travelId, body: requestDTO) } - public func update(travelId: String, expense: Expense) async throws { - let requestDTO = expense.toUpdateRequestDTO() - try await remote.updateExpense(travelId: travelId, expenseId: expense.id, body: requestDTO) + public func update(travelId: String, expenseId: String, input: ExpenseInput) async throws { + let requestDTO = UpdateExpenseRequestDTO( + title: input.title, + amount: input.amount, + currency: input.currency, + expenseDate: input.formatExpenseDate(), + category: input.category.rawValue, + payerId: input.payerId, + participantIds: input.participantIds + ) + try await remote.updateExpense(travelId: travelId, expenseId: expenseId, body: requestDTO) } public func delete(travelId: String, expenseId: String) async throws { diff --git a/Domain/Sources/Repository/ExpenseRepositoryProtocol.swift b/Domain/Sources/Repository/ExpenseRepositoryProtocol.swift index e810e68e..aef38b00 100644 --- a/Domain/Sources/Repository/ExpenseRepositoryProtocol.swift +++ b/Domain/Sources/Repository/ExpenseRepositoryProtocol.swift @@ -13,10 +13,10 @@ public protocol ExpenseRepositoryProtocol { func fetchTravelExpenses(travelId: String) -> AsyncStream> // 지출 내역 저장 - func save(travelId: String, expense: Expense) async throws + func save(travelId: String, input: ExpenseInput) async throws // 지출 내역 수정 - func update(travelId: String, expense: Expense) async throws + func update(travelId: String, expenseId: String, input: ExpenseInput) async throws // 지출 내역 삭제 func delete(travelId: String, expenseId: String) async throws diff --git a/Domain/Sources/Repository/Mock/MockExpenseRepository.swift b/Domain/Sources/Repository/Mock/MockExpenseRepository.swift index 31a15b12..23b0611e 100644 --- a/Domain/Sources/Repository/Mock/MockExpenseRepository.swift +++ b/Domain/Sources/Repository/Mock/MockExpenseRepository.swift @@ -45,28 +45,27 @@ final public actor MockExpenseRepository: ExpenseRepositoryProtocol { } } } - - public func save( - travelId: String, - expense: Expense - ) async throws { + + public func save(travelId: String, input: ExpenseInput) async throws { if shouldFailSave { let reason = saveErrorReason ?? "Unknown error" throw ExpenseRepositoryError.saveFailed(reason: reason) } - - storage[expense.id] = expense + + storage[input.payerId] = Expense.mock1 } + public func update( travelId: String, - expense: Expense + expenseId: String, + input: ExpenseInput ) async throws { if shouldFailUpdate { throw ExpenseRepositoryError.updateFailed(reason: "업데이트 실패") } - storage[expense.id] = expense + storage[expenseId] = Expense.mock1 } public func delete( diff --git a/Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift b/Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift index a624803d..b5a3fffd 100644 --- a/Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift +++ b/Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift @@ -15,22 +15,9 @@ public struct CreateExpenseUseCase { self.repository = repository } - public func execute(travelId: String, input: CreateExpenseInput) async throws { - let expense = Expense( - id: UUID().uuidString, - title: input.title, - amount: input.amount, - currency: input.currency, - convertedAmount: input.convertedAmount, - expenseDate: input.expenseDate, - category: input.category, - payerId: input.payerId, - payerName: input.payerName, - participants: input.participants - ) - - try expense.validate() - try await repository.save(travelId: travelId, expense: expense) + public func execute(travelId: String, input: ExpenseInput) async throws { + try input.validate() + try await repository.save(travelId: travelId, input: input) } } diff --git a/Domain/Sources/UseCase/Expense/Mock/MockUpdateExpenseUseCase.swift b/Domain/Sources/UseCase/Expense/Mock/MockUpdateExpenseUseCase.swift index fc28d8cf..a4f0bae8 100644 --- a/Domain/Sources/UseCase/Expense/Mock/MockUpdateExpenseUseCase.swift +++ b/Domain/Sources/UseCase/Expense/Mock/MockUpdateExpenseUseCase.swift @@ -8,10 +8,10 @@ import Foundation public struct MockUpdateExpenseUseCase: UpdateExpenseUseCaseProtocol { + public init() {} - public func execute(travelId: String, expense: Expense) async throws { - // Mock implementation - 성공으로 간주 - print("Mock: Updated expense \(expense.id) for travel \(travelId)") + public func execute(travelId: String, expenseId: String, input: ExpenseInput) async throws { + print("Mock: Updated expense \(expenseId) for travel \(travelId)") } } diff --git a/Domain/Sources/UseCase/Expense/UpdateExpenseUseCase.swift b/Domain/Sources/UseCase/Expense/UpdateExpenseUseCase.swift index 4cf7ad66..cb49e9e4 100644 --- a/Domain/Sources/UseCase/Expense/UpdateExpenseUseCase.swift +++ b/Domain/Sources/UseCase/Expense/UpdateExpenseUseCase.swift @@ -9,7 +9,7 @@ import Foundation import ComposableArchitecture public protocol UpdateExpenseUseCaseProtocol { - func execute(travelId: String, expense: Expense) async throws + func execute(travelId: String, expenseId: String, input: ExpenseInput) async throws } public struct UpdateExpenseUseCase: UpdateExpenseUseCaseProtocol { @@ -19,9 +19,9 @@ public struct UpdateExpenseUseCase: UpdateExpenseUseCaseProtocol { self.repository = repository } - public func execute(travelId: String, expense: Expense) async throws { - try expense.validate() - try await repository.update(travelId: travelId, expense: expense) + public func execute(travelId: String, expenseId: String, input: ExpenseInput) async throws { + try input.validate() + try await repository.update(travelId: travelId, expenseId: expenseId, input: input) } } diff --git a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift index e986ec03..352a6eb3 100644 --- a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift +++ b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift @@ -11,20 +11,21 @@ import ComposableArchitecture public protocol CalculateSettlementUseCaseProtocol { func execute( expenses: [Expense], - members: [TravelMember], currentUserId: String? ) -> SettlementCalculation } public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { - + public init() {} - + public func execute( expenses: [Expense], - members: [TravelMember], currentUserId: String? ) -> SettlementCalculation { + + // expenses에서 모든 멤버 추출 (payer + participants) + let members = extractMembers(from: expenses) // 1. 총 지출 금액 let totalExpenseAmount = expenses.reduce(0) { $0 + $1.convertedAmount } @@ -67,7 +68,7 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { let amountPerPerson = expense.convertedAmount / participantCount // 결제자는 전체 금액을 지불한 것으로 (+) - memberBalances[expense.payerId, default: 0] += expense.convertedAmount + memberBalances[expense.payer.id, default: 0] += expense.convertedAmount // 참여자들은 각자 분담금을 빚진 것으로 (-) for participant in expense.participants { @@ -114,7 +115,24 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { } // MARK: - Private Helper Methods - + + // expenses에서 모든 멤버 추출 (중복 제거) + private func extractMembers(from expenses: [Expense]) -> [TravelMember] { + var memberDict: [String: TravelMember] = [:] + + for expense in expenses { + // payer 추가 + memberDict[expense.payer.id] = expense.payer + + // participants 추가 + for participant in expense.participants { + memberDict[participant.id] = participant + } + } + + return Array(memberDict.values) + } + // 지급 예정 금액 (내가 빚진 사람들에게 갚아야 할 돈) private func calculatePaymentsToMake( myBalance: Double, @@ -215,7 +233,7 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { ) // 결제자에게 추가 - memberPaidExpenses[expense.payerId, default: []].append(expenseDetail) + memberPaidExpenses[expense.payer.id, default: []].append(expenseDetail) // 참여자들에게 추가 for participant in expense.participants { From 1930ee2508267a1e2230e1260296a7725a9bb979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 12 Dec 2025 20:51:23 +0900 Subject: [PATCH 4/4] =?UTF-8?q?style:=20ExpenseList,=20SaveExpense,=20Sett?= =?UTF-8?q?lementResult=20UI=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExpenseCardView 디자인 개선 - SaveExpense 화면 UI 정렬 및 로직 수정 - SettlementResult 화면 레이아웃 및 스타일 보정 --- .../Sources/Components/ExpenseCardView.swift | 2 +- .../Sources/SaveExpenseFeature.swift | 55 +++++++++---------- .../Sources/SettlementResultFeature.swift | 11 ++-- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/Features/ExpenseList/Sources/Components/ExpenseCardView.swift b/Features/ExpenseList/Sources/Components/ExpenseCardView.swift index 5909bbdc..d25a6fd1 100644 --- a/Features/ExpenseList/Sources/Components/ExpenseCardView.swift +++ b/Features/ExpenseList/Sources/Components/ExpenseCardView.swift @@ -49,7 +49,7 @@ public struct ExpenseCardView: View { Image(asset: .receipt) .resizable() .frame(width: 16, height: 16) - Text(expense.payerName) + Text(expense.payer.name) } // 구분선 (|) diff --git a/Features/SaveExpense/Sources/SaveExpenseFeature.swift b/Features/SaveExpense/Sources/SaveExpenseFeature.swift index 204acfe5..e75dfd7e 100644 --- a/Features/SaveExpense/Sources/SaveExpenseFeature.swift +++ b/Features/SaveExpense/Sources/SaveExpenseFeature.swift @@ -81,7 +81,7 @@ public struct SaveExpenseFeature { // ParticipantSelector 초기화 self.participantSelector = ParticipantSelectorFeature.State( availableParticipants: IdentifiedArray(uniqueElements: travel.members), - payer: travel.members.first { $0.id == expense.payerId }, + payer: expense.payer, participants: IdentifiedArray(uniqueElements: expense.participants) ) @@ -137,7 +137,7 @@ public struct SaveExpenseFeature { } // 결제자 변경 확인 - if participantSelector.payer?.id != original.payerId { + if participantSelector.payer?.id != original.payer.id { return true } @@ -332,42 +332,41 @@ extension SaveExpenseFeature { return .none } - let expense = Expense( - id: state.expenseId ?? UUID().uuidString, + let isEditMode = state.isEditMode + state.isLoading = true + + // 생성/수정 모두 ExpenseInput 사용 + let input = ExpenseInput( title: state.title, amount: amountValue, currency: state.baseCurrency, - convertedAmount: state.baseCurrency == "KRW" ? amountValue : amountValue * state.baseExchangeRate, expenseDate: state.expenseDate, category: category, payerId: payer.id, - payerName: payer.name, - participants: Array(state.participantSelector.participants) + participantIds: state.participantSelector.participants.map { $0.id } ) - let isEditMode = state.isEditMode - state.isLoading = true - return .run { [travelId = state.travelId] send in - do { - if isEditMode { - try await updateExpenseUseCase.execute(travelId: travelId, expense: expense) - } else { - let input = CreateExpenseInput( - title: expense.title, - amount: expense.amount, - currency: expense.currency, - convertedAmount: expense.convertedAmount, - expenseDate: expense.expenseDate, - category: expense.category, - payerId: expense.payerId, - payerName: expense.payerName, - participants: expense.participants - ) + if isEditMode { + // 수정 모드 + guard let expenseId = state.expenseId else { return .none } + + return .run { [travelId = state.travelId] send in + do { + try await updateExpenseUseCase.execute(travelId: travelId, expenseId: expenseId, input: input) + await send(.inner(.saveExpenseResponse(.success(())))) + } catch { + await send(.inner(.saveExpenseResponse(.failure(error)))) + } + } + } else { + // 생성 모드 + return .run { [travelId = state.travelId] send in + do { try await createExpenseUseCase.execute(travelId: travelId, input: input) + await send(.inner(.saveExpenseResponse(.success(())))) + } catch { + await send(.inner(.saveExpenseResponse(.failure(error)))) } - await send(.inner(.saveExpenseResponse(.success(())))) - } catch { - await send(.inner(.saveExpenseResponse(.failure(error)))) } } diff --git a/Features/SettlementResult/Sources/SettlementResultFeature.swift b/Features/SettlementResult/Sources/SettlementResultFeature.swift index 3ef4b585..17d1bfb6 100644 --- a/Features/SettlementResult/Sources/SettlementResultFeature.swift +++ b/Features/SettlementResult/Sources/SettlementResultFeature.swift @@ -108,13 +108,10 @@ public struct SettlementResultFeature { case .view(.onAppear): state.currentUserId = userId // 정산 계산 - if let travel = state.travel { - state.settlementCalculation = calculateSettlementUseCase.execute( - expenses: state.expenses, - members: travel.members, - currentUserId: state.currentUserId - ) - } + state.settlementCalculation = calculateSettlementUseCase.execute( + expenses: state.expenses, + currentUserId: state.currentUserId + ) return .none case .view(.backButtonTapped):