Skip to content
Merged
30 changes: 30 additions & 0 deletions Data/Sources/DTO/Expense/Local/ExpenseCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// ExpenseCache.swift
// Data
//
// Created by 홍석현 on 12/9/25.
//

import Foundation

public struct ExpenseCache: Codable {
let travelId: String
let expenses: [TravelExpenseResponseDTO.ExpenseDTO]
let cachedAt: Date
let expiredAt: Date

var isExpired: Bool {
Date() > expiredAt
}

init(
travelId: String,
expenses: [TravelExpenseResponseDTO.ExpenseDTO],
) {
self.travelId = travelId
self.expenses = expenses
self.cachedAt = .now
self.expiredAt = .now.addingTimeInterval(3600)
}
}

10 changes: 7 additions & 3 deletions Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ public struct CreateExpenseRequestDTO: Encodable {

extension Expense {
func toCreateRequestDTO() -> CreateExpenseRequestDTO {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withFullDate]
// 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ public struct UpdateExpenseRequestDTO: Encodable {

extension Expense {
func toUpdateRequestDTO() -> UpdateExpenseRequestDTO {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withFullDate]
// 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(
Expand Down
10 changes: 5 additions & 5 deletions Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public struct TravelExpenseResponseDTO: Decodable {
let limit: Int
let items: [ExpenseDTO]

struct ExpenseDTO: Decodable {
struct ExpenseDTO: Codable {
let id: String
let title: String
let amount: Double
Expand All @@ -27,7 +27,7 @@ public struct TravelExpenseResponseDTO: Decodable {
let authorId: String
let participants: [ParticipantDTO]

struct ParticipantDTO: Decodable {
struct ParticipantDTO: Codable {
let memberId: String
let name: String

Expand All @@ -41,12 +41,12 @@ public struct TravelExpenseResponseDTO: Decodable {
}

func toDomain() -> Expense? {
// yyyy-MM-dd 날짜 파싱
// yyyy-MM-dd 날짜 파싱 (서버에서 받은 날짜 그대로 사용)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
dateFormatter.calendar = Calendar(identifier: .gregorian)
dateFormatter.locale = Locale(identifier: "ko_KR")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
// Plain date는 timezone 변환하지 않음 (기본값 = TimeZone.current)

guard let date = dateFormatter.date(from: expenseDate) else {
return nil
Expand Down
104 changes: 104 additions & 0 deletions Data/Sources/DataSource/Local/ExpenseLocalDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//
// ExpenseLocalDataSource.swift
// Data
//
// Created by 홍석현 on 12/9/25.
//

import Foundation
import Domain

public enum ExpenseCacheError: Error {
case cacheDirectoryUnavailable
case fileNotFound
case dataCorrupted
}

public protocol ExpenseLocalDataSourceProtocol {
func loadCachedExpenses(_ travelId: String) async throws -> ExpenseCache
func saveCachedExpenses(_ cache: ExpenseCache) async throws
}

public actor ExpenseLocalDataSource: ExpenseLocalDataSourceProtocol {
private let fileManager = FileManager.default
private lazy var cacheDirectory: URL? = {
guard var cachesURL = fileManager.urls(
for: .cachesDirectory,
in: .userDomainMask
).first else {
return nil
}
cachesURL.append(
path: "expenses",
directoryHint: .isDirectory
)
try? fileManager.createDirectory(
at: cachesURL,
withIntermediateDirectories: true
)
return cachesURL
}()
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}()

private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()


public init() {}

private func cacheURL(for travelId: String) throws -> URL {
guard var directory = cacheDirectory else {
throw ExpenseCacheError.cacheDirectoryUnavailable
}

directory.append(path: "travel_\(travelId).json", directoryHint: .notDirectory)
return directory
}

public func loadCachedExpenses(_ travelId: String) async throws -> ExpenseCache {
let url = try cacheURL(for: travelId)

guard fileManager.fileExists(atPath: url.path()) else {
throw ExpenseCacheError.cacheDirectoryUnavailable
}

do {
let data = try Data(contentsOf: url)
let cache = try decoder.decode(ExpenseCache.self, from: data)

if cache.isExpired {
clearCache(travelId)
throw ExpenseCacheError.dataCorrupted
}
return cache
} catch {
try? fileManager.removeItem(at: url)
throw ExpenseCacheError.dataCorrupted
}
}

public func saveCachedExpenses(_ cache: ExpenseCache) async throws {
let url = try cacheURL(for: cache.travelId)
let data = try encoder.encode(cache)

try data.write(to: url, options: [.atomic])
}

private func clearCache(_ travelId: String) {
guard let directory = cacheDirectory else { return }
try? fileManager.removeItem(at: directory)

// 디렉토리 재생성
try? fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true
)
}
}
4 changes: 2 additions & 2 deletions Data/Sources/DataSource/Remote/ExpenseRemoteDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public struct ExpenseRemoteDataSource: ExpenseRemoteDataSourceProtocol {

private let provider: MoyaProvider<ExpenseAPI>

public init(provider: MoyaProvider<ExpenseAPI> = MoyaProvider<ExpenseAPI>()) {
public init(provider: MoyaProvider<ExpenseAPI> = .default) {
self.provider = provider
}

Expand Down Expand Up @@ -75,7 +75,7 @@ extension ExpenseAPI: BaseTargetType {
return "/\(travelId)/expenses"
case .createExpense(let travelId, _):
return "/\(travelId)/expenses"
case .updateExpense(let travelId, let expenseId, let _):
case .updateExpense(let travelId, let expenseId, _):
return "/\(travelId)/expenses/\(expenseId)"
case .deleteExpense(let travelId, let expenseId):
return "/\(travelId)/expenses/\(expenseId)"
Expand Down
76 changes: 76 additions & 0 deletions Data/Sources/Repository/Expense/ExpenseRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// ExpenseRepository.swift
// Data
//
// Created by 홍석현 on 11/28/25.
//

import Foundation
import Domain

public final class ExpenseRepository: ExpenseRepositoryProtocol {

private let remote: ExpenseRemoteDataSourceProtocol
private let local: ExpenseLocalDataSourceProtocol

public init(
remote: ExpenseRemoteDataSourceProtocol,
local: ExpenseLocalDataSourceProtocol
) {
self.remote = remote
self.local = local
}

public func fetchTravelExpenses(
travelId: String,
page: Int,
limit: Int
) -> AsyncStream<Result<[Expense], Error>> {
AsyncStream { continuation in
Task {
// 1. 캐시 데이터
if let cached = try? await local.loadCachedExpenses(travelId) {
let expense = cached.expenses.compactMap { $0.toDomain() }
continuation.yield(.success(expense))
}

// 2. 네트워크
do {
let responseDTO = try await remote.fetchTravelExpenses(
travelId: travelId,
page: page,
limit: limit
)
let expenses = responseDTO.items.compactMap { $0.toDomain() }

Task.detached { [weak self] in
let cache = ExpenseCache(
travelId: travelId,
expenses: responseDTO.items
)

try? await self?.local.saveCachedExpenses(cache)
}
continuation.yield(.success(expenses))
} catch {
continuation.yield(.failure(error))
}
continuation.finish()
}
}
}

public func save(travelId: String, expense: Expense) async throws {
let requestDTO = expense.toCreateRequestDTO()
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 delete(travelId: String, expenseId: String) async throws {
try await remote.deleteExpense(travelId: travelId, expenseId: expenseId)
}
}
46 changes: 0 additions & 46 deletions Data/Sources/Repository/ExpenseRepository.swift

This file was deleted.

2 changes: 1 addition & 1 deletion Domain/Sources/Repository/ExpenseRepositoryProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Dependencies

public protocol ExpenseRepositoryProtocol {
// 여행의 지출 내역 조회
func fetchTravelExpenses(travelId: String, page: Int, limit: Int) async throws -> [Expense]
func fetchTravelExpenses(travelId: String, page: Int, limit: Int) -> AsyncStream<Result<[Expense], Error>>

// 지출 내역 저장
func save(travelId: String, expense: Expense) async throws
Expand Down
12 changes: 9 additions & 3 deletions Domain/Sources/Repository/Mock/MockExpenseRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Foundation

final public actor MockExpenseRepository: ExpenseRepositoryProtocol {

private var storage: [String: Expense] = [:]
private var shouldFailSave = false
private var shouldFailUpdate = false
Expand All @@ -34,12 +35,17 @@ final public actor MockExpenseRepository: ExpenseRepositoryProtocol {
saveErrorReason = nil
}

public func fetchTravelExpenses(
nonisolated public func fetchTravelExpenses(
travelId: String,
page: Int,
limit: Int
) async throws -> [Expense] {
return Expense.mockList
) -> AsyncStream<Result<[Expense], any Error>> {
AsyncStream { continuation in
Task {
continuation.yield(.success(Expense.mockList))
continuation.finish()
}
}
}

public func save(
Expand Down
Loading
Loading