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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// MARK: - PremiumUpgradePendingState

/// A domain model representing the persisted state of a pending Premium upgrade for the
/// active account.
///
struct PremiumUpgradePendingState: Equatable {
// MARK: Properties

/// Whether a Premium upgrade is currently pending.
var isPending: Bool

/// Whether the last sync attempt for the pending Premium upgrade failed.
var lastAttemptFailed: Bool
}
126 changes: 126 additions & 0 deletions BitwardenShared/Core/Billing/Services/BillingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -55,6 +57,16 @@ 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<PremiumUpgradePendingState, Never>

/// Fetches the current subscription status and updates the visibility of the subscription
/// attention action card.
///
Expand Down Expand Up @@ -84,6 +96,12 @@ 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, not just the one that originated the upgrade attempt. Should be called
/// once, for the lifetime of the app.
///
func start() async
}

// MARK: - DefaultBillingService
Expand All @@ -104,6 +122,10 @@ class DefaultBillingService: BillingService {
/// The service used to manage feature flags.
private let configService: ConfigService

/// The task that watches for sync completions for the currently active account, to
/// reconcile a pending Premium upgrade if one exists.
private var currentSyncSubscriber: Task<Void, Never>?

/// The debounce interval applied to the Premium checkout status publisher.
private let debounceInterval: DispatchQueue.SchedulerTimeType.Stride

Expand All @@ -113,9 +135,21 @@ class DefaultBillingService: BillingService {
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter

/// The task that watches for active-account changes and re-subscribes
/// `currentSyncSubscriber` accordingly.
private var lastSyncSubscriber: Task<Void, Never>?

/// Subject that emits the Premium checkout sync status.
private let premiumCheckoutStatusSubject = CurrentValueSubject<PremiumCheckoutStatus?, Never>(nil)

/// Subject that emits the Premium upgrade pending state.
private let premiumUpgradePendingStateSubject = CurrentValueSubject<PremiumUpgradePendingState, Never>(
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

Expand Down Expand Up @@ -160,6 +194,15 @@ class DefaultBillingService: BillingService {
// MARK: Methods

func createCheckoutSession() async throws -> URL {
// A new attempt is starting β€” clear any stale failure from a prior attempt so it
// can't incorrectly surface against this one before its own sync has run.
do {
try await billingStateService.setPremiumUpgradeLastSyncAttemptFailed(false)
} catch {
errorReporter.log(error: error)
}
await refreshPremiumUpgradePendingStateSubject()

let response = try await billingAPIService.createCheckoutSession()
let url = response.checkoutSessionUrl
// Ensure the checkout URL uses HTTPS to prevent man-in-the-middle attacks
Expand Down Expand Up @@ -218,12 +261,25 @@ class DefaultBillingService: BillingService {
}

premiumCheckoutStatusSubject.send(.syncing)
var syncFailed = false
do {
try await syncService.fetchSync(forceSync: true)
} catch {
errorReporter.log(error: error)
syncFailed = true
}
do {
try await billingStateService.setPremiumUpgradeLastSyncAttemptFailed(syncFailed)
} catch {
errorReporter.log(error: error)
}

let hasPremium = await stateService.doesActiveAccountHavePremium()
do {
try await billingStateService.setPremiumUpgradePending(!hasPremium)
} catch {
errorReporter.log(error: error)
}
premiumCheckoutStatusSubject.send(hasPremium ? .confirmed : .pending)
if hasPremium {
premiumCheckoutStatusSubject.send(nil)
Expand All @@ -233,6 +289,22 @@ class DefaultBillingService: BillingService {
errorReporter.log(error: error)
}
}
await refreshPremiumUpgradePendingStateSubject()
}

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<PremiumUpgradePendingState, Never> {
premiumUpgradePendingStateSubject.eraseToAnyPublisher()
}

func refreshSubscriptionAttentionCard(subscription: PremiumSubscription?) async {
Expand Down Expand Up @@ -290,4 +362,58 @@ class DefaultBillingService: BillingService {
return false
}
}

func start() async {
guard !started else { return }
started = true

lastSyncSubscriber = Task {
for await userId in await self.stateService.activeAccountIdPublisher().values {
self.currentSyncSubscriber?.cancel()
guard userId != nil else { continue }

await self.refreshPremiumUpgradePendingStateSubject()

self.currentSyncSubscriber = Task {
guard let publisher = try? await self.stateService.lastSyncTimePublisher() else { return }
for await _ in publisher.values {
await self.reconcilePendingUpgradeIfNeeded()
}
}
}
}
}

// MARK: Private Methods

/// Checks whether a pending Premium upgrade can now be resolved, and clears the pending
/// state if the active account has since become Premium (by any means β€” personal or
/// organization-granted).
///
private func reconcilePendingUpgradeIfNeeded() async {
do {
guard try await billingStateService.getPremiumUpgradePending() else { return }
} catch {
errorReporter.log(error: error)
return
}

guard await stateService.doesActiveAccountHavePremium() else { return }

do {
try await billingStateService.setPremiumUpgradePending(false)
try await billingStateService.setPremiumUpgradeLastSyncAttemptFailed(false)
try await billingStateService.setUpgradedToPremiumActionCardVisible(true)
} catch {
errorReporter.log(error: error)
}
await refreshPremiumUpgradePendingStateSubject()
}

/// Re-reads the persisted Premium upgrade pending state for the active account and pushes
/// it into `premiumUpgradePendingStateSubject`.
///
private func refreshPremiumUpgradePendingStateSubject() async {
await premiumUpgradePendingStateSubject.send(premiumUpgradePendingState())
}
}
83 changes: 83 additions & 0 deletions BitwardenShared/Core/Billing/Services/BillingServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length
#expect(billingAPIService.createCheckoutSessionCallsCount == 1)
}

/// `createCheckoutSession()` clears a stale `lastAttemptFailed` flag left over from a prior
/// attempt before starting a new one.
@Test
func createCheckoutSession_clearsLastAttemptFailed() async throws {
stateService.premiumUpgradeLastSyncAttemptFailedResult = true
billingAPIService.createCheckoutSessionReturnValue = CheckoutSessionResponseModel(
checkoutSessionUrl: URL(string: "https://checkout.stripe.com/session")!,
)

_ = try await subject.createCheckoutSession()

#expect(stateService.premiumUpgradeLastSyncAttemptFailedResult == false)
}

/// `createCheckoutSession()` propagates errors from the API service.
@Test
func createCheckoutSession_apiError() async throws {
Expand Down Expand Up @@ -545,6 +559,75 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length
#expect(errorReporter.errors.first is URLError)
}

/// `premiumStatusChanged()` records the sync failure and marks the upgrade pending when
/// `fetchSync` throws, and publishes the updated `PremiumUpgradePendingState`.
@Test
func premiumStatusChanged_syncError_recordsFailureAndPending() async throws {
stateService.doesActiveAccountHavePremiumResult = false
syncService.fetchSyncResult = .failure(URLError(.notConnectedToInternet))
var pendingStates = [PremiumUpgradePendingState]()
let cancellable = subject.premiumUpgradePendingStatePublisher()
.sink { pendingStates.append($0) }
defer { cancellable.cancel() }

await subject.premiumStatusChanged()

#expect(stateService.premiumUpgradeLastSyncAttemptFailedResult == true)
#expect(stateService.premiumUpgradePendingResult == true)
#expect(pendingStates.last == PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true))
}

/// `premiumStatusChanged()` marks the upgrade pending without recording a failure when sync
/// succeeds but the user is still not Premium.
@Test
func premiumStatusChanged_pending_noFailureRecorded() async throws {
stateService.doesActiveAccountHavePremiumResult = false

await subject.premiumStatusChanged()

#expect(stateService.premiumUpgradeLastSyncAttemptFailedResult == false)
#expect(stateService.premiumUpgradePendingResult == true)
}

/// `premiumStatusChanged()` clears both the pending and failure flags once the user is
/// confirmed Premium.
@Test
func premiumStatusChanged_confirmed_clearsPendingState() async throws {
stateService.premiumUpgradePendingResult = true
stateService.premiumUpgradeLastSyncAttemptFailedResult = true
stateService.doesActiveAccountHavePremiumResult = false
syncService.fetchSyncHandler = {
stateService.doesActiveAccountHavePremiumResult = true
}

await subject.premiumStatusChanged()

#expect(stateService.premiumUpgradePendingResult == false)
#expect(stateService.premiumUpgradeLastSyncAttemptFailedResult == false)
}

// MARK: start()

/// `start()` resolves a pending Premium upgrade when a generic sync completes and the
/// active account has since become Premium, by any means (not just the original checkout
/// attempt's own subscription).
@Test
func start_resolvesPendingUpgradeOnGenericSync() async throws {
stateService.activeAccount = .fixture()
stateService.doesActiveAccountHavePremiumResult = false

await subject.start()

stateService.premiumUpgradePendingResult = true
stateService.premiumUpgradeLastSyncAttemptFailedResult = true
stateService.doesActiveAccountHavePremiumResult = true
stateService.lastSyncTimeSubject.send(Date())

try await waitForAsync { stateService.premiumUpgradePendingResult == false }
#expect(stateService.premiumUpgradeLastSyncAttemptFailedResult == false)
#expect(stateService.upgradedToPremiumActionCardVisibleResult == true)
}

// MARK: refreshSubscriptionAttentionCard

/// `refreshSubscriptionAttentionCard(subscription:)` sets the cached visibility based on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,36 @@ protocol BillingStateService { // sourcery: AutoMockable
///
func isPremiumUpgradeEligible() async -> Bool

// MARK: Premium Upgrade Pending

/// Returns whether the last sync attempt for a pending Premium upgrade failed for the
/// active account.
///
/// - Returns: `true` if the last sync attempt failed.
///
func getPremiumUpgradeLastSyncAttemptFailed() async throws -> Bool

/// Returns whether a Premium upgrade is pending for the active account.
///
/// - Returns: `true` if a Premium upgrade is pending.
///
func getPremiumUpgradePending() async throws -> Bool

/// Sets whether the last sync attempt for a pending Premium upgrade failed for the active
/// account.
///
/// - Parameters:
/// - failed: Whether the last sync attempt failed.
///
func setPremiumUpgradeLastSyncAttemptFailed(_ failed: Bool) async throws

/// 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

// MARK: Subscription Attention Card

/// Returns whether the "subscription needs attention" action card should be shown for the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,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,
Expand Down
Loading
Loading