From def61fde2d6c384d79946de1013b6079e53f4aa7 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Sun, 12 Jul 2026 13:51:23 -0400 Subject: [PATCH] feat: add the Add Money analytics funnel --- .../Deep Links/DeepLinkController.swift | 2 +- Flipcash/Core/Navigation/AppRouter.swift | 6 +- .../Conversation/ConversationScreen.swift | 2 +- .../AddMoney/AddMoneyAmountViewModel.swift | 18 +++++ .../AddMoneyProcessingViewModel.swift | 12 +++ .../Main/AddMoney/AddMoneyStartScreen.swift | 1 + .../AddMoney/CoinbaseDepositOperation.swift | 1 + .../AddMoney/PhantomDepositOperation.swift | 1 + .../Core/Screens/Main/BalanceScreen.swift | 4 +- .../Screens/Main/Buy/BuyAmountViewModel.swift | 4 +- .../CurrencyCreationSummaryScreen.swift | 2 +- .../CurrencyCreationWizardScreen.swift | 2 +- .../Currency Info/CurrencyInfoScreen.swift | 2 +- Flipcash/Core/Screens/Main/ScanScreen.swift | 2 +- .../Settings/Deposit/DepositScreen.swift | 10 ++- .../Screens/Settings/SettingsScreen.swift | 2 +- Flipcash/UI/DialogItem+CashFlows.swift | 7 +- Flipcash/Utilities/Events.swift | 81 +++++++++++++++++++ .../AddMoneyAmountViewModelTests.swift | 27 +++++++ .../AddMoney/AddMoneyRoutingTests.swift | 14 ++-- .../Analytics/AddMoneyEventsTests.swift | 47 +++++++++++ 21 files changed, 221 insertions(+), 26 deletions(-) create mode 100644 FlipcashTests/Analytics/AddMoneyEventsTests.swift diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 573074b9f..fa1dce539 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -315,7 +315,7 @@ struct DeepLinkAction { if sheet == .give { let rate = container.ratesController.rateForBalanceCurrency() let gate = giveCashGate(session: container.session, rate: rate) - if let dialog = gate.blockingDialog(router: container.appRouter) { + if let dialog = gate.blockingDialog(router: container.appRouter, addMoneySource: .giveShortfall) { container.session.dialogItem = dialog return } diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 034f4d193..ff8dd38ec 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -322,8 +322,10 @@ final class AppRouter { } /// Presents the Add Money sheet — as the root sheet when nothing is - /// presented, stacked on top of the current sheet otherwise. - func presentAddMoney(_ context: AddMoneyContext) { + /// presented, stacked on top of the current sheet otherwise. `source` + /// names the entry point on the funnel's opened event. + func presentAddMoney(_ context: AddMoneyContext, source: Analytics.AddMoneySource) { + Analytics.addMoneyOpened(source: source) if presentedSheets.isEmpty { present(.addMoney(context)) } else { diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 5062f4d23..c827ca588 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -335,7 +335,7 @@ struct ConversationScreen: View { private func sendCash() { guard let sendTarget else { return } let rate = ratesController.rateForBalanceCurrency() - if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router) { + if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .chat) { session.dialogItem = dialog return } diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift index aa84c52c3..30eb62755 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyAmountViewModel.swift @@ -112,6 +112,7 @@ final class AddMoneyAmountViewModel { verificationCoordinator: VerificationCoordinator, onProceed: @escaping (AddMoneyProcessingInput) -> Void ) { + Analytics.addMoneyAmountConfirmed(method: .coinbase, exchangedFiat: amount) verificationCoordinator.runGated( for: session, bind: { [weak self] vm in self?.verificationViewModel = vm } @@ -134,6 +135,12 @@ final class AddMoneyAmountViewModel { ) } } catch let DepositError.externalRejected(title, subtitle) { + Analytics.addMoney( + method: .coinbase, + exchangedFiat: amount, + successful: false, + error: DepositError.externalRejected(title: title, subtitle: subtitle) + ) self?.dialogItem = .error(title: title, subtitle: subtitle) } catch { logger.error("Coinbase deposit failed", metadata: [ @@ -142,6 +149,7 @@ final class AddMoneyAmountViewModel { "orderId": "\(operation.orderId ?? "nil")", ]) ErrorReporting.captureError(error) + Analytics.addMoney(method: .coinbase, exchangedFiat: amount, successful: false, error: error) self?.dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again later") } } @@ -153,6 +161,7 @@ final class AddMoneyAmountViewModel { walletConnection: any TransactionSigning, onProceed: @escaping (AddMoneyProcessingInput) -> Void ) { + Analytics.addMoneyAmountConfirmed(method: .phantom, exchangedFiat: amount) let operation = PhantomDepositOperation(walletConnection: walletConnection) depositTask = Task { [weak self, operation] in self?.actionButtonState = .loading @@ -163,7 +172,15 @@ final class AddMoneyAmountViewModel { onProceed(.init(amount: amount, method: .phantom, depositRef: operation.submittedSignature)) } catch is CancellationError { // User dismissed the Phantom flow — silent. + } catch DepositError.userCancelled { + // User rejected the transaction in Phantom — silent. } catch let DepositError.externalRejected(title, subtitle) { + Analytics.addMoney( + method: .phantom, + exchangedFiat: amount, + successful: false, + error: DepositError.externalRejected(title: title, subtitle: subtitle) + ) self?.dialogItem = .error(title: title, subtitle: subtitle) } catch { logger.error("Phantom deposit failed", metadata: [ @@ -171,6 +188,7 @@ final class AddMoneyAmountViewModel { "amount": "\(amount.nativeAmount.formatted())", ]) ErrorReporting.captureError(error) + Analytics.addMoney(method: .phantom, exchangedFiat: amount, successful: false, error: error) self?.dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again later") } } diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift index f07b81fdc..f6bfae25d 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift @@ -121,6 +121,7 @@ final class AddMoneyProcessingViewModel { "depositRef": "\(input.depositRef ?? "nil")", ]) displayState = .success + Analytics.addMoney(method: input.method, exchangedFiat: input.amount, successful: true, error: nil) return } @@ -138,5 +139,16 @@ final class AddMoneyProcessingViewModel { "depositRef": "\(input.depositRef ?? "nil")", ]) displayState = .failed + Analytics.addMoney( + method: input.method, + exchangedFiat: input.amount, + successful: false, + error: SettlementError.deliveryTimedOut + ) + } + + /// The USDF credit never arrived within the poll deadline. + private enum SettlementError: Error { + case deliveryTimedOut } } diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyStartScreen.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyStartScreen.swift index 410447b7a..786638e8b 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyStartScreen.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyStartScreen.swift @@ -46,6 +46,7 @@ struct AddMoneyStartScreen: View { /// Over the buy amount sheet the deposit flow pushes onto that sheet's /// stack; everywhere else it opens as its own sheet on top. private func select(_ method: DepositMethod) { + Analytics.addMoneyMethodSelected(method: method) if router.isAddMoneyOverBuy { router.dismissSheet() router.pushAny(AddMoneyFlowStep.method(method)) diff --git a/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift b/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift index c0b37962a..4c9adef42 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift @@ -79,6 +79,7 @@ final class CoinbaseDepositOperation { state = .working let order = try await createOrder(for: amount) orderId = order.id + Analytics.addMoneyPaymentInvoked(method: .coinbase, exchangedFiat: amount) coinbaseService.setOrder(order) defer { coinbaseService.clearOrder() } diff --git a/Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift b/Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift index 1d6ed51a1..84d6e131e 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/PhantomDepositOperation.swift @@ -104,6 +104,7 @@ final class PhantomDepositOperation { swapId: swapId, displayName: "USDF" ) + Analytics.addMoneyPaymentInvoked(method: .phantom, exchangedFiat: amount) let signedTx = try await awaitSignedTransaction(swapId: swapId) diff --git a/Flipcash/Core/Screens/Main/BalanceScreen.swift b/Flipcash/Core/Screens/Main/BalanceScreen.swift index 353b80edd..4590dff2a 100644 --- a/Flipcash/Core/Screens/Main/BalanceScreen.swift +++ b/Flipcash/Core/Screens/Main/BalanceScreen.swift @@ -149,7 +149,7 @@ private struct BalanceScreenContent: View { .frame(maxWidth: .infinity, alignment: .center) BubbleButton(text: "Add Money") { - router.presentAddMoney(.general) + router.presentAddMoney(.general, source: .balance) } .padding(.top, 8) } @@ -195,7 +195,7 @@ private struct BalanceScreenContent: View { } footer: { if hasBalances { CodeButton(style: .filledSecondary, title: "Add Money") { - router.presentAddMoney(.general) + router.presentAddMoney(.general, source: .balance) } .padding(.horizontal, 20) .padding(.top, 20) diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index 462d19e52..d8a07e40f 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -73,7 +73,7 @@ final class BuyAmountViewModel: Identifiable { break case .insufficient: session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) { - router.presentAddMoney(.buyCurrency) + router.presentAddMoney(.buyCurrency, source: .buyShortfall) } return } @@ -120,7 +120,7 @@ final class BuyAmountViewModel: Identifiable { // Race: the balance gate said OK but the reserves buy disagreed. actionButtonState = .normal session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) { - router.presentAddMoney(.buyCurrency) + router.presentAddMoney(.buyCurrency, source: .buyShortfall) } } catch Session.Error.verifiedStateStale { actionButtonState = .normal diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationSummaryScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationSummaryScreen.swift index 908ad75f8..a29a06f4d 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationSummaryScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationSummaryScreen.swift @@ -53,7 +53,7 @@ struct CurrencyCreationSummaryScreen: View { Button("Get Started") { if shouldAddMoneyBeforeLaunch(session: session, launchCost: totalLaunchCost.onChainAmount) { session.dialogItem = .noBalance(subtitle: AddMoneyContext.createCurrency.noBalanceSubtitle) { - router.presentAddMoney(.createCurrency) + router.presentAddMoney(.createCurrency, source: .buyShortfall) } } else { router.push(.currencyCreationWizard) diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift index 0cd5b77c5..7447e2bc2 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -634,7 +634,7 @@ struct CurrencyCreationWizardScreen: View { launchAndBuyWithReserves() } else { session.dialogItem = .noBalance(subtitle: AddMoneyContext.createCurrency.noBalanceSubtitle) { - router.presentAddMoney(.createCurrency) + router.presentAddMoney(.createCurrency, source: .buyShortfall) } } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 2312b1e9e..c53f10708 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -178,7 +178,7 @@ private struct CurrencyInfoScreenContent: View { private func presentBuyOrAddMoney() { if shouldAddMoneyBeforeBuy(session: session) { session.dialogItem = .noBalance(subtitle: AddMoneyContext.buyCurrency.noBalanceSubtitle) { - router.presentAddMoney(.buyCurrency) + router.presentAddMoney(.buyCurrency, source: .buyShortfall) } } else { router.presentNested(.buy(mint)) diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index edfb337f9..05c761312 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -282,7 +282,7 @@ private struct ScanScreenContent: View { private func presentGive() { let rate = sessionContainer.ratesController.rateForBalanceCurrency() - if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router) { + if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .scanner) { session.dialogItem = dialog return } diff --git a/Flipcash/Core/Screens/Settings/Deposit/DepositScreen.swift b/Flipcash/Core/Screens/Settings/Deposit/DepositScreen.swift index 03ef6c652..9e392df7a 100644 --- a/Flipcash/Core/Screens/Settings/Deposit/DepositScreen.swift +++ b/Flipcash/Core/Screens/Settings/Deposit/DepositScreen.swift @@ -14,10 +14,12 @@ struct DepositScreen: View { private let address: String private let name: String? + private let mint: PublicKey - init(address: String, name: String?) { + init(address: String, name: String?, mint: PublicKey) { self.address = address self.name = name + self.mint = mint } var body: some View { @@ -52,6 +54,7 @@ struct DepositScreen: View { private func copyAddress() { UIPasteboard.general.string = address + Analytics.addMoneyAddressCopied(mint: mint) buttonState = .successText("Copied") Task { try? await Task.sleep(for: .seconds(1.5)) @@ -69,7 +72,7 @@ extension DepositScreen { /// exist on-chain yet, so wallets derive another ATA and funds land one /// level deeper than the server queries. static func usdcDeposit(session: Session) -> DepositScreen { - DepositScreen(address: session.owner.authorityPublicKey.base58, name: "USDC") + DepositScreen(address: session.owner.authorityPublicKey.base58, name: "USDC", mint: .usdc) } /// The deposit screen for `mint`'s derived deposit ATA, or `nil` when @@ -79,7 +82,8 @@ extension DepositScreen { let vmAuthority = balance.vmAuthority else { return nil } return DepositScreen( address: session.owner.use(mint: mint, timeAuthority: vmAuthority).depositPublicKey.base58, - name: mint == .usdf ? balance.symbol : balance.name + name: mint == .usdf ? balance.symbol : balance.name, + mint: mint ) } } diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index cf8cc79a1..e34aed03a 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -28,7 +28,7 @@ struct SettingsScreen: View { VStack(spacing: 6) { HStack(spacing: 12) { Button("Add Money") { - router.presentAddMoney(.general) + router.presentAddMoney(.general, source: .menu) } .buttonStyle(.card(icon: .deposit)) diff --git a/Flipcash/UI/DialogItem+CashFlows.swift b/Flipcash/UI/DialogItem+CashFlows.swift index b9fb2fa09..ff9fff328 100644 --- a/Flipcash/UI/DialogItem+CashFlows.swift +++ b/Flipcash/UI/DialogItem+CashFlows.swift @@ -45,9 +45,10 @@ extension DialogItem { extension GiveCashGate { /// The blocking dialog for this gate outcome, or `nil` to proceed into - /// the flow. + /// the flow. `addMoneySource` names the hosting entry point on the Add + /// Money funnel's opened event. @MainActor - func blockingDialog(router: AppRouter) -> DialogItem? { + func blockingDialog(router: AppRouter, addMoneySource: Analytics.AddMoneySource) -> DialogItem? { switch self { case .proceed: nil @@ -55,7 +56,7 @@ extension GiveCashGate { .noCommunityCurrencies { router.present(.discover) } case .addMoney: .noBalance(subtitle: AddMoneyContext.giveCash.noBalanceSubtitle) { - router.presentAddMoney(.giveCash) + router.presentAddMoney(.giveCash, source: addMoneySource) } } } diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 9ae179db5..3f1cdc8de 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -100,6 +100,29 @@ extension Analytics { case parse = "Deeplink: Parse" case routed = "Deeplink: Routed" } + + /// The Add Money funnel, modeled after the transfer pattern — a single + /// terminal event with State/Error properties. Names are shared verbatim + /// with Android. + enum AddMoneyEvent: String, AnalyticsEvent { + case opened = "Add Money: Opened" + case methodSelected = "Add Money: Method Selected" + case amountConfirmed = "Add Money: Amount Confirmed" + case paymentInvoked = "Add Money: Payment Invoked" + case addressCopied = "Add Money: Address Copied" + case terminal = "Add Money" + } + + /// The `Source` property of `AddMoneyEvent.opened` — where the user + /// entered the flow. Values are shared verbatim with Android. + enum AddMoneySource: String { + case menu = "Menu" + case giveShortfall = "Give Shortfall" + case buyShortfall = "Buy Shortfall" + case chat = "Chat" + case scanner = "Scanner" + case balance = "Balance" + } } // MARK: - General - @@ -192,6 +215,62 @@ extension Analytics { } } +// MARK: - Add Money - + +extension Analytics { + static func addMoneyOpened(source: AddMoneySource) { + track(event: AddMoneyEvent.opened, properties: [.source: source.rawValue]) + } + + static func addMoneyMethodSelected(method: DepositMethod) { + track(event: AddMoneyEvent.methodSelected, properties: [.method: method.analyticsValue]) + } + + static func addMoneyAmountConfirmed(method: DepositMethod, exchangedFiat: ExchangedFiat) { + var properties = amountProperties(exchangedFiat) + properties[.method] = method.analyticsValue + track(event: AddMoneyEvent.amountConfirmed, properties: properties) + } + + static func addMoneyPaymentInvoked(method: DepositMethod, exchangedFiat: ExchangedFiat) { + var properties = amountProperties(exchangedFiat) + properties[.method] = method.analyticsValue + track(event: AddMoneyEvent.paymentInvoked, properties: properties) + } + + static func addMoneyAddressCopied(mint: PublicKey) { + track(event: AddMoneyEvent.addressCopied, properties: [.mint: mint.base58]) + } + + static func addMoney(method: DepositMethod, exchangedFiat: ExchangedFiat?, successful: Bool, error: Error?) { + var properties: [Property: AnalyticsValue] = exchangedFiat.map(amountProperties) ?? [:] + properties[.method] = method.analyticsValue + properties[.state] = successful ? String.success : String.failure + track(event: AddMoneyEvent.terminal, properties: properties, error: error) + } + + private static func amountProperties(_ exchangedFiat: ExchangedFiat) -> [Property: AnalyticsValue] { + [ + .mint: exchangedFiat.mint.base58, + .quarks: exchangedFiat.onChainAmount.quarks.analyticsValue, + .fiat: exchangedFiat.nativeAmount.doubleValue, + .fx: exchangedFiat.currencyRate.fx.analyticsValue, + .currency: exchangedFiat.currencyRate.currency.rawValue, + ] + } +} + +extension DepositMethod { + /// The `Method` property value, shared verbatim with Android. + var analyticsValue: String { + switch self { + case .coinbase: "Coinbase" + case .phantom: "Phantom" + case .otherWallet: "Other Wallet" + } + } +} + // MARK: - Onramp - extension Analytics { @@ -326,6 +405,8 @@ extension Analytics { case time = "Time" case state = "State" + case source = "Source" + case method = "Method" case quarks = "Quarks" case mint = "Mint" case fiat = "Fiat" diff --git a/FlipcashTests/AddMoney/AddMoneyAmountViewModelTests.swift b/FlipcashTests/AddMoney/AddMoneyAmountViewModelTests.swift index 5e7d19319..66fd2e0f9 100644 --- a/FlipcashTests/AddMoney/AddMoneyAmountViewModelTests.swift +++ b/FlipcashTests/AddMoney/AddMoneyAmountViewModelTests.swift @@ -95,4 +95,31 @@ struct AddMoneyAmountViewModelTests { let viewModel = Self.makeViewModel(method: method, container: container) #expect(viewModel.actionTitle == title) } + + @Test("Rejecting the sign request in Phantom stays silent — no error dialog") + func phantomDeposit_userCancelled_staysSilent() async throws { + let container = try Self.makeContainer() + let viewModel = Self.makeViewModel(method: .phantom, container: container) + let wallet = MockTransactionSigning() + var proceeded = false + + viewModel.enteredAmount = "10" + viewModel.addMoney( + coinbaseService: CoinbaseService(coinbase: MockOnrampOrdering()), + verificationCoordinator: VerificationCoordinator( + session: container.session, + flipClient: Container.mock.flipClient, + deeplinkInbox: OnrampDeeplinkInbox() + ), + walletConnection: wallet, + onProceed: { _ in proceeded = true } + ) + + try await waitUntil(wallet) { $0.sendSignRequestCalls.count == 1 } + wallet.yieldDeeplinkEvent(.userCancelled) + try await waitUntil(viewModel) { $0.actionButtonState == .normal } + + #expect(viewModel.dialogItem == nil) + #expect(!proceeded) + } } diff --git a/FlipcashTests/AddMoney/AddMoneyRoutingTests.swift b/FlipcashTests/AddMoney/AddMoneyRoutingTests.swift index 2678ed296..54bf20d53 100644 --- a/FlipcashTests/AddMoney/AddMoneyRoutingTests.swift +++ b/FlipcashTests/AddMoney/AddMoneyRoutingTests.swift @@ -20,7 +20,7 @@ struct AddMoneyRoutingTests { @Test("With no sheet presented, Add Money presents at root") func giveCash_presentsAtRoot() { let router = AppRouter() - router.presentAddMoney(.giveCash) + router.presentAddMoney(.giveCash, source: .scanner) #expect(router.presentedSheets == [.addMoney(.giveCash)]) } @@ -28,7 +28,7 @@ struct AddMoneyRoutingTests { func overRootSheet_stacksNested() { let router = AppRouter() router.present(.send) - router.presentAddMoney(.giveCash) + router.presentAddMoney(.giveCash, source: .scanner) #expect(router.presentedSheets == [.send, .addMoney(.giveCash)]) } @@ -37,7 +37,7 @@ struct AddMoneyRoutingTests { let router = AppRouter() router.present(.discover) router.presentNested(.buy(.usdc)) - router.presentAddMoney(.buyCurrency) + router.presentAddMoney(.buyCurrency, source: .buyShortfall) #expect(router.presentedSheets == [.discover, .buy(.usdc), .addMoney(.buyCurrency)]) } @@ -46,7 +46,7 @@ struct AddMoneyRoutingTests { let router = AppRouter() router.present(.discover) router.presentNested(.buy(.usdc)) - router.presentAddMoney(.buyCurrency) + router.presentAddMoney(.buyCurrency, source: .buyShortfall) #expect(router.isAddMoneyOverBuy) } @@ -57,14 +57,14 @@ struct AddMoneyRoutingTests { func isAddMoneyOverBuy_nonBuyEntry(root: AppRouter.SheetPresentation) { let router = AppRouter() router.present(root) - router.presentAddMoney(.general) + router.presentAddMoney(.general, source: .balance) #expect(!router.isAddMoneyOverBuy) } @Test("The options at root report a non-buy entry") func isAddMoneyOverBuy_rootEntry() { let router = AppRouter() - router.presentAddMoney(.giveCash) + router.presentAddMoney(.giveCash, source: .scanner) #expect(!router.isAddMoneyOverBuy) } @@ -87,7 +87,7 @@ struct AddMoneyRoutingTests { let router = AppRouter() router.present(.discover) router.presentNested(.buy(.usdc)) - router.presentAddMoney(.buyCurrency) + router.presentAddMoney(.buyCurrency, source: .buyShortfall) // Mirrors AddMoneyStartScreen.select(_:) for the buy entry. router.dismissSheet() diff --git a/FlipcashTests/Analytics/AddMoneyEventsTests.swift b/FlipcashTests/Analytics/AddMoneyEventsTests.swift new file mode 100644 index 000000000..a8b5182ea --- /dev/null +++ b/FlipcashTests/Analytics/AddMoneyEventsTests.swift @@ -0,0 +1,47 @@ +// +// AddMoneyEventsTests.swift +// FlipcashTests +// + +import Testing +@testable import Flipcash + +@MainActor +@Suite("Add Money funnel contract — names shared verbatim with Android") +struct AddMoneyEventsTests { + + @Test("Event names match the Android funnel") + func eventNames_matchAndroid() { + #expect(Analytics.AddMoneyEvent.opened.eventName == "Add Money: Opened") + #expect(Analytics.AddMoneyEvent.methodSelected.eventName == "Add Money: Method Selected") + #expect(Analytics.AddMoneyEvent.amountConfirmed.eventName == "Add Money: Amount Confirmed") + #expect(Analytics.AddMoneyEvent.paymentInvoked.eventName == "Add Money: Payment Invoked") + #expect(Analytics.AddMoneyEvent.addressCopied.eventName == "Add Money: Address Copied") + #expect(Analytics.AddMoneyEvent.terminal.eventName == "Add Money") + } + + @Test("Property keys have the expected raw values") + func propertyKeys_rawValues_areExpected() { + #expect(Analytics.Property.source.rawValue == "Source") + #expect(Analytics.Property.method.rawValue == "Method") + #expect(Analytics.Property.state.rawValue == "State") + #expect(Analytics.Property.mint.rawValue == "Mint") + } + + @Test("Source values match the Android funnel") + func sourceValues_matchAndroid() { + #expect(Analytics.AddMoneySource.menu.rawValue == "Menu") + #expect(Analytics.AddMoneySource.giveShortfall.rawValue == "Give Shortfall") + #expect(Analytics.AddMoneySource.buyShortfall.rawValue == "Buy Shortfall") + #expect(Analytics.AddMoneySource.chat.rawValue == "Chat") + #expect(Analytics.AddMoneySource.scanner.rawValue == "Scanner") + #expect(Analytics.AddMoneySource.balance.rawValue == "Balance") + } + + @Test("Method values match the Android funnel") + func methodValues_matchAndroid() { + #expect(DepositMethod.coinbase.analyticsValue == "Coinbase") + #expect(DepositMethod.phantom.analyticsValue == "Phantom") + #expect(DepositMethod.otherWallet.analyticsValue == "Other Wallet") + } +}