diff --git a/BitwardenShared/Core/Billing/Models/Domain/PremiumUpgradePendingState.swift b/BitwardenShared/Core/Billing/Models/Domain/PremiumUpgradePendingState.swift new file mode 100644 index 0000000000..9793e9a2ec --- /dev/null +++ b/BitwardenShared/Core/Billing/Models/Domain/PremiumUpgradePendingState.swift @@ -0,0 +1,13 @@ +// MARK: - PremiumUpgradePendingState + +/// The persisted state of a pending Premium upgrade for the active account. +/// +struct PremiumUpgradePendingState: Equatable, Sendable { + // MARK: Properties + + /// Whether a Premium upgrade is currently pending. + let isPending: Bool + + /// Whether the last sync attempt for the pending Premium upgrade failed. + let lastAttemptFailed: Bool +} diff --git a/BitwardenShared/Core/Billing/Services/BillingService.swift b/BitwardenShared/Core/Billing/Services/BillingService.swift index 385d024f18..0d3382f72c 100644 --- a/BitwardenShared/Core/Billing/Services/BillingService.swift +++ b/BitwardenShared/Core/Billing/Services/BillingService.swift @@ -2,6 +2,8 @@ import BitwardenKit import Combine import Foundation +// swiftlint:disable file_length + // MARK: - BillingService /// A protocol for a service used to manage billing operations. @@ -55,6 +57,23 @@ protocol BillingService: AnyObject { // sourcery: AutoMockable /// func premiumStatusChanged() async + /// Gets the current Premium upgrade pending state for the active account. + /// + /// - Returns: The current `PremiumUpgradePendingState`. + /// + func premiumUpgradePendingState() async -> PremiumUpgradePendingState + + /// A publisher that emits the Premium upgrade pending state for the active account. + /// + func premiumUpgradePendingStatePublisher() -> AnyPublisher + + /// Confirms whether a just-succeeded Stripe checkout has been granted Premium yet, syncing + /// to check and publishing checkout status updates as it resolves. If the sync doesn't + /// confirm Premium (or fails), the upgrade is left pending so `start()`'s background watcher + /// can resolve it once a later sync does. + /// + func reconcileCheckoutSuccess() async + /// Fetches the current subscription status and updates the visibility of the subscription /// attention action card. /// @@ -84,15 +103,25 @@ protocol BillingService: AnyObject { // sourcery: AutoMockable /// - Returns: Whether the action card should be shown. /// func shouldShowUpgradedToPremiumActionCard() async -> Bool + + /// Starts observing sync completions so a pending Premium upgrade can be resolved by any + /// successful sync for the active account — not just the sync `reconcileCheckoutSuccess()` + /// itself forces. Should be called once, for the lifetime of the app. + /// + func start() async } // MARK: - DefaultBillingService /// The default implementation of `BillingService`. /// -class DefaultBillingService: BillingService { +class DefaultBillingService: BillingService { // swiftlint:disable:this type_body_length // MARK: Properties + /// The task that watches for active-account changes and re-subscribes + /// `syncCompletionSubscriber` accordingly. + private var activeAccountSubscriber: Task? + /// The API service used for billing requests. private let billingAPIService: BillingAPIService @@ -113,12 +142,27 @@ class DefaultBillingService: BillingService { /// The service used by the application to report non-fatal errors. private let errorReporter: ErrorReporter - /// Subject that emits the Premium checkout sync status. - private let premiumCheckoutStatusSubject = CurrentValueSubject(nil) + /// Subject that emits the Premium checkout sync status. A `PassthroughSubject`, deliberately + /// not a `CurrentValueSubject`: subscribers attach fresh at the start of each upgrade flow, + /// before any status for that flow can exist, and must never replay a stale status left over + /// from a previous flow or account to a new subscriber. + private let premiumCheckoutStatusSubject = PassthroughSubject() + + /// Subject that emits the Premium upgrade pending state for the active account. + private let premiumUpgradePendingStateSubject = CurrentValueSubject( + PremiumUpgradePendingState(isPending: false, lastAttemptFailed: false), + ) + + /// Whether `start()` has already been called, to guard against subscribing more than once. + private var started = false /// The service used to manage the app's state. private let stateService: StateService + /// The task that watches for sync completions for the currently active account, to + /// reconcile a pending Premium upgrade if one exists. + private var syncCompletionSubscriber: Task? + /// The service used to handle syncing vault data with the API. private let syncService: SyncService @@ -190,7 +234,6 @@ class DefaultBillingService: BillingService { func premiumCheckoutCanceled() { premiumCheckoutStatusSubject.send(.canceled) - premiumCheckoutStatusSubject.send(nil) } func isSelfHosted() async -> Bool { @@ -202,7 +245,6 @@ class DefaultBillingService: BillingService { func premiumCheckoutStatusPublisher() -> AnyPublisher { premiumCheckoutStatusSubject - .compactMap(\.self) .debounce(for: debounceInterval, scheduler: DispatchQueue.main) .eraseToAnyPublisher() } @@ -228,13 +270,66 @@ class DefaultBillingService: BillingService { let hasPremium = await stateService.doesActiveAccountHavePremium() premiumCheckoutStatusSubject.send(hasPremium ? .confirmed : .pending) if hasPremium { - premiumCheckoutStatusSubject.send(nil) do { try await billingStateService.setUpgradedToPremiumActionCardVisible(true) } catch { errorReporter.log(error: error) } } + // No further action needed if a pending upgrade was also in flight: the forced sync + // above updates the active account's last-sync time either way, and `start()`'s + // background watcher resolves any pending upgrade generically on every sync. + } + + func premiumUpgradePendingState() async -> PremiumUpgradePendingState { + do { + let isPending = try await billingStateService.getPremiumUpgradePending() + let lastAttemptFailed = try await billingStateService.getPremiumUpgradeLastSyncAttemptFailed() + return PremiumUpgradePendingState(isPending: isPending, lastAttemptFailed: lastAttemptFailed) + } catch { + errorReporter.log(error: error) + return PremiumUpgradePendingState(isPending: false, lastAttemptFailed: false) + } + } + + func premiumUpgradePendingStatePublisher() -> AnyPublisher { + // `premiumUpgradePendingStateSubject.send(_:)` is called from whichever background + // `Task` (`start()`'s account/sync subscribers) happens to be resolving it — never + // guaranteed to be the main thread. Pinned here so a consumer driving `@Published` UI + // state from this publisher doesn't need to hop to main itself. + premiumUpgradePendingStateSubject + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + func reconcileCheckoutSuccess() async { + guard await isEligibleForPremiumUpgradePath() else { return } + guard let userId = try? await stateService.getActiveAccountId() else { return } + + do { + try await billingStateService.setPremiumUpgradePending(true, userId: userId) + } catch { + errorReporter.log(error: error) + } + await refreshPremiumUpgradePendingStateSubject() + + premiumCheckoutStatusSubject.send(.syncing) + var syncFailed = false + do { + try await syncService.fetchSync(forceSync: true) + } catch { + errorReporter.log(error: error) + syncFailed = true + } + + let hasPremium = await resolvePendingUpgrade(userId: userId, syncFailed: syncFailed) + await refreshPremiumUpgradePendingStateSubject() + + // Only the account this reconcile started for should see its own checkout result — the + // "Sync Now" tap that led here already dismissed to an interactive vault list, so the + // active account can have switched away while the sync above was in flight. + guard await (try? stateService.getActiveAccountId()) == userId else { return } + premiumCheckoutStatusSubject.send(hasPremium ? .confirmed : .pending) } func refreshSubscriptionAttentionCard(subscription: PremiumSubscription?) async { @@ -292,4 +387,158 @@ class DefaultBillingService: BillingService { return false } } + + func start() async { + guard !started else { return } + started = true + + activeAccountSubscriber = Task { + // `activeAccountIdPublisher()`'s backing store re-emits on every write, not only + // when the active account actually changes (e.g. once per sync, via + // `updateProfile(from:userId:)`) — `removeDuplicates()` keeps this subscriber tied + // to actual account switches. + for await userId in await self.stateService.activeAccountIdPublisher().removeDuplicates().values { + self.syncCompletionSubscriber?.cancel() + guard let userId else { + self.premiumUpgradePendingStateSubject.send( + PremiumUpgradePendingState(isPending: false, lastAttemptFailed: false), + ) + continue + } + + await self.refreshPremiumUpgradePendingStateSubject() + self.syncCompletionSubscriber = Task { await self.reconcileOnEachNewSync(userId: userId) } + } + } + } + + // MARK: Private Methods + + /// Refreshes the subscription attention card cache and reports whether the active account + /// is eligible to participate in the Premium upgrade path at all (self-hosted/feature-flag + /// gated) — independent of whether Premium has already been granted. Used by + /// `reconcileCheckoutSuccess()`, where an already-Premium account reached via this method is + /// the success case, not a no-op. + /// + /// - Returns: Whether the active account is eligible for the Premium upgrade path. + /// + private func isEligibleForPremiumUpgradePath() async -> Bool { + await refreshSubscriptionAttentionCard(subscription: nil) + guard await !isSelfHosted(), + await configService.getFeatureFlag(.premiumUpgradePath) + else { + return false + } + return true + } + + /// Subscribes to the active account's sync completions and resolves a pending Premium + /// upgrade after each genuinely new sync. + /// + /// - Parameter userId: The user ID of the account this subscriber was started for. + /// + private func reconcileOnEachNewSync(userId: String) async { + let publisher: AnyPublisher + do { + publisher = try await stateService.lastSyncTimePublisher() + } catch { + errorReporter.log(error: error) + return + } + + // Snapshot the last-known sync time directly, rather than relying on whichever value + // the publisher happens to deliver first: its `CurrentValueSubject` backing replays the + // existing cached value immediately on subscribe (not evidence that a sync just + // happened), and a *different* account's sync can also re-emit this shared store + // without this account's own value having changed. + var lastSeenDate: Date? + do { + lastSeenDate = try await stateService.getLastSyncTime() + } catch { + errorReporter.log(error: error) + } + + for await date in publisher.values { + guard date != lastSeenDate else { continue } + lastSeenDate = date + _ = await resolvePendingUpgrade(userId: userId, syncFailed: false) + await refreshPremiumUpgradePendingStateSubject() + } + } + + /// Refreshes the persisted Premium upgrade pending state for the active account and pushes + /// it into `premiumUpgradePendingStateSubject`. + /// + private func refreshPremiumUpgradePendingStateSubject() async { + await premiumUpgradePendingStateSubject.send(premiumUpgradePendingState()) + } + + /// Resolves a pending Premium upgrade for `userId`, if one is recorded: checks whether the + /// account now has Premium, persists the result, and — once confirmed — shows the + /// "Upgraded to Premium" card. Leaves persisted state untouched if `userId` has no pending + /// upgrade or prior failure recorded, so this can safely run on every sync for every account, + /// not just those mid-upgrade — it still always reports current Premium status, though. + /// + /// Called both right after a checkout's own forced sync (`reconcileCheckoutSuccess()`) and + /// from the background watcher on every subsequent sync (`reconcileOnEachNewSync(userId:)`). + /// Every read and write here is scoped to the explicit `userId` passed in, not whichever + /// account happens to be active when this runs, so an account switch mid-flight can't + /// corrupt a different account's flags, and calling this twice for the same sync is + /// redundant but never incorrect for the flags it fully owns (`isPending`, the "Upgraded to + /// Premium" card). + /// + /// One case is a deliberately accepted exception: if `reconcileCheckoutSuccess()`'s own + /// forced sync updates the last-sync time (so the background watcher also reacts to it) but + /// then throws on a later step, whichever of the two calls persists `lastAttemptFailed` last + /// wins — the watcher's `syncFailed: false` can overwrite this call's `true`. Left unresolved + /// since the only planned consumer of `lastAttemptFailed`, the "Sync Unsuccessful" dialog, is + /// itself still undecided (see PM-39767); `isPending` — which every path here agrees on — is + /// what keeps the upgrade from being abandoned in the meantime. + /// + /// - Parameters: + /// - userId: The account to resolve the pending upgrade for. + /// - syncFailed: Whether the sync that triggered this resolution failed outright. + /// - Returns: Whether `userId` has Premium after this resolution. + /// + @discardableResult + private func resolvePendingUpgrade(userId: String, syncFailed: Bool) async -> Bool { + let isPending: Bool + let lastAttemptFailed: Bool + do { + isPending = try await billingStateService.getPremiumUpgradePending(userId: userId) + lastAttemptFailed = try await billingStateService.getPremiumUpgradeLastSyncAttemptFailed(userId: userId) + } catch { + errorReporter.log(error: error) + return await stateService.doesAccountHavePremium(userId: userId) + } + // Regardless of which branch below runs, the return value always answers "does `userId` + // have Premium right now" — never a stand-in like "was anything pending." The background + // sync watcher (`reconcileOnEachNewSync(userId:)`) and this method's other caller + // (`reconcileCheckoutSuccess()`) can both resolve the same sync, and whichever runs + // second must still get an accurate answer even though there's nothing left pending by + // the time it checks. + guard isPending || lastAttemptFailed else { + return await stateService.doesAccountHavePremium(userId: userId) + } + + let hasPremium = await stateService.doesAccountHavePremium(userId: userId) + do { + // `syncFailed` and `hasPremium` aren't mutually exclusive: the profile (and its + // Premium status) is persisted early in `fetchSync()`, so a later step in that same + // sync can still throw after Premium was already confirmed. Only record a failure if + // Premium wasn't actually granted, so a confirmed upgrade never persists a + // contradictory flag. + try await billingStateService.setPremiumUpgradeLastSyncAttemptFailed( + syncFailed && !hasPremium, + userId: userId, + ) + try await billingStateService.setPremiumUpgradePending(!hasPremium, userId: userId) + if hasPremium { + try await billingStateService.setUpgradedToPremiumActionCardVisible(true, userId: userId) + } + } catch { + errorReporter.log(error: error) + } + return hasPremium + } } diff --git a/BitwardenShared/Core/Billing/Services/BillingServiceTests.swift b/BitwardenShared/Core/Billing/Services/BillingServiceTests.swift index 63cbad8e1c..3083c6182a 100644 --- a/BitwardenShared/Core/Billing/Services/BillingServiceTests.swift +++ b/BitwardenShared/Core/Billing/Services/BillingServiceTests.swift @@ -34,6 +34,7 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length environmentService.region = .unitedStates errorReporter = MockErrorReporter() stateService = MockStateService() + stateService.activeAccount = .fixture() syncService = MockSyncService() subject = DefaultBillingService( billingAPIService: billingAPIService, @@ -306,29 +307,6 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length try await waitForAsync { lateStatuses.isEmpty } } - /// A subscriber connecting after `.pending` is emitted receives the pending status immediately - /// (CurrentValueSubject replays the last value to new subscribers). - @Test - func premiumCheckoutStatusPublisher_lateSubscriberReceivesPendingStatus() async throws { - stateService.doesActiveAccountHavePremiumResult = false - var earlyStatuses = [PremiumCheckoutStatus]() - let earlyCancellable = subject.premiumCheckoutStatusPublisher() - .sink { earlyStatuses.append($0) } - - await subject.premiumStatusChanged() - try await waitForAsync { !earlyStatuses.isEmpty } - - // Late subscriber connects after .pending was emitted and should receive it. - var lateStatuses = [PremiumCheckoutStatus]() - let lateCancellable = subject.premiumCheckoutStatusPublisher() - .sink { lateStatuses.append($0) } - try await waitForAsync { !lateStatuses.isEmpty } - - #expect(lateStatuses == [.pending]) - _ = earlyCancellable - _ = lateCancellable - } - /// `premiumStatusChanged()` returns early without syncing when the user already has Premium. @Test func premiumStatusChanged_alreadyHasPremium() async throws { @@ -668,4 +646,257 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length #expect(result) #expect(!billingAPIService.getSubscriptionCalled) } + + // MARK: premiumUpgradePendingState + + /// `premiumUpgradePendingState()` reflects the persisted pending/failure flags for the active account. + @Test + func premiumUpgradePendingState_reflectsPersistedFlags() async { + stateService.premiumUpgradePendingByUserId["1"] = true + stateService.premiumUpgradeSyncAttemptFailedByUserId["1"] = true + + let result = await subject.premiumUpgradePendingState() + + #expect(result == PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true)) + } + + /// `premiumUpgradePendingState()` returns a default, non-pending state and logs the error + /// when the state service can't resolve the active account. + @Test + func premiumUpgradePendingState_error() async { + stateService.activeAccount = nil + + let result = await subject.premiumUpgradePendingState() + + #expect(result == PremiumUpgradePendingState(isPending: false, lastAttemptFailed: false)) + #expect(errorReporter.errors.first as? StateServiceError == .noActiveAccount) + } + + // MARK: reconcileCheckoutSuccess + + /// `reconcileCheckoutSuccess()` marks the upgrade pending, syncs, and publishes `.confirmed` + /// when the sync confirms Premium. + @Test + func reconcileCheckoutSuccess_confirmed() async throws { + stateService.doesAccountHavePremiumByUserId["1"] = false + syncService.fetchSyncHandler = { + stateService.doesAccountHavePremiumByUserId["1"] = true + } + var statuses = [PremiumCheckoutStatus]() + let cancellable = subject.premiumCheckoutStatusPublisher() + .sink { statuses.append($0) } + defer { cancellable.cancel() } + + await subject.reconcileCheckoutSuccess() + + try await waitForAsync { !statuses.isEmpty } + #expect(statuses == [.confirmed]) + #expect(syncService.didFetchSync) + #expect(stateService.premiumUpgradePendingByUserId["1"] == false) + #expect(stateService.premiumUpgradeSyncAttemptFailedByUserId["1"] == false) + #expect(stateService.upgradedToPremiumCardVisibleByUserId["1"] == true) + } + + /// `reconcileCheckoutSuccess()` leaves the upgrade pending and publishes `.pending` when the + /// sync succeeds but Premium hasn't been granted yet. + @Test + func reconcileCheckoutSuccess_pending() async throws { + stateService.doesAccountHavePremiumByUserId["1"] = false + var statuses = [PremiumCheckoutStatus]() + let cancellable = subject.premiumCheckoutStatusPublisher() + .sink { statuses.append($0) } + defer { cancellable.cancel() } + + await subject.reconcileCheckoutSuccess() + + try await waitForAsync { !statuses.isEmpty } + #expect(statuses == [.pending]) + #expect(stateService.premiumUpgradePendingByUserId["1"] == true) + #expect(stateService.premiumUpgradeSyncAttemptFailedByUserId["1"] == false) + } + + /// `reconcileCheckoutSuccess()` records a sync failure (leaving the upgrade pending so a + /// later sync can retry) and publishes `.pending` when the forced sync throws. + @Test + func reconcileCheckoutSuccess_syncError() async throws { + stateService.doesAccountHavePremiumByUserId["1"] = false + syncService.fetchSyncResult = .failure(URLError(.notConnectedToInternet)) + var statuses = [PremiumCheckoutStatus]() + let cancellable = subject.premiumCheckoutStatusPublisher() + .sink { statuses.append($0) } + defer { cancellable.cancel() } + + await subject.reconcileCheckoutSuccess() + + try await waitForAsync { !statuses.isEmpty } + #expect(statuses == [.pending]) + #expect(errorReporter.errors.first is URLError) + #expect(stateService.premiumUpgradePendingByUserId["1"] == true) + #expect(stateService.premiumUpgradeSyncAttemptFailedByUserId["1"] == true) + } + + /// `reconcileCheckoutSuccess()` never records a sync failure once Premium is confirmed, even + /// if the sync that granted it later throws on an unrelated step. + @Test + func reconcileCheckoutSuccess_syncErrorAfterPremiumConfirmed() async throws { + stateService.doesAccountHavePremiumByUserId["1"] = false + syncService.fetchSyncHandler = { + stateService.doesAccountHavePremiumByUserId["1"] = true + } + syncService.fetchSyncResult = .failure(URLError(.notConnectedToInternet)) + + await subject.reconcileCheckoutSuccess() + + #expect(stateService.premiumUpgradeSyncAttemptFailedByUserId["1"] == false) + #expect(stateService.premiumUpgradePendingByUserId["1"] == false) + #expect(stateService.upgradedToPremiumCardVisibleByUserId["1"] == true) + } + + /// `reconcileCheckoutSuccess()` does nothing when the environment is self-hosted. + @Test + func reconcileCheckoutSuccess_selfHosted_doesNothing() async { + environmentService.region = .selfHosted + + await subject.reconcileCheckoutSuccess() + + #expect(!syncService.didFetchSync) + #expect(stateService.premiumUpgradePendingByUserId["1"] == nil) + } + + /// `reconcileCheckoutSuccess()` does nothing when the premiumUpgradePath feature flag is disabled. + @Test + func reconcileCheckoutSuccess_featureFlagDisabled_doesNothing() async { + configService.featureFlagsBool[.premiumUpgradePath] = false + + await subject.reconcileCheckoutSuccess() + + #expect(!syncService.didFetchSync) + #expect(stateService.premiumUpgradePendingByUserId["1"] == nil) + } + + /// `reconcileCheckoutSuccess()` does nothing when there's no active account to reconcile for. + @Test + func reconcileCheckoutSuccess_noActiveAccount_doesNothing() async { + stateService.activeAccount = nil + + await subject.reconcileCheckoutSuccess() + + #expect(!syncService.didFetchSync) + } + + /// `reconcileCheckoutSuccess()` still persists the correct result for the account it started + /// for, even if the active account switches away while its forced sync is in flight — and + /// doesn't publish a checkout status meant for the now-active (unrelated) account. + @Test + func reconcileCheckoutSuccess_accountSwitchedDuringSync_writesOriginalAccountOnly() async throws { + stateService.doesAccountHavePremiumByUserId["1"] = false + syncService.fetchSyncHandler = { + stateService.doesAccountHavePremiumByUserId["1"] = true + stateService.activeAccount = .fixture(profile: .fixture(userId: "2")) + } + var statuses = [PremiumCheckoutStatus]() + let cancellable = subject.premiumCheckoutStatusPublisher() + .sink { statuses.append($0) } + defer { cancellable.cancel() } + + await subject.reconcileCheckoutSuccess() + + #expect(statuses.isEmpty) + #expect(stateService.premiumUpgradePendingByUserId["1"] == false) + #expect(stateService.upgradedToPremiumCardVisibleByUserId["1"] == true) + #expect(stateService.premiumUpgradePendingByUserId["2"] == nil) + #expect(stateService.upgradedToPremiumCardVisibleByUserId["2"] == nil) + } + + /// `reconcileCheckoutSuccess()` still reports `.confirmed` when the pending flags it set are + /// already cleared by the time its own resolution step runs — e.g. `start()`'s background + /// sync watcher reacting to this same forced sync and resolving it first. Regression test for + /// `resolvePendingUpgrade(userId:syncFailed:)`'s early-exit branch, which used to return a + /// hardcoded `false` in this situation regardless of the account's actual Premium status. + @Test + func reconcileCheckoutSuccess_alreadyResolvedByConcurrentSync_stillReportsConfirmed() async throws { + stateService.doesAccountHavePremiumByUserId["1"] = false + syncService.fetchSyncHandler = { + // Simulates `start()`'s background watcher (`reconcileOnEachNewSync(userId:)`) + // reacting to this same sync and resolving the pending upgrade before this call's own + // `resolvePendingUpgrade(userId:syncFailed:)` runs. + stateService.doesAccountHavePremiumByUserId["1"] = true + stateService.premiumUpgradePendingByUserId["1"] = false + stateService.premiumUpgradeSyncAttemptFailedByUserId["1"] = false + } + var statuses = [PremiumCheckoutStatus]() + let cancellable = subject.premiumCheckoutStatusPublisher() + .sink { statuses.append($0) } + defer { cancellable.cancel() } + + await subject.reconcileCheckoutSuccess() + + try await waitForAsync { !statuses.isEmpty } + #expect(statuses == [.confirmed]) + } + + // MARK: start + + /// `start()` resolves a pending upgrade the moment it starts observing an account that + /// already has one recorded (e.g. left over from a previous app session). + @Test + func start_resolvesExistingPendingUpgradeOnFirstSync() async throws { + stateService.premiumUpgradePendingByUserId["1"] = true + stateService.doesAccountHavePremiumByUserId["1"] = true + + await subject.start() + stateService.lastSyncTimeSubject.send(Date()) + + try await waitForAsync { stateService.premiumUpgradePendingByUserId["1"] == false } + #expect(stateService.upgradedToPremiumCardVisibleByUserId["1"] == true) + } + + /// `start()` resolves a pending upgrade on a later, unrelated sync — not just the sync that + /// originated the checkout attempt (the "delayed sync" case: Settings > Sync Now, or a sync + /// triggered from the web vault). + @Test + func start_resolvesPendingUpgradeOnDelayedSync() async throws { + await subject.start() + + stateService.premiumUpgradePendingByUserId["1"] = true + stateService.doesAccountHavePremiumByUserId["1"] = false + stateService.lastSyncTimeSubject.send(Date()) + try await waitForAsync { stateService.premiumUpgradeSyncAttemptFailedByUserId["1"] == false } + #expect(stateService.premiumUpgradePendingByUserId["1"] == true) + + stateService.doesAccountHavePremiumByUserId["1"] = true + stateService.lastSyncTimeSubject.send(Date(timeIntervalSinceNow: 1)) + + try await waitForAsync { stateService.premiumUpgradePendingByUserId["1"] == false } + #expect(stateService.upgradedToPremiumCardVisibleByUserId["1"] == true) + } + + /// `start()`'s background sync watcher leaves an account with no pending upgrade (and no + /// prior failure) untouched, so a normal sync for a long-since-Premium or free account never + /// spuriously shows the "Upgraded to Premium" card. + @Test + func start_ignoresSyncsWithNoPendingUpgrade() async throws { + stateService.doesAccountHavePremiumByUserId["1"] = true + + await subject.start() + stateService.lastSyncTimeSubject.send(Date()) + + // Give the background watcher a chance to (not) act before asserting nothing changed. + try await Task.sleep(nanoseconds: 50_000_000) + #expect(stateService.premiumUpgradePendingByUserId["1"] == nil) + #expect(stateService.upgradedToPremiumCardVisibleByUserId["1"] == nil) + } + + /// `start()` only subscribes once, ignoring subsequent calls. + @Test + func start_subscribesOnlyOnce() async throws { + stateService.premiumUpgradePendingByUserId["1"] = true + stateService.doesAccountHavePremiumByUserId["1"] = true + + await subject.start() + await subject.start() + stateService.lastSyncTimeSubject.send(Date()) + + try await waitForAsync { stateService.premiumUpgradePendingByUserId["1"] == false } + } } diff --git a/BitwardenShared/Core/Platform/Services/BillingStateService.swift b/BitwardenShared/Core/Platform/Services/BillingStateService.swift index c94ceca39c..e1ed78b7b4 100644 --- a/BitwardenShared/Core/Platform/Services/BillingStateService.swift +++ b/BitwardenShared/Core/Platform/Services/BillingStateService.swift @@ -20,6 +20,38 @@ protocol BillingStateService { // sourcery: AutoMockable /// func isPremiumUpgradeEligible() async -> Bool + // MARK: Premium Upgrade Pending + + /// Returns whether the last sync attempt to confirm a pending Premium upgrade failed. + /// + /// - Parameter userId: The user ID of the account to check. Defaults to the active account if `nil`. + /// - Returns: `true` if the last sync attempt failed. + /// + func getPremiumUpgradeLastSyncAttemptFailed(userId: String?) async throws -> Bool + + /// Returns whether a Premium upgrade is pending. + /// + /// - Parameter userId: The user ID of the account to check. Defaults to the active account if `nil`. + /// - Returns: `true` if a Premium upgrade is pending. + /// + func getPremiumUpgradePending(userId: String?) async throws -> Bool + + /// Sets whether the last sync attempt to confirm a pending Premium upgrade failed. + /// + /// - Parameters: + /// - failed: Whether the last sync attempt failed. + /// - userId: The user ID of the account to update. Defaults to the active account if `nil`. + /// + func setPremiumUpgradeLastSyncAttemptFailed(_ failed: Bool, userId: String?) async throws + + /// Sets whether a Premium upgrade is pending. + /// + /// - Parameters: + /// - pending: Whether a Premium upgrade is pending. + /// - userId: The user ID of the account to update. Defaults to the active account if `nil`. + /// + func setPremiumUpgradePending(_ pending: Bool, userId: String?) async throws + // MARK: Subscription Attention Card /// Returns whether the "subscription needs attention" action card should be shown for the @@ -39,16 +71,73 @@ protocol BillingStateService { // sourcery: AutoMockable // MARK: Upgraded to Premium Card + /// Returns whether the "Upgraded to Premium" action card should be shown. + /// + /// - Parameter userId: The user ID of the account to check. Defaults to the active account if `nil`. + /// - Returns: `true` if the card should be shown. + /// + func getUpgradedToPremiumActionCardVisible(userId: String?) async throws -> Bool + + /// Sets whether the "Upgraded to Premium" action card should be shown. + /// + /// - Parameters: + /// - visible: Whether the action card should be shown. + /// - userId: The user ID of the account to update. Defaults to the active account if `nil`. + /// + func setUpgradedToPremiumActionCardVisible(_ visible: Bool, userId: String?) async throws +} + +extension BillingStateService { + /// Returns whether the last sync attempt to confirm a pending Premium upgrade failed, for the + /// active account. + /// + /// - Returns: `true` if the last sync attempt failed. + /// + func getPremiumUpgradeLastSyncAttemptFailed() async throws -> Bool { + try await getPremiumUpgradeLastSyncAttemptFailed(userId: nil) + } + + /// Returns whether a Premium upgrade is pending for the active account. + /// + /// - Returns: `true` if a Premium upgrade is pending. + /// + func getPremiumUpgradePending() async throws -> Bool { + try await getPremiumUpgradePending(userId: nil) + } + /// Returns whether the "Upgraded to Premium" action card should be shown for the active account. /// /// - Returns: `true` if the card should be shown. /// - func getUpgradedToPremiumActionCardVisible() async throws -> Bool + func getUpgradedToPremiumActionCardVisible() async throws -> Bool { + try await getUpgradedToPremiumActionCardVisible(userId: nil) + } + + /// Sets whether the last sync attempt to confirm a pending Premium upgrade failed, for the + /// active account. + /// + /// - Parameters: + /// - failed: Whether the last sync attempt failed. + /// + func setPremiumUpgradeLastSyncAttemptFailed(_ failed: Bool) async throws { + try await setPremiumUpgradeLastSyncAttemptFailed(failed, userId: nil) + } + + /// Sets whether a Premium upgrade is pending for the active account. + /// + /// - Parameters: + /// - pending: Whether a Premium upgrade is pending. + /// + func setPremiumUpgradePending(_ pending: Bool) async throws { + try await setPremiumUpgradePending(pending, userId: nil) + } /// Sets whether the "Upgraded to Premium" action card should be shown for the active account. /// /// - Parameters: /// - visible: Whether the action card should be shown. /// - func setUpgradedToPremiumActionCardVisible(_ visible: Bool) async throws + func setUpgradedToPremiumActionCardVisible(_ visible: Bool) async throws { + try await setUpgradedToPremiumActionCardVisible(visible, userId: nil) + } } diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 3f46acc166..024cbf34af 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -1179,6 +1179,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le vaultTimeoutService: vaultTimeoutService, ) Task { await authenticatorSyncService.start() } + Task { await billingService.start() } self.init( apiService: apiService, diff --git a/BitwardenShared/Core/Platform/Services/StateService+BillingStateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateService+BillingStateServiceTests.swift index 9a3f6680cc..00ed7802b7 100644 --- a/BitwardenShared/Core/Platform/Services/StateService+BillingStateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateService+BillingStateServiceTests.swift @@ -132,6 +132,66 @@ struct StateServiceBillingStateServiceTests { #expect(!isEligible) } + // MARK: Premium Upgrade Pending + + /// `getPremiumUpgradePending()` returns `false` when no value has been set. + @Test + func getPremiumUpgradePending_defaultsFalse() async throws { + await subject.addAccount(.fixture()) + + let result = try await subject.getPremiumUpgradePending() + #expect(!result) + } + + /// `setPremiumUpgradePending(_:)` persists the value for the active account. + @Test + func setPremiumUpgradePending() async throws { + await subject.addAccount(.fixture()) + + try await subject.setPremiumUpgradePending(true) + #expect(appSettingsStore.premiumUpgradePendingByUserId["1"] == true) + + try await subject.setPremiumUpgradePending(false) + #expect(appSettingsStore.premiumUpgradePendingByUserId["1"] == false) + } + + /// `getPremiumUpgradeLastSyncAttemptFailed()` returns `false` when no value has been set. + @Test + func getPremiumUpgradeLastSyncAttemptFailed_defaultsFalse() async throws { + await subject.addAccount(.fixture()) + + let result = try await subject.getPremiumUpgradeLastSyncAttemptFailed() + #expect(!result) + } + + /// `setPremiumUpgradeLastSyncAttemptFailed(_:)` persists the value for the active account. + @Test + func setPremiumUpgradeLastSyncAttemptFailed() async throws { + await subject.addAccount(.fixture()) + + try await subject.setPremiumUpgradeLastSyncAttemptFailed(true) + #expect(appSettingsStore.premiumUpgradeSyncAttemptFailedByUserId["1"] == true) + + try await subject.setPremiumUpgradeLastSyncAttemptFailed(false) + #expect(appSettingsStore.premiumUpgradeSyncAttemptFailedByUserId["1"] == false) + } + + /// `getPremiumUpgradePending(userId:)` and `setPremiumUpgradePending(_:userId:)` operate on the + /// given account regardless of which account is currently active. + @Test + func premiumUpgradePending_explicitUserId_notActiveAccount() async throws { + await subject.addAccount(.fixture(profile: .fixture(userId: "1"))) + await subject.addAccount(.fixture(profile: .fixture(userId: "2"))) + try await subject.setActiveAccount(userId: "1") + + try await subject.setPremiumUpgradePending(true, userId: "2") + + let activeAccountPending = try await subject.getPremiumUpgradePending() + let otherAccountPending = try await subject.getPremiumUpgradePending(userId: "2") + #expect(!activeAccountPending) + #expect(otherAccountPending) + } + // MARK: Subscription Attention Card /// `getSubscriptionAttentionCardVisible()` returns `false` when no value has been set. @@ -202,4 +262,21 @@ struct StateServiceBillingStateServiceTests { try await subject.setUpgradedToPremiumActionCardVisible(false) #expect(appSettingsStore.upgradedToPremiumCardVisibleByUserId["1"] == false) } + + /// `getUpgradedToPremiumActionCardVisible(userId:)` and + /// `setUpgradedToPremiumActionCardVisible(_:userId:)` operate on the given account regardless + /// of which account is currently active. + @Test + func upgradedToPremiumActionCardVisible_explicitUserId_notActiveAccount() async throws { + await subject.addAccount(.fixture(profile: .fixture(userId: "1"))) + await subject.addAccount(.fixture(profile: .fixture(userId: "2"))) + try await subject.setActiveAccount(userId: "1") + + try await subject.setUpgradedToPremiumActionCardVisible(true, userId: "2") + + let activeAccountVisible = try await subject.getUpgradedToPremiumActionCardVisible() + let otherAccountVisible = try await subject.getUpgradedToPremiumActionCardVisible(userId: "2") + #expect(!activeAccountVisible) + #expect(otherAccountVisible) + } } diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 1e1a71699b..cdecfcfdd9 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -38,6 +38,14 @@ protocol StateService: AnyObject, BillingStateService, DebugStateService { /// func didAccountSwitchInExtension() async throws -> Bool + /// Returns whether an account has access to Premium features (personally, or via an enabled + /// organization that grants it), independent of which account is currently active. + /// + /// - Parameter userId: The user ID of the account to check. + /// - Returns: Whether the account has access to Premium features. + /// + func doesAccountHavePremium(userId: String) async -> Bool + /// Returns whether the active user account has access to Premium features. /// /// - Returns: Whether the active account has access to Premium features. @@ -1641,16 +1649,16 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState } } - func doesActiveAccountHavePremium() async -> Bool { + func doesAccountHavePremium(userId: String) async -> Bool { do { - let account = try await getActiveAccount() + let account = try await getAccount(userId: userId) let hasPremiumPersonally = account.profile.hasPremiumPersonally ?? false guard !hasPremiumPersonally else { return true } let organizations = try await dataStore - .fetchAllOrganizations(userId: account.profile.userId) + .fetchAllOrganizations(userId: userId) .filter { $0.enabled && $0.usersGetPremium } return !organizations.isEmpty } catch { @@ -1659,6 +1667,16 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState } } + func doesActiveAccountHavePremium() async -> Bool { + do { + let userId = try getActiveAccountUserId() + return await doesAccountHavePremium(userId: userId) + } catch { + errorReporter.log(error: error) + return false + } + } + func doesActiveAccountHavePremiumPersonally() async -> Bool { do { let account = try await getActiveAccount() @@ -2541,6 +2559,28 @@ extension DefaultStateService: BillingStateService { return timeProvider.timeSince(creationDate) >= Constants.premiumUpgradeBannerAccountAge } + // MARK: Premium Upgrade Pending + + func getPremiumUpgradeLastSyncAttemptFailed(userId: String?) async throws -> Bool { + let userId = try userId ?? getActiveAccountUserId() + return appSettingsStore.premiumUpgradeLastSyncAttemptFailed(userId: userId) + } + + func getPremiumUpgradePending(userId: String?) async throws -> Bool { + let userId = try userId ?? getActiveAccountUserId() + return appSettingsStore.premiumUpgradePending(userId: userId) + } + + func setPremiumUpgradeLastSyncAttemptFailed(_ failed: Bool, userId: String?) async throws { + let userId = try userId ?? getActiveAccountUserId() + appSettingsStore.setPremiumUpgradeLastSyncAttemptFailed(failed, userId: userId) + } + + func setPremiumUpgradePending(_ pending: Bool, userId: String?) async throws { + let userId = try userId ?? getActiveAccountUserId() + appSettingsStore.setPremiumUpgradePending(pending, userId: userId) + } + // MARK: Subscription Attention Card func getSubscriptionAttentionCardVisible() async throws -> Bool { @@ -2555,13 +2595,13 @@ extension DefaultStateService: BillingStateService { // MARK: Upgraded to Premium Card - func getUpgradedToPremiumActionCardVisible() async throws -> Bool { - let userId = try getActiveAccountUserId() + func getUpgradedToPremiumActionCardVisible(userId: String?) async throws -> Bool { + let userId = try userId ?? getActiveAccountUserId() return appSettingsStore.upgradedToPremiumActionCardVisible(userId: userId) } - func setUpgradedToPremiumActionCardVisible(_ visible: Bool) async throws { - let userId = try getActiveAccountUserId() + func setUpgradedToPremiumActionCardVisible(_ visible: Bool, userId: String?) async throws { + let userId = try userId ?? getActiveAccountUserId() appSettingsStore.setUpgradedToPremiumActionCardVisible(visible, userId: userId) } } diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index f88cc37171..ec80205682 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -299,6 +299,20 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertEqual(errorReporter.errors as? [StateServiceError], [.noActiveAccount]) } + /// `doesAccountHavePremium(userId:)` checks the given account regardless of which account is + /// currently active. + func test_doesAccountHavePremium_checksExplicitAccountNotActiveAccount() async throws { + await subject.addAccount(.fixture(profile: .fixture(hasPremiumPersonally: false, userId: "1"))) + await subject.addAccount(.fixture(profile: .fixture(hasPremiumPersonally: true, userId: "2"))) + try await subject.setActiveAccount(userId: "1") + + let activeAccountHasPremium = await subject.doesAccountHavePremium(userId: "1") + let otherAccountHasPremium = await subject.doesAccountHavePremium(userId: "2") + + XCTAssertFalse(activeAccountHasPremium) + XCTAssertTrue(otherAccountHasPremium) + } + /// `doesActiveAccountHavePremiumPersonally()` returns true when the user has Premium personally. func test_doesActiveAccountHavePremiumPersonally_personalTrue() async throws { await subject.addAccount(.fixture(profile: .fixture(hasPremiumPersonally: true))) diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift index bd2968876e..827cd95edb 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -300,6 +300,20 @@ protocol AppSettingsStore: AnyObject { /// func premiumUpgradeBannerDismissed(userId: String) -> Bool + /// Gets whether the last sync attempt to confirm a pending Premium upgrade failed for the given user. + /// + /// - Parameter userId: The user ID. + /// - Returns: Whether the last sync attempt failed. + /// + func premiumUpgradeLastSyncAttemptFailed(userId: String) -> Bool + + /// Gets whether a Premium upgrade is pending for the given user. + /// + /// - Parameter userId: The user ID. + /// - Returns: Whether a Premium upgrade is pending. + /// + func premiumUpgradePending(userId: String) -> Bool + /// Gets whether the "subscription needs attention" action card should be shown for the given user. /// /// - Parameter userId: The user ID. @@ -587,6 +601,22 @@ protocol AppSettingsStore: AnyObject { /// func setPremiumUpgradeBannerDismissed(_ dismissed: Bool, userId: String) + /// Sets whether the last sync attempt to confirm a pending Premium upgrade failed for the given user. + /// + /// - Parameters: + /// - failed: Whether the last sync attempt failed. + /// - userId: The user ID. + /// + func setPremiumUpgradeLastSyncAttemptFailed(_ failed: Bool, userId: String) + + /// Sets whether a Premium upgrade is pending for the given user. + /// + /// - Parameters: + /// - pending: Whether a Premium upgrade is pending. + /// - userId: The user ID. + /// + func setPremiumUpgradePending(_ pending: Bool, userId: String) + /// Sets whether the "subscription needs attention" action card should be shown for the given user. /// /// - Parameters: @@ -897,6 +927,8 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore { case accountCreationEnvironmentURLs(email: String) case preAuthServerConfig case premiumUpgradeBannerDismissed(userId: String) + case premiumUpgradeLastSyncAttemptFailed(userId: String) + case premiumUpgradePending(userId: String) case rememberedEmail case rememberedOrgIdentifier case reviewPromptData @@ -1015,6 +1047,10 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore { "preAuthServerConfig" case let .premiumUpgradeBannerDismissed(userId): "premiumUpgradeBannerDismissed_\(userId)" + case let .premiumUpgradeLastSyncAttemptFailed(userId): + "premiumUpgradeLastSyncAttemptFailed_\(userId)" + case let .premiumUpgradePending(userId): + "premiumUpgradePending_\(userId)" case .rememberedEmail: "rememberedEmail" case .rememberedOrgIdentifier: @@ -1292,6 +1328,14 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore { fetch(for: .premiumUpgradeBannerDismissed(userId: userId)) } + func premiumUpgradeLastSyncAttemptFailed(userId: String) -> Bool { + fetch(for: .premiumUpgradeLastSyncAttemptFailed(userId: userId)) + } + + func premiumUpgradePending(userId: String) -> Bool { + fetch(for: .premiumUpgradePending(userId: userId)) + } + func subscriptionAttentionCardVisible(userId: String) -> Bool { fetch(for: .subscriptionAttentionCardVisible(userId: userId)) } @@ -1441,6 +1485,14 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore { store(dismissed, for: .premiumUpgradeBannerDismissed(userId: userId)) } + func setPremiumUpgradeLastSyncAttemptFailed(_ failed: Bool, userId: String) { + store(failed, for: .premiumUpgradeLastSyncAttemptFailed(userId: userId)) + } + + func setPremiumUpgradePending(_ pending: Bool, userId: String) { + store(pending, for: .premiumUpgradePending(userId: userId)) + } + func setSubscriptionAttentionCardVisible(_ visible: Bool, userId: String) { store(visible, for: .subscriptionAttentionCardVisible(userId: userId)) } diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift index 04d05b8539..031f57ebf8 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift @@ -1121,6 +1121,39 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_ XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:premiumUpgradeBannerDismissed_1")) } + /// `premiumUpgradeLastSyncAttemptFailed(userId:)` returns `false` if there isn't a previously stored value. + func test_premiumUpgradeLastSyncAttemptFailed_isInitiallyFalse() { + XCTAssertFalse(subject.premiumUpgradeLastSyncAttemptFailed(userId: "1")) + } + + /// `premiumUpgradeLastSyncAttemptFailed(userId:)` can be used to get and set the persisted value + /// in user defaults. + func test_premiumUpgradeLastSyncAttemptFailed_withValue() { + subject.setPremiumUpgradeLastSyncAttemptFailed(true, userId: "1") + XCTAssertTrue(subject.premiumUpgradeLastSyncAttemptFailed(userId: "1")) + XCTAssertTrue(userDefaults.bool(forKey: "bwPreferencesStorage:premiumUpgradeLastSyncAttemptFailed_1")) + + subject.setPremiumUpgradeLastSyncAttemptFailed(false, userId: "1") + XCTAssertFalse(subject.premiumUpgradeLastSyncAttemptFailed(userId: "1")) + XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:premiumUpgradeLastSyncAttemptFailed_1")) + } + + /// `premiumUpgradePending(userId:)` returns `false` if there isn't a previously stored value. + func test_premiumUpgradePending_isInitiallyFalse() { + XCTAssertFalse(subject.premiumUpgradePending(userId: "1")) + } + + /// `premiumUpgradePending(userId:)` can be used to get and set the persisted value in user defaults. + func test_premiumUpgradePending_withValue() { + subject.setPremiumUpgradePending(true, userId: "1") + XCTAssertTrue(subject.premiumUpgradePending(userId: "1")) + XCTAssertTrue(userDefaults.bool(forKey: "bwPreferencesStorage:premiumUpgradePending_1")) + + subject.setPremiumUpgradePending(false, userId: "1") + XCTAssertFalse(subject.premiumUpgradePending(userId: "1")) + XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:premiumUpgradePending_1")) + } + /// `upgradedToPremiumActionCardVisible(userId:)` returns `false` if there isn't a previously stored value. func test_upgradedToPremiumActionCardVisible_isInitiallyFalse() { XCTAssertFalse(subject.upgradedToPremiumActionCardVisible(userId: "1")) diff --git a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift index 32619d45b1..d8242955b9 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift @@ -65,6 +65,8 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo var pinProtectedUserKey = [String: String]() var pinProtectedUserKeyEnvelope = [String: String]() var premiumUpgradeBannerDismissedByUserId = [String: Bool]() + var premiumUpgradeSyncAttemptFailedByUserId = [String: Bool]() + var premiumUpgradePendingByUserId = [String: Bool]() var subscriptionAttentionCardVisibleByUserId = [String: Bool]() var upgradedToPremiumCardVisibleByUserId = [String: Bool]() var accountCreationEnvironmentURLs = [String: EnvironmentURLData]() @@ -226,6 +228,14 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo premiumUpgradeBannerDismissedByUserId[userId] ?? false } + func premiumUpgradeLastSyncAttemptFailed(userId: String) -> Bool { + premiumUpgradeSyncAttemptFailedByUserId[userId] ?? false + } + + func premiumUpgradePending(userId: String) -> Bool { + premiumUpgradePendingByUserId[userId] ?? false + } + func subscriptionAttentionCardVisible(userId: String) -> Bool { subscriptionAttentionCardVisibleByUserId[userId] ?? false } @@ -401,6 +411,14 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo premiumUpgradeBannerDismissedByUserId[userId] = dismissed } + func setPremiumUpgradeLastSyncAttemptFailed(_ failed: Bool, userId: String) { + premiumUpgradeSyncAttemptFailedByUserId[userId] = failed + } + + func setPremiumUpgradePending(_ pending: Bool, userId: String) { + premiumUpgradePendingByUserId[userId] = pending + } + func setSubscriptionAttentionCardVisible(_ visible: Bool, userId: String) { subscriptionAttentionCardVisibleByUserId[userId] = visible } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift index 82328af108..7e5bdd6a66 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift @@ -35,10 +35,20 @@ class MockStateService: StateService, ActiveAccountStateProvider, AutofillStateS var archiveOnboardingShown = false var premiumUpgradeBannerDismissedByUserId = [String: Bool]() var premiumUpgradeBannerDismissedResult: Result = .success(()) + var premiumUpgradeSyncAttemptFailedByUserId = [String: Bool]() + var premiumUpgradePendingByUserId = [String: Bool]() var setSubscriptionAttentionCardResult: Result = .success(()) var setUpgradedToPremiumActionCardResult: Result = .success(()) var subscriptionAttentionCardVisibleResult: Bool = false - var upgradedToPremiumActionCardVisibleResult: Bool = false + var upgradedToPremiumCardVisibleByUserId = [String: Bool]() + + /// Convenience accessor for `upgradedToPremiumCardVisibleByUserId`, keyed to + /// `activeAccount`, for tests that don't care about multi-account distinctions. + var upgradedToPremiumActionCardVisibleResult: Bool { + get { upgradedToPremiumCardVisibleByUserId[activeAccount?.profile.userId ?? ""] ?? false } + set { upgradedToPremiumCardVisibleByUserId[activeAccount?.profile.userId ?? ""] = newValue } + } + var biometricsEnabled = [String: Bool]() var capturedUserId: String? var clearClipboardValues = [String: ClearClipboardValue]() @@ -57,6 +67,7 @@ class MockStateService: StateService, ActiveAccountStateProvider, AutofillStateS var defaultUriMatchTypeByUserId = [String: UriMatchType]() var didAccountSwitchInExtensionResult: Result = .success(false) var disableAutoTotpCopyByUserId = [String: Bool]() + var doesAccountHavePremiumByUserId = [String: Bool]() var doesActiveAccountHavePremiumCalled = false var fillAssistEnabledByUserId = [String: Bool]() var getFillAssistEnabledError: Error? @@ -201,6 +212,10 @@ class MockStateService: StateService, ActiveAccountStateProvider, AutofillStateS try didAccountSwitchInExtensionResult.get() } + func doesAccountHavePremium(userId: String) async -> Bool { + doesAccountHavePremiumByUserId[userId] ?? false + } + func doesActiveAccountHavePremium() async -> Bool { doesActiveAccountHavePremiumCalled = true return doesActiveAccountHavePremiumResult @@ -491,12 +506,23 @@ class MockStateService: StateService, ActiveAccountStateProvider, AutofillStateS twoFactorTokens[email] } + func getPremiumUpgradeLastSyncAttemptFailed(userId: String?) async throws -> Bool { + let userId = try unwrapUserId(userId) + return premiumUpgradeSyncAttemptFailedByUserId[userId] ?? false + } + + func getPremiumUpgradePending(userId: String?) async throws -> Bool { + let userId = try unwrapUserId(userId) + return premiumUpgradePendingByUserId[userId] ?? false + } + func getSubscriptionAttentionCardVisible() async -> Bool { subscriptionAttentionCardVisibleResult } - func getUpgradedToPremiumActionCardVisible() async -> Bool { - upgradedToPremiumActionCardVisibleResult + func getUpgradedToPremiumActionCardVisible(userId: String?) async throws -> Bool { + let userId = try unwrapUserId(userId) + return upgradedToPremiumCardVisibleByUserId[userId] ?? false } func getUserHasMasterPassword(userId: String?) async throws -> Bool { @@ -658,14 +684,25 @@ class MockStateService: StateService, ActiveAccountStateProvider, AutofillStateS premiumUpgradeBannerDismissedByUserId[userId] = dismissed } + func setPremiumUpgradeLastSyncAttemptFailed(_ failed: Bool, userId: String?) async throws { + let userId = try unwrapUserId(userId) + premiumUpgradeSyncAttemptFailedByUserId[userId] = failed + } + + func setPremiumUpgradePending(_ pending: Bool, userId: String?) async throws { + let userId = try unwrapUserId(userId) + premiumUpgradePendingByUserId[userId] = pending + } + func setSubscriptionAttentionCardVisible(_ visible: Bool) async throws { try setSubscriptionAttentionCardResult.get() subscriptionAttentionCardVisibleResult = visible } - func setUpgradedToPremiumActionCardVisible(_ visible: Bool) async throws { + func setUpgradedToPremiumActionCardVisible(_ visible: Bool, userId: String?) async throws { try setUpgradedToPremiumActionCardResult.get() - upgradedToPremiumActionCardVisibleResult = visible + let userId = try unwrapUserId(userId) + upgradedToPremiumCardVisibleByUserId[userId] = visible } func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws { diff --git a/BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessor.swift b/BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessor.swift index 20d37c82fc..c50aece50d 100644 --- a/BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessor.swift +++ b/BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessor.swift @@ -188,7 +188,7 @@ final class PremiumUpgradeProcessor: StateProcessor< if callbackURL.host == BitwardenDeepLinkConstants.premiumCheckoutResultHost, result == BitwardenDeepLinkConstants.PremiumCheckoutResultQuery.successValue { - await services.billingService.premiumStatusChanged() + await services.billingService.reconcileCheckoutSuccess() } else { services.billingService.premiumCheckoutCanceled() } diff --git a/BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessorTests.swift b/BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessorTests.swift index 706843295f..9f2cff40b5 100644 --- a/BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessorTests.swift +++ b/BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessorTests.swift @@ -183,7 +183,7 @@ struct PremiumUpgradeProcessorTests { #expect(coordinator.alertShown.count == 1) } - /// `perform(_:)` with `.upgradeNowTapped` calls `premiumStatusChanged` when the session returns a success URL. + /// `perform(_:)` with `.upgradeNowTapped` calls `reconcileCheckoutSuccess` when the session returns a success URL. @Test func perform_upgradeNowTapped_checkoutSucceeded() async throws { let checkoutURL = URL(string: "https://checkout.stripe.com/session")! @@ -197,7 +197,7 @@ struct PremiumUpgradeProcessorTests { #expect(billingService.createCheckoutSessionCallsCount == 1) #expect(delegate.performCheckoutWebAuthSessionReceivedUrl == checkoutURL) - #expect(billingService.premiumStatusChangedCalled) + #expect(billingService.reconcileCheckoutSuccessCalled) #expect(subject.state.isLoading == false) } diff --git a/BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift b/BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift index 2a8c9ef765..b0d6a72d11 100644 --- a/BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift +++ b/BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift @@ -65,6 +65,21 @@ class DefaultPremiumUpgradeHelper: PremiumUpg // MARK: Private Properties + /// Whether `startInAppPremiumUpgrade(onConfirmed:)` navigated to the Premium upgrade screen + /// for the checkout status subscription currently live in `premiumStatusChangedCancellable`. + /// `false` when it instead found an upgrade already pending and showed the pending alert + /// directly without navigating anywhere — in that case, a later `.pending` emission (e.g. + /// from tapping "Sync Now" and the retry not confirming either) must not try to dismiss a + /// screen that was never opened. + private var navigatedToUpgradeScreen = false + + /// Whether a `startInAppPremiumUpgrade(onConfirmed:)` call's pending-state check is currently + /// in flight. Unlike the old, synchronous "check availability then navigate immediately" + /// call, this now awaits a storage read before deciding, opening a real (if brief) window for + /// a rapid double-tap on the same entry point to fire two overlapping checks — each of which + /// would otherwise navigate or show the pending alert on its own. Guards against that. + private var isResolvingStartRequest = false + /// A cancellable for the Premium checkout status subscription. private var premiumStatusChangedCancellable: AnyCancellable? @@ -114,14 +129,46 @@ class DefaultPremiumUpgradeHelper: PremiumUpg } func startInAppPremiumUpgrade(onConfirmed: (() async -> Void)? = nil) { + guard !isResolvingStartRequest else { return } + isResolvingStartRequest = true + // Set before the `await` below, not after: `subscribeToPremiumCheckoutStatus(onConfirmed:)` + // attaches the new live subscription synchronously, right now — if a status arrived in the + // gap before the pending-state check resolves, its handler would otherwise judge it + // against the previous call's leftover value instead of "haven't navigated yet." + navigatedToUpgradeScreen = false subscribeToPremiumCheckoutStatus(onConfirmed: onConfirmed) - coordinator.navigate(to: .premiumUpgrade) + Task { [weak self] in + guard let self else { return } + defer { isResolvingStartRequest = false } + // Checked here, not just left to each caller's own CTA visibility (e.g. the Vault + // tab's action card, hidden while pending): a pending upgrade doesn't stop any of + // the other eight entry points into this flow (Settings > Plan, Send, item views, + // etc.) from starting a second, redundant checkout. Intercepting at this single, + // shared choke point closes all of them at once instead of gating each separately. + guard await services.billingService.premiumUpgradePendingState().isPending else { + navigatedToUpgradeScreen = true + coordinator.navigate(to: .premiumUpgrade) + return + } + showUpgradePendingAlert() + } } // MARK: Private Methods + /// Calls `onPendingDismiss`, then shows the upgrade pending alert with "Sync Now" wired to + /// `reconcileCheckoutSuccess()`. + /// + private func showUpgradePendingAlert() { + onPendingDismiss?() + coordinator.showAlert(.upgradePending { [weak self] in + await self?.services.billingService.reconcileCheckoutSuccess() + }) + } + /// Subscribes to checkout status updates. On `.confirmed`, calls `onConfirmed`. - /// On `.pending`, navigates to dismiss and shows the upgrade pending alert. + /// On `.pending`, dismisses the Premium upgrade screen first if one was navigated to for + /// this subscription, then shows the upgrade pending alert. /// /// - Parameter onConfirmed: An optional closure called when the upgrade is confirmed. /// @@ -140,13 +187,18 @@ class DefaultPremiumUpgradeHelper: PremiumUpg Task { @MainActor in await onConfirmed() } } case .pending: + guard navigatedToUpgradeScreen else { + showUpgradePendingAlert() + return + } + // Consume the flag: the screen this refers to is about to be dismissed, so a + // later `.pending` (e.g. a "Sync Now" retry that also doesn't confirm) must + // not dismiss it a second time. + navigatedToUpgradeScreen = false coordinator.navigate(to: .dismiss(DismissAction { [weak self] in guard let self else { return } coordinator.hideLoadingOverlay() - onPendingDismiss?() - coordinator.showAlert(.upgradePending { - await self.services.billingService.premiumStatusChanged() - }) + showUpgradePendingAlert() })) case .syncing: // PremiumUpgradeProcessor shows the loading overlay on the upgrade screen. diff --git a/BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift b/BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift index e49c216fd6..bb699d77e2 100644 --- a/BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift +++ b/BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift @@ -12,7 +12,7 @@ import Testing // MARK: - PremiumUpgradeHelperTests @MainActor -struct PremiumUpgradeHelperTests { +struct PremiumUpgradeHelperTests { // swiftlint:disable:this type_body_length // MARK: Properties let billingRepository: MockBillingRepository @@ -25,6 +25,10 @@ struct PremiumUpgradeHelperTests { init() { billingRepository = MockBillingRepository() billingService = MockBillingService() + billingService.premiumUpgradePendingStateReturnValue = PremiumUpgradePendingState( + isPending: false, + lastAttemptFailed: false, + ) coordinator = MockCoordinator() environmentService = MockEnvironmentService() } @@ -51,7 +55,7 @@ struct PremiumUpgradeHelperTests { /// `navigateToPremiumUpgrade(onConfirmed:)` navigates to the Premium upgrade route when /// in-app upgrade is available. @Test - func navigateToPremiumUpgrade_inAppAvailable() async { + func navigateToPremiumUpgrade_inAppAvailable() async throws { billingRepository.isInAppUpgradeAvailableReturnValue = true let statusSubject = PassthroughSubject() billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() @@ -68,7 +72,7 @@ struct PremiumUpgradeHelperTests { await subject.navigateToPremiumUpgrade() - #expect(coordinator.routes.last == .premiumUpgrade) + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } #expect(capturedURL == nil) } @@ -96,9 +100,10 @@ struct PremiumUpgradeHelperTests { // MARK: Tests — startInAppPremiumUpgrade - /// `startInAppPremiumUpgrade(onConfirmed:)` navigates directly without checking availability. + /// `startInAppPremiumUpgrade(onConfirmed:)` navigates directly without checking availability + /// when no upgrade is currently pending. @Test - func startInAppPremiumUpgrade_navigatesWithoutAvailabilityCheck() { + func startInAppPremiumUpgrade_navigatesWithoutAvailabilityCheck() async throws { let statusSubject = PassthroughSubject() billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() let subject = DefaultPremiumUpgradeHelper( @@ -113,10 +118,100 @@ struct PremiumUpgradeHelperTests { subject.startInAppPremiumUpgrade() - #expect(coordinator.routes.last == .premiumUpgrade) + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } #expect(!billingRepository.isInAppUpgradeAvailableCalled) } + /// `startInAppPremiumUpgrade(onConfirmed:)` ignores a second call that arrives while the + /// first call's pending-state check is still in flight (e.g. a rapid double-tap on the same + /// CTA) — only the first call navigates. + @Test + func startInAppPremiumUpgrade_rapidDoubleCall_onlyNavigatesOnce() async throws { + let statusSubject = PassthroughSubject() + billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() + let subject = makeSubject() + + subject.startInAppPremiumUpgrade() + subject.startInAppPremiumUpgrade() + + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } + // Give a wrongly-allowed second resolution a chance to also land before asserting. + try await Task.sleep(nanoseconds: 50_000_000) + #expect(coordinator.routes.count(where: { $0 == .premiumUpgrade }) == 1) + } + + /// `startInAppPremiumUpgrade(onConfirmed:)` shows the upgrade pending alert instead of + /// navigating to the upgrade screen when an upgrade is already pending — closing the door on + /// any of this helper's callers (Settings > Plan, Send, item views, etc.) starting a second, + /// redundant checkout while one is still unresolved. + @Test + func startInAppPremiumUpgrade_showsPendingAlertWhenAlreadyPending() async throws { + let statusSubject = PassthroughSubject() + billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() + billingService.premiumUpgradePendingStateReturnValue = PremiumUpgradePendingState( + isPending: true, + lastAttemptFailed: false, + ) + let subject = DefaultPremiumUpgradeHelper( + services: ServiceContainer.withMocks( + billingRepository: billingRepository, + billingService: billingService, + environmentService: environmentService, + ), + coordinator: coordinator.asAnyCoordinator(), + setURL: { _ in }, + ) + + subject.startInAppPremiumUpgrade() + + try await waitForAsync { !coordinator.alertShown.isEmpty } + #expect(coordinator.alertShown.last?.title == Localizations.upgradePending) + #expect(coordinator.routes.last != .premiumUpgrade) + } + + /// `startInAppPremiumUpgrade(onConfirmed:)` calls `onPendingDismiss` when it shows the + /// upgrade pending alert directly, matching the cleanup already done when `.pending` arrives + /// mid-checkout (e.g. dismissing the Vault tab's action card). + @Test + func startInAppPremiumUpgrade_pendingAlert_callsOnPendingDismiss() async throws { + let statusSubject = PassthroughSubject() + billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() + billingService.premiumUpgradePendingStateReturnValue = PremiumUpgradePendingState( + isPending: true, + lastAttemptFailed: false, + ) + var onPendingDismissCalled = false + let subject = makeSubject(onPendingDismiss: { onPendingDismissCalled = true }) + + subject.startInAppPremiumUpgrade() + + try await waitForAsync { onPendingDismissCalled } + } + + /// `startInAppPremiumUpgrade(onConfirmed:)`, having shown the pending alert directly without + /// navigating anywhere, does not try to dismiss anything if the "Sync Now" retry it triggers + /// also comes back `.pending` — there's no Premium upgrade screen to dismiss, since none was + /// ever opened. Re-shows the alert directly instead. + @Test + func startInAppPremiumUpgrade_pendingAlert_retryStillPending_doesNotDismiss() async throws { + let statusSubject = PassthroughSubject() + billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() + billingService.premiumUpgradePendingStateReturnValue = PremiumUpgradePendingState( + isPending: true, + lastAttemptFailed: false, + ) + let subject = makeSubject() + + subject.startInAppPremiumUpgrade() + try await waitForAsync { !coordinator.alertShown.isEmpty } + + statusSubject.send(.pending) + + try await waitForAsync { coordinator.alertShown.count == 2 } + #expect(coordinator.alertShown.last?.title == Localizations.upgradePending) + #expect(coordinator.routes.isEmpty) + } + // MARK: Tests — subscribeToPremiumCheckoutStatus /// When the billing service emits `.canceled`, nothing happens. @@ -135,6 +230,7 @@ struct PremiumUpgradeHelperTests { setURL: { _ in }, ) await subject.navigateToPremiumUpgrade() + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } let routeCountBeforeSend = coordinator.routes.count statusSubject.send(.canceled) @@ -164,6 +260,7 @@ struct PremiumUpgradeHelperTests { await subject.navigateToPremiumUpgrade(onConfirmed: { onConfirmedCalled = true }) + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } statusSubject.send(.confirmed) @@ -188,6 +285,7 @@ struct PremiumUpgradeHelperTests { setURL: { _ in }, ) await subject.navigateToPremiumUpgrade() + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } statusSubject.send(.pending) @@ -214,6 +312,7 @@ struct PremiumUpgradeHelperTests { var onPendingDismissCalled = false let subject = makeSubject(onPendingDismiss: { onPendingDismissCalled = true }) await subject.navigateToPremiumUpgrade() + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } statusSubject.send(.pending) @@ -229,6 +328,48 @@ struct PremiumUpgradeHelperTests { #expect(onPendingDismissCalled) } + /// When the billing service emits `.pending` a second time after the first `.pending`'s + /// dismiss action already ran, the coordinator does not dismiss again — there's no longer a + /// Premium upgrade screen on the stack to dismiss, since the first `.pending` already closed + /// it. The pending alert is shown directly instead. + @Test + func subscribeToPremiumCheckoutStatus_pending_secondPendingAfterDismiss_doesNotDismissAgain() async throws { + billingRepository.isInAppUpgradeAvailableReturnValue = true + let statusSubject = PassthroughSubject() + billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() + let subject = DefaultPremiumUpgradeHelper( + services: ServiceContainer.withMocks( + billingRepository: billingRepository, + billingService: billingService, + environmentService: environmentService, + ), + coordinator: coordinator.asAnyCoordinator(), + setURL: { _ in }, + ) + await subject.navigateToPremiumUpgrade() + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } + + statusSubject.send(.pending) + try await waitForAsync { + guard case let .dismiss(action) = coordinator.routes.last else { return false } + return action != nil + } + guard case let .dismiss(action) = coordinator.routes.last else { + Issue.record("Expected .dismiss route") + return + } + action?.action() + try await waitForAsync { coordinator.alertShown.count == 1 } + + statusSubject.send(.pending) + + try await waitForAsync { coordinator.alertShown.count == 2 } + #expect(coordinator.routes.count(where: { route in + guard case .dismiss = route else { return false } + return true + }) == 1) + } + /// When the billing service emits `.syncing`, nothing happens (the loading overlay is shown /// by `PremiumUpgradeProcessor`). @Test @@ -246,6 +387,7 @@ struct PremiumUpgradeHelperTests { setURL: { _ in }, ) await subject.navigateToPremiumUpgrade() + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } let routeCountBeforeSend = coordinator.routes.count statusSubject.send(.syncing)