From 19e1ce2799bbd78a448e1b306563f209abf02eea 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: Wed, 10 Dec 2025 20:18:09 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=A7=80=EC=B6=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=EC=9E=90=20ID(payerId)=20=EB=88=84=EB=9D=BD=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Request/UpdateExpenseRequestDTO.swift | 2 + Domain/Tests/RecordExpenseUseCaseTests.swift | 38 ------------------- 2 files changed, 2 insertions(+), 38 deletions(-) delete mode 100644 Domain/Tests/RecordExpenseUseCaseTests.swift diff --git a/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift b/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift index 033934e1..0e7dc84a 100644 --- a/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift +++ b/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift @@ -14,6 +14,7 @@ public struct UpdateExpenseRequestDTO: Encodable { let currency: String let expenseDate: String let category: String + let payerId: String let participantIds: [String] } @@ -33,6 +34,7 @@ extension Expense { currency: currency, expenseDate: dateString, category: category.rawValue, + payerId: payerId, participantIds: participants.map { $0.id } ) } diff --git a/Domain/Tests/RecordExpenseUseCaseTests.swift b/Domain/Tests/RecordExpenseUseCaseTests.swift deleted file mode 100644 index 0591ad52..00000000 --- a/Domain/Tests/RecordExpenseUseCaseTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// RecordExpenseUseCaseTests.swift -// DomainTests -// -// Created by 홍석현 on 11/25/25. -// - -import Foundation -import Testing -@testable import Domain - -struct RecordExpenseUseCaseTests { - @Test("지출 기록 성공") - func recordExpenseSuccess() async throws { - let repository = MockExpenseRepository() - let useCase = RecordExpenseUseCase(repository: repository) - let id = "123" - let expense = Expense( - id: id, - title: "점심", - note: nil, - amount: 12_000, - currency: "KRW", - convertedAmount: 12_000, - expenseDate: Date(), - category: .foodAndDrink, - payerId: "user1", - payerName: "홍석현", - participants: [ - TravelMember(id: "user1", name: "홍석현", role: "owner") - ] - ) - - try await useCase.execute(expense: expense) - let saved = await repository.fetch(id: id) - #expect(saved?.title == "점심") - } -} From 73061ae9eee0fda8b0a8f97ddb1f3d01f0bf9ae6 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: Wed, 10 Dec 2025 20:18:25 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B3=84=EC=82=B0=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20UseCase=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CalculateSettlementUseCase.swift | 192 ++++++++++++++++++ .../CalculateSettlementUseCaseProtocol.swift | 68 +++++++ .../Application/LiveDependencies.swift | 3 + 3 files changed, 263 insertions(+) create mode 100644 Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift create mode 100644 Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift diff --git a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift new file mode 100644 index 00000000..0099b407 --- /dev/null +++ b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift @@ -0,0 +1,192 @@ +// +// CalculateSettlementUseCase.swift +// Domain +// +// Created by 홍석현 on 12/10/25. +// + +import Foundation +import ComposableArchitecture + +public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { + + public init() {} + + public func execute( + expenses: [Expense], + members: [TravelMember], + currentUserId: String? + ) -> SettlementCalculation { + + // 1. 총 지출 금액 + let totalExpenseAmount = expenses.reduce(0) { $0 + $1.convertedAmount } + + // 2. 내가 부담해야 할 금액 (내가 참여한 지출들의 분담금 합계) + let myShareAmount: Double + if let userId = currentUserId { + myShareAmount = expenses + .filter { expense in + expense.participants.contains(where: { $0.id == userId }) + } + .reduce(0) { sum, expense in + let participantCount = Double(expense.participants.count) + guard participantCount > 0 else { return sum } + return sum + (expense.convertedAmount / participantCount) + } + } else { + myShareAmount = 0 + } + + // 3. 인원수 + let totalPersonCount = members.count + + // 4. 1인 평균 지출 + let averagePerPerson = totalPersonCount > 0 ? totalExpenseAmount / Double(totalPersonCount) : 0 + + // 5. 각 멤버의 순 차액 계산 (Net Balance = Pay - Owe) + var memberBalances: [String: Double] = [:] + + // 모든 멤버 초기화 + for member in members { + memberBalances[member.id] = 0.0 + } + + // 각 지출에 대해 계산 + for expense in expenses { + let participantCount = Double(expense.participants.count) + guard participantCount > 0 else { continue } + + let amountPerPerson = expense.convertedAmount / participantCount + + // 결제자는 전체 금액을 지불한 것으로 (+) + memberBalances[expense.payerId, default: 0] += expense.convertedAmount + + // 참여자들은 각자 분담금을 빚진 것으로 (-) + for participant in expense.participants { + memberBalances[participant.id, default: 0] -= amountPerPerson + } + } + + // 6. 내 순 차액 + let myNetBalance = currentUserId.flatMap { memberBalances[$0] } ?? 0 + + // 7. 지급 예정 금액 계산 + let paymentsToMake = calculatePaymentsToMake( + myBalance: myNetBalance, + memberBalances: memberBalances, + members: members, + currentUserId: currentUserId + ) + + // 8. 수령 예정 금액 계산 + let paymentsToReceive = calculatePaymentsToReceive( + myBalance: myNetBalance, + memberBalances: memberBalances, + members: members, + currentUserId: currentUserId + ) + + return SettlementCalculation( + totalExpenseAmount: totalExpenseAmount, + myShareAmount: myShareAmount, + totalPersonCount: totalPersonCount, + averagePerPerson: averagePerPerson, + myNetBalance: myNetBalance, + memberBalances: memberBalances, + paymentsToMake: paymentsToMake, + paymentsToReceive: paymentsToReceive + ) + } + + // MARK: - Private Helper Methods + + // 지급 예정 금액 (내가 빚진 사람들에게 갚아야 할 돈) + private func calculatePaymentsToMake( + myBalance: Double, + memberBalances: [String: Double], + members: [TravelMember], + currentUserId: String? + ) -> [PaymentInfo] { + guard currentUserId != nil else { return [] } + guard myBalance < 0 else { return [] } // 내가 받을 돈이 있으면 지급할 것이 없음 + + // 양수 잔액을 가진 멤버들 (받을 돈이 있는 사람들) + let creditors = memberBalances + .filter { $0.value > 0 } + .sorted { $0.value > $1.value } // 많이 받을 사람부터 + + var payments: [PaymentInfo] = [] + var remainingDebt = abs(myBalance) + + for (memberId, creditAmount) in creditors { + guard remainingDebt > 0.01 else { break } // 소수점 오차 고려 + + let paymentAmount = min(remainingDebt, creditAmount) + + if let member = members.first(where: { $0.id == memberId }) { + payments.append(PaymentInfo( + id: UUID().uuidString, + memberId: memberId, + memberName: member.name, + amount: paymentAmount + )) + remainingDebt -= paymentAmount + } + } + + return payments + } + + // 수령 예정 금액 (나에게 빚진 사람들로부터 받을 돈) + private func calculatePaymentsToReceive( + myBalance: Double, + memberBalances: [String: Double], + members: [TravelMember], + currentUserId: String? + ) -> [PaymentInfo] { + guard currentUserId != nil else { return [] } + guard myBalance > 0 else { return [] } // 내가 빚진 돈이 있으면 받을 것이 없음 + + // 음수 잔액을 가진 멤버들 (빚진 사람들) + let debtors = memberBalances + .filter { $0.value < 0 } + .sorted { $0.value < $1.value } // 많이 빚진 사람부터 + + var receipts: [PaymentInfo] = [] + var remainingCredit = myBalance + + for (memberId, debtAmount) in debtors { + guard remainingCredit > 0.01 else { break } // 소수점 오차 고려 + + let receiptAmount = min(remainingCredit, abs(debtAmount)) + + if let member = members.first(where: { $0.id == memberId }) { + receipts.append(PaymentInfo( + id: UUID().uuidString, + memberId: memberId, + memberName: member.name, + amount: receiptAmount + )) + remainingCredit -= receiptAmount + } + } + + return receipts + } +} + +// MARK: - DependencyKey +public enum CalculateSettlementUseCaseDependencyKey: DependencyKey { + public static var liveValue: any CalculateSettlementUseCaseProtocol = CalculateSettlementUseCase() + + public static var testValue: any CalculateSettlementUseCaseProtocol = CalculateSettlementUseCase() + + public static var previewValue: any CalculateSettlementUseCaseProtocol = CalculateSettlementUseCase() +} + +public extension DependencyValues { + var calculateSettlementUseCase: any CalculateSettlementUseCaseProtocol { + get { self[CalculateSettlementUseCaseDependencyKey.self] } + set { self[CalculateSettlementUseCaseDependencyKey.self] = newValue } + } +} diff --git a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift new file mode 100644 index 00000000..80a0fa9c --- /dev/null +++ b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift @@ -0,0 +1,68 @@ +// +// CalculateSettlementUseCaseProtocol.swift +// Domain +// +// Created by 홍석현 on 12/10/25. +// + +import Foundation + +// 정산 계산 결과 +public struct SettlementCalculation: Equatable { + public let totalExpenseAmount: Double // 총 지출 금액 + public let myShareAmount: Double // 내가 부담해야 할 금액 (내가 참여한 지출들의 분담금 합계) + public let totalPersonCount: Int // 인원수 + public let averagePerPerson: Double // 1인 평균 지출 + public let myNetBalance: Double // 내 순 차액 (Pay - Owe) + public let memberBalances: [String: Double] // 각 멤버의 순 차액 + public let paymentsToMake: [PaymentInfo] // 지급 예정 금액 + public let paymentsToReceive: [PaymentInfo] // 수령 예정 금액 + + public init( + totalExpenseAmount: Double, + myShareAmount: Double, + totalPersonCount: Int, + averagePerPerson: Double, + myNetBalance: Double, + memberBalances: [String: Double], + paymentsToMake: [PaymentInfo], + paymentsToReceive: [PaymentInfo] + ) { + self.totalExpenseAmount = totalExpenseAmount + self.myShareAmount = myShareAmount + self.totalPersonCount = totalPersonCount + self.averagePerPerson = averagePerPerson + self.myNetBalance = myNetBalance + self.memberBalances = memberBalances + self.paymentsToMake = paymentsToMake + self.paymentsToReceive = paymentsToReceive + } +} + +// 지급/수령 정보 +public struct PaymentInfo: Equatable, Identifiable { + public let id: String + public let memberId: String + public let memberName: String + public let amount: Double + + public init( + id: String, + memberId: String, + memberName: String, + amount: Double + ) { + self.id = id + self.memberId = memberId + self.memberName = memberName + self.amount = amount + } +} + +public protocol CalculateSettlementUseCaseProtocol { + func execute( + expenses: [Expense], + members: [TravelMember], + currentUserId: String? + ) -> SettlementCalculation +} diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index c06af6a5..081b0ff6 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -67,7 +67,10 @@ public enum LiveDependencies { // Country & Exchange dependencies.fetchCountriesUseCase = FetchCountriesUseCase(repository: countryRepository) dependencies.fetchExchangeRateUseCase = FetchExchangeRateUseCase(repository: exchangeRateRepository) + + // Settlement dependencies.fetchSettlementUseCase = FetchSettlementUseCase(repository: settlementRepository) + dependencies.calculateSettlementUseCase = CalculateSettlementUseCase() } // MARK: - Factory Methods From 7a592a4f065fd8c7137ea83aef5d9211b0ba9f98 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: Wed, 10 Dec 2025 20:18:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B0=94=EC=9D=B8?= =?UTF-8?q?=EB=94=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/SettlementResultDemoApp.swift | 14 +- .../Sources/SettlementResultFeature.swift | 143 +++++------------- .../Sources/SettlementResultView.swift | 12 +- 3 files changed, 55 insertions(+), 114 deletions(-) diff --git a/Features/SettlementResult/Demo/Sources/SettlementResultDemoApp.swift b/Features/SettlementResult/Demo/Sources/SettlementResultDemoApp.swift index f193aa14..6a8f244f 100644 --- a/Features/SettlementResult/Demo/Sources/SettlementResultDemoApp.swift +++ b/Features/SettlementResult/Demo/Sources/SettlementResultDemoApp.swift @@ -15,9 +15,17 @@ struct SettlementResultDemoApp: App { var body: some Scene { WindowGroup { NavigationView { - SettlementResultView(store: Store(initialState: SettlementResultFeature.State(travelId: "travel_01"), reducer: { - SettlementResultFeature() - })) + SettlementResultView( + store: Store( + initialState: SettlementResultFeature.State( + travelId: "travel_id", + travel: .init(value: nil), + expenses: .init(value: []) + ), + reducer: { + SettlementResultFeature() + }) + ) } } } diff --git a/Features/SettlementResult/Sources/SettlementResultFeature.swift b/Features/SettlementResult/Sources/SettlementResultFeature.swift index 07502f79..2543f2e5 100644 --- a/Features/SettlementResult/Sources/SettlementResultFeature.swift +++ b/Features/SettlementResult/Sources/SettlementResultFeature.swift @@ -11,7 +11,7 @@ import ComposableArchitecture @Reducer public struct SettlementResultFeature { - @Dependency(\.fetchSettlementUseCase) var fetchSettlementUseCase + @Dependency(\.calculateSettlementUseCase) var calculateSettlementUseCase @Shared(.appStorage("userId")) var userId: String? = "" public init() {} @@ -23,38 +23,43 @@ public struct SettlementResultFeature { @Shared public var expenses: [Expense] public var currentUserId: String? - public var settlement: TravelSettlement? - public var isLoading: Bool = false @Presents public var alert: AlertState? + // 정산 계산 결과 + public var settlementCalculation: SettlementCalculation = SettlementCalculation( + totalExpenseAmount: 0, + myShareAmount: 0, + totalPersonCount: 0, + averagePerPerson: 0, + myNetBalance: 0, + memberBalances: [:], + paymentsToMake: [], + paymentsToReceive: [] + ) + + // 총 지출 금액 public var totalExpenseAmount: Int { - Int(expenses.reduce(0) { $0 + $1.convertedAmount }) + Int(settlementCalculation.totalExpenseAmount) } + // 내 부담 금액 (내가 실제로 부담해야 할 금액) public var myExpenseAmount: Int { - guard let userId = currentUserId else { return 0 } - return Int(expenses - .filter { $0.payerId == userId } - .reduce(0) { $0 + $1.convertedAmount }) + Int(settlementCalculation.myShareAmount) } + // 인원수 public var totalPersonCount: Int { - guard let travel = travel else { return 0 } - return travel.members.count + settlementCalculation.totalPersonCount } - public var paymentsToMake: [Settlement] { - guard let settlement = settlement else { return [] } - // 내가 지급해야 하는 정산 (내가 fromMember인 경우) - // TODO: 현재 사용자 ID 가져오기 - return settlement.recommendedSettlements.filter { $0.status == .pending } + // 지급 예정 금액 + public var paymentsToMake: [PaymentInfo] { + settlementCalculation.paymentsToMake } - public var paymentsToReceive: [Settlement] { - guard let settlement = settlement else { return [] } - // 내가 받아야 하는 정산 (내가 toMember인 경우) - // TODO: 현재 사용자 ID 가져오기 - return settlement.recommendedSettlements.filter { $0.status == .pending } + // 수령 예정 금액 + public var paymentsToReceive: [PaymentInfo] { + settlementCalculation.paymentsToReceive } public init( @@ -72,8 +77,6 @@ public struct SettlementResultFeature { public enum Action: BindableAction, ViewAction { case binding(BindingAction) case view(ViewAction) - case inner(InnerAction) - case async(AsyncAction) case scope(ScopeAction) @CasePathable @@ -82,21 +85,11 @@ public struct SettlementResultFeature { case backButtonTapped } - @CasePathable - public enum InnerAction { - case settlementResponse(Result) - } - - @CasePathable - public enum AsyncAction { - case fetchData - } - @CasePathable public enum ScopeAction { case alert(PresentationAction) } - + @CasePathable public enum AlertAction { case confirmTapped @@ -108,79 +101,25 @@ public struct SettlementResultFeature { Reduce { state, action in switch action { - case .view(let viewAction): - return handleViewAction(state: &state, action: viewAction) - case .inner(let innerAction): - return handleInnerAction(state: &state, action: innerAction) - case .async(let asyncAction): - return handleAsyncAction(state: &state, action: asyncAction) - case .scope(let scopeAction): - return handleScopeAction(state: &state, action: scopeAction) - case .binding: - return .none - } - } - .ifLet(\.$alert, action: \.scope.alert) - } -} - -extension SettlementResultFeature { - // MARK: - View Action Handler - private func handleViewAction(state: inout State, action: Action.ViewAction) -> Effect { - switch action { - case .onAppear: - state.currentUserId = userId - return .send(.async(.fetchData)) - - case .backButtonTapped: - return .none - } - } - - // MARK: - Inner Action Handler - private func handleInnerAction(state: inout State, action: Action.InnerAction) -> Effect { - switch action { - case let .settlementResponse(.success(settlement)): - state.settlement = settlement - state.isLoading = false - return .none - - case let .settlementResponse(.failure(error)): - state.isLoading = false - state.alert = AlertState { - TextState("오류") - } actions: { - ButtonState(action: .confirmTapped) { - TextState("확인") + case .view(.onAppear): + state.currentUserId = userId + // 정산 계산 + if let travel = state.travel { + state.settlementCalculation = calculateSettlementUseCase.execute( + expenses: state.expenses, + members: travel.members, + currentUserId: state.currentUserId + ) } - } message: { - TextState("정산 정보를 불러오는데 실패했습니다.\n\(error.localizedDescription)") - } - return .none - } - } + return .none - // MARK: - Async Action Handler - private func handleAsyncAction(state: inout State, action: Action.AsyncAction) -> Effect { - switch action { - case .fetchData: - let travelId = state.travelId - state.isLoading = true - return .run { send in - let settlementResult = await Result { - try await fetchSettlementUseCase.execute(travelId: travelId) - } + case .view(.backButtonTapped): + return .none - await send(.inner(.settlementResponse(settlementResult))) + case .scope, .binding: + return .none } } - } - - // MARK: - Scope Action Handler - private func handleScopeAction(state: inout State, action: Action.ScopeAction) -> Effect { - switch action { - case .alert: - return .none - } + .ifLet(\.$alert, action: \.scope.alert) } } diff --git a/Features/SettlementResult/Sources/SettlementResultView.swift b/Features/SettlementResult/Sources/SettlementResultView.swift index 026204e4..5aba4625 100644 --- a/Features/SettlementResult/Sources/SettlementResultView.swift +++ b/Features/SettlementResult/Sources/SettlementResultView.swift @@ -35,18 +35,18 @@ public struct SettlementResultView: View { totalAmount: store.paymentsToMake.reduce(0) { $0 + $1.amount }, amountColor: .red, payments: store.paymentsToMake.map { - PaymentItem(id: $0.id, name: $0.toMemberName, amount: Int($0.amount)) + PaymentItem(id: $0.id, name: $0.memberName, amount: Int($0.amount)) } ) } - + if !store.paymentsToReceive.isEmpty { PaymentSectionView( title: "수령 예정 금액", totalAmount: store.paymentsToReceive.reduce(0) { $0 + $1.amount }, amountColor: .primary500, payments: store.paymentsToReceive.map { - PaymentItem(id: $0.id, name: $0.fromMemberName, amount: Int($0.amount)) + PaymentItem(id: $0.id, name: $0.memberName, amount: Int($0.amount)) } ) } @@ -64,12 +64,6 @@ public struct SettlementResultView: View { } } .padding(.horizontal, 16) - .overlay(content: { - if store.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - }) .background(Color.primary50) .scrollIndicators(.hidden) .onAppear { From 4ede873a27ba1fe418f489847c84bb5ab14d4f9c 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: Wed, 10 Dec 2025 20:19:21 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EC=A0=95=EC=82=B0=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20UseCase=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A7=80=EC=B6=9C=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Domain/Tests/ExpenseValidationTests.swift | 6 - .../CalculateSettlementUseCaseTests.swift | 716 ++++++++++++++++++ 2 files changed, 716 insertions(+), 6 deletions(-) create mode 100644 Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift diff --git a/Domain/Tests/ExpenseValidationTests.swift b/Domain/Tests/ExpenseValidationTests.swift index 1a09d585..7da1e818 100644 --- a/Domain/Tests/ExpenseValidationTests.swift +++ b/Domain/Tests/ExpenseValidationTests.swift @@ -17,7 +17,6 @@ struct ExpenseValidationTests { let expense = Expense( id: "1", title: "점심", - note: nil, amount: -1000, // ❌ 음수 currency: "KRW", convertedAmount: -1000, @@ -40,7 +39,6 @@ struct ExpenseValidationTests { let expense = Expense( id: "1", title: " ", // ❌ 공백만 - note: nil, amount: 1000, currency: "KRW", convertedAmount: 1000, @@ -65,7 +63,6 @@ struct ExpenseValidationTests { let expense = Expense( id: "1", title: "점심", - note: nil, amount: 12_000, currency: "KRW", convertedAmount: 12_000, @@ -88,7 +85,6 @@ struct ExpenseValidationTests { let expense = Expense( id: "1", title: "점심", - note: nil, amount: 12_000, currency: "KRW", convertedAmount: 12_000, @@ -109,7 +105,6 @@ struct ExpenseValidationTests { let expense = Expense( id: "1", title: "점심", - note: nil, amount: 12_000, currency: "KRW", convertedAmount: 12_000, @@ -133,7 +128,6 @@ struct ExpenseValidationTests { let expense = Expense( id: "1", title: "점심", - note: "회식", amount: 50_000, currency: "KRW", convertedAmount: 50_000, diff --git a/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift b/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift new file mode 100644 index 00000000..7d5dea11 --- /dev/null +++ b/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift @@ -0,0 +1,716 @@ +// +// CalculateSettlementUseCaseTests.swift +// Domain +// +// Created by 홍석현 on 12/10/25. +// + +import Testing +@testable import Domain +import Foundation + +@Suite("정산 계산 UseCase 테스트") +struct CalculateSettlementUseCaseTests { + + let useCase = CalculateSettlementUseCase() + + // MARK: - Test Data + let members = [ + TravelMember(id: "user1", name: "홍석현", role: "owner"), + TravelMember(id: "user2", name: "김철수", role: "member"), + TravelMember(id: "user3", name: "이영희", role: "member") + ] + + // MARK: - Tests + + @Test("지출이 없을 때 모든 값이 0") + func noExpenses() { + // given + let expenses: [Expense] = [] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + #expect(result.totalExpenseAmount == 0) + #expect(result.myShareAmount == 0) + #expect(result.totalPersonCount == 3) + #expect(result.averagePerPerson == 0) + #expect(result.myNetBalance == 0) + #expect(result.paymentsToMake.isEmpty) + #expect(result.paymentsToReceive.isEmpty) + } + + @Test("총 지출 금액 계산 - 단일 지출") + func totalExpenseAmount_singleExpense() { + // given + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 100_000, + currency: "KRW", + convertedAmount: 100_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + #expect(result.totalExpenseAmount == 100_000) + #expect(Int(result.myShareAmount) == 100_000 / 3) + } + + @Test("총 지출 금액 계산 - 여러 지출") + func totalExpenseAmount_multipleExpenses() throws { + // given + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 100_000, + currency: "KRW", + convertedAmount: 100_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members + ), + Expense( + id: "2", + title: "식사", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user2", + payerName: "김철수", + participants: members + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + #expect(result.totalExpenseAmount == 130_000) + let 개인당지불해야할돈 = (100_000 + 30_000) / 3 + // 내가 지출할 금액 100_000 / 3 + 30_000 / 3 + #expect(Int(result.myShareAmount) == 개인당지불해야할돈) + + #expect(result.paymentsToReceive.count == 2) + #expect(result.paymentsToReceive.contains(where: { $0.memberId == "user2" })) + #expect(result.paymentsToReceive.contains(where: { $0.memberId == "user3" })) + let 철수에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "김철수" })?.amount) + #expect(Int(철수에게받을돈) == abs(개인당지불해야할돈 - 30_000)) // 받아야할 돈 - 철수가 지불한 돈 + let 영희에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "이영희" })?.amount) + #expect(Int(영희에게받을돈) == (개인당지불해야할돈)) + #expect(result.paymentsToMake.isEmpty) + } + + @Test("내 부담 금액 계산 - 모든 지출에 참여") + func myShareAmount_allExpenses() { + // given + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 90_000, + currency: "KRW", + convertedAmount: 90_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members // 3명 + ), + Expense( + id: "2", + title: "식사", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user2", + payerName: "김철수", + participants: members // 3명 + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + // 내 부담금 = 90,000 / 3 + 30,000 / 3 = 30,000 + 10,000 = 40,000 + #expect(result.myShareAmount == 40_000) + } + + @Test("내 부담 금액 계산 - 일부 지출만 참여") + func myShareAmount_partialExpenses() { + // given + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 90_000, + currency: "KRW", + convertedAmount: 90_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members // 3명 - 참여함 + ), + Expense( + id: "2", + title: "식사", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user2", + payerName: "김철수", + participants: [ + TravelMember(id: "user2", name: "김철수", role: "member"), + TravelMember(id: "user3", name: "이영희", role: "member") + ] // 2명 - 참여 안함 + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + // 내 부담금 = 90,000 / 3 = 30,000 (두 번째 지출은 참여 안함) + #expect(result.myShareAmount == 30_000) + } + + @Test("1인 평균 지출 계산") + func averagePerPerson() { + // given + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 90_000, + currency: "KRW", + convertedAmount: 90_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + // 1인 평균 = 90,000 / 3 = 30,000 + #expect(result.averagePerPerson == 30_000) + } + + @Test("모든 멤버가 균등하게 결제한 경우 - 정산 없음") + func allMembersPayEqually() { + // given - 각자 30,000원씩 결제 (총 90,000원) + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members + ), + Expense( + id: "2", + title: "식사", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user2", + payerName: "김철수", + participants: members + ), + Expense( + id: "3", + title: "교통", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .transportation, + payerId: "user3", + payerName: "이영희", + participants: members + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + let 개인당지불해야할돈 = (30_000 * 3) / 3 + #expect(result.totalExpenseAmount == 90_000) + #expect(Int(result.myShareAmount) == 개인당지불해야할돈) + #expect(Int(result.myNetBalance) == 0) // 균등하게 냈으므로 순 차액 0 + #expect(result.paymentsToMake.isEmpty) + #expect(result.paymentsToReceive.isEmpty) + } + + @Test("내가 아무것도 결제하지 않은 경우 - 빚만 있음") + func iDidNotPayAnything() throws { + // given - 나는 참여만 했고 결제는 안함 + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 60_000, + currency: "KRW", + convertedAmount: 60_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user2", + payerName: "김철수", + participants: members + ), + Expense( + id: "2", + title: "식사", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user3", + payerName: "이영희", + participants: members + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + let 개인당지불해야할돈 = (60_000 + 30_000) / 3 + #expect(result.totalExpenseAmount == 90_000) + #expect(Int(result.myShareAmount) == 개인당지불해야할돈) + #expect(Int(result.myNetBalance) == -개인당지불해야할돈) // 내가 낸 돈이 없으므로 음수 + + // 내가 줘야할 돈 + // 철수: Pay(60,000) - Owe(30,000) = +30,000 + // 영희: Pay(30,000) - Owe(30,000) = 0 + // 따라서 철수에게만 30,000원 지급 + #expect(result.paymentsToMake.count == 1) + let 철수에게줄돈 = try #require(result.paymentsToMake.first(where: { $0.memberName == "김철수" })?.amount) + #expect(Int(철수에게줄돈) == 30_000) // 철수의 net balance가 +30,000 + + // 받을 돈 없음 + #expect(result.paymentsToReceive.isEmpty) + } + + @Test("내가 모든 것을 결제한 경우 - 받을 돈만 있음") + func iPaidEverything() throws { + // given - 모든 지출을 내가 결제 + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 60_000, + currency: "KRW", + convertedAmount: 60_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members + ), + Expense( + id: "2", + title: "식사", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user1", + payerName: "홍석현", + participants: members + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + let 개인당지불해야할돈 = (60_000 + 30_000) / 3 + #expect(result.totalExpenseAmount == 90_000) + #expect(Int(result.myShareAmount) == 개인당지불해야할돈) + #expect(Int(result.myNetBalance) == 90_000 - 개인당지불해야할돈) // 내가 모두 냈으므로 양수 + + // 받을 돈 + #expect(result.paymentsToReceive.count == 2) + let 철수에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "김철수" })?.amount) + #expect(Int(철수에게받을돈) == 개인당지불해야할돈) + let 영희에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "이영희" })?.amount) + #expect(Int(영희에게받을돈) == 개인당지불해야할돈) + + // 줄 돈 없음 + #expect(result.paymentsToMake.isEmpty) + } + + @Test("일부 지출에만 참여 - 참여하지 않은 지출은 제외") + func partialParticipation() throws { + // given + let expenses = [ + Expense( + id: "1", + title: "호텔 (3명 모두 참여)", + amount: 90_000, + currency: "KRW", + convertedAmount: 90_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members // 3명 참여 + ), + Expense( + id: "2", + title: "술집 (나는 불참)", + amount: 60_000, + currency: "KRW", + convertedAmount: 60_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user2", + payerName: "김철수", + participants: [ // 나(user1) 제외 + TravelMember(id: "user2", name: "김철수", role: "member"), + TravelMember(id: "user3", name: "이영희", role: "member") + ] + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + #expect(result.totalExpenseAmount == 150_000) + // 내 부담금 = 90,000 / 3 = 30,000 (술집은 불참이므로 제외) + #expect(Int(result.myShareAmount) == 30_000) + // 내 순 차액 = 90,000(결제) - 30,000(부담) = +60,000 + #expect(Int(result.myNetBalance) == 60_000) + + // 받을 돈 + // 철수: Pay(60,000) - Owe(90,000/3 + 60,000/2) = 60,000 - 60,000 = 0 (정산 대상 아님) + // 영희: Pay(0) - Owe(90,000/3 + 60,000/2) = 0 - 60,000 = -60,000 + #expect(result.paymentsToReceive.count == 1) + let 영희에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "이영희" })?.amount) + #expect(Int(영희에게받을돈) == 60_000) + + #expect(result.paymentsToMake.isEmpty) + } + + @Test("결제자가 여러 번 바뀌는 복잡한 시나리오") + func complexPayerChanges() throws { + // given - 5개의 지출, 결제자가 계속 변경됨 + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 120_000, + currency: "KRW", + convertedAmount: 120_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members + ), + Expense( + id: "2", + title: "아침식사", + amount: 30_000, + currency: "KRW", + convertedAmount: 30_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user2", + payerName: "김철수", + participants: members + ), + Expense( + id: "3", + title: "점심식사", + amount: 45_000, + currency: "KRW", + convertedAmount: 45_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user3", + payerName: "이영희", + participants: members + ), + Expense( + id: "4", + title: "저녁식사", + amount: 60_000, + currency: "KRW", + convertedAmount: 60_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user1", + payerName: "홍석현", + participants: members + ), + Expense( + id: "5", + title: "교통비", + amount: 45_000, + currency: "KRW", + convertedAmount: 45_000, + expenseDate: Date(), + category: .transportation, + payerId: "user2", + payerName: "김철수", + participants: members + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + let 총지출 = 120_000 + 30_000 + 45_000 + 60_000 + 45_000 + let 개인당지불해야할돈 = 총지출 / 3 + + #expect(result.totalExpenseAmount == Double(총지출)) + #expect(Int(result.myShareAmount) == 개인당지불해야할돈) + + // 내가 결제한 금액: 120,000 + 60,000 = 180,000 + // 내 부담금: 300,000 / 3 = 100,000 + // 순 차액: +80,000 + #expect(Int(result.myNetBalance) == 80_000) + + // 받을 돈 + #expect(result.paymentsToReceive.count == 2) + + // 철수: Pay(30,000 + 45,000 = 75,000) - Owe(100,000) = -25,000 + let 철수에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "김철수" })?.amount) + #expect(Int(철수에게받을돈) == 25_000) + + // 영희: Pay(45,000) - Owe(100,000) = -55,000 + let 영희에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "이영희" })?.amount) + #expect(Int(영희에게받을돈) == 55_000) + + #expect(result.paymentsToMake.isEmpty) + } + + @Test("2명만 참여하는 지출이 섞인 경우") + func mixedParticipantCounts() throws { + // given + let expenses = [ + Expense( + id: "1", + title: "호텔 (3명)", + amount: 90_000, + currency: "KRW", + convertedAmount: 90_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user1", + payerName: "홍석현", + participants: members // 3명 + ), + Expense( + id: "2", + title: "나와 철수 식사 (2명)", + amount: 40_000, + currency: "KRW", + convertedAmount: 40_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user2", + payerName: "김철수", + participants: [ // 2명만 + TravelMember(id: "user1", name: "홍석현", role: "owner"), + TravelMember(id: "user2", name: "김철수", role: "member") + ] + ), + Expense( + id: "3", + title: "철수와 영희 술 (2명)", + amount: 50_000, + currency: "KRW", + convertedAmount: 50_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user3", + payerName: "이영희", + participants: [ // 나 제외 + TravelMember(id: "user2", name: "김철수", role: "member"), + TravelMember(id: "user3", name: "이영희", role: "member") + ] + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + #expect(result.totalExpenseAmount == 180_000) + // 내 부담금 = 90,000 / 3 + 40,000 / 2 = 30,000 + 20,000 = 50,000 + #expect(Int(result.myShareAmount) == 50_000) + // 내 순 차액 = 90,000(결제) - 50,000(부담) = +40,000 + #expect(Int(result.myNetBalance) == 40_000) + + // 받을 돈 + #expect(result.paymentsToReceive.count == 2) + + // 철수: Pay(40,000) - Owe(90,000/3 + 40,000/2 + 50,000/2) = 40,000 - 75,000 = -35,000 + let 철수에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "김철수" })?.amount) + #expect(Int(철수에게받을돈) == 35_000) + + // 영희: Pay(50,000) - Owe(90,000/3 + 50,000/2) = 50,000 - 55,000 = -5,000 + let 영희에게받을돈 = try #require(result.paymentsToReceive.first(where: { $0.memberName == "이영희" })?.amount) + #expect(Int(영희에게받을돈) == 5_000) + + #expect(result.paymentsToMake.isEmpty) + } + + @Test("내가 다른 사람들보다 적게 낸 경우") + func iPaidLessThanOthers() throws { + // given - 나는 작은 금액만 결제 + let expenses = [ + Expense( + id: "1", + title: "호텔", + amount: 120_000, + currency: "KRW", + convertedAmount: 120_000, + expenseDate: Date(), + category: .accommodation, + payerId: "user2", + payerName: "김철수", + participants: members + ), + Expense( + id: "2", + title: "식사", + amount: 60_000, + currency: "KRW", + convertedAmount: 60_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user3", + payerName: "이영희", + participants: members + ), + Expense( + id: "3", + title: "커피 (내가 결제)", + amount: 12_000, + currency: "KRW", + convertedAmount: 12_000, + expenseDate: Date(), + category: .foodAndDrink, + payerId: "user1", + payerName: "홍석현", + participants: members + ) + ] + + // when + let result = useCase.execute( + expenses: expenses, + members: members, + currentUserId: "user1" + ) + + // then + let 총지출 = 120_000 + 60_000 + 12_000 + let 개인당지불해야할돈 = 총지출 / 3 + + #expect(result.totalExpenseAmount == Double(총지출)) + #expect(Int(result.myShareAmount) == 개인당지불해야할돈) + // 순 차액 = 12,000 - 64,000 = -52,000 + #expect(Int(result.myNetBalance) == -52_000) + + // 줄 돈 + // 철수: Pay(120,000) - Owe(64,000) = +56,000 (받을 돈) + // 영희: Pay(60,000) - Owe(64,000) = -4,000 (빚) + // 따라서 철수에게만 52,000원 지급 + #expect(result.paymentsToMake.count == 1) + + let 철수에게줄돈 = try #require(result.paymentsToMake.first(where: { $0.memberName == "김철수" })?.amount) + #expect(Int(철수에게줄돈) == 52_000) + + #expect(result.paymentsToReceive.isEmpty) + } +}