From a6ce5f47d4e402efcff8469ed7502e9b5b672fdf Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 23 Aug 2026 09:27:54 +0900 Subject: [PATCH 01/24] chore: add applink url --- Projects/App/Resources/App.entitlements | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Projects/App/Resources/App.entitlements b/Projects/App/Resources/App.entitlements index 4ea3fcaa..a8cfd6de 100644 --- a/Projects/App/Resources/App.entitlements +++ b/Projects/App/Resources/App.entitlements @@ -2,6 +2,11 @@ + com.apple.developer.associated-domains + + applinks:dev.moneymong.site + applinks:prod.moneymong.site + com.apple.developer.applesignin Default From 9fdd7d2f6d34702c6c2028e79037aa44c2159378 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 23 Aug 2026 09:40:18 +0900 Subject: [PATCH 02/24] refactor: generalize DeepLinkManager.setDestination to setQuery --- .../Base/Sources/DeepLinkManager.swift | 22 +++++-------------- .../Main/Sources/MainTapViewController.swift | 10 ++++----- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/Projects/Feature/Base/Sources/DeepLinkManager.swift b/Projects/Feature/Base/Sources/DeepLinkManager.swift index f482886a..2950f509 100644 --- a/Projects/Feature/Base/Sources/DeepLinkManager.swift +++ b/Projects/Feature/Base/Sources/DeepLinkManager.swift @@ -2,30 +2,20 @@ import Foundation public struct DeepLinkManager { - public static var destination: [String : Any]? + public static var query: [String : Any]? // CreateLedger, LedgerDetail - public static func setDestination(_ urlString: String, agencyID: Int?) { - let query = urlString.replacingOccurrences(of: "widget://", with: "") - - guard let agencyID else { return } - - destination = [ - "query": query, - "agencyID": agencyID - ] + public static func setQuery(_ query: [String : Any], notiName: Notification.Name) { + self.query = query NotificationCenter.default.post( - name: .init("deeplink"), + name: notiName, object: nil, - userInfo: [ - "query": query, - "agencyID": agencyID - ] + userInfo: query ) } public static func clear() { - destination = nil + query = nil } } diff --git a/Projects/Feature/Main/Sources/MainTapViewController.swift b/Projects/Feature/Main/Sources/MainTapViewController.swift index 1ae9eb50..d1c9f948 100644 --- a/Projects/Feature/Main/Sources/MainTapViewController.swift +++ b/Projects/Feature/Main/Sources/MainTapViewController.swift @@ -60,13 +60,13 @@ public final class MainTapViewController: UITabBarController { private func observeNotification() { NotificationCenter.default.rx .notification(.init("deeplink")) - .compactMap { noti -> (query: String, agencyID: Int)? in - guard let query = noti.userInfo?["query"] as? String, + .compactMap { noti -> (destination: String, agencyID: Int)? in + guard let destination = noti.userInfo?["destination"] as? String, let agencyID = noti.userInfo?["agencyID"] as? Int else { return nil } - return (query, agencyID) + return (destination, agencyID) } .bind(with: self) { owner, userInfo in - switch userInfo.query { + switch userInfo.destination { case "CreateLedger": owner.coordinator?.move(to: .createManualLedger(userInfo.agencyID)) case "LedgerDetail": @@ -80,7 +80,7 @@ public final class MainTapViewController: UITabBarController { } .disposed(by: disposeBag) - if let destination = DeepLinkManager.destination { + if let destination = DeepLinkManager.query { NotificationCenter.default.post(name: .init("deeplink"), object: nil, userInfo: destination) } } From d83710fc99c9fcaa4f967d969484747829fd6595 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 23 Aug 2026 09:45:33 +0900 Subject: [PATCH 03/24] feat: Handle Universal Link for invitation flow Add scene(_:continue:) to parse webpageURL query items and post via invitationLink notification when the app is opened through a Universal Link. --- Projects/App/Sources/SceneDelegate.swift | 18 +++++++++++++++++- .../Base/Sources/NSNotificationName+.swift | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index d1f13cac..a921254b 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -50,11 +50,27 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let url = URLContexts.first?.url else { return } if url.absoluteString.contains("widget://") { - DeepLinkManager.setDestination(url.absoluteString, agencyID: localStorage.selectedAgency) + let destination = url.absoluteString.replacingOccurrences(of: "widget://", with: "") + guard let agencyID = localStorage.selectedAgency else { return } + DeepLinkManager.setQuery([ + "destination": destination, + "agencyID": agencyID + ], notiName: .init("deeplink")) } else { KakaoAuthManager.shared.openURL(url) } } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL, + let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return } + var queryItems: [String: Any] = [:] + components.queryItems?.forEach { + queryItems[$0.name] = $0.value + } + DeepLinkManager.setQuery(queryItems, notiName: .invitationLink) + } func sceneDidDisconnect(_ scene: UIScene) {} diff --git a/Projects/Feature/Base/Sources/NSNotificationName+.swift b/Projects/Feature/Base/Sources/NSNotificationName+.swift index d4105e36..cdb661f5 100644 --- a/Projects/Feature/Base/Sources/NSNotificationName+.swift +++ b/Projects/Feature/Base/Sources/NSNotificationName+.swift @@ -4,4 +4,5 @@ public extension NSNotification.Name { static let tabBarHidden = NSNotification.Name("tabBarHidden") // toggle Tab static let presentManualCreater = NSNotification.Name("presentManualCreater") // 동아리 운영비 등록화면 present static let presentAgencyCreater = NSNotification.Name("presentAgencyCreater") // 소속 생성화면 present + static let invitationLink = NSNotification.Name("invitationLink") } From a06f0ed4d1963e7feb8c8b8d788e27c4a1cecbd4 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 23 Aug 2026 09:48:19 +0900 Subject: [PATCH 04/24] feat: Add agencyID-based overload to ConfirmCertificateCodeUseCase Support confirming an invitation by agencyID: skip certification if the user already belongs to the agency, otherwise certify with the code and update the selected agency. --- ...onfirmCertificateCodeUseCaseInterface.swift | 1 + .../ConfirmCertificateCodeUseCase.swift | 18 ++++++++++++++++++ .../MockConfirmCertificateCodeUseCase.swift | 3 +++ 3 files changed, 22 insertions(+) diff --git a/Projects/Domain/Agency/Interface/ConfirmCertificateCodeUseCaseInterface.swift b/Projects/Domain/Agency/Interface/ConfirmCertificateCodeUseCaseInterface.swift index b0f88e59..044c53a7 100644 --- a/Projects/Domain/Agency/Interface/ConfirmCertificateCodeUseCaseInterface.swift +++ b/Projects/Domain/Agency/Interface/ConfirmCertificateCodeUseCaseInterface.swift @@ -5,4 +5,5 @@ import BaseDomain // 소속가입시, 초대코드가 맞는지 확인한다 public protocol ConfirmCertificateCodeUseCaseInterface { func execute(code: [String]) async throws -> Agency? + func execute(code: String, agencyID: Int) async throws -> Agency? } diff --git a/Projects/Domain/Agency/Sources/ConfirmCertificateCodeUseCase.swift b/Projects/Domain/Agency/Sources/ConfirmCertificateCodeUseCase.swift index 51f1b54d..a7993e4f 100644 --- a/Projects/Domain/Agency/Sources/ConfirmCertificateCodeUseCase.swift +++ b/Projects/Domain/Agency/Sources/ConfirmCertificateCodeUseCase.swift @@ -25,4 +25,22 @@ public struct ConfirmCertificateCodeUseCase: ConfirmCertificateCodeUseCaseInterf } return nil } + + public func execute(code: String, agencyID: Int) async throws -> Agency? { + let agencies = try await agencyRepo.fetchMyAgency() + if let agency = agencies.first(where: { $0.id == agencyID }) { + // 이미 소속에 가입되어 있는 경우 + userRepo.updateSelectedAgency(id: agencyID) + return agency + } else { + // 소속에 가입되어 있는 않는 경우 + let response = try await agencyRepo.certificateCode(code: code) + if response.certified { + userRepo.updateSelectedAgency(id: response.agencyId) + return agencies.first { $0.id == response.agencyId } + } + } + + return nil + } } diff --git a/Projects/Domain/Agency/Testing/MockConfirmCertificateCodeUseCase.swift b/Projects/Domain/Agency/Testing/MockConfirmCertificateCodeUseCase.swift index 6127868e..3de7a003 100644 --- a/Projects/Domain/Agency/Testing/MockConfirmCertificateCodeUseCase.swift +++ b/Projects/Domain/Agency/Testing/MockConfirmCertificateCodeUseCase.swift @@ -5,4 +5,7 @@ public struct MockConfirmCertificateCodeUseCase: ConfirmCertificateCodeUseCaseIn public init() {} public func execute(code: [String]) async throws -> Agency? { return nil } + public func execute(code: String, agencyID: Int) async throws -> Agency? { + return nil + } } From 8e118439fe44c3d2eef7d780812f408152307d9a Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 23 Aug 2026 10:31:42 +0900 Subject: [PATCH 05/24] chore: ignore CLAUDE.md --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7cf52ed8..a0cec436 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ fastlane/report.xml fastlane/README.md Projects/App/Resources/APIKey.xcconfig graph.png +CLAUDE.md From 142bc1b1319d4a6fb85fe0408e04ffae8fca5046 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 23 Aug 2026 10:31:49 +0900 Subject: [PATCH 06/24] feat: handle invitation link on Ledger screen after login Post a notification with the pending invitation code/agencyID when LedgerVC loads, and confirm it via a new invite reactor action instead of the normal my-agencies fetch. LoginReactor now routes straight to main when a deep link query is pending, skipping the sign-up check so the invitation can be resolved on the Ledger screen. --- .../Ledger/Sources/Common/LedgerFactory.swift | 4 +- .../Sources/Scene/Ledger/LedgerReactor.swift | 67 +++++++++++++------ .../Sources/Scene/Ledger/LedgerVC.swift | 32 +++++++-- .../Sources/Scene/Login/LoginReactor.swift | 1 + 4 files changed, 74 insertions(+), 30 deletions(-) diff --git a/Projects/Feature/Ledger/Sources/Common/LedgerFactory.swift b/Projects/Feature/Ledger/Sources/Common/LedgerFactory.swift index 8e510098..329d6965 100644 --- a/Projects/Feature/Ledger/Sources/Common/LedgerFactory.swift +++ b/Projects/Feature/Ledger/Sources/Common/LedgerFactory.swift @@ -18,8 +18,8 @@ struct LedgerFactory { let vc = LedgerVC([ledgerTab, memberTab]) vc.reactor = LedgerReactor( getMyAgencyUseCase: DIContainer.shared.resolve(type: GetMyAgencyUseCaseInterface.self), - getSelectedAgency: DIContainer.shared.resolve(type: GetSelectedAgencyUseCaseInterface.self), - updateSelectedAgency: DIContainer.shared.resolve(type: UpdateSelectedAgencyUseCaseInterface.self), + getSelectedAgencyUseCase: DIContainer.shared.resolve(type: GetSelectedAgencyUseCaseInterface.self), + updateSelectedAgencyUseCase: DIContainer.shared.resolve(type: UpdateSelectedAgencyUseCaseInterface.self), ledgerService: DIContainer.shared.resolve(type: LedgerServiceInterface.self) ) return vc diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerReactor.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerReactor.swift index 66f9b997..ccc14ded 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerReactor.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerReactor.swift @@ -3,6 +3,7 @@ import Foundation import LedgerFeatureInterface import AgencyInterface import BaseDomain +import BaseFeature import UserInterface import Utility @@ -12,16 +13,19 @@ public final class LedgerReactor: Reactor { public enum Action { case requestMyAgencies + case invite(code: String, agencyID: Int) } public enum Mutation { case setAgency(Agency?) case setError(MoneyMongError) + case setLoading(Bool) } public struct State { @Pulse var agency: Agency? @Pulse var error: MoneyMongError? + @Pulse var isLoading: Bool = false } public let initialState = State() @@ -29,41 +33,58 @@ public final class LedgerReactor: Reactor { private let service: LedgerServiceInterface private let getMyAgencyUseCase: GetMyAgencyUseCaseInterface - private let getSelectedAgency: GetSelectedAgencyUseCaseInterface - private let updateSelectedAgency: UpdateSelectedAgencyUseCaseInterface + private let getSelectedAgencyUseCase: GetSelectedAgencyUseCaseInterface + private let updateSelectedAgencyUseCase: UpdateSelectedAgencyUseCaseInterface + private let confirmCertificateCodeUseCase: ConfirmCertificateCodeUseCaseInterface init( getMyAgencyUseCase: GetMyAgencyUseCaseInterface, - getSelectedAgency: GetSelectedAgencyUseCaseInterface, - updateSelectedAgency: UpdateSelectedAgencyUseCaseInterface, + getSelectedAgencyUseCase: GetSelectedAgencyUseCaseInterface, + updateSelectedAgencyUseCase: UpdateSelectedAgencyUseCaseInterface, + confirmCertificateCodeUseCase: ConfirmCertificateCodeUseCaseInterface = DIContainer.shared.resolve(type: ConfirmCertificateCodeUseCaseInterface.self), ledgerService: LedgerServiceInterface ) { self.getMyAgencyUseCase = getMyAgencyUseCase - self.getSelectedAgency = getSelectedAgency - self.updateSelectedAgency = updateSelectedAgency + self.getSelectedAgencyUseCase = getSelectedAgencyUseCase + self.updateSelectedAgencyUseCase = updateSelectedAgencyUseCase + self.confirmCertificateCodeUseCase = confirmCertificateCodeUseCase self.service = ledgerService } public func mutate(action: Action) -> Observable { switch action { case .requestMyAgencies: - return .task { - try await getMyAgencyUseCase.execute() - } - .map { [weak self] agencies in - let agencyID = self?.getSelectedAgency.execute() - let agency = agencies.first(where: { $0.id == agencyID }) ?? agencies.first - - if let agency { - self?.updateSelectedAgency.execute(id: agency.id) - self?.service.agency.updateAgency(agency) - } + return .concat( + .just(.setLoading(true)), + .task { + try await getMyAgencyUseCase.execute() + } + .map { [weak self] agencies in + let agencyID = self?.getSelectedAgencyUseCase.execute() + let agency = agencies.first(where: { $0.id == agencyID }) ?? agencies.first + + if let agency { + self?.updateSelectedAgencyUseCase.execute(id: agency.id) + self?.service.agency.updateAgency(agency) + } - return .setAgency(agency) - } - .catch { - return .just(.setError($0.toMMError)) - } + return .setAgency(agency) + } + .catch { return .just(.setError($0.toMMError)) }, + .just(.setLoading(false)) + ) + case .invite(let code, let agencyID): + return .concat( + .just(.setLoading(true)), + .task { + try await confirmCertificateCodeUseCase.execute(code: code, agencyID: agencyID) + } + .map { [weak self] agency in + self?.service.agency.updateAgency(agency) + return .setAgency(agency) + }.catch { return .just(.setError($0.toMMError)) }, + .just(.setLoading(false)) + ) } } @@ -74,6 +95,8 @@ public final class LedgerReactor: Reactor { newState.agency = agency case let .setError(error): newState.error = error + case .setLoading(let isLoading): + newState.isLoading = isLoading } return newState diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift index 7a56efd9..8601d254 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift @@ -68,11 +68,26 @@ public final class LedgerVC: BaseVC, View { public func bind(reactor: LedgerReactor) { setTitle(agencyButton) - rx.viewDidLoad - .map { Reactor.Action.requestMyAgencies } + NotificationCenter.default.rx.notification(.invitationLink) + .compactMap { noti -> (code: String, agencyID: Int)? in + guard let code = noti.userInfo?["code"] as? String, + let agencyID = noti.userInfo?["agencyID"] as? Int else { return nil } + return (code, agencyID) + } + .map { Reactor.Action.invite(code: $0.code, agencyID: $0.agencyID) } + .do { _ in DeepLinkManager.clear() } .bind(to: reactor.action) .disposed(by: disposeBag) + if let query = DeepLinkManager.query { + NotificationCenter.default.post(name: .invitationLink, object: nil, userInfo: query) + } else { + rx.viewDidLoad + .map { Reactor.Action.requestMyAgencies } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + rx.viewDidLoad .bind(with: self, onNext: { owner, _ in owner.coordinator?.moveTab = { owner.lineTab.currentPage = $0 } @@ -85,6 +100,12 @@ public final class LedgerVC: BaseVC, View { } .disposed(by: disposeBag) + agencyButton.rx.tap + .bind(with: self) { owner, _ in + owner.coordinator?.selectAgencySheet() + } + .disposed(by: disposeBag) + reactor.pulse(\.$agency) .skip(1) .observe(on: MainScheduler.instance) @@ -105,10 +126,9 @@ public final class LedgerVC: BaseVC, View { } .disposed(by: disposeBag) - agencyButton.rx.tap - .bind(with: self) { owner, _ in - owner.coordinator?.selectAgencySheet() - } + reactor.pulse(\.$isLoading) + .observe(on: MainScheduler.instance) + .bind(to: rx.isLoading) .disposed(by: disposeBag) } } diff --git a/Projects/Feature/Sign/Sources/Scene/Login/LoginReactor.swift b/Projects/Feature/Sign/Sources/Scene/Login/LoginReactor.swift index 69592f49..8859a8e4 100644 --- a/Projects/Feature/Sign/Sources/Scene/Login/LoginReactor.swift +++ b/Projects/Feature/Sign/Sources/Scene/Login/LoginReactor.swift @@ -57,6 +57,7 @@ final class LoginReactor: Reactor { case .login(let loginType): return .task { _ = try await signUpUseCase.execute(loginType: loginType) + if DeepLinkManager.query != nil { return true } return try await !getMyAgencyUseCase.execute().isEmpty } .map { .setDestination($0 ? .main : .signUp) } From 706d0c2a9932ac74b2808b404d918f3e9087dd70 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 23 Aug 2026 10:31:54 +0900 Subject: [PATCH 07/24] fix: show loading indicator instead of resizing agency list Replace the isLoading -> full-content-hiding behavior with a small indicator inside the agency list area, and cap the list height at 3 rows with correct inter-item spacing. --- .../SelectAgency/SelectAgencySheetVC.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/Sheets/SelectAgency/SelectAgencySheetVC.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/Sheets/SelectAgency/SelectAgencySheetVC.swift index a190ee5c..e717bb17 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/Sheets/SelectAgency/SelectAgencySheetVC.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/Sheets/SelectAgency/SelectAgencySheetVC.swift @@ -36,13 +36,18 @@ final class SelectAgencySheetVC: BottomSheetVC, View { private let registerCodeInputButton: MMButton = MMButton(title: "초대 코드 입력하기", image: Images.pencil, type: .tertiary) private let createAgencyButton: MMButton = MMButton(title: "새로운 장부 만들기", image: Images.plusCircleLineWhite, type: .primary) + private let loadingIndicator: MMIndicator = .init() override func setupConstraints() { super.setupConstraints() contentView.flex.define { flex in - flex.addItem(tableView).height(3 * (Constant.cellHeight) + Constant.cellSpacing * 3) + flex.addItem(tableView).height(Constant.cellHeight) .marginTop(Constant.topSpacing) .marginHorizontal(Constant.horizontalMargin) + .alignItems(.center) + .define { flex in + flex.addItem(loadingIndicator) + } flex.addItem(registerCodeInputButton) .marginHorizontal(Constant.horizontalMargin) .height(Constant.buttonHeight) @@ -53,7 +58,7 @@ final class SelectAgencySheetVC: BottomSheetVC, View { .marginBottom(Constant.buttonSpacing + Constant.bottomSpacing) componentHeight = Constant.topSpacing + Constant.buttonHeight * 2 + Constant.buttonSpacing * 3 + Constant.bottomSpacing // (버튼 + 마진) 높이 - contentHeight = componentHeight + 3 * (Constant.cellHeight) + Constant.cellSpacing * 2 // 소속 리스트 높이 + contentHeight = componentHeight + (Constant.cellHeight) // 소속 리스트 높이 } } @@ -89,7 +94,12 @@ final class SelectAgencySheetVC: BottomSheetVC, View { .disposed(by: disposeBag) reactor.pulse(\.$isLoading) - .bind(to: rx.isLoading) + .observe(on: MainScheduler.instance) + .bind(with: self) { owner, isLoading in + owner.loadingIndicator.isHidden = !isLoading + owner.loadingIndicator.flex.isIncludedInLayout(isLoading).markDirty() + isLoading ? owner.loadingIndicator.startAnimating() : owner.loadingIndicator.stopAnimating() + } .disposed(by: disposeBag) reactor.pulse(\.$selectedAgencyID) @@ -113,8 +123,8 @@ final class SelectAgencySheetVC: BottomSheetVC, View { .bind(with: self) { owner, agencies in let count = CGFloat(agencies.count) let height = min( - count * 72 + count * 12, - 3 * (72) + 3 * 12 + count * 72 + (count - 1) * 12, + 3 * (72) + 2 * 12 ) owner.tableView.isScrollEnabled = count > 3 From bece2539042c563cd7e85730b643e51b2e309853 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 23 Aug 2026 13:43:06 +0900 Subject: [PATCH 08/24] refactor: move Config to Shared/Utility module Config is now needed outside of Repository (e.g. for building universal-link URLs in feature modules), so relocate it to the shared Utility module and expose it publicly. --- .../Sources/APIs/AgencyAPI/Repository/AgencyAPI.swift | 1 + .../Sources/APIs/LedgerAPI/Repository/LedgerAPI.swift | 1 + .../Sources/APIs/SignAPI/Repository/SignAPI.swift | 1 + .../Sources/APIs/TokenAPI/Repository/TokenAPI.swift | 1 + .../Sources/APIs/UserAPI/Repository/UserAPI.swift | 1 + .../Sources/APIs/VersionAPI/Repository/VersionAPI.swift | 1 + Projects/Core/Repository/Sources/Common/Config.swift | 7 ------- Projects/Shared/Utility/Sources/Config.swift | 7 +++++++ 8 files changed, 13 insertions(+), 7 deletions(-) delete mode 100644 Projects/Core/Repository/Sources/Common/Config.swift create mode 100644 Projects/Shared/Utility/Sources/Config.swift diff --git a/Projects/Core/Repository/Sources/APIs/AgencyAPI/Repository/AgencyAPI.swift b/Projects/Core/Repository/Sources/APIs/AgencyAPI/Repository/AgencyAPI.swift index cb771c5d..36d9b1b3 100644 --- a/Projects/Core/Repository/Sources/APIs/AgencyAPI/Repository/AgencyAPI.swift +++ b/Projects/Core/Repository/Sources/APIs/AgencyAPI/Repository/AgencyAPI.swift @@ -1,6 +1,7 @@ import Foundation import MMNetworkInterface +import Utility enum AgencyAPI { case create(param: AgencyCreateRequestDTO) // 소속생성 diff --git a/Projects/Core/Repository/Sources/APIs/LedgerAPI/Repository/LedgerAPI.swift b/Projects/Core/Repository/Sources/APIs/LedgerAPI/Repository/LedgerAPI.swift index 45e05d51..662b9843 100644 --- a/Projects/Core/Repository/Sources/APIs/LedgerAPI/Repository/LedgerAPI.swift +++ b/Projects/Core/Repository/Sources/APIs/LedgerAPI/Repository/LedgerAPI.swift @@ -1,6 +1,7 @@ import Foundation import MMNetworkInterface +import Utility import Alamofire diff --git a/Projects/Core/Repository/Sources/APIs/SignAPI/Repository/SignAPI.swift b/Projects/Core/Repository/Sources/APIs/SignAPI/Repository/SignAPI.swift index ee6463e5..320356e4 100644 --- a/Projects/Core/Repository/Sources/APIs/SignAPI/Repository/SignAPI.swift +++ b/Projects/Core/Repository/Sources/APIs/SignAPI/Repository/SignAPI.swift @@ -1,6 +1,7 @@ import Foundation import MMNetworkInterface +import Utility enum SignAPI { case sign(SignRequestDTO) diff --git a/Projects/Core/Repository/Sources/APIs/TokenAPI/Repository/TokenAPI.swift b/Projects/Core/Repository/Sources/APIs/TokenAPI/Repository/TokenAPI.swift index 39492798..63153fcf 100644 --- a/Projects/Core/Repository/Sources/APIs/TokenAPI/Repository/TokenAPI.swift +++ b/Projects/Core/Repository/Sources/APIs/TokenAPI/Repository/TokenAPI.swift @@ -1,6 +1,7 @@ import Foundation import MMNetworkInterface +import Utility import Alamofire diff --git a/Projects/Core/Repository/Sources/APIs/UserAPI/Repository/UserAPI.swift b/Projects/Core/Repository/Sources/APIs/UserAPI/Repository/UserAPI.swift index 9bc0939d..d8ba6f22 100644 --- a/Projects/Core/Repository/Sources/APIs/UserAPI/Repository/UserAPI.swift +++ b/Projects/Core/Repository/Sources/APIs/UserAPI/Repository/UserAPI.swift @@ -1,6 +1,7 @@ import Foundation import MMNetworkInterface +import Utility enum UserAPI { case user // 내정보 조회 diff --git a/Projects/Core/Repository/Sources/APIs/VersionAPI/Repository/VersionAPI.swift b/Projects/Core/Repository/Sources/APIs/VersionAPI/Repository/VersionAPI.swift index 12234835..7c63daa8 100644 --- a/Projects/Core/Repository/Sources/APIs/VersionAPI/Repository/VersionAPI.swift +++ b/Projects/Core/Repository/Sources/APIs/VersionAPI/Repository/VersionAPI.swift @@ -1,6 +1,7 @@ import Foundation import MMNetworkInterface +import Utility enum VersionAPI { case version(VersionRequestDTO) // 버전조회 diff --git a/Projects/Core/Repository/Sources/Common/Config.swift b/Projects/Core/Repository/Sources/Common/Config.swift deleted file mode 100644 index 2f614758..00000000 --- a/Projects/Core/Repository/Sources/Common/Config.swift +++ /dev/null @@ -1,7 +0,0 @@ -enum Config { -#if DEBUG - static let base = "https://dev.moneymong.site/api/" -#else - static let base = "https://prod.moneymong.site/api/" -#endif -} diff --git a/Projects/Shared/Utility/Sources/Config.swift b/Projects/Shared/Utility/Sources/Config.swift new file mode 100644 index 00000000..f17a8b3c --- /dev/null +++ b/Projects/Shared/Utility/Sources/Config.swift @@ -0,0 +1,7 @@ +public enum Config { +#if DEBUG + public static let base = "https://dev.moneymong.site/api/" +#else + public static let base = "https://prod.moneymong.site/api/" +#endif +} From 8c081421e41e49b02425be63bdc3b953bcd7198d Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 30 Aug 2026 08:20:07 +0900 Subject: [PATCH 09/24] chore: ignore .claude directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a0cec436..de791f91 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ fastlane/README.md Projects/App/Resources/APIKey.xcconfig graph.png CLAUDE.md +.claude From 2a960cc920b43ee3540bfc662aba7ce3c65b3538 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 30 Aug 2026 08:20:11 +0900 Subject: [PATCH 10/24] feat: share invite link from member tab Add an InvitationLinkButton to the member tab that fetches the agency's invitation code, builds a link with code/agencyID query params from Config.base, and presents the system share sheet with it. The button is only shown to staff members. --- .../Components/InvitationLinkButton.swift | 79 +++++++++++++++++++ .../Member/MemberList/MemberTabReactor.swift | 14 ++++ .../Member/MemberList/MemberTabVC.swift | 24 +++++- 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/Components/InvitationLinkButton.swift diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/Components/InvitationLinkButton.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/Components/InvitationLinkButton.swift new file mode 100644 index 00000000..b9002fe4 --- /dev/null +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/Components/InvitationLinkButton.swift @@ -0,0 +1,79 @@ +import UIKit + +import DesignSystem + +import PinLayout +import FlexLayout + +final class InvitationLinkButton: UIButton { + private let rootContainer = UIView() + + private let badgeView: UIView = { + let v = UIView() + v.backgroundColor = Colors.Black._1.withAlphaComponent(0.2) + v.layer.cornerRadius = 4 + return v + }() + + private let badgeLabel: UILabel = { + let v = UILabel() + v.font = Fonts.caption + v.textColor = Colors.White._1 + v.text = "이걸로 초대해보세요" + return v + }() + + private let buttonTitleLabel: UILabel = { + let v = UILabel() + v.font = Fonts.body._4 + v.textColor = Colors.White._1 + v.text = "친구 초대하기" + return v + }() + + override var isHighlighted: Bool { + didSet { alpha = isHighlighted ? 0.7 : 1 } + } + + init() { + super.init(frame: .zero) + setupView() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + backgroundColor = Colors.Blue._4 + clipsToBounds = true + } + + private func setupConstraints() { + rootContainer.isUserInteractionEnabled = false + addSubview(rootContainer) + + rootContainer.flex + .direction(.row) + .justifyContent(.center) + .alignItems(.center) + .paddingVertical(14) + .define { flex in + flex.addItem(badgeView).marginRight(8).define { flex in + flex.addItem(badgeLabel).marginHorizontal(8).marginVertical(4) + } + flex.addItem(buttonTitleLabel) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + rootContainer.pin.all() + rootContainer.flex.layout() + + layer.cornerRadius = 10 + } +} diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift index 42895615..6356ecde 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift @@ -1,3 +1,5 @@ +import Foundation + import ReactorKit import AgencyInterface @@ -27,6 +29,7 @@ final class MemberTabReactor: Reactor { case kickOffAlert(memberID: Int) case agencyDeleteAlert case ledgerTab + case sharedSheet(content: String) } } @@ -37,6 +40,7 @@ final class MemberTabReactor: Reactor { case tapCodeCopyButton // 초대코드 복사 case tapAgencyDeleteButton // 소속삭제 얼럿 present case tapAgnecyDeleteAlertButton // 소속삭제 얼럿 -> 소속삭제 + case didTapInviteButton } enum Mutation { @@ -154,6 +158,16 @@ final class MemberTabReactor: Reactor { return .setDestination(.ledgerTab) } .catch { return .just(.setError($0.toMMError)) } + case .didTapInviteButton: + guard let code = currentState.invitationCode, + let agencyID = currentState.agencyID else { return .empty() } + var components = URLComponents(string: Config.base) + components?.queryItems = [ + URLQueryItem(name: "code", value: code), + URLQueryItem(name: "agencyID", value: String(agencyID)) + ] + let content = components?.url?.absoluteString ?? "" + return .just(.setDestination(.sharedSheet(content: content))) } } diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift index 0383d0f4..d2a09963 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift @@ -21,6 +21,7 @@ final class MemberTabVC: BaseVC, View { }() private let profileView = MyProfileView() + private let invitationLinkButton = InvitationLinkButton() private let emptyView = MemberEmptyView() private let memberHeaderLabel: UILabel = { @@ -49,7 +50,8 @@ final class MemberTabVC: BaseVC, View { rootContainer.flex.paddingHorizontal(20).define { flex in flex.addItem(profileHeaderLabel).marginTop(24).marginBottom(8) - flex.addItem(profileView).marginBottom(24) + flex.addItem(profileView).marginBottom(16) + flex.addItem(invitationLinkButton).marginBottom(20) flex.addItem(memberHeaderLabel).marginBottom(8) flex.addItem(tableView).grow(1) } @@ -82,6 +84,11 @@ final class MemberTabVC: BaseVC, View { .map { Reactor.Action.tapAgencyDeleteButton } .bind(to: reactor.action) .disposed(by: disposeBag) + + invitationLinkButton.rx.tap + .map { Reactor.Action.didTapInviteButton } + .bind(to: reactor.action) + .disposed(by: disposeBag) tableView.rx.modelSelected(Member.self) .bind(with: self) { owner, member in @@ -120,6 +127,9 @@ final class MemberTabVC: BaseVC, View { .observe(on: MainScheduler.instance) .bind(with: self) { owner, element in owner.profileView.configure(title: element.name, role: element.role, code: element.code) + owner.invitationLinkButton.isHidden = element.role == .member + owner.invitationLinkButton.flex.isIncludedInLayout(element.role == .staff).markDirty() + owner.profileView.flex.marginBottom(element.role == .staff ? 16 : 24).markDirty() } .disposed(by: disposeBag) @@ -158,8 +168,20 @@ final class MemberTabVC: BaseVC, View { reactor.action.onNext(.tapAgnecyDeleteAlertButton) }, cancelAction: {}) )) + case let .sharedSheet(content): + owner.showShareSheet(content: content) } } .disposed(by: disposeBag) } } + +private extension MemberTabVC { + func showShareSheet(content: String) { + let activityVC = UIActivityViewController( + activityItems: [content], + applicationActivities: nil + ) + present(activityVC, animated: true) + } +} From 8bcb82c940830eda8650328e116977ba7a3274f3 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 30 Aug 2026 08:28:45 +0900 Subject: [PATCH 11/24] fix: call addChild/didMove for embedded view controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lineTab과 pageViewController를 addSubview로만 붙이고 부모-자식 컨테인먼트 관계를 설정하지 않아 뷰컨트롤러 생명주기 이벤트가 자식에게 제대로 전파되지 않던 문제를 수정. addChild(_:)와 didMove(toParent:) 호출을 추가했다. --- Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift | 4 +++- .../DesignSystem/Sources/Tabs/LineTabViewController.swift | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift index 8601d254..81f239af 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift @@ -58,11 +58,13 @@ public final class LedgerVC: BaseVC, View { public override func setupConstraints() { super.setupConstraints() view.addSubview(emptyView) - + + addChild(lineTab) rootContainer.flex .define { flex in flex.addItem(lineTab.view) } + lineTab.didMove(toParent: self) } public func bind(reactor: LedgerReactor) { diff --git a/Projects/Shared/DesignSystem/Sources/Tabs/LineTabViewController.swift b/Projects/Shared/DesignSystem/Sources/Tabs/LineTabViewController.swift index b1baa01c..f4bc3380 100644 --- a/Projects/Shared/DesignSystem/Sources/Tabs/LineTabViewController.swift +++ b/Projects/Shared/DesignSystem/Sources/Tabs/LineTabViewController.swift @@ -79,10 +79,11 @@ public final class LineTabViewController: UIViewController { private func setupView() {} private func setupConstraints() { + addChild(pageViewController) view.addSubview(rootContainer) rootContainer.addSubview(pageViewController.view) rootContainer.addSubview(tabView) - + pageViewController.didMove(toParent: self) } @objc From e0b48dc78b5c6382b6c3afdf2318be20d477c59a Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 30 Aug 2026 10:05:56 +0900 Subject: [PATCH 12/24] test: add MockAgencyRepository and MockUserRepository --- .../Testing/MockAgencyRepository.swift | 123 ++++++++++++++++++ .../Testing/MockUserRepository.swift | 65 +++++++++ 2 files changed, 188 insertions(+) create mode 100644 Projects/Domain/BaseDomain/Testing/MockAgencyRepository.swift create mode 100644 Projects/Domain/BaseDomain/Testing/MockUserRepository.swift diff --git a/Projects/Domain/BaseDomain/Testing/MockAgencyRepository.swift b/Projects/Domain/BaseDomain/Testing/MockAgencyRepository.swift new file mode 100644 index 00000000..aefb55fd --- /dev/null +++ b/Projects/Domain/BaseDomain/Testing/MockAgencyRepository.swift @@ -0,0 +1,123 @@ +// +// MockAgencyRepository.swift +// BaseDomain +// +// Created by 이시원 on 5/30/26. +// + +import Foundation + +import BaseDomain + +public class MockAgencyRepository: AgencyRepositoryInterface { + public struct CallCount { + public var create = 0 + public var fetchMemberList = 0 + public var changeMemberRole = 0 + public var kickoutMember = 0 + public var fetchMyAgency = 0 + public var fetchCode = 0 + public var certificateCode = 0 + public var reissueCode = 0 + public var deleteAgency = 0 + public var getCategories = 0 + public var createCategory = 0 + public var deleteCategory = 0 + } + + public struct ReturnValue { + public var create: Int? + public var fetchMemberList: [BaseDomain.Member]? + public var fetchMyAgency: [BaseDomain.Agency]? + public var fetchCode: String? + public var certificateCode: BaseDomain.CertificationResult? + public var reissueCode: String? + public var getCategories: [BaseDomain.MMCategory]? + } + + public struct InputValue { + public var create: String? = nil + public var fetchMemberList: Int? = nil + public var changeMemberRole: (id: Int, userId: Int, role: String)? = nil + public var kickoutMember: (id: Int, userId: Int)? = nil + public var fetchCode: Int? = nil + public var certificateCode: String? = nil + public var reissueCode: Int? = nil + public var deleteAgency: Int? = nil + public var getCategories: Int? = nil + public var createCategory: (agencyId: Int, name: String)? = nil + public var deleteCategory: Int? = nil + } + + public var callCount: CallCount = .init() + public var returnValue: ReturnValue = .init() + public var inputValue: InputValue = .init() + + public init() {} + + public func create(name: String) async throws -> Int { + callCount.create += 1 + inputValue.create = name + return returnValue.create! + } + + public func fetchMemberList(id: Int) async throws -> [BaseDomain.Member] { + callCount.fetchMemberList += 1 + inputValue.fetchMemberList = id + return returnValue.fetchMemberList! + } + + public func changeMemberRole(id: Int, userId: Int, role: String) async throws { + callCount.changeMemberRole += 1 + inputValue.changeMemberRole = (id: id, userId: userId, role: role) + } + + public func kickoutMember(id: Int, userId: Int) async throws { + callCount.kickoutMember += 1 + inputValue.kickoutMember = (id: id, userId: userId) + } + + public func fetchMyAgency() async throws -> [BaseDomain.Agency] { + callCount.fetchMyAgency += 1 + return returnValue.fetchMyAgency! + } + + public func fetchCode(id: Int) async throws -> String { + callCount.fetchCode += 1 + inputValue.fetchCode = id + return returnValue.fetchCode! + } + + public func certificateCode(code: String) async throws -> BaseDomain.CertificationResult { + callCount.certificateCode += 1 + inputValue.certificateCode = code + return returnValue.certificateCode! + } + + public func reissueCode(id: Int) async throws -> String { + callCount.reissueCode += 1 + inputValue.reissueCode = id + return returnValue.reissueCode! + } + + public func deleteAgency(id: Int) async throws { + callCount.deleteAgency += 1 + inputValue.deleteAgency = id + } + + public func getCategories(id: Int) async throws -> [BaseDomain.MMCategory] { + callCount.getCategories += 1 + inputValue.getCategories = id + return returnValue.getCategories! + } + + public func createCategory(agencyId: Int, name: String) async throws { + callCount.createCategory += 1 + inputValue.createCategory = (agencyId: agencyId, name: name) + } + + public func deleteCategory(id: Int) async throws { + callCount.deleteCategory += 1 + inputValue.deleteCategory = id + } +} diff --git a/Projects/Domain/BaseDomain/Testing/MockUserRepository.swift b/Projects/Domain/BaseDomain/Testing/MockUserRepository.swift new file mode 100644 index 00000000..3dfe4865 --- /dev/null +++ b/Projects/Domain/BaseDomain/Testing/MockUserRepository.swift @@ -0,0 +1,65 @@ +// +// MockUserRepository.swift +// BaseDomain +// +// Created by 이시원 on 5/30/26. +// + +import Foundation + +import BaseDomain + +public class MockUserRepository: UserRepositoryInterface { + public struct CallCount { + public var user = 0 + public var fetchUserID = 0 + public var fetchSelectedAgency = 0 + public var updateSelectedAgency = 0 + public var logout = 0 + public var withdrawl = 0 + } + + public struct ReturnValue { + public var user: BaseDomain.UserInfo? + public var fetchUserID: Int = 0 + public var fetchSelectedAgency: Int? + } + + public struct InputValue { + public var updateSelectedAgency: Int?? = nil + } + + public var callCount: CallCount = .init() + public var returnValue: ReturnValue = .init() + public var inputValue: InputValue = .init() + + public init() {} + + public func user() async throws -> BaseDomain.UserInfo { + callCount.user += 1 + return returnValue.user! + } + + public func fetchUserID() -> Int { + callCount.fetchUserID += 1 + return returnValue.fetchUserID + } + + public func fetchSelectedAgency() -> Int? { + callCount.fetchSelectedAgency += 1 + return returnValue.fetchSelectedAgency + } + + public func updateSelectedAgency(id: Int?) { + callCount.updateSelectedAgency += 1 + inputValue.updateSelectedAgency = id + } + + public func logout() async throws { + callCount.logout += 1 + } + + public func withdrawl() async throws { + callCount.withdrawl += 1 + } +} From 162c621b620db83321c844c5f0e5714e012546a6 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Sun, 30 Aug 2026 10:06:00 +0900 Subject: [PATCH 13/24] test: add unit tests for ConfirmCertificateCodeUseCase Wire AgencyTests to BaseDomainTesting so the new tests can use MockAgencyRepository and MockUserRepository. --- Projects/Domain/Agency/Project.swift | 3 +- .../ConfirmCerrificateCodeUseCaseTests.swift | 117 ++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 Projects/Domain/Agency/Tests/ConfirmCerrificateCodeUseCaseTests.swift diff --git a/Projects/Domain/Agency/Project.swift b/Projects/Domain/Agency/Project.swift index bae38d91..46392d3d 100644 --- a/Projects/Domain/Agency/Project.swift +++ b/Projects/Domain/Agency/Project.swift @@ -38,7 +38,8 @@ let project = Project( sources: ["Tests/**"], dependencies: [ .target(name: "Agency"), - .target(name: "AgencyTesting") + .target(name: "AgencyTesting"), + .project(target: "BaseDomainTesting", path: .relativeToRoot("Projects/Domain/BaseDomain")), ] ), Target( diff --git a/Projects/Domain/Agency/Tests/ConfirmCerrificateCodeUseCaseTests.swift b/Projects/Domain/Agency/Tests/ConfirmCerrificateCodeUseCaseTests.swift new file mode 100644 index 00000000..75fdf8ec --- /dev/null +++ b/Projects/Domain/Agency/Tests/ConfirmCerrificateCodeUseCaseTests.swift @@ -0,0 +1,117 @@ +import XCTest +@testable import Agency +@testable import BaseDomain +@testable import BaseDomainTesting + +final class ConfirmCerrificateCodeUseCaseTests: XCTestCase { + var sut: ConfirmCertificateCodeUseCase! + var mockAgencyRepo: MockAgencyRepository! + var mockUserRepo: MockUserRepository! + + override func setUpWithError() throws { + mockAgencyRepo = MockAgencyRepository() + mockUserRepo = MockUserRepository() + sut = ConfirmCertificateCodeUseCase(agencyRepo: mockAgencyRepo, userRepo: mockUserRepo) + } + + override func tearDownWithError() throws { + mockAgencyRepo = nil + mockUserRepo = nil + sut = nil + } + + func test_execute_코드배열로_호출_시_인증에_성공하면_소속을_저장하고_해당_Agency를_반환한다() async { + // Arrange + let agency = Agency(id: 1, name: "몽테스트", count: 1) + mockAgencyRepo.returnValue.certificateCode = CertificationResult(certified: true, agencyId: agency.id) + mockAgencyRepo.returnValue.fetchMyAgency = [agency] + + do { + // Act + let output = try await sut.execute(code: ["1", "2", "3", "4"]) + + // Assert + XCTAssertEqual(output, agency) + XCTAssertEqual(mockAgencyRepo.inputValue.certificateCode, "1234") + XCTAssertEqual(mockUserRepo.inputValue.updateSelectedAgency, agency.id) + XCTAssertEqual(mockUserRepo.callCount.updateSelectedAgency, 1) + } catch { + // Assert + XCTFail() + } + } + + func test_execute_코드배열로_호출_시_인증에_실패하면_nil을_반환한다() async { + // Arrange + mockAgencyRepo.returnValue.certificateCode = CertificationResult(certified: false, agencyId: 0) + + do { + // Act + let output = try await sut.execute(code: ["1", "2", "3", "4"]) + + // Assert + XCTAssertNil(output) + XCTAssertEqual(mockUserRepo.callCount.updateSelectedAgency, 0) + } catch { + // Assert + XCTFail() + } + } + + func test_execute_코드와_소속ID로_호출_시_이미_가입된_소속이면_인증코드_확인없이_소속을_저장하고_해당_Agency를_반환한다() async { + // Arrange + let agency = Agency(id: 1, name: "몽테스트", count: 1) + mockAgencyRepo.returnValue.fetchMyAgency = [agency] + + do { + // Act + let output = try await sut.execute(code: "1234", agencyID: agency.id) + + // Assert + XCTAssertEqual(output, agency) + XCTAssertEqual(mockAgencyRepo.callCount.certificateCode, 0) + XCTAssertEqual(mockUserRepo.inputValue.updateSelectedAgency, agency.id) + } catch { + // Assert + XCTFail() + } + } + + func test_execute_코드와_소속ID로_호출_시_가입되지_않은_소속이고_인증에_성공하면_해당_Agency를_반환한다() async { + // Arrange + let agency = Agency(id: 2, name: "몽테스트", count: 1) + mockAgencyRepo.returnValue.fetchMyAgency = [agency] + mockAgencyRepo.returnValue.certificateCode = CertificationResult(certified: true, agencyId: agency.id) + + do { + // Act + let output = try await sut.execute(code: "1234", agencyID: 999) + + // Assert + XCTAssertEqual(output, agency) + XCTAssertEqual(mockAgencyRepo.callCount.certificateCode, 1) + XCTAssertEqual(mockUserRepo.inputValue.updateSelectedAgency, agency.id) + } catch { + // Assert + XCTFail() + } + } + + func test_execute_코드와_소속ID로_호출_시_가입되지_않은_소속이고_인증에_실패하면_nil을_반환한다() async { + // Arrange + mockAgencyRepo.returnValue.fetchMyAgency = [] + mockAgencyRepo.returnValue.certificateCode = CertificationResult(certified: false, agencyId: 0) + + do { + // Act + let output = try await sut.execute(code: "1234", agencyID: 999) + + // Assert + XCTAssertNil(output) + XCTAssertEqual(mockUserRepo.callCount.updateSelectedAgency, 0) + } catch { + // Assert + XCTFail() + } + } +} From 271c31d9c8016f1a91957fab7a01e26a1790077b Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Mon, 31 Aug 2026 18:34:24 +0900 Subject: [PATCH 14/24] fix: resolve newly joined agency from refetched list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증(가입) 성공 직후 가입 전에 조회한 stale 목록에서 소속을 찾아 새로 가입한 소속은 항상 nil로 반환되던 문제 수정. 코드 배열 오버로드와 동일하게 certificateCode 성공 후 fetchMyAgency를 재호출해 조회한다. --- .../Sources/ConfirmCertificateCodeUseCase.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Projects/Domain/Agency/Sources/ConfirmCertificateCodeUseCase.swift b/Projects/Domain/Agency/Sources/ConfirmCertificateCodeUseCase.swift index a7993e4f..d476ac3e 100644 --- a/Projects/Domain/Agency/Sources/ConfirmCertificateCodeUseCase.swift +++ b/Projects/Domain/Agency/Sources/ConfirmCertificateCodeUseCase.swift @@ -32,15 +32,14 @@ public struct ConfirmCertificateCodeUseCase: ConfirmCertificateCodeUseCaseInterf // 이미 소속에 가입되어 있는 경우 userRepo.updateSelectedAgency(id: agencyID) return agency - } else { - // 소속에 가입되어 있는 않는 경우 - let response = try await agencyRepo.certificateCode(code: code) - if response.certified { - userRepo.updateSelectedAgency(id: response.agencyId) - return agencies.first { $0.id == response.agencyId } - } } - - return nil + + // 소속에 가입되어 있지 않은 경우 + let response = try await agencyRepo.certificateCode(code: code) + guard response.certified else { return nil } + + userRepo.updateSelectedAgency(id: response.agencyId) + // 방금 가입했으므로 가입 전 조회한 `agencies`(stale)가 아니라 재조회한 목록에서 찾는다. + return try await agencyRepo.fetchMyAgency().first { $0.id == response.agencyId } } } From bb19f5a77a6e1488b4e5a18b9ba235b47578ffdd Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Mon, 31 Aug 2026 18:34:30 +0900 Subject: [PATCH 15/24] test: rename ConfirmCertificateCode tests and cover refetch after join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일/클래스명 오타 수정 (Cerrificate -> Certificate) - MockAgencyRepository에 호출 순서별로 다른 값을 반환하는 fetchMyAgencySequence 추가 - '가입되지 않은 소속' 케이스가 fetchMyAgency에 소속을 미리 넣어두어 stale 목록 버그를 가리던 것을, 가입 전 [] / 재조회 시 [agency] 로 구성해 재조회 동작을 실제로 검증하도록 수정 --- ...ts.swift => ConfirmCertificateCodeUseCaseTests.swift} | 9 ++++++--- .../Domain/BaseDomain/Testing/MockAgencyRepository.swift | 6 ++++++ 2 files changed, 12 insertions(+), 3 deletions(-) rename Projects/Domain/Agency/Tests/{ConfirmCerrificateCodeUseCaseTests.swift => ConfirmCertificateCodeUseCaseTests.swift} (88%) diff --git a/Projects/Domain/Agency/Tests/ConfirmCerrificateCodeUseCaseTests.swift b/Projects/Domain/Agency/Tests/ConfirmCertificateCodeUseCaseTests.swift similarity index 88% rename from Projects/Domain/Agency/Tests/ConfirmCerrificateCodeUseCaseTests.swift rename to Projects/Domain/Agency/Tests/ConfirmCertificateCodeUseCaseTests.swift index 75fdf8ec..2e375c98 100644 --- a/Projects/Domain/Agency/Tests/ConfirmCerrificateCodeUseCaseTests.swift +++ b/Projects/Domain/Agency/Tests/ConfirmCertificateCodeUseCaseTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import BaseDomain @testable import BaseDomainTesting -final class ConfirmCerrificateCodeUseCaseTests: XCTestCase { +final class ConfirmCertificateCodeUseCaseTests: XCTestCase { var sut: ConfirmCertificateCodeUseCase! var mockAgencyRepo: MockAgencyRepository! var mockUserRepo: MockUserRepository! @@ -77,10 +77,11 @@ final class ConfirmCerrificateCodeUseCaseTests: XCTestCase { } } - func test_execute_코드와_소속ID로_호출_시_가입되지_않은_소속이고_인증에_성공하면_해당_Agency를_반환한다() async { + func test_execute_코드와_소속ID로_호출_시_가입되지_않은_소속이고_인증에_성공하면_재조회한_목록의_Agency를_반환한다() async { // Arrange let agency = Agency(id: 2, name: "몽테스트", count: 1) - mockAgencyRepo.returnValue.fetchMyAgency = [agency] + // 가입 전 조회에는 해당 소속이 없고, 인증(가입) 후 재조회에서야 등장한다. + mockAgencyRepo.returnValue.fetchMyAgencySequence = [[], [agency]] mockAgencyRepo.returnValue.certificateCode = CertificationResult(certified: true, agencyId: agency.id) do { @@ -90,6 +91,7 @@ final class ConfirmCerrificateCodeUseCaseTests: XCTestCase { // Assert XCTAssertEqual(output, agency) XCTAssertEqual(mockAgencyRepo.callCount.certificateCode, 1) + XCTAssertEqual(mockAgencyRepo.callCount.fetchMyAgency, 2) XCTAssertEqual(mockUserRepo.inputValue.updateSelectedAgency, agency.id) } catch { // Assert @@ -108,6 +110,7 @@ final class ConfirmCerrificateCodeUseCaseTests: XCTestCase { // Assert XCTAssertNil(output) + XCTAssertEqual(mockAgencyRepo.callCount.fetchMyAgency, 1) XCTAssertEqual(mockUserRepo.callCount.updateSelectedAgency, 0) } catch { // Assert diff --git a/Projects/Domain/BaseDomain/Testing/MockAgencyRepository.swift b/Projects/Domain/BaseDomain/Testing/MockAgencyRepository.swift index aefb55fd..7da814c9 100644 --- a/Projects/Domain/BaseDomain/Testing/MockAgencyRepository.swift +++ b/Projects/Domain/BaseDomain/Testing/MockAgencyRepository.swift @@ -29,6 +29,7 @@ public class MockAgencyRepository: AgencyRepositoryInterface { public var create: Int? public var fetchMemberList: [BaseDomain.Member]? public var fetchMyAgency: [BaseDomain.Agency]? + public var fetchMyAgencySequence: [[BaseDomain.Agency]] = [] public var fetchCode: String? public var certificateCode: BaseDomain.CertificationResult? public var reissueCode: String? @@ -79,6 +80,11 @@ public class MockAgencyRepository: AgencyRepositoryInterface { public func fetchMyAgency() async throws -> [BaseDomain.Agency] { callCount.fetchMyAgency += 1 + if !returnValue.fetchMyAgencySequence.isEmpty { + return returnValue.fetchMyAgencySequence.count > 1 + ? returnValue.fetchMyAgencySequence.removeFirst() + : returnValue.fetchMyAgencySequence[0] + } return returnValue.fetchMyAgency! } From e30ac93670ce57516120b9fc12a5184c862f6f86 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Mon, 31 Aug 2026 18:34:37 +0900 Subject: [PATCH 16/24] refactor: distinguish widget deep link from invitation link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위젯 딥링크와 초대 링크가 DeepLinkManager.query 하나를 공유해 소비 순서에 따라 서로 간섭하던 문제 완화. - DeepLinkManager가 마지막 링크 종류(notiName)를 함께 보관하고, 빈 쿼리는 저장/발송하지 않는다 - MainTapViewController는 notiName이 .widgetLink일 때만 재발송/소비 - LoginReactor는 query 존재 여부가 아니라 notiName == .invitationLink 일 때만 회원가입 체크를 건너뛴다 - deeplink 문자열 리터럴을 .widgetLink 상수로 정리 --- .../Feature/Base/Sources/DeepLinkManager.swift | 14 +++++++++----- .../Feature/Base/Sources/NSNotificationName+.swift | 1 + .../Main/Sources/MainTapViewController.swift | 6 +++--- .../Sign/Sources/Scene/Login/LoginReactor.swift | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Projects/Feature/Base/Sources/DeepLinkManager.swift b/Projects/Feature/Base/Sources/DeepLinkManager.swift index 2950f509..79050776 100644 --- a/Projects/Feature/Base/Sources/DeepLinkManager.swift +++ b/Projects/Feature/Base/Sources/DeepLinkManager.swift @@ -1,21 +1,25 @@ import Foundation public struct DeepLinkManager { - + public static var query: [String : Any]? - - // CreateLedger, LedgerDetail + public static var notiName: Notification.Name? + public static func setQuery(_ query: [String : Any], notiName: Notification.Name) { + guard !query.isEmpty else { return } + self.query = query - + self.notiName = notiName + NotificationCenter.default.post( name: notiName, object: nil, userInfo: query ) } - + public static func clear() { query = nil + notiName = nil } } diff --git a/Projects/Feature/Base/Sources/NSNotificationName+.swift b/Projects/Feature/Base/Sources/NSNotificationName+.swift index cdb661f5..02735b8f 100644 --- a/Projects/Feature/Base/Sources/NSNotificationName+.swift +++ b/Projects/Feature/Base/Sources/NSNotificationName+.swift @@ -5,4 +5,5 @@ public extension NSNotification.Name { static let presentManualCreater = NSNotification.Name("presentManualCreater") // 동아리 운영비 등록화면 present static let presentAgencyCreater = NSNotification.Name("presentAgencyCreater") // 소속 생성화면 present static let invitationLink = NSNotification.Name("invitationLink") + static let widgetLink = NSNotification.Name("deeplink") } diff --git a/Projects/Feature/Main/Sources/MainTapViewController.swift b/Projects/Feature/Main/Sources/MainTapViewController.swift index d1c9f948..07237d98 100644 --- a/Projects/Feature/Main/Sources/MainTapViewController.swift +++ b/Projects/Feature/Main/Sources/MainTapViewController.swift @@ -59,7 +59,7 @@ public final class MainTapViewController: UITabBarController { private func observeNotification() { NotificationCenter.default.rx - .notification(.init("deeplink")) + .notification(.widgetLink) .compactMap { noti -> (destination: String, agencyID: Int)? in guard let destination = noti.userInfo?["destination"] as? String, let agencyID = noti.userInfo?["agencyID"] as? Int else { return nil } @@ -80,8 +80,8 @@ public final class MainTapViewController: UITabBarController { } .disposed(by: disposeBag) - if let destination = DeepLinkManager.query { - NotificationCenter.default.post(name: .init("deeplink"), object: nil, userInfo: destination) + if let destination = DeepLinkManager.query, DeepLinkManager.notiName == .widgetLink { + NotificationCenter.default.post(name: .widgetLink, object: nil, userInfo: destination) } } } diff --git a/Projects/Feature/Sign/Sources/Scene/Login/LoginReactor.swift b/Projects/Feature/Sign/Sources/Scene/Login/LoginReactor.swift index 8859a8e4..e4742bd9 100644 --- a/Projects/Feature/Sign/Sources/Scene/Login/LoginReactor.swift +++ b/Projects/Feature/Sign/Sources/Scene/Login/LoginReactor.swift @@ -57,7 +57,7 @@ final class LoginReactor: Reactor { case .login(let loginType): return .task { _ = try await signUpUseCase.execute(loginType: loginType) - if DeepLinkManager.query != nil { return true } + if DeepLinkManager.notiName == .invitationLink { return true } return try await !getMyAgencyUseCase.execute().isEmpty } .map { .setDestination($0 ? .main : .signUp) } From 16d97023150664e19240fdd6a61e180a2684c0cc Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Mon, 31 Aug 2026 18:34:43 +0900 Subject: [PATCH 17/24] fix: validate invitation universal link path and query params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 콜드 스타트(willConnectTo)에서 connectionOptions.userActivities로 전달되는 초대 링크를 처리 - 초대 경로(Config.invitationPath)이고 code, agencyID가 모두 있을 때만 DeepLinkManager에 저장 → 쿼리 없는 링크가 빈 딕셔너리로 저장돼 장부 화면의 requestMyAgencies 바인딩을 막던 문제 방지 - queryItems 순회 중 값 없는 파라미터가 키를 삭제하던 것을 명시적 추출로 교체 - Config에 API 베이스와 분리된 webBase, invitationPath, invitationURL 추가 --- Projects/App/Sources/SceneDelegate.swift | 35 +++++++++++++++----- Projects/Shared/Utility/Sources/Config.swift | 15 +++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index a921254b..3a08919d 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -21,6 +21,7 @@ import Repository import SignFeature import User import UserInterface +import Utility class SceneDelegate: UIResponder, UIWindowSceneDelegate { let localStorage = LocalStorage() @@ -44,6 +45,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { appCoordinator?.start(animated: false) self.scene(scene, openURLContexts: connectionOptions.urlContexts) + + if let userActivity = connectionOptions.userActivities.first( + where: { $0.activityType == NSUserActivityTypeBrowsingWeb } + ) { + handleUniversalLink(from: userActivity) + } } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { @@ -55,21 +62,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { DeepLinkManager.setQuery([ "destination": destination, "agencyID": agencyID - ], notiName: .init("deeplink")) + ], notiName: .widgetLink) } else { KakaoAuthManager.shared.openURL(url) } } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { - guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, - let url = userActivity.webpageURL, - let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return } - var queryItems: [String: Any] = [:] - components.queryItems?.forEach { - queryItems[$0.name] = $0.value - } - DeepLinkManager.setQuery(queryItems, notiName: .invitationLink) + handleUniversalLink(from: userActivity) } func sceneDidDisconnect(_ scene: UIScene) {} @@ -81,6 +81,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneWillEnterForeground(_ scene: UIScene) {} func sceneDidEnterBackground(_ scene: UIScene) {} + + private func handleUniversalLink(from userActivity: NSUserActivity) { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL, + let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + url.path == Config.invitationPath else { return } + + let queryItems = components.queryItems ?? [] + guard let code = queryItems.first(where: { $0.name == "code" })?.value, + let agencyID = queryItems.first(where: { $0.name == "agencyID" })?.value, + !code.isEmpty, !agencyID.isEmpty else { return } + + DeepLinkManager.setQuery( + ["code": code, "agencyID": agencyID], + notiName: .invitationLink + ) + } } extension SceneDelegate { diff --git a/Projects/Shared/Utility/Sources/Config.swift b/Projects/Shared/Utility/Sources/Config.swift index f17a8b3c..44370fc8 100644 --- a/Projects/Shared/Utility/Sources/Config.swift +++ b/Projects/Shared/Utility/Sources/Config.swift @@ -1,7 +1,22 @@ +import Foundation + public enum Config { #if DEBUG public static let base = "https://dev.moneymong.site/api/" + public static let webBase = "https://dev.moneymong.site" #else public static let base = "https://prod.moneymong.site/api/" + public static let webBase = "https://prod.moneymong.site" #endif + + public static let invitationPath = "/invite" + + public static func invitationURL(code: String, agencyID: Int) -> String? { + var components = URLComponents(string: webBase + invitationPath) + components?.queryItems = [ + URLQueryItem(name: "code", value: code), + URLQueryItem(name: "agencyID", value: String(agencyID)) + ] + return components?.url?.absoluteString + } } From a63a5828b41ff7b5cd2185e715491c5ccad171f1 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Mon, 31 Aug 2026 18:34:47 +0900 Subject: [PATCH 18/24] fix: build invite link from web host instead of API base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config.base(REST API 베이스)로 링크를 만들어 .../api/?code=... 형태가 되던 것을 수정. Config.invitationURL로 웹 호스트 + 초대 경로 기반의 링크를 생성한다. --- .../TabItems/Member/MemberList/MemberTabReactor.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift index 6356ecde..78273e2e 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift @@ -160,13 +160,9 @@ final class MemberTabReactor: Reactor { .catch { return .just(.setError($0.toMMError)) } case .didTapInviteButton: guard let code = currentState.invitationCode, - let agencyID = currentState.agencyID else { return .empty() } - var components = URLComponents(string: Config.base) - components?.queryItems = [ - URLQueryItem(name: "code", value: code), - URLQueryItem(name: "agencyID", value: String(agencyID)) - ] - let content = components?.url?.absoluteString ?? "" + let agencyID = currentState.agencyID, + let content = Config.invitationURL(code: code, agencyID: agencyID) + else { return .empty() } return .just(.setDestination(.sharedSheet(content: content))) } } From 36fb5dc063c815766d30e0c5586141e9f4d99633 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Mon, 31 Aug 2026 18:34:55 +0900 Subject: [PATCH 19/24] fix: always load agencies on ledger entry with invite fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LedgerVC가 대기 중인 초대 query가 있으면 requestMyAgencies 바인딩을 통째로 건너뛰어, 초대 파싱 실패/위젯 딥링크와의 간섭 시 장부 탭이 빈 화면으로 멈추던 문제 수정. viewDidLoad에서 항상 한 개의 액션(.invite 또는 .requestMyAgencies)을 디스패치하도록 정리하고, bind 내부의 동기 NotificationCenter.post 제거 - LedgerReactor.invite가 결과 agency가 nil이거나 네트워크 오류일 때 setAgency(nil)로 '소속 없음' 화면이 되던 것을, 기존 소속 목록 조회로 폴백하도록 변경 - requestMyAgencies/invite의 소속 선택 부수효과를 applySelected로 일원화하고 map 대신 do(onNext:)로 분리 --- .../Sources/Scene/Ledger/LedgerReactor.swift | 71 +++++++++++-------- .../Sources/Scene/Ledger/LedgerVC.swift | 39 +++++----- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerReactor.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerReactor.swift index ccc14ded..5ddea681 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerReactor.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerReactor.swift @@ -55,38 +55,52 @@ public final class LedgerReactor: Reactor { switch action { case .requestMyAgencies: return .concat( - .just(.setLoading(true)), - .task { - try await getMyAgencyUseCase.execute() - } - .map { [weak self] agencies in - let agencyID = self?.getSelectedAgencyUseCase.execute() - let agency = agencies.first(where: { $0.id == agencyID }) ?? agencies.first - - if let agency { - self?.updateSelectedAgencyUseCase.execute(id: agency.id) - self?.service.agency.updateAgency(agency) - } - - return .setAgency(agency) - } - .catch { return .just(.setError($0.toMMError)) }, - .just(.setLoading(false)) - ) + .just(.setLoading(true)), + requestMyAgenciesMutation(), + .just(.setLoading(false)) + ) case .invite(let code, let agencyID): return .concat( - .just(.setLoading(true)), - .task { - try await confirmCertificateCodeUseCase.execute(code: code, agencyID: agencyID) - } - .map { [weak self] agency in - self?.service.agency.updateAgency(agency) - return .setAgency(agency) - }.catch { return .just(.setError($0.toMMError)) }, - .just(.setLoading(false)) - ) + .just(.setLoading(true)), + .task { + try await confirmCertificateCodeUseCase.execute(code: code, agencyID: agencyID) + } + .flatMap { [weak self] agency -> Observable in + guard let self else { return .empty() } + guard let agency else { return requestMyAgenciesMutation() } + applySelected(agency) + return .just(.setAgency(agency)) + } + .catch { [weak self] error in + guard let self else { return .just(.setError(error.toMMError)) } + return .concat( + .just(.setError(error.toMMError)), + self.requestMyAgenciesMutation() + ) + }, + .just(.setLoading(false)) + ) } } + + private func requestMyAgenciesMutation() -> Observable { + return .task { try await getMyAgencyUseCase.execute() } + .map { [weak self] agencies -> Agency? in + let selectedID = self?.getSelectedAgencyUseCase.execute() + return agencies.first(where: { $0.id == selectedID }) ?? agencies.first + } + .do(onNext: { [weak self] agency in + guard let agency else { return } + self?.applySelected(agency) + }) + .map { .setAgency($0) } + .catch { .just(.setError($0.toMMError)) } + } + + private func applySelected(_ agency: Agency) { + updateSelectedAgencyUseCase.execute(id: agency.id) + service.agency.updateAgency(agency) + } public func reduce(state: State, mutation: Mutation) -> State { var newState = state @@ -110,7 +124,6 @@ public final class LedgerReactor: Reactor { return .just(.setAgency(agency)) } } - return .merge(stream, mutation) } } diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift index 81f239af..650ec167 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/LedgerVC.swift @@ -71,25 +71,26 @@ public final class LedgerVC: BaseVC, View { setTitle(agencyButton) NotificationCenter.default.rx.notification(.invitationLink) - .compactMap { noti -> (code: String, agencyID: Int)? in - guard let code = noti.userInfo?["code"] as? String, - let agencyID = noti.userInfo?["agencyID"] as? Int else { return nil } - return (code, agencyID) - } - .map { Reactor.Action.invite(code: $0.code, agencyID: $0.agencyID) } - .do { _ in DeepLinkManager.clear() } + .compactMap { Self.inviteAction(code: $0.userInfo?["code"], agencyID: $0.userInfo?["agencyID"]) } + .do(onNext: { _ in DeepLinkManager.clear() }) .bind(to: reactor.action) .disposed(by: disposeBag) - if let query = DeepLinkManager.query { - NotificationCenter.default.post(name: .invitationLink, object: nil, userInfo: query) - } else { - rx.viewDidLoad - .map { Reactor.Action.requestMyAgencies } - .bind(to: reactor.action) - .disposed(by: disposeBag) - } - + rx.viewDidLoad + .map { _ -> Reactor.Action in + if DeepLinkManager.notiName == .invitationLink, + let action = Self.inviteAction( + code: DeepLinkManager.query?["code"], + agencyID: DeepLinkManager.query?["agencyID"] + ) { + DeepLinkManager.clear() + return action + } + return .requestMyAgencies + } + .bind(to: reactor.action) + .disposed(by: disposeBag) + rx.viewDidLoad .bind(with: self, onNext: { owner, _ in owner.coordinator?.moveTab = { owner.lineTab.currentPage = $0 } @@ -133,4 +134,10 @@ public final class LedgerVC: BaseVC, View { .bind(to: rx.isLoading) .disposed(by: disposeBag) } + + private static func inviteAction(code: Any?, agencyID: Any?) -> LedgerReactor.Action? { + guard let code = code as? String, !code.isEmpty, + let agencyID = Int(agencyID as? String ?? "") else { return nil } + return .invite(code: code, agencyID: agencyID) + } } From b4e74cf693374fc4affd6a187d017d734423631e Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Mon, 31 Aug 2026 18:34:59 +0900 Subject: [PATCH 20/24] fix: show invite link button for non-member roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit staff 역할에만 노출하던 초대 링크 버튼과 여백 처리를 member가 아닌 모든 역할(운영진 포함)에 적용 --- .../Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift index d2a09963..5b6cb4cd 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabVC.swift @@ -128,8 +128,8 @@ final class MemberTabVC: BaseVC, View { .bind(with: self) { owner, element in owner.profileView.configure(title: element.name, role: element.role, code: element.code) owner.invitationLinkButton.isHidden = element.role == .member - owner.invitationLinkButton.flex.isIncludedInLayout(element.role == .staff).markDirty() - owner.profileView.flex.marginBottom(element.role == .staff ? 16 : 24).markDirty() + owner.invitationLinkButton.flex.isIncludedInLayout(element.role != .member).markDirty() + owner.profileView.flex.marginBottom(element.role != .member ? 16 : 24).markDirty() } .disposed(by: disposeBag) From 1132ee2b8d59c7607bc13c9f1898f57b6e9191a4 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Thu, 10 Sep 2026 20:26:53 +0900 Subject: [PATCH 21/24] feat: personalize invitation share message with inviter and agency name Send a full invitation message (inviter name + agency name + link) in the share sheet instead of a bare URL. Drop getSelectedAgencyUseCase and take the agency id/name from the ledger service update stream instead. --- .../Ledger/Sources/Common/LedgerFactory.swift | 1 - .../Member/MemberList/MemberTabReactor.swift | 32 +++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Projects/Feature/Ledger/Sources/Common/LedgerFactory.swift b/Projects/Feature/Ledger/Sources/Common/LedgerFactory.swift index 329d6965..f06b666e 100644 --- a/Projects/Feature/Ledger/Sources/Common/LedgerFactory.swift +++ b/Projects/Feature/Ledger/Sources/Common/LedgerFactory.swift @@ -44,7 +44,6 @@ struct LedgerFactory { let vc = MemberTabVC() vc.reactor = MemberTabReactor( getUserIDUseCase: DIContainer.shared.resolve(type: GetUserIDUseCaseInterface.self), - getSelectedAgencyUseCase: DIContainer.shared.resolve(type: GetSelectedAgencyUseCaseInterface.self), reissueCodeUseCase: DIContainer.shared.resolve(type: ReissueCodeUseCaseInterface.self), kickoutMemberUseCase: DIContainer.shared.resolve(type: KickoutMemberUseCaseInterface.self), deleteAgencyUseCase: DIContainer.shared.resolve(type: DeleteAgencyUseCaseInterface.self), diff --git a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift index 78273e2e..f70e5fc0 100644 --- a/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift +++ b/Projects/Feature/Ledger/Sources/Scene/Ledger/TabItems/Member/MemberList/MemberTabReactor.swift @@ -13,7 +13,8 @@ final class MemberTabReactor: Reactor { struct State { let userID: Int - @Pulse var agencyID: Int? + @Pulse var agencyID: Int? = nil + @Pulse var agecnyName: String? @Pulse var name: String? @Pulse var role: Member.Role? @@ -45,7 +46,7 @@ final class MemberTabReactor: Reactor { enum Mutation { case setName(String) - case setAgencyID(Int) + case setAgency(id: Int, name: String) case setMembers([Member]) case setRole(Member.Role) case setInvitationCode(String) @@ -58,7 +59,6 @@ final class MemberTabReactor: Reactor { let initialState: State private let getUserIDUseCase: GetUserIDUseCaseInterface - private let getSelectedAgencyUseCase: GetSelectedAgencyUseCaseInterface private let reissueCodeUseCase: ReissueCodeUseCaseInterface private let kickoutMemberUseCase: KickoutMemberUseCaseInterface private let deleteAgencyUseCase: DeleteAgencyUseCaseInterface @@ -70,7 +70,6 @@ final class MemberTabReactor: Reactor { init( getUserIDUseCase: GetUserIDUseCaseInterface, - getSelectedAgencyUseCase: GetSelectedAgencyUseCaseInterface, reissueCodeUseCase: ReissueCodeUseCaseInterface, kickoutMemberUseCase: KickoutMemberUseCaseInterface, deleteAgencyUseCase: DeleteAgencyUseCaseInterface, @@ -80,7 +79,6 @@ final class MemberTabReactor: Reactor { ledgerService: LedgerServiceInterface ) { self.getUserIDUseCase = getUserIDUseCase - self.getSelectedAgencyUseCase = getSelectedAgencyUseCase self.reissueCodeUseCase = reissueCodeUseCase self.kickoutMemberUseCase = kickoutMemberUseCase self.deleteAgencyUseCase = deleteAgencyUseCase @@ -91,7 +89,6 @@ final class MemberTabReactor: Reactor { self.initialState = .init( userID: getUserIDUseCase.execute(), - agencyID: getSelectedAgencyUseCase.execute() ) } @@ -159,10 +156,7 @@ final class MemberTabReactor: Reactor { } .catch { return .just(.setError($0.toMMError)) } case .didTapInviteButton: - guard let code = currentState.invitationCode, - let agencyID = currentState.agencyID, - let content = Config.invitationURL(code: code, agencyID: agencyID) - else { return .empty() } + guard let content = makeInvitationContent() else { return .empty() } return .just(.setDestination(.sharedSheet(content: content))) } } @@ -173,8 +167,9 @@ final class MemberTabReactor: Reactor { case let .setName(name): newState.name = name - case let .setAgencyID(id): + case let .setAgency(id, name): newState.agencyID = id + newState.agecnyName = name case let .setInvitationCode(code): newState.invitationCode = code @@ -229,9 +224,9 @@ final class MemberTabReactor: Reactor { case let .update(agency): if currentState.agencyID == agency?.id { return .empty() - } else if let id = agency?.id { + } else if let id = agency?.id, let name = agency?.name { return .concat( - .just(.setAgencyID(id)), + .just(.setAgency(id: id, name: name)), .just(.setLoading(true)), requestInvitationCode(agencyID: id), requestMembers(agencyID: id), @@ -284,4 +279,15 @@ final class MemberTabReactor: Reactor { ) } } + + private func makeInvitationContent() -> String? { + guard let code = currentState.invitationCode, + let agencyID = currentState.agencyID, + let invitationURL = Config.invitationURL(code: code, agencyID: agencyID), + let userName = currentState.name, + let angecyName = currentState.agecnyName else { return nil } + + return "\(userName)님이 \(angecyName) 장부에 초대했어요.\n초대 받고 함께 쓴 돈을 같이 관리해봐요!\n\n\(invitationURL)" + + } } From 2aeb6baf0e3561fc38206cfd15ccef2fa1f39d63 Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Thu, 10 Sep 2026 20:26:56 +0900 Subject: [PATCH 22/24] chore: log network responses as public and drop unused queue Mark the network log strings as .public so responses are readable in Console/logs, and remove the unused DispatchQueue property. --- Projects/Core/MMNetwork/Sources/NetworkLogger.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Projects/Core/MMNetwork/Sources/NetworkLogger.swift b/Projects/Core/MMNetwork/Sources/NetworkLogger.swift index 03d677b4..92d8406f 100644 --- a/Projects/Core/MMNetwork/Sources/NetworkLogger.swift +++ b/Projects/Core/MMNetwork/Sources/NetworkLogger.swift @@ -4,8 +4,6 @@ import OSLog import Alamofire final class NetworkLogger: EventMonitor { - let queue = DispatchQueue(label: "NetworkLogger") - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "NetworkLogger") func request(_ request: DataRequest, didParseResponse response: DataResponse) where Value : Sendable { @@ -34,9 +32,9 @@ final class NetworkLogger: EventMonitor { log.append("\(response.data?.toPrettyPrintedString ?? "None")") if (200..<300) ~= statusCode { - logger.log("\(log)") + logger.log("\(log, privacy: .public)") } else { - logger.error("\(log)") + logger.error("\(log, privacy: .public)") } } From 117172b9e8dad0c8976f59ce90df551066ef386d Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Thu, 10 Sep 2026 20:27:05 +0900 Subject: [PATCH 23/24] chore: bump marketing version to 2.2.1 --- Projects/App/Project.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index f9a4d5a7..c0c29193 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -8,7 +8,7 @@ let project = Project( ), settings: .settings( base: .init() - .marketingVersion("2.2.0") + .marketingVersion("2.2.1") .swiftVersion("5.7") .currentProjectVersion("1") .appleGenericVersioningSystem() From 1fef2e64d103c35c30b1e80cde5ce61466b1744f Mon Sep 17 00:00:00 2001 From: Siwon-L <111dltldnjs@gmail.com> Date: Thu, 10 Sep 2026 20:27:08 +0900 Subject: [PATCH 24/24] chore: drop profile id suffix from provisioning specifier Reference the match profiles by name only (match Development/AppStore com.yapp.moneymong) so regenerated profile ids don't need updating here. --- Projects/App/Project.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index c0c29193..be5d2f50 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -118,11 +118,11 @@ let project = Project( configurations: [ .debug(name: "Debug", settings: [ "CODE_SIGN_IDENTITY": "Apple Development: Nayeon Gu (3CMPGMMD7L)", - "PROVISIONING_PROFILE_SPECIFIER": "match Development com.yapp.moneymong 1769511003" + "PROVISIONING_PROFILE_SPECIFIER": "match Development com.yapp.moneymong" ]), .release(name: "Release", settings: [ "CODE_SIGN_IDENTITY": "Apple Distribution: Nayeon Gu (H5G7RFWFSQ)", - "PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.yapp.moneymong 1769511386" + "PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.yapp.moneymong" ]) ] ),