Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public struct UpdateExpenseRequestDTO: Encodable {
let currency: String
let expenseDate: String
let category: String
let payerId: String
let participantIds: [String]
}

Expand All @@ -33,6 +34,7 @@ extension Expense {
currency: currency,
expenseDate: dateString,
category: category.rawValue,
payerId: payerId,
participantIds: participants.map { $0.id }
)
}
Expand Down
192 changes: 192 additions & 0 deletions Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 0 additions & 6 deletions Domain/Tests/ExpenseValidationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ struct ExpenseValidationTests {
let expense = Expense(
id: "1",
title: "점심",
note: nil,
amount: -1000, // ❌ 음수
currency: "KRW",
convertedAmount: -1000,
Expand All @@ -40,7 +39,6 @@ struct ExpenseValidationTests {
let expense = Expense(
id: "1",
title: " ", // ❌ 공백만
note: nil,
amount: 1000,
currency: "KRW",
convertedAmount: 1000,
Expand All @@ -65,7 +63,6 @@ struct ExpenseValidationTests {
let expense = Expense(
id: "1",
title: "점심",
note: nil,
amount: 12_000,
currency: "KRW",
convertedAmount: 12_000,
Expand All @@ -88,7 +85,6 @@ struct ExpenseValidationTests {
let expense = Expense(
id: "1",
title: "점심",
note: nil,
amount: 12_000,
currency: "KRW",
convertedAmount: 12_000,
Expand All @@ -109,7 +105,6 @@ struct ExpenseValidationTests {
let expense = Expense(
id: "1",
title: "점심",
note: nil,
amount: 12_000,
currency: "KRW",
convertedAmount: 12_000,
Expand All @@ -133,7 +128,6 @@ struct ExpenseValidationTests {
let expense = Expense(
id: "1",
title: "점심",
note: "회식",
amount: 50_000,
currency: "KRW",
convertedAmount: 50_000,
Expand Down
38 changes: 0 additions & 38 deletions Domain/Tests/RecordExpenseUseCaseTests.swift

This file was deleted.

Loading
Loading